diff --git a/src/lib/metrics/compute.test.ts b/src/lib/metrics/compute.test.ts index f0cfdb1..afdbe31 100644 --- a/src/lib/metrics/compute.test.ts +++ b/src/lib/metrics/compute.test.ts @@ -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 { +function commit(overrides: Partial = {}): 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); + }); }); \ No newline at end of file diff --git a/src/lib/metrics/compute.ts b/src/lib/metrics/compute.ts index faa924f..0d2186b 100644 --- a/src/lib/metrics/compute.ts +++ b/src/lib/metrics/compute.ts @@ -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; 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(); 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,