fix: place calendar events at their clock time on DST change days

This commit is contained in:
Linus Rath
2026-10-25 14:18:45 +01:00
parent 9857fe1711
commit f358d60584
2 changed files with 53 additions and 10 deletions
+35
View File
@@ -14,6 +14,7 @@ import {
layoutOverlappingEvents, layoutOverlappingEvents,
CALENDAR_COLOR_PALETTE, CALENDAR_COLOR_PALETTE,
reopenedEdit, reopenedEdit,
getTimedEventBoundsForDay,
} from '../calendar-utils'; } from '../calendar-utils';
import type { CalendarEvent } from '../../api/types'; import type { CalendarEvent } from '../../api/types';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
@@ -294,3 +295,37 @@ describe('reopenedEdit', () => {
expect('locations' in reopened).toBe(false); expect('locations' in reopened).toBe(false);
}); });
}); });
describe('getTimedEventBoundsForDay on DST change days', () => {
it('places events by wall-clock time, not elapsed time since midnight', () => {
const tz = process.env.TZ;
process.env.TZ = 'Europe/Berlin';
try {
// 2026-10-25 has 25 hours in Berlin; 12:51 CET is 13h51m after midnight.
const fallBack = ev({
start: '2026-10-25T12:51:00',
timeZone: 'Europe/Berlin',
utcStart: '2026-10-25T11:51:00Z',
utcEnd: '2026-10-25T12:16:00Z',
});
expect(getTimedEventBoundsForDay(fallBack, new Date(2026, 9, 25))).toMatchObject({
startMinutes: 12 * 60 + 51,
endMinutes: 13 * 60 + 16,
});
// 2026-03-29 has 23 hours; 09:00 CEST is 8h after midnight.
const springForward = ev({
start: '2026-03-29T09:00:00',
timeZone: 'Europe/Berlin',
utcStart: '2026-03-29T07:00:00Z',
utcEnd: '2026-03-29T08:00:00Z',
});
expect(getTimedEventBoundsForDay(springForward, new Date(2026, 2, 29))).toMatchObject({
startMinutes: 9 * 60,
endMinutes: 10 * 60,
});
} finally {
process.env.TZ = tz;
}
});
});
+18 -10
View File
@@ -263,6 +263,15 @@ export interface TimedDayBounds {
continuesAfter: boolean; continuesAfter: boolean;
} }
function wallClockMinutes(date: Date): number {
return (
date.getHours() * 60 +
date.getMinutes() +
date.getSeconds() / 60 +
date.getMilliseconds() / 60000
);
}
export function getTimedEventBoundsForDay( export function getTimedEventBoundsForDay(
event: CalendarEvent, event: CalendarEvent,
day: Date, day: Date,
@@ -276,16 +285,15 @@ export function getTimedEventBoundsForDay(
if (eventEnd <= dayStart || eventStart >= nextDayStart) return null; if (eventEnd <= dayStart || eventStart >= nextDayStart) return null;
const clippedStart = eventStart > dayStart ? eventStart : dayStart; // Wall-clock minutes, not elapsed time since midnight: on a DST change day
const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart; // the day has 23 or 25 hours, and elapsed time would shift every event
const startMinutes = Math.max( // after the change by an hour.
0, const startMinutes =
Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000), eventStart > dayStart ? Math.floor(wallClockMinutes(eventStart)) : 0;
); const endMinutes =
const endMinutes = Math.min( eventEnd < nextDayStart
1440, ? Math.max(startMinutes, Math.ceil(wallClockMinutes(eventEnd)))
Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000), : 1440;
);
return { return {
startMinutes, startMinutes,