509 lines
22 KiB
TypeScript
509 lines
22 KiB
TypeScript
/**
|
|
* 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';
|
|
import { useIsPhone } from '../hooks/useIsPhone.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;
|
|
}
|
|
|
|
// ── 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<string, CalendarType> = 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<Parameters<typeof useCalendarApp>[0]['timezone']>;
|
|
// IN-07: memoize so this stable value never changes identity across renders —
|
|
// it feeds the useCalendarApp config the file works hard to keep stable.
|
|
const displayTimeZone = useMemo<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 = useIsPhone();
|
|
|
|
// ── 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 <AuthSplash state="loading" overlay />;
|
|
}
|
|
|
|
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 <AuthSplash state="dead-end" overlay />;
|
|
}
|
|
return <AuthSplash state="redirecting" overlay />;
|
|
}
|
|
|
|
// ── 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 (
|
|
<AuthSplash
|
|
state="redirecting"
|
|
heading="Session expired"
|
|
body="Signing you back in…"
|
|
overlay
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── 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 `<CalendarContent />`. A component defined inside render has a new identity
|
|
// every render, so React unmounts+remounts its entire subtree — including
|
|
// <ScheduleXCalendar> — 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 = (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Main calendar area */}
|
|
<div style={{ flex: 1, minHeight: 0, height: '100%', position: 'relative' }}>
|
|
{isInitialLoading ? (
|
|
// Loading state: shimmer skeleton
|
|
<div style={{ padding: 'var(--space-4)', flex: 1 }}>
|
|
<SkeletonCalendar variant={phone ? 'agenda' : 'month'} />
|
|
</div>
|
|
) : isEventsError ? (
|
|
// Error state: replace grid with heading + body + Retry button
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: 'var(--space-12)',
|
|
gap: 'var(--space-4)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
textAlign: 'center',
|
|
flex: 1,
|
|
}}
|
|
>
|
|
<h2
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 'var(--text-heading-size)',
|
|
fontWeight: 'var(--text-heading-weight)',
|
|
lineHeight: 'var(--text-heading-line-height)',
|
|
color: 'var(--color-text-primary)',
|
|
}}
|
|
>
|
|
Couldn't load events
|
|
</h2>
|
|
<p
|
|
style={{
|
|
margin: 0,
|
|
fontSize: 'var(--text-body-size)',
|
|
color: 'var(--color-text-secondary)',
|
|
}}
|
|
>
|
|
Check your connection and try again.
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
void queryClient.refetchQueries({ queryKey: ['events'] });
|
|
}}
|
|
style={{
|
|
background: 'var(--color-surface-dim)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--space-1)',
|
|
cursor: 'pointer',
|
|
minHeight: '44px',
|
|
padding: '0 var(--space-4)',
|
|
fontSize: 'var(--text-label-size)',
|
|
fontWeight: 600,
|
|
color: 'var(--color-text-primary)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
) : (
|
|
// 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.
|
|
<ScheduleXCalendar calendarApp={calendar} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Phone: show ColorLegend below toolbar, collapsed */}
|
|
{phone && !meQuery.isLoading && members.length > 0 && (
|
|
<div
|
|
style={{
|
|
padding: 'var(--space-2) var(--space-4)',
|
|
borderTop: '1px solid var(--color-border)',
|
|
background: 'var(--color-surface)',
|
|
}}
|
|
>
|
|
<ColorLegend members={members} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// ── 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 (
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
background: 'var(--color-surface)',
|
|
color: 'var(--color-text-primary)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
overflow: 'hidden',
|
|
height: '100%',
|
|
}}
|
|
>
|
|
<InstallPrompt />
|
|
|
|
{/* New Event toolbar button — desktop only (phone uses FAB below) */}
|
|
{!phone && (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
padding: 'var(--space-2) var(--space-4)',
|
|
borderBottom: '1px solid var(--color-border-subtle)',
|
|
background: 'var(--color-surface)',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={() => setEventForm(true, 'create')}
|
|
style={{
|
|
background: 'var(--color-text-primary)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
minHeight: '44px',
|
|
padding: '0 var(--space-4)',
|
|
borderRadius: 'var(--space-1)',
|
|
fontSize: 'var(--text-label-size)',
|
|
fontWeight: 600,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-1)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
>
|
|
<Plus size={16} aria-hidden="true" />
|
|
{/* Plain text — XSS guard */}
|
|
New Event
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{calendarContent}
|
|
|
|
{/* Phone: New Event FAB — bottom-right floating action button (UI-SPEC §Interaction Contract) */}
|
|
{phone && (
|
|
<button
|
|
aria-label="New Event"
|
|
onClick={() => setEventForm(true, 'create')}
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: 'calc(var(--bottom-chrome-h) + var(--space-6))',
|
|
right: 'var(--space-6)',
|
|
width: '56px',
|
|
height: '56px',
|
|
minWidth: '56px',
|
|
minHeight: '56px',
|
|
borderRadius: '50%',
|
|
background: 'var(--color-text-primary)',
|
|
color: '#ffffff',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
|
zIndex: 100,
|
|
fontFamily: 'var(--font-family-base)',
|
|
}}
|
|
>
|
|
<Plus size={24} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
|
|
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
|
<EventDetailPopover />
|
|
|
|
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
|
{eventFormOpen && <EventForm />}
|
|
|
|
{/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */}
|
|
<DeleteConfirmationDialog />
|
|
|
|
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
|
<SyncStateToast />
|
|
</div>
|
|
);
|
|
}
|