feat(metrics): session clustering with defined boundaries

The plan's summariseSessions test expected 2 sessions for four commits
spanning two 3-hour gaps, but with the default 120-minute gap three hours
is too far and produces 3 sessions. The implementation matches the boundary
rule defined in the plan (strictly less than the gap continues a session);
the test was off. Adjusted to a 90-minute follow-up which keeps the same
session and verifies total + longest correctly.
This commit is contained in:
Dave 2026-09-16 13:45:01 +02:00
parent 265d623220
commit b4d4a3bcb6
2 changed files with 275 additions and 0 deletions

View file

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

137
src/lib/metrics/sessions.ts Normal file
View file

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