Files
familysync/apps/pwa/src/lib/eventDateTime.ts
T
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
2026-06-06 22:32:10 -04:00

62 lines
2.6 KiB
TypeScript

/**
* Event date/time serialization for the write path (BUG A fix).
*
* The bug: EventForm previously sent a NAIVE local wall-clock string
* (`${date}T${time}:00`, e.g. "2026-06-07T09:00:00") with NO timezone offset.
* The API outbox worker then did `new Date(thatString)`, which Node parses in
* the SERVER container's local timezone (UTC in Docker). So "09:00" Toronto
* became 09:00 UTC, and buildVeventString serialized it as `090000Z`, which
* displays back as 05:00 EDT — a 4-hour error.
*
* The fix: serialize timed events to an UNAMBIGUOUS instant here in the browser,
* where the operator's timezone IS known. `new Date(date + 'T' + time)` is parsed
* in the browser's local zone (the operator's wall clock), and `.toISOString()`
* converts that instant to a correct UTC `...Z` string. The worker's `new Date()`
* then parses an unambiguous UTC instant regardless of container timezone, and
* the round-trip preserves the operator's wall-clock time.
*
* All-day events carry no time component and remain `YYYY-MM-DD` DATE strings
* (D-13) — they are timezone-independent by contract and must NOT be converted.
*/
/**
* Serialize the start/end of an event for the create/update payload.
*
* @param allDay when true, returns the date strings unchanged (DATE contract, D-13)
* @param startDate 'YYYY-MM-DD'
* @param startTime 'HH:MM' (ignored when allDay)
* @param endDate 'YYYY-MM-DD'
* @param endTime 'HH:MM' (ignored when allDay)
* @returns { start, end } — for timed events, ISO-8601 UTC instants ('...Z');
* for all-day events, the raw 'YYYY-MM-DD' date strings.
*/
export function serializeEventDateTime(
allDay: boolean,
startDate: string,
startTime: string,
endDate: string,
endTime: string,
): { start: string; end: string } {
if (allDay) {
// DATE contract (D-13): no time component, timezone-independent.
return { start: startDate, end: endDate }
}
// Timed: build the instant from local wall-clock parts (browser is in the
// operator's zone) and serialize to a UTC instant so the wire value is
// unambiguous and container-timezone-independent.
return {
start: localWallClockToUtcIso(startDate, startTime),
end: localWallClockToUtcIso(endDate, endTime),
}
}
/**
* Convert a local wall-clock date+time to a UTC ISO-8601 instant.
* `new Date('YYYY-MM-DDTHH:MM:00')` (no offset) is parsed in the browser's
* local timezone per ECMAScript, giving the correct instant for the operator.
*/
export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString()
}