From 3be99a4bf7f2a5d9c2721a7d9bb625dc00b038ea Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:01:27 +0100 Subject: [PATCH] fix: count an event's length on its own clock across a daylight-saving switch --- .../event-modal-time-zone-ux-pim.test.tsx | 24 +++++++++++ components/calendar/event-modal.tsx | 28 ++++++++----- lib/event-time-zone.ts | 40 +++++++++++++++++++ 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx index 803cf0d02..5ce88cfed 100644 --- a/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx +++ b/components/calendar/__tests__/event-modal-time-zone-ux-pim.test.tsx @@ -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' } }); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index c2ee2538a..029737861 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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; diff --git a/lib/event-time-zone.ts b/lib/event-time-zone.ts index 29faa6805..20fa089f3 100644 --- a/lib/event-time-zone.ts +++ b/lib/event-time-zone.ts @@ -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;