diff --git a/src/lib/metrics/heatmap.test.ts b/src/lib/metrics/heatmap.test.ts new file mode 100644 index 0000000..44e0786 --- /dev/null +++ b/src/lib/metrics/heatmap.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; +import { buildHeatmap, HEATMAP_LEVELS } from "@/lib/metrics/heatmap"; + +describe("buildHeatmap", () => { + it("returns no weeks for an empty range", () => { + expect(buildHeatmap({}, "", "").weeks).toEqual([]); + }); + + it("starts each week on Monday", () => { + // 2026-09-01 is a Tuesday. The first column must begin on Monday the 31st + // so rows line up with the weekday labels. + const grid = buildHeatmap({ "2026-09-01": 1 }, "2026-09-01", "2026-09-07"); + expect(grid.weeks[0][0].date).toBe("2026-08-31"); + expect(grid.weeks[0][0].inRange).toBe(false); + expect(grid.weeks[0][1].date).toBe("2026-09-01"); + expect(grid.weeks[0][1].inRange).toBe(true); + }); + + it("gives every week exactly seven days", () => { + const grid = buildHeatmap({}, "2026-05-24", "2026-09-08"); + expect(grid.weeks.every((w) => w.length === 7)).toBe(true); + }); + + it("carries the commit count onto the right day", () => { + const grid = buildHeatmap({ "2026-09-02": 5 }, "2026-09-01", "2026-09-07"); + const day = grid.weeks.flat().find((d) => d.date === "2026-09-02"); + expect(day?.count).toBe(5); + }); + + it("assigns level 0 to a day with no commits", () => { + const grid = buildHeatmap({ "2026-09-02": 5 }, "2026-09-01", "2026-09-07"); + const day = grid.weeks.flat().find((d) => d.date === "2026-09-03"); + expect(day?.count).toBe(0); + expect(day?.level).toBe(0); + }); + + it("scales levels against the busiest day", () => { + const grid = buildHeatmap( + { "2026-09-01": 1, "2026-09-02": 10, "2026-09-03": 20 }, + "2026-09-01", + "2026-09-07" + ); + const byDate = new Map(grid.weeks.flat().map((d) => [d.date, d])); + expect(byDate.get("2026-09-03")!.level).toBe(HEATMAP_LEVELS - 1); + expect(byDate.get("2026-09-01")!.level).toBe(1); + expect(byDate.get("2026-09-02")!.level).toBeGreaterThan(1); + }); + + it("gives any day with commits at least level 1", () => { + // A day someone worked must never look identical to a day they did not. + const grid = buildHeatmap( + { "2026-09-01": 1, "2026-09-02": 500 }, + "2026-09-01", + "2026-09-07" + ); + const day = grid.weeks.flat().find((d) => d.date === "2026-09-01"); + expect(day?.level).toBe(1); + }); + + it("reports the busiest day and total", () => { + const grid = buildHeatmap( + { "2026-09-01": 3, "2026-09-02": 9 }, + "2026-09-01", + "2026-09-07" + ); + expect(grid.busiestCount).toBe(9); + expect(grid.totalCommits).toBe(12); + }); +}); \ No newline at end of file diff --git a/src/lib/metrics/heatmap.ts b/src/lib/metrics/heatmap.ts new file mode 100644 index 0000000..3f5993d --- /dev/null +++ b/src/lib/metrics/heatmap.ts @@ -0,0 +1,93 @@ +/** + * The activity calendar grid. + * + * Weeks are columns and weekdays are rows, so the shape of a project's + * working rhythm is visible at a glance. This was the most legible element of + * the developer's original build log. + * + * Weeks start on Monday, matching the Mon/Wed/Fri row labels and the workday + * framing used everywhere else. + */ + +import { toIsoDate } from "@/lib/metrics/workdays"; + +/** Level 0 is "nothing"; 1..4 are increasing intensity. */ +export const HEATMAP_LEVELS = 5; + +const DAY_MS = 86_400_000; + +export interface HeatmapDay { + date: string; + count: number; + level: number; + /** False for padding days outside the project's span. */ + inRange: boolean; +} + +export interface Heatmap { + weeks: HeatmapDay[][]; + busiestCount: number; + totalCommits: number; +} + +function toUtc(isoDate: string): number { + return Date.UTC( + Number(isoDate.slice(0, 4)), + Number(isoDate.slice(5, 7)) - 1, + Number(isoDate.slice(8, 10)) + ); +} + +/** Monday of the week containing this date. */ +function mondayOf(time: number): number { + const day = new Date(time).getUTCDay(); + const back = day === 0 ? 6 : day - 1; + return time - back * DAY_MS; +} + +export function buildHeatmap( + activity: Record, + from: string, + to: string +): Heatmap { + if (!from || !to) return { weeks: [], busiestCount: 0, totalCommits: 0 }; + + const start = toUtc(from); + const end = toUtc(to); + if (end < start) return { weeks: [], busiestCount: 0, totalCommits: 0 }; + + let busiestCount = 0; + let totalCommits = 0; + for (const count of Object.values(activity)) { + totalCommits += count; + if (count > busiestCount) busiestCount = count; + } + + const weeks: HeatmapDay[][] = []; + + for (let cursor = mondayOf(start); cursor <= end; cursor += 7 * DAY_MS) { + const week: HeatmapDay[] = []; + + for (let i = 0; i < 7; i++) { + const time = cursor + i * DAY_MS; + const date = toIsoDate(time); + const inRange = time >= start && time <= end; + const count = inRange ? (activity[date] ?? 0) : 0; + + let level = 0; + if (count > 0 && busiestCount > 0) { + // Any day with work is at least level 1, so a day someone coded never + // looks identical to a day they did not — which a purely proportional + // scale would do whenever one day dwarfs the rest. + const share = count / busiestCount; + level = Math.max(1, Math.ceil(share * (HEATMAP_LEVELS - 1))); + } + + week.push({ date, count, level, inRange }); + } + + weeks.push(week); + } + + return { weeks, busiestCount, totalCommits }; +} \ No newline at end of file