import { describe, expect, test } from "bun:test"; import type { ProjectInfo, RollupDelta, RollupEvent } from "./rollups"; import { computeRollupDeltas, getBucketStarts, getDisplayBucketForRollupRow, mergeRollupDeltas, } from "./rollups"; const DAY_S = 86_400; const HOUR_S = 3_600; function utcSec( y: number, m: number, d: number, h = 0, min = 0, s = 0, ): number { return Date.UTC(y, m, d, h, min, s) / 1000; } describe("getDisplayBucketForRollupRow", () => { test("row coarser than display grid passes through unchanged (day rows behind 4h request)", () => { // 14-day range → 4h display buckets, but the finest rollups are days. // The old epoch floor collapsed each day into one 4h cell at midnight // UTC plus a 20h gap; pass-through keeps the full day covered. const start = utcSec(2026, 0, 5); const out = getDisplayBucketForRollupRow(start, start + DAY_S, 4 * HOUR_S); expect(out).toEqual({ start, end: start + DAY_S }); }); test("hour rows group evenly into sub-day buckets via epoch floor", () => { const h13 = utcSec(2026, 0, 5, 13); const out = getDisplayBucketForRollupRow(h13, h13 + HOUR_S, 2 * HOUR_S); expect(out).toEqual({ start: utcSec(2026, 0, 5, 12), end: utcSec(2026, 0, 5, 14), }); }); test("consecutive calendar months tile a 30d display grid with no gaps", () => { // 1–5y ranges request 30d buckets; calendar months drift against a // fixed epoch grid (28-day Feb), eventually leaving an empty grid cell. // Calendar-aware mapping keeps months contiguous. const months: { start: number; end: number }[] = []; for (let m = 0; m < 12; m++) { const start = utcSec(2026, m, 1); const end = utcSec(2026, m + 1, 1); months.push(getDisplayBucketForRollupRow(start, end, 30 * DAY_S)); } for (let i = 1; i < months.length; i++) { expect(months[i].start).toBe(months[i - 1].end); } // Short February passes through as its own calendar month expect(months[1]).toEqual({ start: utcSec(2026, 1, 1), end: utcSec(2026, 2, 1), }); }); test("months group into contiguous calendar quarters for ~90d buckets", () => { const quarters = new Set(); for (let m = 0; m < 12; m++) { const start = utcSec(2026, m, 1); const end = utcSec(2026, m + 1, 1); const q = getDisplayBucketForRollupRow(start, end, 90 * DAY_S); quarters.add(`${q.start}:${q.end}`); } expect(quarters.size).toBe(4); const sorted = [...quarters] .map((k) => k.split(":").map(Number)) .sort((a, b) => a[0] - b[0]); for (let i = 1; i < sorted.length; i++) { expect(sorted[i][0]).toBe(sorted[i - 1][1]); } expect(sorted[0][0]).toBe(utcSec(2026, 0, 1)); expect(sorted[3][1]).toBe(utcSec(2027, 0, 1)); }); test("day rows group onto Monday-aligned weeks matching rollup week boundaries", () => { // 2026-06-10 is a Wednesday; its rollup week starts Monday 2026-06-08. const wedStart = utcSec(2026, 5, 10); const out = getDisplayBucketForRollupRow( wedStart, wedStart + DAY_S, 7 * DAY_S, ); const expected = getBucketStarts(wedStart).week; expect(out).toEqual({ start: expected.start, end: expected.end }); expect(new Date(out.start * 1000).getUTCDay()).toBe(1); }); test("week rows pass through on a 7d display grid", () => { const monday = getBucketStarts(utcSec(2026, 5, 10)).week; const out = getDisplayBucketForRollupRow( monday.start, monday.end, 7 * DAY_S, ); expect(out).toEqual({ start: monday.start, end: monday.end }); }); }); describe("mergeRollupDeltas", () => { const projectLookup = new Map([ ["root-a", { id: "proj-a", name: "A", color: "c-a" }], ]); const mkEvent = ( y: number, m: number, d: number, source = "git", ): RollupEvent => ({ timestamp: new Date(Date.UTC(y, m, d, 12)), source, projectRootId: "root-a", estimatedMinutes: 10, }); const sortByKey = (deltas: RollupDelta[]): RollupDelta[] => [...deltas].sort((a, b) => `${a.granularity}:${a.bucketStart}:${a.projectId}`.localeCompare( `${b.granularity}:${b.bucketStart}:${b.projectId}`, ), ); test("windowed aggregation merges to the same totals as a single pass", () => { // A week straddling the Jan/Feb 2026 boundary: Jan 30 (Fri), Jan 31 (Sat), // Feb 1 (Sun) all fall in the Mon-Jan-26 week; Feb 2 (Mon) starts the next. const janEvents = [mkEvent(2026, 0, 30), mkEvent(2026, 0, 31)]; const febEvents = [mkEvent(2026, 1, 1), mkEvent(2026, 1, 2)]; const singlePass = computeRollupDeltas( [...janEvents, ...febEvents], "org-1", projectLookup, ); const windowed = mergeRollupDeltas([ computeRollupDeltas(janEvents, "org-1", projectLookup), computeRollupDeltas(febEvents, "org-1", projectLookup), ]); expect(sortByKey(windowed)).toEqual(sortByKey(singlePass)); // The straddling week bucket must carry all three of its events, proving the // split-across-windows contributions summed rather than overwrote. const weekStart = getBucketStarts( Math.floor(Date.UTC(2026, 0, 30, 12) / 1000), ).week.start; const weekBucket = windowed.find( (d) => d.granularity === "week" && d.bucketStart === weekStart, ); expect(weekBucket?.eventCount).toBe(3); expect(weekBucket?.estimatedMinutes).toBe(30); }); test("sums sourceCounts across batches for a shared bucket", () => { const batchA = computeRollupDeltas( [mkEvent(2026, 2, 3, "git")], "org-1", projectLookup, ); const batchB = computeRollupDeltas( [mkEvent(2026, 2, 3, "linear")], "org-1", projectLookup, ); const merged = mergeRollupDeltas([batchA, batchB]); const day = merged.find((d) => d.granularity === "day"); expect(day?.eventCount).toBe(2); expect(day?.sourceCounts).toEqual({ git: 1, linear: 1 }); }); test("returns an empty array for no batches", () => { expect(mergeRollupDeltas([])).toEqual([]); expect(mergeRollupDeltas([[], []])).toEqual([]); }); test("does not mutate the input delta batches", () => { const batch = computeRollupDeltas( [mkEvent(2026, 3, 4)], "org-1", projectLookup, ); const dayBefore = batch.find((d) => d.granularity === "day")?.eventCount; mergeRollupDeltas([batch, batch]); const dayAfter = batch.find((d) => d.granularity === "day")?.eventCount; expect(dayAfter).toBe(dayBefore); }); });