feat(metrics): fold manually logged minutes into project totals
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4d1e6cf412
commit
284385ca2b
2 changed files with 67 additions and 3 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue