From 40301f2a59b6b6a8e7d4156c2d066b4c7fa45ce8 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sun, 4 Jan 2026 13:36:16 +0100 Subject: [PATCH] fix(calendar): correct day-of-week for full-day recurring events across all timezones (#4004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes **full-day recurring events showing on wrong day** in timezones west of UTC (reported in #4003). **Root cause**: `moment.tz(date, eventTimezone).startOf("day")` interprets UTC midnight as local time: - `2025-11-03T00:00:00.000Z` in America/Chicago (UTC-6) - → Converts to `2025-11-02 18:00:00` (6 hours back) - → `.startOf("day")` → `2025-11-02 00:00:00` ❌ **Wrong day!** **Impact**: The bug affects: - All timezones west of UTC (UTC-1 through UTC-12): Americas, Pacific - Timezones east of UTC (UTC+1 through UTC+12): Europe, Asia, Africa - work correctly - UTC itself - works correctly The issue was introduced with commit c2ec6fc2 (#3976), which fixed the time but broke the date. This PR fixes both. | | Result | Day | Time | Notes | |----------|--------|-----|------|-------| | **Before c2ec6fc2** | `2025-11-03 05:00:00 Monday` | ✅ | ❌ | Wrong time, but correct day | | **Current (c2ec6fc2)** | `2025-11-02 00:00:00 Sunday` | ❌ (west of UTC)
✅ (east of UTC) | ✅ | Wrong day - visible bug! | | **This fix** | `2025-11-03 00:00:00 Monday` | ✅ | ✅ | Correct in all timezones | Note: While the old logic had incorrect timing, it produced the correct calendar day due to how it handled UTC offsets. The current logic fixed the timing issue but introduced the more visible calendar day bug. ### Solution Extract UTC date components and interpret as local calendar dates: ```javascript const utcYear = date.getUTCFullYear(); const utcMonth = date.getUTCMonth(); const utcDate = date.getUTCDate(); return moment.tz([utcYear, utcMonth, utcDate], eventTimezone); ``` ### Testing To prevent this from happening again in future refactorings, I wrote a test for it. ```bash npm test -- tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js ``` --- .../default/calendar/calendarfetcherutils.js | 8 +++- .../calendar/calendar_fetcher_utils_spec.js | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/modules/default/calendar/calendarfetcherutils.js b/modules/default/calendar/calendarfetcherutils.js index 61766c92..0959d52f 100644 --- a/modules/default/calendar/calendarfetcherutils.js +++ b/modules/default/calendar/calendarfetcherutils.js @@ -82,8 +82,12 @@ const CalendarFetcherUtils = { // rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone return dates.map((date) => { if (isFullDayEvent) { - // For all-day events, anchor to calendar day in event's timezone - return moment.tz(date, eventTimezone).startOf("day"); + // For all-day events, extract UTC date components and interpret as local calendar date + // This prevents timezone offsets from shifting the date to the previous/next day + const utcYear = date.getUTCFullYear(); + const utcMonth = date.getUTCMonth(); + const utcDate = date.getUTCDate(); + return moment.tz([utcYear, utcMonth, utcDate], eventTimezone); } // For timed events, preserve the time in the event's original timezone return moment.tz(date, "UTC").tz(eventTimezone, true); diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js index de072a67..8f5ab5bc 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -111,5 +111,47 @@ END:VEVENT`); expect(januaryFirst[0].toISOString(true)).toContain("09:00:00.000+01:00"); expect(julyFirst[0].toISOString(true)).toContain("09:00:00.000+02:00"); }); + + it("should return correct day-of-week for full-day recurring events across DST transitions", () => { + // Test case for GitHub issue #3976: recurring full-day events showing on wrong day + // This happens when DST transitions change the UTC offset between occurrences + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:20251027 +DTEND;VALUE=DATE:20251028 +RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3 +DTSTAMP:20260103T123138Z +UID:dst-test@google.com +SUMMARY:Weekly Monday Event +END:VEVENT +END:VCALENDAR`); + + const event = data["dst-test@google.com"]; + + // Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config) + // This is how MagicMirror handles full-day events from calendars with timezones + event.start.tz = "America/Chicago"; + + const pastMoment = moment("2025-10-01"); + const futureMoment = moment("2025-11-30"); + + // Get moments for the recurring event + const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastMoment, futureMoment, 0); + + // All occurrences should be on Monday (day() === 1) at midnight + // Oct 27, 2025 - Before DST ends + // Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix) + // Nov 10, 2025 - After DST ends + expect(moments).toHaveLength(3); + expect(moments[0].day()).toBe(1); // Monday + expect(moments[0].format("YYYY-MM-DD")).toBe("2025-10-27"); + expect(moments[0].hour()).toBe(0); // Midnight + expect(moments[1].day()).toBe(1); // Monday (not Sunday!) + expect(moments[1].format("YYYY-MM-DD")).toBe("2025-11-03"); + expect(moments[1].hour()).toBe(0); // Midnight + expect(moments[2].day()).toBe(1); // Monday + expect(moments[2].format("YYYY-MM-DD")).toBe("2025-11-10"); + expect(moments[2].hour()).toBe(0); // Midnight + }); }); });