feat(metrics): project metric aggregation from commits
This commit is contained in:
parent
b4d4a3bcb6
commit
b379669db3
2 changed files with 300 additions and 0 deletions
141
src/lib/metrics/compute.test.ts
Normal file
141
src/lib/metrics/compute.test.ts
Normal 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
159
src/lib/metrics/compute.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue