2b closes a gap between the spec and the schema: the spec's [#42] explicit link had nothing to reference, because cards only had PocketBase ids. Adds per-project card numbers, and sharpens two rules the spec left loose — a scope rule is bounded by start_date OR created, and ties go to the card with the latest bound rather than the most recently edited one. 2c builds the dashboard. The South African holiday table is verified against the Hermes build log: 24 May to 8 Sep 2026 gives 108 calendar days and 75 workdays, both exact, and that is now a regression test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
69 KiB
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:
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
npm test
Expected: FAIL — Failed to resolve import "@/lib/metrics/holidays".
- Step 3: Write the holiday table
src/lib/metrics/holidays.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:
/**
* 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
npm test
Expected: PASS — 9 tests in workdays.test.ts, including the 108/75 anchor.
- Step 6: Commit
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:
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
npm test
Expected: FAIL — Failed to resolve import "@/lib/metrics/sessions".
- Step 3: Write the implementation
src/lib/metrics/sessions.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
npm test
Expected: PASS — 11 tests in sessions.test.ts.
- Step 5: Commit
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:
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>): 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
npm test
Expected: FAIL — Failed to resolve import "@/lib/metrics/compute".
- Step 3: Write the implementation
src/lib/metrics/compute.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<string, number>;
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<string, number>): 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<string, number>();
const scopeCounts = new Map<string, number>();
const activity: Record<string, number> = {};
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<string, number>();
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
npm test
Expected: PASS — 11 tests in compute.test.ts.
- Step 5: Commit
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:
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
npm test
Expected: FAIL — Failed to resolve import "@/lib/metrics/heatmap".
- Step 3: Write the implementation
src/lib/metrics/heatmap.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<string, number>,
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
npm test
Expected: PASS — 8 tests in heatmap.test.ts.
- Step 5: Commit
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:
// 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:
{ 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:
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:
session_gap_minutes: number;
pre_session_minutes: number;
post_session_minutes: number;
and add:
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:
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<ProjectRecord>): 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<ProjectMetrics> {
const pb = await getAdminPb();
const project = await pb
.collection("projects")
.getOne<ProjectRecord>(projectId);
const commits = await pb.collection("commits").getFullList<CommitRecord>({
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<ProjectMetricsRecord>(`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:
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:
"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
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
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:
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<string, number>;
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 (
<section className="space-y-3">
<div className="flex items-baseline gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Activity
</h2>
<p className="text-xs text-text-muted">
<span className="tabular">{activeDays}</span> active of{" "}
<span className="tabular">{workdays}</span> workdays · {from} to {to}
</p>
</div>
<div className="overflow-x-auto">
<svg
width={width}
height={height}
role="img"
aria-label={`Commit activity: ${activeDays} active days out of ${workdays} workdays between ${from} and ${to}`}
>
{monthLabels.map((label) => (
<text
key={`${label.label}-${label.x}`}
x={label.x}
y={10}
className="fill-[var(--text-muted)] text-[9px]"
>
{label.label}
</text>
))}
{["Mon", "Wed", "Fri"].map((name, i) => (
<text
key={name}
x={0}
y={18 + (i * 2) * (CELL + GAP) + CELL - 1}
className="fill-[var(--text-muted)] text-[9px]"
>
{name}
</text>
))}
{grid.weeks.map((week, weekIndex) =>
week.map((day, dayIndex) => {
if (!day.inRange) return null;
return (
<rect
key={day.date}
x={LABEL_WIDTH + weekIndex * (CELL + GAP)}
y={18 + dayIndex * (CELL + GAP)}
width={CELL}
height={CELL}
rx={2}
fill={fill(day.level)}
>
<title>
{day.date}: {day.count}{" "}
{day.count === 1 ? "commit" : "commits"}
</title>
</rect>
);
})
)}
</svg>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-text-muted">
<span>Less</span>
{Array.from({ length: HEATMAP_LEVELS }, (_, level) => (
<span
key={level}
aria-hidden
className="inline-block h-2.5 w-2.5 rounded-[2px]"
style={{ background: fill(level) }}
/>
))}
<span>More</span>
</div>
</section>
);
}
- Step 2: Write the horizontal bar list
src/components/dashboard/bar-list.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 (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
{title}
</h2>
{rows.length === 0 ? (
<p className="text-sm text-text-muted">{emptyLabel}</p>
) : (
<ul className="space-y-1.5">
{rows.map((row) => (
<li key={row.key} className="flex items-center gap-3">
<span className="w-24 shrink-0 truncate text-xs text-text-secondary">
{row.key}
</span>
<span className="h-5 flex-1 overflow-hidden rounded bg-surface-raised">
<span
className="block h-full rounded bg-brand"
style={{ width: `${(row.count / max) * 100}%` }}
/>
</span>
<span className="tabular w-10 shrink-0 text-right text-xs text-text-muted">
{row.count}
</span>
</li>
))}
</ul>
)}
</section>
);
}
- Step 3: Write the monthly bars
src/components/dashboard/month-bars.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 (
<section className="space-y-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
Estimated hours by month
</h2>
<div className="flex h-40 items-end gap-2">
{hours.map((month, index) => (
<div key={`${month.label}-${index}`} className="flex flex-1 flex-col items-center gap-1.5">
<span className="tabular text-[11px] text-text-secondary">
{month.hours}h
</span>
<div
className="w-full rounded-t bg-brand"
style={{ height: `${Math.max(2, (month.hours / max) * 100)}%` }}
role="img"
aria-label={`${month.label}: about ${month.hours} estimated hours`}
/>
<span className="text-[11px] text-text-muted">{month.label}</span>
</div>
))}
</div>
</section>
);
}
- Step 4: Commit
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:
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 (
<div className="rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<div className="flex items-baseline gap-2">
<p className="text-xs text-text-muted">{label}</p>
{/* The spec requires "estimated" in the UI, not only in a footnote:
these figures are shown to clients and have to survive scrutiny. */}
{estimated && <Pill tone="neutral">estimated</Pill>}
</div>
<p className="tabular mt-2 text-3xl font-semibold tracking-tight text-text-primary">
{value}
</p>
{sub && <p className="mt-1 text-xs text-text-muted">{sub}</p>}
</div>
);
}
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 (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Tile
label="Commits"
value={String(metrics.totalCommits)}
sub={
metrics.mergeCommits > 0
? `${metrics.mergeCommits} merges excluded`
: undefined
}
/>
<Tile
label="Features shipped"
value={String(metrics.features)}
sub={`${metrics.fixes} ${metrics.fixes === 1 ? "fix" : "fixes"} along the way`}
/>
<Tile
label="Hours invested"
value={`${hours}h`}
sub={`across ${metrics.sessionCount} sessions · longest ${longestHours}h`}
estimated
/>
<Tile
label="Active days"
value={`${metrics.activeDays}/${metrics.workdays}`}
sub={`${activePercent}% of workdays · ${metrics.firstCommit} to ${metrics.lastCommit}`}
/>
</div>
);
}
- Step 2: Write the methodology panel
src/components/dashboard/methodology.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 (
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle bg-surface p-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-text-secondary">
How these numbers are worked out
</h2>
<div className="space-y-2.5 text-sm text-text-secondary">
<p>
Git records what changed and when, but not how long it took. Hours
here are <strong className="text-text-primary">estimated</strong> from
the timing of commits, not measured.
</p>
<p>
Commits less than{" "}
<span className="tabular text-text-primary">{sessionGapMinutes}</span>{" "}
minutes apart are treated as one working session. Each session counts
the time between its first and last commit, plus{" "}
<span className="tabular text-text-primary">{preSessionMinutes}</span>{" "}
minutes before it — reading and planning happen before code is
committed — and{" "}
<span className="tabular text-text-primary">{postSessionMinutes}</span>{" "}
minutes after, for testing and deploying.
</p>
<p>
<strong className="text-text-primary">What this cannot see:</strong>{" "}
design, meetings, reading code, and debugging that ended without a
commit. The estimate is more likely to be low than high.
</p>
<p>
Merge commits are excluded — a merge records when a branch was
integrated, not work done.
{metrics.mergeCommits > 0 && (
<>
{" "}
<span className="tabular">{metrics.mergeCommits}</span>{" "}
{metrics.mergeCommits === 1 ? "merge was" : "merges were"} left out.
</>
)}
</p>
<p>
Active days are counted against{" "}
<strong className="text-text-primary">workdays</strong> — weekdays
excluding South African public holidays — rather than calendar days,
so weekends are not counted as time not worked.
</p>
</div>
</section>
);
}
- Step 3: Commit
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:
"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<string | null>(null);
return (
<span className="flex items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await recomputeMetricsAction({
projectId,
clientSlug,
projectSlug,
});
setDone(`${result.commits} commits`);
})
}
className="inline-flex items-center gap-1.5 rounded-lg border border-border-subtle px-2.5 py-1.5 text-xs text-text-secondary hover:bg-surface-raised disabled:opacity-50"
>
<RefreshCw size={13} strokeWidth={2} />
{pending ? "Recomputing…" : "Recompute"}
</button>
{done && <span className="text-xs text-text-muted">{done}</span>}
</span>
);
}
- Step 2: Write the page
src/app/p/[clientSlug]/[projectSlug]/dashboard/page.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<ProjectMetricsRecord>(`project = "${board.project.id}"`)
.catch(() => null);
const metrics = cached?.payload as ProjectMetrics | undefined;
const boardHref = `/p/${clientSlug}/${projectSlug}`;
return (
<AppShell role={session.role} currentPath={boardHref}>
<TopBar title={board.project.name} subtitle={board.client.name}>
{session.role === "owner" && (
<RecomputeButton
projectId={board.project.id}
clientSlug={clientSlug}
projectSlug={projectSlug}
/>
)}
</TopBar>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="flex items-center gap-3">
<Link
href={boardHref}
className="inline-flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary"
>
<ArrowLeft size={13} strokeWidth={2} />
Back to board
</Link>
{/* Staleness is shown. A dashboard quietly twelve hours behind,
with no indication, is worse than one that says so. */}
{cached && (
<span className="text-xs text-text-muted">
Updated {relativeTime(cached.computed_at)}
</span>
)}
</div>
{!metrics || metrics.totalCommits === 0 ? (
<div className="rounded-[var(--radius-tile)] border border-dashed border-border-subtle p-10 text-center">
<h2 className="text-base font-medium text-text-primary">
No commit history yet
</h2>
<p className="mx-auto mt-1.5 max-w-sm text-sm text-text-muted">
{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."}
</p>
</div>
) : (
<>
<KpiRow metrics={metrics} />
<ActivityCalendar
activity={metrics.activity}
from={metrics.firstCommit}
to={metrics.lastCommit}
activeDays={metrics.activeDays}
workdays={metrics.workdays}
/>
<MonthBars monthly={metrics.monthly} />
<div className="grid gap-8 sm:grid-cols-2">
<BarList title="Commits by type" items={metrics.byType} />
<BarList
title="Feature areas"
items={metrics.byScope}
emptyLabel="No scoped commits yet"
/>
</div>
<Methodology metrics={metrics} />
</>
)}
</div>
</div>
</AppShell>
);
}
- Step 3: Link it from the board
In src/app/p/[clientSlug]/[projectSlug]/page.tsx, add inside <TopBar>,
before the existing links:
<Link
href={`/p/${clientSlug}/${projectSlug}/dashboard`}
className="text-xs text-text-muted hover:text-text-secondary"
>
Dashboard
</Link>
- Step 4: Verify
npm run build
Expected: build succeeds; the route list includes
/p/[clientSlug]/[projectSlug]/dashboard.
- Step 5: Commit
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:
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<string, number> };
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:
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
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
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
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:
featfar ahead offix, thendocs,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
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
git add -A
git commit -m "fix: issues found during Phase 2c verification"
Definition of done
npm run test:allpasses: 198 unit, 38 integration.npm run buildsucceeds with no type errors.countWorkdays("2026-05-24", "2026-09-08")returns 75 andcountCalendarDaysreturns 108 — the Hermes anchor.- The dashboard's commit total equals stored non-merge commits, and merges are excluded everywhere.
- Every effort figure carries "estimated" in the UI.
- The methodology panel renders for both roles and states the constants actually used for that computation.
- Staleness is shown via
computed_at. - No monetary figure appears in any view.
- 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.