Skip to content

fix(ics): resolve Windows TZIDs from Outlook published calendars#1913

Open
kapej42 wants to merge 1 commit into
callumalpass:mainfrom
kapej42:fix/windows-tzid-fallback
Open

fix(ics): resolve Windows TZIDs from Outlook published calendars#1913
kapej42 wants to merge 1 commit into
callumalpass:mainfrom
kapej42:fix/windows-tzid-fallback

Conversation

@kapej42
Copy link
Copy Markdown

@kapej42 kapej42 commented May 20, 2026

Fixes the bugs where ICS subscriptions from Microsoft Outlook (/owa/calendar/.../calendar.ics) display events at the wrong time — typically with a fixed offset equal to the user's local zone (e.g. +2h in CEST, +1h in CET).

Refs #781, #1085.

Repro

Outlook's published-calendar feeds reference events with Windows-style TZIDs and only inline a VTIMEZONE block for some of those TZIDs — typically the calendar owner's primary zone. Events from invitees in other zones reference TZIDs with no accompanying VTIMEZONE. Example from a real feed:

BEGIN:VTIMEZONE
TZID:Romance Standard Time
...
END:VTIMEZONE

BEGIN:VEVENT
SUMMARY:Tech Lunch
DTSTART;TZID=W. Europe Standard Time:20260520T093000   ← no VTIMEZONE for this!
DTEND;TZID=W. Europe Standard Time:20260520T103000
END:VEVENT

The user, viewing in Europe/Amsterdam (CEST = UTC+2), sees this event at 11:30 instead of 09:30.

Root cause

registerCalendarVTimezones correctly registers Romance Standard Time (because the feed inlines its VTIMEZONE) but W. Europe Standard Time is unknown to ical.js. ICAL.Event#startDate.zone becomes floating. icalTimeToISOString then calls toUnixTime() on a floating time, which interprets the wall clock as UTC. When that ISO string is rendered in the user's local zone, it surfaces as a fixed offset (= user's UTC offset).

ical.js 2.2.1 has no IANA tzdata built in (ICAL.TimezoneService.has('Europe/Berlin') is false until a VTIMEZONE is registered), so it can't recover from a missing VTIMEZONE on its own.

Fix

Add a fallback that activates only when ical.js has demoted a time to floating:

  1. Read the raw TZID parameter from the source property (DTSTART/DTEND/recurring instances).
  2. Resolve it via the CLDR windowsZones table to an IANA name (e.g. W. Europe Standard TimeEurope/Berlin), or pass through if it's already a valid IANA name.
  3. Convert the wall clock to a UTC instant using Intl.DateTimeFormat, which is fully populated with IANA tzdata in Electron/Obsidian.

The existing VTIMEZONE-resolved path remains the fast path — fallback only runs when zone.tzid === 'floating'.

Files

  • src/utils/icsTimezoneFallback.ts (new) — CLDR map + Intl-based wall-to-UTC conversion
  • src/services/ICSSubscriptionService.ts — wire the fallback through icalTimeToISOString, including the recurring-instance and modified-instance paths
  • src/types/ical.d.ts — add Component.getFirstProperty and a typed Time.zone shape
  • tests/__mocks__/ical.ts — extend the mock with getFirstProperty for parity with real ical.js
  • tests/unit/utils/icsTimezoneFallback.test.ts (new) — 11 unit tests covering the Windows→IANA mapping, IANA pass-through, Intl-based wall-to-UTC conversion across CEST/CET/EST/PDT/UTC/Etc-GMT zones, and DST-boundary behavior

Trade-offs

  • No new dependencies. CLDR map is ~130 entries (~3 KB), Intl conversion is ~30 lines.
  • DST ambiguity: during the spring-forward gap (a wall time that doesn't exist) and fall-back overlap (a wall time that exists twice), the algorithm resolves to one consistent interpretation per iteration. The 3-iteration loop guarantees convergence for normal cases.
  • Behavior preserved for working feeds: events with proper VTIMEZONE blocks (Google, Cozi, Apple, Thunderbird, …) go through the original toUnixTime() path untouched.

Testing

  • ✅ 11/11 new unit tests pass.
  • ✅ Full unit suite: 3404 passing, same 4 failures as baseline (pre-existing, unrelated to ICS).
  • ✅ Typecheck clean (the 2 unrelated releaseNotes errors are pre-existing for the build-time generated file).
  • ✅ Verified against a real Outlook-published feed (40 KB, 661 VEVENTs): parses 623 events with 0 errors, and times now match what Outlook itself displays (09:30 events show at 09:30, previously they showed at 11:30 in CEST).

…lumalpass#781, callumalpass#1085)

Outlook's "publish to web" calendar feeds reference events with Windows-
style TZIDs (e.g. `W. Europe Standard Time`, `Romance Standard Time`) but
only inline VTIMEZONE blocks for some of the TZIDs referenced — typically
just the calendar owner's primary zone. Events from invitees in other
zones reference TZIDs that have no accompanying VTIMEZONE, and ical.js
silently demotes those to floating time. `toUnixTime()` then interprets
the wall clock as UTC, surfacing to the user as a fixed offset equal to
their local zone (e.g. +2h in Europe/Amsterdam in summer).

The existing `registerCalendarVTimezones` helper already handles VTIMEZONE
blocks that *are* in the feed. This change adds a fallback for the missing
ones: when ical.js ends up with a floating zone but the source property
had a TZID parameter, resolve that TZID against the CLDR `windowsZones`
table (Windows -> IANA) and convert the wall time to UTC via
`Intl.DateTimeFormat`, which is fully populated with IANA tzdata in any
modern JS runtime (Electron/Obsidian included).

No new dependencies. The CLDR map is ~3 KB; the Intl-based conversion is
~30 lines. Existing VTIMEZONE-based resolution remains the fast path.

- Add src/utils/icsTimezoneFallback.ts:
  - WINDOWS_TZID_TO_IANA (CLDR windowsZones, ~130 entries)
  - resolveTzidToIANA: passes through IANA names, maps Windows names,
    strips "(GMT+01:00) ..." prefixes some clients prepend
  - wallTimeInZoneToUtcIso: Intl-based wall-to-UTC with DST-gap retry
- Modify ICSSubscriptionService.parseICS to read raw TZID parameters
  from DTSTART/DTEND (incl. modified recurring instances) and pass them
  to icalTimeToISOString for fallback resolution.
- Switch recurring-instance end-time derivation to use the already-
  resolved start/end ISO strings, so fallback resolution applies to
  every instance, not just the first.
- Extend the ical.js .d.ts shim with Component.getFirstProperty and a
  typed Time.zone shape; extend the test mock with getFirstProperty
  for parity.
- Add 11 unit tests covering the Windows->IANA mapping, IANA pass-
  through, Intl-based wall-to-UTC conversion across CEST/CET/EST/PDT/
  UTC/Etc-GMT zones, and DST-boundary behavior.

Refs callumalpass#781 callumalpass#1085
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant