diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-08 17:06:51 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-09-08 17:06:51 +0200 |
| commit | bd2f982c489b15ce1165c051fdedf35af979f784 (patch) | |
| tree | 5c76d07f94b34f6b7b31be6a133793b2cdaccd79 /src/lib/shared/routine-metrics.ts | |
| parent | 6d69c7b1e6a8051338e052cc9d1836f4f1a20f51 (diff) | |
Migrate date handling from Date to Temporal API
Routine scheduling, health series, service stamps, session expiry, and
date formatting move to native Temporal (Instant / ZonedDateTime in
UTC), with ISO strings kept at DB and chart boundaries. Ambient types
in src/temporal-ambient.d.ts cover the native runtime; no polyfill.
Observable behavior and stored formats are unchanged.
Diffstat (limited to 'src/lib/shared/routine-metrics.ts')
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 207 |
1 files changed, 132 insertions, 75 deletions
diff --git a/src/lib/shared/routine-metrics.ts b/src/lib/shared/routine-metrics.ts index cffeb85..953a025 100644 --- a/src/lib/shared/routine-metrics.ts +++ b/src/lib/shared/routine-metrics.ts @@ -1,14 +1,53 @@ // 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 +// All timestamps are ISO strings; calendar math uses Temporal in UTC: 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; +const UTC = 'UTC'; -/** 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()); +/** 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'; @@ -46,28 +85,22 @@ export interface RoutineState { export function computeRoutineState( routine: RoutineInput, completions: CompletionInput[], - now: number, + now: Temporal.Instant, ): 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 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 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 due = reference + .toZonedDateTimeISO(UTC) + .startOfDay() + .add({ days: routine.frequencyDays }) + .toInstant(); + const rawRatio = (now.epochMilliseconds - due.epochMilliseconds) / period; 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 + rawRatio < 0 && lastDone !== null ? 'ok' : overdueRatio < 1 ? 'due' @@ -80,8 +113,8 @@ export function computeRoutineState( version: routine.version, name: routine.name, frequencyDays: routine.frequencyDays, - lastDoneAt: lastDoneMs !== null ? new Date(lastDoneMs).toISOString() : null, - dueAt: new Date(dueMs).toISOString(), + lastDoneAt: lastDone !== null ? iso(lastDone) : null, + dueAt: iso(due), overdueDays: Math.floor(overdueRatio * routine.frequencyDays), overdueRatio, status, @@ -104,9 +137,18 @@ export interface HealthPoint { } /** 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; +function healthAtPoint( + reference: Temporal.Instant, + frequencyDays: number, + t: Temporal.Instant, +): number { + const due = reference + .toZonedDateTimeISO(UTC) + .startOfDay() + .add({ days: frequencyDays }) + .toInstant(); + const rawRatio = + (t.epochMilliseconds - due.epochMilliseconds) / periodMs(frequencyDays); return Math.max(0, Math.round(100 - Math.max(0, rawRatio) * 50)); } @@ -120,43 +162,41 @@ function healthAtPoint(refMs: number, periodMs: number, t: number): number { export function computeHealthSeries( routine: RoutineInput, completions: CompletionInput[], - now: number, + now: Temporal.Instant, ): 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 start = parseInstant(routine.createdAt, now); + const doneAt = parseCompletions(routine.id, completions); const points: HealthPoint[] = []; - if (now <= startMs) { - return [{ at: now, health: 100 }]; + if (Temporal.Instant.compare(now, start) <= 0) { + return [{ at: now.epochMilliseconds, health: 100 }]; } // Reference anchor: latest completion at-or-before the sampled instant. - let refMs = startMs; + let reference = start; let nextCompletion = 0; - const sampleAt = (t: number): number => { + const sampleAt = (t: Temporal.Instant): number => { while ( - nextCompletion < completionMs.length && - completionMs[nextCompletion] <= t + nextCompletion < doneAt.length && + Temporal.Instant.compare(doneAt[nextCompletion], t) <= 0 ) { - refMs = completionMs[nextCompletion]; + reference = doneAt[nextCompletion]; nextCompletion++; } - return healthAtPoint(refMs, periodMs, t); + return healthAtPoint(reference, routine.frequencyDays, t); }; - for (let t = startMs; t <= now; t += DAY_MS) { - points.push({ at: t, health: sampleAt(t) }); + 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) { - points.push({ at: now, health: sampleAt(now) }); + if (points[points.length - 1].at < now.epochMilliseconds) { + points.push({ at: now.epochMilliseconds, health: sampleAt(now) }); } return points; } @@ -170,54 +210,71 @@ export function computeHealthSeries( export function computeProjectHealthSeries( routines: RoutineInput[], completions: CompletionInput[], - now: number, + now: Temporal.Instant, ): HealthPoint[] { if (routines.length === 0) return []; const ids = new Set(routines.map((r) => r.id)); - const completionsByRoutine = new Map<number, number[]>(); + const completionsByRoutine = new Map<number, Temporal.Instant[]>(); 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, - ]); + 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((a, b) => a - b); + times.sort(Temporal.Instant.compare); - const createdOrNow = (r: RoutineInput): number => { - const t = Date.parse(r.createdAt); - return Number.isFinite(t) ? t : now; - }; + const createdOrNow = (r: RoutineInput): Temporal.Instant => + parseInstant(r.createdAt, 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); + 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) => d <= t, + (d) => Temporal.Instant.compare(d, t) <= 0, ); - const anchor = doneAt.length > 0 ? Math.max(...doneAt) : createdOrNow(r); - return sum + healthAtPoint(anchor, r.frequencyDays * DAY_MS, t); + const anchor = + doneAt.length > 0 ? (maxInstant(doneAt) ?? now) : createdOrNow(r); + return sum + healthAtPoint(anchor, r.frequencyDays, t); }, 0); return Math.round(total / alive.length); }; - const startMs = Math.min(...routines.map(createdOrNow)); - if (now <= startMs) return [{ at: now, health: 100 }]; + 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 = startMs; t <= now; t += DAY_MS) { + 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, health }); + if (health !== null) points.push({ at: t.epochMilliseconds, health }); } - if (points.length === 0) return [{ at: now, health: 100 }]; + 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) { - points.push({ at: now, health: sampleAt(now) ?? 100 }); + if (points[points.length - 1].at < now.epochMilliseconds) { + points.push({ at: now.epochMilliseconds, health: sampleAt(now) ?? 100 }); } return points; } |
