Skip to content

Keep a calendar's VTIMEZONEs in step with the zones it references - #2921

Open
brhellman wants to merge 1 commit into
Foundry376:masterfrom
brhellman:vtimezones-in-step
Open

brhellman wants to merge 1 commit into
Foundry376:masterfrom
brhellman:vtimezones-in-step

Conversation

@brhellman

@brhellman brhellman commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

RFC 5545 section 3.2.19 makes a TZID meaningful only by reference to a VTIMEZONE in the same iCalendar object, so the two have to change together. updateEventTimes replaced every VTIMEZONE with the new zone's when an event was retimed into another zone, so a recurring event with an inline exception still in the old zone referenced a TZID with no component behind it - which a strict parser is entitled to reject and a lenient one reads as floating. One syncVTimezones now runs at the end of createICSString and updateEventTimes: zones the calendar still references keep the server's own component, zones nothing references any more are dropped, and a referenced zone with no component gets one from createVTIMEZONEString - only when resolveIanaZone identifies the zone, so an Outlook display name or a private X- identifier is left as it arrived.

Nothing reached that case on master: only the single-event editor save passed the picker's zone, and a series save went through updateRecurringEventTimes and ignored it, so picking a new zone for a series did nothing. The series save now passes a zone the user picked (and only then - passing it unconditionally rewrites a UTC series into the machine zone). Rezoning the master alone would leave its EXDATE, RDATE, RRULE UNTIL and each inline exception's RECURRENCE-ID naming instants the rewritten rule no longer produces wherever the two zones' DST dates differ, so realignOccurrenceAnchors moves them by the master's wall-clock difference: a Berlin 10:00 EXDATE on 15 March becomes Chicago 03:00 (08:00Z, the rezoned instance), not 04:00 (the old instant). Exception DTSTART/DTEND stay where the user put them, which is the case the VTIMEZONE bookkeeping exists for. In a live DB all 1923 EXDATEs and 3645 inline RECURRENCE-IDs are zoned in the master's zone and all 117 UNTILs are UTC; the fixtures use that shape.

Verified in app/spec/ics-event-helpers-spec.ts and app/spec/calendar-event-popover-spec.ts: the popover's series save with the picker changed writes the master in the new zone, keeps both VTIMEZONEs and moves the EXDATE (red on master); the four anchors move by wall clock across the DST gap and ical-expander still excludes, adds and substitutes the same occurrences; an RDATE-only series and a UTC RECURRENCE-ID are handled; the untouched-picker save is unchanged. Each of fifteen mutations (zone passed unconditionally or never, realignment skipped per anchor kind, same instant instead of wall clock, TZID parameter left behind, guards dropped) goes red. ICS helper + popover suites 164 passing; full suite 2111 passing, 0 failing. Lint, prettier and tsc clean on master 10b5526b7. Extracted from the calendar-scheduling branch.

🤖 Generated with Claude Code

@manilabui

Copy link
Copy Markdown
Contributor

Nothing reaches this yet. Only the single-event editor save passes timezone to updateEventTimes; a series save goes through updateRecurringEventTimes and ignores the zone picker. I ran the editor on a single event, the editor on a Berlin series with a Berlin exception, and a drag: no stranded TZID on master, and identical output on this branch.

The gap behind it is real, though: picking a new zone for a series does nothing today. Would you like to take that on, so the bookkeeping here has a caller? Either in this PR or one this stacks under, whichever you prefer.

🤖 Generated with Claude Code

RFC 5545 section 3.2.19 makes a TZID meaningful only through a VTIMEZONE in the same
iCalendar object. updateEventTimes replaced every VTIMEZONE with the new zone's, so an inline
exception still in the old zone referenced a TZID with nothing behind it. syncVTimezones now
runs at the end of createICSString and updateEventTimes: referenced zones keep the server's
component, unreferenced ones go, and a referenced zone with none gets one.

Nothing reached that case: only the single-event save passed the picker's zone, and a series
save ignored it. The series save now passes a zone the user picked through
updateRecurringEventTimes, and the master's EXDATE, RDATE, RRULE UNTIL and each inline
exception's RECURRENCE-ID move with it by wall clock, so they keep naming the same instances
across the weeks where only one of the two zones is on DST.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@brhellman

Copy link
Copy Markdown
Contributor Author

Taken on here, in ed5f23b22 (rebased onto 10b5526).

A series save now passes the picker's zone through updateRecurringEventTimes into updateEventTimes, so syncVTimezones has a caller: your Berlin series with a Berlin exception, saved into America/Chicago, comes out with DTSTART;TZID=America/Chicago on the master, the exception still in Berlin, and both VTIMEZONEs present. The zone is passed only when the picker was changed; passing it unconditionally rewrites a UTC series into the machine zone, which the existing "keeps a detailed RRULE when only the title changed" case catches (its EXDATE:20260317T140000Z becomes 20260317T130000Z).

Rezoning the master alone leaves its EXDATE, RDATE, RRULE UNTIL and each inline exception's RECURRENCE-ID naming instants the rewritten rule no longer hits wherever the two zones' DST dates differ (Chicago from 10 March 2024, Berlin from 31 March), so realignOccurrenceAnchors moves them by the master's wall-clock difference instead: EXDATE;TZID=Europe/Berlin:20240315T100000 becomes EXDATE;TZID=America/Chicago:20240315T030000 (08:00Z, the rezoned instance), not 04:00 (09:00Z, the old instant). Expanding the result through ical-expander shows the same day excluded, the RDATE added and the exception substituted. In the real DB all 1923 EXDATEs and 3645 inline RECURRENCE-IDs are zoned in the master's zone and all 117 UNTILs are UTC, which is the fixture's shape; this app's own UTC RECURRENCE-IDs stay UTC at the rezoned instant.

Red on master source with these specs: 7 (the popover series save and the six helper cases). Mutations I ran, each red: zone passed unconditionally (2), zone never passed (1), updateRecurringEventTimes drops the zone (6), no realign call (5), RDATE skipped (3), UNTIL skipped (1), RECURRENCE-ID skipped (3), same instant instead of wall clock (5), UTC value not read in the old zone first (3), every anchor written zoned (3), every anchor written UTC (3), old TZID parameter kept on EXDATE/RDATE (4) and on RECURRENCE-ID (1), rrule.until guard dropped (4), rrule guard dropped (2). Drag is unchanged: modifyAllOccurrences passes no zone.

ICS helper + popover suites 164 passing; full suite 2111 passing, 0 failing; lint, prettier and tsc clean. PR body updated to match.

One thing I left as it is: the single-event save still passes the picker's zone unconditionally, so a UTC single event edited in the editor gets the machine zone's TZID. Happy to bring it in line with the series save in a follow-up if you want that.

This branch has not been deployed

No deployments
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.

2 participants