Files
familysync/apps/pwa/src/components/CalendarShell.tsx
T
Lucas Berger c2e0ab1b1f feat(260606-tv8-01): wire login redirect into CalendarShell meQuery handling
- Import maybeRedirectToLogin + clearLoginRedirect from loginRedirect.ts
- useEffect on meQuery.isError calls maybeRedirectToLogin() (one-shot, loop-guarded)
- useEffect on meQuery.isSuccess calls clearLoginRedirect() for future re-auth
- Existing 'Sign-in required' branch retained as fall-through for already-attempted case
2026-06-06 21:38:38 -04:00

465 lines
18 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:
* - 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<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']>
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<typeof eventsService.set>[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 (
<div
role="alert"
style={{
color: 'var(--color-destructive)',
padding: 'var(--space-4)',
background: 'var(--color-surface-dim)',
borderRadius: 'var(--space-2)',
fontFamily: 'var(--font-family-base)',
}}
>
Sign-in required
</div>
)
}
// ── Calendar content ───────────────────────────────────────────────────────
// The content panel (right of sidebar on desktop, full-width on phone)
function CalendarContent() {
return (
<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={() => 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>
)
}
// ── Full layout ────────────────────────────────────────────────────────────
if (phone) {
// Phone: stacked layout — AppNav top bar + content below + FAB
return (
<div
style={{
height: '100dvh',
display: 'flex',
flexDirection: 'column',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
overflow: 'hidden',
}}
>
<AppNav
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
/>
<InstallPrompt />
<CalendarContent />
<EventDetailPopover />
{/* New Event FAB — phone: bottom-right floating action button (UI-SPEC §Interaction Contract) */}
<button
aria-label="New Event"
onClick={() => setEventForm(true, 'create')}
style={{
position: 'fixed',
bottom: '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>
{/* 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>
)
}
// Tablet/Desktop: sidebar + main area
return (
<div
style={{
height: '100dvh',
display: 'flex',
flexDirection: 'row',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
overflow: 'hidden',
}}
>
{/* Left sidebar: AppNav (includes ColorLegend on desktop) */}
<AppNav
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
/>
{/* Main content area */}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<InstallPrompt />
{/* New Event toolbar button — tablet/desktop (UI-SPEC §Interaction Contract) */}
<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 />
</div>
{/* 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>
)
}