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/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 +++++++---- 11 files changed, 94 insertions(+), 44 deletions(-) (limited to 'src/lib/server') 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 -- cgit v1.3