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:
+2
-107
@@ -1,110 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { CalendarShell } from './components/CalendarShell.js'
|
||||||
import { fetchMe, type MeUser } from './api/client'
|
|
||||||
import { EventProof } from './components/EventProof'
|
|
||||||
|
|
||||||
interface HealthResponse {
|
|
||||||
ok: boolean
|
|
||||||
db: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchHealth(): Promise<HealthResponse> {
|
|
||||||
const res = await fetch('/health')
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Health check failed: ${res.status}`)
|
|
||||||
}
|
|
||||||
return res.json() as Promise<HealthResponse>
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColorSwatch({ color }: { color: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '1rem',
|
|
||||||
height: '1rem',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: color,
|
|
||||||
marginRight: '0.5rem',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
border: '1px solid rgba(0,0,0,0.1)',
|
|
||||||
}}
|
|
||||||
aria-label={`Color: ${color}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MemberBadge({ user }: { user: MeUser }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: '#f0f9ff',
|
|
||||||
border: `2px solid ${user.color}`,
|
|
||||||
marginBottom: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ColorSwatch color={user.color} />
|
|
||||||
<span style={{ fontWeight: 600 }}>
|
|
||||||
{user.displayName ?? 'Member'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const meQuery = useQuery({
|
return <CalendarShell />
|
||||||
queryKey: ['me'],
|
|
||||||
queryFn: fetchMe,
|
|
||||||
retry: false, // 401 triggers Authelia redirect — don't retry
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 min — session persists via refresh rotation
|
|
||||||
})
|
|
||||||
|
|
||||||
const healthQuery = useQuery({
|
|
||||||
queryKey: ['health'],
|
|
||||||
queryFn: fetchHealth,
|
|
||||||
retry: 1,
|
|
||||||
refetchInterval: 30_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif', padding: '2rem', maxWidth: '480px', margin: '0 auto' }}>
|
|
||||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>FamilySync</h1>
|
|
||||||
|
|
||||||
{/* Authenticated member identity (AUTH-03) */}
|
|
||||||
{meQuery.isLoading && (
|
|
||||||
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
|
|
||||||
)}
|
|
||||||
{meQuery.isError && (
|
|
||||||
<div style={{ color: '#991b1b', marginBottom: '1rem', padding: '0.75rem', background: '#fee2e2', borderRadius: '8px' }}>
|
|
||||||
Sign-in required
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{meQuery.data && (
|
|
||||||
<MemberBadge user={meQuery.data.user} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Broker proof: one cached Fastmail event (CAL-01) */}
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
|
||||||
<EventProof />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stack health indicator (from Plan 01) */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '1rem',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: healthQuery.isLoading ? '#f5f5f5' : healthQuery.isError ? '#fee2e2' : '#dcfce7',
|
|
||||||
color: healthQuery.isLoading ? '#666' : healthQuery.isError ? '#991b1b' : '#166534',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{healthQuery.isLoading && 'Checking stack...'}
|
|
||||||
{healthQuery.isError && 'stack: down'}
|
|
||||||
{healthQuery.data && `stack: ${healthQuery.data.ok && healthQuery.data.db === 'up' ? 'up' : 'down'}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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