diff --git a/crates/runway-core/Cargo.toml b/crates/runway-core/Cargo.toml index 264ed97..4b894bd 100644 --- a/crates/runway-core/Cargo.toml +++ b/crates/runway-core/Cargo.toml @@ -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 diff --git a/crates/runway-core/src/lib.rs b/crates/runway-core/src/lib.rs index 5035105..5fac332 100644 --- a/crates/runway-core/src/lib.rs +++ b/crates/runway-core/src/lib.rs @@ -16,4 +16,7 @@ pub mod model; #[cfg(feature = "ical")] pub mod ical; +#[cfg(feature = "recurrence")] +pub mod recurrence; + pub use model::*; diff --git a/crates/runway-core/src/model/mod.rs b/crates/runway-core/src/model/mod.rs index 7c43c81..2166e05 100644 --- a/crates/runway-core/src/model/mod.rs +++ b/crates/runway-core/src/model/mod.rs @@ -10,6 +10,7 @@ mod calendar; mod datetime; mod event; mod object; +mod occurrence; mod person; mod property; mod timezone; @@ -22,6 +23,7 @@ pub use event::{ VEvent, }; pub use object::{CalendarObject, EditScope}; +pub use occurrence::Occurrence; pub use person::{Attendee, CalendarUser, CalendarUserType, ParticipationStatus, Role}; pub use property::{PropertyParam, UnknownComponent, UnknownProperty}; pub use timezone::{InvalidUtcOffset, TimeZoneRule, TimeZoneRuleKind, UtcOffset, VTimeZone}; diff --git a/crates/runway-core/src/model/occurrence.rs b/crates/runway-core/src/model/occurrence.rs new file mode 100644 index 0000000..de79ad0 --- /dev/null +++ b/crates/runway-core/src/model/occurrence.rs @@ -0,0 +1,71 @@ +//! A single concrete instance of an event. +//! +//! Expansion happens on the server and the client receives these, already +//! discrete. That is the opposite of the previous iteration, which shipped ~650 +//! lines of hand-rolled RRULE expansion into the browser and then had to invent +//! identities for the instances it produced — encoding them as +//! `"{uid}-{timestamp}"` strings that the view layer split apart again to work +//! out what it was looking at. An occurrence names itself here instead. + +use super::datetime::CalendarDateTime; +use super::event::VEvent; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// One instance of an event on a calendar. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Occurrence { + /// The event describing this instance: the series master, or the override + /// that replaces it. Carried whole so the UI needs nothing else to render. + pub event: VEvent, + + /// When this instance starts, in the form the calendar expresses it — a + /// date for an all-day event, a zoned wall-clock time otherwise. + pub start: CalendarDateTime, + /// When it ends, in the same form. + pub end: CalendarDateTime, + + /// The same instants, resolved. Ordering, windowing and overlap tests use + /// these; rendering uses the pair above. Keeping both means the client + /// never has to resolve a zone, and never has to guess one. + pub start_utc: DateTime, + pub end_utc: DateTime, + + /// Which instance of the series this is — the `RECURRENCE-ID` that + /// addresses it. `None` for a one-off event. + /// + /// This is the identity the previous iteration reconstructed by splitting + /// a string on a hyphen. An edit to a single occurrence sends this value + /// back, and it is the same value the server would write as + /// `RECURRENCE-ID`, so nothing has to be reverse-engineered. + pub recurrence_id: Option, + + /// True when this instance came from a `VEVENT` of its own rather than + /// from expanding the master — a modified occurrence. + pub is_override: bool, +} + +impl Occurrence { + /// The `UID` of the series this instance belongs to. + pub fn uid(&self) -> &str { + &self.event.uid + } + + /// The title to display. + pub fn title(&self) -> Option<&str> { + self.event.title() + } + + /// Whether this instance occupies whole days. + pub fn is_all_day(&self) -> bool { + self.start.is_date_only() + } + + /// Whether this instance overlaps a half-open window. + /// + /// Half-open on purpose: an event ending exactly when a window begins does + /// not belong to it, and a zero-length event still does. + pub fn overlaps(&self, from: DateTime, to: DateTime) -> bool { + self.start_utc < to && (self.end_utc > from || self.start_utc >= from) + } +} diff --git a/crates/runway-core/src/recurrence/error.rs b/crates/runway-core/src/recurrence/error.rs new file mode 100644 index 0000000..6075913 --- /dev/null +++ b/crates/runway-core/src/recurrence/error.rs @@ -0,0 +1,22 @@ +//! Errors from expanding recurrence rules. + +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RecurrenceError { + /// The `RRULE` could not be parsed, or is not valid against its `DTSTART`. + #[error("event {uid} has an unusable RRULE {rrule:?}: {reason}")] + InvalidRule { + uid: String, + rrule: String, + reason: String, + }, + + /// The rule would produce more instances in this window than any calendar + /// could sensibly show. + /// + /// Reported instead of truncating: a view quietly missing events is worse + /// than one that admits it could not build the list. + #[error("event {uid} generates more than {limit} occurrences in this window")] + TooManyOccurrences { uid: String, limit: u16 }, +} diff --git a/crates/runway-core/src/recurrence/expand.rs b/crates/runway-core/src/recurrence/expand.rs new file mode 100644 index 0000000..f0b49b2 --- /dev/null +++ b/crates/runway-core/src/recurrence/expand.rs @@ -0,0 +1,282 @@ +//! Expanding a calendar into the occurrences that fall inside a window. +//! +//! The rules themselves come from the `rrule` crate. The previous iteration +//! hand-wrote this — `generate_weekly_byday_occurrences`, +//! `generate_monthly_byday_occurrences`, `add_months`, `days_in_month`, +//! `is_leap_year` and the rest, some 650 lines — and shipped it into the +//! browser with no tests. `chrono` already had half of it and `rrule` had the +//! other half. +//! +//! What is genuinely ours is the part above the rule engine: reconciling a +//! series master with the overrides that replace individual occurrences of it. +//! That is where the real subtlety lives, and it is what makes the ~500 lines +//! of "duplicate" heuristics in the old ICS importer unnecessary. + +use super::error::RecurrenceError; +use super::zone::{Zones, to_instant, to_local}; +use crate::model::{CalendarDateTime, Occurrence, TzId, VCalendar, VEvent}; +use chrono::{DateTime, TimeDelta, Utc}; +use rrule::{RRule, RRuleSet, Tz as RRuleTz, Unvalidated}; +use std::collections::HashMap; + +/// The most occurrences one series may contribute to a single window. +/// +/// A malformed or pathological rule — `FREQ=SECONDLY` with no `COUNT` — must +/// not be able to hang the server. Hitting this is reported rather than +/// silently truncating the calendar, because a view that is quietly missing +/// events is worse than one that says it failed. +const MAX_PER_SERIES: u16 = 10_000; + +/// A half-open span of time to expand into. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Window { + pub from: DateTime, + pub to: DateTime, +} + +impl Window { + pub fn new(from: DateTime, to: DateTime) -> Self { + Self { from, to } + } +} + +/// Expands every event in a calendar into the occurrences visible in a window. +/// +/// Results are ordered by start instant, then by `UID`, so the output is +/// deterministic for a given input. +pub fn expand( + calendar: &VCalendar, + window: Window, + zones: Zones, +) -> Result, RecurrenceError> { + let mut out = Vec::new(); + for series in group_by_uid(&calendar.events) { + expand_series(&series, window, zones, &mut out)?; + } + out.retain(|o| o.start_utc < window.to && o.end_utc > window.from || touches_start(o, window)); + out.sort_by(|a, b| { + a.start_utc + .cmp(&b.start_utc) + .then_with(|| a.uid().cmp(b.uid())) + .then_with(|| a.is_override.cmp(&b.is_override)) + }); + Ok(out) +} + +/// A zero-length event exactly on the window's edge still belongs to it. +fn touches_start(occurrence: &Occurrence, window: Window) -> bool { + occurrence.start_utc == occurrence.end_utc + && occurrence.start_utc >= window.from + && occurrence.start_utc < window.to +} + +/// Everything sharing one `UID`, in the order first seen. +/// +/// This grouping is the whole game. A published feed lists a series master and +/// its modified occurrences as separate `VEVENT`s with the same `UID` and the +/// same `SUMMARY`; read as a flat list they look like duplicates, which is +/// exactly what the old importer concluded before it started merging them by +/// title and throwing the losers away. +fn group_by_uid(events: &[VEvent]) -> Vec> { + let mut order: Vec<&str> = Vec::new(); + let mut groups: HashMap<&str, Vec<&VEvent>> = HashMap::new(); + for event in events { + let uid = event.uid.as_str(); + if !groups.contains_key(uid) { + order.push(uid); + } + groups.entry(uid).or_default().push(event); + } + order + .into_iter() + .filter_map(|uid| groups.remove(uid)) + .collect() +} + +fn expand_series( + series: &[&VEvent], + window: Window, + zones: Zones, + out: &mut Vec, +) -> Result<(), RecurrenceError> { + let master = series.iter().copied().find(|e| !e.is_override()); + let overrides: Vec<&VEvent> = series.iter().copied().filter(|e| e.is_override()).collect(); + + // Every override is an occurrence in its own right. Emitted first, and + // emitted even when the master is absent: a feed truncated at its window + // edge leaves overrides whose master is outside it, and those are real + // events. Discarding them is data loss the viewer would never know about. + for event in &overrides { + out.push(single_occurrence(event, zones)); + } + + let Some(master) = master else { + return Ok(()); + }; + + if !master.is_recurring() { + out.push(single_occurrence(master, zones)); + return Ok(()); + } + + // An occurrence the master would generate is suppressed when an override + // replaces it. Matched on the instant the RECURRENCE-ID names, not on its + // written form, so a producer that writes the same moment a different way + // still lines up. + let replaced: Vec> = overrides + .iter() + .filter_map(|e| e.recurrence_id.as_ref()) + .map(|rid| zones.instant(rid)) + .collect(); + + for start in rule_starts(master, window, zones)? { + if replaced.contains(&zones.instant(&start)) { + continue; + } + out.push(occurrence_at(master, start, zones)); + } + Ok(()) +} + +/// Runs the rule engine for one master, returning starts in the master's own +/// representation. +fn rule_starts( + master: &VEvent, + window: Window, + zones: Zones, +) -> Result, RecurrenceError> { + let resolved = zones.zone_of(&master.dtstart); + let tz = RRuleTz::Tz(resolved.tz); + // Built through the same conversion as everything else, so a DTSTART that + // lands in a daylight-saving gap is nudged once, here, rather than + // differently in each caller. + let dt_start = to_instant(resolved.tz, master.dtstart.naive_local()).with_timezone(&tz); + + let mut set = RRuleSet::new(dt_start); + if let Some(text) = &master.rrule { + let parsed: RRule = + text.parse() + .map_err(|e: rrule::RRuleError| RecurrenceError::InvalidRule { + uid: master.uid.clone(), + rrule: text.clone(), + reason: e.to_string(), + })?; + let validated = parsed + .validate(dt_start) + .map_err(|e| RecurrenceError::InvalidRule { + uid: master.uid.clone(), + rrule: text.clone(), + reason: e.to_string(), + })?; + set = set.rrule(validated); + } + for date in &master.rdate { + set = set.rdate(zones.instant(date).with_timezone(&tz)); + } + for date in &master.exdate { + set = set.exdate(zones.instant(date).with_timezone(&tz)); + } + + // An event may begin before the window and run into it, so the search + // starts far enough back to catch the longest one present. + let lead = master.duration().max(TimeDelta::days(1)); + let result = set + .after((window.from - lead).with_timezone(&tz)) + .before(window.to.with_timezone(&tz)) + .all(MAX_PER_SERIES); + + if result.limited { + return Err(RecurrenceError::TooManyOccurrences { + uid: master.uid.clone(), + limit: MAX_PER_SERIES, + }); + } + + Ok(result + .dates + .into_iter() + .map(|dt| { + rebuild( + &master.dtstart, + to_local(resolved.tz, dt.with_timezone(&Utc)), + ) + }) + .collect()) +} + +/// Puts a generated instant back into the form the master's `DTSTART` used. +/// +/// A series of all-day events yields dates; a zoned series yields the same zone +/// it started in, spelled the same way. Neither is normalised, because an +/// occurrence that changed shape on the way out would not match the +/// `RECURRENCE-ID` a later edit has to send back. +fn rebuild(pattern: &CalendarDateTime, local: chrono::NaiveDateTime) -> CalendarDateTime { + match pattern { + CalendarDateTime::Date { .. } => CalendarDateTime::Date { date: local.date() }, + CalendarDateTime::Floating { .. } => CalendarDateTime::Floating { local }, + CalendarDateTime::Utc { .. } => CalendarDateTime::Utc { + utc: local.and_utc(), + }, + CalendarDateTime::Zoned { tzid, .. } => CalendarDateTime::Zoned { + local, + tzid: tzid.clone(), + }, + } +} + +/// An event that is not part of a series, or an override standing on its own. +fn single_occurrence(event: &VEvent, zones: Zones) -> Occurrence { + let start = event.dtstart.clone(); + let end = event.dtend(); + Occurrence { + start_utc: zones.instant(&start), + end_utc: zones.instant(&end), + recurrence_id: event.recurrence_id.clone(), + is_override: event.is_override(), + event: event.clone(), + start, + end, + } +} + +/// One generated instance of a series. +fn occurrence_at(master: &VEvent, start: CalendarDateTime, zones: Zones) -> Occurrence { + // Wall-clock arithmetic, which is what RFC 5545 means: a 09:00-10:00 + // meeting is 09:00-10:00 local at every occurrence, including the ones + // either side of a daylight-saving change. + let end = start.shifted(master.duration()); + Occurrence { + start_utc: zones.instant(&start), + end_utc: zones.instant(&end), + recurrence_id: Some(start.clone()), + is_override: false, + event: master.clone(), + start, + end, + } +} + +/// Every zone identifier the calendar names that could not be resolved. +/// +/// Worth surfacing: an unresolved zone means times may be wrong by hours, and +/// the alternative to reporting it is pretending otherwise. +pub fn unresolved_zones(calendar: &VCalendar, zones: Zones) -> Vec { + let mut names: Vec = calendar + .referenced_tzids() + .into_iter() + .filter(|id| { + TzId::new(*id) + .is_ok_and(|tzid| zones.resolve(&tzid).source == super::zone::ZoneSource::Assumed) + }) + .map(str::to_owned) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// Resolves a single wall-clock time in a named zone. Exposed because the +/// CalDAV layer needs the same conversion when it builds time-range queries. +pub fn instant_in(tz: chrono_tz::Tz, local: chrono::NaiveDateTime) -> DateTime { + to_instant(tz, local) +} diff --git a/crates/runway-core/src/recurrence/mod.rs b/crates/runway-core/src/recurrence/mod.rs new file mode 100644 index 0000000..f4a066a --- /dev/null +++ b/crates/runway-core/src/recurrence/mod.rs @@ -0,0 +1,25 @@ +//! Recurrence expansion — RFC 5545 §3.8.5. +//! +//! Two decisions from the audit shape this module. +//! +//! **It runs on the server.** The previous iteration expanded rules in the +//! browser, which meant one hand-written implementation with no tests, shipped +//! into the WASM bundle, producing instances the view layer then had to invent +//! identities for. Here the client receives [`Occurrence`]s that already know +//! what they are. +//! +//! **The rules come from `rrule`.** What this module actually implements is the +//! layer above: reconciling a series master with the `RECURRENCE-ID` overrides +//! that replace individual occurrences of it, and resolving the zones involved. +//! +//! [`Occurrence`]: crate::model::Occurrence + +mod error; +mod expand; +mod windows_zones; +mod zone; + +pub use error::RecurrenceError; +pub use expand::{Window, expand, instant_in, unresolved_zones}; +pub use windows_zones::iana_for_windows; +pub use zone::{ResolvedZone, ZoneSource, Zones, to_instant, to_local}; diff --git a/crates/runway-core/src/recurrence/windows_zones.rs b/crates/runway-core/src/recurrence/windows_zones.rs new file mode 100644 index 0000000..28a1c23 --- /dev/null +++ b/crates/runway-core/src/recurrence/windows_zones.rs @@ -0,0 +1,166 @@ +//! Windows time zone names, mapped to IANA identifiers. +//! +//! Exchange writes `TZID:Pacific Standard Time` rather than +//! `America/Los_Angeles`, so a published Outlook feed names zones that no IANA +//! database contains. This is the mapping that closes the gap. +//! +//! Generated from CLDR `common/supplemental/windowsZones.xml` (typeVersion +//! 2021a), keeping the `territory="001"` row for each Windows name — the +//! territory-independent default. Regenerate rather than hand-editing. +//! +//! The reverse direction is deliberately absent: many IANA zones share one +//! Windows name, so it does not round-trip. That is one more reason the parser +//! stores whatever `TZID` the producer wrote and resolves it only here. + +/// The IANA identifier for a Windows zone name, if it is one. +/// +/// Matched case-insensitively; these names are prose and producers vary. +pub fn iana_for_windows(name: &str) -> Option<&'static str> { + WINDOWS_TO_IANA + .iter() + .find(|(windows, _)| windows.eq_ignore_ascii_case(name)) + .map(|(_, iana)| *iana) +} + +/// 139 Windows zone names and the IANA zone each stands for. +const WINDOWS_TO_IANA: [(&str, &str); 139] = [ + ("AUS Central Standard Time", "Australia/Darwin"), + ("AUS Eastern Standard Time", "Australia/Sydney"), + ("Afghanistan Standard Time", "Asia/Kabul"), + ("Alaskan Standard Time", "America/Anchorage"), + ("Aleutian Standard Time", "America/Adak"), + ("Altai Standard Time", "Asia/Barnaul"), + ("Arab Standard Time", "Asia/Riyadh"), + ("Arabian Standard Time", "Asia/Dubai"), + ("Arabic Standard Time", "Asia/Baghdad"), + ("Argentina Standard Time", "America/Buenos_Aires"), + ("Astrakhan Standard Time", "Europe/Astrakhan"), + ("Atlantic Standard Time", "America/Halifax"), + ("Aus Central W. Standard Time", "Australia/Eucla"), + ("Azerbaijan Standard Time", "Asia/Baku"), + ("Azores Standard Time", "Atlantic/Azores"), + ("Bahia Standard Time", "America/Bahia"), + ("Bangladesh Standard Time", "Asia/Dhaka"), + ("Belarus Standard Time", "Europe/Minsk"), + ("Bougainville Standard Time", "Pacific/Bougainville"), + ("Canada Central Standard Time", "America/Regina"), + ("Cape Verde Standard Time", "Atlantic/Cape_Verde"), + ("Caucasus Standard Time", "Asia/Yerevan"), + ("Cen. Australia Standard Time", "Australia/Adelaide"), + ("Central America Standard Time", "America/Guatemala"), + ("Central Asia Standard Time", "Asia/Bishkek"), + ("Central Brazilian Standard Time", "America/Cuiaba"), + ("Central Europe Standard Time", "Europe/Budapest"), + ("Central European Standard Time", "Europe/Warsaw"), + ("Central Pacific Standard Time", "Pacific/Guadalcanal"), + ("Central Standard Time", "America/Chicago"), + ("Central Standard Time (Mexico)", "America/Mexico_City"), + ("Chatham Islands Standard Time", "Pacific/Chatham"), + ("China Standard Time", "Asia/Shanghai"), + ("Cuba Standard Time", "America/Havana"), + ("Dateline Standard Time", "Etc/GMT+12"), + ("E. Africa Standard Time", "Africa/Nairobi"), + ("E. Australia Standard Time", "Australia/Brisbane"), + ("E. Europe Standard Time", "Europe/Chisinau"), + ("E. South America Standard Time", "America/Sao_Paulo"), + ("Easter Island Standard Time", "Pacific/Easter"), + ("Eastern Standard Time", "America/New_York"), + ("Eastern Standard Time (Mexico)", "America/Cancun"), + ("Egypt Standard Time", "Africa/Cairo"), + ("Ekaterinburg Standard Time", "Asia/Yekaterinburg"), + ("FLE Standard Time", "Europe/Kiev"), + ("Fiji Standard Time", "Pacific/Fiji"), + ("GMT Standard Time", "Europe/London"), + ("GTB Standard Time", "Europe/Bucharest"), + ("Georgian Standard Time", "Asia/Tbilisi"), + ("Greenland Standard Time", "America/Godthab"), + ("Greenwich Standard Time", "Atlantic/Reykjavik"), + ("Haiti Standard Time", "America/Port-au-Prince"), + ("Hawaiian Standard Time", "Pacific/Honolulu"), + ("India Standard Time", "Asia/Calcutta"), + ("Iran Standard Time", "Asia/Tehran"), + ("Israel Standard Time", "Asia/Jerusalem"), + ("Jordan Standard Time", "Asia/Amman"), + ("Kaliningrad Standard Time", "Europe/Kaliningrad"), + ("Korea Standard Time", "Asia/Seoul"), + ("Libya Standard Time", "Africa/Tripoli"), + ("Line Islands Standard Time", "Pacific/Kiritimati"), + ("Lord Howe Standard Time", "Australia/Lord_Howe"), + ("Magadan Standard Time", "Asia/Magadan"), + ("Magallanes Standard Time", "America/Punta_Arenas"), + ("Marquesas Standard Time", "Pacific/Marquesas"), + ("Mauritius Standard Time", "Indian/Mauritius"), + ("Middle East Standard Time", "Asia/Beirut"), + ("Montevideo Standard Time", "America/Montevideo"), + ("Morocco Standard Time", "Africa/Casablanca"), + ("Mountain Standard Time", "America/Denver"), + ("Mountain Standard Time (Mexico)", "America/Mazatlan"), + ("Myanmar Standard Time", "Asia/Rangoon"), + ("N. Central Asia Standard Time", "Asia/Novosibirsk"), + ("Namibia Standard Time", "Africa/Windhoek"), + ("Nepal Standard Time", "Asia/Katmandu"), + ("New Zealand Standard Time", "Pacific/Auckland"), + ("Newfoundland Standard Time", "America/St_Johns"), + ("Norfolk Standard Time", "Pacific/Norfolk"), + ("North Asia East Standard Time", "Asia/Irkutsk"), + ("North Asia Standard Time", "Asia/Krasnoyarsk"), + ("North Korea Standard Time", "Asia/Pyongyang"), + ("Omsk Standard Time", "Asia/Omsk"), + ("Pacific SA Standard Time", "America/Santiago"), + ("Pacific Standard Time", "America/Los_Angeles"), + ("Pacific Standard Time (Mexico)", "America/Tijuana"), + ("Pakistan Standard Time", "Asia/Karachi"), + ("Paraguay Standard Time", "America/Asuncion"), + ("Qyzylorda Standard Time", "Asia/Qyzylorda"), + ("Romance Standard Time", "Europe/Paris"), + ("Russia Time Zone 10", "Asia/Srednekolymsk"), + ("Russia Time Zone 11", "Asia/Kamchatka"), + ("Russia Time Zone 3", "Europe/Samara"), + ("Russian Standard Time", "Europe/Moscow"), + ("SA Eastern Standard Time", "America/Cayenne"), + ("SA Pacific Standard Time", "America/Bogota"), + ("SA Western Standard Time", "America/La_Paz"), + ("SE Asia Standard Time", "Asia/Bangkok"), + ("Saint Pierre Standard Time", "America/Miquelon"), + ("Sakhalin Standard Time", "Asia/Sakhalin"), + ("Samoa Standard Time", "Pacific/Apia"), + ("Sao Tome Standard Time", "Africa/Sao_Tome"), + ("Saratov Standard Time", "Europe/Saratov"), + ("Singapore Standard Time", "Asia/Singapore"), + ("South Africa Standard Time", "Africa/Johannesburg"), + ("South Sudan Standard Time", "Africa/Juba"), + ("Sri Lanka Standard Time", "Asia/Colombo"), + ("Sudan Standard Time", "Africa/Khartoum"), + ("Syria Standard Time", "Asia/Damascus"), + ("Taipei Standard Time", "Asia/Taipei"), + ("Tasmania Standard Time", "Australia/Hobart"), + ("Tocantins Standard Time", "America/Araguaina"), + ("Tokyo Standard Time", "Asia/Tokyo"), + ("Tomsk Standard Time", "Asia/Tomsk"), + ("Tonga Standard Time", "Pacific/Tongatapu"), + ("Transbaikal Standard Time", "Asia/Chita"), + ("Turkey Standard Time", "Europe/Istanbul"), + ("Turks And Caicos Standard Time", "America/Grand_Turk"), + ("US Eastern Standard Time", "America/Indianapolis"), + ("US Mountain Standard Time", "America/Phoenix"), + ("UTC", "Etc/UTC"), + ("UTC+12", "Etc/GMT-12"), + ("UTC+13", "Etc/GMT-13"), + ("UTC-02", "Etc/GMT+2"), + ("UTC-08", "Etc/GMT+8"), + ("UTC-09", "Etc/GMT+9"), + ("UTC-11", "Etc/GMT+11"), + ("Ulaanbaatar Standard Time", "Asia/Ulaanbaatar"), + ("Venezuela Standard Time", "America/Caracas"), + ("Vladivostok Standard Time", "Asia/Vladivostok"), + ("Volgograd Standard Time", "Europe/Volgograd"), + ("W. Australia Standard Time", "Australia/Perth"), + ("W. Central Africa Standard Time", "Africa/Lagos"), + ("W. Europe Standard Time", "Europe/Berlin"), + ("W. Mongolia Standard Time", "Asia/Hovd"), + ("West Asia Standard Time", "Asia/Tashkent"), + ("West Bank Standard Time", "Asia/Hebron"), + ("West Pacific Standard Time", "Pacific/Port_Moresby"), + ("Yakutsk Standard Time", "Asia/Yakutsk"), + ("Yukon Standard Time", "America/Whitehorse"), +]; diff --git a/crates/runway-core/src/recurrence/zone.rs b/crates/runway-core/src/recurrence/zone.rs new file mode 100644 index 0000000..20b3c20 --- /dev/null +++ b/crates/runway-core/src/recurrence/zone.rs @@ -0,0 +1,146 @@ +//! Turning a `TZID` into a real time zone, and local times into instants. +//! +//! The rule this whole module exists to enforce: **store the zone, derive the +//! offset**. The previous iteration did the reverse — the client computed its +//! current UTC offset with `get_timezone_offset()` and sent that as the +//! event's `timezone` — and an offset cannot tell January from July. A weekly +//! 9am meeting created in winter moved to 8am the moment the clocks changed. +//! +//! Resolution is a ladder, because `TZID` is an opaque string and producers +//! disagree about what goes in it: +//! +//! 1. an IANA identifier, which is what most clients write; +//! 2. a Windows zone name, which is what Exchange writes; +//! 3. nothing that resolves — recorded as an assumption rather than hidden. + +use super::windows_zones::iana_for_windows; +use crate::model::{CalendarDateTime, TzId}; +use chrono::{DateTime, LocalResult, NaiveDateTime, Offset, TimeDelta, TimeZone, Utc}; +use chrono_tz::Tz; + +/// A resolved zone, and how it was arrived at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedZone { + pub tz: Tz, + pub source: ZoneSource, +} + +/// Which rung of the ladder produced a zone. +/// +/// Reported rather than swallowed: `Assumed` means a calendar named a zone +/// nothing could identify, and times from it may be wrong by hours. That is +/// worth a log line at the point of use, not a silent shrug. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZoneSource { + /// The `TZID` was an IANA identifier. + Iana, + /// The `TZID` was a Windows zone name, mapped through CLDR. + WindowsName, + /// Nothing matched; the viewer's own zone was substituted. + Assumed, +} + +/// Resolves the zones named by one calendar, against a viewer's own zone. +#[derive(Debug, Clone, Copy)] +pub struct Zones { + /// The zone to interpret floating times in, and to fall back to. + /// + /// A floating time means "this wall-clock time, wherever it is read", so + /// the viewer's zone is not a guess there — it is the definition. + pub display: Tz, +} + +impl Zones { + pub fn new(display: Tz) -> Self { + Self { display } + } + + /// Walks the ladder for one identifier. + pub fn resolve(&self, tzid: &TzId) -> ResolvedZone { + let name = tzid.as_str().trim(); + + if let Ok(tz) = name.parse::() { + return ResolvedZone { + tz, + source: ZoneSource::Iana, + }; + } + if let Some(tz) = iana_for_windows(name).and_then(|iana| iana.parse::().ok()) { + return ResolvedZone { + tz, + source: ZoneSource::WindowsName, + }; + } + ResolvedZone { + tz: self.display, + source: ZoneSource::Assumed, + } + } + + /// The zone a value should be interpreted in. + pub fn zone_of(&self, dt: &CalendarDateTime) -> ResolvedZone { + match dt { + CalendarDateTime::Zoned { tzid, .. } => self.resolve(tzid), + CalendarDateTime::Utc { .. } => ResolvedZone { + tz: Tz::UTC, + source: ZoneSource::Iana, + }, + // A date has no time of day and a floating time has no zone; both + // are read in the viewer's zone. + CalendarDateTime::Date { .. } | CalendarDateTime::Floating { .. } => ResolvedZone { + tz: self.display, + source: ZoneSource::Iana, + }, + } + } + + /// The instant a value denotes. + pub fn instant(&self, dt: &CalendarDateTime) -> DateTime { + match dt { + CalendarDateTime::Utc { utc } => *utc, + _ => { + let tz = self.zone_of(dt).tz; + to_instant(tz, dt.naive_local()) + } + } + } +} + +/// Converts a wall-clock time in a zone to the instant it names. +/// +/// Both awkward cases are handled deliberately rather than by `unwrap`: +/// +/// - **Ambiguous** — the hour that repeats when clocks go back. The earlier of +/// the two is taken, which is what a person setting an alarm for 01:30 means. +/// - **Non-existent** — the hour skipped when clocks go forward. The time is +/// read using the offset that was in force just before the gap, which slides +/// it past the gap by exactly the gap's own length: an 02:30 meeting on a +/// spring-forward Sunday happens at 03:30. +/// +/// The alternative for a gap — snapping to the first valid instant — would put +/// an 02:15 and an 02:45 meeting at the same moment, and lose the order they +/// were in. Sliding preserves both. +pub fn to_instant(tz: Tz, local: NaiveDateTime) -> DateTime { + match tz.from_local_datetime(&local) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&Utc), + LocalResult::None => { + // Walk back to the last wall-clock time that does exist and borrow + // its offset. Minute steps, so an unusual transition size — Lord + // Howe Island moves by 30 minutes — is still handled exactly. + for minutes in 1..=(6 * 60) { + let before = local - TimeDelta::minutes(minutes); + if let Some(dt) = tz.from_local_datetime(&before).earliest() { + let offset = dt.offset().fix(); + return local.and_utc() - TimeDelta::seconds(offset.local_minus_utc().into()); + } + } + local.and_utc() + } + } +} + +/// Converts an instant to the wall-clock time it shows in a zone. +pub fn to_local(tz: Tz, instant: DateTime) -> NaiveDateTime { + instant.with_timezone(&tz).naive_local() +} diff --git a/crates/runway-core/tests/recurrence.rs b/crates/runway-core/tests/recurrence.rs new file mode 100644 index 0000000..c918ef6 --- /dev/null +++ b/crates/runway-core/tests/recurrence.rs @@ -0,0 +1,806 @@ +//! Recurrence expansion, checked against known-good outputs. +//! +//! Most of these are table-driven and deliberately boring: a rule, a window, +//! and the exact list of local times it should produce. The interesting ones +//! are the boundaries — daylight saving, leap day, the difference between an +//! excluded occurrence and a replaced one — because those are where the +//! previous iteration's hand-written expander went wrong, and it had no tests +//! at all to say so. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use chrono::{DateTime, TimeDelta, Utc}; +use chrono_tz::Tz; +use pretty_assertions::assert_eq; +use runway_core::ical; +use runway_core::model::*; +use runway_core::recurrence::{RecurrenceError, Window, ZoneSource, Zones, expand}; +use std::path::Path; + +// ------------------------------------------------------------------ helpers -- + +fn denver() -> Tz { + Tz::America__Denver +} + +fn instant(text: &str) -> DateTime { + text.parse::>() + .unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn window(from: &str, to: &str) -> Window { + Window::new(instant(from), instant(to)) +} + +/// Wraps event bodies in a `VCALENDAR` and parses them. +fn calendar(events: &str) -> VCalendar { + let ics = format!( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//test//EN\r\n{}END:VCALENDAR\r\n", + events.replace('\n', "\r\n"), + ); + ical::parse(&ics).unwrap_or_else(|e| panic!("fixture did not parse: {e}\n{ics}")) +} + +/// A recurring event in Denver, 09:00–09:30, starting on the given date. +fn denver_series(start: &str, rrule: &str, extra: &str) -> VCalendar { + calendar(&format!( + "BEGIN:VEVENT +UID:series@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:{start}T090000 +DTEND;TZID=America/Denver:{start}T093000 +SUMMARY:Series +RRULE:{rrule} +{extra}END:VEVENT +" + )) +} + +fn occurrences(cal: &VCalendar, window: Window, tz: Tz) -> Vec { + expand(cal, window, Zones::new(tz)).unwrap_or_else(|e| panic!("expansion failed: {e}")) +} + +/// The local wall-clock start of each occurrence, as written. +fn local_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec { + occurrences(cal, window, tz) + .iter() + .map(|o| match &o.start { + CalendarDateTime::Date { date } => date.to_string(), + other => other.naive_local().to_string(), + }) + .collect() +} + +/// The UTC instant of each occurrence, which is what shows a zone bug. +fn utc_starts(cal: &VCalendar, window: Window, tz: Tz) -> Vec { + occurrences(cal, window, tz) + .iter() + .map(|o| o.start_utc.to_rfc3339()) + .collect() +} + +// -------------------------------------------------------------- basic rules -- + +#[test] +fn a_weekly_rule_produces_one_occurrence_a_week() { + let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO", ""); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver() + ), + vec![ + "2026-01-05 09:00:00", + "2026-01-12 09:00:00", + "2026-01-19 09:00:00", + "2026-01-26 09:00:00", + ], + ); +} + +#[test] +fn count_and_until_bound_a_series_the_same_way() { + let by_count = denver_series("20260105", "FREQ=DAILY;COUNT=3", ""); + let by_until = denver_series("20260105", "FREQ=DAILY;UNTIL=20260107T163000Z", ""); + let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"); + + let expected = vec![ + "2026-01-05 09:00:00", + "2026-01-06 09:00:00", + "2026-01-07 09:00:00", + ]; + assert_eq!(local_starts(&by_count, span, denver()), expected); + assert_eq!( + local_starts(&by_until, span, denver()), + expected, + "UNTIL is a UTC instant while the occurrences are local; comparing the \ + two in the wrong frame is how a series gains or loses its last event", + ); +} + +#[test] +fn a_monthly_nth_weekday_rule_lands_on_the_right_days() { + // The case the previous iteration built a form for and then dropped on the + // way to the API, so it never reached the server at all. + let cal = denver_series("20260120", "FREQ=MONTHLY;BYDAY=3TU", ""); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-05-01T00:00:00Z"), + denver() + ), + vec![ + "2026-01-20 09:00:00", + "2026-02-17 09:00:00", + "2026-03-17 09:00:00", + "2026-04-21 09:00:00", + ], + ); +} + +#[test] +fn a_monthly_by_monthday_rule_skips_months_that_are_too_short() { + let cal = denver_series("20260131", "FREQ=MONTHLY;BYMONTHDAY=31", ""); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-06-01T00:00:00Z"), + denver() + ), + vec![ + "2026-01-31 09:00:00", + "2026-03-31 09:00:00", + "2026-05-31 09:00:00", + ], + "February, April and June have no 31st, and the rule simply does not \ + fire -- it does not roll over into the next month", + ); +} + +#[test] +fn a_leap_day_rule_only_fires_in_leap_years() { + let cal = calendar( + "BEGIN:VEVENT +UID:leap@test +DTSTAMP:20240101T000000Z +DTSTART;VALUE=DATE:20240229 +DTEND;VALUE=DATE:20240301 +SUMMARY:Leap day +RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29 +END:VEVENT +", + ); + + assert_eq!( + local_starts( + &cal, + window("2024-01-01T00:00:00Z", "2034-01-01T00:00:00Z"), + denver() + ), + vec!["2024-02-29", "2028-02-29", "2032-02-29"], + ); +} + +// ------------------------------------------------------------- daylight saving + +#[test] +fn a_weekly_meeting_keeps_its_local_time_across_spring_forward() { + // Denver moves to daylight time on 2026-03-08. + let cal = denver_series("20260302", "FREQ=WEEKLY;BYDAY=MO", ""); + let span = window("2026-03-01T00:00:00Z", "2026-03-24T00:00:00Z"); + + assert_eq!( + local_starts(&cal, span, denver()), + vec![ + "2026-03-02 09:00:00", + "2026-03-09 09:00:00", + "2026-03-16 09:00:00", + "2026-03-23 09:00:00", + ], + "the meeting is at 09:00 every week, before and after the change", + ); + assert_eq!( + utc_starts(&cal, span, denver()), + vec![ + "2026-03-02T16:00:00+00:00", + "2026-03-09T15:00:00+00:00", + "2026-03-16T15:00:00+00:00", + "2026-03-23T15:00:00+00:00", + ], + "and the instant moves by an hour, which is the whole point. The last \ + iteration sent a fixed UTC offset in place of a zone, so this series \ + drifted to 08:00 for the rest of the year", + ); +} + +#[test] +fn a_weekly_meeting_keeps_its_local_time_across_fall_back() { + // Denver returns to standard time on 2026-11-01. + let cal = denver_series("20261026", "FREQ=WEEKLY;BYDAY=MO", ""); + let span = window("2026-10-20T00:00:00Z", "2026-11-17T00:00:00Z"); + + assert_eq!( + utc_starts(&cal, span, denver()), + vec![ + "2026-10-26T15:00:00+00:00", + "2026-11-02T16:00:00+00:00", + "2026-11-09T16:00:00+00:00", + "2026-11-16T16:00:00+00:00", + ], + ); +} + +#[test] +fn a_start_inside_the_spring_forward_gap_moves_past_it() { + // 02:30 on 2026-03-08 does not exist in Denver; the clock jumps 01:59 to + // 03:00. Something has to happen, and the choice is made once, explicitly: + // the time slides past the gap by the gap's own length. + let cal = calendar( + "BEGIN:VEVENT +UID:gap@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260308T023000 +DTEND;TZID=America/Denver:20260308T033000 +SUMMARY:In the gap +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-03-08T00:00:00Z", "2026-03-09T00:00:00Z"), + denver(), + ); + assert_eq!(found.len(), 1); + assert_eq!( + found[0].start_utc.to_rfc3339(), + "2026-03-08T09:30:00+00:00", + "03:30 local -- slid past the gap, not silently dropped, not thrown \ + back to the previous day, and not snapped to 03:00 alongside every \ + other lost time that morning", + ); +} + +#[test] +fn an_ambiguous_start_takes_the_earlier_of_the_two() { + // 01:30 on 2026-11-01 happens twice in Denver. + let cal = calendar( + "BEGIN:VEVENT +UID:ambiguous@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20261101T013000 +DTEND;TZID=America/Denver:20261101T020000 +SUMMARY:Twice over +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-11-01T00:00:00Z", "2026-11-02T00:00:00Z"), + denver(), + ); + assert_eq!( + found[0].start_utc.to_rfc3339(), + "2026-11-01T07:30:00+00:00", + "the first 01:30, which is what a person setting that time means", + ); +} + +// ------------------------------------------------------- exclusions and extras + +#[test] +fn exdate_removes_an_occurrence_the_rule_would_generate() { + let cal = denver_series( + "20260105", + "FREQ=WEEKLY;BYDAY=MO", + "EXDATE;TZID=America/Denver:20260112T090000,20260126T090000\n", + ); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver() + ), + vec!["2026-01-05 09:00:00", "2026-01-19 09:00:00"], + "both values on the one EXDATE line have to be honoured", + ); +} + +#[test] +fn rdate_adds_an_occurrence_the_rule_would_not() { + let cal = denver_series( + "20260105", + "FREQ=WEEKLY;BYDAY=MO;COUNT=2", + "RDATE;TZID=America/Denver:20260108T090000\n", + ); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver() + ), + vec![ + "2026-01-05 09:00:00", + "2026-01-08 09:00:00", + "2026-01-12 09:00:00", + ], + ); +} + +#[test] +fn an_all_day_series_excludes_by_date() { + let cal = calendar( + "BEGIN:VEVENT +UID:bins@test +DTSTAMP:20260101T000000Z +DTSTART;VALUE=DATE:20260105 +DTEND;VALUE=DATE:20260106 +SUMMARY:Bin day +RRULE:FREQ=WEEKLY;BYDAY=MO +EXDATE;VALUE=DATE:20260119 +END:VEVENT +", + ); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver() + ), + vec!["2026-01-05", "2026-01-12", "2026-01-26"], + ); +} + +// ----------------------------------------------------------------- overrides -- + +#[test] +fn an_override_replaces_the_occurrence_it_names_without_duplicating_it() { + let cal = calendar( + "BEGIN:VEVENT +UID:standup@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260105T090000 +DTEND;TZID=America/Denver:20260105T093000 +SUMMARY:Standup +RRULE:FREQ=WEEKLY;BYDAY=MO +END:VEVENT +BEGIN:VEVENT +UID:standup@test +RECURRENCE-ID;TZID=America/Denver:20260112T090000 +DTSTAMP:20260106T000000Z +DTSTART;TZID=America/Denver:20260112T140000 +DTEND;TZID=America/Denver:20260112T143000 +SUMMARY:Standup (moved) +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver(), + ); + let on_the_12th: Vec<&Occurrence> = found + .iter() + .filter(|o| o.start.date().to_string() == "2026-01-12") + .collect(); + + assert_eq!( + on_the_12th.len(), + 1, + "exactly one occurrence that day -- the master must not also generate \ + its 09:00 version. Emitting both is what made a correct feed look \ + like it was full of duplicates", + ); + assert!(on_the_12th[0].is_override); + assert_eq!( + on_the_12th[0].start.naive_local().to_string(), + "2026-01-12 14:00:00" + ); + assert_eq!(found.len(), 4, "four Mondays in January 2026"); +} + +#[test] +fn an_override_moved_to_another_day_lands_on_the_new_day() { + let cal = calendar( + "BEGIN:VEVENT +UID:moved@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260105T090000 +DTEND;TZID=America/Denver:20260105T093000 +SUMMARY:Weekly +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3 +END:VEVENT +BEGIN:VEVENT +UID:moved@test +RECURRENCE-ID;TZID=America/Denver:20260112T090000 +DTSTAMP:20260106T000000Z +DTSTART;TZID=America/Denver:20260114T110000 +DTEND;TZID=America/Denver:20260114T113000 +SUMMARY:Weekly (rescheduled) +END:VEVENT +", + ); + + assert_eq!( + local_starts( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver() + ), + vec![ + "2026-01-05 09:00:00", + "2026-01-14 11:00:00", + "2026-01-19 09:00:00", + ], + "RECURRENCE-ID says which occurrence is replaced; DTSTART says when the \ + replacement happens. Nothing should appear on the 12th", + ); +} + +#[test] +fn an_override_carries_the_recurrence_id_needed_to_edit_it_again() { + let cal = calendar( + "BEGIN:VEVENT +UID:edit@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260105T090000 +DTEND;TZID=America/Denver:20260105T093000 +SUMMARY:Weekly +RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=2 +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver(), + ); + + assert_eq!( + found[1].recurrence_id, + Some(CalendarDateTime::Zoned { + local: chrono::NaiveDate::from_ymd_opt(2026, 1, 12) + .unwrap() + .and_hms_opt(9, 0, 0) + .unwrap(), + tzid: TzId::new("America/Denver").unwrap(), + }), + "a generated occurrence knows its own RECURRENCE-ID, so editing it \ + sends back the value the server will write. The last iteration \ + encoded this as \"{{uid}}-{{timestamp}}\" and split the string apart \ + again at the other end", + ); +} + +#[test] +fn an_override_without_a_master_is_still_an_occurrence() { + let cal = calendar( + "BEGIN:VEVENT +UID:orphan@test +RECURRENCE-ID;TZID=America/Denver:20260112T090000 +DTSTAMP:20260106T000000Z +DTSTART;TZID=America/Denver:20260112T140000 +DTEND;TZID=America/Denver:20260112T143000 +SUMMARY:Orphaned exception +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + denver(), + ); + + assert_eq!( + found.len(), + 1, + "a published feed truncates series at its edge" + ); + assert!(found[0].is_override); +} + +// ------------------------------------------------------------------- windows -- + +#[test] +fn an_event_straddling_the_window_edge_is_included() { + let cal = calendar( + "BEGIN:VEVENT +UID:long@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260104T220000 +DTEND;TZID=America/Denver:20260105T020000 +SUMMARY:Across midnight +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-01-05T00:00:00Z", "2026-01-06T00:00:00Z"), + denver(), + ); + assert_eq!( + found.len(), + 1, + "it starts before the window but runs into it" + ); +} + +#[test] +fn an_event_wholly_outside_the_window_is_excluded() { + let cal = denver_series("20260105", "FREQ=WEEKLY;BYDAY=MO;COUNT=2", ""); + + assert!( + occurrences( + &cal, + window("2026-06-01T00:00:00Z", "2026-07-01T00:00:00Z"), + denver() + ) + .is_empty() + ); +} + +#[test] +fn results_are_ordered_and_deterministic() { + let cal = calendar( + "BEGIN:VEVENT +UID:b@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260105T140000 +DTEND;TZID=America/Denver:20260105T150000 +SUMMARY:Afternoon +END:VEVENT +BEGIN:VEVENT +UID:a@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=America/Denver:20260105T090000 +DTEND;TZID=America/Denver:20260105T100000 +SUMMARY:Morning +END:VEVENT +", + ); + let span = window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"); + + let first = occurrences(&cal, span, denver()); + assert_eq!( + first.iter().map(Occurrence::uid).collect::>(), + vec!["a@test", "b@test"], + ); + assert_eq!(first, occurrences(&cal, span, denver())); +} + +// ----------------------------------------------------------------- timezones -- + +#[test] +fn a_windows_zone_name_resolves_through_cldr() { + let zones = Zones::new(denver()); + + for (windows, iana) in [ + ("Pacific Standard Time", Tz::America__Los_Angeles), + ("Eastern Standard Time", Tz::America__New_York), + ("Mountain Standard Time", Tz::America__Denver), + ("GTB Standard Time", Tz::Europe__Bucharest), + ] { + let resolved = zones.resolve(&TzId::new(windows).unwrap()); + assert_eq!(resolved.tz, iana, "{windows}"); + assert_eq!(resolved.source, ZoneSource::WindowsName); + } +} + +#[test] +fn an_iana_identifier_is_preferred_and_reported_as_such() { + let resolved = Zones::new(denver()).resolve(&TzId::new("Europe/Zurich").unwrap()); + + assert_eq!(resolved.tz, Tz::Europe__Zurich); + assert_eq!(resolved.source, ZoneSource::Iana); +} + +#[test] +fn an_unresolvable_zone_is_reported_rather_than_hidden() { + let resolved = Zones::new(denver()).resolve(&TzId::new("Customized Time Zone 3").unwrap()); + + assert_eq!(resolved.source, ZoneSource::Assumed); + assert_eq!( + resolved.tz, + denver(), + "something has to be assumed, but the caller is told it was assumed \ + rather than being handed a silently wrong time", + ); +} + +#[test] +fn an_event_in_a_windows_zone_resolves_to_the_right_instant() { + let cal = calendar( + "BEGIN:VEVENT +UID:exchange@test +DTSTAMP:20260101T000000Z +DTSTART;TZID=Pacific Standard Time:20260115T090000 +DTEND;TZID=Pacific Standard Time:20260115T093000 +SUMMARY:From Exchange +END:VEVENT +", + ); + + let found = occurrences( + &cal, + window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z"), + denver(), + ); + assert_eq!( + found[0].start_utc.to_rfc3339(), + "2026-01-15T17:00:00+00:00", + "09:00 Los Angeles in January is 17:00 UTC", + ); +} + +#[test] +fn a_floating_time_is_read_in_the_viewers_zone() { + let cal = calendar( + "BEGIN:VEVENT +UID:floating@test +DTSTAMP:20260101T000000Z +DTSTART:20260115T090000 +DTEND:20260115T100000 +SUMMARY:Nine, wherever you are +END:VEVENT +", + ); + let span = window("2026-01-15T00:00:00Z", "2026-01-16T00:00:00Z"); + + assert_eq!( + occurrences(&cal, span, denver())[0].start_utc.to_rfc3339(), + "2026-01-15T16:00:00+00:00", + ); + assert_eq!( + occurrences(&cal, span, Tz::Europe__Zurich)[0] + .start_utc + .to_rfc3339(), + "2026-01-15T08:00:00+00:00", + "the same floating value is a different instant for a different reader, \ + which is exactly what floating means", + ); +} + +// -------------------------------------------------------------------- limits -- + +#[test] +fn a_runaway_rule_is_reported_not_truncated() { + let cal = denver_series("20260101", "FREQ=MINUTELY", ""); + + let result = expand( + &cal, + window("2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"), + Zones::new(denver()), + ); + + assert!( + matches!(result, Err(RecurrenceError::TooManyOccurrences { .. })), + "half a million occurrences is not a calendar view; failing loudly \ + beats returning a list that is quietly missing most of the year", + ); +} + +#[test] +fn an_unusable_rule_names_the_event_and_the_rule() { + let mut cal = denver_series("20260105", "FREQ=WEEKLY", ""); + cal.events[0].rrule = Some("FREQ=NONSENSE".to_owned()); + + match expand( + &cal, + window("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z"), + Zones::new(denver()), + ) { + Err(RecurrenceError::InvalidRule { uid, rrule, .. }) => { + assert_eq!(uid, "series@test"); + assert_eq!(rrule, "FREQ=NONSENSE"); + } + other => panic!("expected a typed error, got {other:?}"), + } +} + +// ------------------------------------------------------- against real data --- + +fn outlook_feed() -> VCalendar { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/golden/outlook/feed-overrides-windows-tz.ics"); + ical::parse(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn the_outlook_feed_expands_without_duplicating_a_single_day() { + let cal = outlook_feed(); + let found = occurrences( + &cal, + window("2025-10-01T00:00:00Z", "2026-01-01T00:00:00Z"), + denver(), + ); + + assert!(!found.is_empty()); + + // Within one series, no two occurrences may claim the same start. This is + // the assertion the old importer's title-matching heuristics were standing + // in for -- and it holds here because overrides suppress the occurrence + // they replace, not because anything was merged away. + let mut seen: Vec<(&str, DateTime)> = + found.iter().map(|o| (o.uid(), o.start_utc)).collect(); + let before = seen.len(); + seen.sort(); + seen.dedup(); + assert_eq!( + before, + seen.len(), + "a series produced two occurrences at one instant" + ); +} + +#[test] +fn every_zone_in_the_outlook_feed_resolves() { + let cal = outlook_feed(); + + assert_eq!( + runway_core::recurrence::unresolved_zones(&cal, Zones::new(denver())), + Vec::::new(), + "the feed names its zones in Windows form and every one of them maps", + ); +} + +#[test] +fn an_exchange_series_honours_its_exclusions() { + let cal = outlook_feed(); + let master = cal + .events + .iter() + .find(|e| !e.exdate.is_empty() && e.rrule.is_some()) + .expect("the feed has a series with exclusions"); + let excluded: Vec> = master + .exdate + .iter() + .map(|d| Zones::new(denver()).instant(d)) + .collect(); + + let found = occurrences( + &cal, + window("2025-10-01T00:00:00Z", "2027-01-01T00:00:00Z"), + denver(), + ); + + for instant in excluded { + assert!( + !found + .iter() + .any(|o| o.uid() == master.uid && o.start_utc == instant && !o.is_override), + "an EXDATE'd occurrence at {instant} was still generated", + ); + } +} + +#[test] +fn expanding_the_whole_feed_stays_cheap() { + // Not a benchmark, a guard: the old client re-expanded every rule in the + // browser on every view change. + let cal = outlook_feed(); + let started = std::time::Instant::now(); + let found = occurrences( + &cal, + window("2025-01-01T00:00:00Z", "2028-01-01T00:00:00Z"), + denver(), + ); + let elapsed = started.elapsed(); + + assert!(!found.is_empty()); + assert!( + elapsed < TimeDelta::seconds(5).to_std().unwrap(), + "three years of a real feed took {elapsed:?}", + ); +}