From 1a24b00de9cdead2f0574555596860f7b7d13d94 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 14:34:36 -0400 Subject: [PATCH] fix(02): day-view 400, invalid date display, duplicate popover (BUG 2-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BUG 2: onRangeUpdate sets exclusive end = range.end + 1 day so day view sends a 1-day window (start < end, no 400) and week/month include the last day - BUG 3: formatDateTime strips IANA bracket '[Zone]' before new Date() to prevent 'Invalid Date, Invalid Date – Invalid Date' in event popover; regression test added - BUG 4: remove createEventModalPlugin + customComponents.eventModal — keep only the Zustand-driven standalone EventDetailPopover to prevent double-open fight --- apps/pwa/src/components/CalendarShell.tsx | 21 ++++++++--------- .../components/EventDetailPopover.test.tsx | 23 +++++++++++++++++++ .../pwa/src/components/EventDetailPopover.tsx | 16 +++++++++---- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 5cf0a0d..b244c0e 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -6,11 +6,13 @@ * → hydrateEvents (ISO → Temporal) → eventsService.set() → Schedule-X render * * Critical constraints: - * - Plugins (eventsService, eventModal) created once via useState stable initialiser + * - Plugins (eventsService) created once via useState stable initialiser * - onRangeUpdate fires on navigation; initial fetch uses Zustand default range (A4/Q2) * - DateRange.start/end are Temporal.ZonedDateTime → converted to 'YYYY-MM-DD' for Zustand * - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed * by buildCalendarConfig; never the DB calendar-row id + * - Event popover: driven exclusively by Zustand openEventId via onEventClick → standalone + * EventDetailPopover; createEventModalPlugin and customComponents.eventModal are NOT used * - Threat T-02d-01: all event fields are plain-text JSX children — no raw HTML injection * * Layout: @@ -35,7 +37,6 @@ import { type CalendarType, } from '@schedule-x/calendar' import { createEventsServicePlugin } from '@schedule-x/events-service' -import { createEventModalPlugin } from '@schedule-x/event-modal' import { fetchMe, fetchEvents } from '../api/client.js' import { hydrateEvents } from '../lib/hydrateEvents.js' @@ -88,7 +89,6 @@ export function CalendarShell() { // Create plugins once (stable across renders) const eventsService = useState(() => createEventsServicePlugin())[0] - const eventModal = useState(() => createEventModalPlugin())[0] // Build members list from /api/me for AppNav + ColorLegend const members = useMemo(() => { @@ -124,11 +124,13 @@ export function CalendarShell() { calendars: calendarsConfig, callbacks: { onRangeUpdate(range) { - // range.start / range.end are Temporal.ZonedDateTime - // Convert to ISO date strings ('YYYY-MM-DD') for the Zustand range + // range.start / range.end are Temporal.ZonedDateTime. + // Set end to the day AFTER range.end (exclusive window end) so that: + // - Day view: start === day N, end === day N+1 → 1-day window (avoids 400 on 0-day span) + // - Week/Month: end includes the last visible day instead of dropping it setCalendarRange({ start: range.start.toPlainDate().toString(), - end: range.end.toPlainDate().toString(), + end: range.end.toPlainDate().add({ days: 1 }).toString(), }) }, onEventClick(event) { @@ -138,7 +140,7 @@ export function CalendarShell() { }, }, }, - [eventsService, eventModal], + [eventsService], ) // Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard) @@ -263,10 +265,7 @@ export function CalendarShell() { ) : ( // Normal: Schedule-X calendar (primary focal point) - + )} diff --git a/apps/pwa/src/components/EventDetailPopover.test.tsx b/apps/pwa/src/components/EventDetailPopover.test.tsx index 762aa0d..e9197ef 100644 --- a/apps/pwa/src/components/EventDetailPopover.test.tsx +++ b/apps/pwa/src/components/EventDetailPopover.test.tsx @@ -190,4 +190,27 @@ describe('EventDetailPopover', () => { expect(descEl.innerHTML).not.toContain('') expect(descEl.textContent).toContain('Bold description') }) + + it('BUG-3 regression: IANA-bracketed start/end does not produce "Invalid Date" in rendered output', () => { + // Fastmail events are serialized with IANA bracket notation e.g. '2026-06-18T08:00:00-04:00[America/Toronto]'. + // new Date() cannot parse the bracket, so the date/time line showed "Invalid Date, Invalid Date – Invalid Date". + // After the fix, the bracket is stripped before parsing. + const occurrence: CalendarOccurrence = { + ...TIMED_OCCURRENCE, + id: 'iana-bracket-uid::1718712000000', + uid: 'iana-bracket-uid', + start: '2026-06-18T08:00:00-04:00[America/Toronto]', + end: '2026-06-18T09:00:00-04:00[America/Toronto]', + } + renderPopover(occurrence) + + // The date/time text must not contain 'Invalid Date' + const dialogEl = screen.getByRole('dialog') + expect(dialogEl.textContent).not.toContain('Invalid Date') + + // It must contain recognizable date content (month name or a digit) + // toLocaleDateString output varies by locale; check for a digit at minimum + const dateTimeText = dialogEl.textContent ?? '' + expect(dateTimeText).toMatch(/\d/) + }) }) diff --git a/apps/pwa/src/components/EventDetailPopover.tsx b/apps/pwa/src/components/EventDetailPopover.tsx index c6411f4..05f0c2d 100644 --- a/apps/pwa/src/components/EventDetailPopover.tsx +++ b/apps/pwa/src/components/EventDetailPopover.tsx @@ -48,7 +48,11 @@ interface ScheduleXEventModalProps { /** * Format a start/end pair for display. - * Handles both timed (ISO with tz) and all-day (YYYY-MM-DD) strings. + * Handles both timed (IANA-annotated ISO e.g. '2026-06-18T08:00:00-04:00[America/Toronto]') + * and all-day ('YYYY-MM-DD') strings. + * + * The IANA bracket suffix '[Zone]' is stripped before passing to new Date() because + * the built-in Date constructor cannot parse it and returns Invalid Date (BUG 3). */ function formatDateTime(start: string, end: string, allDay: boolean): string { if (allDay) { @@ -65,10 +69,14 @@ function formatDateTime(start: string, end: string, allDay: boolean): string { return start } } - // Timed — parse offset-aware ISO string + // Timed — parse offset-aware ISO string. + // Strip trailing IANA bracket e.g. '[America/Toronto]' before passing to new Date(): + // new Date() cannot parse the bracket notation and returns Invalid Date. try { - const startDate = new Date(start) - const endDate = new Date(end) + const cleanStart = start.replace(/\[[^\]]*\]$/, '') + const cleanEnd = end.replace(/\[[^\]]*\]$/, '') + const startDate = new Date(cleanStart) + const endDate = new Date(cleanEnd) const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'long',