From fa76bb895d07c531d3ffbcec9f0e0c4c8b0c3cbf Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:00:10 +0200 Subject: [PATCH] feat(calendar): move the end with the start, keeping the event's length Editing an event's start now shifts the end by the same amount, preserving the current duration (end - start). Moving the start forward never leaves the end before it, and moving it either way never silently changes the length. Guards against an already-negative-duration state. Extracted a pure `shiftedEnd(oldStart, oldEnd, newStart)` for the start date/time onChange handlers. +7 unit tests. tsc + eslint clean. --- components/calendar/event-modal.tsx | 32 +++++++++++++- lib/__tests__/event-end-follows-start.test.ts | 43 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/event-end-follows-start.test.ts diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 5e0cfc145..392a176b5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -51,6 +51,19 @@ interface EventModalProps { isMobile?: boolean; } +/** + * When an event's start is edited, the end moves with it to keep the SAME + * duration (oldEnd - oldStart), so the appointment's length is preserved. + * Returns the new end, or null for invalid or already-negative-duration input. + * Pure, for testing. + */ +export function shiftedEnd(oldStart: Date, oldEnd: Date, newStart: Date): Date | null { + if (isNaN(oldStart.getTime()) || isNaN(oldEnd.getTime()) || isNaN(newStart.getTime())) return null; + const durationMs = oldEnd.getTime() - oldStart.getTime(); + if (durationMs < 0) return null; + return new Date(newStart.getTime() + durationMs); +} + function formatDateInput(d: Date): string { return format(d, "yyyy-MM-dd"); } @@ -257,6 +270,21 @@ export function EventModal({ const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false); + + // Editing the start shifts the end with it, preserving the event's current + // length (end - start) - so moving the start never leaves the end before it + // and never silently changes the duration. + const shiftEndKeepingDuration = (nextStartDate: string, nextStartTime: string) => { + const oldStart = new Date(`${startDate}T${allDay ? "00:00" : (startTime || "00:00")}:00`); + const oldEnd = new Date(`${endDate}T${allDay ? "00:00" : (endTime || "00:00")}:00`); + const newStart = new Date(`${nextStartDate}T${allDay ? "00:00" : (nextStartTime || "00:00")}:00`); + const nextEnd = shiftedEnd(oldStart, oldEnd, newStart); + if (!nextEnd) return; + setEndDate(formatDateInput(nextEnd)); + if (!allDay) setEndTime(formatTimeInput(nextEnd)); + }; + const handleStartDateChange = (v: string) => { setStartDate(v); shiftEndKeepingDuration(v, startTime); }; + const handleStartTimeChange = (v: string) => { setStartTime(v); shiftEndKeepingDuration(startDate, v); }; const [calendarId, setCalendarId] = useState(() => { if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; @@ -1097,7 +1125,7 @@ export function EventModal({ setStartDate(e.target.value)} + onChange={(e) => handleStartDateChange(e.target.value)} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> @@ -1107,7 +1135,7 @@ export function EventModal({ setStartTime(e.target.value)} + onChange={(e) => handleStartTimeChange(e.target.value)} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" /> diff --git a/lib/__tests__/event-end-follows-start.test.ts b/lib/__tests__/event-end-follows-start.test.ts new file mode 100644 index 000000000..cef9b7728 --- /dev/null +++ b/lib/__tests__/event-end-follows-start.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { shiftedEnd } from '@/components/calendar/event-modal'; + +const d = (s: string) => new Date(s); + +describe('shiftedEnd (event editor: end follows start, keeping duration)', () => { + it('moves the end forward by the same duration when start moves forward', () => { + // 2h event 09:00-11:00, start -> 14:00 => end 16:00 (still 2h) + expect(shiftedEnd(d('2026-03-01T09:00:00'), d('2026-03-01T11:00:00'), d('2026-03-01T14:00:00'))) + .toEqual(d('2026-03-01T16:00:00')); + }); + + it('moves the end backward by the same duration when start moves backward', () => { + // 1h event 10:00-11:00, start -> 08:00 => end 09:00 (still 1h) + expect(shiftedEnd(d('2026-03-01T10:00:00'), d('2026-03-01T11:00:00'), d('2026-03-01T08:00:00'))) + .toEqual(d('2026-03-01T09:00:00')); + }); + + it('preserves a multi-hour duration exactly', () => { + // 90min event, start moved past the old end + expect(shiftedEnd(d('2026-03-01T09:00:00'), d('2026-03-01T10:30:00'), d('2026-03-01T12:00:00'))) + .toEqual(d('2026-03-01T13:30:00')); + }); + + it('preserves a multi-day (all-day) duration', () => { + // 2-day span, start -> 03-10 => end 03-12 + expect(shiftedEnd(d('2026-03-01T00:00:00'), d('2026-03-03T00:00:00'), d('2026-03-10T00:00:00'))) + .toEqual(d('2026-03-12T00:00:00')); + }); + + it('keeps a zero-length event zero-length', () => { + expect(shiftedEnd(d('2026-03-01T10:00:00'), d('2026-03-01T10:00:00'), d('2026-03-01T15:00:00'))) + .toEqual(d('2026-03-01T15:00:00')); + }); + + it('returns null for an already-negative duration (does not amplify a bad state)', () => { + expect(shiftedEnd(d('2026-03-01T11:00:00'), d('2026-03-01T10:00:00'), d('2026-03-01T14:00:00'))).toBeNull(); + }); + + it('returns null for invalid dates', () => { + expect(shiftedEnd(new Date('nope'), d('2026-03-01T11:00:00'), d('2026-03-01T09:00:00'))).toBeNull(); + }); +});