aboutsummaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/lib/components/HealthChart.svelte26
-rw-r--r--src/lib/server/auth.test.ts18
-rw-r--r--src/lib/server/auth.ts17
-rw-r--r--src/lib/server/db/groceries.test.ts12
-rw-r--r--src/lib/server/db/ondelete.test.ts4
-rw-r--r--src/lib/server/services/categories.test.ts4
-rw-r--r--src/lib/server/services/groceries.test.ts20
-rw-r--r--src/lib/server/services/groceries.ts4
-rw-r--r--src/lib/server/services/references.test.ts12
-rw-r--r--src/lib/server/services/routine-actions.ts2
-rw-r--r--src/lib/server/services/routines.test.ts33
-rw-r--r--src/lib/server/services/routines.ts12
-rw-r--r--src/lib/shared/routine-metrics.test.ts116
-rw-r--r--src/lib/shared/routine-metrics.ts207
-rw-r--r--src/lib/shared/temporal.ts27
-rw-r--r--src/routes/+page.server.ts2
-rw-r--r--src/routes/projects/[id]/+page.server.ts2
-rw-r--r--src/routes/projects/[id]/+page.svelte10
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.server.ts4
-rw-r--r--src/routes/projects/[projectId]/routine/[routineId]/+page.svelte28
-rw-r--r--src/temporal-ambient.d.ts54
21 files changed, 401 insertions, 213 deletions
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 @@
<script lang="ts">
import { AreaChart, Area, Points, Text } from 'layerchart';
import type { HealthPoint } from '$lib/shared/routine-metrics';
+ import { formatDayMonth } from '$lib/shared/temporal';
let {
series,
@@ -12,11 +13,6 @@
ariaLabel: string;
} = $props();
- const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
- day: 'numeric',
- month: 'short',
- });
-
const data = $derived(
series.map((p) => ({ date: new Date(p.at), health: p.health })),
);
@@ -25,24 +21,28 @@
);
// Up to 5 x ticks spread across the series, skipping repeats (two samples
- // the same day would print the same label twice).
+ // the same day would print the same label twice). Dates stay Date objects
+ // because LayerChart's time scale consumes them; labels go through Temporal.
const xTicks = $derived.by(() => {
if (data.length < 2) return undefined;
const step = Math.max(1, Math.floor((data.length - 1) / 4));
- const ticks: Date[] = [];
+ const dayKey = (date: Date): string =>
+ Temporal.Instant.fromEpochMilliseconds(date.getTime())
+ .toString()
+ .slice(0, 10);
+ const values: Date[] = [];
for (let i = 0; i < data.length; i += step) {
const last = i + step >= data.length;
const date = last ? data[data.length - 1].date : data[i].date;
- const label = dateFormatter.format(date);
if (
- ticks.length > 0 &&
- dateFormatter.format(ticks[ticks.length - 1]) === label
+ values.length > 0 &&
+ dayKey(values[values.length - 1]) === dayKey(date)
)
continue;
- ticks.push(date);
+ values.push(date);
if (last) break;
}
- return ticks;
+ return values;
});
</script>
@@ -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<ReturnType<typeof createTestDb>>['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<void> {
...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<ProjectDashboard> {
const versions = await db
@@ -249,7 +253,7 @@ export interface ProjectsOverview {
}
export async function loadOverview(
- now: number,
+ now: Temporal.Instant,
db = defaultDb,
): Promise<ProjectsOverview> {
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<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',
+ });
+}
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 @@
<span class="routine-meta">
{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 @@
>
<span class="archived-date">
{formatFrequency(routine.frequencyDays)} · archivée le
- {dateFormatter.format(new Date(routine.archivedAt))}
+ {formatDayMonth(routine.archivedAt)}
</span>
</li>
{/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,
+ ),
+ );
</script>
<main>
@@ -60,9 +56,7 @@
{#if data.routine.archived}
<p class="archived-badge">
Archivée le
- {dateTimeFormatter.format(
- new Date(data.routine.archivedAt ?? data.routine.createdAt),
- )}
+ {formatDayMonthYear(data.routine.archivedAt ?? data.routine.createdAt)}
</p>
{/if}
@@ -144,7 +138,7 @@
<span class="chart-meta">
{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)}
<li class="item-row item-row--card completion-row">
<span class="completion-date">
- {dateTimeFormatter.format(new Date(c.doneAt))}
+ {formatDayMonthYear(c.doneAt)}
</span>
{#if c.note}
<span class="completion-note">{c.note}</span>
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 {};