From cbf5f98eb9b3d400a51c71c71e5e2821d7e78d33 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 10 Jun 2026 11:38:24 -0400 Subject: [PATCH] feat(06-06): wire end-tracking and recurrence-bound control into EventForm - Import computeNewTimedEnd/computeNewAllDayEnd from eventDateTime.ts (D-04) - Start date onChange: calls computeNewAllDayEnd (all-day) or computeNewTimedEnd (timed) - Start time onChange: calls computeNewTimedEnd preserving duration (timed only) - Add recurrenceBound/recurrenceUntil/recurrenceCount state (D-06) - Reset useEffect extended to reset bound state on form open - Add 'Ends' control (Never/On date/After N times) shown when recurrence != none - Inline validation: count < 1 and until < start - Payload conditionally includes recurrenceUntil/recurrenceCount (create mode only) - Error state type extended for recurrenceBound validation --- apps/pwa/src/components/EventForm.tsx | 133 +++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 5 deletions(-) diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index 6b8fc17..def00de 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -40,7 +40,11 @@ import { type RecurrencePreset, } from '../api/client.js' import type { CalendarOccurrence } from '../api/client.js' -import { serializeEventDateTime } from '../lib/eventDateTime.js' +import { + serializeEventDateTime, + computeNewTimedEnd, + computeNewAllDayEnd, +} from '../lib/eventDateTime.js' // ── Constants ───────────────────────────────────────────────────────────────── @@ -205,6 +209,10 @@ export function EventForm() { const [endDate, setEndDate] = useState(initEndDate) const [endTime, setEndTime] = useState(initEnd.time) const [recurrence, setRecurrence] = useState('none') + // D-06: recurrence bound state — "Ends" control + const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never') + const [recurrenceUntil, setRecurrenceUntil] = useState('') + const [recurrenceCount, setRecurrenceCount] = useState(1) const [location, setLocation] = useState(occurrence?.location ?? '') const [description, setDescription] = useState(occurrence?.description ?? '') const [calendarUrl, setCalendarUrl] = useState(() => { @@ -256,6 +264,10 @@ export function EventForm() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined setRecurrence(derivedRecurrence ?? 'none') + // D-06: reset bound state to defaults on form open/occurrence change + setRecurrenceBound('never') + setRecurrenceUntil('') + setRecurrenceCount(1) setLocation(occurrence?.location ?? '') setDescription(occurrence?.description ?? '') } @@ -263,7 +275,7 @@ export function EventForm() { // ── Validation state ──────────────────────────────────────────────────────── - const [errors, setErrors] = useState<{ title?: string; endTime?: string }>({}) + const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({}) // ── Mutations ─────────────────────────────────────────────────────────────── @@ -306,7 +318,7 @@ export function EventForm() { } const validate = (): boolean => { - const newErrors: { title?: string; endTime?: string } = {} + const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {} if (!title.trim()) { newErrors.title = 'Title is required' @@ -333,6 +345,17 @@ export function EventForm() { } } + // D-06: validate recurrence bound inputs (T-06-06-input: client-side UX gate) + if (recurrence !== 'none') { + if (recurrenceBound === 'count' && recurrenceCount < 1) { + newErrors.recurrenceBound = 'Must be at least 1 occurrence' + } else if (recurrenceBound === 'until' && recurrenceUntil) { + if (recurrenceUntil < startDate) { + newErrors.recurrenceBound = 'End date must be after the event starts' + } + } + } + setErrors(newErrors) return Object.keys(newErrors).length === 0 } @@ -365,6 +388,13 @@ export function EventForm() { start: serializedStart, end: serializedEnd, ...(isEdit ? {} : { recurrence }), + // D-06: only send bound fields when recurrence is set and bound type is not 'never' + ...(!isEdit && recurrence !== 'none' && recurrenceBound === 'until' && recurrenceUntil + ? { recurrenceUntil } + : {}), + ...(!isEdit && recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1 + ? { recurrenceCount } + : {}), ...(location.trim() ? { location: location.trim() } : {}), ...(description.trim() ? { description: description.trim() } : {}), ...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}), @@ -668,7 +698,20 @@ export function EventForm() { id="event-start-date" type="date" value={startDate} - onChange={(e) => setStartDate(e.target.value)} + onChange={(e) => { + const newStart = e.target.value + // D-04: recompute end to preserve duration when start date changes + if (allDay) { + setEndDate(computeNewAllDayEnd(newStart, startDate, endDate)) + } else { + const { endDate: ed, endTime: et } = computeNewTimedEnd( + newStart, startTime, startDate, startTime, endDate, endTime, + ) + setEndDate(ed) + setEndTime(et) + } + setStartDate(newStart) + }} style={inputStyle} /> @@ -681,7 +724,16 @@ export function EventForm() { id="event-start-time" type="time" value={startTime} - onChange={(e) => setStartTime(e.target.value)} + onChange={(e) => { + const newTime = e.target.value + // D-04: recompute end to preserve duration when start time changes (timed only) + const { endDate: ed, endTime: et } = computeNewTimedEnd( + startDate, newTime, startDate, startTime, endDate, endTime, + ) + setEndDate(ed) + setEndTime(et) + setStartTime(newTime) + }} style={inputStyle} /> @@ -800,6 +852,77 @@ export function EventForm() { )} + {/* D-06: Recurrence bound control — shown only when recurrence ≠ 'none' in create mode */} + {eventFormMode !== 'edit' && recurrence !== 'none' && ( +
+ + + + {/* "On date" — date input */} + {recurrenceBound === 'until' && ( +
+ + setRecurrenceUntil(e.target.value)} + style={{ + ...inputStyle, + ...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}), + }} + /> +
+ )} + + {/* "After N times" — number input */} + {recurrenceBound === 'count' && ( +
+ + setRecurrenceCount(Number(e.target.value))} + style={{ + ...inputStyle, + ...(errors.recurrenceBound ? { borderColor: 'var(--color-destructive)' } : {}), + }} + /> +
+ )} + + {/* Inline validation error (T-06-06-input) */} + {errors.recurrenceBound && ( +
+ {/* Plain text — XSS guard (T-03-15) */} + {errors.recurrenceBound} +
+ )} +
+ )} + {/* Location */}