/** * 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 (FIX 3): * AppNav has been lifted to App.tsx and is no longer rendered here. CalendarShell fills * its parent container (a flex:1 area in App.tsx). Auth splashes use position:fixed to * overlay everything including AppNav. * * 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, useRef } 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 { AuthSplash } from './AuthSplash.js'; import { EventDetailPopover } from './EventDetailPopover.js'; import { EventForm } from './EventForm.js'; import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'; import { SyncStateToast } from './SyncStateToast.js'; import { ColorLegend } from './ColorLegend.js'; import { SkeletonCalendar } from './SkeletonCalendar.js'; import { InstallPrompt } from './InstallPrompt.js'; // ── Helpers ──────────────────────────────────────────────────────────────── /** * IN-02: this does NOT compute the D-05 phone/desktop default — that lives in the * store's readPersistedView() (calendarStore.ts), which seeds selectedView with the * breakpoint-aware default. By the time this runs, `persistedView` already carries that * resolved default. The ONLY job here is the SSR guard: return a stable 'month-grid' * when `window` is undefined (no breakpoint to read), otherwise pass the already-resolved * view through unchanged. Do not expect breakpoint logic here. */ 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 sessionExpired = useCalendarStore((s) => s.sessionExpired); const { start, end } = calendarRange; const queryClient = useQueryClient(); // Ref to ensure the session-expiry redirect timer fires only once per expiry const sessionExpiredRedirectFired = useRef(false); // When the one-shot redirect guard is already exhausted (flag set from a prior // navigation), maybeRedirectToLogin() returns false — arm the dead-end state so // AuthSplash shows the tap-to-retry recovery instead of spinning forever (D-11). const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false); // Fetch current user to build per-member color config. // Same query key (['me']) as App.tsx — TanStack Query deduplicates, no double fetch. const meQuery = useQuery({ queryKey: ['me'], queryFn: fetchMe, retry: false, staleTime: 5 * 60 * 1000, }); // Fetch windowed occurrences — key includes start/end so navigation refetches. // // enabled: meQuery.isSuccess is load-bearing for the OIDC login flow, not just // an optimization. When unauthenticated, every /api/* request hits the OIDC // guard, which 302-redirects to Authelia AND sets a fresh state cookie. If this // query ran concurrently with fetchMe (and retried), each /api/events redirect // would overwrite the OIDC state cookie mid-login — so the state returned to // /callback no longer matched the cookie, producing OAUTH_INVALID_RESPONSE // ("unexpected state parameter") and an Internal Server Error after Authelia. // Gating on a successful /api/me means only fetchMe (redirect:'manual', // retry:false) touches a guarded endpoint while unauthenticated, so the single // top-level /api/login navigation owns the state cookie uncontested. const eventsQuery = useQuery({ queryKey: ['events', start, end], queryFn: () => fetchEvents(start, end), enabled: meQuery.isSuccess, retry: 2, staleTime: 5 * 60 * 1000, }); // Create plugins once (stable across renders) const eventsService = useState(() => createEventsServicePlugin())[0]; // Build members list from /api/me for ColorLegend (AppNav uses the same data from App.tsx) 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: SxTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; // 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, 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); }, [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 — arm // loginRedirectExhausted so the dead-end "Tap to try again" branch renders // instead of spinning indefinitely (D-11 dead-end recovery). useEffect(() => { if (meQuery.isError) { const willRedirect = maybeRedirectToLogin(); if (!willRedirect) { setLoginRedirectExhausted(true); } } }, [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". // Also reset the dead-end state in case the component is reused after auth recovery. useEffect(() => { if (meQuery.isSuccess) { clearLoginRedirect(); setLoginRedirectExhausted(false); } }, [meQuery.isSuccess]); // Session-expiry redirect (D-11) — fires when a mid-use 401 is detected by the // global QueryCache/MutationCache handler and arms the Zustand sessionExpired flag. // Re-arms the one-shot guard (clearLoginRedirect) so maybeRedirectToLogin fires // afresh, then schedules the top-level navigation after ~1.5s (UI-SPEC §Surface 2 // "≤2s before redirect, no dismiss button"). useEffect(() => { if (!sessionExpired) return; if (sessionExpiredRedirectFired.current) return; sessionExpiredRedirectFired.current = true; clearLoginRedirect(); const timer = setTimeout(() => { maybeRedirectToLogin(); }, 1500); return () => { clearTimeout(timer); }; }, [sessionExpired]); // ── Render helpers ──────────────────────────────────────────────────────── // Determine which content to show in the calendar area. // Note: meQuery.isLoading is handled above by an early AuthSplash return — it // will always be false when we reach this line (meQuery.isSuccess is guaranteed). const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data; const isEventsError = eventsQuery.isError; const phone = isPhone(); // ── Auth splash (D-10) ──────────────────────────────────────────────────── // Gate the calendar render on auth state so no calendar shell, skeleton, or // "Sign-in required" alert paints before Authelia (D-10 success criterion 5). // // Auth splashes use position:fixed (inset:0, z-index:999) to overlay the entire // viewport including the persistent AppNav in App.tsx (FIX 3). // // loading: meQuery is still pending — show the neutral "Signing you in" splash. // No skeleton, no CalendarContent — nothing app-specific until authed. // isError: meQuery failed — the maybeRedirectToLogin() useEffect fires immediately // after this render. Show the "redirecting" splash while the browser navigates. // If the one-shot guard is exhausted (flag already set), maybeRedirectToLogin // returns false and this splash stays — the dead-end "Tap to try again" is // rendered by AuthSplash's dead-end state (wired in Task 3 via sessionExpired). if (meQuery.isLoading) { return ; } if (meQuery.isError) { // loginRedirectExhausted: the one-shot guard was already set (prior navigation), // so maybeRedirectToLogin() returned false — show dead-end tap-to-retry (D-11). // Otherwise the useEffect above already fired maybeRedirectToLogin() and // the browser is navigating — show the redirecting splash while it does. if (loginRedirectExhausted) { return ; } return ; } // ── Session-expiry interstitial (D-11) ───────────────────────────────────── // When a mid-use query or mutation returns 401, the global QueryCache/MutationCache // handler sets sessionExpired=true. Show the "Session expired" interstitial while the // 1.5s timer fires (wired above in the sessionExpired useEffect). if (sessionExpired) { return ( ); } // ── Calendar content ─────────────────────────────────────────────────────── // The content panel fills its parent container (a flex:1 area set by App.tsx). // // This is a plain JSX value, NOT a nested `function CalendarContent()` rendered // as ``. A component defined inside render has a new identity // every render, so React unmounts+remounts its entire subtree — including // — on ANY CalendarShell re-render (popup/form close, post- // write events refetch). That full remount is the "calendar flash" (Bug B). As // an element value it reconciles in place across re-renders: no remount, no flash. const calendarContent = (
{/* 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 && (
)}
); // ── Layout ───────────────────────────────────────────────────────────────── // CalendarShell fills its container (App.tsx provides the outer layout with AppNav). // Both phone and desktop use the same flex column structure here since AppNav is // now outside this component. return (
{/* New Event toolbar button — desktop only (phone uses FAB below) */} {!phone && (
)} {calendarContent} {/* Phone: New Event FAB — bottom-right floating action button (UI-SPEC §Interaction Contract) */} {phone && ( )} {/* 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 */}
); }