diff --git a/apps/pwa/src/components/EventForm.test.tsx b/apps/pwa/src/components/EventForm.test.tsx index 3d07730..dcba6c7 100644 --- a/apps/pwa/src/components/EventForm.test.tsx +++ b/apps/pwa/src/components/EventForm.test.tsx @@ -262,6 +262,36 @@ describe('EventForm', () => { expect(timeInputs.length).toBeGreaterThan(0) }) + // WR-02 (iteration 2): toggling all-day ON deterministically clamps endDate to + // max(startDate, endDate). When the end day is BEHIND the start day, it snaps forward + // to a single-day event rather than validating as an inconsistent span, and any stale + // end-time error from the timed view is cleared. + it('toggling All-day ON clamps an end date that is behind the start date up to the start date', async () => { + renderForm() + fireEvent.change(screen.getByPlaceholderText('Event title'), { + target: { value: 'Span Title' }, + }) + const dateInputs = document.querySelectorAll('input[type="date"]') + expect(dateInputs.length).toBeGreaterThanOrEqual(2) + // Start 2026-06-10, end 2026-06-09 (end behind start) — invalid timed span + fireEvent.change(dateInputs[0], { target: { value: '2026-06-10' } }) + fireEvent.change(dateInputs[1], { target: { value: '2026-06-09' } }) + + // Toggle all-day ON: endDate must clamp up to the start date (single-day event) + const allDaySwitch = screen.getByRole('switch') + fireEvent.click(allDaySwitch) + + const dateInputsAfter = document.querySelectorAll('input[type="date"]') + expect((dateInputsAfter[1] as HTMLInputElement).value).toBe('2026-06-10') + + // The clamped all-day event validates cleanly (no end-time error surfaced) + const saveButton = screen.getByText('Create Event') + fireEvent.click(saveButton) + await waitFor(() => { + expect(screen.queryByText('End time must be after start')).toBeNull() + }) + }) + // ── Calendar picker D-02 ─────────────────────────────────────────────────── it('calendar picker is absent when fetchWritableCalendars returns 1 calendar (D-02)', () => { @@ -368,6 +398,39 @@ describe('EventForm', () => { }) }) + // WR-01 (iteration 2): edit mode shows explanatory helper text near the disabled + // recurrence select so the locked schedule is not a silent surprise. + it('WR-01: edit mode surfaces helper text that repeat cannot be changed', () => { + renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: EDIT_OCCURRENCE }) + expect(screen.getByText(/Repeat can't be changed yet/i)).toBeDefined() + }) + + it('WR-01: create mode does NOT show the repeat helper text', () => { + renderForm({ mode: 'create' }) + expect(screen.queryByText(/Repeat can't be changed yet/i)).toBeNull() + }) + + // IN-02 (iteration 2): in edit mode an unparseable cached start/end must leave the + // field blank and block submit, rather than silently rewriting the event to today/09:00. + it('IN-02: edit mode with an unparseable start leaves the date blank and blocks submit', async () => { + const corruptOccurrence: CalendarOccurrence = { + ...EDIT_OCCURRENCE, + start: 'not-a-real-date', + } + renderForm({ mode: 'edit', uid: 'edit-uid-456', eventOccurrence: corruptOccurrence }) + + // The start date input must be blank (not today's date) + const dateInputs = document.querySelectorAll('input[type="date"]') + expect((dateInputs[0] as HTMLInputElement).value).toBe('') + + // Submit must be blocked with a guidance message; updateEvent must NOT fire. + fireEvent.click(screen.getByText('Save Changes')) + await waitFor(() => { + expect(screen.getByText(/Couldn't read this event's date/i)).toBeDefined() + }) + expect(mockUpdateEvent).not.toHaveBeenCalled() + }) + // ── Close behaviors ──────────────────────────────────────────────────────── it('pressing Escape closes the form', () => { diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index d93f9a0..6b8fc17 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -104,7 +104,7 @@ function exclusiveEndToInclusiveDate(dateStr: string): string { * Both date and time use getFullYear/getMonth/getDate/getHours/getMinutes so * the pair describes the same wall-clock consistently in the viewer's zone. */ -function parseDateTime(iso: string): { date: string; time: string } { +function parseDateTime(iso: string): { date: string; time: string; ok: boolean } { try { // Strip IANA bracket suffix e.g. '[America/Toronto]' const clean = iso.replace(/\[[^\]]*\]$/, '') @@ -113,7 +113,7 @@ function parseDateTime(iso: string): { date: string; time: string } { // to new Date(clean); using `clean` matches the "strip IANA suffix" intent above. if (/^\d{4}-\d{2}-\d{2}$/.test(clean)) { // All-day date string — use as-is (no time component) - return { date: clean, time: '09:00' } + return { date: clean, time: '09:00', ok: true } } const d = new Date(clean) if (isNaN(d.getTime())) throw new Error('Invalid date') @@ -124,12 +124,35 @@ function parseDateTime(iso: string): { date: string; time: string } { const day = String(d.getDate()).padStart(2, '0') const hours = String(d.getHours()).padStart(2, '0') const mins = String(d.getMinutes()).padStart(2, '0') - return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}` } + return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}`, ok: true } } catch { - return { date: todayIso(), time: '09:00' } + // IN-02: signal failure so the CREATE path can fall back to today/09:00 (a benign + // default for a brand-new event) while the EDIT path leaves the field blank and + // blocks submit — never silently rewriting a corrupt cached value to today/09:00. + return { date: todayIso(), time: '09:00', ok: false } } } +/** + * IN-02: resolve the form's initial date/time for a pre-filled occurrence value. + * In CREATE mode (or when there is no occurrence) a parse failure falls back to the + * benign today/09:00 default. In EDIT mode a parse failure leaves the field BLANK so + * the user sees the value did not load and submit is blocked (validate() treats a blank + * start/end as invalid), rather than silently substituting today/09:00 and saving it. + */ +function initFormDateTime( + iso: string | undefined, + isEdit: boolean, + fallbackTime: string, +): { date: string; time: string } { + if (iso === undefined) return { date: todayIso(), time: fallbackTime } + const parsed = parseDateTime(iso) + if (parsed.ok) return { date: parsed.date, time: parsed.time } + // Parse failed + if (isEdit) return { date: '', time: '' } + return { date: todayIso(), time: fallbackTime } +} + // ── Component ───────────────────────────────────────────────────────────────── export function EventForm() { @@ -165,13 +188,15 @@ export function EventForm() { // ── Form state ────────────────────────────────────────────────────────────── - const initStart = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' } - const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' } + const isEditMode = eventFormMode === 'edit' && !!eventFormUid + const initStart = initFormDateTime(occurrence?.start, isEditMode, '09:00') + const initEnd = initFormDateTime(occurrence?.end, isEditMode, '10:00') // CR-03: occurrence.end for an all-day event is the EXCLUSIVE DTEND; the form's // end-date input is the INCLUSIVE last day. Convert when pre-filling so a re-edit // does not re-advance the span (buildVeventString rolls forward again on write). + // IN-02: skip the roll-back when initEnd.date is blank (parse failure in edit mode). const initEndDate = - occurrence?.allDay ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date + occurrence?.allDay && initEnd.date ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date const [title, setTitle] = useState(occurrence?.title ?? '') const [allDay, setAllDay] = useState(occurrence?.allDay ?? false) @@ -206,12 +231,16 @@ export function EventForm() { // has hydrated the occurrence for the requested UID. useEffect(() => { if (eventFormOpen) { - const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' } - const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' } + const editMode = eventFormMode === 'edit' && !!eventFormUid + const startParsed = initFormDateTime(occurrence?.start, editMode, '09:00') + const endParsed = initFormDateTime(occurrence?.end, editMode, '10:00') // CR-03: see exclusiveEndToInclusiveDate — pre-fill the inclusive last day for // all-day events so re-saving an edit does not grow the span by a day each time. + // IN-02: skip the roll-back when endParsed.date is blank (parse failure in edit mode). const endDateValue = - occurrence?.allDay ? exclusiveEndToInclusiveDate(endParsed.date) : endParsed.date + occurrence?.allDay && endParsed.date + ? exclusiveEndToInclusiveDate(endParsed.date) + : endParsed.date setTitle(occurrence?.title ?? '') setAllDay(occurrence?.allDay ?? false) setStartDate(startParsed.date) @@ -264,9 +293,15 @@ export function EventForm() { setStartTime('09:00') setEndTime('10:00') } - if (next && endDate < startDate) { - // All-day ON: advance end date to match start date if it's behind - setEndDate(startDate) + if (next) { + // WR-02: turning all-day ON discards the time inputs, so a midnight-spanning + // timed event (start 06-10 23:00, end 06-11 01:00) would otherwise leave endDate + // at 06-11 — a 2-day all-day span the user did not intend. Clamp endDate to + // max(startDate, endDate) deterministically: when the end day is behind the start + // it snaps forward to a single-day event; an already-valid multi-day all-day span + // is preserved. Also clear any stale end-time error left over from the timed view. + setEndDate((prev) => (prev < startDate ? startDate : prev)) + setErrors((prev) => (prev.endTime ? { ...prev, endTime: undefined } : prev)) } } @@ -277,6 +312,15 @@ export function EventForm() { newErrors.title = 'Title is required' } + // IN-02: a blank start/end date means a cached value failed to parse in edit mode + // (initFormDateTime left it empty rather than substituting today/09:00). Block submit + // so the corrupt value is never silently saved as today/09:00. + if (!startDate || !endDate || (!allDay && (!startTime || !endTime))) { + newErrors.endTime = "Couldn't read this event's date — re-open it from the calendar" + setErrors(newErrors) + return false + } + if (!allDay) { const startISO = `${startDate}T${startTime}:00` const endISO = `${endDate}T${endTime}:00` @@ -739,6 +783,21 @@ export function EventForm() { + {/* WR-01: surface the v1 constraint so a user editing a recurring event is not + silently surprised that the schedule is locked. Additive helper text only — + the existing RRULE is preserved server-side on edit. */} + {eventFormMode === 'edit' && ( +
+ {/* Plain text — XSS guard (T-03-15) */} + Repeat can't be changed yet — edits keep the existing schedule. +
+ )} {/* Location */}