aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/routine-metrics.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-07 11:32:41 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-07 11:32:41 +0200
commit18a0f9c874aaa09c0f71252dad853c23f9511ed6 (patch)
tree93103b83f6d0057c40f0bab9c9473bf04292e9c4 /src/lib/shared/routine-metrics.ts
parent113415ce357a9cea1759d0452ebae6352f8fe36b (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/routine-metrics.ts')
-rw-r--r--src/lib/shared/routine-metrics.ts113
1 files changed, 113 insertions, 0 deletions
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',
+};