From bd2f982c489b15ce1165c051fdedf35af979f784 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Tue, 8 Sep 2026 17:06:51 +0200 Subject: 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. --- src/lib/components/HealthChart.svelte | 26 +-- src/lib/server/auth.test.ts | 18 +- src/lib/server/auth.ts | 17 +- src/lib/server/db/groceries.test.ts | 12 +- src/lib/server/db/ondelete.test.ts | 4 +- src/lib/server/services/categories.test.ts | 4 +- src/lib/server/services/groceries.test.ts | 20 +- src/lib/server/services/groceries.ts | 4 +- src/lib/server/services/references.test.ts | 12 +- src/lib/server/services/routine-actions.ts | 2 +- src/lib/server/services/routines.test.ts | 33 ++-- src/lib/server/services/routines.ts | 12 +- src/lib/shared/routine-metrics.test.ts | 116 ++++++------ src/lib/shared/routine-metrics.ts | 207 +++++++++++++-------- src/lib/shared/temporal.ts | 27 +++ src/routes/+page.server.ts | 2 +- src/routes/projects/[id]/+page.server.ts | 2 +- src/routes/projects/[id]/+page.svelte | 10 +- .../routine/[routineId]/+page.server.ts | 4 +- .../[projectId]/routine/[routineId]/+page.svelte | 28 ++- src/temporal-ambient.d.ts | 54 ++++++ 21 files changed, 401 insertions(+), 213 deletions(-) create mode 100644 src/lib/shared/temporal.ts create mode 100644 src/temporal-ambient.d.ts diff --git a/src/lib/components/HealthChart.svelte b/src/lib/components/HealthChart.svelte index f1f87ed..c6121e3 100644 --- a/src/lib/components/HealthChart.svelte +++ b/src/lib/components/HealthChart.svelte @@ -1,6 +1,7 @@ @@ -60,7 +60,7 @@ props={{ xAxis: { ticks: xTicks, - format: (d: Date) => dateFormatter.format(d), + format: (d: Date) => formatDayMonth(d.toISOString()), tickLabel: xTickLabel, }, yAxis: { ticks: [25, 50, 75, 100] }, diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts index 59ddabc..d19aa03 100644 --- a/src/lib/server/auth.test.ts +++ b/src/lib/server/auth.test.ts @@ -12,7 +12,7 @@ import { const TEST_PASSWORD = vi.hoisted(() => { return 'family-password-5f2c1d7e'; }); -const NOW = 1_800_000_000_000; +const NOW = Temporal.Instant.fromEpochMilliseconds(1_800_000_000_000); vi.mock('$env/dynamic/private', () => ({ env: { AUTH_PASSWORD: TEST_PASSWORD }, @@ -27,12 +27,12 @@ describe('authentication', () => { it('accepts a signed session until its expiry', () => { const session = createSession(NOW); - expect( - isValidSession(session, NOW + SESSION_MAX_AGE_SECONDS * 1000 - 1), - ).toBe(true); - expect(isValidSession(session, NOW + SESSION_MAX_AGE_SECONDS * 1000)).toBe( - false, - ); + const almostExpired = NOW.add({ + milliseconds: SESSION_MAX_AGE_SECONDS * 1000 - 1, + }); + expect(isValidSession(session, almostExpired)).toBe(true); + const expired = NOW.add({ milliseconds: SESSION_MAX_AGE_SECONDS * 1000 }); + expect(isValidSession(session, expired)).toBe(false); expect(isValidSession(`${session}x`, NOW)).toBe(false); }); @@ -43,7 +43,9 @@ describe('authentication', () => { set: vi.fn(), } as unknown as Cookies; - expect(renewSession(cookies, true, NOW + 1_000)).toBe(true); + expect(renewSession(cookies, true, NOW.add({ milliseconds: 1_000 }))).toBe( + true, + ); expect(cookies.set).toHaveBeenCalledWith( AUTH_COOKIE_NAME, expect.any(String), diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index f80eda0..3db93f0 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -30,15 +30,19 @@ export function verifyPassword(candidate: string): boolean { return timingSafeEqual(actual, expected); } -export function createSession(now = Date.now()): string { - const expiresAt = now + SESSION_MAX_AGE_MS; +export function createSession( + now: Temporal.Instant = Temporal.Now.instant(), +): string { + const expiresAt = now.add({ + milliseconds: SESSION_MAX_AGE_MS, + }).epochMilliseconds; const payload = String(expiresAt); return `${payload}.${sign(payload)}`; } export function isValidSession( value: string | undefined, - now = Date.now(), + now: Temporal.Instant = Temporal.Now.instant(), ): boolean { if (!value) return false; @@ -46,7 +50,8 @@ export function isValidSession( if (!expiresAtText || !signature || extra) return false; const expiresAt = Number(expiresAtText); - if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= now.epochMilliseconds) + return false; const expected = Buffer.from(sign(expiresAtText), 'base64url'); const actual = Buffer.from(signature, 'base64url'); @@ -58,7 +63,7 @@ export function isValidSession( export function renewSession( cookies: Cookies, secure: boolean, - now = Date.now(), + now: Temporal.Instant = Temporal.Now.instant(), ): boolean { const current = cookies.get(AUTH_COOKIE_NAME); if (!isValidSession(current, now)) return false; @@ -76,7 +81,7 @@ export function renewSession( export function setSession( cookies: Cookies, secure: boolean, - now = Date.now(), + now: Temporal.Instant = Temporal.Now.instant(), ): void { cookies.set(AUTH_COOKIE_NAME, createSession(now), { path: '/', diff --git a/src/lib/server/db/groceries.test.ts b/src/lib/server/db/groceries.test.ts index 143e4da..7fec40b 100644 --- a/src/lib/server/db/groceries.test.ts +++ b/src/lib/server/db/groceries.test.ts @@ -29,7 +29,9 @@ describe('groceries add', () => { await db.insert(groceries).values({ name, categoryId, - createdAt: new Date().toISOString(), + createdAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), }); } } @@ -44,7 +46,9 @@ describe('groceries add', () => { }); it('unchecks an existing checked item instead of creating duplicate', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db .insert(groceries) .values({ name: 'milk', checked: true, createdAt: now }); @@ -56,7 +60,9 @@ describe('groceries add', () => { }); it('keeps an unchecked item unchanged (already on list)', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db .insert(groceries) .values({ name: 'milk', checked: false, createdAt: now }); diff --git a/src/lib/server/db/ondelete.test.ts b/src/lib/server/db/ondelete.test.ts index 347bb92..5dcf69e 100644 --- a/src/lib/server/db/ondelete.test.ts +++ b/src/lib/server/db/ondelete.test.ts @@ -18,7 +18,9 @@ describe('category delete behavior', () => { await db.insert(groceries).values({ name: 'lait', checked: false, - createdAt: new Date().toISOString(), + createdAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), categoryId: catId, }); diff --git a/src/lib/server/services/categories.test.ts b/src/lib/server/services/categories.test.ts index a89b816..dbb21fb 100644 --- a/src/lib/server/services/categories.test.ts +++ b/src/lib/server/services/categories.test.ts @@ -31,7 +31,9 @@ describe('categories service', () => { .insert(categories) .values({ name: 'Dairy' }) .returning(); - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db.insert(groceries).values({ name: 'milk', checked: false, diff --git a/src/lib/server/services/groceries.test.ts b/src/lib/server/services/groceries.test.ts index a704b57..4d9aa2f 100644 --- a/src/lib/server/services/groceries.test.ts +++ b/src/lib/server/services/groceries.test.ts @@ -18,7 +18,9 @@ describe('groceries service', () => { }); it('loadUnchecked returns only unchecked items', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db.insert(groceries).values([ { name: 'milk', checked: false, createdAt: now }, { name: 'bread', checked: true, createdAt: now }, @@ -30,7 +32,9 @@ describe('groceries service', () => { }); it('loadAll returns all items', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db.insert(groceries).values([ { name: 'milk', checked: false, createdAt: now }, { name: 'bread', checked: true, createdAt: now }, @@ -41,7 +45,9 @@ describe('groceries service', () => { }); it('toggle flips checked state', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db .insert(groceries) .values({ name: 'milk', checked: false, createdAt: now }); @@ -67,7 +73,9 @@ describe('groceries service', () => { }); it('addOrUncheck unchecks an existing checked item', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db .insert(groceries) .values({ name: 'milk', checked: true, createdAt: now }); @@ -83,7 +91,9 @@ describe('groceries service', () => { }); it('deleteGrocery removes the item', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); await db .insert(groceries) .values({ name: 'milk', checked: false, createdAt: now }); diff --git a/src/lib/server/services/groceries.ts b/src/lib/server/services/groceries.ts index 8ffcde3..9aa8102 100644 --- a/src/lib/server/services/groceries.ts +++ b/src/lib/server/services/groceries.ts @@ -96,7 +96,9 @@ export async function addOrUncheck( .values({ name, categoryId, - createdAt: new Date().toISOString(), + createdAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), }) .returning(); diff --git a/src/lib/server/services/references.test.ts b/src/lib/server/services/references.test.ts index 1ec3fdb..49c5c94 100644 --- a/src/lib/server/services/references.test.ts +++ b/src/lib/server/services/references.test.ts @@ -12,7 +12,9 @@ describe('references service', () => { }); it('loadForGrocery returns refs for the given grocery', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); const [g1] = await db .insert(groceries) .values({ name: 'coffee', checked: false, createdAt: now }) @@ -33,7 +35,9 @@ describe('references service', () => { }); it('add creates a reference', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); const [g] = await db .insert(groceries) .values({ name: 'pasta', checked: false, createdAt: now }) @@ -51,7 +55,9 @@ describe('references service', () => { }); it('deleteReference removes the ref', async () => { - const now = new Date().toISOString(); + const now = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); const [g] = await db .insert(groceries) .values({ name: 'rice', checked: false, createdAt: now }) diff --git a/src/lib/server/services/routine-actions.ts b/src/lib/server/services/routine-actions.ts index b546ec7..64d0a1b 100644 --- a/src/lib/server/services/routine-actions.ts +++ b/src/lib/server/services/routine-actions.ts @@ -68,7 +68,7 @@ export async function completeRoutineAction(data: FormData, routineId: number) { await complete( routineId, parseFormString(data.get('note')), - new Date().toISOString(), + Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }), ); } catch { return fail(400, { error: 'Routine introuvable' }); diff --git a/src/lib/server/services/routines.test.ts b/src/lib/server/services/routines.test.ts index 22b49ff..eda8824 100644 --- a/src/lib/server/services/routines.test.ts +++ b/src/lib/server/services/routines.test.ts @@ -12,8 +12,6 @@ import { loadOverview, } from './routines'; -const DAY_MS = 24 * 60 * 60 * 1000; - describe('routines service', () => { let db: Awaited>['db']; let projectId: number; @@ -84,7 +82,11 @@ describe('routines service', () => { expect(versions[0].archived).toBe(false); expect(versions[1].archived).toBe(true); - const dashboard = await loadProjectDashboard(projectId, Date.now(), db); + const dashboard = await loadProjectDashboard( + projectId, + Temporal.Now.instant(), + db, + ); expect(dashboard.routines).toHaveLength(0); expect(dashboard.healthScore).toBe(100); }); @@ -116,15 +118,19 @@ describe('routines service', () => { const archived = await loadArchived(projectId, db); expect(archived).toHaveLength(2); // Descending archive date; equal timestamps keep insertion order. - expect(new Date(archived[0].archivedAt).getTime()).toBeGreaterThanOrEqual( - new Date(archived[1].archivedAt).getTime(), + expect( + Temporal.Instant.from(archived[0].archivedAt).epochMilliseconds, + ).toBeGreaterThanOrEqual( + Temporal.Instant.from(archived[1].archivedAt).epochMilliseconds, ); }); it('complete records the snapshot version it was made against', async () => { const r = await add(projectId, 'clean kitchen', 7, db); await edit(r.id, { frequencyDays: 5 }, db); - const doneAt = new Date().toISOString(); + const doneAt = Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }); const completion = await complete(r.id, 'with vinegar', doneAt, db); expect(completion.routineVersion).toBe(2); expect(completion.note).toBe('with vinegar'); @@ -132,16 +138,21 @@ describe('routines service', () => { it('dashboard computes urgency and health from completions', async () => { const r = await add(projectId, 'clean kitchen', 7, db); - const createdAt = Date.parse(r.createdAt); - await complete(r.id, null, new Date(createdAt).toISOString(), db); + const created = Temporal.Instant.from(r.createdAt); + await complete( + r.id, + null, + created.toString({ smallestUnit: 'millisecond' }), + db, + ); // 22 days after completion with a 7-day routine => two periods overdue - const now = createdAt + 22 * DAY_MS; + const now = created.toZonedDateTimeISO('UTC').add({ days: 22 }).toInstant(); const dashboard = await loadProjectDashboard(projectId, now, db); expect(dashboard.routines).toHaveLength(1); expect(dashboard.routines[0].status).toBe('veryLate'); expect(dashboard.healthScore).toBe(0); const last = dashboard.healthSeries[dashboard.healthSeries.length - 1]; - expect(last.at).toBe(now); + expect(last.at).toBe(now.epochMilliseconds); expect(last.health).toBe(dashboard.healthScore); }); @@ -150,7 +161,7 @@ describe('routines service', () => { const growth = await addProject('Personal growth', db); await add(growth.id, 'read 30 minutes', 1, db); - const now = Date.now(); + const now = Temporal.Now.instant(); const overview = await loadOverview(now, db); expect(overview.projects).toHaveLength(2); expect(overview.globalScore).toBe( diff --git a/src/lib/server/services/routines.ts b/src/lib/server/services/routines.ts index ec03c50..a29872a 100644 --- a/src/lib/server/services/routines.ts +++ b/src/lib/server/services/routines.ts @@ -36,7 +36,9 @@ export async function add( projectId, name, frequencyDays, - createdAt: new Date().toISOString(), + createdAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), }) .returning(); return inserted; @@ -91,7 +93,9 @@ export async function archive(id: number, db = defaultDb): Promise { ...current, version: current.version + 1, archived: true, - archivedAt: new Date().toISOString(), + archivedAt: Temporal.Now.instant().toString({ + smallestUnit: 'millisecond', + }), }); } @@ -206,7 +210,7 @@ export interface ProjectDashboard { export async function loadProjectDashboard( projectId: number, - now: number, + now: Temporal.Instant, db = defaultDb, ): Promise { const versions = await db @@ -249,7 +253,7 @@ export interface ProjectsOverview { } export async function loadOverview( - now: number, + now: Temporal.Instant, db = defaultDb, ): Promise { const activeProjects = await db 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(); + const completionsByRoutine = new Map(); 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', + }); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index fc160df..fe9b4e3 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -5,7 +5,7 @@ import { parseFormString } from '$lib/server/services/validation'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { - return loadOverview(Date.now()); + return loadOverview(Temporal.Now.instant()); }; export const actions: Actions = { diff --git a/src/routes/projects/[id]/+page.server.ts b/src/routes/projects/[id]/+page.server.ts index 7cb90ff..19aea9b 100644 --- a/src/routes/projects/[id]/+page.server.ts +++ b/src/routes/projects/[id]/+page.server.ts @@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ params }) => { if (!project || project.archived) { throw error(404, 'Projet introuvable'); } - const dashboard = await loadProjectDashboard(id, Date.now()); + const dashboard = await loadProjectDashboard(id, Temporal.Now.instant()); const archived = await loadArchived(id); return { project: { id: project.id, name: project.name }, diff --git a/src/routes/projects/[id]/+page.svelte b/src/routes/projects/[id]/+page.svelte index a58aeba..9931f40 100644 --- a/src/routes/projects/[id]/+page.svelte +++ b/src/routes/projects/[id]/+page.svelte @@ -9,6 +9,7 @@ parseIntervalToDays, type RoutineState, } from '$lib/shared/routine-metrics'; + import { formatDayMonth } from '$lib/shared/temporal'; let { data, form } = $props(); @@ -30,11 +31,6 @@ : matchItems(data.archived, archiveFilter).map((m) => m.obj), ); - const dateFormatter = new Intl.DateTimeFormat('fr-FR', { - day: 'numeric', - month: 'short', - }); - function sorted(routines: RoutineState[]): RoutineState[] { return [...routines].sort( (a, b) => b.overdueRatio - a.overdueRatio || a.name.localeCompare(b.name), @@ -182,7 +178,7 @@ {formatFrequency(routine.frequencyDays)} {#if routine.lastDoneAt} - · fait le {dateFormatter.format(new Date(routine.lastDoneAt))} + · fait le {formatDayMonth(routine.lastDoneAt)} {:else} · jamais fait {/if} @@ -244,7 +240,7 @@ > {formatFrequency(routine.frequencyDays)} · archivée le - {dateFormatter.format(new Date(routine.archivedAt))} + {formatDayMonth(routine.archivedAt)} {/each} diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts index df4c2cc..bdfa452 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts @@ -26,7 +26,9 @@ export const load: PageServerLoad = async ({ params }) => { const completions = await loadCompletions(routineId); // An archived routine is frozen at its archive instant: state and chart // stop there instead of now. - const at = routine.archivedAt ? Date.parse(routine.archivedAt) : Date.now(); + const at = routine.archivedAt + ? Temporal.Instant.from(routine.archivedAt) + : Temporal.Now.instant(); return { project: { id: project.id, name: project.name }, routine, diff --git a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte index ab2644d..e593ad1 100644 --- a/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte +++ b/src/routes/projects/[projectId]/routine/[routineId]/+page.svelte @@ -8,6 +8,7 @@ parseIntervalToDays, computeHealthSeries, } from '$lib/shared/routine-metrics'; + import { formatDayMonth, formatDayMonthYear } from '$lib/shared/temporal'; let { data, form } = $props(); let name = $state(''); let frequency = $state(''); @@ -27,24 +28,19 @@ } }); const frequencyDays = $derived(parseIntervalToDays(frequency)); - const dateFormatter = new Intl.DateTimeFormat('fr-FR', { - day: 'numeric', - month: 'short', - }); - const dateTimeFormatter = new Intl.DateTimeFormat('fr-FR', { - day: 'numeric', - month: 'short', - year: 'numeric', - }); const frozenAt = $derived( data.routine.archived - ? Date.parse(data.routine.archivedAt ?? data.routine.createdAt) - : Date.now(), + ? Temporal.Instant.from(data.routine.archivedAt ?? data.routine.createdAt) + : Temporal.Now.instant(), ); const series = $derived( computeHealthSeries(data.routine, data.completions, frozenAt), ); - const markers = $derived(data.completions.map((c) => Date.parse(c.doneAt))); + const markers = $derived( + data.completions.map( + (c) => Temporal.Instant.from(c.doneAt).epochMilliseconds, + ), + );
@@ -60,9 +56,7 @@ {#if data.routine.archived}

Archivée le - {dateTimeFormatter.format( - new Date(data.routine.archivedAt ?? data.routine.createdAt), - )} + {formatDayMonthYear(data.routine.archivedAt ?? data.routine.createdAt)}

{/if} @@ -144,7 +138,7 @@ {formatFrequency(data.routine.frequencyDays)} {#if data.state.lastDoneAt} - · fait le {dateFormatter.format(new Date(data.state.lastDoneAt))} + · fait le {formatDayMonth(data.state.lastDoneAt)} {:else} · jamais fait {/if} @@ -182,7 +176,7 @@ {#each data.completions as c (c.id)}
  • - {dateTimeFormatter.format(new Date(c.doneAt))} + {formatDayMonthYear(c.doneAt)} {#if c.note} {c.note} diff --git a/src/temporal-ambient.d.ts b/src/temporal-ambient.d.ts new file mode 100644 index 0000000..f6080b0 --- /dev/null +++ b/src/temporal-ambient.d.ts @@ -0,0 +1,54 @@ +// Minimal ambient Temporal declarations (native runtime, no polyfill). +// Covers only what this app uses; extend as needed. If a future TS lib +// ships Temporal types, these merge with them. + +declare global { + namespace Temporal { + type TimeZoneId = string; + type DurationLike = { + days?: number; + hours?: number; + milliseconds?: number; + }; + + interface Duration { + total(options: { unit: 'millisecond' | 'day' }): number; + } + namespace Duration { + function from(durationLike: DurationLike): Duration; + } + + interface Instant { + readonly epochMilliseconds: number; + add(durationLike: { hours?: number; milliseconds?: number }): Instant; + toString(options?: { smallestUnit?: 'millisecond' }): string; + toZonedDateTimeISO(timeZone: TimeZoneId): ZonedDateTime; + toLocaleString( + locales?: string | string[], + options?: Intl.DateTimeFormatOptions, + ): string; + } + namespace Instant { + function compare(a: string | Instant, b: string | Instant): number; + function from(iso: string): Instant; + function fromEpochMilliseconds(ms: number): Instant; + } + + interface ZonedDateTime { + add(durationLike: DurationLike): ZonedDateTime; + startOfDay(): ZonedDateTime; + toInstant(): Instant; + toLocaleString( + locales?: string | string[], + options?: Intl.DateTimeFormatOptions, + ): string; + } + + namespace Now { + function instant(): Instant; + function timeZoneId(): TimeZoneId; + } + } +} + +export {}; -- cgit v1.3