diff --git a/lib/__tests__/calendar-utils.test.ts b/lib/__tests__/calendar-utils.test.ts index c34990532..87f245585 100644 --- a/lib/__tests__/calendar-utils.test.ts +++ b/lib/__tests__/calendar-utils.test.ts @@ -177,6 +177,42 @@ describe('calendar-utils all-day handling', () => { }); }); + it('places events by wall-clock time on DST change days', () => { + 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 = makeEvent({ + start: '2026-10-25T12:51:00', + duration: 'PT25M', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + 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 = makeEvent({ + start: '2026-03-29T09:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + 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; + } + }); + it('lays out continued timed events using clipped bounds for the active day', () => { const event = makeEvent({ start: '2026-03-14T22:00:00', diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index a6d41eb66..2299fb54c 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -102,6 +102,10 @@ export function getEventDayBounds(event: CalendarEvent): { startDay: Date; endDa }; } +function wallClockMinutes(date: Date): number { + return date.getHours() * 60 + date.getMinutes() + date.getSeconds() / 60 + date.getMilliseconds() / 60000; +} + export function getTimedEventBoundsForDay( event: CalendarEvent, day: Date, @@ -117,10 +121,13 @@ export function getTimedEventBoundsForDay( 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,