diff --git a/src/lib/metrics/sessions.test.ts b/src/lib/metrics/sessions.test.ts new file mode 100644 index 0000000..4179e05 --- /dev/null +++ b/src/lib/metrics/sessions.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_SESSION_CONSTANTS, + clusterSessions, + summariseSessions, +} from "@/lib/metrics/sessions"; + +const C = DEFAULT_SESSION_CONSTANTS; +const PADDING = C.preSessionMinutes + C.postSessionMinutes; // 65 + +function at(iso: string) { + return { authoredAt: iso }; +} + +describe("clusterSessions", () => { + it("returns nothing for no commits", () => { + expect(clusterSessions([], C)).toEqual([]); + }); + + it("gives a lone commit only the padding", () => { + // A single commit spans no time at all, so its estimate is entirely the + // assumed work before and after it. + const [session] = clusterSessions([at("2026-09-01T10:00:00Z")], C); + expect(session.commitCount).toBe(1); + expect(session.minutes).toBe(PADDING); + }); + + it("adds padding to the span of a multi-commit session", () => { + const [session] = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T10:30:00Z")], + C + ); + expect(session.commitCount).toBe(2); + expect(session.minutes).toBe(30 + PADDING); + }); + + it("splits when the gap reaches the threshold", () => { + // Strictly less than the gap means "same session", so a gap of exactly + // SESSION_GAP_MINUTES starts a new one. Defining the boundary matters: + // an undefined one drifts between implementations. + const exactly = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T12:00:00Z")], + C + ); + expect(exactly).toHaveLength(2); + + const justUnder = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T11:59:00Z")], + C + ); + expect(justUnder).toHaveLength(1); + }); + + it("sorts unordered input before clustering", () => { + const sessions = clusterSessions( + [at("2026-09-01T10:30:00Z"), at("2026-09-01T10:00:00Z")], + C + ); + expect(sessions).toHaveLength(1); + expect(sessions[0].minutes).toBe(30 + PADDING); + }); + + it("handles PocketBase's space-separated timestamps", () => { + const sessions = clusterSessions( + [at("2026-09-01 10:00:00.000Z"), at("2026-09-01 10:30:00.000Z")], + C + ); + expect(sessions).toHaveLength(1); + expect(sessions[0].minutes).toBe(30 + PADDING); + }); + + it("records the day of the session's first commit", () => { + // A session that runs past midnight belongs to the day it started on, so + // the activity calendar does not split one night's work across two cells. + const [session] = clusterSessions( + [at("2026-09-01T23:30:00Z"), at("2026-09-02T00:15:00Z")], + C + ); + expect(session.day).toBe("2026-09-01"); + expect(session.commitCount).toBe(2); + }); + + it("respects custom constants", () => { + const tight = { sessionGapMinutes: 30, preSessionMinutes: 0, postSessionMinutes: 0 }; + const sessions = clusterSessions( + [at("2026-09-01T10:00:00Z"), at("2026-09-01T10:45:00Z")], + tight + ); + expect(sessions).toHaveLength(2); + expect(sessions[0].minutes).toBe(0); + }); +}); + +describe("summariseSessions", () => { + it("is all zeroes for no sessions", () => { + expect(summariseSessions([])).toEqual({ + sessionCount: 0, + totalMinutes: 0, + longestMinutes: 0, + longestCommits: 0, + longestDay: "", + }); + }); + + it("totals the minutes and finds the longest session", () => { + const sessions = clusterSessions( + [ + at("2026-09-01T10:00:00Z"), + at("2026-09-01T10:30:00Z"), + // three hours later: a new session + at("2026-09-01T13:30:00Z"), + // 90 minutes later — still within the 120-minute gap, same session + at("2026-09-01T15:00:00Z"), + ], + C + ); + const summary = summariseSessions(sessions); + + expect(summary.sessionCount).toBe(2); + expect(summary.totalMinutes).toBe(30 + PADDING + (90 + PADDING)); + expect(summary.longestMinutes).toBe(90 + PADDING); + expect(summary.longestCommits).toBe(2); + expect(summary.longestDay).toBe("2026-09-01"); + }); + + it("keeps the earliest session when two are equally long", () => { + const sessions = clusterSessions( + [ + at("2026-09-01T10:00:00Z"), + at("2026-09-01T10:30:00Z"), + at("2026-09-05T10:00:00Z"), + at("2026-09-05T10:30:00Z"), + ], + C + ); + expect(summariseSessions(sessions).longestDay).toBe("2026-09-01"); + }); +}); \ No newline at end of file diff --git a/src/lib/metrics/sessions.ts b/src/lib/metrics/sessions.ts new file mode 100644 index 0000000..b9e45cb --- /dev/null +++ b/src/lib/metrics/sessions.ts @@ -0,0 +1,137 @@ +/** + * Effort estimation by session clustering. + * + * Git records no duration. This module is the whole answer to "where did eight + * hours come from?", so its rules are explicit and its boundaries are defined + * rather than emergent. + * + * Commits close together in time are one working session. A session's estimate + * is the span between its first and last commit, plus padding at each end: work + * begins before the first commit lands and continues after the last. The + * developer's own hand-built log made this mistake in its first version — + * 164.6 h with no padding, revised to 218.7 h — because a session that ends at + * its last commit assumes work stops the instant code is committed, which is + * never true. + * + * What this cannot see: design, meetings, reading, and debugging that ended in + * a revert. It undercounts, and the dashboard says so. + * + * Merge commits must be filtered out by the caller. They are not units of work + * and they land at whatever moment a branch was integrated, which drags session + * boundaries around. + */ + +export interface SessionConstants { + sessionGapMinutes: number; + preSessionMinutes: number; + postSessionMinutes: number; +} + +export const DEFAULT_SESSION_CONSTANTS: SessionConstants = { + /** Two hours between commits is a break, not a pause. */ + sessionGapMinutes: 120, + /** Reading, reproducing and planning before the first commit lands. */ + preSessionMinutes: 45, + /** Testing, merging and deploying after the last one. */ + postSessionMinutes: 20, +}; + +export interface SessionCommit { + authoredAt: string; +} + +export interface Session { + start: string; + end: string; + /** UTC date of the session's first commit. */ + day: string; + commitCount: number; + minutes: number; +} + +export interface SessionSummary { + sessionCount: number; + totalMinutes: number; + longestMinutes: number; + longestCommits: number; + longestDay: string; +} + +/** PocketBase returns "2026-09-01 10:00:00.000Z"; Date.parse wants the T. */ +function parse(value: string): number { + return new Date(value.replace(" ", "T")).getTime(); +} + +export function clusterSessions( + commits: SessionCommit[], + constants: SessionConstants = DEFAULT_SESSION_CONSTANTS +): Session[] { + if (commits.length === 0) return []; + + const times = commits + .map((c) => parse(c.authoredAt)) + .filter((t) => Number.isFinite(t)) + .sort((a, b) => a - b); + + if (times.length === 0) return []; + + const gapMs = constants.sessionGapMinutes * 60_000; + const padding = constants.preSessionMinutes + constants.postSessionMinutes; + + const sessions: Session[] = []; + let startIndex = 0; + + const flush = (endIndex: number) => { + const start = times[startIndex]; + const end = times[endIndex]; + sessions.push({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + day: new Date(start).toISOString().slice(0, 10), + commitCount: endIndex - startIndex + 1, + minutes: Math.round((end - start) / 60_000) + padding, + }); + }; + + for (let i = 1; i < times.length; i++) { + // Strictly less than the gap continues the session; exactly the gap starts + // a new one. The boundary is defined here so it cannot drift. + if (times[i] - times[i - 1] >= gapMs) { + flush(i - 1); + startIndex = i; + } + } + flush(times.length - 1); + + return sessions; +} + +export function summariseSessions(sessions: Session[]): SessionSummary { + if (sessions.length === 0) { + return { + sessionCount: 0, + totalMinutes: 0, + longestMinutes: 0, + longestCommits: 0, + longestDay: "", + }; + } + + let totalMinutes = 0; + let longest = sessions[0]; + + for (const session of sessions) { + totalMinutes += session.minutes; + // Strictly greater, so the earliest of equally long sessions is kept and + // the reported "longest day" is stable across recomputations. + if (session.minutes > longest.minutes) longest = session; + } + + return { + sessionCount: sessions.length, + totalMinutes, + longestMinutes: longest.minutes, + longestCommits: longest.commitCount, + longestDay: longest.day, + }; +} \ No newline at end of file