fix: count an event's length on its own clock across a daylight-saving switch

This commit is contained in:
Linus Rath
2026-11-04 12:01:27 +01:00
parent 5f450c6945
commit 3be99a4bf7
3 changed files with 81 additions and 11 deletions
@@ -72,6 +72,30 @@ describe('the event editor keeps the event in its own time zone (pim-2)', () =>
expect(state.timeZoneChanged).toBeUndefined();
});
// Review of pim-2: the duration and the editor state came from Dates in the
// browser's zone. 01:00-04:00 in New York on the night its clocks go
// forward lasts two hours, not three; and 02:30 exists in New York on the
// night Berlin's clocks go forward, where a browser Date moved it to 03:30.
it("counts the duration on the event's clock across its daylight-saving switch", async () => {
const onSave = editor({ ...newYork, start: '2026-03-08T01:00:00', duration: 'PT2H', utcStart: '2026-03-08T06:00:00Z', utcEnd: '2026-03-08T08:00:00Z' } as CalendarEvent);
expect(field('form.end_time').value).toBe('04:00');
fireEvent.change(field('form.title'), { target: { value: 'Night shift handover' } });
const { data } = await save(onSave);
expect(data).toMatchObject({ start: '2026-03-08T01:00:00', timeZone: 'America/New_York', duration: 'PT2H' });
});
it("keeps a time that falls in the browser's daylight-saving gap", async () => {
const onSave = editor(newYork);
fireEvent.change(field('form.start_date'), { target: { value: '2026-03-29' } });
fireEvent.change(field('form.end_date'), { target: { value: '2026-03-29' } });
fireEvent.change(field('form.start_time'), { target: { value: '02:30' } });
fireEvent.change(field('form.end_time'), { target: { value: '03:30' } });
const { data, state } = await save(onSave);
expect(data).toMatchObject({ start: '2026-03-29T02:30:00', duration: 'PT1H' });
// 02:30 in New York (UTC-4 since 8 March) is 06:30 UTC.
expect(fromDisplayDate(state.when.start).toISOString()).toBe('2026-03-29T06:30:00.000Z');
});
it('moves the event on its own clock', async () => {
const onSave = editor(newYork);
fireEvent.change(field('form.start_time'), { target: { value: '12:00' } });
+17 -11
View File
@@ -20,7 +20,7 @@ import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
import { displayNow, fromDisplayDate, fromWallClock, getEffectiveTimeZone, listTimeZones, parseLocalDateTime, toZonedDisplayDate } from "@/lib/timezone";
import { displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, zonedTimesText, zoneWallToDisplay } from "@/lib/event-time-zone";
import { addToWall, compareWall, displayToZoneWall, eventFormTimeZone, eventZonedTimes, timeZoneCity, wallFields, zonedTimesText, zoneFieldsToDisplay, zoneWallDurationMs, zoneWallToDisplay } from "@/lib/event-time-zone";
import { createRecurrenceRule, sameRecurrenceRules } from "@/lib/recurrence-rule";
import { ParticipantInput, type ParticipantInputHandle } from "./participant-input";
import { ParticipantAvailability } from "./participant-availability";
@@ -796,6 +796,7 @@ export function EventModal({
let duration: string;
// The last day of an all-day event, the end of a timed one.
let end: Date;
let timedWhen: { start: Date; end: Date } | undefined;
if (allDay) {
let inclusiveEnd = new Date(`${endDate}T00:00:00`);
@@ -805,25 +806,30 @@ export function EventModal({
duration = buildAllDayDuration(start, inclusiveEnd);
end = inclusiveEnd;
} else {
const endStr = `${endDate}T${endTime}:00`;
end = new Date(endStr);
// Clock fields, not Dates: see wallFields.
const startWall = wallFields(startDate, startTime);
let endWall = wallFields(endDate, endTime);
// An end at or before the start becomes an hour - but an event that
// lasts no time (a deadline, a reminder) keeps doing so.
const lastedNoTime = !!event && !event.showWithoutTime
&& getEventEndDate(event).getTime() === getEventStartDate(event).getTime();
if (end < start || (end.getTime() === start.getTime() && !lastedNoTime)) {
end = new Date(start.getTime() + 3600000);
const order = compareWall(endWall, startWall);
if (order < 0 || (order === 0 && !lastedNoTime)) {
endWall = addToWall(startWall, 3600000);
}
duration = buildDuration(start, end);
// Exact time in the event's zone, not the browser's.
duration = buildDuration(new Date(0), new Date(Math.max(0, zoneWallDurationMs(startWall, endWall, timeZone))));
// The fields are the wall clock in the form's zone: the event's own, so
// an edit keeps it, or the one the user picked.
end = zoneFieldsToDisplay(endWall, timeZone);
timedWhen = { start: zoneFieldsToDisplay(startWall, timeZone), end };
}
// The fields are the wall clock in the form's zone: the event's own, so
// an edit keeps it, or the one the user picked. The editor state below
// is in display dates, as the rest of the calendar.
// The editor state below is in display dates, as the rest of the calendar.
const when = {
allDay,
start: allDay ? start : zoneWallToDisplay(start, timeZone),
end: allDay ? end : zoneWallToDisplay(end, timeZone),
start: allDay ? start : timedWhen!.start,
end: allDay ? end : timedWhen!.end,
};
const timeZoneChanged = !!event && !allDay && timeZone !== initialForm.timeZone;
+40
View File
@@ -8,6 +8,7 @@ import {
getTimeZoneOffsetMs,
toDisplayDate,
toZonedDisplayDate,
type WallClock,
} from "@/lib/timezone";
/**
@@ -59,6 +60,45 @@ export function zoneWallToDisplay(wall: Date, zone: string | null): Date {
}, zone));
}
/**
* The form's "yyyy-MM-dd" and "HH:mm" as clock fields. Not through a Date: in
* the browser's own zone a time in its daylight-saving gap (02:30 on the
* last Sunday of March in Berlin) would move an hour, though it exists in
* the event's zone.
*/
export function wallFields(date: string, time: string): WallClock {
const [year, month, day] = date.split("-").map(Number);
const [hour, minute] = time.split(":").map(Number);
return { year, month, day, hour: hour || 0, minute: minute || 0, second: 0 };
}
/** The clock fields `ms` later, by the clock. */
export function addToWall(w: WallClock, ms: number): WallClock {
const d = new Date(Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second) + ms);
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate(), hour: d.getUTCHours(), minute: d.getUTCMinutes(), second: d.getUTCSeconds() };
}
/** Clock fields compared as a clock reads them: negative, 0 or positive. */
export function compareWall(a: WallClock, b: WallClock): number {
return Date.UTC(a.year, a.month - 1, a.day, a.hour, a.minute, a.second) - Date.UTC(b.year, b.month - 1, b.day, b.hour, b.minute, b.second);
}
/** The display date at which the clock in `zone` reads `w` (zoneWallToDisplay, from fields). */
export function zoneFieldsToDisplay(w: WallClock, zone: string | null): Date {
if (!zone || zone === getEffectiveTimeZone()) return new Date(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
return toDisplayDate(fromWallClock(w, zone));
}
/**
* The time from `a` to `b` on the clock in `zone`, as an event's duration
* counts it: exact time, so 01:00-04:00 in New York on the night the clocks
* go forward lasts two hours. A floating event (no zone) counts by the clock.
*/
export function zoneWallDurationMs(a: WallClock, b: WallClock, zone: string | null): number {
if (!zone) return compareWall(b, a);
return fromWallClock(b, zone).getTime() - fromWallClock(a, zone).getTime();
}
/** "America/New_York" as people say it: "New York". */
export function timeZoneCity(zone: string): string {
const last = zone.split("/").pop() || zone;