import { describe, it, expect } from 'vitest'; import { computeRoutineState, computeHealthScore, computeHealthSeries, computeProjectHealthSeries, parseIntervalToDays, } from './routine-metrics'; const DAY_MS = 24 * 60 * 60 * 1000; const CREATED = '2026-09-01T00:00:00.000Z'; const routine = { id: 1, version: 1, name: 'clean kitchen', frequencyDays: 7, createdAt: CREATED, }; describe('computeRoutineState', () => { it('is due when never done, even before the due date', () => { const now = Date.parse(CREATED) + 3 * DAY_MS; const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); expect(state.overdueDays).toBe(0); expect(state.health).toBe(100); expect(state.dueAt).toBe( new Date(Date.parse(CREATED) + 7 * DAY_MS).toISOString(), ); }); it('uses the last completion as reference once done', () => { const doneAt = new Date(Date.parse(CREATED) + 5 * DAY_MS).toISOString(); const now = Date.parse(doneAt) + 2 * DAY_MS; const state = computeRoutineState(routine, [{ routineId: 1, doneAt }], now); expect(state.lastDoneAt).toBe(doneAt); expect(state.status).toBe('ok'); }); it('is due at exactly the frequency', () => { const now = Date.parse(CREATED) + 7 * DAY_MS; const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); expect(state.health).toBe(100); }); it('is very urgent (veryLate) when two weeks old for a weekly routine', () => { const now = Date.parse(CREATED) + 15 * DAY_MS; const state = computeRoutineState(routine, [], now); expect(state.status).toBe('veryLate'); expect(state.overdueDays).toBe(8); expect(state.health).toBe(0); }); it('is late when overdue by half a frequency', () => { const now = Date.parse(CREATED) + 10.5 * DAY_MS; const state = computeRoutineState(routine, [], now); expect(state.status).toBe('late'); }); it('health of 75 means a quarter of the period overdue', () => { const now = Date.parse(CREATED) + 7 * DAY_MS + 1.75 * DAY_MS; const state = computeRoutineState(routine, [], now); expect(state.health).toBe(75); }); 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 = Date.parse('2026-09-08T08:00:00.000Z'); // this morning, <24h later const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); expect(state.status).toBe('due'); }); 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 = Date.parse('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 late when never done', () => { const now = Date.parse(CREATED) + 15 * DAY_MS; const series = computeHealthSeries(routine, [], now); expect(series[0].health).toBe(100); const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS); expect(at8d?.health).toBe(86); // 1/7 of a period overdue const last = series[series.length - 1]; expect(last.at).toBe(now); expect(last.health).toBe(0); // more than a full period overdue }); it('resets to 100 right after a completion, then decays again (sawtooth)', () => { const doneAt = Date.parse(CREATED) + 5 * DAY_MS; const now = doneAt + 13 * DAY_MS; // 13 days after the completion const series = computeHealthSeries( routine, [{ routineId: 1, doneAt: new Date(doneAt).toISOString() }], now, ); const rightAfter = series.find((p) => p.at >= doneAt); 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 === doneAt + 8 * DAY_MS); expect(at8d?.health).toBe(86); }); it('a second completion resets the decay again', () => { const first = Date.parse(CREATED) + 3 * DAY_MS; const second = Date.parse(CREATED) + 10 * DAY_MS; const now = Date.parse(CREATED) + 11 * DAY_MS; const series = computeHealthSeries( routine, [ { routineId: 1, doneAt: new Date(first).toISOString() }, { routineId: 1, doneAt: new Date(second).toISOString() }, ], 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([], [], Date.parse(CREATED))).toEqual([]); }); it('averages per-routine health and ends at the current score', () => { const second = { ...routine, id: 2, name: 'read' }; const doneAt = Date.parse(CREATED) + 5 * DAY_MS; const now = Date.parse(CREATED) + 8 * DAY_MS; const series = computeProjectHealthSeries( [routine, second], [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], now, ); const states = [ computeRoutineState(routine, [], now), computeRoutineState( second, [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], now, ), ]; const last = series[series.length - 1]; expect(last.at).toBe(now); expect(last.health).toBe(computeHealthScore(states)); }); it('only counts routines alive that day', () => { const late = { ...routine, id: 2, name: 'latecomer', createdAt: new Date(Date.parse(CREATED) + 10 * DAY_MS).toISOString(), }; const now = Date.parse(CREATED) + 11 * DAY_MS; const series = computeProjectHealthSeries([routine, late], [], now); const beforeLate = series.find( (p) => p.at === Date.parse(CREATED) + 5 * DAY_MS, ); const single = computeHealthSeries( routine, [], Date.parse(CREATED) + 5 * DAY_MS, ); 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 = Date.parse(CREATED) + 8 * DAY_MS; const a = computeRoutineState(routine, [], now); // overdue 1 day const b = computeRoutineState( routine, [], Date.parse(CREATED) + 3 * DAY_MS, ); expect(computeHealthScore([a, b])).toBe( Math.round((a.health + b.health) / 2), ); }); });