Add recurrence expansion

Rules come from the rrule crate. What is ours is the layer above it:
reconciling a series master with the RECURRENCE-ID overrides that replace
individual occurrences, which is where the subtlety actually lives. An
override suppresses the occurrence it names, and an override whose master
falls outside the window is still emitted -- a published feed truncates
series at its edge, and those are real events.

Occurrences know their own RECURRENCE-ID. v1 encoded instance identity as
a "{uid}-{timestamp}" string and split it apart again in the view layer.

TZID resolution is a ladder: IANA, then Windows zone names through a CLDR
table generated from windowsZones.xml, then an assumption that is reported
rather than hidden. Exchange writes "Pacific Standard Time" and every zone
in the real feed maps. Deriving a VTIMEZONE's own offsets is not built --
nothing in the corpus needs it, and the definitions are already carried
should that change.

Daylight saving is settled explicitly rather than by unwrap. An ambiguous
time takes the earlier reading; a time inside a spring-forward gap slides
past it by the gap's own length, so 02:15 and 02:45 stay distinct and stay
in order instead of both snapping to 03:00.

Tested against known-good outputs: DST both directions, leap day, nth
weekday, BYMONTHDAY on short months, COUNT against UNTIL, EXDATE against
an override, and the real Outlook feed -- where the assertion is that no
series ever yields two occurrences at one instant, which is what the old
importer's title-matching heuristics were standing in for.
This commit is contained in:
2026-08-26 14:50:52 -04:00
parent 7043a151f6
commit 689b78154b
10 changed files with 1524 additions and 1 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ serde_json = { workspace = true }
pretty_assertions = { workspace = true }
# Turns the optional features on for the test build only, so `cargo test` covers
# the iCalendar layer without the frontend's default build ever pulling it in.
runway-core = { path = ".", features = ["ical"] }
runway-core = { path = ".", features = ["ical", "recurrence"] }
[lints]
workspace = true