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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user