fix(02): CSS-safe occurrence ids + error boundary to surface render errors
Schedule-X rejects ids containing ':' '[' ']' (the old ${uid}::${iso} form) — mint ev-<uid>-<epochMs> instead. Add an ErrorBoundary so a render throw shows the error instead of a blank page.
This commit is contained in:
@@ -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.
|
* The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId.
|
||||||
*/
|
*/
|
||||||
export interface CalendarOccurrence {
|
export interface CalendarOccurrence {
|
||||||
/** `${uid}::${startIso}` — stable unique identity for this occurrence */
|
/** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
|
||||||
id: string
|
id: string
|
||||||
uid: string
|
uid: string
|
||||||
calendarId: number
|
calendarId: number
|
||||||
@@ -72,6 +72,27 @@ export interface OccurrenceMeta {
|
|||||||
isShared: boolean
|
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-<sanitized-uid>-<epochMs>`
|
||||||
|
* - '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.
|
* Format a UTC offset (in seconds) as ±HH:MM.
|
||||||
* ICAL.Time.utcOffset() returns total seconds (positive = east of UTC).
|
* ICAL.Time.utcOffset() returns total seconds (positive = east of UTC).
|
||||||
@@ -190,7 +211,7 @@ export function expandOccurrences(
|
|||||||
const start = serializeTime(dtstart, allDay)
|
const start = serializeTime(dtstart, allDay)
|
||||||
const end = serializeTime(dtend, allDay)
|
const end = serializeTime(dtend, allDay)
|
||||||
occurrences.push({
|
occurrences.push({
|
||||||
id: `${uid}::${start}`,
|
id: makeOccurrenceId(uid, dtstart),
|
||||||
uid,
|
uid,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarName,
|
calendarName,
|
||||||
@@ -226,7 +247,7 @@ export function expandOccurrences(
|
|||||||
const end = serializeTime(occEnd, allDay)
|
const end = serializeTime(occEnd, allDay)
|
||||||
|
|
||||||
occurrences.push({
|
occurrences.push({
|
||||||
id: `${uid}::${start}`,
|
id: makeOccurrenceId(uid, next),
|
||||||
uid,
|
uid,
|
||||||
calendarId,
|
calendarId,
|
||||||
calendarName,
|
calendarName,
|
||||||
|
|||||||
@@ -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<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
state: ErrorBoundaryState = { error: null, info: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
color: '#7f1d1d',
|
||||||
|
background: '#fef2f2',
|
||||||
|
height: '100dvh',
|
||||||
|
overflow: 'auto',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginTop: 0 }}>The calendar hit an error</h2>
|
||||||
|
<p style={{ fontWeight: 600 }}>{String(this.state.error?.message ?? this.state.error)}</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
fontSize: '12px',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #fecaca',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error?.stack}
|
||||||
|
{this.state.info ? '\n\n--- component stack ---' + this.state.info : ''}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import App from './App.js'
|
import App from './App.js'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary.js'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -25,7 +26,9 @@ const queryClient = new QueryClient({
|
|||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user