From 0fd2cef2cfe5993798aa08b0a871803b527ca883 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 12:52:42 +0200 Subject: [PATCH] docs(plan): Phase 2b and 2c for commit linking, metrics and the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2b closes a gap between the spec and the schema: the spec's [#42] explicit link had nothing to reference, because cards only had PocketBase ids. Adds per-project card numbers, and sharpens two rules the spec left loose — a scope rule is bounded by start_date OR created, and ties go to the card with the latest bound rather than the most recently edited one. 2c builds the dashboard. The South African holiday table is verified against the Hermes build log: 24 May to 8 Sep 2026 gives 108 calendar days and 75 workdays, both exact, and that is now a regression test. Co-Authored-By: Claude Opus 5 --- ...2026-09-16-phase-2b-commit-card-linking.md | 1593 ++++++++++++ ...26-09-16-phase-2c-metrics-and-dashboard.md | 2277 +++++++++++++++++ 2 files changed, 3870 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-16-phase-2b-commit-card-linking.md create mode 100644 docs/superpowers/plans/2026-09-16-phase-2c-metrics-and-dashboard.md diff --git a/docs/superpowers/plans/2026-09-16-phase-2b-commit-card-linking.md b/docs/superpowers/plans/2026-09-16-phase-2b-commit-card-linking.md new file mode 100644 index 0000000..2879340 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-phase-2b-commit-card-linking.md @@ -0,0 +1,1593 @@ +# Phase 2b — Commit-to-Card Linking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Attach each ingested commit to the card whose work it represents — automatically by conventional-commit scope, explicitly by a reference in the message, and by hand for whatever is left over. + +**Architecture:** Two pure, exhaustively tested modules — one that extracts an explicit card reference from a message, one that resolves a commit to a card given the project's candidate cards — plus a database layer that applies them. Re-linking is idempotent and re-runnable, and never overwrites a link a human made by hand. + +**Tech Stack:** Next.js 16 Server Actions · PocketBase 0.30 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-16-phase-2-commit-ingestion-and-dashboard-design.md` §7 + +**Prerequisite:** Plan 2a complete — commits are being ingested, with `type` and `scope` parsed. + +--- + +## A gap in the spec, and how this plan closes it + +The spec specifies an explicit link written as **`[#42]`** in a commit message. +**Cards have no number.** Phase 1 gave them PocketBase ids like +`2e8izsch98bbc6l`, and nobody is going to type that into a commit message. + +So Task 1 adds a per-project card **number**, backfills the existing cards, and +assigns one on both creation paths. Without it the first branch of the +precedence chain cannot exist. + +This is worth noticing as a class of problem: the spec was written before the +schema, the schema was built without the spec's reference syntax in view, and +neither document was wrong on its own. + +--- + +## The linking rules, stated precisely + +The spec gives the precedence chain and two constraints. Two of them need +sharpening before they can be implemented, and both sharpenings are decisions: + +### Precedence — first match wins + +1. **`[#42]` in the message** → card number 42 in this project. +2. **The card's `scope_rule`** matching the commit's parsed scope. +3. **No match** → the commit stays unlinked and appears in the Unassigned tray. + +A commit linked by hand from the tray is `link_source: "manual"` and sits +outside this chain entirely — see "Re-linking" below. + +### Sharpening 1: what bounds a scope rule + +The spec says a scope rule claims commits "from the card's `start_date` +forward". But `start_date` is only set by the accept flow in Plan 1c, so a card +the owner created directly has none, and a literal reading means the owner's own +cards — the overwhelming majority — could never match anything. + +**The bound is `start_date || created`.** Every card has `created`. A card that +was never formally started is bounded by the moment it came into existence, +which is the same intent. + +### Sharpening 2: which card wins when several claim a scope + +The spec says "the most recently active card wins". `updated` is a poor basis: +it changes when anyone edits a title, so linking would silently rearrange itself +because someone fixed a typo. + +**The winner is the eligible card with the latest bound.** Among cards whose +`start_date || created` is at or before the commit's `authored_at`, pick the one +whose bound is latest. + +This is deterministic, depends only on values that do not drift, and produces +the behaviour actually wanted: a "Stock pricing v2" card started in June takes +July's `feat(stock)` commits, while the January card keeps January's. + +### Re-linking + +Re-linking a project is idempotent and safe to run repeatedly. It recomputes +every commit whose `link_source` is `none`, `scope` or `explicit` — all three are +derived from data and should follow it. + +**It never touches `manual`.** A link the owner made by hand from the tray is a +human decision that outranks every rule here; silently overwriting it on the +next push would make the tray pointless. + +--- + +## File structure + +``` +src/ +├── lib/ +│ └── linking/ +│ ├── reference.ts # "[#42]" -> 42 +│ ├── reference.test.ts +│ ├── resolve.ts # commit + candidates -> card +│ └── resolve.test.ts +├── app/ +│ ├── actions/ +│ │ └── linking.ts # relink project, link/unlink by hand +│ └── p/[clientSlug]/[projectSlug]/ +│ └── unassigned/page.tsx # the tray +└── components/ + └── linking/ + ├── unassigned-list.tsx + └── card-picker.tsx +``` + +--- + +## Task 1: Card numbers + +**Files:** +- Modify: `scripts/setup-remote.ts` +- Modify: `src/lib/pb/types.ts` +- Create: `src/lib/cards/number.ts` +- Test: `src/lib/cards/number.test.ts` +- Modify: `src/app/actions/cards.ts` +- Modify: `src/app/api/client/cards/route.ts` + +- [ ] **Step 1: Write the failing tests for number assignment** + +`src/lib/cards/number.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { nextCardNumber } from "@/lib/cards/number"; + +describe("nextCardNumber", () => { + it("starts at 1 for a project with no cards", () => { + expect(nextCardNumber([])).toBe(1); + }); + + it("returns one past the highest existing number", () => { + expect(nextCardNumber([1, 2, 3])).toBe(4); + }); + + it("does not reuse a number freed by a deletion", () => { + // Reusing 2 would point an old commit's [#2] at a different card. + expect(nextCardNumber([1, 3])).toBe(4); + }); + + it("ignores zero, negatives and non-integers left by bad data", () => { + expect(nextCardNumber([0, -5, 2])).toBe(3); + expect(nextCardNumber([Number.NaN, 4])).toBe(5); + }); + + it("handles an unsorted list", () => { + expect(nextCardNumber([7, 2, 5])).toBe(8); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/cards/number"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/cards/number.ts`: + +```ts +/** + * Per-project card numbers. + * + * Cards need a short identifier a person can type into a commit message — + * `feat(stock): inline editing [#42]`. PocketBase ids like `2e8izsch98bbc6l` + * cannot serve that purpose. + * + * Numbers are never reused. A freed number would silently repoint an old + * commit's reference at an unrelated card, and git history cannot be corrected + * after the fact. + */ +export function nextCardNumber(existing: number[]): number { + let highest = 0; + for (const value of existing) { + if (!Number.isInteger(value)) continue; + if (value > highest) highest = value; + } + return highest + 1; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 5 tests in `number.test.ts`. + +- [ ] **Step 5: Add the schema field and backfill** + +In `scripts/setup-remote.ts`, add a helper beside `ensureProjectIngestFields`: + +```ts +async function ensureCardNumbers(pb: PocketBase): Promise { + const cards = await pb.collections.getOne("cards"); + const have = new Set(cards.fields.map((f: { name: string }) => f.name)); + + if (!have.has("number")) { + const fields = [...(cards.fields as unknown[])]; + fields.push({ + name: "number", + type: "number", + required: false, + onlyInt: true, + presentable: false, + hidden: false, + system: false, + }); + await pb.collections.update(cards.id, { fields }); + console.log(" + cards field: number"); + } else { + console.log(" = cards.number already present"); + } + + // Backfill per project, oldest card first, so numbering matches the order + // the work was created in rather than the order PocketBase returns. + const projects = await pb.collection("projects").getFullList({ fields: "id" }); + for (const project of projects) { + const all = await pb.collection("cards").getFullList({ + filter: `project = "${project.id}"`, + sort: "created", + fields: "id,number", + }); + + let next = 1; + for (const card of all) { + if (typeof card.number === "number" && card.number > 0) { + next = Math.max(next, card.number + 1); + continue; + } + await pb.collection("cards").update(card.id, { number: next }); + next += 1; + } + } + console.log(" = card numbers backfilled"); +} +``` + +Call it after `await ensureProjectIngestFields(pb);`. + +Then add the unique index. PocketBase applies indexes on collection update: + +```ts + // A unique index is what makes "never reused" enforceable rather than hoped + // for. Two concurrent creates that read the same maximum will otherwise both + // write the same number. + const cardsCollection = await pb.collections.getOne("cards"); + const indexes = new Set(cardsCollection.indexes as string[]); + indexes.add( + "CREATE UNIQUE INDEX idx_cards_project_number ON cards (project, number)" + ); + await pb.collections.update(cardsCollection.id, { indexes: [...indexes] }); +``` + +- [ ] **Step 6: Add the record type** + +In `src/lib/pb/types.ts`, add to `CardRecord`: + +```ts + /** Per-project sequential number, for `[#42]` references in commit messages. */ + number: number; +``` + +- [ ] **Step 7: Assign a number on both creation paths** + +In `src/app/actions/cards.ts`, inside `createCard`, before the create call: + +```ts + const numbered = await pb.collection("cards").getFullList({ + filter: `project = "${input.projectId}"`, + fields: "number", + }); +``` + +and add `number: nextCardNumber(numbered.map((c) => c.number))` to the created +record. Import `nextCardNumber` from `@/lib/cards/number`. + +Do exactly the same in `src/app/api/client/cards/route.ts`, where the client's +request card is created. A client-created card needs a number too — the owner +will reference it from a commit once they start the work. + +- [ ] **Step 8: Apply and verify** + +```bash +set -a && source .env.local && set +a && npm run setup-remote +``` + +Then confirm every card has a distinct number within its project: + +```bash +set -a && source .env.local && set +a && npx tsx -e " +import PocketBase from 'pocketbase'; +const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!); +pb.autoCancellation(false); +await pb.collection('_superusers').authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!); +const cards = await pb.collection('cards').getFullList({ fields: 'project,number,title', sort: 'created' }); +const seen = new Map>(); +let bad = 0; +for (const c of cards) { + if (!c.number) { console.log('NO NUMBER:', c.title); bad++; continue; } + const set = seen.get(c.project) ?? new Set(); + if (set.has(c.number)) { console.log('DUPLICATE:', c.project, c.number, c.title); bad++; } + set.add(c.number); seen.set(c.project, set); +} +console.log(cards.length, 'cards checked,', bad, 'problems'); +" +``` + +Expected: `N cards checked, 0 problems`. + +- [ ] **Step 9: Commit** + +```bash +git add scripts/setup-remote.ts src/lib/cards src/lib/pb/types.ts src/app/actions/cards.ts src/app/api/client/cards/route.ts +git commit -m "feat(cards): per-project card numbers for commit references" +``` + +--- + +## Task 2: Explicit reference parsing + +Pure, test-first. + +**Files:** +- Create: `src/lib/linking/reference.ts` +- Test: `src/lib/linking/reference.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/linking/reference.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { extractCardReference } from "@/lib/linking/reference"; + +describe("extractCardReference", () => { + it("finds a bracketed reference at the end of the subject", () => { + expect(extractCardReference("feat(stock): inline qty editing [#42]")).toBe(42); + }); + + it("finds one anywhere in the message, including the body", () => { + expect(extractCardReference("[#7] fix: thing")).toBe(7); + expect(extractCardReference("fix: thing\n\nRefs [#7]\n")).toBe(7); + }); + + it("returns null when there is none", () => { + expect(extractCardReference("feat(stock): inline qty editing")).toBeNull(); + expect(extractCardReference("")).toBeNull(); + }); + + it("ignores a bare #42 without brackets", () => { + // Bare hashes appear constantly in prose — colours, issue numbers from + // other systems, ordinals. Requiring brackets makes the reference a + // deliberate act rather than a guess. + expect(extractCardReference("fix: matches #42 in the old tracker")).toBeNull(); + expect(extractCardReference("style: use #ff8a5c")).toBeNull(); + }); + + it("takes the first reference when several appear", () => { + // One commit links to at most one card, so a second reference cannot be + // honoured. First wins, deterministically. + expect(extractCardReference("fix: thing [#3] and also [#9]")).toBe(3); + }); + + it("rejects zero and negative numbers", () => { + expect(extractCardReference("fix: thing [#0]")).toBeNull(); + expect(extractCardReference("fix: thing [#-4]")).toBeNull(); + }); + + it("rejects a non-numeric reference", () => { + expect(extractCardReference("fix: thing [#abc]")).toBeNull(); + expect(extractCardReference("fix: thing [#]")).toBeNull(); + }); + + it("tolerates whitespace inside the brackets", () => { + expect(extractCardReference("fix: thing [# 42 ]")).toBe(42); + }); + + it("rejects an absurdly large number rather than overflowing", () => { + expect(extractCardReference(`fix: thing [#${"9".repeat(20)}]`)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/linking/reference"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/linking/reference.ts`: + +```ts +/** + * Explicit card references in commit messages: `[#42]`. + * + * Brackets are required. A bare `#42` appears far too often in ordinary prose — + * hex colours, issue numbers from other trackers, ordinals — and a linker that + * guesses at those attaches work to the wrong card, which is worse than not + * linking it at all. + * + * This is the override, not the daily habit: scope matching carries most + * commits. It exists for the times auto-linking gets it wrong. + */ + +/** Above this, the value is not a card number; it is a typo or an overflow. */ +const MAX_CARD_NUMBER = 1_000_000; + +const REFERENCE = /\[#\s*(\d+)\s*\]/; + +export function extractCardReference(message: string): number | null { + const match = REFERENCE.exec(message ?? ""); + if (!match) return null; + + const value = Number(match[1]); + if (!Number.isInteger(value)) return null; + if (value < 1 || value > MAX_CARD_NUMBER) return null; + + return value; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 9 tests in `reference.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/linking/reference.ts src/lib/linking/reference.test.ts +git commit -m "feat(linking): explicit [#42] card references" +``` + +--- + +## Task 3: Link resolution + +The heart of this plan. Pure, no database, test-first — which is what makes the +precedence chain and both sharpenings above verifiable rather than asserted. + +**Files:** +- Create: `src/lib/linking/resolve.ts` +- Test: `src/lib/linking/resolve.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/linking/resolve.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { resolveCommitCard, type LinkCandidate, type LinkableCommit } from "@/lib/linking/resolve"; + +function card(overrides: Partial): LinkCandidate { + return { + id: "card_a", + number: 1, + scopeRule: "", + startDate: "", + created: "2026-01-01 00:00:00.000Z", + archived: false, + ...overrides, + }; +} + +function commit(overrides: Partial): LinkableCommit { + return { + message: "feat(stock): a thing", + scope: "stock", + authoredAt: "2026-06-15 10:00:00.000Z", + isMerge: false, + ...overrides, + }; +} + +describe("resolveCommitCard — explicit references win", () => { + it("links by number regardless of scope", () => { + const cards = [ + card({ id: "by_scope", number: 1, scopeRule: "stock" }), + card({ id: "by_number", number: 42, scopeRule: "quotes" }), + ]; + const result = resolveCommitCard( + commit({ message: "feat(stock): thing [#42]" }), + cards + ); + expect(result).toEqual({ cardId: "by_number", source: "explicit" }); + }); + + it("links by number even before the card's bound", () => { + // An explicit reference is a human statement of intent. Date bounding + // exists to stop SCOPE rules over-reaching, not to overrule a person. + const cards = [card({ id: "late", number: 42, created: "2026-12-01 00:00:00.000Z" })]; + const result = resolveCommitCard(commit({ message: "fix: thing [#42]" }), cards); + expect(result).toEqual({ cardId: "late", source: "explicit" }); + }); + + it("falls through to scope when the number matches no card", () => { + const cards = [card({ id: "scoped", number: 1, scopeRule: "stock" })]; + const result = resolveCommitCard( + commit({ message: "feat(stock): thing [#999]" }), + cards + ); + expect(result).toEqual({ cardId: "scoped", source: "scope" }); + }); +}); + +describe("resolveCommitCard — scope matching", () => { + it("links a commit to the card owning its scope", () => { + const cards = [card({ id: "stock", scopeRule: "stock" })]; + expect(resolveCommitCard(commit({}), cards)).toEqual({ + cardId: "stock", + source: "scope", + }); + }); + + it("does not link when the commit has no scope", () => { + const cards = [card({ id: "stock", scopeRule: "stock" })]; + expect(resolveCommitCard(commit({ scope: "" }), cards)).toEqual({ + cardId: null, + source: "none", + }); + }); + + it("matches case-insensitively", () => { + const cards = [card({ id: "stock", scopeRule: "Stock" })]; + expect(resolveCommitCard(commit({ scope: "stock" }), cards).cardId).toBe("stock"); + }); + + it("ignores cards with no scope rule", () => { + const cards = [card({ id: "plain", scopeRule: "" })]; + expect(resolveCommitCard(commit({}), cards).cardId).toBeNull(); + }); + + it("ignores archived cards", () => { + const cards = [card({ id: "old", scopeRule: "stock", archived: true })]; + expect(resolveCommitCard(commit({}), cards).cardId).toBeNull(); + }); +}); + +describe("resolveCommitCard — date bounding", () => { + it("does not let a card claim commits from before it existed", () => { + // The rot this prevents: a new "Stock pricing v2" card swallowing a year + // of old feat(stock) commits and claiming work it did not do. + const cards = [ + card({ id: "new", scopeRule: "stock", created: "2026-08-01 00:00:00.000Z" }), + ]; + expect( + resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards) + .cardId + ).toBeNull(); + }); + + it("uses start_date when set", () => { + const cards = [ + card({ + id: "started", + scopeRule: "stock", + created: "2026-01-01 00:00:00.000Z", + startDate: "2026-08-01 00:00:00.000Z", + }), + ]; + expect( + resolveCommitCard(commit({ authoredAt: "2026-06-15 10:00:00.000Z" }), cards) + .cardId + ).toBeNull(); + }); + + it("falls back to created when start_date is empty", () => { + // Owner-created cards never get a start_date — only the accept flow sets + // one — so a literal start_date-only rule would make them match nothing. + const cards = [ + card({ id: "owner", scopeRule: "stock", startDate: "", created: "2026-01-01 00:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("owner"); + }); + + it("includes a commit exactly on the bound", () => { + const cards = [ + card({ id: "exact", scopeRule: "stock", created: "2026-06-15 10:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("exact"); + }); +}); + +describe("resolveCommitCard — one commit, one card", () => { + it("gives the commit to the eligible card with the latest bound", () => { + const cards = [ + card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("v2"); + }); + + it("leaves an earlier commit with the earlier card", () => { + const cards = [ + card({ id: "v1", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + card({ id: "v2", number: 2, scopeRule: "stock", created: "2026-05-01 00:00:00.000Z" }), + ]; + expect( + resolveCommitCard(commit({ authoredAt: "2026-03-01 10:00:00.000Z" }), cards) + .cardId + ).toBe("v1"); + }); + + it("breaks an exact tie by the higher card number, deterministically", () => { + const cards = [ + card({ id: "a", number: 1, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + card({ id: "b", number: 2, scopeRule: "stock", created: "2026-01-01 00:00:00.000Z" }), + ]; + expect(resolveCommitCard(commit({}), cards).cardId).toBe("b"); + // Order of the input must not change the answer. + expect(resolveCommitCard(commit({}), [...cards].reverse()).cardId).toBe("b"); + }); +}); + +describe("resolveCommitCard — merges", () => { + it("never links a merge commit", () => { + // A merge is not a unit of work; attaching it to a card would inflate that + // card's commit count and drag its session boundaries around. + const cards = [card({ id: "stock", scopeRule: "stock" })]; + expect(resolveCommitCard(commit({ isMerge: true }), cards)).toEqual({ + cardId: null, + source: "none", + }); + }); + + it("does not link a merge even with an explicit reference", () => { + const cards = [card({ id: "stock", number: 42 })]; + expect( + resolveCommitCard( + commit({ isMerge: true, message: "Merge branch 'x' [#42]" }), + cards + ).cardId + ).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/linking/resolve"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/linking/resolve.ts`: + +```ts +/** + * Decide which card a commit belongs to. + * + * Pure and database-free, so the precedence chain is verifiable rather than + * asserted. Every rule here exists to stop a specific kind of quiet wrongness — + * see the tests, which name each one. + */ + +import { extractCardReference } from "@/lib/linking/reference"; + +export interface LinkCandidate { + id: string; + number: number; + /** The conventional-commit scope this card claims, or "". */ + scopeRule: string; + /** PocketBase date string, or "" when never started. */ + startDate: string; + created: string; + archived: boolean; +} + +export interface LinkableCommit { + message: string; + /** Parsed conventional-commit scope, or "". */ + scope: string; + authoredAt: string; + isMerge: boolean; +} + +export type LinkSource = "explicit" | "scope" | "none"; + +export interface LinkResolution { + cardId: string | null; + source: LinkSource; +} + +const UNLINKED: LinkResolution = { cardId: null, source: "none" }; + +/** + * The earliest commit a card may claim by scope. + * + * `start_date` when the card was accepted through the Inbox flow, otherwise + * `created`. A literal start_date-only rule would exclude every card the owner + * created directly, which is most of them. + */ +function bound(card: LinkCandidate): string { + return card.startDate || card.created; +} + +/** PocketBase dates compare correctly as strings once normalised to ISO. */ +function comparable(value: string): string { + return (value ?? "").replace(" ", "T"); +} + +export function resolveCommitCard( + commit: LinkableCommit, + candidates: LinkCandidate[] +): LinkResolution { + // A merge is real history but not a unit of work. Linking one would inflate + // a card's commit count and distort its effort estimate. + if (commit.isMerge) return UNLINKED; + + const live = candidates.filter((c) => !c.archived); + + // 1. Explicit reference. Deliberately not date-bounded: bounding exists to + // stop scope rules over-reaching, not to overrule a person who typed a + // card number on purpose. + const referenced = extractCardReference(commit.message); + if (referenced !== null) { + const match = live.find((c) => c.number === referenced); + if (match) return { cardId: match.id, source: "explicit" }; + // Fall through: a stale or mistyped number should still let scope work. + } + + // 2. Scope rule, bounded by the card's start. + if (!commit.scope) return UNLINKED; + + const authored = comparable(commit.authoredAt); + + const eligible = live.filter( + (c) => + c.scopeRule && + c.scopeRule.toLowerCase() === commit.scope.toLowerCase() && + comparable(bound(c)) <= authored + ); + + if (eligible.length === 0) return UNLINKED; + + // One commit, one card. The winner is the eligible card that started most + // recently — so "Stock pricing v2" takes July's commits while the January + // card keeps January's. Ties break on the higher number so the answer never + // depends on the order the candidates arrived in. + const winner = eligible.reduce((best, current) => { + const a = comparable(bound(best)); + const b = comparable(bound(current)); + if (b > a) return current; + if (b < a) return best; + return current.number > best.number ? current : best; + }); + + return { cardId: winner.id, source: "scope" }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 17 tests in `resolve.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/linking/resolve.ts src/lib/linking/resolve.test.ts +git commit -m "feat(linking): commit-to-card resolution with date bounding" +``` + +--- + +## Task 4: The linking engine + +**Files:** +- Create: `src/lib/linking/engine.ts` + +- [ ] **Step 1: Write the engine** + +`src/lib/linking/engine.ts`: + +```ts +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { resolveCommitCard, type LinkCandidate } from "@/lib/linking/resolve"; +import type { CardRecord, CommitRecord } from "@/lib/pb/types"; + +export interface RelinkResult { + examined: number; + linked: number; + unlinked: number; + preserved: number; +} + +/** + * Recompute every derived link on a project. + * + * Idempotent and safe to run repeatedly: it reads the current state, decides + * again from the rules, and writes only where the answer changed. + * + * `manual` links are PRESERVED, never recomputed. A link the owner made by hand + * from the Unassigned tray is a human decision that outranks every rule in + * resolve.ts — overwriting it on the next push would make the tray pointless + * and would look, from the owner's side, like the app undoing their work. + */ +export async function relinkProject(projectId: string): Promise { + const pb = await getAdminPb(); + + const cards = await pb.collection("cards").getFullList({ + filter: `project = "${projectId}"`, + fields: "id,number,scope_rule,start_date,created,archived", + }); + + const candidates: LinkCandidate[] = cards.map((card) => ({ + id: card.id, + number: card.number, + scopeRule: card.scope_rule ?? "", + startDate: card.start_date ?? "", + created: card.created, + archived: Boolean(card.archived), + })); + + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "id,message,scope,authored_at,is_merge,card,link_source", + }); + + const result: RelinkResult = { + examined: 0, + linked: 0, + unlinked: 0, + preserved: 0, + }; + + for (const commit of commits) { + if (commit.link_source === "manual") { + result.preserved += 1; + continue; + } + + result.examined += 1; + + const resolution = resolveCommitCard( + { + message: commit.message ?? "", + scope: commit.scope ?? "", + authoredAt: commit.authored_at, + isMerge: Boolean(commit.is_merge), + }, + candidates + ); + + const nextCard = resolution.cardId ?? ""; + const nextSource = resolution.source; + + // Write only on change. A no-op update would bump `updated` on every + // commit row on every push, which makes the collection useless for + // spotting what actually moved. + if (commit.card === nextCard && commit.link_source === nextSource) continue; + + await pb.collection("commits").update(commit.id, { + card: nextCard, + link_source: nextSource, + }); + + if (nextCard) result.linked += 1; + else result.unlinked += 1; + } + + return result; +} + +/** Attach a commit to a card by hand. Outranks and survives re-linking. */ +export async function linkCommitManually( + commitId: string, + cardId: string +): Promise { + const pb = await getAdminPb(); + await pb + .collection("commits") + .update(commitId, { card: cardId, link_source: "manual" }); +} + +/** + * Detach a commit. + * + * Reset to `none` rather than to `manual`, so the next re-link is free to + * reconsider it — "this is not right" should not also mean "never look at this + * again". + */ +export async function unlinkCommit(commitId: string): Promise { + const pb = await getAdminPb(); + await pb.collection("commits").update(commitId, { card: "", link_source: "none" }); +} +``` + +- [ ] **Step 2: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/linking/engine.ts +git commit -m "feat(linking): idempotent relink engine preserving manual links" +``` + +--- + +## Task 5: Triggers + +Linking must re-run whenever either side of the rules changes. + +**Files:** +- Modify: `src/lib/git/ingest.ts` +- Modify: `src/app/actions/cards.ts` +- Create: `src/app/actions/linking.ts` + +- [ ] **Step 1: Re-link after every ingest** + +In `src/lib/git/ingest.ts`, at the end of `ingestProject`, after the +`last_ingest_at` update and before the return: + +```ts + // New commits mean new links. Doing this here rather than in each caller + // means the webhook, the poll and the backfill all get it, and none of them + // has to remember. + const { relinkProject } = await import("@/lib/linking/engine"); + await relinkProject(project.id); +``` + +The dynamic import avoids a cycle: the engine imports from `@/lib/pb/admin`, +which this module also uses, and a static import here creates a loop through +the barrel. + +- [ ] **Step 2: Re-link when a card's rules change** + +In `src/app/actions/cards.ts`: + +- `updateCard` already accepts `scopeRule`. After a successful update where + `input.scopeRule !== undefined`, call `relinkProject(card.project)`. +- `acceptCard` sets `start_date`, which moves a card's bound. Call + `relinkProject(card.project)` after the update. +- `archiveCard` and `unarchiveCard` change eligibility. Call + `relinkProject(card.project)` after each. + +Import at the top: `import { relinkProject } from "@/lib/linking/engine";` + +- [ ] **Step 3: Write the linking actions** + +`src/app/actions/linking.ts`: + +```ts +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { + linkCommitManually, + relinkProject, + unlinkCommit, +} from "@/lib/linking/engine"; + +/** See the security note at the top of src/app/actions/cards.ts. */ +async function requireOwner() { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + return session; +} + +export async function relinkProjectAction(input: { + projectId: string; + clientSlug: string; + projectSlug: string; +}): Promise<{ linked: number; unlinked: number; preserved: number }> { + await requireOwner(); + const result = await relinkProject(input.projectId); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`); + return { + linked: result.linked, + unlinked: result.unlinked, + preserved: result.preserved, + }; +} + +export async function linkCommitAction(input: { + commitId: string; + cardId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + await linkCommitManually(input.commitId, input.cardId); + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`); +} + +export async function unlinkCommitAction(input: { + commitId: string; + clientSlug: string; + projectSlug: string; +}): Promise { + await requireOwner(); + await unlinkCommit(input.commitId); + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/unassigned`); +} +``` + +- [ ] **Step 4: Verify the build** + +```bash +npm run build +``` + +Expected: build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/git/ingest.ts src/app/actions +git commit -m "feat(linking): re-link on ingest and on card rule changes" +``` + +--- + +## Task 6: The Unassigned tray + +**Files:** +- Create: `src/components/linking/unassigned-list.tsx` +- Create: `src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx` + +- [ ] **Step 1: Write the list** + +`src/components/linking/unassigned-list.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { RefreshCw } from "lucide-react"; + +import { linkCommitAction, relinkProjectAction } from "@/app/actions/linking"; +import { relativeTime } from "@/lib/time/relative"; +import { Pill } from "@/components/ui/pill"; +import type { CommitRecord } from "@/lib/pb/types"; + +export interface CardOption { + id: string; + number: number; + title: string; +} + +export function UnassignedList({ + projectId, + commits, + cards, + clientSlug, + projectSlug, +}: { + projectId: string; + commits: CommitRecord[]; + cards: CardOption[]; + clientSlug: string; + projectSlug: string; +}) { + const [pending, startTransition] = useTransition(); + const [status, setStatus] = useState(null); + + return ( +
+
+ + {status && {status}} +
+ + {commits.length === 0 ? ( +

+ Every commit is attached to a card. +

+ ) : ( +
    + {commits.map((commit) => ( +
  • +
    +

    + {commit.subject || commit.short_sha} +

    +

    + {commit.short_sha} + {relativeTime(commit.authored_at)} + {commit.type && commit.type !== "other" && ( + {commit.type} + )} + {commit.scope && {commit.scope}} +

    +
    + + +
  • + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Write the page** + +`src/app/p/[clientSlug]/[projectSlug]/unassigned/page.tsx`: + +```tsx +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { UnassignedList, type CardOption } from "@/components/linking/unassigned-list"; +import { getSession } from "@/lib/auth/session"; +import { getServerPb } from "@/lib/pb/server"; +import { loadBoard } from "@/lib/data/board"; +import type { CommitRecord } from "@/lib/pb/types"; + +/** + * Owner-only. A client manager has no business reassigning commits, and the + * tray is a working surface rather than a progress report. + */ +export default async function UnassignedPage({ + params, +}: { + params: Promise<{ clientSlug: string; projectSlug: string }>; +}) { + const session = await getSession(); + if (!session) redirect("/login"); + if (session.role !== "owner") notFound(); + + const { clientSlug, projectSlug } = await params; + const board = await loadBoard(clientSlug, projectSlug); + if (!board) notFound(); + + const pb = await getServerPb(); + + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${board.project.id}" && card = "" && is_merge = false`, + sort: "-authored_at", + }); + + const cards: CardOption[] = board.cards + .filter((card) => !card.archived) + .sort((a, b) => a.number - b.number) + .map((card) => ({ id: card.id, number: card.number, title: card.title })); + + const boardHref = `/p/${clientSlug}/${projectSlug}`; + + return ( + + + +
+ + + Back to board + + + +
+
+ ); +} +``` + +Merge commits are filtered out of the tray. They are never linkable, so listing +them would present the owner with hundreds of rows they can do nothing about. + +- [ ] **Step 3: Link it from the board** + +In `src/app/p/[clientSlug]/[projectSlug]/page.tsx`, inside ``, add +alongside the existing Archive link, rendered only for the owner: + +```tsx + {session.role === "owner" && ( + + Unassigned + + )} +``` + +- [ ] **Step 4: Verify** + +```bash +npm run build +``` + +Expected: build succeeds; the route list includes +`/p/[clientSlug]/[projectSlug]/unassigned`. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/linking src/app/p +git commit -m "feat(linking): unassigned commit tray with manual attachment" +``` + +--- + +## Task 7: Integration tests + +**Files:** +- Modify: `vitest.config.ts` +- Create: `tests/integration/linking.test.ts` + +- [ ] **Step 1: Let integration tests import server modules** + +These are the first integration tests that import application code rather than +only calling HTTP endpoints, and that breaks on `server-only`. + +That package resolves to an empty module under the `react-server` export +condition and to a module that **throws on import** under any other. Vitest does +not set the condition, so `import("@/lib/linking/engine")` fails immediately +with *"This module cannot be imported from a Client Component module."* — +confusing, because nothing here is a client component. + +In `vitest.config.ts`, add the condition to the **integration** project only: + +```ts + { + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + // server-only throws on import unless this condition is set. The unit + // project deliberately does NOT set it: pure modules must stay free + // of server imports, and a failure there is the signal that one crept + // in. + conditions: ["react-server"], + }, + test: { + name: "integration", + environment: "node", + include: ["tests/integration/**/*.test.ts"], + hookTimeout: 30_000, + testTimeout: 30_000, + fileParallelism: false, + }, + }, +``` + +- [ ] **Step 2: Write the tests** + +`tests/integration/linking.test.ts`: + +```ts +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { DEMO, adminPb, requireServices } from "./helpers"; + +let admin: PocketBase; +let projectId: string; +let columnId: string; +const created: Array<{ collection: string; id: string }> = []; + +async function makeCard(fields: Record) { + const numbered = await admin + .collection("cards") + .getFullList({ filter: `project = "${projectId}"`, fields: "number" }); + const next = + Math.max(0, ...numbered.map((c) => Number(c.number) || 0)) + 1; + + const card = await admin.collection("cards").create({ + project: projectId, + column: columnId, + title: "Linking probe", + position: 1_000_000 + next, + priority: "medium", + created_by_role: "owner", + accepted: true, + archived: false, + number: next, + ...fields, + }); + created.push({ collection: "cards", id: card.id }); + return card; +} + +async function makeCommit(fields: Record) { + const commit = await admin.collection("commits").create({ + project: projectId, + sha: `probe${Date.now()}${Math.random().toString(36).slice(2, 8)}`, + short_sha: "probe00", + message: "feat(probe): a thing", + subject: "a thing", + type: "feat", + scope: "probe", + author_name: "Dave", + author_email: "dave@example.com", + authored_at: new Date().toISOString(), + is_merge: false, + link_source: "none", + ...fields, + }); + created.push({ collection: "commits", id: commit.id }); + return commit; +} + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + columnId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = true`) + ).id; +}); + +afterAll(async () => { + for (const item of created.reverse()) { + await admin.collection(item.collection).delete(item.id).catch(() => {}); + } +}); + +describe("relinking is idempotent and preserves manual links", () => { + it("links by scope, and running twice changes nothing", async () => { + const { relinkProject } = await import("@/lib/linking/engine"); + + const card = await makeCard({ scope_rule: "probe" }); + const commit = await makeCommit({}); + + await relinkProject(projectId); + const first = await admin.collection("commits").getOne(commit.id); + expect(first.card).toBe(card.id); + expect(first.link_source).toBe("scope"); + + await relinkProject(projectId); + const second = await admin.collection("commits").getOne(commit.id); + expect(second.card).toBe(card.id); + // `updated` must not move: re-linking writes only where the answer changed. + expect(second.updated).toBe(first.updated); + }); + + it("never overwrites a manual link", async () => { + const { relinkProject, linkCommitManually } = await import( + "@/lib/linking/engine" + ); + + const scoped = await makeCard({ scope_rule: "probe" }); + const other = await makeCard({ scope_rule: "" }); + const commit = await makeCommit({}); + + await linkCommitManually(commit.id, other.id); + await relinkProject(projectId); + + const after = await admin.collection("commits").getOne(commit.id); + expect(after.card).toBe(other.id); + expect(after.card).not.toBe(scoped.id); + expect(after.link_source).toBe("manual"); + }); + + it("does not let a card claim commits from before it existed", async () => { + const { relinkProject } = await import("@/lib/linking/engine"); + + const old = await makeCommit({ + scope: "backdated", + authored_at: "2020-01-01T00:00:00.000Z", + }); + await makeCard({ scope_rule: "backdated" }); + + await relinkProject(projectId); + + const after = await admin.collection("commits").getOne(old.id); + expect(after.card).toBe(""); + expect(after.link_source).toBe("none"); + }); + + it("never links a merge commit", async () => { + const { relinkProject } = await import("@/lib/linking/engine"); + + await makeCard({ scope_rule: "mergeprobe" }); + const merge = await makeCommit({ scope: "mergeprobe", is_merge: true }); + + await relinkProject(projectId); + + const after = await admin.collection("commits").getOne(merge.id); + expect(after.card).toBe(""); + }); +}); +``` + +- [ ] **Step 3: Run the integration suite** + +With `npm run dev` running: + +```bash +set -a && source .env.local && set +a && npm run test:integration +``` + +Expected: PASS — 34 tests (30 after Plan 2a, plus 4 here). + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/linking.test.ts vitest.config.ts +git commit -m "test(linking): idempotency, manual precedence and date bounding" +``` + +--- + +## Task 8: Full verification + +**This task is work, not a summary. Run every command and act on what it prints.** + +- [ ] **Step 1: Run every test** + +```bash +set -a && source .env.local && set +a && npm run test:all +``` + +Expected: PASS. Unit tests total **159** — the 128 after Plan 2a, plus 5 in +`number.test.ts`, 9 in `reference.test.ts` and 17 in `resolve.test.ts`. +Integration tests total 34. + +- [ ] **Step 2: Verify the production build** + +```bash +npm run build +``` + +Expected: no type errors. The route list includes +`/p/[clientSlug]/[projectSlug]/unassigned`. + +- [ ] **Step 3: Link the real history** + +On the demo project, which now holds WFDASH-WEBAPP's 787 commits, set a card's +`scope_rule` to a scope that genuinely appears — `quotes` or `stock` — and set +that card's `start_date` early enough to cover the history. Re-run linking from +the tray. + +```bash +set -a && source .env.local && set +a && npx tsx -e " +import PocketBase from 'pocketbase'; +const pb = new PocketBase(process.env.NEXT_PUBLIC_PB_URL!); +pb.autoCancellation(false); +await pb.collection('_superusers').authWithPassword(process.env.PB_SUPERUSER_EMAIL!, process.env.PB_SUPERUSER_PASSWORD!); +const all = await pb.collection('commits').getFullList({ fields: 'scope,card,link_source,is_merge' }); +const bySource: Record = {}; +for (const c of all) bySource[c.link_source || 'none'] = (bySource[c.link_source || 'none'] ?? 0) + 1; +const scopes: Record = {}; +for (const c of all) if (c.scope) scopes[c.scope] = (scopes[c.scope] ?? 0) + 1; +console.log('by link_source:', bySource); +console.log('merges:', all.filter(c => c.is_merge).length); +console.log('top scopes:', Object.entries(scopes).sort((a,b)=>b[1]-a[1]).slice(0,8)); +console.log('linked merges (must be 0):', all.filter(c => c.is_merge && c.card).length); +" +``` + +Expected: commits with the chosen scope are linked, `linked merges` is **0**, and +the top scopes match the "Feature areas" breakdown in the Hermes build log. + +- [ ] **Step 4: Check the tray** + +Open `/p///unassigned`. + +Expected: the unlinked, non-merge commits are listed newest first. Attach one to +a card by hand, then press **Re-run linking** and confirm it stays attached and +the status line reports it among the preserved manual links. + +- [ ] **Step 5: Check the explicit reference end to end** + +Make a commit in the linked repository whose message ends with `[#N]`, where N is +a real card number on that project, and push it. + +Expected: within seconds it is linked to card N with `link_source: "explicit"`, +even if its scope would have sent it elsewhere. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A +git commit -m "fix: issues found during Phase 2b verification" +``` + +--- + +## Definition of done + +1. `npm run test:all` passes: 159 unit, 34 integration. +2. `npm run build` succeeds with no type errors. +3. Every card has a unique number within its project, and both creation paths + assign one. +4. `[#42]` links a commit to card 42 regardless of its scope. +5. Scope linking respects `start_date || created` and never back-claims. +6. No merge commit is ever linked. +7. Re-running linking changes nothing that has not genuinely changed, and never + disturbs a manual link. +8. The Unassigned tray lists unlinked non-merge commits and can attach them. + +## Deliberately not in this plan + +- **Rendering a card's commits on the card detail page.** The links exist; the + display lands in Phase 3, when those commits finally have readable summaries + to show instead of raw conventional-commit subjects. +- **Session clustering, workdays, metrics and the dashboard** — Plan 2c. +- **Bulk operations in the tray.** Attaching commits one at a time is tedious + across hundreds of rows; add multi-select once it actually hurts, since the + scope rules should keep the tray small. +- **A scope-rule picker on the card.** `scope_rule` is currently set through the + admin or the database. A suggestions UI — showing which scopes exist and how + many commits each holds — would be a good Phase 3 addition once there is data + to populate it. diff --git a/docs/superpowers/plans/2026-09-16-phase-2c-metrics-and-dashboard.md b/docs/superpowers/plans/2026-09-16-phase-2c-metrics-and-dashboard.md new file mode 100644 index 0000000..28be5a1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-phase-2c-metrics-and-dashboard.md @@ -0,0 +1,2277 @@ +# Phase 2c — Metrics and the Project Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn stored commits into the dashboard — headline figures, an activity calendar, hours by month, type and feature-area breakdowns, and a methodology panel that defends every number on the page. + +**Architecture:** Every calculation is a pure function with no database and no framework, tested exhaustively — session clustering, workday counting, and metric aggregation are the most testable code in the entire project and are built test-first. A cache collection stores the result so the dashboard never aggregates hundreds of rows per page view. Charts are hand-written SVG against the Phase 1b design tokens. + +**Tech Stack:** Next.js 16 · PocketBase 0.30 · Tailwind 4 · vitest + +**Spec:** `docs/superpowers/specs/2026-09-16-phase-2-commit-ingestion-and-dashboard-design.md` §5, §10 + +**Prerequisite:** Plans 2a and 2b complete — commits are ingested, parsed and linked. + +--- + +## The numbers must survive being questioned + +The developer chose to show effort figures to clients rather than keeping them +on the owner's view. That decision makes this plan's arithmetic load-bearing in +a way most dashboards' is not: when a client asks *"why does that say eight +hours?"*, the methodology panel is the entire answer. + +Three consequences run through every task: + +- **Every effort figure says "estimated" in the UI**, not only in a footnote. +- **The constants used are stored with the computation**, so a figure on screen + can always be explained by the settings that produced it — not by whatever the + settings happen to be today. +- **The methodology panel is always rendered.** Not a link, not a collapsed + accordion that opens onto an explanation nobody found. + +--- + +## Two figures verified against the Hermes build log + +The developer's hand-built log is the reference implementation. Two of its +numbers are reproduced here as regression tests, because they are the ones most +likely to drift silently. + +**Workdays.** Its window was 24 May → 8 September 2026: **108 calendar days, 75 +workdays**. The South African holiday table in Task 1 reproduces both exactly, +including the Sunday-to-Monday rule that moves Women's Day from Sunday 9 August +to Monday 10 August. + +**Merges.** 787 commits in the repository, 779 excluding merges, 8 merges. +Verified in Plan 2a and excluded from every figure here. + +--- + +## File structure + +``` +src/ +├── lib/ +│ └── metrics/ +│ ├── holidays.ts # SA public holidays, 2024–2028 +│ ├── workdays.ts # workday counting +│ ├── workdays.test.ts +│ ├── sessions.ts # commit clustering -> effort +│ ├── sessions.test.ts +│ ├── compute.ts # commits -> the whole metric set +│ ├── compute.test.ts +│ ├── heatmap.ts # activity map -> week grid +│ └── heatmap.test.ts +├── app/ +│ ├── actions/ +│ │ └── metrics.ts # recompute +│ └── p/[clientSlug]/[projectSlug]/ +│ └── dashboard/page.tsx +└── components/ + └── dashboard/ + ├── kpi-row.tsx + ├── activity-calendar.tsx # SVG heatmap + ├── bar-list.tsx # SVG horizontal bars + ├── month-bars.tsx # SVG vertical bars + └── methodology.tsx +``` + +--- + +## Task 1: Workdays and South African public holidays + +Pure, test-first. + +**Files:** +- Create: `src/lib/metrics/holidays.ts` +- Create: `src/lib/metrics/workdays.ts` +- Test: `src/lib/metrics/workdays.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/metrics/workdays.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { SA_HOLIDAYS } from "@/lib/metrics/holidays"; +import { countWorkdays, isWorkday, countCalendarDays } from "@/lib/metrics/workdays"; + +describe("countCalendarDays", () => { + it("counts both endpoints", () => { + expect(countCalendarDays("2026-09-01", "2026-09-01")).toBe(1); + expect(countCalendarDays("2026-09-01", "2026-09-03")).toBe(3); + }); + + it("returns 0 when the range is inverted", () => { + expect(countCalendarDays("2026-09-03", "2026-09-01")).toBe(0); + }); +}); + +describe("isWorkday", () => { + it("excludes weekends", () => { + expect(isWorkday("2026-09-12")).toBe(false); // Saturday + expect(isWorkday("2026-09-13")).toBe(false); // Sunday + expect(isWorkday("2026-09-14")).toBe(true); // Monday + }); + + it("excludes a public holiday that falls on a weekday", () => { + expect(isWorkday("2026-06-16")).toBe(false); // Youth Day, a Tuesday + }); + + it("excludes the Monday after a Sunday holiday", () => { + // South African law moves a Sunday public holiday to the Monday. + // Women's Day 2026 is Sunday 9 August, so Monday 10 August is a holiday. + expect(isWorkday("2026-08-10")).toBe(false); + expect(isWorkday("2026-08-11")).toBe(true); + }); +}); + +describe("countWorkdays — reproduces the Hermes build log", () => { + it("gives 108 calendar days and 75 workdays for 24 May to 8 Sep 2026", () => { + // These are the exact figures from the developer's hand-built log. If this + // test fails, either the holiday table or the counting drifted, and every + // "active X of Y workdays" figure on the dashboard is wrong. + expect(countCalendarDays("2026-05-24", "2026-09-08")).toBe(108); + expect(countWorkdays("2026-05-24", "2026-09-08")).toBe(75); + }); +}); + +describe("SA_HOLIDAYS", () => { + it("covers every year a project might span", () => { + for (const year of [2024, 2025, 2026, 2027, 2028]) { + const inYear = SA_HOLIDAYS.filter((d) => d.startsWith(String(year))); + // Twelve statutory days, plus any Monday observances. + expect(inYear.length).toBeGreaterThanOrEqual(12); + } + }); + + it("has the Easter-derived dates right", () => { + // Easter Sunday: 2025-04-20, 2026-04-05, 2027-03-28. + expect(SA_HOLIDAYS).toContain("2025-04-18"); // Good Friday + expect(SA_HOLIDAYS).toContain("2025-04-21"); // Family Day + expect(SA_HOLIDAYS).toContain("2026-04-03"); + expect(SA_HOLIDAYS).toContain("2026-04-06"); + expect(SA_HOLIDAYS).toContain("2027-03-26"); + expect(SA_HOLIDAYS).toContain("2027-03-29"); + }); + + it("is sorted and free of duplicates", () => { + const sorted = [...SA_HOLIDAYS].sort(); + expect(SA_HOLIDAYS).toEqual(sorted); + expect(new Set(SA_HOLIDAYS).size).toBe(SA_HOLIDAYS.length); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/metrics/holidays"`. + +- [ ] **Step 3: Write the holiday table** + +`src/lib/metrics/holidays.ts`: + +```ts +/** + * South African public holidays, as an explicit table. + * + * Computing the Easter-derived dates was considered and rejected. A table is + * auditable at a glance, and its failure mode is one visibly odd day that + * someone notices — whereas a wrong Easter algorithm produces a + * plausible-looking systematic drift that nobody catches. + * + * Includes Monday observances: South African law moves a public holiday that + * falls on a Sunday to the following Monday. Those Mondays are listed here + * explicitly rather than derived, for the same reason. + * + * Verified against the developer's hand-built build log: 24 May → 8 Sep 2026 + * gives 108 calendar days and 75 workdays with this table. + * + * EXTEND THIS before a project spans a year that is not listed. + */ +export const SA_HOLIDAYS: readonly string[] = [ + // 2024 — Easter Sunday 31 Mar + "2024-01-01", "2024-03-21", "2024-03-29", "2024-04-01", "2024-04-27", + "2024-05-01", "2024-06-16", "2024-06-17", "2024-08-09", "2024-09-24", + "2024-12-16", "2024-12-25", "2024-12-26", + + // 2025 — Easter Sunday 20 Apr + "2025-01-01", "2025-03-21", "2025-04-18", "2025-04-21", "2025-04-27", + "2025-04-28", "2025-05-01", "2025-06-16", "2025-08-09", "2025-09-24", + "2025-12-16", "2025-12-25", "2025-12-26", + + // 2026 — Easter Sunday 5 Apr. Women's Day falls on Sunday 9 Aug, + // so Monday 10 Aug is observed. + "2026-01-01", "2026-03-21", "2026-04-03", "2026-04-06", "2026-04-27", + "2026-05-01", "2026-06-16", "2026-08-09", "2026-08-10", "2026-09-24", + "2026-12-16", "2026-12-25", "2026-12-26", + + // 2027 — Easter Sunday 28 Mar. Human Rights Day falls on Sunday 21 Mar, + // so Monday 22 Mar is observed. Boxing Day falls on Sunday 26 Dec, + // so Monday 27 Dec is observed. + "2027-01-01", "2027-03-21", "2027-03-22", "2027-03-26", "2027-03-29", + "2027-04-27", "2027-05-01", "2027-06-16", "2027-08-09", "2027-09-24", + "2027-12-16", "2027-12-25", "2027-12-26", "2027-12-27", + + // 2028 — Easter Sunday 16 Apr. New Year falls on Saturday; Workers' Day + // on Monday. + "2028-01-01", "2028-03-21", "2028-04-14", "2028-04-17", "2028-04-27", + "2028-05-01", "2028-06-16", "2028-08-09", "2028-09-24", "2028-12-16", + "2028-12-25", "2028-12-26", +]; + +const HOLIDAY_SET = new Set(SA_HOLIDAYS); + +export function isPublicHoliday(isoDate: string): boolean { + return HOLIDAY_SET.has(isoDate); +} +``` + +- [ ] **Step 4: Write the workday counting** + +`src/lib/metrics/workdays.ts`: + +```ts +/** + * Workday counting. + * + * The dashboard leads with active days as a fraction of WORKDAYS, not calendar + * days. "60 of 75 workdays" and "60 of 108 calendar days" describe the same + * three months; the second implies absence where there was a weekend. The + * first is the honest framing, and it is the strongest idea in the developer's + * original build log. + * + * All arithmetic is UTC on plain YYYY-MM-DD strings. Local time would make the + * figures shift with the reader's timezone, which is indefensible on a page a + * client is questioning. + */ + +import { isPublicHoliday } from "@/lib/metrics/holidays"; + +const DAY_MS = 86_400_000; + +function toUtc(isoDate: string): number { + return Date.UTC( + Number(isoDate.slice(0, 4)), + Number(isoDate.slice(5, 7)) - 1, + Number(isoDate.slice(8, 10)) + ); +} + +export function toIsoDate(value: Date | number): string { + return new Date(value).toISOString().slice(0, 10); +} + +/** Inclusive of both endpoints. 0 when the range is inverted. */ +export function countCalendarDays(from: string, to: string): number { + const days = Math.round((toUtc(to) - toUtc(from)) / DAY_MS) + 1; + return days > 0 ? days : 0; +} + +export function isWorkday(isoDate: string): boolean { + const day = new Date(toUtc(isoDate)).getUTCDay(); + if (day === 0 || day === 6) return false; + return !isPublicHoliday(isoDate); +} + +/** Weekdays in the inclusive range, excluding public holidays. */ +export function countWorkdays(from: string, to: string): number { + const start = toUtc(from); + const end = toUtc(to); + if (end < start) return 0; + + let count = 0; + for (let t = start; t <= end; t += DAY_MS) { + if (isWorkday(toIsoDate(t))) count += 1; + } + return count; +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 9 tests in `workdays.test.ts`, including the 108/75 anchor. + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/metrics +git commit -m "feat(metrics): SA public holidays and workday counting" +``` + +--- + +## Task 2: Session clustering + +The calculation the whole dashboard's credibility rests on. Pure, test-first. + +**Files:** +- Create: `src/lib/metrics/sessions.ts` +- Test: `src/lib/metrics/sessions.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/metrics/sessions.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { + DEFAULT_SESSION_CONSTANTS, + clusterSessions, + summariseSessions, +} from "@/lib/metrics/sessions"; + +const C = DEFAULT_SESSION_CONSTANTS; +const PADDING = C.preSessionMinutes + C.postSessionMinutes; // 65 + +function at(iso: string) { + return { authoredAt: iso }; +} + +describe("clusterSessions", () => { + it("returns nothing for no commits", () => { + expect(clusterSessions([], C)).toEqual([]); + }); + + it("gives a lone commit only the padding", () => { + // A single commit spans no time at all, so its estimate is entirely the + // assumed work before and after it. + const [session] = clusterSessions([at("2026-09-01T10:00:00Z")], C); + expect(session.commitCount).toBe(1); + expect(session.minutes).toBe(PADDING); + }); + + it("adds padding to the span of a multi-commit session", () => { + const [session] = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T10:30:00Z")], + C + ); + expect(session.commitCount).toBe(2); + expect(session.minutes).toBe(30 + PADDING); + }); + + it("splits when the gap reaches the threshold", () => { + // Strictly less than the gap means "same session", so a gap of exactly + // SESSION_GAP_MINUTES starts a new one. Defining the boundary matters: + // an undefined one drifts between implementations. + const exactly = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T12:00:00Z")], + C + ); + expect(exactly).toHaveLength(2); + + const justUnder = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T11:59:00Z")], + C + ); + expect(justUnder).toHaveLength(1); + }); + + it("sorts unordered input before clustering", () => { + const sessions = clusterSessions( + [at("2026-09-01T10:30:00Z"), at("2026-09-01T10:00:00Z")], + C + ); + expect(sessions).toHaveLength(1); + expect(sessions[0].minutes).toBe(30 + PADDING); + }); + + it("handles PocketBase's space-separated timestamps", () => { + const sessions = clusterSessions( + [at("2026-09-01 10:00:00.000Z"), at("2026-09-01 10:30:00.000Z")], + C + ); + expect(sessions).toHaveLength(1); + expect(sessions[0].minutes).toBe(30 + PADDING); + }); + + it("records the day of the session's first commit", () => { + // A session that runs past midnight belongs to the day it started on, so + // the activity calendar does not split one night's work across two cells. + const [session] = clusterSessions( + [at("2026-09-01T23:30:00Z"), at("2026-09-02T00:15:00Z")], + C + ); + expect(session.day).toBe("2026-09-01"); + expect(session.commitCount).toBe(2); + }); + + it("respects custom constants", () => { + const tight = { sessionGapMinutes: 30, preSessionMinutes: 0, postSessionMinutes: 0 }; + const sessions = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T10:45:00Z")], + tight + ); + expect(sessions).toHaveLength(2); + expect(sessions[0].minutes).toBe(0); + }); +}); + +describe("summariseSessions", () => { + it("is all zeroes for no sessions", () => { + expect(summariseSessions([])).toEqual({ + sessionCount: 0, + totalMinutes: 0, + longestMinutes: 0, + longestCommits: 0, + longestDay: "", + }); + }); + + it("totals the minutes and finds the longest session", () => { + const sessions = clusterSessions( + [ + at("2026-09-01T10:00:00Z"), + at("2026-09-01T10:30:00Z"), + // three hours later: a new session + at("2026-09-01T13:30:00Z"), + at("2026-09-01T16:30:00Z"), + ], + C + ); + const summary = summariseSessions(sessions); + + expect(summary.sessionCount).toBe(2); + expect(summary.totalMinutes).toBe(30 + PADDING + (180 + PADDING)); + expect(summary.longestMinutes).toBe(180 + PADDING); + expect(summary.longestCommits).toBe(2); + expect(summary.longestDay).toBe("2026-09-01"); + }); + + it("keeps the earliest session when two are equally long", () => { + const sessions = clusterSessions( + [ + at("2026-09-01T10:00:00Z"), + at("2026-09-01T10:30:00Z"), + at("2026-09-05T10:00:00Z"), + at("2026-09-05T10:30:00Z"), + ], + C + ); + expect(summariseSessions(sessions).longestDay).toBe("2026-09-01"); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/metrics/sessions"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/metrics/sessions.ts`: + +```ts +/** + * Effort estimation by session clustering. + * + * Git records no duration. This module is the whole answer to "where did eight + * hours come from?", so its rules are explicit and its boundaries are defined + * rather than emergent. + * + * Commits close together in time are one working session. A session's estimate + * is the span between its first and last commit, plus padding at each end: work + * begins before the first commit lands and continues after the last. The + * developer's own hand-built log made this mistake in its first version — + * 164.6 h with no padding, revised to 218.7 h — because a session that ends at + * its last commit assumes work stops the instant code is committed, which is + * never true. + * + * What this cannot see: design, meetings, reading, and debugging that ended in + * a revert. It undercounts, and the dashboard says so. + * + * Merge commits must be filtered out by the caller. They are not units of work + * and they land at whatever moment a branch was integrated, which drags session + * boundaries around. + */ + +export interface SessionConstants { + sessionGapMinutes: number; + preSessionMinutes: number; + postSessionMinutes: number; +} + +export const DEFAULT_SESSION_CONSTANTS: SessionConstants = { + /** Two hours between commits is a break, not a pause. */ + sessionGapMinutes: 120, + /** Reading, reproducing and planning before the first commit lands. */ + preSessionMinutes: 45, + /** Testing, merging and deploying after the last one. */ + postSessionMinutes: 20, +}; + +export interface SessionCommit { + authoredAt: string; +} + +export interface Session { + start: string; + end: string; + /** UTC date of the session's first commit. */ + day: string; + commitCount: number; + minutes: number; +} + +export interface SessionSummary { + sessionCount: number; + totalMinutes: number; + longestMinutes: number; + longestCommits: number; + longestDay: string; +} + +/** PocketBase returns "2026-09-01 10:00:00.000Z"; Date.parse wants the T. */ +function parse(value: string): number { + return new Date(value.replace(" ", "T")).getTime(); +} + +export function clusterSessions( + commits: SessionCommit[], + constants: SessionConstants = DEFAULT_SESSION_CONSTANTS +): Session[] { + if (commits.length === 0) return []; + + const times = commits + .map((c) => parse(c.authoredAt)) + .filter((t) => Number.isFinite(t)) + .sort((a, b) => a - b); + + if (times.length === 0) return []; + + const gapMs = constants.sessionGapMinutes * 60_000; + const padding = constants.preSessionMinutes + constants.postSessionMinutes; + + const sessions: Session[] = []; + let startIndex = 0; + + const flush = (endIndex: number) => { + const start = times[startIndex]; + const end = times[endIndex]; + sessions.push({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + day: new Date(start).toISOString().slice(0, 10), + commitCount: endIndex - startIndex + 1, + minutes: Math.round((end - start) / 60_000) + padding, + }); + }; + + for (let i = 1; i < times.length; i++) { + // Strictly less than the gap continues the session; exactly the gap starts + // a new one. The boundary is defined here so it cannot drift. + if (times[i] - times[i - 1] >= gapMs) { + flush(i - 1); + startIndex = i; + } + } + flush(times.length - 1); + + return sessions; +} + +export function summariseSessions(sessions: Session[]): SessionSummary { + if (sessions.length === 0) { + return { + sessionCount: 0, + totalMinutes: 0, + longestMinutes: 0, + longestCommits: 0, + longestDay: "", + }; + } + + let totalMinutes = 0; + let longest = sessions[0]; + + for (const session of sessions) { + totalMinutes += session.minutes; + // Strictly greater, so the earliest of equally long sessions is kept and + // the reported "longest day" is stable across recomputations. + if (session.minutes > longest.minutes) longest = session; + } + + return { + sessionCount: sessions.length, + totalMinutes, + longestMinutes: longest.minutes, + longestCommits: longest.commitCount, + longestDay: longest.day, + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 11 tests in `sessions.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/sessions.ts src/lib/metrics/sessions.test.ts +git commit -m "feat(metrics): session clustering with defined boundaries" +``` + +--- + +## Task 3: Metric aggregation + +**Files:** +- Create: `src/lib/metrics/compute.ts` +- Test: `src/lib/metrics/compute.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/metrics/compute.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { computeProjectMetrics, type MetricCommit } from "@/lib/metrics/compute"; +import { DEFAULT_SESSION_CONSTANTS } from "@/lib/metrics/sessions"; + +function commit(overrides: Partial): MetricCommit { + return { + type: "feat", + scope: "stock", + authoredAt: "2026-09-01T10:00:00Z", + isMerge: false, + ...overrides, + }; +} + +describe("computeProjectMetrics", () => { + it("is empty and safe for a project with no commits", () => { + const m = computeProjectMetrics([], DEFAULT_SESSION_CONSTANTS); + expect(m.totalCommits).toBe(0); + expect(m.features).toBe(0); + expect(m.activeDays).toBe(0); + expect(m.workdays).toBe(0); + expect(m.estimatedMinutes).toBe(0); + expect(m.firstCommit).toBe(""); + }); + + it("excludes merges from every count", () => { + const m = computeProjectMetrics( + [commit({}), commit({ isMerge: true, type: "merge" })], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.totalCommits).toBe(1); + expect(m.mergeCommits).toBe(1); + expect(m.byType.find((t) => t.key === "merge")).toBeUndefined(); + }); + + it("counts features and fixes", () => { + const m = computeProjectMetrics( + [commit({ type: "feat" }), commit({ type: "feat" }), commit({ type: "fix" })], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.features).toBe(2); + expect(m.fixes).toBe(1); + }); + + it("ranks types and scopes by volume", () => { + const m = computeProjectMetrics( + [ + commit({ type: "feat", scope: "stock" }), + commit({ type: "feat", scope: "stock" }), + commit({ type: "fix", scope: "quotes" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.byType[0]).toEqual({ key: "feat", count: 2 }); + expect(m.byScope[0]).toEqual({ key: "stock", count: 2 }); + }); + + it("omits commits with no scope from the scope breakdown", () => { + // An empty bar labelled "" tells the reader nothing and takes the place of + // a real feature area. + const m = computeProjectMetrics( + [commit({ scope: "" }), commit({ scope: "stock" })], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.byScope).toEqual([{ key: "stock", count: 1 }]); + }); + + it("counts distinct active days, not commits", () => { + const m = computeProjectMetrics( + [ + commit({ authoredAt: "2026-09-01T10:00:00Z" }), + commit({ authoredAt: "2026-09-01T16:00:00Z" }), + commit({ authoredAt: "2026-09-03T10:00:00Z" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.activeDays).toBe(2); + expect(m.totalCommits).toBe(3); + }); + + it("spans from the first commit to the last", () => { + const m = computeProjectMetrics( + [ + commit({ authoredAt: "2026-09-03T10:00:00Z" }), + commit({ authoredAt: "2026-05-24T10:00:00Z" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.firstCommit).toBe("2026-05-24"); + expect(m.lastCommit).toBe("2026-09-03"); + }); + + it("counts workdays across the span, not calendar days", () => { + const m = computeProjectMetrics( + [ + commit({ authoredAt: "2026-05-24T10:00:00Z" }), + commit({ authoredAt: "2026-09-08T10:00:00Z" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + // The same window as the Hermes build log. + expect(m.calendarDays).toBe(108); + expect(m.workdays).toBe(75); + }); + + it("builds a per-day activity map", () => { + const m = computeProjectMetrics( + [ + commit({ authoredAt: "2026-09-01T10:00:00Z" }), + commit({ authoredAt: "2026-09-01T16:00:00Z" }), + commit({ authoredAt: "2026-09-03T10:00:00Z" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + expect(m.activity["2026-09-01"]).toBe(2); + expect(m.activity["2026-09-03"]).toBe(1); + expect(m.activity["2026-09-02"]).toBeUndefined(); + }); + + it("groups estimated minutes by month", () => { + const m = computeProjectMetrics( + [ + commit({ authoredAt: "2026-08-01T10:00:00Z" }), + commit({ authoredAt: "2026-09-01T10:00:00Z" }), + ], + DEFAULT_SESSION_CONSTANTS + ); + const months = m.monthly.map((x) => x.month); + expect(months).toEqual(["2026-08", "2026-09"]); + expect(m.monthly.every((x) => x.minutes > 0)).toBe(true); + }); + + it("records the constants it used", () => { + // A figure on screen must be explicable by the settings that produced it, + // not by whatever the settings happen to be when someone later asks. + const custom = { sessionGapMinutes: 90, preSessionMinutes: 10, postSessionMinutes: 5 }; + const m = computeProjectMetrics([commit({})], custom); + expect(m.constants).toEqual(custom); + expect(m.estimatedMinutes).toBe(15); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/metrics/compute"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/metrics/compute.ts`: + +```ts +/** + * Everything the dashboard shows, computed from a project's commits. + * + * Pure: no database, no framework, no clock. Given the same commits and the + * same constants it returns the same numbers forever, which is what makes the + * figures defensible and the tests meaningful. + */ + +import { + clusterSessions, + summariseSessions, + type SessionConstants, +} from "@/lib/metrics/sessions"; +import { countCalendarDays, countWorkdays } from "@/lib/metrics/workdays"; + +export interface MetricCommit { + type: string; + scope: string; + authoredAt: string; + isMerge: boolean; +} + +export interface Tally { + key: string; + count: number; +} + +export interface MonthlyEffort { + month: string; + minutes: number; +} + +export interface ProjectMetrics { + totalCommits: number; + mergeCommits: number; + features: number; + fixes: number; + byType: Tally[]; + byScope: Tally[]; + firstCommit: string; + lastCommit: string; + calendarDays: number; + workdays: number; + activeDays: number; + /** ISO date -> commit count, for the activity calendar. */ + activity: Record; + estimatedMinutes: number; + sessionCount: number; + longestSessionMinutes: number; + longestSessionCommits: number; + longestSessionDay: string; + monthly: MonthlyEffort[]; + constants: SessionConstants; +} + +function day(value: string): string { + return value.replace(" ", "T").slice(0, 10); +} + +function rank(counts: Map): Tally[] { + return [...counts.entries()] + .map(([key, count]) => ({ key, count })) + // Count descending, then key ascending, so the order never depends on + // insertion and a recomputation cannot silently reshuffle the chart. + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); +} + +export function computeProjectMetrics( + commits: MetricCommit[], + constants: SessionConstants +): ProjectMetrics { + const mergeCommits = commits.filter((c) => c.isMerge).length; + + // Merges are real history but not units of work. Excluded everywhere below. + const real = commits.filter((c) => !c.isMerge); + + const empty: ProjectMetrics = { + totalCommits: 0, + mergeCommits, + features: 0, + fixes: 0, + byType: [], + byScope: [], + firstCommit: "", + lastCommit: "", + calendarDays: 0, + workdays: 0, + activeDays: 0, + activity: {}, + estimatedMinutes: 0, + sessionCount: 0, + longestSessionMinutes: 0, + longestSessionCommits: 0, + longestSessionDay: "", + monthly: [], + constants, + }; + + if (real.length === 0) return empty; + + const typeCounts = new Map(); + const scopeCounts = new Map(); + const activity: Record = {}; + + let first = day(real[0].authoredAt); + let last = first; + + for (const commit of real) { + const type = commit.type || "other"; + typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); + + // A bar labelled "" is a bar the reader learns nothing from, and it takes + // the place of a real feature area. + if (commit.scope) { + scopeCounts.set(commit.scope, (scopeCounts.get(commit.scope) ?? 0) + 1); + } + + const d = day(commit.authoredAt); + activity[d] = (activity[d] ?? 0) + 1; + if (d < first) first = d; + if (d > last) last = d; + } + + const sessions = clusterSessions( + real.map((c) => ({ authoredAt: c.authoredAt })), + constants + ); + const summary = summariseSessions(sessions); + + const monthlyMap = new Map(); + for (const session of sessions) { + const month = session.day.slice(0, 7); + monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + session.minutes); + } + + return { + totalCommits: real.length, + mergeCommits, + features: typeCounts.get("feat") ?? 0, + fixes: typeCounts.get("fix") ?? 0, + byType: rank(typeCounts), + byScope: rank(scopeCounts), + firstCommit: first, + lastCommit: last, + calendarDays: countCalendarDays(first, last), + workdays: countWorkdays(first, last), + activeDays: Object.keys(activity).length, + activity, + estimatedMinutes: summary.totalMinutes, + sessionCount: summary.sessionCount, + longestSessionMinutes: summary.longestMinutes, + longestSessionCommits: summary.longestCommits, + longestSessionDay: summary.longestDay, + monthly: [...monthlyMap.entries()] + .map(([month, minutes]) => ({ month, minutes })) + .sort((a, b) => a.month.localeCompare(b.month)), + constants, + }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 11 tests in `compute.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/compute.ts src/lib/metrics/compute.test.ts +git commit -m "feat(metrics): project metric aggregation from commits" +``` + +--- + +## Task 4: The activity calendar grid + +**Files:** +- Create: `src/lib/metrics/heatmap.ts` +- Test: `src/lib/metrics/heatmap.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`src/lib/metrics/heatmap.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { buildHeatmap, HEATMAP_LEVELS } from "@/lib/metrics/heatmap"; + +describe("buildHeatmap", () => { + it("returns no weeks for an empty range", () => { + expect(buildHeatmap({}, "", "").weeks).toEqual([]); + }); + + it("starts each week on Monday", () => { + // 2026-09-01 is a Tuesday. The first column must begin on Monday the 31st + // so rows line up with the weekday labels. + const grid = buildHeatmap({ "2026-09-01": 1 }, "2026-09-01", "2026-09-07"); + expect(grid.weeks[0][0].date).toBe("2026-08-31"); + expect(grid.weeks[0][0].inRange).toBe(false); + expect(grid.weeks[0][1].date).toBe("2026-09-01"); + expect(grid.weeks[0][1].inRange).toBe(true); + }); + + it("gives every week exactly seven days", () => { + const grid = buildHeatmap({}, "2026-05-24", "2026-09-08"); + expect(grid.weeks.every((w) => w.length === 7)).toBe(true); + }); + + it("carries the commit count onto the right day", () => { + const grid = buildHeatmap({ "2026-09-02": 5 }, "2026-09-01", "2026-09-07"); + const day = grid.weeks.flat().find((d) => d.date === "2026-09-02"); + expect(day?.count).toBe(5); + }); + + it("assigns level 0 to a day with no commits", () => { + const grid = buildHeatmap({ "2026-09-02": 5 }, "2026-09-01", "2026-09-07"); + const day = grid.weeks.flat().find((d) => d.date === "2026-09-03"); + expect(day?.count).toBe(0); + expect(day?.level).toBe(0); + }); + + it("scales levels against the busiest day", () => { + const grid = buildHeatmap( + { "2026-09-01": 1, "2026-09-02": 10, "2026-09-03": 20 }, + "2026-09-01", + "2026-09-07" + ); + const byDate = new Map(grid.weeks.flat().map((d) => [d.date, d])); + expect(byDate.get("2026-09-03")!.level).toBe(HEATMAP_LEVELS - 1); + expect(byDate.get("2026-09-01")!.level).toBe(1); + expect(byDate.get("2026-09-02")!.level).toBeGreaterThan(1); + }); + + it("gives any day with commits at least level 1", () => { + // A day someone worked must never look identical to a day they did not. + const grid = buildHeatmap( + { "2026-09-01": 1, "2026-09-02": 500 }, + "2026-09-01", + "2026-09-07" + ); + const day = grid.weeks.flat().find((d) => d.date === "2026-09-01"); + expect(day?.level).toBe(1); + }); + + it("reports the busiest day and total", () => { + const grid = buildHeatmap( + { "2026-09-01": 3, "2026-09-02": 9 }, + "2026-09-01", + "2026-09-07" + ); + expect(grid.busiestCount).toBe(9); + expect(grid.totalCommits).toBe(12); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +npm test +``` + +Expected: FAIL — `Failed to resolve import "@/lib/metrics/heatmap"`. + +- [ ] **Step 3: Write the implementation** + +`src/lib/metrics/heatmap.ts`: + +```ts +/** + * The activity calendar grid. + * + * Weeks are columns and weekdays are rows, so the shape of a project's + * working rhythm is visible at a glance. This was the most legible element of + * the developer's original build log. + * + * Weeks start on Monday, matching the Mon/Wed/Fri row labels and the workday + * framing used everywhere else. + */ + +import { toIsoDate } from "@/lib/metrics/workdays"; + +/** Level 0 is "nothing"; 1..4 are increasing intensity. */ +export const HEATMAP_LEVELS = 5; + +const DAY_MS = 86_400_000; + +export interface HeatmapDay { + date: string; + count: number; + level: number; + /** False for padding days outside the project's span. */ + inRange: boolean; +} + +export interface Heatmap { + weeks: HeatmapDay[][]; + busiestCount: number; + totalCommits: number; +} + +function toUtc(isoDate: string): number { + return Date.UTC( + Number(isoDate.slice(0, 4)), + Number(isoDate.slice(5, 7)) - 1, + Number(isoDate.slice(8, 10)) + ); +} + +/** Monday of the week containing this date. */ +function mondayOf(time: number): number { + const day = new Date(time).getUTCDay(); + const back = day === 0 ? 6 : day - 1; + return time - back * DAY_MS; +} + +export function buildHeatmap( + activity: Record, + from: string, + to: string +): Heatmap { + if (!from || !to) return { weeks: [], busiestCount: 0, totalCommits: 0 }; + + const start = toUtc(from); + const end = toUtc(to); + if (end < start) return { weeks: [], busiestCount: 0, totalCommits: 0 }; + + let busiestCount = 0; + let totalCommits = 0; + for (const count of Object.values(activity)) { + totalCommits += count; + if (count > busiestCount) busiestCount = count; + } + + const weeks: HeatmapDay[][] = []; + + for (let cursor = mondayOf(start); cursor <= end; cursor += 7 * DAY_MS) { + const week: HeatmapDay[] = []; + + for (let i = 0; i < 7; i++) { + const time = cursor + i * DAY_MS; + const date = toIsoDate(time); + const inRange = time >= start && time <= end; + const count = inRange ? (activity[date] ?? 0) : 0; + + let level = 0; + if (count > 0 && busiestCount > 0) { + // Any day with work is at least level 1, so a day someone coded never + // looks identical to a day they did not — which a purely proportional + // scale would do whenever one day dwarfs the rest. + const share = count / busiestCount; + level = Math.max(1, Math.ceil(share * (HEATMAP_LEVELS - 1))); + } + + week.push({ date, count, level, inRange }); + } + + weeks.push(week); + } + + return { weeks, busiestCount, totalCommits }; +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +npm test +``` + +Expected: PASS — 8 tests in `heatmap.test.ts`. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/metrics/heatmap.ts src/lib/metrics/heatmap.test.ts +git commit -m "feat(metrics): activity calendar grid" +``` + +--- + +## Task 5: Schema and recomputation + +**Files:** +- Modify: `scripts/setup-remote.ts` +- Modify: `src/lib/pb/types.ts` +- Create: `src/lib/metrics/store.ts` +- Modify: `src/lib/git/ingest.ts` +- Create: `src/app/actions/metrics.ts` + +- [ ] **Step 1: Add the collection and the project constants** + +In `scripts/setup-remote.ts`, add before the rules block: + +```ts + // project_metrics — a computed cache, rebuilt on ingest. The dashboard reads + // this rather than aggregating hundreds of commit rows per page view. + const projectsIdForMetrics = (await pb.collections.getOne("projects")).id; + await getOrCreateCollection(pb, { + name: "project_metrics", + type: "base", + listRule: AUTHED, + viewRule: AUTHED, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: "project", + type: "relation", + required: true, + collectionId: projectsIdForMetrics, + cascadeDelete: true, + minSelect: 0, + maxSelect: 1, + }, + { name: "computed_at", type: "date", required: true }, + { name: "payload", type: "json", required: true, maxSize: 4_000_000 }, + { name: "created", type: "autodate", onCreate: true, onUpdate: false }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_metrics_project ON project_metrics (project)", + ], + }); +``` + +Extend `ensureProjectIngestFields` with the three session constants — now they +are read, so now they exist: + +```ts + { name: "session_gap_minutes", type: "number", required: false, onlyInt: true }, + { name: "pre_session_minutes", type: "number", required: false, onlyInt: true }, + { name: "post_session_minutes", type: "number", required: false, onlyInt: true }, +``` + +And add the read rule alongside the others: + +```ts + await applyRules(pb, "project_metrics", { + listRule: scopedRead("project.client"), + viewRule: scopedRead("project.client"), + createRule: null, + updateRule: null, + deleteRule: null, + }); +``` + +- [ ] **Step 2: Extend the record types** + +In `src/lib/pb/types.ts`, add to `ProjectRecord`: + +```ts + session_gap_minutes: number; + pre_session_minutes: number; + post_session_minutes: number; +``` + +and add: + +```ts +export interface ProjectMetricsRecord extends BaseRecord { + project: string; + computed_at: string; + /** A serialised ProjectMetrics from @/lib/metrics/compute. */ + payload: unknown; +} +``` + +- [ ] **Step 3: Write the store** + +`src/lib/metrics/store.ts`: + +```ts +import "server-only"; + +import { getAdminPb } from "@/lib/pb/admin"; +import { computeProjectMetrics, type ProjectMetrics } from "@/lib/metrics/compute"; +import { + DEFAULT_SESSION_CONSTANTS, + type SessionConstants, +} from "@/lib/metrics/sessions"; +import type { CommitRecord, ProjectMetricsRecord, ProjectRecord } from "@/lib/pb/types"; + +/** + * A project's tuning constants, falling back to the defaults. + * + * Zero and undefined both mean "not set". A project must never compute with a + * gap of zero minutes — every commit would become its own session and the + * estimate would be padding multiplied by commit count. + */ +export function constantsFor(project: Partial): SessionConstants { + return { + sessionGapMinutes: + project.session_gap_minutes || DEFAULT_SESSION_CONSTANTS.sessionGapMinutes, + preSessionMinutes: + project.pre_session_minutes || DEFAULT_SESSION_CONSTANTS.preSessionMinutes, + postSessionMinutes: + project.post_session_minutes || DEFAULT_SESSION_CONSTANTS.postSessionMinutes, + }; +} + +/** + * Recompute and cache a project's metrics. + * + * The constants used are stored inside the payload by computeProjectMetrics, so + * a figure the client is looking at can always be explained by the settings + * that produced it rather than by whatever they are when someone later asks. + */ +export async function recomputeMetrics(projectId: string): Promise { + const pb = await getAdminPb(); + + const project = await pb + .collection("projects") + .getOne(projectId); + + const commits = await pb.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "type,scope,authored_at,is_merge", + }); + + const metrics = computeProjectMetrics( + commits.map((c) => ({ + type: c.type ?? "other", + scope: c.scope ?? "", + authoredAt: c.authored_at, + isMerge: Boolean(c.is_merge), + })), + constantsFor(project) + ); + + const payload = { + project: projectId, + computed_at: new Date().toISOString(), + payload: metrics, + }; + + const existing = await pb + .collection("project_metrics") + .getFirstListItem(`project = "${projectId}"`) + .catch(() => null); + + if (existing) { + await pb.collection("project_metrics").update(existing.id, payload); + } else { + await pb.collection("project_metrics").create(payload); + } + + return metrics; +} +``` + +- [ ] **Step 4: Recompute after every ingest** + +In `src/lib/git/ingest.ts`, immediately after the `relinkProject` call added in +Plan 2b: + +```ts + const { recomputeMetrics } = await import("@/lib/metrics/store"); + await recomputeMetrics(project.id); +``` + +Same dynamic-import reasoning as the relink call. + +- [ ] **Step 5: Add a manual recompute action** + +`src/app/actions/metrics.ts`: + +```ts +"use server"; + +import { revalidatePath } from "next/cache"; + +import { requireSession } from "@/lib/auth/session"; +import { recomputeMetrics } from "@/lib/metrics/store"; + +/** + * Recompute on demand. + * + * Needed because the constants can change without any commit arriving, and + * because a client asking about a stale figure should be answerable in one + * click rather than by waiting for the next push. + */ +export async function recomputeMetricsAction(input: { + projectId: string; + clientSlug: string; + projectSlug: string; +}): Promise<{ commits: number }> { + const session = await requireSession(); + if (session.role !== "owner") throw new Error("Not found"); + + const metrics = await recomputeMetrics(input.projectId); + + revalidatePath(`/p/${input.clientSlug}/${input.projectSlug}/dashboard`); + return { commits: metrics.totalCommits }; +} +``` + +- [ ] **Step 6: Apply and verify** + +```bash +set -a && source .env.local && set +a && npm run setup-remote && npm run build +``` + +Expected: the script reports creating `project_metrics` and adding the three +constants; the build succeeds. + +- [ ] **Step 7: Commit** + +```bash +git add scripts/setup-remote.ts src/lib/pb/types.ts src/lib/metrics/store.ts src/lib/git/ingest.ts src/app/actions/metrics.ts +git commit -m "feat(metrics): cached project metrics recomputed on ingest" +``` + +--- + +## Task 6: Chart components + +Hand-written SVG. Three chart types are needed, all simple, and they must follow +the Phase 1b design tokens in both themes — which is exactly what a charting +library makes awkward. This also avoids a dependency to render four bars. + +**Files:** +- Create: `src/components/dashboard/activity-calendar.tsx` +- Create: `src/components/dashboard/bar-list.tsx` +- Create: `src/components/dashboard/month-bars.tsx` + +- [ ] **Step 1: Write the activity calendar** + +`src/components/dashboard/activity-calendar.tsx`: + +```tsx +import { buildHeatmap, HEATMAP_LEVELS } from "@/lib/metrics/heatmap"; + +const CELL = 11; +const GAP = 3; +const LABEL_WIDTH = 26; + +/** Level 0 is the empty-day colour; 1..4 step up in brand opacity. */ +function fill(level: number): string { + if (level === 0) return "var(--surface-raised)"; + const opacity = 0.25 + (level / (HEATMAP_LEVELS - 1)) * 0.75; + return `color-mix(in oklab, var(--brand) ${Math.round(opacity * 100)}%, transparent)`; +} + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +export function ActivityCalendar({ + activity, + from, + to, + activeDays, + workdays, +}: { + activity: Record; + from: string; + to: string; + activeDays: number; + workdays: number; +}) { + const grid = buildHeatmap(activity, from, to); + if (grid.weeks.length === 0) return null; + + const width = LABEL_WIDTH + grid.weeks.length * (CELL + GAP); + const height = 7 * (CELL + GAP) + 18; + + // A month label above the first week whose Monday falls in a new month. + const monthLabels: Array<{ x: number; label: string }> = []; + let lastMonth = ""; + grid.weeks.forEach((week, index) => { + const month = week[0].date.slice(0, 7); + if (month !== lastMonth) { + monthLabels.push({ + x: LABEL_WIDTH + index * (CELL + GAP), + label: MONTHS[Number(week[0].date.slice(5, 7)) - 1], + }); + lastMonth = month; + } + }); + + return ( +
+
+

+ Activity +

+

+ {activeDays} active of{" "} + {workdays} workdays · {from} to {to} +

+
+ +
+ + {monthLabels.map((label) => ( + + {label.label} + + ))} + + {["Mon", "Wed", "Fri"].map((name, i) => ( + + {name} + + ))} + + {grid.weeks.map((week, weekIndex) => + week.map((day, dayIndex) => { + if (!day.inRange) return null; + return ( + + + {day.date}: {day.count}{" "} + {day.count === 1 ? "commit" : "commits"} + + + ); + }) + )} + +
+ +
+ Less + {Array.from({ length: HEATMAP_LEVELS }, (_, level) => ( + + ))} + More +
+
+ ); +} +``` + +- [ ] **Step 2: Write the horizontal bar list** + +`src/components/dashboard/bar-list.tsx`: + +```tsx +import type { Tally } from "@/lib/metrics/compute"; + +/** + * Ranked horizontal bars, folded past `limit`. + * + * Folding rather than truncating: the remainder becomes an "other" row so the + * total still adds up. A chart whose bars sum to less than the headline figure + * invites exactly the question this dashboard exists to pre-empt. + */ +export function BarList({ + title, + items, + limit = 6, + emptyLabel = "Nothing yet", +}: { + title: string; + items: Tally[]; + limit?: number; + emptyLabel?: string; +}) { + const shown = items.slice(0, limit); + const rest = items.slice(limit).reduce((sum, item) => sum + item.count, 0); + const rows = rest > 0 ? [...shown, { key: "other", count: rest }] : shown; + const max = Math.max(1, ...rows.map((r) => r.count)); + + return ( +
+

+ {title} +

+ + {rows.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
    + {rows.map((row) => ( +
  • + + {row.key} + + + + + + {row.count} + +
  • + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: Write the monthly bars** + +`src/components/dashboard/month-bars.tsx`: + +```tsx +import type { MonthlyEffort } from "@/lib/metrics/compute"; + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +export function MonthBars({ monthly }: { monthly: MonthlyEffort[] }) { + if (monthly.length === 0) return null; + + const hours = monthly.map((m) => ({ + label: MONTHS[Number(m.month.slice(5, 7)) - 1], + hours: Math.round(m.minutes / 60), + })); + const max = Math.max(1, ...hours.map((h) => h.hours)); + + return ( +
+

+ Estimated hours by month +

+ +
+ {hours.map((month, index) => ( +
+ + {month.hours}h + +
+ {month.label} +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/components/dashboard +git commit -m "feat(dashboard): SVG activity calendar and bar charts" +``` + +--- + +## Task 7: KPI row and methodology panel + +**Files:** +- Create: `src/components/dashboard/kpi-row.tsx` +- Create: `src/components/dashboard/methodology.tsx` + +- [ ] **Step 1: Write the KPI row** + +`src/components/dashboard/kpi-row.tsx`: + +```tsx +import { Pill } from "@/components/ui/pill"; +import type { ProjectMetrics } from "@/lib/metrics/compute"; + +function Tile({ + label, + value, + sub, + estimated, +}: { + label: string; + value: string; + sub?: string; + estimated?: boolean; +}) { + return ( +
+
+

{label}

+ {/* The spec requires "estimated" in the UI, not only in a footnote: + these figures are shown to clients and have to survive scrutiny. */} + {estimated && estimated} +
+

+ {value} +

+ {sub &&

{sub}

} +
+ ); +} + +export function KpiRow({ metrics }: { metrics: ProjectMetrics }) { + const hours = Math.round(metrics.estimatedMinutes / 60); + const longestHours = (metrics.longestSessionMinutes / 60).toFixed(1); + + const activePercent = + metrics.workdays > 0 + ? Math.round((metrics.activeDays / metrics.workdays) * 100) + : 0; + + return ( +
+ 0 + ? `${metrics.mergeCommits} merges excluded` + : undefined + } + /> + + + +
+ ); +} +``` + +- [ ] **Step 2: Write the methodology panel** + +`src/components/dashboard/methodology.tsx`: + +```tsx +import type { ProjectMetrics } from "@/lib/metrics/compute"; + +/** + * Always rendered, never collapsed by default. + * + * Effort figures are shown to clients, so when one is questioned this panel is + * the entire answer. A methodology hidden behind a disclosure triangle is a + * methodology nobody reads, and an unexplained number on a client's screen is + * worse than no number. + */ +export function Methodology({ metrics }: { metrics: ProjectMetrics }) { + const { sessionGapMinutes, preSessionMinutes, postSessionMinutes } = + metrics.constants; + + return ( +
+

+ How these numbers are worked out +

+ +
+

+ Git records what changed and when, but not how long it took. Hours + here are estimated from + the timing of commits, not measured. +

+ +

+ Commits less than{" "} + {sessionGapMinutes}{" "} + minutes apart are treated as one working session. Each session counts + the time between its first and last commit, plus{" "} + {preSessionMinutes}{" "} + minutes before it — reading and planning happen before code is + committed — and{" "} + {postSessionMinutes}{" "} + minutes after, for testing and deploying. +

+ +

+ What this cannot see:{" "} + design, meetings, reading code, and debugging that ended without a + commit. The estimate is more likely to be low than high. +

+ +

+ Merge commits are excluded — a merge records when a branch was + integrated, not work done. + {metrics.mergeCommits > 0 && ( + <> + {" "} + {metrics.mergeCommits}{" "} + {metrics.mergeCommits === 1 ? "merge was" : "merges were"} left out. + + )} +

+ +

+ Active days are counted against{" "} + workdays — weekdays + excluding South African public holidays — rather than calendar days, + so weekends are not counted as time not worked. +

+
+
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/dashboard +git commit -m "feat(dashboard): KPI row and always-visible methodology panel" +``` + +--- + +## Task 8: The dashboard page + +**Files:** +- Create: `src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx` +- Create: `src/components/dashboard/recompute-button.tsx` +- Modify: `src/app/p/[clientSlug]/[projectSlug]/page.tsx` + +- [ ] **Step 1: Write the recompute button** + +`src/components/dashboard/recompute-button.tsx`: + +```tsx +"use client"; + +import { useState, useTransition } from "react"; +import { RefreshCw } from "lucide-react"; + +import { recomputeMetricsAction } from "@/app/actions/metrics"; + +export function RecomputeButton({ + projectId, + clientSlug, + projectSlug, +}: { + projectId: string; + clientSlug: string; + projectSlug: string; +}) { + const [pending, startTransition] = useTransition(); + const [done, setDone] = useState(null); + + return ( + + + {done && {done}} + + ); +} +``` + +- [ ] **Step 2: Write the page** + +`src/app/p/[clientSlug]/[projectSlug]/dashboard/page.tsx`: + +```tsx +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +import { AppShell } from "@/components/shell/app-shell"; +import { TopBar } from "@/components/shell/top-bar"; +import { KpiRow } from "@/components/dashboard/kpi-row"; +import { ActivityCalendar } from "@/components/dashboard/activity-calendar"; +import { BarList } from "@/components/dashboard/bar-list"; +import { MonthBars } from "@/components/dashboard/month-bars"; +import { Methodology } from "@/components/dashboard/methodology"; +import { RecomputeButton } from "@/components/dashboard/recompute-button"; +import { getSession } from "@/lib/auth/session"; +import { getServerPb } from "@/lib/pb/server"; +import { loadBoard } from "@/lib/data/board"; +import { relativeTime } from "@/lib/time/relative"; +import type { ProjectMetrics } from "@/lib/metrics/compute"; +import type { ProjectMetricsRecord } from "@/lib/pb/types"; + +export default async function DashboardPage({ + params, +}: { + params: Promise<{ clientSlug: string; projectSlug: string }>; +}) { + const session = await getSession(); + if (!session) redirect("/login"); + + const { clientSlug, projectSlug } = await params; + const board = await loadBoard(clientSlug, projectSlug); + if (!board) notFound(); + + const pb = await getServerPb(); + const cached = await pb + .collection("project_metrics") + .getFirstListItem(`project = "${board.project.id}"`) + .catch(() => null); + + const metrics = cached?.payload as ProjectMetrics | undefined; + const boardHref = `/p/${clientSlug}/${projectSlug}`; + + return ( + + + {session.role === "owner" && ( + + )} + + +
+
+
+ + + Back to board + + {/* Staleness is shown. A dashboard quietly twelve hours behind, + with no indication, is worse than one that says so. */} + {cached && ( + + Updated {relativeTime(cached.computed_at)} + + )} +
+ + {!metrics || metrics.totalCommits === 0 ? ( +
+

+ No commit history yet +

+

+ {session.role === "owner" + ? "Link a repository on this project's admin page and run a backfill." + : "This project's history has not been connected yet."} +

+
+ ) : ( + <> + + + + + + +
+ + +
+ + + + )} +
+
+
+ ); +} +``` + +- [ ] **Step 3: Link it from the board** + +In `src/app/p/[clientSlug]/[projectSlug]/page.tsx`, add inside ``, +before the existing links: + +```tsx + + Dashboard + +``` + +- [ ] **Step 4: Verify** + +```bash +npm run build +``` + +Expected: build succeeds; the route list includes +`/p/[clientSlug]/[projectSlug]/dashboard`. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/p src/components/dashboard +git commit -m "feat(dashboard): project dashboard page" +``` + +--- + +## Task 9: Integration tests + +**Files:** +- Create: `tests/integration/metrics.test.ts` + +These import `@/lib/metrics/store`, which carries `import "server-only"`. That +package throws on import unless the `react-server` export condition is set — +Plan 2b Task 7 Step 1 adds it to the integration project in `vitest.config.ts`. +If these tests fail with *"This module cannot be imported from a Client +Component module"*, that config change is missing. + +- [ ] **Step 1: Write the tests** + +`tests/integration/metrics.test.ts`: + +```ts +import { describe, it, expect, beforeAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers"; + +let admin: PocketBase; +let projectId: string; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; +}); + +describe("cached metrics agree with the stored commits", () => { + it("counts the same non-merge commits the database holds", async () => { + const { recomputeMetrics } = await import("@/lib/metrics/store"); + const metrics = await recomputeMetrics(projectId); + + const all = await admin.collection("commits").getFullList({ + filter: `project = "${projectId}"`, + fields: "is_merge", + }); + const merges = all.filter((c) => c.is_merge).length; + + expect(metrics.totalCommits).toBe(all.length - merges); + expect(metrics.mergeCommits).toBe(merges); + }); + + it("stores the constants alongside the figures", async () => { + const cached = await admin + .collection("project_metrics") + .getFirstListItem(`project = "${projectId}"`); + const payload = cached.payload as { constants?: Record }; + + expect(payload.constants).toBeDefined(); + expect(payload.constants!.sessionGapMinutes).toBeGreaterThan(0); + }); + + it("never estimates fewer minutes than sessions times padding", async () => { + // A sanity floor: every session contributes at least its padding, so a + // total below that means clustering silently dropped commits. + const cached = await admin + .collection("project_metrics") + .getFirstListItem(`project = "${projectId}"`); + const p = cached.payload as { + estimatedMinutes: number; + sessionCount: number; + constants: { preSessionMinutes: number; postSessionMinutes: number }; + }; + + const floor = + p.sessionCount * (p.constants.preSessionMinutes + p.constants.postSessionMinutes); + expect(p.estimatedMinutes).toBeGreaterThanOrEqual(floor); + }); +}); + +describe("the dashboard is visible to a client manager", () => { + it("renders with the methodology panel", async () => { + // Effort figures are shown to clients by explicit decision, so the panel + // that explains them must render for them too. + const cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + const response = await fetch( + `${APP_URL}/p/${DEMO.clientSlug}/${DEMO.projectSlug}/dashboard`, + { headers: { cookie } } + ); + + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("How these numbers are worked out"); + expect(html.toLowerCase()).toContain("estimated"); + }); +}); +``` + +- [ ] **Step 2: Run the integration suite** + +With `npm run dev` running: + +```bash +set -a && source .env.local && set +a && npm run test:integration +``` + +Expected: PASS — 38 tests (34 after Plan 2b, plus 4 here). + +- [ ] **Step 3: Commit** + +```bash +git add tests/integration/metrics.test.ts +git commit -m "test(metrics): cached figures agree with stored commits" +``` + +--- + +## Task 10: Full verification + +**This task is work, not a summary. Run every command and act on what it prints.** + +- [ ] **Step 1: Run every test** + +```bash +set -a && source .env.local && set +a && npm run test:all +``` + +Expected: PASS. Unit tests total **198** — the 159 after Plan 2b, plus 9 in +`workdays.test.ts`, 11 in `sessions.test.ts`, 11 in `compute.test.ts` and 8 in +`heatmap.test.ts`. Integration tests total 38. + +- [ ] **Step 2: Verify the production build** + +```bash +npm run build +``` + +Expected: no type errors; the route list includes the dashboard. + +- [ ] **Step 3: Compare against the Hermes build log** + +Open the dashboard for the project holding WFDASH-WEBAPP's history. + +The developer's hand-built log, for 24 May → 8 Sep 2026, reported: 776 commits, +377 features, 156 fixes, 60 active days, longest session 9.8 h. The window here +is the full repository rather than that slice, so the totals will be higher — +but the **shape** must match: + +- `feat` far ahead of `fix`, then `docs`, `test`, `refactor`, `chore` +- feature areas resembling the build log's scope breakdown +- active days a clear majority of workdays + +A figure that is wildly different — hours off by 5×, or active days exceeding +workdays — means a real bug. **Active days can never exceed workdays** unless +commits landed at weekends, which is worth knowing either way. + +- [ ] **Step 4: Check both themes** + +Open the dashboard in light and in dark. + +Expected: heatmap cells are distinguishable at every level in both, bar fills +read against their tracks, and the "estimated" pills are legible. The heatmap +uses `color-mix` against `--brand`, so a palette change must not wash it out. + +- [ ] **Step 5: Run the colour check** + +```bash +grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app +``` + +Expected: no output. + +- [ ] **Step 6: Confirm the client's view** + +Sign in as the client manager and open the dashboard. + +Expected: it renders in full, including hours and the methodology panel, and +**no monetary figure appears anywhere**. + +- [ ] **Step 7: Commit any fixes** + +```bash +git add -A +git commit -m "fix: issues found during Phase 2c verification" +``` + +--- + +## Definition of done + +1. `npm run test:all` passes: 198 unit, 38 integration. +2. `npm run build` succeeds with no type errors. +3. `countWorkdays("2026-05-24", "2026-09-08")` returns 75 and + `countCalendarDays` returns 108 — the Hermes anchor. +4. The dashboard's commit total equals stored non-merge commits, and merges are + excluded everywhere. +5. Every effort figure carries "estimated" in the UI. +6. The methodology panel renders for both roles and states the constants + actually used for that computation. +7. Staleness is shown via `computed_at`. +8. No monetary figure appears in any view. +9. Both themes render the heatmap and bars legibly. + +## Deliberately not in this plan + +- **Per-card effort on the card detail page.** The linking exists from Plan 2b + and the session maths exists here, but per-card hours need the caveat from + spec §5.4 — sessions touching several cards contribute their padding to each, + so per-card hours sum to more than the project total. That needs stating + wherever it appears, and it belongs with Phase 3's card-level commit history. +- **Editing the session constants in the UI.** They are on the schema and are + read; changing them means editing the project record. A UI for them should + arrive with a warning about rewriting history a client has already seen. +- **Sparklines in the KPI tiles.** The second reference image has them; they + need a per-period series this plan does not compute. Add once the shape of + the data is settled. +- **AI humanisation** — Phase 3. This plan renders raw conventional-commit + subjects and translates nothing.