From 18a0f9c874aaa09c0f71252dad853c23f9511ed6 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Mon, 7 Sep 2026 11:32:41 +0200 Subject: Add projects with routines, urgency tracking and health scores Projects group recurring routines (chores included) with an append-only snapshot table (id, version) so edits never rewrite history; completions reference the exact snapshot. Pure metrics compute due dates, urgency buckets (ok/due/late/veryLate) and 0-100 health scores per routine, project, and globally. Home page lists projects; project dashboard sorts routines most-urgent first with one-tap completion and interval parser inputs (1d/2w/3m/1y). --- src/lib/shared/routine-metrics.test.ts | 105 +++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/lib/shared/routine-metrics.test.ts (limited to 'src/lib/shared/routine-metrics.test.ts') diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts new file mode 100644 index 0000000..4a84eda --- /dev/null +++ b/src/lib/shared/routine-metrics.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + computeRoutineState, + computeHealthScore, + 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 ok and healthy before the due date', () => { + const now = Date.parse(CREATED) + 3 * DAY_MS; + const state = computeRoutineState(routine, [], now); + expect(state.status).toBe('ok'); + 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); + }); +}); + +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), + ); + }); +}); -- cgit v1.3