fix(02): day-view 400, invalid date display, duplicate popover (BUG 2-4)
- 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
This commit is contained in:
@@ -6,11 +6,13 @@
|
|||||||
* → hydrateEvents (ISO → Temporal) → eventsService.set() → Schedule-X render
|
* → hydrateEvents (ISO → Temporal) → eventsService.set() → Schedule-X render
|
||||||
*
|
*
|
||||||
* Critical constraints:
|
* 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)
|
* - 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
|
* - DateRange.start/end are Temporal.ZonedDateTime → converted to 'YYYY-MM-DD' for Zustand
|
||||||
* - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed
|
* - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed
|
||||||
* by buildCalendarConfig; never the DB calendar-row id
|
* 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
|
* - Threat T-02d-01: all event fields are plain-text JSX children — no raw HTML injection
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
@@ -35,7 +37,6 @@ import {
|
|||||||
type CalendarType,
|
type CalendarType,
|
||||||
} from '@schedule-x/calendar'
|
} from '@schedule-x/calendar'
|
||||||
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
||||||
import { createEventModalPlugin } from '@schedule-x/event-modal'
|
|
||||||
|
|
||||||
import { fetchMe, fetchEvents } from '../api/client.js'
|
import { fetchMe, fetchEvents } from '../api/client.js'
|
||||||
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
||||||
@@ -88,7 +89,6 @@ export function CalendarShell() {
|
|||||||
|
|
||||||
// Create plugins once (stable across renders)
|
// Create plugins once (stable across renders)
|
||||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||||
const eventModal = useState(() => createEventModalPlugin())[0]
|
|
||||||
|
|
||||||
// Build members list from /api/me for AppNav + ColorLegend
|
// Build members list from /api/me for AppNav + ColorLegend
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
@@ -124,11 +124,13 @@ export function CalendarShell() {
|
|||||||
calendars: calendarsConfig,
|
calendars: calendarsConfig,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onRangeUpdate(range) {
|
onRangeUpdate(range) {
|
||||||
// range.start / range.end are Temporal.ZonedDateTime
|
// range.start / range.end are Temporal.ZonedDateTime.
|
||||||
// Convert to ISO date strings ('YYYY-MM-DD') for the Zustand range
|
// 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({
|
setCalendarRange({
|
||||||
start: range.start.toPlainDate().toString(),
|
start: range.start.toPlainDate().toString(),
|
||||||
end: range.end.toPlainDate().toString(),
|
end: range.end.toPlainDate().add({ days: 1 }).toString(),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onEventClick(event) {
|
onEventClick(event) {
|
||||||
@@ -138,7 +140,7 @@ export function CalendarShell() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[eventsService, eventModal],
|
[eventsService],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
||||||
@@ -263,10 +265,7 @@ export function CalendarShell() {
|
|||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
// Normal: Schedule-X calendar (primary focal point)
|
// Normal: Schedule-X calendar (primary focal point)
|
||||||
<ScheduleXCalendar
|
<ScheduleXCalendar calendarApp={calendar} />
|
||||||
calendarApp={calendar}
|
|
||||||
customComponents={{ eventModal: EventDetailPopover }}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -190,4 +190,27 @@ describe('EventDetailPopover', () => {
|
|||||||
expect(descEl.innerHTML).not.toContain('<b>')
|
expect(descEl.innerHTML).not.toContain('<b>')
|
||||||
expect(descEl.textContent).toContain('<b>Bold</b> description')
|
expect(descEl.textContent).toContain('<b>Bold</b> 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/)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ interface ScheduleXEventModalProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a start/end pair for display.
|
* 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 {
|
function formatDateTime(start: string, end: string, allDay: boolean): string {
|
||||||
if (allDay) {
|
if (allDay) {
|
||||||
@@ -65,10 +69,14 @@ function formatDateTime(start: string, end: string, allDay: boolean): string {
|
|||||||
return start
|
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 {
|
try {
|
||||||
const startDate = new Date(start)
|
const cleanStart = start.replace(/\[[^\]]*\]$/, '')
|
||||||
const endDate = new Date(end)
|
const cleanEnd = end.replace(/\[[^\]]*\]$/, '')
|
||||||
|
const startDate = new Date(cleanStart)
|
||||||
|
const endDate = new Date(cleanEnd)
|
||||||
const dateStr = startDate.toLocaleDateString(undefined, {
|
const dateStr = startDate.toLocaleDateString(undefined, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|||||||
Reference in New Issue
Block a user