diff --git a/apps/pwa/src/components/EventForm.tsx b/apps/pwa/src/components/EventForm.tsx index 706b0c4..5635fc9 100644 --- a/apps/pwa/src/components/EventForm.tsx +++ b/apps/pwa/src/components/EventForm.tsx @@ -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 * { 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 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 [allDay, setAllDay] = useState(occurrence?.allDay ?? false) const [startDate, setStartDate] = useState(initStart.date) const [startTime, setStartTime] = useState(initStart.time) - const [endDate, setEndDate] = useState(initEnd.date) + const [endDate, setEndDate] = useState(initEndDate) const [endTime, setEndTime] = useState(initEnd.time) const [recurrence, setRecurrence] = useState('none') const [location, setLocation] = useState(occurrence?.location ?? '') @@ -179,11 +205,15 @@ export function EventForm() { if (eventFormOpen) { const startParsed = occurrence ? parseDateTime(occurrence.start) : { date: todayIso(), time: '09: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 ?? '') setAllDay(occurrence?.allDay ?? false) setStartDate(startParsed.date) setStartTime(startParsed.time) - setEndDate(endParsed.date) + setEndDate(endDateValue) setEndTime(endParsed.time) // WR-03 recurrence: derive from occurrence if present; default 'none' only when // genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type