feat(02-04): mount Schedule-X CalendarShell wired to TanStack Query + Zustand + hydrateEvents

- CalendarShell.tsx: all four views (day/week/month-grid/month-agenda), eventsService + eventModal plugins
- calendarId routing: 'shared' | String(ownerUserId) via hydrateEvents, matching buildCalendarConfig keys
- SX_FIRST_DAY_OF_WEEK=7 (Sunday, Temporal convention); initial range from Zustand default (A4 guard)
- onRangeUpdate updates Zustand range, triggering TanStack Query refetch on navigation
- App.tsx: replaces EventProof landing with CalendarShell; tokens only (no hardcoded hex/px)
This commit is contained in:
Lucas Berger
2026-06-05 10:40:07 -04:00
parent dabe2ccd57
commit b79f649ac5
2 changed files with 186 additions and 107 deletions
+184
View File
@@ -0,0 +1,184 @@
/**
* 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, eventModal) 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
* - Threat T-02d-01: no dangerouslySetInnerHTML for event fields (React JSX default escaping)
*/
import { useState, useEffect, useMemo } from 'react'
import { useQuery } 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 { createEventModalPlugin } from '@schedule-x/event-modal'
import { fetchMe, fetchEvents } from '../api/client.js'
import { hydrateEvents } from '../lib/hydrateEvents.js'
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
import { useCalendarStore } from '../store/calendarStore.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 the store already has a persisted non-default value, honour it.
// Otherwise derive from viewport width.
if (typeof window === 'undefined') return 'month-grid'
return persistedView
}
// ── Component ──────────────────────────────────────────────────────────────
export function CalendarShell() {
const { calendarRange, setCalendarRange, setOpenEventId, selectedView } = useCalendarStore()
const { start, end } = calendarRange
// 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]
const eventModal = useState(() => createEventModalPlugin())[0]
// Build calendars config whenever the authenticated user changes
const calendarsConfig: Record<string, CalendarType> = useMemo(() => {
const members = meQuery.data?.user
? [
{
id: String(meQuery.data.user.id),
name: meQuery.data.user.displayName ?? 'Member',
color: meQuery.data.user.color,
},
]
: []
return buildCalendarConfig(members).calendars
}, [meQuery.data])
const defaultView = resolveDefaultView(selectedView)
// useCalendarApp — config is stable; plugins passed as second argument
const calendar = useCalendarApp(
{
views: [
createViewDay(),
createViewWeek(),
createViewMonthGrid(),
createViewMonthAgenda(),
],
defaultView,
firstDayOfWeek: SX_FIRST_DAY_OF_WEEK as 7,
calendars: calendarsConfig,
callbacks: {
onRangeUpdate(range) {
// range.start / range.end are Temporal.ZonedDateTime
// Convert to ISO date strings ('YYYY-MM-DD') for the Zustand range
setCalendarRange({
start: range.start.toPlainDate().toString(),
end: range.end.toPlainDate().toString(),
})
},
onEventClick(event) {
// Reserve event click for Plan 05 popover — store the id now
if (event.id != null) {
setOpenEventId(String(event.id))
}
},
},
},
[eventsService, eventModal],
)
// 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)
// CalendarEventExternal is structurally compatible with ScheduleXEvent;
// extra _familySync fields pass through via index signature
eventsService.set(sxEvents as Parameters<typeof eventsService.set>[0])
}, [eventsQuery.data, eventsService])
// ── Render ──────────────────────────────────────────────────────────────
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)',
}}
>
Sign-in required
</div>
)
}
return (
<div
style={{
height: '100dvh',
display: 'flex',
flexDirection: 'column',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
}}
>
{/* Loading overlay — skeleton provided by Schedule-X's built-in empty state */}
{(meQuery.isLoading || eventsQuery.isLoading) && (
<div
aria-busy="true"
aria-label="Loading calendar"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 'var(--space-1)',
background: 'var(--color-member-0)',
opacity: 0.6,
}}
/>
)}
{/* Schedule-X calendar fills available space */}
<div style={{ flex: 1, minHeight: 0 }}>
<ScheduleXCalendar calendarApp={calendar} />
</div>
</div>
)
}