diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index def00de..d3bf3d8 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -45,6 +45,7 @@ import { computeNewTimedEnd, computeNewAllDayEnd, } from '../lib/eventDateTime.js' +import { SeriesEditPrompt } from './SeriesEditPrompt.js' // ── Constants ───────────────────────────────────────────────────────────────── @@ -276,6 +277,8 @@ export function EventForm() { // ── Validation state ──────────────────────────────────────────────────────── const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({}) + // D-08/D-09: series-edit confirmation prompt state + const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false) // ── Mutations ─────────────────────────────────────────────────────────────── @@ -360,9 +363,13 @@ export function EventForm() { return Object.keys(newErrors).length === 0 } - const handleSubmit = () => { - if (!validate()) return - + /** + * Build the payload and fire the mutation. Called directly for non-recurring edits + * and create mode; called from the SeriesEditPrompt onConfirm for recurring edits + * (D-08/D-09). The whole-series PATCH reuses the existing /api/events/:uid/edit route + * which PUTs the master VEVENT — no RECURRENCE-ID, per D-08. + */ + const executeSubmit = () => { // BUG A fix: serialize timed events to an unambiguous UTC instant here in // the browser (operator's zone is known) instead of sending a naive local // wall-clock string. The API container is UTC; a naive string was being read @@ -403,6 +410,20 @@ export function EventForm() { mutation.mutate(payload) } + const handleSubmit = () => { + if (!validate()) return + + const isEdit = eventFormMode === 'edit' && !!eventFormUid + + // D-08/D-09: gate recurring-series edits behind the confirmation prompt + if (isEdit && occurrence?.hasRrule === true) { + setSeriesEditPromptOpen(true) + return + } + + executeSubmit() + } + // ── Keyboard: Escape to close ─────────────────────────────────────────────── useEffect(() => { @@ -466,11 +487,15 @@ export function EventForm() { const isPhone = isPhoneBreakpoint() const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event' + // D-08/D-09: CTA label is "Update series" for recurring edits (UI-SPEC "EventForm primary CTAs") + const isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true const saveLabel = mutation.isPending ? 'Saving…' - : eventFormMode === 'edit' - ? 'Save Changes' - : 'Create Event' + : isEditRecurring + ? 'Update series' + : eventFormMode === 'edit' + ? 'Save Changes' + : 'Create Event' const dialogStyle: React.CSSProperties = isPhone ? { @@ -1014,6 +1039,16 @@ export function EventForm() { + + {/* D-08/D-09: Series-edit confirmation prompt — shown when editing a recurring occurrence */} + { + setSeriesEditPromptOpen(false) + executeSubmit() + }} + onCancel={() => setSeriesEditPromptOpen(false)} + /> ) } diff --git a/apps/pwa/src/components/SeriesEditPrompt.tsx b/apps/pwa/src/components/SeriesEditPrompt.tsx new file mode 100644 index 0000000..e1a586e --- /dev/null +++ b/apps/pwa/src/components/SeriesEditPrompt.tsx @@ -0,0 +1,223 @@ +/** + * SeriesEditPrompt — whole-series edit confirmation sheet/dialog (Plan 06-06). + * + * D-08/D-09: Editing a recurring occurrence must confirm before submitting the + * whole-series PATCH (which PUTs the master VEVENT back to Fastmail without a + * RECURRENCE-ID, updating all occurrences). This prompt gates that submit. + * + * Trigger: EventForm calls setOpen(true) when in edit mode with occurrence.hasRrule===true. + * Confirm: "Update series" — runs the existing whole-series PATCH; no new privilege. + * Cancel: Escape or "Cancel" button — returns to the form without submitting. + * + * Layout (Surface 6 — UI-SPEC): + * - Phone (≤767px): bottom sheet (full-screen overlay, slide from below) + * - Tablet/desktop (≥768px): centered dialog (max-width 480px) + * + * Accessibility: + * - role="dialog", aria-modal="true", aria-labelledby → heading + * - Focus trap: Tab/Shift+Tab cycle between Cancel and Update series + * - Escape key fires Cancel + * + * Security: T-03-15 — all text rendered as plain-text JSX children. + * NEVER use dangerouslySetInnerHTML here. + */ + +import { useEffect, useRef } from 'react' + +// ── Component ───────────────────────────────────────────────────────────────── + +interface SeriesEditPromptProps { + open: boolean + onConfirm: () => void + onCancel: () => void +} + +export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPromptProps) { + const dialogRef = useRef(null) + const headingId = 'series-edit-prompt-heading' + + // Focus trap — focus the dialog when it opens + useEffect(() => { + if (open && dialogRef.current) { + dialogRef.current.focus() + } + }, [open]) + + // Escape key listener — cancel without submitting + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, onCancel]) + + // Focus trap: Tab / Shift+Tab cycles within dialog + const handleDialogKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Tab' || !dialogRef.current) return + + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ), + ).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1') + + if (focusable.length === 0) return + + const first = focusable[0] + const last = focusable[focusable.length - 1] + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault() + last.focus() + } + } else { + if (document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + + if (!open) return null + + const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches + + const dialogStyle: React.CSSProperties = isPhone + ? { + 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)', + zIndex: 400, + fontFamily: 'var(--font-family-base)', + } + : { + 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: '480px', + zIndex: 400, + fontFamily: 'var(--font-family-base)', + } + + return ( + <> + {/* Backdrop */} +