kimai-plugin-heatmap/assets/test/stats.test.ts
Christopher Mühl d0dc64f333
feat: add streak, stats, weekend styling, week-start preference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:05:44 +02:00

128 lines
3.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { timeFormat } from 'd3-time-format';
import { timeDay } from 'd3-time';
import { calculateStreak, calculateStats } from '../src/heatmap';
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);
});
});