import { describe, it, expect } from 'vitest'; import { timeFormat } from 'd3-time-format'; import { timeDay } from 'd3-time'; import { calculateStreak, calculateStats } from '../src/shared/stats'; import type { DayEntry } from '../src/types'; const DATE_FORMAT = timeFormat('%Y-%m-%d'); function daysAgo(n: number): string { const d = new Date(); d.setHours(0, 0, 0, 0); return DATE_FORMAT(timeDay.offset(d, -n)); } describe('calculateStreak', () => { it('returns 0 for empty data', () => { expect(calculateStreak([])).toBe(0); }); it('returns 0 when no days have hours > 0', () => { const days: DayEntry[] = [ { date: daysAgo(0), hours: 0, count: 0 }, ]; expect(calculateStreak(days)).toBe(0); }); it('returns 1 for a single entry today', () => { const days: DayEntry[] = [ { date: daysAgo(0), hours: 2.0, count: 1 }, ]; expect(calculateStreak(days)).toBe(1); }); it('counts consecutive days ending today', () => { const days: DayEntry[] = [ { date: daysAgo(0), hours: 1.0, count: 1 }, { date: daysAgo(1), hours: 2.0, count: 1 }, { date: daysAgo(2), hours: 3.0, count: 1 }, ]; expect(calculateStreak(days)).toBe(3); }); it('starts from yesterday if today has no entry', () => { const days: DayEntry[] = [ { date: daysAgo(1), hours: 2.0, count: 1 }, { date: daysAgo(2), hours: 3.0, count: 1 }, ]; expect(calculateStreak(days)).toBe(2); }); it('breaks streak at gaps', () => { const days: DayEntry[] = [ { date: daysAgo(0), hours: 1.0, count: 1 }, { date: daysAgo(1), hours: 2.0, count: 1 }, // gap at daysAgo(2) { date: daysAgo(3), hours: 3.0, count: 1 }, ]; expect(calculateStreak(days)).toBe(2); }); it('does not count days with 0 hours as tracked', () => { const days: DayEntry[] = [ { date: daysAgo(0), hours: 1.0, count: 1 }, { date: daysAgo(1), hours: 0, count: 0 }, { date: daysAgo(2), hours: 2.0, count: 1 }, ]; expect(calculateStreak(days)).toBe(1); }); }); describe('calculateStats', () => { it('returns zeros for empty data', () => { const stats = calculateStats([]); expect(stats.totalHours).toBe(0); expect(stats.avgHours).toBe(0); expect(stats.busiestDay).toBeNull(); }); it('returns zeros when all hours are 0', () => { const days: DayEntry[] = [ { date: '2025-01-01', hours: 0, count: 0 }, ]; const stats = calculateStats(days); expect(stats.totalHours).toBe(0); expect(stats.busiestDay).toBeNull(); }); it('computes total hours correctly', () => { const days: DayEntry[] = [ { date: '2025-01-01', hours: 2.5, count: 1 }, { date: '2025-01-02', hours: 3.5, count: 2 }, { date: '2025-01-03', hours: 4.0, count: 1 }, ]; const stats = calculateStats(days); expect(stats.totalHours).toBe(10); }); it('computes average over days with entries only', () => { const days: DayEntry[] = [ { date: '2025-01-01', hours: 3.0, count: 1 }, { date: '2025-01-02', hours: 0, count: 0 }, { date: '2025-01-03', hours: 6.0, count: 2 }, ]; const stats = calculateStats(days); // avg = 9.0 / 2 = 4.5 (not 9/3) expect(stats.avgHours).toBe(4.5); }); it('identifies the busiest day', () => { const days: DayEntry[] = [ { date: '2025-01-01', hours: 2.0, count: 1 }, { date: '2025-01-02', hours: 8.5, count: 3 }, { date: '2025-01-03', hours: 4.0, count: 2 }, ]; const stats = calculateStats(days); expect(stats.busiestDay).toEqual({ date: '2025-01-02', hours: 8.5 }); }); it('rounds to one decimal place', () => { const days: DayEntry[] = [ { date: '2025-01-01', hours: 1.33, count: 1 }, { date: '2025-01-02', hours: 2.67, count: 1 }, ]; const stats = calculateStats(days); expect(stats.totalHours).toBe(4); expect(stats.avgHours).toBe(2); }); });