Files
familysync/apps/pwa/src/components/EventDetailPopover.tsx
T
Lucas Berger 1a24b00de9 fix(02): day-view 400, invalid date display, duplicate popover (BUG 2-4)
- BUG 2: onRangeUpdate sets exclusive end = range.end + 1 day so day view
  sends a 1-day window (start < end, no 400) and week/month include the last day
- BUG 3: formatDateTime strips IANA bracket '[Zone]' before new Date() to prevent
  'Invalid Date, Invalid Date – Invalid Date' in event popover; regression test added
- BUG 4: remove createEventModalPlugin + customComponents.eventModal — keep only
  the Zustand-driven standalone EventDetailPopover to prevent double-open fight
2026-06-05 14:34:36 -04:00

388 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<HTMLDivElement>(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 */}
<div
data-testid="popover-backdrop"
onClick={handleClose}
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay)',
zIndex: 199,
}}
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={occurrence.title}
tabIndex={-1}
style={dialogStyle}
>
{/* Header: close button */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
marginBottom: 'var(--space-2)',
}}
>
<button
aria-label="Close"
onClick={handleClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
minWidth: '44px',
minHeight: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
color: 'var(--color-text-secondary)',
borderRadius: 'var(--space-1)',
padding: 0,
}}
>
×
</button>
</div>
{/* Color chip + title */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-3)',
marginBottom: 'var(--space-4)',
}}
>
<span
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: occurrence.color,
flexShrink: 0,
marginTop: '4px',
}}
aria-hidden="true"
/>
<h2
style={{
margin: 0,
fontSize: 'var(--text-heading-size)',
fontWeight: 'var(--text-heading-weight)',
lineHeight: 'var(--text-heading-line-height)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
}}
>
{/* Plain text child only — XSS guard (T-02e-01) */}
{occurrence.title}
</h2>
</div>
{/* Date/time */}
<div
style={{
fontSize: 'var(--text-label-size)',
fontWeight: 'var(--text-label-weight)',
lineHeight: 'var(--text-label-line-height)',
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-3)',
fontFamily: 'var(--font-family-base)',
}}
>
{/* Plain text — XSS guard */}
{formatDateTime(occurrence.start, occurrence.end, occurrence.allDay)}
</div>
{/* Location (optional) */}
{occurrence.location != null && (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-2)',
fontSize: 'var(--text-label-size)',
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-3)',
fontFamily: 'var(--font-family-base)',
}}
>
<MapPin
size={14}
style={{ flexShrink: 0, marginTop: '2px' }}
aria-hidden="true"
/>
{/* Plain text — XSS guard */}
<span>{occurrence.location}</span>
</div>
)}
{/* Description (optional) — max 4 lines then scroll */}
{occurrence.description != null && (
<div
data-testid="event-description"
style={{
fontSize: 'var(--text-body-size)',
fontWeight: 'var(--text-body-weight)',
lineHeight: 'var(--text-body-line-height)',
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-4)',
maxHeight: `calc(var(--text-body-size) * var(--text-body-line-height) * 4)`,
overflowY: 'auto',
fontFamily: 'var(--font-family-base)',
// Plain text — XSS guard: rendered as {occurrence.description} text child only
whiteSpace: 'pre-wrap',
}}
>
{/* Plain text child only — XSS guard (T-02e-01) */}
{occurrence.description}
</div>
)}
{/* Calendar name + owner color swatch */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
fontSize: 'var(--text-label-size)',
color: 'var(--color-text-muted)',
paddingTop: 'var(--space-3)',
borderTop: '1px solid var(--color-border-subtle)',
fontFamily: 'var(--font-family-base)',
}}
>
<span
style={{
width: '10px',
height: '10px',
borderRadius: '2px',
background: occurrence.color,
flexShrink: 0,
}}
aria-hidden="true"
/>
{/* Plain text — XSS guard */}
{occurrence.calendarName}
</div>
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
<div
aria-hidden="true"
style={{
// Reserved: empty in Phase 2 (read-only); Phase 3 wires edit/delete buttons here
marginTop: 'var(--space-4)',
}}
/>
</div>
</>
)
}