Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
2 changed files with 186 additions and 107 deletions
Showing only changes of commit b79f649ac5 - Show all commits
+2 -107
View File
@@ -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 />
}
+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>
)
}