From 35f725d45098866d418007db754de501b5c0081d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 14:02:36 -0400 Subject: [PATCH] fix(02): emit IANA-annotated timed strings from serializeTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporal.ZonedDateTime.from() rejects offset-only ISO strings such as '2026-06-18T08:00:00-04:00'; it requires an IANA bracket, e.g. '2026-06-18T08:00:00-04:00[America/New_York]'. serializeTime() was emitting offset-only for named zones and bare 'Z' for UTC — both unparseable by the frontend, blanking the calendar view. Changes: - Named IANA zone: emit '...±HH:MM[tzid]' using t.zone.tzid - UTC zone: strip trailing 'Z' from toString(), emit '+00:00[UTC]' - Floating zone (no registered VTIMEZONE): fall back to '+00:00[UTC]' - Update CalendarOccurrence docstrings to reflect the IANA-annotated contract - Add temporal-polyfill@0.3.2 as dev dep in api for cross-contract test --- apps/api/package.json | 1 + apps/api/src/broker/expand.ts | 39 +++++++++++++++++++++++------------ pnpm-lock.yaml | 3 +++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index 562087a..eb6481a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@types/node": "^22.0.0", "drizzle-kit": "0.31.10", + "temporal-polyfill": "0.3.2", "typescript": "^5.5.0", "vitest": "^4.1.8" } diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index d4556d3..2e21140 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -27,7 +27,8 @@ import ICAL from 'ical.js' * id: stable identity key = `${uid}::${startIso}` — Schedule-X uses this for dedup. * start/end: * - All-day: 'YYYY-MM-DD' (DATE string, no time component) — must use Temporal.PlainDate on client - * - Timed: offset-aware ISO-8601 e.g. '2026-06-01T10:00:00-04:00' — use Temporal.ZonedDateTime on client + * - Timed: IANA-annotated ISO-8601 e.g. '2026-06-01T10:00:00-04:00[America/New_York]' — + * Temporal.ZonedDateTime.from() requires the IANA bracket; offset-only strings throw. * * Color routing (D-06, CAL-02): * The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId) @@ -49,9 +50,9 @@ export interface CalendarOccurrence { /** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */ isShared: boolean title: string - /** 'YYYY-MM-DD' for all-day events; offset-aware ISO string for timed events */ + /** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T10:00:00-04:00[America/New_York]' */ start: string - /** 'YYYY-MM-DD' for all-day events; offset-aware ISO string for timed events */ + /** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T11:00:00-04:00[America/New_York]' */ end: string allDay: boolean location: string | null @@ -84,10 +85,12 @@ function formatUtcOffset(offsetSeconds: number): string { } /** - * Serialize an ICAL.Time to an offset-aware ISO 8601 string for timed events, + * Serialize an ICAL.Time to an IANA-annotated ISO 8601 string for timed events, * or a plain 'YYYY-MM-DD' string for all-day events. * - * For timed events: '2026-03-01T10:00:00-05:00' + * For timed events: '2026-03-01T10:00:00-05:00[America/New_York]' + * The IANA bracket is REQUIRED — Temporal.ZonedDateTime.from() throws on offset-only + * strings such as '2026-03-01T10:00:00-05:00'. See verified diagnosis in PLAN.md. * For all-day events: '2026-06-15' */ function serializeTime(t: ICAL.Time, allDay: boolean): string { @@ -99,17 +102,27 @@ function serializeTime(t: ICAL.Time, allDay: boolean): string { return `${y}-${m}-${d}` } - // Timed: build an offset-aware ISO string so the client can construct Temporal.ZonedDateTime - // ICAL.Time.toString() gives 'YYYY-MM-DDTHH:mm:ss' (no offset) — we append the offset. - const base = t.toString() // e.g. '2026-03-01T10:00:00' + // Timed: build an IANA-annotated ISO string so the client can construct Temporal.ZonedDateTime. + // ICAL.Time.toString() gives 'YYYY-MM-DDTHH:mm:ssZ' for UTC or 'YYYY-MM-DDTHH:mm:ss' for local. + // We always emit '...±HH:MM[IANA/Zone]' — the bracket is mandatory for ZonedDateTime.from(). if (t.zone === ICAL.Timezone.utcTimezone) { - // UTC zone: toString() appends 'Z' already — but toString() doesn't do this. - // We handle it explicitly. - return base + 'Z' + // UTC zone: toString() produces '...Z'; strip the Z and emit +00:00[UTC]. + const base = t.toString().replace(/Z$/, '') // e.g. '2026-03-01T10:00:00' + return `${base}+00:00[UTC]` } + + const tzid = t.zone?.tzid + // Floating zone (tzid === 'floating') has no real timezone — fall back to UTC. + // This is a safe degradation: the event had no VTIMEZONE and no offset is knowable. + if (!tzid || tzid === 'floating') { + const base = t.toString() // no trailing Z for floating + return `${base}+00:00[UTC]` + } + + // Named IANA zone: combine base datetime + offset + IANA bracket. + const base = t.toString() // e.g. '2026-03-01T10:00:00' const offsetSec = t.utcOffset() - // If zone is floating (no registered timezone), utcOffset() returns 0 — treat as UTC - return base + formatUtcOffset(offsetSec) + return `${base}${formatUtcOffset(offsetSec)}[${tzid}]` } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64b6649..affda64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: drizzle-kit: specifier: 0.31.10 version: 0.31.10 + temporal-polyfill: + specifier: 0.3.2 + version: 0.3.2 typescript: specifier: ^5.5.0 version: 5.9.3