fix(03): WR-01/WR-02/IN-02 recurrence-edit helper text, all-day toggle clamp, edit-mode parse-failure guard
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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() {
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
{/* 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' && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--text-label-size)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginTop: 'var(--space-1)',
|
||||
}}
|
||||
>
|
||||
{/* Plain text — XSS guard (T-03-15) */}
|
||||
Repeat can't be changed yet — edits keep the existing schedule.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
|
||||
Reference in New Issue
Block a user