// 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. // Due dates anchor on UTC calendar days, not exact 24h intervals: a daily // routine done yesterday is due today, whatever the time of day. const DAY_MS = 24 * 60 * 60 * 1000; /** Midnight UTC starting the calendar day containing ms. */ function startOfUtcDay(ms: number): number { const d = new Date(ms); return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); } 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, 50 when a full period overdue, 0 at two periods, 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; // Calendar-day anchor: the period starts at midnight of the reference day, // so any new calendar day past the frequency flips the routine to due. const dueMs = startOfUtcDay(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: due // until a full period is missed (a daily done yesterday is due today, not // late), late once an occurrence was skipped entirely, very late after two. // A routine that was never done is always at least due: there is // something to do, even right after creation. const status: RoutineStatus = rawRatio < 0 && lastDoneMs !== null ? 'ok' : overdueRatio < 1 ? 'due' : overdueRatio < 2 ? '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 * 50)), }; } /** 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 interface HealthPoint { /** Epoch ms of the sample. */ at: number; /** Health (0-100) at that instant. */ health: number; } /** Health at instant t given the reference anchor (creation or last completion). */ function healthAtPoint(refMs: number, periodMs: number, t: number): number { const dueMs = startOfUtcDay(refMs) + periodMs; const rawRatio = (t - dueMs) / periodMs; return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50)); } /** * Daily health samples from the routine's creation to now: 100 right after a * completion, then a linear decay to 0 once two full periods are overdue * (sawtooth). * Each sample anchors on the latest completion at or before it (or creation). * Uses the routine's current frequency across the whole history. */ export function computeHealthSeries( routine: RoutineInput, completions: CompletionInput[], now: number, ): HealthPoint[] { const createdMs = Date.parse(routine.createdAt); const startMs = Number.isFinite(createdMs) ? createdMs : now; const periodMs = routine.frequencyDays * DAY_MS; const completionMs = completions .filter((c) => c.routineId === routine.id) .map((c) => Date.parse(c.doneAt)) .sort((a, b) => a - b); const points: HealthPoint[] = []; if (now <= startMs) { return [{ at: now, health: 100 }]; } // Reference anchor: latest completion at-or-before the sampled instant. let refMs = startMs; let nextCompletion = 0; const sampleAt = (t: number): number => { while ( nextCompletion < completionMs.length && completionMs[nextCompletion] <= t ) { refMs = completionMs[nextCompletion]; nextCompletion++; } return healthAtPoint(refMs, periodMs, t); }; for (let t = startMs; t <= now; t += DAY_MS) { points.push({ at: t, health: sampleAt(t) }); } // Final unaligned sample so the curve ends at the present instant. if (points[points.length - 1].at < now) { points.push({ at: now, health: sampleAt(now) }); } return points; } /** * Project health series: daily mean of per-routine health across the routines * alive that day (createdAt <= t). Same sawtooth as the per-routine series, * so the project curve ends at the current health score. Empty when no * routines. */ export function computeProjectHealthSeries( routines: RoutineInput[], completions: CompletionInput[], now: number, ): HealthPoint[] { if (routines.length === 0) return []; const ids = new Set(routines.map((r) => r.id)); const completionsByRoutine = new Map(); for (const c of completions) { if (!ids.has(c.routineId)) continue; const t = Date.parse(c.doneAt); if (!Number.isFinite(t)) continue; completionsByRoutine.set(c.routineId, [ ...(completionsByRoutine.get(c.routineId) ?? []), t, ]); } for (const times of completionsByRoutine.values()) times.sort((a, b) => a - b); const createdOrNow = (r: RoutineInput): number => { const t = Date.parse(r.createdAt); return Number.isFinite(t) ? t : now; }; // Mean per-routine health across the routines alive at t. const sampleAt = (t: number): number | null => { const alive = routines.filter((r) => Date.parse(r.createdAt) <= t); if (alive.length === 0) return null; const total = alive.reduce((sum, r) => { const doneAt = (completionsByRoutine.get(r.id) ?? []).filter( (d) => d <= t, ); const anchor = doneAt.length > 0 ? Math.max(...doneAt) : createdOrNow(r); return sum + healthAtPoint(anchor, r.frequencyDays * DAY_MS, t); }, 0); return Math.round(total / alive.length); }; const startMs = Math.min(...routines.map(createdOrNow)); if (now <= startMs) return [{ at: now, health: 100 }]; const points: HealthPoint[] = []; for (let t = startMs; t <= now; t += DAY_MS) { const health = sampleAt(t); if (health !== null) points.push({ at: t, health }); } if (points.length === 0) return [{ at: now, health: 100 }]; // Final unaligned sample so the curve ends at the present instant. if (points[points.length - 1].at < now) { points.push({ at: now, health: sampleAt(now) ?? 100 }); } return points; } 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', };