diff --git a/src/lib/metrics/compute.test.ts b/src/lib/metrics/compute.test.ts new file mode 100644 index 0000000..f0cfdb1 --- /dev/null +++ b/src/lib/metrics/compute.test.ts @@ -0,0 +1,141 @@ +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); + }); +}); \ No newline at end of file diff --git a/src/lib/metrics/compute.ts b/src/lib/metrics/compute.ts new file mode 100644 index 0000000..faa924f --- /dev/null +++ b/src/lib/metrics/compute.ts @@ -0,0 +1,159 @@ +/** + * 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, + }; +} \ No newline at end of file