mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
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.
This commit is contained in:
@@ -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<string>(() => {
|
||||
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({
|
||||
<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>
|
||||
@@ -1107,7 +1135,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user