Milestone v1.0: FamilySync MVP #1
+2
-107
@@ -1,110 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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>
|
||||
)
|
||||
}
|
||||
import { CalendarShell } from './components/CalendarShell.js'
|
||||
|
||||
export default function App() {
|
||||
const meQuery = useQuery({
|
||||
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>
|
||||
)
|
||||
return <CalendarShell />
|
||||
}
|
||||
|
||||
@@ -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