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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}]`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user