feat(metrics): fold manually logged minutes into project totals

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-19 10:25:04 +02:00
parent 4d1e6cf412
commit 284385ca2b
2 changed files with 67 additions and 3 deletions

View file

@ -2,7 +2,7 @@ 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 {
function commit(overrides: Partial<MetricCommit> = {}): MetricCommit {
return {
type: "feat",
scope: "stock",
@ -138,4 +138,48 @@ describe("computeProjectMetrics", () => {
expect(m.constants).toEqual(custom);
expect(m.estimatedMinutes).toBe(15);
});
});
describe("computeProjectMetrics — manual entries", () => {
it("adds manual minutes to the total without clustering them", () => {
// One real commit alone forms one session: 0 span + 45 pre + 20 post = 65.
// The manual entry's 180 is added flat, regardless of how close its own
// timestamp sits to the real commit's — it is never fed to the clusterer.
const m = computeProjectMetrics(
[
commit({ authoredAt: "2026-09-01T10:00:00Z" }),
commit({ manualMinutes: 180, authoredAt: "2026-09-01T10:05:00Z" }),
],
DEFAULT_SESSION_CONSTANTS
);
expect(m.estimatedMinutes).toBe(65 + 180);
expect(m.manualMinutes).toBe(180);
});
it("still counts a manual entry toward totals, activity and type", () => {
const m = computeProjectMetrics(
[commit({ manualMinutes: 60, type: "chore", scope: "", authoredAt: "2026-09-01T10:00:00Z" })],
DEFAULT_SESSION_CONSTANTS
);
expect(m.totalCommits).toBe(1);
expect(m.activeDays).toBe(1);
expect(m.byType.find((t) => t.key === "chore")?.count).toBe(1);
});
it("folds a manual entry's minutes into its own month", () => {
const m = computeProjectMetrics(
[commit({ manualMinutes: 90, authoredAt: "2026-03-15T10:00:00Z" })],
DEFAULT_SESSION_CONSTANTS
);
expect(m.monthly).toEqual([{ month: "2026-03", minutes: 90 }]);
});
it("computes identically to before when nothing is manual", () => {
const withoutField = computeProjectMetrics([commit({}), commit({})], DEFAULT_SESSION_CONSTANTS);
const withUndefined = computeProjectMetrics(
[commit({ manualMinutes: undefined }), commit({ manualMinutes: undefined })],
DEFAULT_SESSION_CONSTANTS
);
expect(withUndefined).toEqual(withoutField);
});
});

View file

@ -18,6 +18,8 @@ export interface MetricCommit {
scope: string;
authoredAt: string;
isMerge: boolean;
/** Present only for a manually logged entry; minutes, never clustered. */
manualMinutes?: number;
}
export interface Tally {
@ -45,6 +47,9 @@ export interface ProjectMetrics {
/** ISO date -> commit count, for the activity calendar. */
activity: Record<string, number>;
estimatedMinutes: number;
/** The portion of estimatedMinutes logged directly rather than estimated
* from commit timing. */
manualMinutes: number;
sessionCount: number;
longestSessionMinutes: number;
longestSessionCommits: number;
@ -88,6 +93,7 @@ export function computeProjectMetrics(
activeDays: 0,
activity: {},
estimatedMinutes: 0,
manualMinutes: 0,
sessionCount: 0,
longestSessionMinutes: 0,
longestSessionCommits: 0,
@ -121,17 +127,30 @@ export function computeProjectMetrics(
if (d > last) last = d;
}
// A manually logged entry is a single declared block of time, not a series
// of commits with gaps to cluster — it is excluded from the clusterer and
// its minutes are added directly instead.
const clusterable = real.filter((c) => c.manualMinutes === undefined);
const sessions = clusterSessions(
real.map((c) => ({ authoredAt: c.authoredAt })),
clusterable.map((c) => ({ authoredAt: c.authoredAt })),
constants
);
const summary = summariseSessions(sessions);
const manualMinutes = real.reduce((sum, c) => sum + (c.manualMinutes ?? 0), 0);
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);
}
// A manual entry's minutes belong in its own month too, or the monthly
// chart would under-report a month relative to the KPI total that includes it.
for (const c of real) {
if (c.manualMinutes === undefined) continue;
const month = day(c.authoredAt).slice(0, 7);
monthlyMap.set(month, (monthlyMap.get(month) ?? 0) + c.manualMinutes);
}
return {
totalCommits: real.length,
@ -146,7 +165,8 @@ export function computeProjectMetrics(
workdays: countWorkdays(first, last),
activeDays: Object.keys(activity).length,
activity,
estimatedMinutes: summary.totalMinutes,
estimatedMinutes: summary.totalMinutes + manualMinutes,
manualMinutes,
sessionCount: summary.sessionCount,
longestSessionMinutes: summary.longestMinutes,
longestSessionCommits: summary.longestCommits,