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.