Calendar: expand recurring CalDAV events and feed them into the range/year API #69

Closed
opened 2026-09-24 18:19:24 +00:00 by kayg · 9 comments
Owner

Follow-up to #40 (CalDAV client, merged in 41aa774) and #56 (calendar range/year API, now in the same crate crates/plugins/calendar, module view.rs).

The range endpoint GET /api/v1/calendar/range has a typed hook, view::ExternalEventProvider / install_external_event_provider, but nothing installs it. Connected CalDAV events therefore never appear in days[].events. The cache (cache/store.rs) also stores only master VEVENTs: RRULE/RDATE/EXDATE/RECURRENCE-ID overrides are not expanded, and GET /api/v1/calendar/events has no range filter.

Build:

  • Recurrence expansion for a date window, using calcard's expansion if it has one (check); otherwise implement RRULE/RDATE/EXDATE plus RECURRENCE-ID overrides with a hard cap on instances per series and per window. Each instance id is <event id>@<instance start>; all-day events never shift across zones.
  • Indexed range query: store each master's first start and last end (open-ended when unbounded) so a range read only touches overlapping series. Budget: week range < 150 ms warm with 5k cached events.
  • Install the provider from the plugin's router construction so range/year include the viewer's visible calendars only (per-calendar visibility and enabled accounts). Each Event is grouped by local day in the requested zone. Multi-day events appear on each day they cover.
  • Add from/to query parameters to GET /api/v1/calendar/events, returning expanded instances.
  • Tests: weekly COUNT, UNTIL, EXDATE, a moved override, DST in Europe/Berlin, an all-day multi-day event, hidden calendar excluded, other user's events never leaked. Extend tests/adversarial/attack.py section 11 so the provider event shows up in range.
Follow-up to #40 (CalDAV client, merged in 41aa774) and #56 (calendar range/year API, now in the same crate `crates/plugins/calendar`, module `view.rs`). The range endpoint `GET /api/v1/calendar/range` has a typed hook, `view::ExternalEventProvider` / `install_external_event_provider`, but nothing installs it. Connected CalDAV events therefore never appear in `days[].events`. The cache (`cache/store.rs`) also stores only master VEVENTs: `RRULE`/`RDATE`/`EXDATE`/`RECURRENCE-ID` overrides are not expanded, and `GET /api/v1/calendar/events` has no range filter. Build: - **Recurrence expansion** for a date window, using `calcard`'s expansion if it has one (check); otherwise implement RRULE/RDATE/EXDATE plus RECURRENCE-ID overrides with a hard cap on instances per series and per window. Each instance id is `<event id>@<instance start>`; all-day events never shift across zones. - **Indexed range query**: store each master's first start and last end (open-ended when unbounded) so a range read only touches overlapping series. Budget: week range < 150 ms warm with 5k cached events. - **Install the provider** from the plugin's router construction so `range`/`year` include the viewer's visible calendars only (per-calendar visibility and enabled accounts). Each Event is grouped by local day in the requested zone. Multi-day events appear on each day they cover. - Add `from`/`to` query parameters to `GET /api/v1/calendar/events`, returning expanded instances. - Tests: weekly COUNT, UNTIL, EXDATE, a moved override, DST in Europe/Berlin, an all-day multi-day event, hidden calendar excluded, other user's events never leaked. Extend `tests/adversarial/attack.py` section 11 so the provider event shows up in range.
Author
Owner

Starting issue #69 in branch job/events-range at base 41aa77499e0a090371ff252458722fd80b4e6a44. I have read the issue spec and am loading the binding calendar design and vocabulary before inspecting the plugin implementation.

Starting issue #69 in branch `job/events-range` at base `41aa77499e0a090371ff252458722fd80b4e6a44`. I have read the issue spec and am loading the binding calendar design and vocabulary before inspecting the plugin implementation.
Author
Owner

Finding: the locked calcard 0.3.14 crate contains ICalendar::expand_dates, which expands RRULEs, RDATEs, EXDATEs, and RECURRENCE-ID overrides. Its iterator is capped by a count from DTSTART and does not accept a date window. I am using that implementation for expansion and adding an indexed conservative series envelope plus bounded post-filtering for the requested window.

Finding: the locked `calcard 0.3.14` crate contains `ICalendar::expand_dates`, which expands RRULEs, RDATEs, EXDATEs, and RECURRENCE-ID overrides. Its iterator is capped by a count from DTSTART and does not accept a date window. I am using that implementation for expansion and adding an indexed conservative series envelope plus bounded post-filtering for the requested window.
Author
Owner

Finding: the cached range index needs open bounds for RDATEs and moved RECURRENCE-ID overrides. Those dates can fall outside DTSTART/RRULE, so a finite master envelope can otherwise exclude a valid instance before expansion. A large COUNT with a BY* selector can also span many more rule periods than COUNT * INTERVAL; its coarse end estimate was unsafe. The new regression tests cover a 2030 RDATE and moved override plus a 20,000-occurrence Monday-only daily rule. Unknown bounds now remain candidates, and the tests pass.

Finding: the cached range index needs open bounds for RDATEs and moved RECURRENCE-ID overrides. Those dates can fall outside DTSTART/RRULE, so a finite master envelope can otherwise exclude a valid instance before expansion. A large COUNT with a BY* selector can also span many more rule periods than COUNT * INTERVAL; its coarse end estimate was unsafe. The new regression tests cover a 2030 RDATE and moved override plus a 20,000-occurrence Monday-only daily rule. Unknown bounds now remain candidates, and the tests pass.
Author
Owner

Finding: calcard 0.3.14 compares a UTC UNTIL value with the floating DTSTART wall time. A weekly DTSTART;TZID=Europe/Berlin:20261018T090000 with UNTIL=20261101T080000Z should include the 09:00 local occurrence on Nov 1, but the unadjusted iterator can drop it at the DST boundary. The expansion normalizes UTC UNTIL to the DTSTART zone before iterating; the Berlin DST regression passes. The same iterator counts its hard limit from DTSTART, so an unbounded daily Event from 1900 exhausted the limit before a 2026 window. Uncounted rules now rebase near the requested window; its regression passes.

Finding: `calcard 0.3.14` compares a UTC UNTIL value with the floating DTSTART wall time. A weekly `DTSTART;TZID=Europe/Berlin:20261018T090000` with `UNTIL=20261101T080000Z` should include the 09:00 local occurrence on Nov 1, but the unadjusted iterator can drop it at the DST boundary. The expansion normalizes UTC UNTIL to the DTSTART zone before iterating; the Berlin DST regression passes. The same iterator counts its hard limit from DTSTART, so an unbounded daily Event from 1900 exhausted the limit before a 2026 window. Uncounted rules now rebase near the requested window; its regression passes.
Author
Owner

Finding: a counted daily rule from 1900 with COUNT=50,000 had valid 2026 instances but Calcard's 10,000-instance limit stopped before the requested window (regression output before the fix: expected 2 instances, got 0). Simple counted rules now rebase near the window and subtract skipped intervals from COUNT. The 2026 regression passes. Rules with BY* filters keep DTSTART because their skipped occurrence count needs a separate exact calculation.

Finding: a counted daily rule from 1900 with COUNT=50,000 had valid 2026 instances but Calcard's 10,000-instance limit stopped before the requested window (regression output before the fix: expected 2 instances, got 0). Simple counted rules now rebase near the window and subtract skipped intervals from COUNT. The 2026 regression passes. Rules with BY* filters keep DTSTART because their skipped occurrence count needs a separate exact calculation.
Author
Owner

Finished issue #69 on branch job/events-range.
Head: d5c08e1893a07013aff55ef9c34894be2a66419c.

Gates:

  • cargo fmt --check: no output; exit 0.
  • cargo clippy --workspace --all-targets -- -D warnings:
        Checking calternal-plugin-calendar v0.0.1 (/home/kayg/Developer/calternal-wt/events-range/crates/plugins/calendar)
        Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/events-range/crates/calternal-server)
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.68s
    
  • cargo test --workspace: exit 0. Calendar package output:
    test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s
    test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
    test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s
    
  • bash packages/api-client/check-generated.sh:
        Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.23s
         Running `target/debug/calternal-server openapi`
    $ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts
    ✨ openapi-typescript 7.13.0
    🚀 ../../contracts/openapi.json → src/generated.ts [551.3ms]
    
  • bash tests/adversarial/run.sh:
    ==== FINDINGS 0
    ==== ROUND 2 FINDINGS 0
    
  • cargo clean:
         Removed 16363 files, 9.7GiB total
    

Decisions not stated in the design: ranged Events use UTC when tz is omitted and accept up to 366 dates. The range index uses open bounds for unbounded or uncertain recurrence data. Expansion is capped at 10,000 instances per series, 5,000 visible resources and 5,000 list instances; Calendar day projection is capped at 20,000 placements.

Known gaps: old counted rules with BY* filters cannot be safely rebased without counting their exact prior occurrences, so Calcard may hit the per-series cap before a distant requested window. The 150 ms warm-range budget for 5,000 cached Events was not benchmarked. The list endpoint reports truncation; range/year responses do not expose a truncation field.

Finished issue #69 on branch `job/events-range`. Head: `d5c08e1893a07013aff55ef9c34894be2a66419c`. Gates: - `cargo fmt --check`: no output; exit 0. - `cargo clippy --workspace --all-targets -- -D warnings`: ``` Checking calternal-plugin-calendar v0.0.1 (/home/kayg/Developer/calternal-wt/events-range/crates/plugins/calendar) Compiling calternal-server v0.0.1 (/home/kayg/Developer/calternal-wt/events-range/crates/calternal-server) Finished `dev` profile [unoptimized + debuginfo] target(s) in 10.68s ``` - `cargo test --workspace`: exit 0. Calendar package output: ``` test result: ok. 27 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s ``` - `bash packages/api-client/check-generated.sh`: ``` Finished `dev` profile [unoptimized + debuginfo] target(s) in 23.23s Running `target/debug/calternal-server openapi` $ bunx --package openapi-typescript@7.13.0 openapi-typescript ../../contracts/openapi.json -o src/generated.ts ✨ openapi-typescript 7.13.0 🚀 ../../contracts/openapi.json → src/generated.ts [551.3ms] ``` - `bash tests/adversarial/run.sh`: ``` ==== FINDINGS 0 ==== ROUND 2 FINDINGS 0 ``` - `cargo clean`: ``` Removed 16363 files, 9.7GiB total ``` Decisions not stated in the design: ranged Events use UTC when `tz` is omitted and accept up to 366 dates. The range index uses open bounds for unbounded or uncertain recurrence data. Expansion is capped at 10,000 instances per series, 5,000 visible resources and 5,000 list instances; Calendar day projection is capped at 20,000 placements. Known gaps: old counted rules with BY* filters cannot be safely rebased without counting their exact prior occurrences, so Calcard may hit the per-series cap before a distant requested window. The 150 ms warm-range budget for 5,000 cached Events was not benchmarked. The list endpoint reports truncation; range/year responses do not expose a truncation field.
Author
Owner

Hygiene review: the recurrence report still lists old counted BY* rules that can hit the expansion cap, no range/year truncation field, and an unmeasured 150 ms budget. Keeping #69 open.

Hygiene review: the recurrence report still lists old counted BY* rules that can hit the expansion cap, no range/year truncation field, and an unmeasured 150 ms budget. Keeping #69 open.
Author
Owner

Already implemented on origin/dev. git log origin/dev --grep='(#69)' shows d238579e6, which merges recurring CalDAV Events into the range and year API. Current crates/plugins/calendar/src/recurrence.rs handles RRULE, RDATE, EXDATE and RECURRENCE-ID overrides, with tests for DST, moved instances and bounded expansion; the provider is installed in the Calendar view. Recommend recording the merged implementation here. Do not close the issue in this audit.

Already implemented on origin/dev. git log origin/dev --grep='(#69)' shows d238579e6, which merges recurring CalDAV Events into the range and year API. Current crates/plugins/calendar/src/recurrence.rs handles RRULE, RDATE, EXDATE and RECURRENCE-ID overrides, with tests for DST, moved instances and bounded expansion; the provider is installed in the Calendar view. Recommend recording the merged implementation here. Do not close the issue in this audit.
Author
Owner

Fixed in d238579e6 (origin/dev); Calendar recurrence expansion and range projection are covered by crates/plugins/calendar/src/recurrence.rs tests and tests/adversarial/attack.py.

Fixed in d238579e6 (origin/dev); Calendar recurrence expansion and range projection are covered by `crates/plugins/calendar/src/recurrence.rs` tests and `tests/adversarial/attack.py`.
kayg closed this issue 2026-10-03 11:55:17 +00:00
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal#69
No description provided.