/** * EventDetailPopover — read-only event detail overlay (Phase 2). * * Phase 3 note: This component is built for reuse as the edit surface. * Reserve the footer action area (marked below) — Phase 3 adds edit/delete here (D-08). * * Rendering modes: * 1. Driven by Zustand openEventId + TanStack Query cache (primary mode) * 2. Passed calendarEvent prop by Schedule-X customComponents.eventModal * * Responsive: * - Phone (≤767px): bottom sheet (slides from bottom) * - Tablet/desktop (≥768px): anchored popover (max-width 360px) * * Security: T-02e-01 — all event fields rendered as plain-text JSX children. * NEVER inject raw HTML for any event field. All fields are plain-text JSX children. * * Accessibility: * - Focus trap while open * - Escape closes and returns focus to triggering element * - Close button: aria-label="Close", min 44px touch target * - aria-modal="true", role="dialog" */ import { useEffect, useRef } from 'react' import { useQueryClient } from '@tanstack/react-query' import { MapPin } from 'lucide-react' import { useCalendarStore } from '../store/calendarStore.js' import type { CalendarOccurrence } from '../api/client.js' // ── Types ────────────────────────────────────────────────────────────────── /** Shape of props passed by Schedule-X customComponents.eventModal */ interface ScheduleXEventModalProps { calendarEvent?: { id?: string | number title?: string start?: unknown end?: unknown calendarId?: string location?: string description?: string _familySync?: { uid: string; color: string; isShared: boolean } } } // ── Helpers ──────────────────────────────────────────────────────────────── /** * Format a start/end pair for display. * Handles both timed (IANA-annotated ISO e.g. '2026-06-18T08:00:00-04:00[America/Toronto]') * and all-day ('YYYY-MM-DD') strings. * * The IANA bracket suffix '[Zone]' is stripped before passing to new Date() because * the built-in Date constructor cannot parse it and returns Invalid Date (BUG 3). */ function formatDateTime(start: string, end: string, allDay: boolean): string { if (allDay) { // YYYY-MM-DD — format as a date without time try { const d = new Date(start + 'T00:00:00') return d.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'long', day: 'numeric', }) } catch { return start } } // Timed — parse offset-aware ISO string. // Strip trailing IANA bracket e.g. '[America/Toronto]' before passing to new Date(): // new Date() cannot parse the bracket notation and returns Invalid Date. try { const cleanStart = start.replace(/\[[^\]]*\]$/, '') const cleanEnd = end.replace(/\[[^\]]*\]$/, '') const startDate = new Date(cleanStart) const endDate = new Date(cleanEnd) const dateStr = startDate.toLocaleDateString(undefined, { weekday: 'short', month: 'long', day: 'numeric', }) const startTime = startDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', }) const endTime = endDate.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', }) return `${dateStr}, ${startTime} – ${endTime}` } catch { return start } } // ── Component ────────────────────────────────────────────────────────────── /** * EventDetailPopover renders when openEventId is non-null (Zustand) or when * Schedule-X passes a calendarEvent prop (customComponents.eventModal mode). * * In Schedule-X modal mode the component receives props from the library. * In standalone mode it resolves the event from TanStack Query cache. */ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) { const { openEventId, setOpenEventId } = useCalendarStore() const queryClient = useQueryClient() const dialogRef = useRef(null) // Resolve the event to display: // 1. If Schedule-X passed a calendarEvent prop, use it to get the id // 2. Otherwise use Zustand openEventId const activeId: string | null = (() => { if (props.calendarEvent?.id != null) { return String(props.calendarEvent.id) } return openEventId })() // Lookup the occurrence in TanStack Query cache. // We search all 'events' query entries for a matching id. const occurrence: CalendarOccurrence | null = (() => { if (!activeId) return null // queryClient.getQueriesData returns [{queryKey, data}] entries const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({ queryKey: ['events'], }) for (const [, data] of allEntries) { if (!data?.occurrences) continue const found = data.occurrences.find((o) => o.id === activeId) if (found) return found } return null })() // Close handler const handleClose = () => setOpenEventId(null) // Escape key listener — add to document so it works even when focus is trapped useEffect(() => { if (!activeId) return const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { handleClose() } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [activeId]) // eslint-disable-line react-hooks/exhaustive-deps // Focus trap — when popover opens, focus the dialog useEffect(() => { if (activeId && dialogRef.current) { dialogRef.current.focus() } }, [activeId]) // Nothing to show if (!activeId || !occurrence) return null // Responsive: detect phone breakpoint const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches const dialogStyle: React.CSSProperties = isPhone ? { // Phone: bottom sheet position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--color-surface-raised)', borderRadius: 'var(--space-3) var(--space-3) 0 0', boxShadow: '0 -4px 24px rgba(0,0,0,0.12)', padding: 'var(--space-6)', maxHeight: '80dvh', overflowY: 'auto', zIndex: 200, fontFamily: 'var(--font-family-base)', } : { // Tablet/desktop: centered popover (max-width 360px) position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'var(--color-surface-raised)', borderRadius: 'var(--space-2)', boxShadow: '0 8px 32px rgba(0,0,0,0.16)', padding: 'var(--space-6)', width: '100%', maxWidth: '360px', maxHeight: '80dvh', overflowY: 'auto', zIndex: 200, fontFamily: 'var(--font-family-base)', } return ( <> {/* Backdrop */}
{/* Dialog */}
{/* Header: close button */}
{/* Color chip + title */}
{/* Date/time */}
{/* Plain text — XSS guard */} {formatDateTime(occurrence.start, occurrence.end, occurrence.allDay)}
{/* Location (optional) */} {occurrence.location != null && (
)} {/* Description (optional) — max 4 lines then scroll */} {occurrence.description != null && (
{/* Plain text child only — XSS guard (T-02e-01) */} {occurrence.description}
)} {/* Calendar name + owner color swatch */}
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */} ) }