// Pure routine metrics shared by server and UI; ISO timestamps, UTC calendar. const UTC = 'UTC'; /** Parse an ISO stamp, falling back when it is not a valid instant. */ function parseInstant( value: string, fallback: Temporal.Instant, ): Temporal.Instant { try { return Temporal.Instant.from(value); } catch { return fallback; } } /** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */ function iso(instant: Temporal.Instant): string { return instant.toString({ smallestUnit: 'millisecond' }); } /** Whole days as an exact millisecond count. */ function periodMs(days: number): number { return Temporal.Duration.from({ days }).total({ unit: 'millisecond' }); } function maxInstant(instants: Temporal.Instant[]): Temporal.Instant | null { if (instants.length === 0) return null; return instants.reduce((a, b) => Temporal.Instant.compare(a, b) >= 0 ? a : b, ); } function parseCompletions( routineId: number, completions: CompletionInput[], ): Temporal.Instant[] { const mine: Temporal.Instant[] = []; for (const c of completions) { if (c.routineId !== routineId) continue; try { mine.push(Temporal.Instant.from(c.doneAt)); } catch { // Skip corrupt stamps instead of poisoning the anchor. } } return mine.sort(Temporal.Instant.compare); } 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 one period overdue, 0 at two periods. */ health: number; } export function computeRoutineState( routine: RoutineInput, completions: CompletionInput[], now: Temporal.Instant, ): RoutineState { const lastDone = maxInstant(parseCompletions(routine.id, completions)); const reference = lastDone ?? parseInstant(routine.createdAt, now); const period = periodMs(routine.frequencyDays); // Calendar-day anchor: the period starts at the reference day's midnight. const due = reference .toZonedDateTimeISO(UTC) .startOfDay() .add({ days: lastDone === null ? 0 : routine.frequencyDays }) .toInstant(); const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period; const overdueRatio = Math.max(0, rawRatio); const status: RoutineStatus = rawRatio < 0 && lastDone !== null ? 'ok' : overdueRatio < 1 ? 'due' : overdueRatio < 2 ? 'late' : 'veryLate'; return { id: routine.id, version: routine.version, name: routine.name, frequencyDays: routine.frequencyDays, lastDoneAt: lastDone !== null ? iso(lastDone) : null, dueAt: iso(due), 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 t given the reference anchor (creation or last completion). */ function healthAtPoint( reference: Temporal.Instant, frequencyDays: number, t: Temporal.Instant, neverDone = false, ): number { const due = reference .toZonedDateTimeISO(UTC) .startOfDay() .add({ days: neverDone ? 0 : frequencyDays }) .toInstant(); const rawRatio = (t.epochMilliseconds - due.epochMilliseconds) / periodMs(frequencyDays); return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50)); } /** Daily health samples: 100 at completion, decaying while overdue. */ export function computeHealthSeries( routine: RoutineInput, completions: CompletionInput[], now: Temporal.Instant, ): HealthPoint[] { const start = parseInstant(routine.createdAt, now); const doneAt = parseCompletions(routine.id, completions); const points: HealthPoint[] = []; if (Temporal.Instant.compare(now, start) <= 0) { return [{ at: now.epochMilliseconds, health: 100 }]; } // Reference anchor: latest completion at-or-before the sampled instant. let reference = start; let nextCompletion = 0; const sampleAt = (t: Temporal.Instant): number => { while ( nextCompletion < doneAt.length && Temporal.Instant.compare(doneAt[nextCompletion], t) <= 0 ) { reference = doneAt[nextCompletion]; nextCompletion++; } return healthAtPoint( reference, routine.frequencyDays, t, nextCompletion === 0, ); }; for ( let t = start; Temporal.Instant.compare(t, now) <= 0; t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant() ) { points.push({ at: t.epochMilliseconds, health: sampleAt(t) }); } // Final unaligned sample so the curve ends at the present instant. if (points[points.length - 1].at < now.epochMilliseconds) { points.push({ at: now.epochMilliseconds, health: sampleAt(now) }); } return points; } /** Project health: daily mean of alive routines' health, same sawtooth. */ export function computeProjectHealthSeries( routines: RoutineInput[], completions: CompletionInput[], now: Temporal.Instant, ): 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; try { const t = Temporal.Instant.from(c.doneAt); completionsByRoutine.set(c.routineId, [ ...(completionsByRoutine.get(c.routineId) ?? []), t, ]); } catch { // Skip corrupt stamps instead of poisoning the anchor. } } for (const times of completionsByRoutine.values()) times.sort(Temporal.Instant.compare); const createdOrNow = (r: RoutineInput): Temporal.Instant => parseInstant(r.createdAt, now); // Mean per-routine health across the routines alive at t. const sampleAt = (t: Temporal.Instant): number | null => { const alive = routines.filter((r) => { try { return ( Temporal.Instant.compare(Temporal.Instant.from(r.createdAt), t) <= 0 ); } catch { return false; } }); if (alive.length === 0) return null; const total = alive.reduce((sum, r) => { const doneAt = (completionsByRoutine.get(r.id) ?? []).filter( (d) => Temporal.Instant.compare(d, t) <= 0, ); const anchor = doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r); return ( sum + healthAtPoint(anchor, r.frequencyDays, t, doneAt.length === 0) ); }, 0); return Math.round(total / alive.length); }; const start = routines .map(createdOrNow) .reduce((a, b) => (Temporal.Instant.compare(a, b) <= 0 ? a : b)); if (Temporal.Instant.compare(now, start) <= 0) return [{ at: now.epochMilliseconds, health: 100 }]; const points: HealthPoint[] = []; for ( let t = start; Temporal.Instant.compare(t, now) <= 0; t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant() ) { const health = sampleAt(t); if (health !== null) points.push({ at: t.epochMilliseconds, health }); } if (points.length === 0) return [{ at: now.epochMilliseconds, health: 100 }]; // Final unaligned sample so the curve ends at the present instant. if (points[points.length - 1].at < now.epochMilliseconds) { points.push({ at: now.epochMilliseconds, 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', };