From f358d60584232a754beceaca5dfce0f80055b071 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:18:45 +0100 Subject: [PATCH] fix: place calendar events at their clock time on DST change days --- src/lib/__tests__/calendar-utils.test.ts | 35 ++++++++++++++++++++++++ src/lib/calendar-utils.ts | 28 ++++++++++++------- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/lib/__tests__/calendar-utils.test.ts b/src/lib/__tests__/calendar-utils.test.ts index 5c1b0a66..16e8db3d 100644 --- a/src/lib/__tests__/calendar-utils.test.ts +++ b/src/lib/__tests__/calendar-utils.test.ts @@ -14,6 +14,7 @@ import { layoutOverlappingEvents, CALENDAR_COLOR_PALETTE, reopenedEdit, + getTimedEventBoundsForDay, } from '../calendar-utils'; import type { CalendarEvent } from '../../api/types'; import { useSettingsStore } from '../../stores/settings-store'; @@ -294,3 +295,37 @@ describe('reopenedEdit', () => { 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; + } + }); +}); diff --git a/src/lib/calendar-utils.ts b/src/lib/calendar-utils.ts index d1cf1c94..b39a82c6 100644 --- a/src/lib/calendar-utils.ts +++ b/src/lib/calendar-utils.ts @@ -263,6 +263,15 @@ export interface TimedDayBounds { continuesAfter: boolean; } +function wallClockMinutes(date: Date): number { + return ( + date.getHours() * 60 + + date.getMinutes() + + date.getSeconds() / 60 + + date.getMilliseconds() / 60000 + ); +} + export function getTimedEventBoundsForDay( event: CalendarEvent, day: Date, @@ -276,16 +285,15 @@ export function getTimedEventBoundsForDay( if (eventEnd <= dayStart || eventStart >= nextDayStart) return null; - const clippedStart = eventStart > dayStart ? eventStart : dayStart; - const clippedEnd = eventEnd < nextDayStart ? eventEnd : nextDayStart; - const startMinutes = Math.max( - 0, - Math.floor((clippedStart.getTime() - dayStart.getTime()) / 60000), - ); - const endMinutes = Math.min( - 1440, - Math.ceil((clippedEnd.getTime() - dayStart.getTime()) / 60000), - ); + // Wall-clock minutes, not elapsed time since midnight: on a DST change day + // the day has 23 or 25 hours, and elapsed time would shift every event + // after the change by an hour. + const startMinutes = + eventStart > dayStart ? Math.floor(wallClockMinutes(eventStart)) : 0; + const endMinutes = + eventEnd < nextDayStart + ? Math.max(startMinutes, Math.ceil(wallClockMinutes(eventEnd))) + : 1440; return { startMinutes,