- Create hydrateEvents.ts: Temporal.PlainDate for allDay, ZonedDateTime for timed calendarId routes via isShared ? 'shared' : String(ownerUserId) — NOT String(calendarId) _familySync carries uid/color/isShared through to popover - Update hydrateEvents.test.ts: add temporal-polyfill/global import; all 4 RED stubs now GREEN - Create calendarStore.ts: Zustand store with selectedView (localStorage per breakpoint group), selectedDate, openEventId, calendarRange; D-05 view defaults; calendarRange ± buffer for initial TanStack Query key without depending on onRangeUpdate firing on mount - Update client.ts: add CalendarOccurrence/OccurrencesResponse, windowed fetchEvents(start,end) with credentials:include; keep legacy CalendarEvent/EventsResponse + fetchEventsLegacy as deprecated for EventProof.tsx (removed in Plan 05) - Update EventProof.tsx: switch to fetchEventsLegacy to keep build clean until Plan 05 - tsc --noEmit clean; all 18 PWA tests pass
145 lines
3.8 KiB
TypeScript
145 lines
3.8 KiB
TypeScript
/**
|
|
* EventProof — renders one cached event from /api/events as broker proof.
|
|
*
|
|
* This is the Phase 1 landing page's broker-proof component: it shows a
|
|
* single cached event title and date, confirming the CalDAV broker has
|
|
* successfully fetched and cached at least one real Fastmail event (CAL-01).
|
|
*
|
|
* Data flow:
|
|
* React Query ['events'] → fetchEvents() → GET /api/events
|
|
* → renders first event's summary (parsed from rawVevent) and date
|
|
*
|
|
* Empty state: "No cached events yet" — shown when the poller hasn't run yet.
|
|
* This is expected on first boot before any Fastmail credentials are loaded.
|
|
*/
|
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
// fetchEventsLegacy: Phase 1 broker-proof; replaced by windowed fetchEvents in Plan 04.
|
|
// This component is removed in Plan 05.
|
|
import { fetchEventsLegacy, type CalendarEvent } from '../api/client'
|
|
import ICAL from 'ical.js'
|
|
|
|
/**
|
|
* Extracts the event summary (title) from a raw VCALENDAR/VEVENT string.
|
|
* Falls back to 'Untitled event' if parsing fails or SUMMARY is absent.
|
|
*/
|
|
function extractSummary(rawVevent: string): string {
|
|
try {
|
|
const parsed = ICAL.parse(rawVevent)
|
|
const comp = new ICAL.Component(parsed)
|
|
const vevent = comp.getFirstSubcomponent('vevent')
|
|
if (!vevent) return 'Untitled event'
|
|
return (vevent.getFirstPropertyValue('summary') as string | null) ?? 'Untitled event'
|
|
} catch {
|
|
return 'Untitled event'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats the event date for display.
|
|
* Uses dtstartDate (for all-day) or dtstartUtc (for timed events).
|
|
*/
|
|
function formatDate(event: CalendarEvent): string {
|
|
if (event.allDay && event.dtstartDate) {
|
|
return event.dtstartDate
|
|
}
|
|
if (event.dtstartUtc) {
|
|
try {
|
|
return new Date(event.dtstartUtc).toLocaleDateString(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
} catch {
|
|
return event.dtstartUtc
|
|
}
|
|
}
|
|
return 'Date unknown'
|
|
}
|
|
|
|
export function EventProof() {
|
|
const eventsQuery = useQuery({
|
|
queryKey: ['events-legacy'],
|
|
queryFn: fetchEventsLegacy,
|
|
retry: false,
|
|
staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval
|
|
})
|
|
|
|
if (eventsQuery.isLoading) {
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: '1rem',
|
|
borderRadius: '8px',
|
|
background: '#f5f5f5',
|
|
color: '#666',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
Loading calendar events...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (eventsQuery.isError) {
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: '0.75rem 1rem',
|
|
borderRadius: '8px',
|
|
background: '#fee2e2',
|
|
color: '#991b1b',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
Could not load events
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const events = eventsQuery.data?.events ?? []
|
|
|
|
if (events.length === 0) {
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: '0.75rem 1rem',
|
|
borderRadius: '8px',
|
|
background: '#fef9c3',
|
|
color: '#713f12',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
No cached events yet — broker poller will run in the next 5 minutes.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const first = events[0]
|
|
const summary = extractSummary(first.rawVevent)
|
|
const date = formatDate(first)
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: '0.75rem 1rem',
|
|
borderRadius: '8px',
|
|
background: '#f0fdf4',
|
|
border: '1px solid #bbf7d0',
|
|
fontSize: '0.875rem',
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 600, color: '#166534', marginBottom: '0.25rem' }}>
|
|
Broker proof — 1 event cached
|
|
</div>
|
|
<div style={{ color: '#15803d' }}>
|
|
{summary}
|
|
</div>
|
|
<div style={{ color: '#4ade80', fontSize: '0.75rem', marginTop: '0.25rem' }}>
|
|
{date}
|
|
{events.length > 1 && ` (+${events.length - 1} more)`}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|