fix(03): CR-03 convert exclusive all-day end to inclusive on edit pre-fill

This commit is contained in:
Lucas Berger
2026-06-09 10:37:13 -04:00
parent a596f520b4
commit f645644853
+32 -2
View File
@@ -74,6 +74,27 @@ function writeLastCalendarUrl(url: string): void {
} }
} }
/**
* CR-03: convert an EXCLUSIVE all-day end date ('YYYY-MM-DD') to the INCLUSIVE
* last day the form displays. The occurrence/expand contract keeps all-day ends
* exclusive (matching the DTEND Fastmail stores, RFC-5545 §3.6.1), and
* buildVeventString re-advances the inclusive form value by one day on write.
* Without this subtraction, round-tripping an edit re-advances an already-exclusive
* end, silently growing multi-day all-day events by one day per save. Parses by
* UTC components so the roll-back is DST-safe (mirrors vevent.ts's roll-forward).
*/
function exclusiveEndToInclusiveDate(dateStr: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
if (!m) return dateStr
const [, y, mo, d] = m
const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)))
date.setUTCDate(date.getUTCDate() - 1)
const yy = String(date.getUTCFullYear())
const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
const dd = String(date.getUTCDate()).padStart(2, '0')
return `${yy}-${mm}-${dd}`
}
/** /**
* Parse an ISO date string (possibly with time + offset) into * Parse an ISO date string (possibly with time + offset) into
* { date: 'YYYY-MM-DD', time: 'HH:MM' }. Falls back to today/09:00 if malformed. * { date: 'YYYY-MM-DD', time: 'HH:MM' }. Falls back to today/09:00 if malformed.
@@ -143,12 +164,17 @@ export function EventForm() {
const initStart = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' } const initStart = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' }
const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' } const initEnd = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '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).
const initEndDate =
occurrence?.allDay ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date
const [title, setTitle] = useState(occurrence?.title ?? '') const [title, setTitle] = useState(occurrence?.title ?? '')
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false) const [allDay, setAllDay] = useState(occurrence?.allDay ?? false)
const [startDate, setStartDate] = useState(initStart.date) const [startDate, setStartDate] = useState(initStart.date)
const [startTime, setStartTime] = useState(initStart.time) const [startTime, setStartTime] = useState(initStart.time)
const [endDate, setEndDate] = useState(initEnd.date) const [endDate, setEndDate] = useState(initEndDate)
const [endTime, setEndTime] = useState(initEnd.time) const [endTime, setEndTime] = useState(initEnd.time)
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none') const [recurrence, setRecurrence] = useState<RecurrencePreset>('none')
const [location, setLocation] = useState(occurrence?.location ?? '') const [location, setLocation] = useState(occurrence?.location ?? '')
@@ -179,11 +205,15 @@ export function EventForm() {
if (eventFormOpen) { if (eventFormOpen) {
const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' } const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09:00' }
const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '10:00' } const endParsed = occurrence ? parseDateTime(occurrence.end) : { date: todayIso(), time: '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.
const endDateValue =
occurrence?.allDay ? exclusiveEndToInclusiveDate(endParsed.date) : endParsed.date
setTitle(occurrence?.title ?? '') setTitle(occurrence?.title ?? '')
setAllDay(occurrence?.allDay ?? false) setAllDay(occurrence?.allDay ?? false)
setStartDate(startParsed.date) setStartDate(startParsed.date)
setStartTime(startParsed.time) setStartTime(startParsed.time)
setEndDate(endParsed.date) setEndDate(endDateValue)
setEndTime(endParsed.time) setEndTime(endParsed.time)
// WR-03 recurrence: derive from occurrence if present; default 'none' only when // WR-03 recurrence: derive from occurrence if present; default 'none' only when
// genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type // genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type