feat(metrics): activity calendar grid
This commit is contained in:
parent
b379669db3
commit
dbf524b04c
2 changed files with 162 additions and 0 deletions
69
src/lib/metrics/heatmap.test.ts
Normal file
69
src/lib/metrics/heatmap.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
93
src/lib/metrics/heatmap.ts
Normal file
93
src/lib/metrics/heatmap.ts
Normal file
|
|
@ -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<string, number>,
|
||||
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 };
|
||||
}
|
||||
Loading…
Reference in a new issue