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',