diff options
Diffstat (limited to 'src/lib/shared/routine-metrics.test.ts')
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 231 |
1 files changed, 207 insertions, 24 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index 5bc0a78..88d0498 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -4,7 +4,7 @@ import { computeHealthScore, computeHealthSeries, computeProjectHealthSeries, - parseIntervalToDays, + type HealthPoint, } from './routine-metrics'; const CREATED = '2026-09-01T00:00:00.000Z'; const created = Temporal.Instant.from(CREATED); @@ -24,10 +24,22 @@ const routine = { id: 1, version: 1, name: 'clean kitchen', - frequencyDays: 7, + 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); @@ -47,14 +59,14 @@ describe('computeRoutineState', () => { }); it('is due the calendar day after completion for a daily routine', () => { - const daily = { ...routine, frequencyDays: 1 }; + 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, frequencyDays: 1 }; + const daily = { ...routine, frequency: '1d' }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening const completions = [{ routineId: 1, doneAt }]; const today = computeRoutineState( @@ -72,7 +84,7 @@ describe('computeRoutineState', () => { }); it('is ok later the same day it was done for a daily routine', () => { - const daily = { ...routine, frequencyDays: 1 }; + 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); @@ -80,6 +92,154 @@ describe('computeRoutineState', () => { }); }); +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); @@ -132,6 +292,40 @@ describe('computeHealthSeries', () => { }); }); +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([]); @@ -171,26 +365,15 @@ describe('computeProjectHealthSeries', () => { 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(); + 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); }); }); |
