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.
This commit is contained in:
Lucas Berger
2026-06-06 22:32:10 -04:00
parent 505f64ed93
commit a9d3de658e
9 changed files with 281 additions and 9 deletions
+15 -2
View File
@@ -40,6 +40,7 @@ import {
type RecurrencePreset,
} from '../api/client.js'
import type { CalendarOccurrence } from '../api/client.js'
import { serializeEventDateTime } from '../lib/eventDateTime.js'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -262,11 +263,23 @@ export function EventForm() {
const handleSubmit = () => {
if (!validate()) return
// BUG A fix: serialize timed events to an unambiguous UTC instant here in
// the browser (operator's zone is known) instead of sending a naive local
// wall-clock string. The API container is UTC; a naive string was being read
// as UTC, shifting 09:00 local to 09:00Z (4h off). See lib/eventDateTime.ts.
const { start: serializedStart, end: serializedEnd } = serializeEventDateTime(
allDay,
startDate,
startTime,
endDate,
endTime,
)
const payload: CreateEventPayload = {
title: title.trim(),
allDay,
start: allDay ? startDate : `${startDate}T${startTime}:00`,
end: allDay ? endDate : `${endDate}T${endTime}:00`,
start: serializedStart,
end: serializedEnd,
recurrence,
...(location.trim() ? { location: location.trim() } : {}),
...(description.trim() ? { description: description.trim() } : {}),
+53
View File
@@ -0,0 +1,53 @@
/**
* BUG A regression — write-path timezone serialization.
*
* Verifies that timed events are serialized to an unambiguous UTC instant
* (so the operator's wall-clock time round-trips correctly regardless of the
* API container's timezone), while all-day events keep their DATE strings.
*
* The PWA vitest harness runs with TZ=UTC, so the assertions are computed
* relative to the local zone (whatever it is) rather than hard-coding an offset.
* The core guarantee under test: the serialized timed value is a UTC instant
* (ends in 'Z') derived from the LOCAL wall clock — never the naive wall-clock
* string passed through verbatim, and never an instant that loses the local hour.
*/
import { describe, it, expect } from 'vitest'
import { serializeEventDateTime, localWallClockToUtcIso } from './eventDateTime.js'
describe('serializeEventDateTime (BUG A — write-path TZ)', () => {
it('serializes a timed start to a UTC instant (ends in Z)', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start.endsWith('Z')).toBe(true)
// It must NOT be the naive wall-clock string (the original bug shape).
expect(start).not.toBe('2026-06-07T09:00:00')
})
it('the serialized instant round-trips back to the SAME local wall clock', () => {
// This is the heart of BUG A: 09:00 in → 09:00 back out in the operator's zone.
const { start, end } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:30')
const startBack = new Date(start)
expect(startBack.getHours()).toBe(9)
expect(startBack.getMinutes()).toBe(0)
const endBack = new Date(end)
expect(endBack.getHours()).toBe(10)
expect(endBack.getMinutes()).toBe(30)
})
it('equals the instant new Date(local parts) produces — not a passthrough', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start).toBe(new Date('2026-06-07T09:00:00').toISOString())
})
it('leaves all-day events as DATE strings (no time, no Z) — D-13 contract', () => {
const { start, end } = serializeEventDateTime(true, '2026-06-07', '09:00', '2026-06-09', '10:00')
expect(start).toBe('2026-06-07')
expect(end).toBe('2026-06-09')
})
it('localWallClockToUtcIso round-trips a local wall clock to a UTC instant', () => {
const iso = localWallClockToUtcIso('2026-06-07', '09:00')
expect(iso.endsWith('Z')).toBe(true)
expect(new Date(iso).getHours()).toBe(9)
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* 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()
}