A local-first, backend-free analytics tool for a software team's GitHub activity. A CLI puller fetches + aggregates data into JSON files; a Vite/React SPA reads those files and renders weekly velocity trends and a Team → Week → Person → Repo drill-down, plus a ranked list of the week's most meaningful PRs for status reports.
It describes contribution shape, not individual worth. There is no server, no database, and nothing is deployed — it runs on your machine.
See docs/prd.md for the full specification.
- Bun (the runtime and package manager — never use npm/yarn/pnpm).
- A GitHub Personal Access Token with
repo+read:orgscope (only needed for a real pull).
bun install
bunx playwright install chromium # only needed to run the e2e suite-
Teams & members — copy the example config and edit it:
cp teamvelocity.config.example.json teamvelocity.config.json
githubmay be a bare handle or a full profile URL. Repo scope is hybrid: the GitHub connector auto-discovers repos from members' activity and always includes the explicitreposlist. Config is validated with Zod — an invalid file aborts the pull with a clear message. -
Token — copy the example env file and add your token:
cp .env.example .env # edit .env → GITHUB_TOKEN=ghp_...The token lives only in
.env(git-ignored). It is never written intopublic/data/, never imported by the SPA, and never logged. Under Bun,.envis loaded automatically.
bun run pull # fetch + aggregate → writes public/data/<teamId>/*.json
bun run dev # open the local app, then explore"Fresh data" == re-run bun run pull, then refresh the browser tab. The app itself never calls
GitHub and never sees the token.
- No token yet?
bun run pull(orbun run pull --dry-run) runs the whole pipeline over bundled fixtures and writes valid JSON — so you can try the flow without a token. - Useful flags:
--team <id>,--weeks <N>(how far back to reach, default 26),--since/--until,--data-dir <path>,--verbose.
Pulls are incremental. Each GitHub handle's normalized PRs, commits and review activity are
cached under .cache/pull/<source>/<handle>.json (git-ignored, disposable), together with the
window of time that cache covers. A pull fetches only the gap between that coverage and what you
asked for, plus two days of overlap so anything that changed right at the boundary is still
picked up. The first pull backfills; a pull the next day asks GitHub for about three days.
History accumulates. --weeks 26 means "reach at least 26 weeks back", so a later pull with a
smaller --weeks never truncates what you already have, and the dashboard's range grows with
every pull. Run with --verbose to see the exact window requested per handle, and the GraphQL
rate-limit points each pull spent.
Members who need the same window are searched together — one query covers a whole team, split back per member afterwards — because GitHub charges per page of results, not per member. A routine pull for two 7-person teams costs about 5 rate-limit points; a full 26-week backfill about 50.
| Flag | Default | When you need it |
|---|---|---|
--full |
off | Re-fetch everything and replace the cache. The only way to drop PRs deleted upstream, or to re-apply a change in how data is normalized. Widens to the coverage you already have, so it never shrinks history. |
--no-cache |
off | Read and write nothing. One-off exploratory pull; the result covers only the requested window and does not accumulate. |
--overlap-days <N> |
2 |
How far back before the covered edge to re-fetch. |
--cache-dir <path> |
.cache/pull |
Where the cache lives. |
Deleting .cache/ is always safe: the next pull backfills it. A cache file that is corrupt or
written by an older version is reported and re-fetched rather than failing the pull.
Two things a pull will tell you about rather than paper over. If a search hits GitHub's
1000-result ceiling its window is not cached, so it is retried every pull until you narrow it
with --since — the warning names the handle. And if one handle's fetches keep failing, its
cache stops advancing and the re-fetched span grows by a day per pull; after a week the pull says
so, naming the handle.
| Script | What it does |
|---|---|
bun run dev |
Start the Vite dev server (reads the committed sample dataset). |
bun run pull |
Fetch + aggregate GitHub data → public/data/ (dry-run over fixtures with no token). |
bun run generate:sample |
Regenerate the committed sample dataset in public/data/sample*. |
bun run build |
Type-check + build the static SPA to dist/. |
bun run preview |
Preview the production build. |
bun run lint |
ESLint (must be clean — 0 errors, 0 warnings). |
bun run typecheck |
tsc --noEmit (strict). |
bun run test |
Vitest unit + integration (timezone-pinned to Europe/Prague). |
bun run test:e2e |
Playwright e2e over the committed sample dataset. |
Two halves share one source-agnostic aggregation library:
teamvelocity.config.json .env (GITHUB_TOKEN)
│ │
▼ ▼
CLI puller (cli/) ── SourceConnector plugins (GitHub via Octokit)
fetch + normalize → shared/aggregate (pure metrics) → writer → public/data/*.json
│ (static JSON)
▼
React SPA (src/) — NO token, NO GitHub calls — fetch('/data/…') → dashboards + drill-down
Invariants (see docs/prd.md §3 / CLAUDE.md):
- The SPA (
src/) never importscli/, Octokit, the config, or the token, and makes no GitHub request — enforced by an ESLintno-restricted-importsboundary. Its only data source isfetchof files under/data/. - Connectors only fetch + normalize — no metric math lives in a connector.
- Velocity, PR-score, and week bucketing are pure functions in
shared/aggregate/(the TDD core), identical across sources. - Everything in
public/data/is Zod-validated — the writer validates before writing, the loader validates after fetching. Fail loud on drift.
- Velocity (0–100) — a team-rhythm indicator, not a grade. Each week's log-dampened weighted
signal is normalized against the team's own rolling median/MAD, so a typical week lands near 50,
a stronger week climbs toward 100, and a quiet/PTO week falls toward 0 automatically. Weights and
color thresholds are configurable per team (
docs/prd.md §7.1). - PR relevance (0–100) — per-PR signals (LOC, review activity, participants, files, commits,
linked issues), log-dampened and min-max normalized within the week's PR set, then weighted
(
docs/prd.md §7.2).
src/ React SPA (never imports cli/ or the token)
cli/ data puller: config, connectors/ (GitHub), writer, pull, sample generator
shared/ pure source-agnostic aggregation + Zod schema (imported by cli and tests)
public/data/ generated (git-ignored) + a committed sample/ (+ sample-solo/) fixture set
.cache/pull/ per-handle incremental-pull cache (git-ignored, disposable)
e2e/ Playwright specs over the sample dataset
docs/ the PRD
Bitbucket/JIRA are out of v1 scope, but the seam exists: implement SourceConnector
(cli/connectors/types.ts), register it in cli/connectors/index.ts, and give members the
matching handle in the config. No UI or metric changes are needed — aggregation and scoring are
source-agnostic.
- Unit/integration (Vitest): pure logic is TDD'd (week bucketing incl. DST, velocity, PR-score,
config + JSON validation); the GitHub connector runs against recorded fixtures (no live network);
the writer emits Zod-valid JSON. Date tests run under
TZ=Europe/Prague. - E2E (Playwright): team-select (+ single-team auto-skip), the full drill-down and deselect, date-range changes, a PR row linking to GitHub, and the "no data yet" state — all over the committed sample dataset, zero network, no token.
Run everything: `bun run lint && bun run typecheck && bun run test && bun run test:e2e`.

{ "teams": [ { "id": "jdt", // stable slug used in data paths + URLs "name": "JDT", "sprintStartsOn": "Wed", // week boundary: Mon..Sun (default Mon) "sprintStartHour": 0, // hour the boundary flips (default 0) "members": [ { "name": "Jeff", "github": "jeffhandle" }, { "name": "Jack", "github": "https://github.com/jackhandle" } ], "repos": ["acme/backend", "acme/web"], // OPTIONAL — needed for private repos "thresholds": { "red": 40, "green": 70 } // OPTIONAL — velocity color bands } ] }