/** * CalendarShell — Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range. * * Data flow: * Zustand calendarRange → TanStack Query ['events', start, end] → fetchEvents * → hydrateEvents (ISO → Temporal) → eventsService.set() → Schedule-X render * * Critical constraints: * - 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 * - Navigation: Schedule-X's built-in header is the sole navigation bar (Today/‹›/view switcher * + weekday-name row). The custom ViewToolbar and calendar-controls plugin have been removed. * * Layout: * - Phone (≤767px): AppNav top bar → Schedule-X (header + grid) * - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (Schedule-X header + grid) * * State branches: * isLoading (initial) → SkeletonCalendar * success + 0 occurrences → EmptyState * isError (after retry:2) → error state with Retry button * success + events → ScheduleXCalendar */ import { useState, useEffect, useMemo } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react' import { createViewDay, createViewWeek, createViewMonthGrid, createViewMonthAgenda, type CalendarType, } from '@schedule-x/calendar' import { createEventsServicePlugin } from '@schedule-x/events-service' import { Plus } from 'lucide-react' import { fetchMe, fetchEvents } from '../api/client.js' import { hydrateEvents } from '../lib/hydrateEvents.js' import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js' import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js' import { useCalendarStore } from '../store/calendarStore.js' import { EventDetailPopover } from './EventDetailPopover.js' import { EventForm } from './EventForm.js' import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js' import { SyncStateToast } from './SyncStateToast.js' import { AppNav } from './AppNav.js' import { ColorLegend } from './ColorLegend.js' import { SkeletonCalendar } from './SkeletonCalendar.js' import { InstallPrompt } from './InstallPrompt.js' // ── Helpers ──────────────────────────────────────────────────────────────── /** * D-05: phone (≤767px) defaults to 'month-agenda'; tablet/desktop to 'month-grid'. * Called once at render time; Schedule-X persists selected view internally after that. */ function resolveDefaultView(persistedView: string): string { if (typeof window === 'undefined') return 'month-grid' return persistedView } function isPhone(): boolean { return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches } // ── Component ────────────────────────────────────────────────────────────── export function CalendarShell() { // Use per-field selectors so CalendarShell does NOT subscribe to openEventId. // Without selectors, any popover open/close triggers a full re-render here, // which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B). const calendarRange = useCalendarStore((s) => s.calendarRange) const setCalendarRange = useCalendarStore((s) => s.setCalendarRange) const setOpenEventId = useCalendarStore((s) => s.setOpenEventId) const selectedView = useCalendarStore((s) => s.selectedView) const setEventForm = useCalendarStore((s) => s.setEventForm) const eventFormOpen = useCalendarStore((s) => s.eventFormOpen) const { start, end } = calendarRange const queryClient = useQueryClient() // Fetch current user to build per-member color config const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, }) // Fetch windowed occurrences — key includes start/end so navigation refetches const eventsQuery = useQuery({ queryKey: ['events', start, end], queryFn: () => fetchEvents(start, end), retry: 2, staleTime: 5 * 60 * 1000, }) // Create plugins once (stable across renders) const eventsService = useState(() => createEventsServicePlugin())[0] // Build members list from /api/me for AppNav + ColorLegend const members = useMemo(() => { if (!meQuery.data?.user) return [] return [ { id: String(meQuery.data.user.id), name: meQuery.data.user.displayName ?? 'Member', color: meQuery.data.user.color, }, ] }, [meQuery.data]) // Build calendars config whenever the authenticated user changes const calendarsConfig: Record = useMemo( () => buildCalendarConfig(members).calendars, [members], ) const defaultView = resolveDefaultView(selectedView) // Display events in the VIEWER's local timezone. Schedule-X defaults to 'UTC', which made // a 17:45-04:00 event render at 21:45 (9:45 PM). Events arrive as zoned ISO strings in their // own IANA zones (Toronto/Detroit/New_York/Edmonton); Schedule-X converts them to this one // display zone, so the family sees every event in their own wall-clock time. // IANATimezone (the config's timezone type) is declared but not exported by @schedule-x/calendar, // so derive it from useCalendarApp's config parameter rather than importing it. type SxTimeZone = NonNullable[0]['timezone']> const displayTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone as SxTimeZone // useCalendarApp — config is stable; plugins passed as second argument const calendar = useCalendarApp( { views: [ createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda(), ], defaultView, timezone: displayTimeZone, firstDayOfWeek: SX_FIRST_DAY_OF_WEEK as 7, calendars: calendarsConfig, callbacks: { onRangeUpdate(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().add({ days: 1 }).toString(), }) }, onEventClick(event) { if (event.id != null) { setOpenEventId(String(event.id)) } }, }, }, [eventsService], ) // Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard) // hydrateEvents converts ISO strings → Temporal before eventsService.set() useEffect(() => { if (!eventsQuery.data) return const sxEvents = hydrateEvents(eventsQuery.data.occurrences) eventsService.set(sxEvents as Parameters[0]) }, [eventsQuery.data, eventsService]) // Auth redirect — one-shot full-page nav to /api/login when /api/me fails. // If this is the first failure, maybeRedirectToLogin() sets a sessionStorage // flag and navigates the browser to /api/login (top-level nav, no CORS block). // The page will unmount as the browser navigates. If the flag is already set // (already bounced through login once and still failing), returns false and the // existing "Sign-in required" branch below renders. useEffect(() => { if (meQuery.isError) { maybeRedirectToLogin() } }, [meQuery.isError]) // Clear the one-shot flag on a successful /api/me load so a later session // expiry can trigger another redirect instead of showing "Sign-in required". useEffect(() => { if (meQuery.isSuccess) { clearLoginRedirect() } }, [meQuery.isSuccess]) // ── Render helpers ──────────────────────────────────────────────────────── // Determine which content to show in the calendar area const isInitialLoading = meQuery.isLoading || (eventsQuery.isLoading && !eventsQuery.data) const isEventsError = eventsQuery.isError const phone = isPhone() // ── Sign-in required ─────────────────────────────────────────────────────── if (meQuery.isError) { return (
Sign-in required
) } // ── Calendar content ─────────────────────────────────────────────────────── // The content panel (right of sidebar on desktop, full-width on phone) function CalendarContent() { return (
{/* Main calendar area */}
{isInitialLoading ? ( // Loading state: shimmer skeleton
) : isEventsError ? ( // Error state: replace grid with heading + body + Retry button

Couldn't load events

Check your connection and try again.

) : ( // Normal: Schedule-X calendar (primary focal point). // ALWAYS render the calendar even when the window has no events — its built-in // header carries the navigation, so swapping in an empty-state would strand the // user with no way to navigate away from an empty day/week. An empty grid is clear. )}
{/* Phone: show ColorLegend below toolbar, collapsed */} {phone && !meQuery.isLoading && members.length > 0 && (
)}
) } // ── Full layout ──────────────────────────────────────────────────────────── if (phone) { // Phone: stacked layout — AppNav top bar + content below + FAB return (
{/* New Event FAB — phone: bottom-right floating action button (UI-SPEC §Interaction Contract) */} {/* EventForm modal — conditionally rendered while eventFormOpen */} {eventFormOpen && } {/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */} {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
) } // Tablet/Desktop: sidebar + main area return (
{/* Left sidebar: AppNav (includes ColorLegend on desktop) */} {/* Main content area */}
{/* New Event toolbar button — tablet/desktop (UI-SPEC §Interaction Contract) */}
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */} {/* EventForm modal — conditionally rendered while eventFormOpen */} {eventFormOpen && } {/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */} {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
) }