/** * 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, Edit2, Trash2 } 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 setEventForm = useCalendarStore((s) => s.setEventForm); const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog); 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: Edit / Delete actions (D-10) */}
{/* Edit button — ghost style, left-aligned */} {/* Delete button — ghost style, right-aligned, destructive color */}
); }