aboutsummaryrefslogtreecommitdiff
path: root/src/lib/shared/routine-metrics.ts
diff options
context:
space:
mode:
authorMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 17:06:51 +0200
committerMartin Kagamino Lehoux <martin@lehoux.net>2026-09-08 17:06:51 +0200
commitbd2f982c489b15ce1165c051fdedf35af979f784 (patch)
tree5c76d07f94b34f6b7b31be6a133793b2cdaccd79 /src/lib/shared/routine-metrics.ts
parent6d69c7b1e6a8051338e052cc9d1836f4f1a20f51 (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.ts207
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;
}