mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
fix: count an event's length on its own clock across a daylight-saving switch
This commit is contained in:
@@ -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' } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user