// 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 = { 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 = { ok: 'À jour', due: 'À faire', late: 'En retard', veryLate: 'Très en retard', };