MergeStats is a GitHub PR analytics tool. A user enters a GitHub username, year, and month. The app fetches all pull requests that user created in that period via the GitHub API, then displays aggregated stats (total, merged, closed, open), a pie chart of PR status, a bar chart of top repositories, and a full PR detail table.
MergeStats/
├── index.ts # app bootstrap (cors, json, listen)
├── tsconfig.json # TypeScript config
├── types/
│ └── index.ts # all shared interfaces
├── routes/
│ └── stats.ts # POST /api/stats (HTTP layer only)
├── services/
│ └── github.ts # fetchPRs, getPRDetails, generateStats
├── utils/
│ ├── githubClient.ts # shared githubFetch + rate limit check
│ ├── dateUtils.ts # date range builder
│ └── validate.ts # input validation
├── tests/
│ ├── dateUtils.test.ts
│ ├── validate.test.ts
│ └── github.test.ts
└── mergestats-ui/ # React + TypeScript frontend (Vite)
├── src/
│ ├── App.tsx # Router setup
│ ├── components/
│ │ ├── Home.tsx # Input form (username, year, month)
│ │ ├── Stats.tsx # Stats display + charts + PR table
│ │ └── Navbar.tsx # Top navigation bar
│ └── main.tsx # React entry point
├── vite.config.ts
└── package.json
The Express server exposes a single POST /api/stats endpoint. When called:
routes/stats.ts— Validates the request body is a non-null object, then callsvalidateStatsInput. Returns 400 on bad input.services/github.ts→fetchPRs()— Queries the GitHub Search API (/search/issues?type:pr+author:...) with pagination (up to 10 pages × 100 results). UsesURLSearchParamsto safely encode the query.services/github.ts→getPRDetails()— For each PR found, fetches the full PR object from/repos/{owner}/{repo}/pulls/{number}to get themergedboolean and dates.services/github.ts→generateStats()— Computes aggregate counts (total, merged, closed, open) and a per-repo breakdown.- Returns a JSON payload:
{ username, period, stats, prDetails }.
utils/githubClient.ts—githubFetch<T>()builds auth headers at call time (not import time), checks rate limits, validatesContent-Type, checksresponse.okbefore parsing JSON, and throws clear errors for all failure cases.utils/dateUtils.ts—buildDateRange()returns{ startDate, endDate, monthStr }.getLastDayOfMonth()handles leap years correctly.utils/validate.ts—validateStatsInput()usesINTEGER_REto reject partial strings like"6abc", andGITHUB_USERNAME_REto enforce GitHub's actual username rules.
Home.tsx— A form that collects username, year, and month, then navigates to/statspassing the values via React Router location state.Stats.tsx— On mount, reads location state, callsPOST http://localhost:3000/api/statsviaaxios, and renders:- Summary counts
- Pie chart (merged / closed / open)
- Bar chart (top 5 repos by PR count)
- PR detail table with links
Navbar.tsx— Simple nav with a Home link.
mergestats-ui/src/
├── types/
│ └── stats.ts # All shared TypeScript interfaces
├── api/
│ └── statsApi.ts # Axios call, typed request/response
├── hooks/
│ └── useStats.ts # Custom hook: fetching + loading/error state
├── components/
│ ├── Home.tsx
│ ├── Stats.tsx # Thin — just composes sub-components
│ ├── Navbar.tsx
│ ├── StatsSummary.tsx # Summary counts section
│ ├── PRStatusBadge.tsx # Merged / Closed / Open badge
│ └── PRTable.tsx # PR detail table
└── App.tsx
- Centralize types — Move all interfaces from
Stats.tsxintosrc/types/stats.ts - Isolate the API call — Create
src/api/statsApi.tsusingVITE_API_URLenv var instead of hardcodedhttp://localhost:3000 - Custom hook — Extract data fetching out of
Stats.tsxintosrc/hooks/useStats.ts - Extract
PRStatusBadge— Reusable badge instead of inline ternaries in the table - Fix
e: any— InHome.tsx, replace withReact.FormEvent<HTMLFormElement>
The frontend calls the backend directly at http://localhost:3000. Both must be running simultaneously:
# Terminal 1 — Backend
npm run dev # runs on http://localhost:3000
# Terminal 2 — Frontend
cd mergestats-ui
npm run dev # runs on http://localhost:5173Instead of hardcoding http://localhost:3000, configure a Vite proxy so the frontend can call /api/stats without CORS.
vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});src/api/statsApi.ts:
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
const { data } = await axios.post<StatsResponse>(`${BASE_URL}/api/stats`, { username, year, month });mergestats-ui/.env:
VITE_API_URL=http://localhost:3000
mergestats-ui/.env.production:
VITE_API_URL=https://your-production-api.com
| Option | How |
|---|---|
| Same server | Serve the built frontend as static files from Express: app.use(express.static('mergestats-ui/dist')) |
| Separate hosts | Deploy frontend to Vercel/Netlify, backend to a server. Set VITE_API_URL in the frontend env. |
To serve static files from Express (same-server option):
import path from 'path';
app.use(express.static(path.join(__dirname, 'mergestats-ui/dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'mergestats-ui/dist/index.html'));
});| Command | Description |
|---|---|
npm run dev |
Run backend with ts-node (no build needed) |
npm run build |
Compile TypeScript to dist/ |
npm start |
Build then run dist/index.js |
npm test |
Run all 46 unit tests |