import { describe, it, expect } from 'vitest'; import { computeRoutineState, computeHealthScore, computeHealthSeries, computeProjectHealthSeries, parseIntervalToDays, } from './routine-metrics'; const CREATED = '2026-09-01T00:00:00.000Z'; const created = Temporal.Instant.from(CREATED); const plusDays = (base: Temporal.Instant, days: number): Temporal.Instant => base.toZonedDateTimeISO('UTC').add({ days }).toInstant(); /** ISO stamp n days after the given stamp. */ const isoPlusDays = (iso: string, days: number): string => Temporal.Instant.from(iso) .toZonedDateTimeISO('UTC') .add({ days }) .toInstant() .toString({ smallestUnit: 'millisecond' }); const routine = { id: 1, version: 1, name: 'clean kitchen', frequencyDays: 7, createdAt: CREATED, }; describe('computeRoutineState', () => { it('is due immediately at creation when never done (created_at = due)', () => { const now = plusDays(created, 3); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); expect(state.overdueDays).toBe(3); expect(state.health).toBe(79); // 3/7 of a period overdue expect(state.dueAt).toBe(CREATED); }); it('uses the last completion as reference once done', () => { const doneAt = isoPlusDays(CREATED, 5); const now = plusDays(Temporal.Instant.from(doneAt), 2); const state = computeRoutineState(routine, [{ routineId: 1, doneAt }], now); expect(state.lastDoneAt).toBe(doneAt); expect(state.status).toBe('ok'); }); it('is due the calendar day after completion for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // this morning, <24h later const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); expect(state.status).toBe('due'); }); it('a daily done yesterday is late tomorrow, not today', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const completions = [{ routineId: 1, doneAt }]; const today = computeRoutineState( daily, completions, Temporal.Instant.from('2026-09-08T20:00:00.000Z'), ); expect(today.status).toBe('due'); const tomorrow = computeRoutineState( daily, completions, Temporal.Instant.from('2026-09-09T20:00:00.000Z'), ); expect(tomorrow.status).toBe('late'); }); it('is ok later the same day it was done for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-08T08:00:00.000Z'; const now = Temporal.Instant.from('2026-09-08T20:00:00.000Z'); const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); expect(state.status).toBe('ok'); }); }); describe('computeHealthSeries', () => { it('decays from 100 at creation to 0 two periods overdue when never done', () => { const now = plusDays(created, 22); const series = computeHealthSeries(routine, [], now); expect(series[0].health).toBe(100); const at8d = series.find( (p) => p.at === plusDays(created, 8).epochMilliseconds, ); expect(at8d?.health).toBe(43); // 8/7 of a period overdue const last = series[series.length - 1]; expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(0); // more than two full periods overdue }); it('resets to 100 right after a completion, then decays again (sawtooth)', () => { const doneAt = plusDays(created, 5); const now = plusDays(doneAt, 13); // 13 days after the completion const doneAtIso = doneAt.toString({ smallestUnit: 'millisecond' }); const series = computeHealthSeries( routine, [{ routineId: 1, doneAt: doneAtIso }], now, ); const rightAfter = series.find((p) => p.at >= doneAt.epochMilliseconds); expect(rightAfter?.health).toBe(100); // 8 days after the completion: 1 day into the 7-day period overdue. const at8d = series.find( (p) => p.at === plusDays(doneAt, 8).epochMilliseconds, ); expect(at8d?.health).toBe(93); }); it('a second completion resets the decay again', () => { const first = plusDays(created, 3); const second = plusDays(created, 10); const now = plusDays(created, 11); const stamp = (at: Temporal.Instant): string => at.toString({ smallestUnit: 'millisecond' }); const series = computeHealthSeries( routine, [ { routineId: 1, doneAt: stamp(first) }, { routineId: 1, doneAt: stamp(second) }, ], now, ); // Just after the second completion the routine is back at full health. const nearEnd = series[series.length - 1]; expect(nearEnd.health).toBe(100); }); }); describe('computeProjectHealthSeries', () => { it('is empty with no routines', () => { expect(computeProjectHealthSeries([], [], created)).toEqual([]); }); it('averages per-routine health and ends at the current score', () => { const second = { ...routine, id: 2, name: 'read' }; const doneAt = plusDays(created, 5); const now = plusDays(created, 8); const doneAtIso = doneAt.toString({ smallestUnit: 'millisecond' }); const series = computeProjectHealthSeries( [routine, second], [{ routineId: 2, doneAt: doneAtIso }], now, ); const states = [ computeRoutineState(routine, [], now), computeRoutineState(second, [{ routineId: 2, doneAt: doneAtIso }], now), ]; const last = series[series.length - 1]; expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(computeHealthScore(states)); }); it('only counts routines alive that day', () => { const late = { ...routine, id: 2, name: 'latecomer', createdAt: isoPlusDays(CREATED, 10), }; const now = plusDays(created, 11); const series = computeProjectHealthSeries([routine, late], [], now); const beforeLate = series.find( (p) => p.at === plusDays(created, 5).epochMilliseconds, ); const single = computeHealthSeries(routine, [], plusDays(created, 5)); expect(beforeLate?.health).toBe(single[single.length - 1].health); }); }); describe('parseIntervalToDays', () => { it('converts units to days', () => { expect(parseIntervalToDays('1d')).toBe(1); expect(parseIntervalToDays('2w')).toBe(14); expect(parseIntervalToDays('3m')).toBe(90); expect(parseIntervalToDays('1y')).toBe(365); }); it('accepts whitespace and uppercase', () => { expect(parseIntervalToDays(' 2W ')).toBe(14); }); it('rejects invalid input', () => { expect(parseIntervalToDays('2')).toBeNull(); expect(parseIntervalToDays('w')).toBeNull(); expect(parseIntervalToDays('2x')).toBeNull(); expect(parseIntervalToDays('-1d')).toBeNull(); expect(parseIntervalToDays('')).toBeNull(); }); }); describe('computeHealthScore', () => { it('is 100 with no routines', () => { expect(computeHealthScore([])).toBe(100); }); it('averages routine healths', () => { const now = plusDays(created, 8); const a = computeRoutineState(routine, [], now); // overdue 1 day const b = computeRoutineState(routine, [], plusDays(created, 3)); expect(computeHealthScore([a, b])).toBe( Math.round((a.health + b.health) / 2), ); }); });