import { describe, it, expect } from 'vitest'; import { computeRoutineState, computeHealthScore, computeHealthSeries, computeProjectHealthSeries, type HealthPoint, } 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', frequency: '7d', createdAt: CREATED, }; const quotaRoutine = { id: 1, version: 1, name: 'hoover', frequency: '5/2w', createdAt: '2026-09-07T00:00:00.000Z', // windows: [09-07, 09-21), [09-21, 10-05), … }; /** Completion inputs for routine 1 from ISO stamps. */ const done = (...stamps: string[]) => stamps.map((doneAt) => ({ routineId: 1, doneAt })); 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, frequency: '1d' }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // <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, frequency: '1d' }; 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, frequency: '1d' }; 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('computeRoutineState (quota)', () => { it('is ok at quota met within the window', () => { const state = computeRoutineState( quotaRoutine, done( '2026-09-07T08:00:00.000Z', '2026-09-08T08:00:00.000Z', '2026-09-09T08:00:00.000Z', '2026-09-10T08:00:00.000Z', '2026-09-11T08:00:00.000Z', ), Temporal.Instant.from('2026-09-12T12:00:00.000Z'), ); expect(state.status).toBe('ok'); expect(state.health).toBe(100); expect(state.overdueRatio).toBe(0); expect(state.overdueDays).toBe(0); expect(state.frequency).toBe('5/2w'); expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z'); }); it('is due while the window is open and the quota unmet', () => { const state = computeRoutineState( quotaRoutine, done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), Temporal.Instant.from('2026-09-09T12:00:00.000Z'), ); expect(state.status).toBe('due'); expect(state.health).toBe(91); // 2.5/14 of the window elapsed expect(state.overdueDays).toBe(0); // deadline 09-21 still ahead expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); }); it('is late as soon as the window closes unmet', () => { const state = computeRoutineState( quotaRoutine, done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), Temporal.Instant.from('2026-09-21T00:05:00.000Z'), ); expect(state.status).toBe('late'); expect(state.health).toBe(50); // one whole window behind expect(state.overdueDays).toBe(0); expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); }); it('accumulates overdue days while behind', () => { const state = computeRoutineState( quotaRoutine, done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), Temporal.Instant.from('2026-09-22T12:00:00.000Z'), ); expect(state.status).toBe('late'); expect(state.health).toBe(45); // 1.5/14 of the window elapsed behind expect(state.overdueDays).toBe(1); expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); }); it('is very late after two closed unmet windows', () => { const state = computeRoutineState( quotaRoutine, done('2026-09-07T08:00:00.000Z', '2026-09-08T09:00:00.000Z'), Temporal.Instant.from('2026-10-05T00:00:05.000Z'), ); expect(state.status).toBe('veryLate'); expect(state.health).toBe(0); expect(state.overdueDays).toBe(14); expect(state.dueAt).toBe('2026-09-21T00:00:00.000Z'); }); it('quota met beats a previous window shortfall', () => { const state = computeRoutineState( quotaRoutine, done( '2026-09-08T08:00:00.000Z', '2026-09-09T08:00:00.000Z', '2026-09-10T08:00:00.000Z', '2026-09-22T08:00:00.000Z', '2026-09-23T08:00:00.000Z', '2026-09-24T08:00:00.000Z', '2026-09-25T08:00:00.000Z', '2026-09-26T08:00:00.000Z', ), Temporal.Instant.from('2026-09-26T12:00:00.000Z'), ); expect(state.status).toBe('ok'); expect(state.health).toBe(100); expect(state.dueAt).toBe('2026-10-19T00:00:00.000Z'); }); it('counts a completion at the window start in the new window', () => { const state = computeRoutineState( quotaRoutine, done( '2026-09-08T08:00:00.000Z', '2026-09-09T08:00:00.000Z', '2026-09-10T08:00:00.000Z', '2026-09-11T08:00:00.000Z', '2026-09-21T00:00:00.000Z', ), Temporal.Instant.from('2026-09-21T00:05:00.000Z'), ); // 4/5 in window 0 (missed); the fifth landed in window 1. expect(state.status).toBe('late'); expect(state.health).toBe(50); }); it('counts a completion at the window end in the old window', () => { const state = computeRoutineState( quotaRoutine, done( '2026-09-08T08:00:00.000Z', '2026-09-09T08:00:00.000Z', '2026-09-10T08:00:00.000Z', '2026-09-11T08:00:00.000Z', '2026-09-20T23:59:59.999Z', ), Temporal.Instant.from('2026-09-21T00:05:00.000Z'), ); // 5/5 in window 0: window 1 starts unmet but not behind. expect(state.status).toBe('due'); expect(state.health).toBe(100); expect(state.dueAt).toBe('2026-10-05T00:00:00.000Z'); }); it('counts backdated completions in the window containing doneAt', () => { const twoPerFortnight = { ...quotaRoutine, frequency: '2/2w' }; const state = computeRoutineState( twoPerFortnight, done('2026-09-08T08:00:00.000Z', '2026-09-09T09:00:00.000Z'), Temporal.Instant.from('2026-09-22T12:00:00.000Z'), ); // Window 0 is met (2/2) even though both were logged long after; // window 1 is open and unmet. expect(state.status).toBe('due'); expect(state.health).toBe(95); }); it('throws on a spec that cannot parse', () => { expect(() => computeRoutineState( { ...quotaRoutine, frequency: '5/w' }, [], Temporal.Instant.from('2026-09-09T12:00:00.000Z'), ), ).toThrow('Invalid frequency spec'); }); }); 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('computeHealthSeries (quota)', () => { const healthOn = (series: HealthPoint[], iso: string) => series.find((p) => p.at === Temporal.Instant.from(iso).epochMilliseconds) ?.health; it('decays within a window, then drops a whole window per miss', () => { const now = Temporal.Instant.from('2026-10-05T00:00:00.000Z'); const series = computeHealthSeries(quotaRoutine, [], now); expect(series[0].health).toBe(100); // window open at creation expect(healthOn(series, '2026-09-14T00:00:00.000Z')).toBe(75); // half window expect(healthOn(series, '2026-09-21T00:00:00.000Z')).toBe(50); // 1 window missed expect(healthOn(series, '2026-09-28T00:00:00.000Z')).toBe(25); expect(healthOn(series, '2026-10-05T00:00:00.000Z')).toBe(0); // 2 missed }); it('resets to 100 once the quota is met', () => { const now = Temporal.Instant.from('2026-09-13T00:00:00.000Z'); const series = computeHealthSeries( quotaRoutine, done( '2026-09-07T00:00:00.000Z', '2026-09-08T00:00:00.000Z', '2026-09-09T00:00:00.000Z', '2026-09-10T00:00:00.000Z', '2026-09-11T00:00:00.000Z', ), now, ); expect(healthOn(series, '2026-09-10T00:00:00.000Z')).toBe(89); // 4/5 expect(healthOn(series, '2026-09-11T00:00:00.000Z')).toBe(100); // 5/5 expect(healthOn(series, '2026-09-12T00:00:00.000Z')).toBe(100); // met }); }); 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); }); it('averages quota routines with the same formula', () => { const now = Temporal.Instant.from('2026-09-09T12:00:00.000Z'); const series = computeProjectHealthSeries([quotaRoutine], [], now); const states = [computeRoutineState(quotaRoutine, [], now)]; const last = series[series.length - 1]; expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(computeHealthScore(states)); expect(last.health).toBe(91); }); }); 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), ); }); });