diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-07 11:32:41 +0200 |
| commit | 18a0f9c874aaa09c0f71252dad853c23f9511ed6 (patch) | |
| tree | 93103b83f6d0057c40f0bab9c9473bf04292e9c4 /src/lib/shared | |
| parent | 113415ce357a9cea1759d0452ebae6352f8fe36b (diff) | |
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).
Diffstat (limited to 'src/lib/shared')
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 105 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 113 |
2 files changed, 218 insertions, 0 deletions
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), + ); + }); +}); diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts new file mode 100644 index 0000000..2ac245e --- /dev/null +++ b/src/lib/shared/routine-metrics.ts @@ -0,0 +1,113 @@ +// Pure routine metrics, shared by server services and UI. No db access. +// All timestamps are ISO strings; all math is done in ms then converted to days. + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type RoutineStatus = 'ok' | 'due' | 'late' | 'veryLate'; + +export interface RoutineInput { + id: number; + version: number; + name: string; + frequencyDays: number; + createdAt: string; +} + +export interface CompletionInput { + routineId: number; + doneAt: string; +} + +export interface RoutineState { + id: number; + version: number; + name: string; + frequencyDays: number; + lastDoneAt: string | null; + /** When the routine should next be done. */ + dueAt: string; + /** Days past dueAt (0 if not yet due). */ + overdueDays: number; + /** Time past due, in units of the routine's own frequency (0 if not due). */ + overdueRatio: number; + status: RoutineStatus; + /** 100 on schedule, 0 when overdue by a full frequency, floor at 0. */ + health: number; +} + +export function computeRoutineState( + routine: RoutineInput, + completions: CompletionInput[], + now: number, +): RoutineState { + const mine = completions + .filter((c) => c.routineId === routine.id) + .map((c) => Date.parse(c.doneAt)); + const lastDoneMs = mine.length > 0 ? Math.max(...mine) : null; + const createdMs = Date.parse(routine.createdAt); + const referenceMs = + lastDoneMs ?? (Number.isFinite(createdMs) ? createdMs : now); + const periodMs = routine.frequencyDays * DAY_MS; + const dueMs = referenceMs + periodMs; + const rawRatio = (now - dueMs) / periodMs; + const overdueRatio = Math.max(0, rawRatio); + // Buckets on overdue time in units of the routine's own frequency: + // not due / due now / late (half a period overdue) / very late (a full + // period overdue — a weekly routine two weeks old lands here). + const status: RoutineStatus = + rawRatio < 0 + ? 'ok' + : overdueRatio < 0.5 + ? 'due' + : overdueRatio < 1 + ? 'late' + : 'veryLate'; + + return { + id: routine.id, + version: routine.version, + name: routine.name, + frequencyDays: routine.frequencyDays, + lastDoneAt: lastDoneMs !== null ? new Date(lastDoneMs).toISOString() : null, + dueAt: new Date(dueMs).toISOString(), + overdueDays: Math.floor(overdueRatio * routine.frequencyDays), + overdueRatio, + status, + health: Math.max(0, Math.round(100 - overdueRatio * 100)), + }; +} + +/** Average health across routines; 100 when there is nothing to do. */ +export function computeHealthScore(states: { health: number }[]): number { + if (states.length === 0) return 100; + const total = states.reduce((sum, s) => sum + s.health, 0); + return Math.round(total / states.length); +} + +export function formatFrequency(days: number): string { + if (days === 1) return 'chaque jour'; + if (days === 7) return 'chaque semaine'; + if (days === 30) return 'chaque mois'; + if (days === 365) return 'chaque année'; + return `tous les ${days} jours`; +} + +const UNIT_DAYS: Record<string, number> = { d: 1, w: 7, m: 30, y: 365 }; + +/** Parses "1d | 2w | 3m | 1y" into days; returns null when invalid. */ +export function parseIntervalToDays(input: string): number | null { + const match = input + .trim() + .toLowerCase() + .match(/^(\d+)([dwmy])$/); + if (!match) return null; + const days = Number(match[1]) * UNIT_DAYS[match[2]]; + return days >= 1 ? days : null; +} + +export const STATUS_LABELS: Record<RoutineStatus, string> = { + ok: 'À jour', + due: 'À faire', + late: 'En retard', + veryLate: 'Très en retard', +}; |
