import { describe, expect, test } from "bun:test"; import type { TimelineEventDisplay, TimelineEventMetadata, } from "../types/timeline"; import { computeDerivedStats } from "./derived-stats"; let idCounter = 0; function evt(partial: { source: TimelineEventDisplay["source"]; timestamp: string; metadata?: TimelineEventMetadata | null; estimatedMinutes?: number | null; }): TimelineEventDisplay { idCounter += 1; return { id: `e${idCounter}`, timestamp: partial.timestamp, source: partial.source, sourceId: null, projectRootId: null, projectId: null, metadata: partial.metadata ?? null, estimatedMinutes: partial.estimatedMinutes ?? null, projectName: null, projectColor: null, deviceId: null, deviceName: null, }; } function git(timestamp: string, additions: number, deletions: number) { return evt({ source: "git", timestamp, metadata: { message: "commit", author: "tester", filesChanged: ["a.ts"], additions, deletions, }, }); } function chat(timestamp: string, sessionId: string) { return evt({ source: "claude", timestamp, metadata: { sessionId } as unknown as TimelineEventMetadata, }); } function file(timestamp: string, path: string) { return evt({ source: "file", timestamp, metadata: { path, size: 100, extension: "ts" }, }); } // Fixed "now" far after the test fixtures so momentum windows are empty and // deterministic (no events fall in this/last week). 2025-06-01 is a Sunday. const NOW = new Date("2025-06-01T12:00:00.000Z"); // Every numeric field a panel renders — used to assert nothing is NaN. const NUMERIC_FIELDS = [ "linesChanged", "conversations", "filesModified", "gitCommits", "activeDays", "weekdayDays", "weekendDays", "avgHoursPerWeekday", "avgHoursPerWeekend", "weekendBonusHours", "currentStreak", "longestStreak", "peakHour", "thisWeekHours", "lastWeekHours", "momentumPercent", ] as const; describe("computeDerivedStats", () => { test("returns null for no events", () => { expect(computeDerivedStats([], NOW)).toBeNull(); }); test("counts lines, commits, conversations, and files", () => { const events = [ git("2025-01-01T10:00:00.000Z", 10, 5), git("2025-01-01T11:00:00.000Z", 3, 2), chat("2025-01-01T10:05:00.000Z", "s1"), chat("2025-01-01T10:10:00.000Z", "s1"), // same session → one conversation chat("2025-01-01T10:20:00.000Z", "s2"), file("2025-01-01T10:30:00.000Z", "/a.ts"), file("2025-01-01T10:31:00.000Z", "/a.ts"), // same file → counted once file("2025-01-01T10:32:00.000Z", "/b.ts"), ]; const stats = computeDerivedStats(events, NOW); expect(stats).not.toBeNull(); if (!stats) return; expect(stats.linesChanged).toBe(20); // (10+5) + (3+2) expect(stats.gitCommits).toBe(2); expect(stats.conversations).toBe(2); // s1, s2 (deduped by sessionId) expect(stats.filesModified).toBe(2); // a.ts, b.ts (deduped by path) }); test("never produces NaN, even with null/empty metadata", () => { const events = [ evt({ source: "git", timestamp: "2025-01-01T10:00:00.000Z" }), // null metadata evt({ source: "claude", timestamp: "2025-01-01T10:05:00.000Z", metadata: null, }), evt({ source: "manual", timestamp: "2025-01-01T10:10:00.000Z", estimatedMinutes: null, }), file("2025-01-01T10:20:00.000Z", "/a.ts"), ]; const stats = computeDerivedStats(events, NOW); expect(stats).not.toBeNull(); if (!stats) return; for (const key of NUMERIC_FIELDS) { expect(Number.isFinite(stats[key])).toBe(true); } // git event with null metadata is not counted as a commit expect(stats.gitCommits).toBe(0); expect(stats.conversations).toBe(0); }); test("single event yields finite stats and one active day", () => { const stats = computeDerivedStats( [git("2025-01-01T10:00:00.000Z", 1, 0)], NOW, ); expect(stats).not.toBeNull(); if (!stats) return; expect(stats.activeDays).toBe(1); for (const key of NUMERIC_FIELDS) { expect(Number.isFinite(stats[key])).toBe(true); } expect(typeof stats.peakDay).toBe("string"); expect(stats.peakHour).toBeGreaterThanOrEqual(0); expect(stats.peakHour).toBeLessThanOrEqual(23); }); test("counts distinct active days and computes streaks over consecutive days", () => { // Three consecutive UTC days, then a gap, then one more day. const events = [ git("2025-01-01T10:00:00.000Z", 1, 0), git("2025-01-02T10:00:00.000Z", 1, 0), git("2025-01-03T10:00:00.000Z", 1, 0), git("2025-01-10T10:00:00.000Z", 1, 0), ]; const stats = computeDerivedStats(events, NOW); expect(stats).not.toBeNull(); if (!stats) return; expect(stats.activeDays).toBe(4); // Longest consecutive run is Jan 1-3 (3 days). expect(stats.longestStreak).toBe(3); // Momentum windows are empty (all events predate NOW's week) → 0. expect(stats.momentumPercent).toBe(0); expect(stats.thisWeekHours).toBe(0); expect(stats.lastWeekHours).toBe(0); }); });