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
+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)
})
})