// Pure routine metrics shared by server and UI; ISO timestamps, UTC calendar. import { Frequency } from './frequency'; const UTC = 'UTC'; /** Parse an ISO stamp, null when it is not a valid instant. */ function rawInstant(value: string): Temporal.Instant | null { try { return Temporal.Instant.from(value); } catch { return null; } } /** Parse an ISO stamp, falling back when it is not a valid instant. */ function parseInstant( value: string, fallback: Temporal.Instant, ): Temporal.Instant { return rawInstant(value) ?? 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, ); } /** Whole UTC-calendar days from an instant to now, floored at 0. */ function daysAgo(then: Temporal.Instant, now: Temporal.Instant): number { const days = then .toZonedDateTimeISO(UTC) .toPlainDate() .until(now.toZonedDateTimeISO(UTC).toPlainDate()).days; return Math.max(0, days); } function parseCompletions( routineId: number, completions: CompletionInput[], ): Temporal.Instant[] { const mine: Temporal.Instant[] = []; for (const c of completions) { if (c.routineId !== routineId) continue; const t = rawInstant(c.doneAt); // Skip corrupt stamps instead of poisoning the anchor. if (t !== null) mine.push(t); } return mine.sort(Temporal.Instant.compare); } export type RoutineStatus = 'ok' | 'due' | 'late' | 'veryLate'; export interface RoutineInput { id: number; version: number; name: string; /** Spec text ("7d" interval, "5/2w" quota); parsed at compute time. */ frequency: string; createdAt: string; } export interface CompletionInput { routineId: number; doneAt: string; } export interface RoutineState { id: number; version: number; name: string; /** Spec text, as stored. */ frequency: string; lastDoneAt: string | null; /** Whole UTC-calendar days since lastDoneAt (null when never done). */ daysSinceDone: number | null; /** When the pending obligation must be done (window end for quotas). */ dueAt: string; /** Days past dueAt (0 while the deadline is still ahead). */ overdueDays: number; /** * Overdue severity in units of the routine's own period: interval = time * past due; quota = trailing missed windows + elapsed window fraction. */ 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 freq = Frequency.parse(routine.frequency); const doneAt = parseCompletions(routine.id, completions); const lastDone = maxInstant(doneAt); if (freq.count !== null) { const days = freq.periodDays(); const origin = parseInstant(routine.createdAt, now) .toZonedDateTimeISO(UTC) .startOfDay(); const q = quotaProgress(freq.count, days, doneAt, now, origin.toInstant()); return quotaState(routine, freq.count, days, q, now, lastDone, origin); } const days = freq.periodDays(); const reference = lastDone ?? parseInstant(routine.createdAt, now); const period = periodMs(days); // Calendar-day anchor: the period starts at the reference day's midnight. const due = reference .toZonedDateTimeISO(UTC) .startOfDay() .add({ days: lastDone === null ? 0 : days }) .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, frequency: routine.frequency, lastDoneAt: lastDone !== null ? iso(lastDone) : null, daysSinceDone: lastDone !== null ? daysAgo(lastDone, now) : null, dueAt: iso(due), overdueDays: Math.floor(overdueRatio * days), overdueRatio, status, health: Math.max(0, Math.round(100 - overdueRatio * 50)), }; } interface QuotaProgress { /** Completions inside the current window (by doneAt; backdating-safe). */ done: number; /** Consecutive unmet windows ending just before the current one. */ missed: number; /** Elapsed fraction of the current window. */ elapsed: number; /** Index of the current window since creation day. */ window: number; } /** * Routine-anchored quota windows: window j spans * [startOfDay(createdAt) + j·days, +days). A completion counts toward the * window containing its doneAt; completions before creation count toward * window 0 and future-dated ones are ignored. */ function quotaProgress( quota: number, days: number, doneAt: Temporal.Instant[], t: Temporal.Instant, origin: Temporal.Instant, ): QuotaProgress { const period = periodMs(days); const window = Math.max( 0, Math.floor((t.epochMilliseconds - origin.epochMilliseconds) / period), ); const counts = new Map(); for (const d of doneAt) { if (Temporal.Instant.compare(d, t) > 0) continue; const j = Math.max( 0, Math.floor((d.epochMilliseconds - origin.epochMilliseconds) / period), ); counts.set(j, (counts.get(j) ?? 0) + 1); } let missed = 0; for (let j = window - 1; j >= 0; j--) { if ((counts.get(j) ?? 0) >= quota) break; missed++; } return { done: counts.get(window) ?? 0, missed, elapsed: (t.epochMilliseconds - origin.epochMilliseconds - window * period) / period, window, }; } function quotaState( routine: RoutineInput, quota: number, days: number, q: QuotaProgress, now: Temporal.Instant, lastDone: Temporal.Instant | null, origin: Temporal.ZonedDateTime, ): RoutineState { const met = q.done >= quota; const overdueRatio = met ? 0 : Math.max(0, q.missed + q.elapsed); const status: RoutineStatus = met ? 'ok' : overdueRatio < 1 ? 'due' : overdueRatio < 2 ? 'late' : 'veryLate'; // Deadline of the pending obligation: the oldest unmet window's end while // behind, the current window's end while catching up, the next one when met. const due = origin .add({ days: (met ? q.window + 2 : q.window - q.missed + 1) * days, }) .toInstant(); return { id: routine.id, version: routine.version, name: routine.name, frequency: routine.frequency, lastDoneAt: lastDone !== null ? iso(lastDone) : null, daysSinceDone: lastDone !== null ? daysAgo(lastDone, now) : null, dueAt: iso(due), overdueDays: Math.max( 0, Math.floor((now.epochMilliseconds - due.epochMilliseconds) / periodMs(1)), ), 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)); } /** Health at t: interval anchor formula or quota window formula. */ function healthAt( freq: Frequency, doneAt: Temporal.Instant[], t: Temporal.Instant, created: Temporal.Instant, ): number { if (freq.count !== null) { const origin = created.toZonedDateTimeISO(UTC).startOfDay().toInstant(); const q = quotaProgress(freq.count, freq.periodDays(), doneAt, t, origin); const overdueRatio = q.done >= freq.count ? 0 : Math.max(0, q.missed + q.elapsed); return Math.max(0, Math.round(100 - overdueRatio * 50)); } let reference = created; let neverDone = true; for (const d of doneAt) { if (Temporal.Instant.compare(d, t) > 0) break; reference = d; neverDone = false; } return healthAtPoint(reference, freq.periodDays(), t, neverDone); } /** Daily health samples: 100 at completion, decaying while overdue. */ export function computeHealthSeries( routine: RoutineInput, completions: CompletionInput[], now: Temporal.Instant, ): HealthPoint[] { const freq = Frequency.parse(routine.frequency); 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 }]; } for ( let t = start; Temporal.Instant.compare(t, now) <= 0; t = t.toZonedDateTimeISO(UTC).add({ days: 1 }).toInstant() ) { points.push({ at: t.epochMilliseconds, health: healthAt(freq, doneAt, t, start), }); } // 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: healthAt(freq, doneAt, now, start), }); } 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; const t = rawInstant(c.doneAt); if (t === null) continue; // Skip corrupt stamps instead of poisoning the anchor. completionsByRoutine.set(c.routineId, [ ...(completionsByRoutine.get(c.routineId) ?? []), t, ]); } for (const times of completionsByRoutine.values()) times.sort(Temporal.Instant.compare); const parsed = routines.map((r) => ({ born: rawInstant(r.createdAt), freq: Frequency.parse(r.frequency), created: parseInstant(r.createdAt, now), doneAt: completionsByRoutine.get(r.id) ?? [], })); // Mean per-routine health across the routines alive at t. const sampleAt = (t: Temporal.Instant): number | null => { const alive = parsed.filter( (p) => p.born !== null && Temporal.Instant.compare(p.born, t) <= 0, ); if (alive.length === 0) return null; const total = alive.reduce( (sum, p) => sum + healthAt(p.freq, p.doneAt, t, p.created), 0, ); return Math.round(total / alive.length); }; const start = parsed .map((p) => p.created) .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 const STATUS_LABELS: Record = { ok: 'À jour', due: 'À faire', late: 'En retard', veryLate: 'Très en retard', }; /** Meta stamp next to the frequency: lateness past due, else time since done. */ export function formatDoneAge( state: Pick, ): string { if (state.overdueDays > 0) { const n = state.overdueDays; return `${n} jour${n > 1 ? 's' : ''} de retard`; } if (state.daysSinceDone === null) return 'jamais fait'; if (state.daysSinceDone === 0) return "fait aujourd'hui"; if (state.daysSinceDone === 1) return 'fait hier'; return `fait il y a ${state.daysSinceDone} jours`; }