Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
4 changed files with 397 additions and 6 deletions
Showing only changes of commit 3eebfbff42 - Show all commits
+8 -1
View File
@@ -31,6 +31,7 @@ import { fetchMe, fetchEvents } from '../api/client.js'
import { hydrateEvents } from '../lib/hydrateEvents.js'
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
import { useCalendarStore } from '../store/calendarStore.js'
import { EventDetailPopover } from './EventDetailPopover.js'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -177,8 +178,14 @@ export function CalendarShell() {
{/* Schedule-X calendar fills available space */}
<div style={{ flex: 1, minHeight: 0 }}>
<ScheduleXCalendar calendarApp={calendar} />
<ScheduleXCalendar
calendarApp={calendar}
customComponents={{ eventModal: EventDetailPopover }}
/>
</div>
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
<EventDetailPopover />
</div>
)
}
@@ -31,7 +31,9 @@ vi.mock('../store/calendarStore.js', () => ({
// ── Fixtures ───────────────────────────────────────────────────────────────────
const TIMED_OCCURRENCE = {
import type { CalendarOccurrence } from '../api/client.js'
const TIMED_OCCURRENCE: CalendarOccurrence = {
id: 'test-uid::2026-06-15T10:00:00',
uid: 'test-uid',
calendarId: 1,
@@ -47,7 +49,7 @@ const TIMED_OCCURRENCE = {
description: 'Daily team sync meeting',
}
const OCCURRENCE_WITH_HTML = {
const OCCURRENCE_WITH_HTML: CalendarOccurrence = {
...TIMED_OCCURRENCE,
id: 'xss-uid::2026-06-15T10:00:00',
uid: 'xss-uid',
@@ -56,7 +58,7 @@ const OCCURRENCE_WITH_HTML = {
location: '<img src=x onerror=alert(1)>Room',
}
const ALLDAY_OCCURRENCE = {
const ALLDAY_OCCURRENCE: CalendarOccurrence = {
id: 'allday-uid::2026-06-20',
uid: 'allday-uid',
calendarId: 2,
@@ -81,7 +83,7 @@ import { useCalendarStore } from '../store/calendarStore.js'
function renderPopover(occurrence = TIMED_OCCURRENCE) {
mockOpenEventId = occurrence.id
;(useCalendarStore as ReturnType<typeof vi.fn>).mockImplementation(() => ({
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
openEventId: mockOpenEventId,
setOpenEventId: mockSetOpenEventId,
}))
@@ -157,7 +159,7 @@ describe('EventDetailPopover', () => {
it('renders nothing when openEventId is null', () => {
mockOpenEventId = null
;(useCalendarStore as ReturnType<typeof vi.fn>).mockImplementation(() => ({
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
openEventId: null,
setOpenEventId: mockSetOpenEventId,
}))
@@ -0,0 +1,379 @@
/**
* 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 (ISO with tz) and all-day (YYYY-MM-DD) strings.
*/
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
try {
const startDate = new Date(start)
const endDate = new Date(end)
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>
</>
)
}
+3
View File
@@ -10,6 +10,9 @@
* from viewport width at store initialisation time.
*/
// Extend Vitest's expect with jest-dom matchers (toHaveTextContent, etc.)
import '@testing-library/jest-dom'
// Polyfill window.matchMedia for jsdom.
// jsdom does not implement the CSSOM MediaQueryList API.
// calendarStore.ts calls window.matchMedia during Zustand create(), so this