diff options
Diffstat (limited to 'src/lib/shared/routine-metrics.ts')
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 285 |
1 files changed, 191 insertions, 94 deletions
diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index 4cff240..d3c1470 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -1,17 +1,24 @@ // 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 { - try { - return Temporal.Instant.from(value); - } catch { - return fallback; - } + return rawInstant(value) ?? fallback; } /** Millisecond-precision ISO stamp, byte-identical to Date.toISOString(). */ @@ -38,11 +45,9 @@ function parseCompletions( 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. - } + 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); } @@ -53,7 +58,8 @@ export interface RoutineInput { id: number; version: number; name: string; - frequencyDays: number; + /** Spec text ("7d" interval, "5/2w" quota); parsed at compute time. */ + frequency: string; createdAt: string; } @@ -66,13 +72,17 @@ export interface RoutineState { id: number; version: number; name: string; - frequencyDays: number; + /** Spec text, as stored. */ + frequency: string; lastDoneAt: string | null; - /** When the routine should next be done. */ + /** When the pending obligation must be done (window end for quotas). */ dueAt: string; - /** Days past dueAt (0 if not yet due). */ + /** Days past dueAt (0 while the deadline is still ahead). */ overdueDays: number; - /** Time past due, in units of the routine's own frequency (0 if not due). */ + /** + * 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. */ @@ -84,14 +94,25 @@ export function computeRoutineState( completions: CompletionInput[], now: Temporal.Instant, ): RoutineState { - const lastDone = maxInstant(parseCompletions(routine.id, completions)); + 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(routine.frequencyDays); + 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 : routine.frequencyDays }) + .add({ days: lastDone === null ? 0 : days }) .toInstant(); const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period; const overdueRatio = Math.max(0, rawRatio); @@ -108,10 +129,105 @@ export function computeRoutineState( id: routine.id, version: routine.version, name: routine.name, - frequencyDays: routine.frequencyDays, + frequency: routine.frequency, + lastDoneAt: lastDone !== null ? iso(lastDone) : 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<number, number>(); + 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, dueAt: iso(due), - overdueDays: Math.floor(overdueRatio * routine.frequencyDays), + overdueDays: Math.max( + 0, + Math.floor((now.epochMilliseconds - due.epochMilliseconds) / periodMs(1)), + ), overdueRatio, status, health: Math.max(0, Math.round(100 - overdueRatio * 50)), @@ -149,12 +265,37 @@ function healthAtPoint( 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); @@ -163,36 +304,23 @@ export function computeHealthSeries( 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) }); + 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: sampleAt(now) }); + points.push({ + at: now.epochMilliseconds, + health: healthAt(freq, doneAt, now, start), + }); } return points; } @@ -208,48 +336,38 @@ export function computeProjectHealthSeries( const completionsByRoutine = new Map<number, Temporal.Instant[]>(); 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. - } + 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 createdOrNow = (r: RoutineInput): Temporal.Instant => - parseInstant(r.createdAt, now); + 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 = routines.filter((r) => { - try { - return ( - Temporal.Instant.compare(Temporal.Instant.from(r.createdAt), t) <= 0 - ); - } catch { - return false; - } - }); + 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, 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); + const total = alive.reduce( + (sum, p) => sum + healthAt(p.freq, p.doneAt, t, p.created), + 0, + ); return Math.round(total / alive.length); }; - const start = routines - .map(createdOrNow) + 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 }]; @@ -272,27 +390,6 @@ export function computeProjectHealthSeries( 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<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', |
