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:
parent
265d623220
commit
b4d4a3bcb6
2 changed files with 275 additions and 0 deletions
138
src/lib/metrics/sessions.test.ts
Normal file
138
src/lib/metrics/sessions.test.ts
Normal 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
137
src/lib/metrics/sessions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue