fix(02): emit IANA-annotated timed strings from serializeTime

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
This commit is contained in:
Lucas Berger
2026-06-05 14:02:36 -04:00
parent df5d36308a
commit 35f725d450
3 changed files with 30 additions and 13 deletions
+1
View File
@@ -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"
}
+26 -13
View File
@@ -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}]`
}
/**
+3
View File
@@ -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