- Mount /api/events, /api/sse in index.ts behind oidcAuthMiddleware; /callback + /health before guard - Call startBrokerPoller() on boot (5-min ctag-poll background schedule) - Add sseRouter with GET /heartbeat (streamSSE, 10s interval) for Pangolin SSE smoke test (D-08, T-04-01) - Add CAL-08 spike script (broker/spike.ts): createFastmailClient → fetchCalendars → print calendar URLs - Add fetchEvents() to pwa/api/client.ts with typed CalendarEvent/EventsResponse shapes - Add EventProof.tsx: React Query ['events'], renders first event title+date or empty-state (CAL-01 broker proof) - Update App.tsx to render MemberBadge + EventProof on landing page - Add ical.js@2.2.1 to PWA dependencies for VEVENT summary parsing in EventProof - All 24 API unit tests green; tsc --noEmit clean in both apps/api and apps/pwa
111 lines
3.1 KiB
TypeScript
111 lines
3.1 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|