diff options
Diffstat (limited to 'src/lib/shared')
| -rw-r--r-- | src/lib/shared/routine-metrics.test.ts | 116 | ||||
| -rw-r--r-- | src/lib/shared/routine-metrics.ts | 207 | ||||
| -rw-r--r-- | src/lib/shared/temporal.ts | 27 |
3 files changed, 221 insertions, 129 deletions
diff --git a/src/lib/shared/routine-metrics.test.ts b/src/lib/shared/routine-metrics.test.ts index d18fd18..b3b84a2 100644 --- a/src/lib/shared/routine-metrics.test.ts +++ b/src/lib/shared/routine-metrics.test.ts @@ -6,9 +6,19 @@ import { computeProjectHealthSeries, parseIntervalToDays, } from './routine-metrics'; - -const DAY_MS = 24 * 60 * 60 * 1000; const CREATED = '2026-09-01T00:00:00.000Z'; +const created = Temporal.Instant.from(CREATED); + +const plusDays = (base: Temporal.Instant, days: number): Temporal.Instant => + base.toZonedDateTimeISO('UTC').add({ days }).toInstant(); + +/** ISO stamp n days after the given stamp. */ +const isoPlusDays = (iso: string, days: number): string => + Temporal.Instant.from(iso) + .toZonedDateTimeISO('UTC') + .add({ days }) + .toInstant() + .toString({ smallestUnit: 'millisecond' }); const routine = { id: 1, @@ -20,33 +30,31 @@ const routine = { describe('computeRoutineState', () => { it('is due when never done, even before the due date', () => { - const now = Date.parse(CREATED) + 3 * DAY_MS; + const now = plusDays(created, 3); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); expect(state.overdueDays).toBe(0); expect(state.health).toBe(100); - expect(state.dueAt).toBe( - new Date(Date.parse(CREATED) + 7 * DAY_MS).toISOString(), - ); + expect(state.dueAt).toBe(isoPlusDays(CREATED, 7)); }); it('uses the last completion as reference once done', () => { - const doneAt = new Date(Date.parse(CREATED) + 5 * DAY_MS).toISOString(); - const now = Date.parse(doneAt) + 2 * DAY_MS; + const doneAt = isoPlusDays(CREATED, 5); + const now = plusDays(Temporal.Instant.from(doneAt), 2); const state = computeRoutineState(routine, [{ routineId: 1, doneAt }], now); expect(state.lastDoneAt).toBe(doneAt); expect(state.status).toBe('ok'); }); it('is due at exactly the frequency', () => { - const now = Date.parse(CREATED) + 7 * DAY_MS; + const now = plusDays(created, 7); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); expect(state.health).toBe(100); }); it('is very urgent (veryLate) when two full periods overdue', () => { - const now = Date.parse(CREATED) + 22 * DAY_MS; + const now = plusDays(created, 22); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('veryLate'); expect(state.overdueDays).toBe(15); @@ -54,27 +62,31 @@ describe('computeRoutineState', () => { }); it('is due (not late) when overdue by half a frequency', () => { - const now = Date.parse(CREATED) + 10.5 * DAY_MS; + const now = created + .toZonedDateTimeISO('UTC') + .add({ days: 10 }) + .toInstant() + .add({ hours: 12 }); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('due'); }); it('is late when a full period overdue', () => { - const now = Date.parse(CREATED) + 14 * DAY_MS; + const now = plusDays(created, 14); const state = computeRoutineState(routine, [], now); expect(state.status).toBe('late'); expect(state.health).toBe(50); }); it('health of 50 means a full period overdue', () => { - const now = Date.parse(CREATED) + 7 * DAY_MS + 7 * DAY_MS; + const now = plusDays(created, 14); const state = computeRoutineState(routine, [], now); expect(state.health).toBe(50); }); it('is due the calendar day after completion for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-07T20:00:00.000Z'; // yesterday evening - const now = Date.parse('2026-09-08T08:00:00.000Z'); // this morning, <24h later + const now = Temporal.Instant.from('2026-09-08T08:00:00.000Z'); // this morning, <24h later const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); expect(state.status).toBe('due'); }); @@ -86,13 +98,13 @@ describe('computeRoutineState', () => { const today = computeRoutineState( daily, completions, - Date.parse('2026-09-08T20:00:00.000Z'), + Temporal.Instant.from('2026-09-08T20:00:00.000Z'), ); expect(today.status).toBe('due'); const tomorrow = computeRoutineState( daily, completions, - Date.parse('2026-09-09T20:00:00.000Z'), + Temporal.Instant.from('2026-09-09T20:00:00.000Z'), ); expect(tomorrow.status).toBe('late'); }); @@ -100,7 +112,7 @@ describe('computeRoutineState', () => { it('is ok later the same day it was done for a daily routine', () => { const daily = { ...routine, frequencyDays: 1 }; const doneAt = '2026-09-08T08:00:00.000Z'; - const now = Date.parse('2026-09-08T20:00:00.000Z'); + const now = Temporal.Instant.from('2026-09-08T20:00:00.000Z'); const state = computeRoutineState(daily, [{ routineId: 1, doneAt }], now); expect(state.status).toBe('ok'); }); @@ -108,40 +120,47 @@ describe('computeRoutineState', () => { describe('computeHealthSeries', () => { it('decays from 100 at creation to 0 two periods overdue when never done', () => { - const now = Date.parse(CREATED) + 22 * DAY_MS; + const now = plusDays(created, 22); const series = computeHealthSeries(routine, [], now); expect(series[0].health).toBe(100); - const at8d = series.find((p) => p.at === Date.parse(CREATED) + 8 * DAY_MS); + const at8d = series.find( + (p) => p.at === plusDays(created, 8).epochMilliseconds, + ); expect(at8d?.health).toBe(93); // 1/7 of a period overdue const last = series[series.length - 1]; - expect(last.at).toBe(now); + expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(0); // more than two full periods overdue }); it('resets to 100 right after a completion, then decays again (sawtooth)', () => { - const doneAt = Date.parse(CREATED) + 5 * DAY_MS; - const now = doneAt + 13 * DAY_MS; // 13 days after the completion + const doneAt = plusDays(created, 5); + const now = plusDays(doneAt, 13); // 13 days after the completion + const doneAtIso = doneAt.toString({ smallestUnit: 'millisecond' }); const series = computeHealthSeries( routine, - [{ routineId: 1, doneAt: new Date(doneAt).toISOString() }], + [{ routineId: 1, doneAt: doneAtIso }], now, ); - const rightAfter = series.find((p) => p.at >= doneAt); + const rightAfter = series.find((p) => p.at >= doneAt.epochMilliseconds); expect(rightAfter?.health).toBe(100); // 8 days after the completion: 1 day into the 7-day period overdue. - const at8d = series.find((p) => p.at === doneAt + 8 * DAY_MS); + const at8d = series.find( + (p) => p.at === plusDays(doneAt, 8).epochMilliseconds, + ); expect(at8d?.health).toBe(93); }); it('a second completion resets the decay again', () => { - const first = Date.parse(CREATED) + 3 * DAY_MS; - const second = Date.parse(CREATED) + 10 * DAY_MS; - const now = Date.parse(CREATED) + 11 * DAY_MS; + const first = plusDays(created, 3); + const second = plusDays(created, 10); + const now = plusDays(created, 11); + const stamp = (at: Temporal.Instant): string => + at.toString({ smallestUnit: 'millisecond' }); const series = computeHealthSeries( routine, [ - { routineId: 1, doneAt: new Date(first).toISOString() }, - { routineId: 1, doneAt: new Date(second).toISOString() }, + { routineId: 1, doneAt: stamp(first) }, + { routineId: 1, doneAt: stamp(second) }, ], now, ); @@ -153,28 +172,25 @@ describe('computeHealthSeries', () => { describe('computeProjectHealthSeries', () => { it('is empty with no routines', () => { - expect(computeProjectHealthSeries([], [], Date.parse(CREATED))).toEqual([]); + expect(computeProjectHealthSeries([], [], created)).toEqual([]); }); it('averages per-routine health and ends at the current score', () => { const second = { ...routine, id: 2, name: 'read' }; - const doneAt = Date.parse(CREATED) + 5 * DAY_MS; - const now = Date.parse(CREATED) + 8 * DAY_MS; + const doneAt = plusDays(created, 5); + const now = plusDays(created, 8); + const doneAtIso = doneAt.toString({ smallestUnit: 'millisecond' }); const series = computeProjectHealthSeries( [routine, second], - [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], + [{ routineId: 2, doneAt: doneAtIso }], now, ); const states = [ computeRoutineState(routine, [], now), - computeRoutineState( - second, - [{ routineId: 2, doneAt: new Date(doneAt).toISOString() }], - now, - ), + computeRoutineState(second, [{ routineId: 2, doneAt: doneAtIso }], now), ]; const last = series[series.length - 1]; - expect(last.at).toBe(now); + expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(computeHealthScore(states)); }); @@ -183,18 +199,14 @@ describe('computeProjectHealthSeries', () => { ...routine, id: 2, name: 'latecomer', - createdAt: new Date(Date.parse(CREATED) + 10 * DAY_MS).toISOString(), + createdAt: isoPlusDays(CREATED, 10), }; - const now = Date.parse(CREATED) + 11 * DAY_MS; + const now = plusDays(created, 11); const series = computeProjectHealthSeries([routine, late], [], now); const beforeLate = series.find( - (p) => p.at === Date.parse(CREATED) + 5 * DAY_MS, - ); - const single = computeHealthSeries( - routine, - [], - Date.parse(CREATED) + 5 * DAY_MS, + (p) => p.at === plusDays(created, 5).epochMilliseconds, ); + const single = computeHealthSeries(routine, [], plusDays(created, 5)); expect(beforeLate?.health).toBe(single[single.length - 1].health); }); }); @@ -226,13 +238,9 @@ describe('computeHealthScore', () => { }); it('averages routine healths', () => { - const now = Date.parse(CREATED) + 8 * DAY_MS; + const now = plusDays(created, 8); const a = computeRoutineState(routine, [], now); // overdue 1 day - const b = computeRoutineState( - routine, - [], - Date.parse(CREATED) + 3 * DAY_MS, - ); + const b = computeRoutineState(routine, [], plusDays(created, 3)); expect(computeHealthScore([a, b])).toBe( Math.round((a.health + b.health) / 2), ); 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; } diff --git a/src/lib/shared/temporal.ts b/src/lib/shared/temporal.ts new file mode 100644 index 0000000..7097ba3 --- /dev/null +++ b/src/lib/shared/temporal.ts @@ -0,0 +1,27 @@ +// Shared Temporal display helpers. Storage and logic stay in ISO instants; +// these format one for the viewer in their own timezone. + +/** Format an ISO instant in the viewer's timezone. */ +export function formatInstant( + iso: string, + options: Intl.DateTimeFormatOptions, +): string { + return Temporal.Instant.from(iso) + .toZonedDateTimeISO(Temporal.Now.timeZoneId()) + .toLocaleString('fr-FR', options); +} + +/** Short day-month stamp ("8 sept."). */ + +export function formatDayMonth(iso: string): string { + return formatInstant(iso, { day: 'numeric', month: 'short' }); +} + +/** Day-month-year stamp ("8 sept. 2026"). */ +export function formatDayMonthYear(iso: string): string { + return formatInstant(iso, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} |
