From bef4a83fe0c03879d095e47f80731d0cd55181f6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 14:30:32 -0400 Subject: [PATCH] fix(02): CSS-safe occurrence ids + error boundary to surface render errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule-X rejects ids containing ':' '[' ']' (the old ${uid}::${iso} form) — mint ev-- instead. Add an ErrorBoundary so a render throw shows the error instead of a blank page. --- apps/api/src/broker/expand.ts | 27 ++++++++- apps/pwa/src/components/ErrorBoundary.tsx | 69 +++++++++++++++++++++++ apps/pwa/src/main.tsx | 5 +- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 apps/pwa/src/components/ErrorBoundary.tsx diff --git a/apps/api/src/broker/expand.ts b/apps/api/src/broker/expand.ts index 2e21140..3a7f016 100644 --- a/apps/api/src/broker/expand.ts +++ b/apps/api/src/broker/expand.ts @@ -35,7 +35,7 @@ import ICAL from 'ical.js' * The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId. */ export interface CalendarOccurrence { - /** `${uid}::${startIso}` — stable unique identity for this occurrence */ + /** `ev--` — Schedule-X-safe stable id (see makeOccurrenceId) */ id: string uid: string calendarId: number @@ -72,6 +72,27 @@ export interface OccurrenceMeta { isShared: boolean } +/** + * Build a Schedule-X-safe occurrence id. + * + * Schedule-X validates event ids against `document.querySelector` — the id must be a valid + * CSS identifier (letters, digits, '-', '_'), must NOT contain ':', '[', ']', '+', and must + * NOT start with a digit. The old `${uid}::${iso}` format violated this (the ISO timestamp + * carries ':' and the '[IANA/Zone]' bracket), so eventsService.set() threw and blanked the + * calendar. + * + * Format: `ev--` + * - 'ev-' prefix guarantees a non-digit first character. + * - uid is sanitized (any non [A-Za-z0-9_-] char → '_') since iCal UIDs may contain '@', '.'. + * - epochMs (occurrence start instant) disambiguates recurring occurrences and is stable + * across refetches/windows, so Schedule-X dedup and the popover id lookup keep matching. + */ +function makeOccurrenceId(uid: string, start: ICAL.Time): string { + const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_') + const epochMs = start.toJSDate().getTime() + return `ev-${safeUid}-${epochMs}` +} + /** * Format a UTC offset (in seconds) as ±HH:MM. * ICAL.Time.utcOffset() returns total seconds (positive = east of UTC). @@ -190,7 +211,7 @@ export function expandOccurrences( const start = serializeTime(dtstart, allDay) const end = serializeTime(dtend, allDay) occurrences.push({ - id: `${uid}::${start}`, + id: makeOccurrenceId(uid, dtstart), uid, calendarId, calendarName, @@ -226,7 +247,7 @@ export function expandOccurrences( const end = serializeTime(occEnd, allDay) occurrences.push({ - id: `${uid}::${start}`, + id: makeOccurrenceId(uid, next), uid, calendarId, calendarName, diff --git a/apps/pwa/src/components/ErrorBoundary.tsx b/apps/pwa/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..191dd4a --- /dev/null +++ b/apps/pwa/src/components/ErrorBoundary.tsx @@ -0,0 +1,69 @@ +/** + * ErrorBoundary — catches render/runtime errors in the calendar tree and shows a + * readable fallback instead of a blank white page. + * + * A white-screen-on-error calendar is unacceptable UX (the non-technical Apple member + * would just see nothing). This boundary degrades gracefully and surfaces the error + * text + component stack so failures are diagnosable in the field. + */ + +import React from 'react' + +interface ErrorBoundaryProps { + children: React.ReactNode +} + +interface ErrorBoundaryState { + error: Error | null + info: string | null +} + +export class ErrorBoundary extends React.Component { + state: ErrorBoundaryState = { error: null, info: null } + + static getDerivedStateFromError(error: Error): Partial { + return { error } + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + // Log for DevTools / future telemetry; also kept in state for on-screen display. + console.error('[CalendarShell crash]', error, info.componentStack) + this.setState({ info: info.componentStack ?? null }) + } + + render() { + if (this.state.error) { + return ( +
+

The calendar hit an error

+

{String(this.state.error?.message ?? this.state.error)}

+
+            {this.state.error?.stack}
+            {this.state.info ? '\n\n--- component stack ---' + this.state.info : ''}
+          
+
+ ) + } + return this.props.children + } +} diff --git a/apps/pwa/src/main.tsx b/apps/pwa/src/main.tsx index 5af53a2..cd42445 100644 --- a/apps/pwa/src/main.tsx +++ b/apps/pwa/src/main.tsx @@ -12,6 +12,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App.js' +import { ErrorBoundary } from './components/ErrorBoundary.js' const queryClient = new QueryClient({ defaultOptions: { @@ -25,7 +26,9 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , )