A native macOS app that gives you GitHub's Insights analytics across a whole organisation — or several at once — instead of one repository at a time. Every page GitHub offers per repo is here, with repository turned into a filter you slice by — plus a contributor filter, which GitHub has no equivalent of.
Tauri v2 shell (real .app, WKWebView, ~10 MB) with analytics in TypeScript and local Git scanning in Rust.
brew install --cask adampeterhiggins/tap/github-monitorThe build is unsigned, so installing via Homebrew is the easy path — it skips the quarantine attribute. If you download the .dmg from a release directly instead, macOS will refuse to open it; either allow it in System Settings → Privacy & Security after the first failed launch, or run:
xattr -dr com.apple.quarantine "/Applications/GitHub Monitor.app"npm install
npm run tauri dev # develop
npm run tauri build # produce .app + .dmg in src-tauri/target/release/bundle (see below for worktrees)In a linked git worktree, builds go out of the worktree's way, so worktrees
stay small and can be deleted without losing anything
(scripts/worktree-scratch.mjs decides). Cargo's intermediate artifacts, the
gigabytes, go to ~/Library/Caches/github-monitor/cargo-build, which every
worktree shares, so a new worktree reuses the dependencies another one already
compiled. The built app and bundles stay in the worktree under
node_modules/.cache/github-monitor/target/. T3 Code's automatic worktree
cleanup (Settings → Storage) skips any worktree holding ignored files other
than node_modules. For the same reason Tauri's src-tauri/gen/schemas is
committed, and the frontend builds into node_modules/.cache/github-monitor/dist
in every checkout rather than dist/.
Cargo reads this from node_modules/.cache/github-monitor/cargo.toml, which the
committed .cargo/config.toml includes when it exists, so cargo and
npm run tauri follow it as well as make. npm install and every make
write that file in a linked worktree and remove it anywhere else.
The main checkout and the release worktree keep src-tauri/target. Worktrees
building at once wait on each other for the shared cache's lock.
make clean-shared empties the cache when it has grown, and the next build
starts cold.
On first launch the app asks for a GitHub token. If you have the gh CLI
authenticated, Import from gh CLI lifts the token straight out of it; otherwise
paste a PAT. It is stored via tauri-plugin-store in the app's data directory.
Then choose one or more organisations or user accounts. Every page aggregates
across the selected repositories of all of them, and repository pickers show
owner/name once more than one owner is in play. Add or remove them later under
Settings & sync → Organisations & users; adding one lists its repositories
straight away, and removing one hides them while keeping their cached analytics
on disk. Your own account includes its private repositories; another user's
account lists only their public ones, since GitHub offers no more.
| Scope | Needed for |
|---|---|
repo |
private repository statistics |
read:org |
listing organisation repositories |
| push access per repo | Traffic only — GitHub's own requirement |
security_events |
Dependabot alerts (optional) |
A token without push access still works; Traffic simply stays empty for those repos and the app says so rather than rendering blank charts.
| Page | Source | Notes |
|---|---|---|
| Pulse | GraphQL PRs + issues | Derived — GitHub has no Pulse API. Adds size vs time-to-merge and open-PR age. |
| Contributors | stats/contributors, GraphQL commit history |
Stacked breakdowns, daily commits, and an optional PR-only commit filter |
| Community | community/profile |
Health score + checklist coverage matrix |
| Traffic | traffic/* |
14-day GitHub limit; accumulates locally, including referrer share |
| Commits | stats/commit_activity, stats/contributors |
Day-level detail covers 52 weeks |
| Code frequency | stats/code_frequency |
Additions/deletions around a zero baseline |
| Punch card | stats/punch_card |
All-time only — no date dimension exists |
| Dependency graph | dependency-graph/sbom + Dependabot alerts |
Sharing, version drift, and open alerts joined to drift |
| Network | branches, forks |
Branch/fork topology, not a commit graph |
| Forks | forks |
|
| Actions usage | actions/runs |
Wall-clock elapsed, not billable minutes |
| Actions performance | actions/runs |
p50/p90/p99 duration, failure rates |
| Ownership | contributor_weeks |
Bus factor, concentration over time, people × repositories matrix — no GitHub equivalent |
| Line ownership | Synced git blame snapshots |
Org-wide surviving lines, co-author credit and identity merging; incremental Git-backed sync |
| People | contributor_weeks + PRs |
One person across the org, not across all of GitHub |
| Roster | contributor_weeks |
Arrivals, last-seen, and repositories left cold |
| Scorecard | several | One ranked table joining concentration, health, alerts, open PRs, lead time, views |
Line ownership is included in Settings & sync → Sync now / Sync changes by default. It runs for the same selected repositories as the other analytics, with the same archived-repository controls. Full re-sync rebuilds all selected ownership snapshots, and Resume retries unfinished repositories.
The first sync downloads full Git history using the saved GitHub token. Subsequent syncs fetch only missing Git objects and inspect the new default-branch head. Each repository’s exact commit SHA, calculation/check timestamps, report, and per-file attribution cache are saved together in one atomic SQLite upsert. The page reads these saved results across the selected repositories, including after app restarts. A failure or cancellation retains the previous complete snapshot and checkpoint.
Incremental calculation reuses untouched files, recalculates files touched by the
intervening history and removes deleted files. It examines every intervening commit,
not just the final tree diff: a change followed by a revert can restore identical
content with different attribution. Rewritten history, a missing/incompatible cache,
changes to .mailmap, and full re-sync trigger a complete calculation. New co-author
trailers are loaded only for commits not already cached. An unchanged head requires
no new blame work. Git transfers and blame run independently of the API sync queue.
The Line ownership page shows surviving-line credit across the selected
repositories, with Person/Email/Name grouping, the shared contributor selector and CSV/JSON export.
Ranked bars show the top owners and repository concentration. A people × repositories
heatmap switches between credited lines and share within each repository, with
expand controls, hover details and complete table alternatives. All charts use the
same grouping and contributor selection as the totals; co-author shares remain based on unique
surviving lines rather than being normalized into a 100% stack.
Identity merging happens across the entire selection before co-author credit is
counted, so connected aliases cannot credit the same line twice. Contributor
selections are shared with other pages and can be saved, searched or cleared.
Deselect bots uses the same built-in rules and saved patterns to remove detected
bots from the selection; it is not a separate filter. Names, email usernames and
aliases are checked, so a configured claude alias also identifies Claude Fable 5
when they share an email. GitHub noreply addresses link Git authors to their logins;
other authors remain selectable by name or email. Selection applies to every alias
before Person/Email/Name grouping, and changes take effect without re-syncing.
Each distinct selected co-author gets full credit, so shares can sum above 100%.
Lines with no selected authors leave the share base. Generated files, binary files,
symlinks and submodules are skipped, and attribution ignores whitespace-only edits.
Repository snapshots show the calculated commit and timestamp, including stale saved results when a refresh fails. This is a latest-snapshot view, with no date range filter. The existing Ownership page continues to measure commit concentration.
Managed bare clones live under the app’s cache directory in line-ownership/.
Credentials are passed only to the Git process, never stored in clone config.
Git must be installed; cancellation stops after the current Git operation. Clearing
the analytics cache clears ownership snapshots and forces a fresh calculation on
the next sync, while existing clones can be reused.
Run the native Git-fixture tests with
cargo test --locked --manifest-path src-tauri/Cargo.toml --lib.
npm run test:line-ownership tests sync integration, durable checkpoints, failure
recovery, org-wide aggregation and exports against a real SQLite database.
Modelled on t3code's release flow — preflight resolves the version, build produces artifacts, release publishes them, a final job updates the update manifest — but implemented for Tauri rather than Electron, and stable channel only.
The signing key is what the app uses to prove an update is genuinely yours; without it, an update cannot be installed.
# 1. Generate a keypair (already done if .updater/ exists — it is gitignored)
npx tauri signer generate -w .updater/signing.key -p ""
# 2. Give CI the private key
gh secret set TAURI_SIGNING_PRIVATE_KEY < .updater/signing.key
gh secret set TAURI_SIGNING_PRIVATE_KEY_PASSWORD --body ""
# 3. Let CI update the Homebrew tap — create a fine-grained PAT with
# contents:write on adampeterhiggins/homebrew-tap only, then:
gh secret set HOMEBREW_TAP_TOKEN --repo adampeterhiggins/github-monitorThe public key lives in src-tauri/tauri.conf.json and is committed — that is
what each build trusts. Back up .updater/signing.key. Lose it and existing
installs can never be updated again; they would need replacing by hand.
make release # gate, bump if needed, check, commit, tag, push, watch CI, verify
make release-0.3.0 # release exactly 0.3.0
make release YES=1 # no prompts
make release PUSH=0 # rehearse: stops after tagging, pushes nothingmake on its own lists everything. make doctor checks the prerequisites (tooling,
the universal-build target, the signing key, the repo secret, git state) before you
find out the hard way.
The chain is: npm run check → version gate → bump → commit → tag → push →
follow the CI run → verify the published manifest is actually installable.
That last step reads the same URL the app polls, so a green CI run with a broken
manifest still fails.
| Knob | Effect |
|---|---|
FORCE=1 |
skip the version gate (rebuild the same version) |
YES=1 |
accept prompts; required in a non-interactive shell |
PUSH=0 |
stop after tagging |
WATCH=0 |
don't follow the CI run |
Other useful targets: make version (reports drift across the three files),
make set-version-0.3.0, make app (build and install into /Applications,
verifying the installed version), make runs, make watch
and make verify-release.
If CI is unavailable, make release-local does the whole release from your
machine, of the commit this checkout is on. Nothing newer is pulled from
origin, so pull first if you want the latest main. The commit must already be
on main, and it is built in a worktree of its own under
~/Library/Caches/github-monitor/, so this checkout is never touched and other
work in it carries on. There it runs the checks, bumps the patch version past
the latest tag, commits the bump on top of the commit and tags it, builds the
signed universal bundle, pushes the tag, publishes the GitHub release, writes
the manifest, updates the Homebrew tap and verifies the manifest. The bump
stays off main: only the tag points at it, so nothing is pushed to main and
PRs merging mid-release cannot get in the way. REF=abc1234 releases another
merged commit instead, leaving out anything after it. PUSH=0 stops after the
build. If a run stops partway, it prints a REF=vX.Y.Z to retry with.
make local-release-0.3.0 releases an exact version in place, and
make manifest-X.Y.Z regenerates the manifest for an existing release.
The equivalent long-hand, if you prefer:
npm run release:prepare -- 0.2.0 # bumps package.json, tauri.conf.json, Cargo.toml
git commit -am "chore(release): 0.2.0"
git tag v0.2.0
git push origin main --tagsThe workflow then builds a signed universal macOS bundle, publishes a GitHub
Release with the .dmg, .app.tar.gz and .app.tar.gz.sig, updates the
github-monitor cask in
adampeterhiggins/homebrew-tap,
and commits a latest.json to the releases branch. The running app picks it up
on its next check — 15 seconds after launch, then every 6 hours — or immediately
via Settings → Updates → Check now.
workflow_dispatch with a version input does the same thing without a local tag.
Preflight fails deliberately if tauri.conf.json's version disagrees with the
tag. That mismatch is silent but nasty: the updater compares against the config
version, so the app would either re-offer a version it already runs or never offer
it at all.
The repository is public, so the updater works anonymously — the check sends no
credentials at all. The manifest lives on the releases branch (a stable URL,
unlike per-release asset ids) at
raw.githubusercontent.com/…/releases/latest.json, and points the download at
the asset's api.github.com URL, which honours the updater's
Accept: application/octet-stream.
raw.githubusercontent.com caches, so a freshly published manifest is not
served immediately. Measured on the v0.1.1 release: 182 seconds between the
commit landing on the releases branch and raw serving it. Harmless for updates,
which are not urgent, but it means:
- the app will not offer a new release for roughly three minutes after the run goes green;
- verification must not gate on raw, or every release would look broken. Both
the workflow and
make verify-releasecheck thereleasesbranch through the contents API, which is authoritative about what was committed, and report raw's state for information only.
t3code ships stable and nightly. Tauri's JS updater API cannot switch endpoints at
runtime and there is no {{channel}} template variable, so a user-selectable
channel needs a custom Rust command wrapping updater_builder().endpoints(…).
Stable-only keeps the whole implementation in TypeScript. Adding nightly later
means: a scheduled trigger, a second nightly.json, and that Rust command.
Groups of repositories or contributors can be named and reused. Save from either filter dropdown ("Save current selection…") or from Settings → Saved selections, which is also where you rename them, overwrite one with whatever is currently selected, apply, or delete.
They live in SQLite rather than the settings store, for two reasons: a repository
selection is a list of ids that only mean anything alongside the repos table, and
clearAnalytics deliberately skips the table so wiping the cache never destroys
something you authored. There is a test asserting exactly that.
Repository selections store ids, so one referencing a repository that has since been deleted shows a stale count in Settings and those entries are skipped when applied — rather than the selection quietly shrinking each time. Contributor selections store logins and are unaffected.
Modelled on fd-manager's fieldwork timeline. The Contributors chart — and every contributor card, which follows the same controls — offers:
| Control | Options |
|---|---|
| Shape | Bars · Area · Line |
| Reading | Cumulative · Each period |
| Period | Day · Week · Month · Quarter |
| Break down | None · By contributor · By repository |
| Stacking | Stacked · Overlaid (when broken down) |
With Contributions: Commits, enable PR commits only to exclude direct
commits from the timeline, contributor totals and rankings, and repository chart
splits. This works with every period and reading. The filter uses GitHub's
Commit.associatedPullRequests
field on default-branch history, including commits introduced by squash merges.
It counts credited commits (including co-authors), rather than distinct PRs.
Line statistics are unavailable with this filter; other contribution metrics use
the existing weekly statistics.
Existing caches need Backfill daily commits, offered on Contributors and in Settings & sync. It re-reads the full default branch for repositories with an old commit-log format without repeating other endpoints. Cancelled or failed repositories remain eligible to retry. Until refreshed, unknown associations remain visible in the unfiltered view and are excluded from PR-only counts. Normal incremental syncs record associations for new commits but do not mark old history as backfilled.
The legend is clickable: the first click isolates a series, further clicks add or remove, and clearing the last one returns to showing everything. Hovering gives a crosshair readout of every visible series at that point, totalled.
Two deliberate details. Fill opacity differs by mode — stacked bands do not overlap so the fill is the encoding and reads solid, whereas overlaid series do overlap, so there the 2px stroke carries identity and the fill is only a wash that would otherwise hide what sits behind it. And a cumulative card drops the shared y scale, because accumulated totals differ by an order of magnitude between the top and bottom of the list and a shared ceiling would flatten everyone but the leader.
Roll-up and accumulation are applied last, so they compose with every breakdown
rather than each view needing its own path. Both are pure functions in
lib/agg/series.ts and tested directly: the failure modes — a cumulative series
that dips, a roll-up that loses a bucket or misplaces a month boundary — all still
draw a plausible curve.
The org-wide chart on Contributors can be split by contributor or by repository via Break down, and all contributor cards can be split by repository at once via the Total / By repository control above the grid. Both are stacked columns over the same weekly buckets, and both follow the range slider, so zooming rescopes the split too.
The card split is one control rather than one per card, and it is served by a single query grouped by (contributor, repository, week) — per-card fetching would mean a couple of dozen round trips to answer one question.
The palette has eight categorical slots, assigned in fixed order and never
cycled — a ninth colour would either repeat one already in use or be invented, and
both make the chart lie about identity. So the eight largest contributors or
repositories take the slots and the rest fold into a single muted Other band,
which is honest about being an aggregate rather than posing as a category. The
fold is total-preserving: buildStacks is tested to conserve the sum, to emit a
row for weeks with no data so the axis has no holes, and to break ties
deterministically so colours do not flicker between renders.
Stacking is also the case the palette was validated for: neighbouring segments are the pairs that touch, and the adjacent-pair CVD and normal-vision gates both pass.
Repository. Curate the set in Settings → Repositories (a checkbox list with per-repo commit counts), or change it quickly from the dropdown on any page. Both write the same persisted selection. Presets: select all, clear, active in the last 12 months, has any activity, and "Repositories I've committed in" — which reads your own commits out of the local cache, so it is instant and costs no API quota. It needs a sync to have run first, and says so when it hasn't.
Contributor. Narrows pages to specific people. Because the underlying data varies, each page declares what it can honour rather than pretending:
| Support | Pages | Why |
|---|---|---|
| Full | Contributors, Code frequency, Pulse, Ownership, People, Roster | Per-contributor data exists |
| Partial | Commits, Forks | See below |
| Not available | Punch card, Traffic, Community, Dependency graph, Network, Actions × 2, Scorecard | GitHub supplies no per-contributor breakdown |
On Commits, everything respects the filter except Commits by day of week,
which comes from stats/commit_activity — day totals with no contributor
dimension. On Forks, a login matches the fork owner, which is a different
notion from a contributor. On Scorecard, the filter is off because the row
joins traffic, community health and alerts, which have no person dimension. Where the filter cannot apply, the control is visibly
disabled with the reason; a filter that silently changed nothing would be worse
than none.
Note the deliberate asymmetry: no repositories selected means no data, whereas no contributors selected means everyone. Repositories are an explicit opt-in list; the contributor filter is a narrowing applied on top, so its empty state has to mean unfiltered or every page would start blank.
Code frequency switches source when filtered: unfiltered it uses GitHub's repo-wide
code_frequency series, filtered it uses contributor_weeks, the only table with
per-person line counts. Filtered totals can therefore sit slightly below unfiltered
ones, since the former only counts work GitHub could attribute to an account. The
page says which source is in use.
The stats/* endpoints are lazily computed. A cold repository answers
202 Accepted with an empty body while GitHub builds the statistics in the
background, then returns 200 with data on a later request. Verified against
focaldata: of eight repositories probed cold, seven returned 202 and filled in on
retry. A single request is therefore never enough.
The sync exploits this rather than fighting it:
- Warm pass — one request per repository/endpoint, without waiting. GitHub starts ~80 background jobs concurrently.
- Concurrent work queues — computed statistics, ordinary REST endpoints and Pulse all advance through one six-request client limit. Statistics are checked in fair rounds: every pending endpoint gets one poll before any gets another, and no worker slot is held while an endpoint backs off.
This lets traffic, Actions, dependencies, pull requests and issues fill the gaps while GitHub computes statistics. Polling each repository to completion in turn would serialise all that waiting; merely raising the HTTP limit would invite GitHub's secondary rate limiting instead.
Every (repository, endpoint) pair's outcome is written to sync_state as it
completes — including when a run is cancelled — so an interrupted sync leaves an
accurate record of how far it got. The same record contains each endpoint's last
successful fetch, so normal refreshes do not start from scratch. Settings →
Sync offers:
- Resume (N) — fetches only the outstanding items and leaves finished work alone. Shown only when there is both finished and unfinished work, with a breakdown of what the N is made of.
- Sync changes — the normal refresh. Actions, pull requests and issues start from their own last-successful checkpoint (with a small overlap so boundary events cannot be missed). Commit statistics and repository snapshots are only downloaded for repositories changed since that endpoint last succeeded.
- Full re-sync — explicitly ignores every checkpoint and re-fetches everything. This is mainly for recovery and for changed token permissions.
Traffic is the intentional exception: GitHub only retains its rolling 14-day window, so it is sampled every time. PR and issue pagination is ordered by update time rather than creation time; this ensures an older item that was merged, closed, or commented on after the previous sync is refreshed without walking the full history. Endpoints which GitHub only exposes as aggregate snapshots still return a complete payload when their repository has changed, but unchanged repositories no longer incur those downloads.
What counts as finished:
| Status | Resume | Why |
|---|---|---|
ok |
skip | got the data |
empty |
skip | a repository with no commits still has none |
forbidden |
skip | no access will not change without a new token |
pending |
retry | GitHub was still computing; retrying usually succeeds |
error |
retry | transient failures are the common case |
| (no record) | attempt | new repository, or a run that stopped early |
Because forbidden is treated as finished, run a full re-sync after changing
the token — both resume and incremental sync keep skipping repositories the old
token could not read.
The resume and refresh decisions are exported as small pure functions and tested
directly. Skipping something that had not actually finished is the one way this
feature could quietly lose data, so it is worth isolating. The per-endpoint poll budget is 7 minutes because a
very active repository (focaldata/orchestra) was observed returning 202
continuously for over 20 minutes — constant bot pushes appear to keep invalidating
GitHub's cache. Such repositories land in pending and are retried; Settings →
Incomplete data lists them.
An obvious workaround for a perpetually-202 repository is to aggregate commits
directly from GraphQL history instead. That was tried and deliberately rejected:
aggregating orchestra's default branch over the same window produced materially
different figures from the website — claude (282 commits) and cursoragent (179)
did not appear in the branch history at all, and adampeterhiggins came out at 134
against the website's 247. Whatever attribution stats/contributors applies, a
commit walk does not reproduce it.
Since the whole point is parity with GitHub, a fallback that silently disagrees is
worse than an honest "still computing". The stats/* endpoints stay the single
source of truth for commit analytics.
Everything lands in SQLite (github-monitor.db) because ~80 repositories is far
too many round trips to make per page view, and because traffic data expires from
GitHub after 14 days — the local table is the only way to build longer history.
- Commit/contributor figures cover the default branch and exclude merge commits — GitHub's definition, inherited by using its endpoints.
stats/contributorscaps at 100 contributors per repository.- Contributors are shown by raw login: no bot classification, no alias merging.
claude,claude[bot]andcursoragenttherefore appear as separate contributors alongside humans, exactly as GitHub reports them. - Pulse definitions are ours, not GitHub's: "merged" counts PRs whose merge fell inside the period regardless of when they opened.
- Actions durations are wall-clock, not billable minutes (GitHub rounds up per job and multiplies by runner type). The sync caps at 3 pages of runs per repo, so very busy repositories may be undercounted — the page says so.
- Punch card has no date dimension in GitHub's API, so that page ignores the period filter and says so.
tauri-plugin-sql connects with sqlx's Pool::connect, whose default is
max_connections = 10. Every db.execute() borrows an arbitrary connection, so
a transaction opened by execute("BEGIN") is invisible to the next call if it lands
elsewhere. Issuing BEGIN/COMMIT as separate calls produced two failures under
concurrent sync:
cannot start a transaction within a transaction— twoBEGINs on one connectioncannot commit - no transaction is active—COMMITon a connection that never saw theBEGIN
Multi-statement atomicity therefore isn't available. Instead each write is a single
statement (bulkInsert batches many rows into one multi-row INSERT for exactly
this reason), and delete-then-repopulate pairs run under withWriteLock so they
stay adjacent and concurrent writers don't interleave. A failed write is recorded in
sync_state and retried. scripts/test-queries.mjs asserts no query emits
BEGIN/COMMIT/ROLLBACK, so the class of bug cannot come back unnoticed.
npm run check # typecheck + query testsnpm run test:queries runs the real query functions against a real SQLite database
built from the real schema, via Node's built-in node:sqlite. No GitHub access and
no Tauri runtime: queries.ts only type-imports the SQL plugin, so it can be
bundled and driven with a thin adapter.
This exists because two classes of bug are invisible to tsc, and both have already
been caught by it:
- a schema statement severed by a semicolon inside a comment, which meant the
code_frequencytable silently failed to be created; listContributorssplitting one person across two rows when login casing differed, while the filter matched both case-insensitively.
GH_TOKEN=$(gh auth token) npm run verify:aggregation -- focaldata/fd-core-respondentGitHub returns a total per contributor alongside the weekly buckets, so it will
grade our arithmetic for us. The script checks four things, and a wrong bucketing
convention (off-by-one week, local time instead of UTC, Monday instead of Sunday)
fails the first two:
| Check | Result on fd-core-respondent |
|---|---|
| Week keys align to Sunday 00:00 UTC | 1,216 buckets, 0 misaligned |
sum(weeks[].c) equals GitHub's total |
16/16 contributors agree |
| Dropping all-zero weeks is lossless | 227 kept, 989 pruned — 81% saved, 0 lost |
| A date window partitions totals exactly | 407 + 303 = 710 |
Exact card-for-card comparison against the website was attempted on
focaldata/orchestra but could not be completed: that repository returned 202
continuously for over 20 minutes, so GitHub never served the numbers to compare
against. The checks above verify the aggregation itself; parity of the underlying
figures follows from using the same endpoint the website does.
src/
lib/
github/client.ts 202 polling, rate + secondary limits, ETags, pagination
github/endpoints.ts typed endpoint wrappers
db/schema.ts SQLite DDL
db/queries.ts aggregation in SQL, always scoped by repo id list
ingest/sync.ts warm -> collect -> extras -> pulse orchestrator
ingest/pulse.ts GraphQL PR/issue ingestion
agg/weeks.ts week bucketing (Sunday 00:00 UTC, matching GitHub)
viz/palette.ts validated series palette, light + dark
components/
charts.tsx chart primitives with fixed mark specs
ui.tsx surfaces, tables, stat tiles, chart/table toggle
RepoFilter.tsx the multi-select that makes this org-wide
pages/ one per Insights page, plus Ownership, People, Roster, Scorecard
Aggregation happens in SQL, not JavaScript — summing ~300k weekly rows in the renderer per page view would be needlessly slow. Weekly rows where every measure is zero are dropped on write, since GitHub pads its series with empty weeks.
Series colors come from a palette validated with a CVD/contrast checker rather than picked by eye: adjacent-pair worst CVD ΔE 9.1 light / 8.4 dark, normal-vision ΔE 19.6 / 19.3, both modes passing. Three light-mode slots sit below 3:1 contrast, which obligates a relief channel — so every chart ships a table view via the Chart/Table toggle, and no value is reachable only by telling two fills apart. Dark mode is a separately selected palette, not a lightness flip.