import { describe, expect, test } from "bun:test"; import { calculateBillableMinutes, calculateBillableMinutesByProject, DEFAULT_GAP_THRESHOLD_MINUTES, summarizeActivity, } from "./time-ranges"; import { calculateTimelineStatsFromEvents } from "./timeline"; const at = (iso: string) => new Date(iso); describe("DEFAULT_GAP_THRESHOLD_MINUTES", () => { test("product default is 15 minutes", () => { expect(DEFAULT_GAP_THRESHOLD_MINUTES).toBe(15); }); }); describe("calculateBillableMinutes", () => { test("single event → one gap window", () => { expect( calculateBillableMinutes([ { id: "a", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A" }, ]), ).toBe(15); }); test("dense chat within one project collapses (not N×15)", () => { // 30 messages one minute apart → ~44 min with 15m tail, not 450 const events = Array.from({ length: 30 }, (_, i) => ({ id: String(i), timestamp: new Date(Date.UTC(2026, 0, 1, 10, 0, 0) + i * 60_000), projectId: "A", source: "claude", })); expect(calculateBillableMinutes(events)).toBe(44); expect(calculateBillableMinutes(events)).toBeLessThan(30 * 15); }); test("concurrent multi-project work is fully billable (sum, not unique wall-clock)", () => { // Two agents on different projects in the same wall-clock window: // A: 10:00, B: 10:05, A: 10:10 → per-project sessions sum to 40, not 25. const events = [ { id: "1", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", source: "claude", }, { id: "2", timestamp: at("2026-01-01T10:05:00Z"), projectId: "B", source: "claude", }, { id: "3", timestamp: at("2026-01-01T10:10:00Z"), projectId: "A", source: "git", }, ]; // Project A: 10:00→10:25 = 25; Project B: 10:05→10:20 = 15; total 40 expect(calculateBillableMinutes(events)).toBe(40); }); test("cross-source overlap within one project collapses", () => { const events = [ { id: "g", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", source: "git", }, { id: "c", timestamp: at("2026-01-01T10:05:00Z"), projectId: "A", source: "claude", }, ]; // 10:00→10:20 = 20, not 30 expect(calculateBillableMinutes(events)).toBe(20); }); test("manual events add estimatedMinutes", () => { expect( calculateBillableMinutes([ { id: "m", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", source: "manual", estimatedMinutes: 45, }, { id: "g", timestamp: at("2026-01-01T12:00:00Z"), projectId: "A", source: "git", }, ]), ).toBe(45 + 15); }); test("events without projectId group as unassigned (still gap-merged)", () => { expect( calculateBillableMinutes([ { id: "1", timestamp: at("2026-01-01T10:00:00Z") }, { id: "2", timestamp: at("2026-01-01T10:05:00Z") }, ]), ).toBe(20); }); test("byProject map sums to totalMinutes", () => { const events = [ { id: "1", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", source: "claude", }, { id: "2", timestamp: at("2026-01-01T10:05:00Z"), projectId: "B", source: "claude", }, ]; const byProject = calculateBillableMinutesByProject(events); expect(byProject.get("A")).toBe(15); expect(byProject.get("B")).toBe(15); let sum = 0; for (const m of byProject.values()) sum += m; expect(sum).toBe(calculateBillableMinutes(events)); }); }); describe("minutes surfaces must use billable clock (not rollup storage)", () => { test("timeline stats never uses per-event estimatedMinutes for non-manual work", () => { // Two dense events with inflated estimatedMinutes still gap-merge to ~16m, // proving stats ignore the rollup-style per-event field for non-manual. const events = [ { id: "1", timestamp: at("2026-01-01T09:00:00Z"), projectId: "A", source: "git" as const, estimatedMinutes: 999, }, { id: "2", timestamp: at("2026-01-01T09:01:00Z"), projectId: "A", source: "git" as const, estimatedMinutes: 999, }, ]; expect(calculateBillableMinutes(events)).toBe(16); expect(calculateTimelineStatsFromEvents(events).totalMinutes).toBe(16); }); }); describe("clock consistency: timeline stats ↔ billable minutes", () => { test("straddle case no longer doubles vs gap-merge", () => { // Two events 2 min apart that straddle a UTC 15m epoch boundary. // Old bucket clock reported 30; billable gap-merge is 17. const t0 = new Date("2026-01-01T10:14:00Z").getTime(); const events = [ { id: "x", timestamp: new Date(t0), projectId: "A", source: "git" as const, }, { id: "y", timestamp: new Date(t0 + 2 * 60_000), projectId: "A", source: "git" as const, }, ]; const billable = calculateBillableMinutes(events); const stats = calculateTimelineStatsFromEvents(events); expect(stats.totalMinutes).toBe(billable); expect(stats.totalMinutes).toBe(17); }); test("multi-project concurrent totals match between surfaces", () => { const events = [ { id: "1", timestamp: at("2026-01-01T10:00:00Z"), projectId: "A", source: "claude" as const, }, { id: "2", timestamp: at("2026-01-01T10:05:00Z"), projectId: "B", source: "claude" as const, }, ]; const billable = calculateBillableMinutes(events); const stats = calculateTimelineStatsFromEvents(events); const summary = summarizeActivity(events); expect(stats.totalMinutes).toBe(billable); expect(summary.totalMinutes).toBe(billable); // A 15 + B 15 = 30 concurrent billable expect(billable).toBe(30); }); });