feat(metrics): project metric aggregation from commits

This commit is contained in:
Dave 2026-09-16 13:45:56 +02:00
parent b4d4a3bcb6
commit b379669db3
2 changed files with 300 additions and 0 deletions

View file

@ -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>): 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);
});
});

159
src/lib/metrics/compute.ts Normal file
View file

@ -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<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,
};
}