Merge pull request #807 from hildebrandttk/feat/event-editor-end-follows-start

feat(calendar): move the end with the start, keeping the event's length
This commit is contained in:
Linus Rath
2026-08-13 23:57:37 +02:00
committed by GitHub
2 changed files with 73 additions and 2 deletions
+30 -2
View File
@@ -53,6 +53,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");
}
@@ -268,6 +281,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<string>(() => {
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
@@ -1118,7 +1146,7 @@ export function EventModal({
<input
type="date"
value={startDate}
onChange={(e) => 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"
/>
</div>
@@ -1128,7 +1156,7 @@ export function EventModal({
<input
type="time"
value={startTime}
onChange={(e) => 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"
/>
</div>
@@ -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();
});
});