Story for milestone M1. Read docs/STORY-WORKFLOW.md first — it defines the process, gates, and patterns; this issue defines only the specific work.
crates/nuncio-cal/src/rrule.rs's RecurrenceEngine::expand_occurrences is a real, fully-tested RFC 5545 RRULE expansion engine (handles FREQ/INTERVAL/UNTIL/COUNT, caps output at MAX_EXPANSION_COUNT = 500, and correctly returns an error rather than a fabricated timestamp for an invalid rule) — but nothing in the codebase ever calls it outside its own unit tests. nuncio-store::db::DatabaseEngine stores each calendar event as exactly one row, including its raw rrule string (crates/nuncio-store/src/db.rs:918-942), and list_calendar_events (lines 949-996) filters by WHERE account_id = ? AND start_time <= ? AND end_time >= ? against that ONE stored row's own start/end — so a weekly-recurring event stored with its first occurrence's timestamps is only ever returned for query windows overlapping that first occurrence; every later occurrence is invisible. This story closes that gap: querying a window must materialize the recurring event's actual occurrences that fall inside it.
Goal
When a stored calendar event carries a non-empty rrule, querying an event window must return its real per-occurrence instances that overlap that window (via RecurrenceEngine::expand_occurrences), not just the single raw stored row. A non-recurring event's behavior must be unchanged. This must NOT silently drop or duplicate events: a recurring event whose master row happens to also overlap the window must not appear both as the raw row AND as an expanded occurrence. An unparseable rrule must surface as a genuine, visible error (or safely degrade to the single stored event, per RecurrenceEngine's own existing fallback behavior) — never a fabricated or duplicated event.
Files to touch / patterns to mirror
crates/nuncio-store/src/db.rs — add pub async fn list_recurring_calendar_events(&self, account_id: &str) -> Result<Vec<nuncio_core::model::CalendarEvent>, DatabaseError>, mirroring the shape of list_calendar_events (lines 949-996) but with WHERE account_id = ? AND rrule IS NOT NULL and no time-window filter (recurrence can validly start long before the query window and still produce occurrences inside it). Keep list_calendar_events itself unchanged.
crates/nunciod/src/grpc.rs — this is the layer that already depends on BOTH nuncio-store and nuncio-cal (nuncio-store itself does not and should not gain a dependency on nuncio-cal's RecurrenceEngine — keep expansion out of the storage layer). In CalendarGrpcService::list_events (added by M1-S1), after fetching:
let all_in_window = db.list_calendar_events(&req.account_id, req.start_window, req.end_window).await?;
let recurring = db.list_recurring_calendar_events(&req.account_id).await?;
compute the final event list as: non-recurring rows from all_in_window (e.rrule.is_none()), PLUS every occurrence produced by RecurrenceEngine::expand_occurrences(&event, req.start_window, req.end_window) for each event in recurring — this both fixes the missed-later-occurrences bug AND avoids double-counting a recurring master that also happened to land in all_in_window, since recurring rows are always sourced from recurring/expansion, never from all_in_window. Extract this merge as a small, pure, directly unit-testable helper function (e.g. fn expand_events_for_window(all_in_window: Vec<CalendarEvent>, recurring: Vec<CalendarEvent>, start_window: i64, end_window: i64) -> Vec<CalendarEvent>) so its logic is tested without a database or gRPC server.
RecurrenceEngine::expand_occurrences returns Result<Vec<CalendarEvent>, CalendarError> — an unparseable rrule must not silently vanish the event; either surface it as a per-event error (added to the RPC response is not supported by the current message shape — simplest correct behavior: fall back to including the raw stored master event when expansion errors, and tracing::warn! the parse failure, mirroring how FiltersGrpcService::reload_engine_from_store logs rather than fails the whole RPC for a single bad rule at crates/nunciod/src/grpc.rs:761-780) rather than making the entire ListEvents call fail for one malformed recurring event among many.
crates/nuncio-cal/src/rrule.rs — no change expected; only if you find expand_occurrences's existing behavior (e.g. its exceptions/EXDATE handling — check whether EXDATE is parsed at all today, since the RRULE string alone doesn't carry cancelled instances) needs a genuine fix to correctly support UNTIL/COUNT/exceptions per the Goal, do it here with new unit tests following the existing style (see weekly_recurring_event_expands_multiple_instances, crates/nuncio-cal/src/rrule.rs:102-111).
Tests: extend crates/nuncio-cal/src/rrule.rs's own unit tests if you change engine behavior; add new tests at the nunciod::grpc level (or wherever the merge helper lives) proving: (a) a weekly event's occurrence 4 weeks out is returned when queried for that later window even though the stored master's own timestamps are 4 weeks earlier; (b) a non-recurring event's behavior is unchanged; (c) a recurring event is never double-returned when its master row's own start/end also happens to overlap the query window; (d) an event with UNTIL correctly stops producing occurrences past the bound; (e) an unparseable rrule degrades to the raw event with a warning rather than failing the whole query.
Implementation steps
Confirm CalendarGrpcService::list_events/GetEvent exist (from M1-S1 — see Dependencies below). If M1-S1 has not yet merged, still complete steps 2-3 below (they are independently valuable and testable against DatabaseEngine directly) and note in the PR that RPC-level wiring (step 4) is blocked pending M1-S1.
Add DatabaseEngine::list_recurring_calendar_events; test it directly (ephemeral DB, a mix of recurring/non-recurring rows).
Write the pure expand_events_for_window merge helper (or equivalent) and its direct unit tests using nuncio_cal::RecurrenceEngine + fixture CalendarEvents — no DB or network needed for these.
Wire the helper into CalendarGrpcService::list_events.
Add/extend RecurrenceEngine tests only if a genuine correctness gap (e.g. EXDATE/exception handling) is found; do not touch it otherwise.
Run cargo verify.
Acceptance criteria
DatabaseEngine::list_recurring_calendar_events added and tested.
Querying a window returns the real expanded occurrences of a recurring event that overlap it, including occurrences whose timestamps differ from the stored master row's own start_time/end_time.
A recurring event is never duplicated (raw row + expanded occurrence) in the same query result.
Non-recurring events are unaffected (byte-for-byte identical behavior to before this story).
UNTIL/COUNT-bounded recurrence correctly stops producing occurrences past the bound, proven by a test over a queried window that spans past the bound.
An unparseable rrule never crashes or fails the whole query and never fabricates a bogus instance; document and test the exact degrade-and-warn behavior chosen.
Follow docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in .rs/.proto); mock-only tests. Branch story/m1-rrule-recurrence off dev; PR to dev with the gate output pasted as evidence; the PO reviews before the issue is closed.
Dependencies / notes
Depends on M1-S1 for the RPC-level wiring step (CalendarGrpcService::list_events must exist to wire the merge helper into it). The store-layer method (list_recurring_calendar_events) and the pure merge helper's unit tests do not depend on M1-S1 and can be built/reviewed independently if the two stories overlap in time — just coordinate the final RPC-wiring commit with whichever of the two PRs lands second.
Not a [PROTO] story — no proto changes.
Size: M.
Added scope (deferred from the M1-S1 review of #185)
Since this story reworks CalendarGrpcService::list_events, also apply the calendar_id filter that #185 left as a documented no-op: today ListEvents accepts req.calendar_id but returns all of the account's in-window events regardless. Filter it — cheapest is a post-query events.retain(|e| req.calendar_id.is_empty() || e.calendar_id == req.calendar_id) (or a store-level filter if you're already touching list_calendar_events). Add a test asserting an event in a different calendar_id is excluded. This closes the honesty foot-gun (CLI --calendar defaults to "default", and the mock backend already filters by calendar_id, so mock-sync and store-read currently disagree).
## Context
Story for milestone M1. **Read [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md) first** — it defines the process, gates, and patterns; this issue defines only the specific work.
`crates/nuncio-cal/src/rrule.rs`'s `RecurrenceEngine::expand_occurrences` is a real, fully-tested RFC 5545 RRULE expansion engine (handles `FREQ`/`INTERVAL`/`UNTIL`/`COUNT`, caps output at `MAX_EXPANSION_COUNT` = 500, and correctly returns an error rather than a fabricated timestamp for an invalid rule) — but nothing in the codebase ever calls it outside its own unit tests. `nuncio-store::db::DatabaseEngine` stores each calendar event as exactly one row, including its raw `rrule` string (`crates/nuncio-store/src/db.rs:918-942`), and `list_calendar_events` (lines 949-996) filters by `WHERE account_id = ? AND start_time <= ? AND end_time >= ?` against that ONE stored row's own start/end — so a weekly-recurring event stored with its first occurrence's timestamps is only ever returned for query windows overlapping that first occurrence; every later occurrence is invisible. This story closes that gap: querying a window must materialize the recurring event's actual occurrences that fall inside it.
## Goal
When a stored calendar event carries a non-empty `rrule`, querying an event window must return its real per-occurrence instances that overlap that window (via `RecurrenceEngine::expand_occurrences`), not just the single raw stored row. A non-recurring event's behavior must be unchanged. This must NOT silently drop or duplicate events: a recurring event whose master row happens to also overlap the window must not appear both as the raw row AND as an expanded occurrence. An unparseable `rrule` must surface as a genuine, visible error (or safely degrade to the single stored event, per `RecurrenceEngine`'s own existing fallback behavior) — never a fabricated or duplicated event.
## Files to touch / patterns to mirror
- `crates/nuncio-store/src/db.rs` — add `pub async fn list_recurring_calendar_events(&self, account_id: &str) -> Result<Vec<nuncio_core::model::CalendarEvent>, DatabaseError>`, mirroring the shape of `list_calendar_events` (lines 949-996) but with `WHERE account_id = ? AND rrule IS NOT NULL` and **no** time-window filter (recurrence can validly start long before the query window and still produce occurrences inside it). Keep `list_calendar_events` itself unchanged.
- `crates/nunciod/src/grpc.rs` — this is the layer that already depends on BOTH `nuncio-store` and `nuncio-cal` (`nuncio-store` itself does not and should not gain a dependency on `nuncio-cal`'s `RecurrenceEngine` — keep expansion out of the storage layer). In `CalendarGrpcService::list_events` (added by **M1-S1**), after fetching:
- `let all_in_window = db.list_calendar_events(&req.account_id, req.start_window, req.end_window).await?;`
- `let recurring = db.list_recurring_calendar_events(&req.account_id).await?;`
compute the final event list as: non-recurring rows from `all_in_window` (`e.rrule.is_none()`), PLUS every occurrence produced by `RecurrenceEngine::expand_occurrences(&event, req.start_window, req.end_window)` for each event in `recurring` — this both fixes the missed-later-occurrences bug AND avoids double-counting a recurring master that also happened to land in `all_in_window`, since recurring rows are always sourced from `recurring`/expansion, never from `all_in_window`. Extract this merge as a small, pure, directly unit-testable helper function (e.g. `fn expand_events_for_window(all_in_window: Vec<CalendarEvent>, recurring: Vec<CalendarEvent>, start_window: i64, end_window: i64) -> Vec<CalendarEvent>`) so its logic is tested without a database or gRPC server.
- `RecurrenceEngine::expand_occurrences` returns `Result<Vec<CalendarEvent>, CalendarError>` — an unparseable `rrule` must not silently vanish the event; either surface it as a per-event error (added to the RPC response is not supported by the current message shape — simplest correct behavior: fall back to including the raw stored master event when expansion errors, and `tracing::warn!` the parse failure, mirroring how `FiltersGrpcService::reload_engine_from_store` logs rather than fails the whole RPC for a single bad rule at `crates/nunciod/src/grpc.rs:761-780`) rather than making the entire `ListEvents` call fail for one malformed recurring event among many.
- `crates/nuncio-cal/src/rrule.rs` — no change expected; only if you find `expand_occurrences`'s existing behavior (e.g. its exceptions/`EXDATE` handling — check whether `EXDATE` is parsed at all today, since the RRULE string alone doesn't carry cancelled instances) needs a genuine fix to correctly support `UNTIL`/`COUNT`/exceptions per the Goal, do it here with new unit tests following the existing style (see `weekly_recurring_event_expands_multiple_instances`, `crates/nuncio-cal/src/rrule.rs:102-111`).
- Tests: extend `crates/nuncio-cal/src/rrule.rs`'s own unit tests if you change engine behavior; add new tests at the `nunciod::grpc` level (or wherever the merge helper lives) proving: (a) a weekly event's occurrence 4 weeks out is returned when queried for that later window even though the stored master's own timestamps are 4 weeks earlier; (b) a non-recurring event's behavior is unchanged; (c) a recurring event is never double-returned when its master row's own start/end also happens to overlap the query window; (d) an event with `UNTIL` correctly stops producing occurrences past the bound; (e) an unparseable `rrule` degrades to the raw event with a warning rather than failing the whole query.
## Implementation steps
1. Confirm `CalendarGrpcService::list_events`/`GetEvent` exist (from **M1-S1** — see Dependencies below). If M1-S1 has not yet merged, still complete steps 2-3 below (they are independently valuable and testable against `DatabaseEngine` directly) and note in the PR that RPC-level wiring (step 4) is blocked pending M1-S1.
2. Add `DatabaseEngine::list_recurring_calendar_events`; test it directly (ephemeral DB, a mix of recurring/non-recurring rows).
3. Write the pure `expand_events_for_window` merge helper (or equivalent) and its direct unit tests using `nuncio_cal::RecurrenceEngine` + fixture `CalendarEvent`s — no DB or network needed for these.
4. Wire the helper into `CalendarGrpcService::list_events`.
5. Add/extend `RecurrenceEngine` tests only if a genuine correctness gap (e.g. `EXDATE`/exception handling) is found; do not touch it otherwise.
6. Run `cargo verify`.
## Acceptance criteria
- [ ] `DatabaseEngine::list_recurring_calendar_events` added and tested.
- [ ] Querying a window returns the real expanded occurrences of a recurring event that overlap it, including occurrences whose timestamps differ from the stored master row's own `start_time`/`end_time`.
- [ ] A recurring event is never duplicated (raw row + expanded occurrence) in the same query result.
- [ ] Non-recurring events are unaffected (byte-for-byte identical behavior to before this story).
- [ ] `UNTIL`/`COUNT`-bounded recurrence correctly stops producing occurrences past the bound, proven by a test over a queried window that spans past the bound.
- [ ] An unparseable `rrule` never crashes or fails the whole query and never fabricates a bogus instance; document and test the exact degrade-and-warn behavior chosen.
- [ ] `cargo verify` green (fmt + clippy `-D warnings` all-targets + test-all), 0 ignored tests.
## Definition of done & process
Follow [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md): no fabricated data; comment convention (intent-only, no story/GH/phase refs in `.rs`/`.proto`); mock-only tests. Branch `story/m1-rrule-recurrence` off `dev`; PR to `dev` with the gate output pasted as evidence; the PO reviews before the issue is closed.
## Dependencies / notes
- **Depends on M1-S1** for the RPC-level wiring step (`CalendarGrpcService::list_events` must exist to wire the merge helper into it). The store-layer method (`list_recurring_calendar_events`) and the pure merge helper's unit tests do not depend on M1-S1 and can be built/reviewed independently if the two stories overlap in time — just coordinate the final RPC-wiring commit with whichever of the two PRs lands second.
- Not a `[PROTO]` story — no proto changes.
- Size: M.
---
## Added scope (deferred from the M1-S1 review of #185)
Since this story reworks `CalendarGrpcService::list_events`, also apply the `calendar_id` filter that #185 left as a documented no-op: today `ListEvents` accepts `req.calendar_id` but returns all of the account's in-window events regardless. Filter it — cheapest is a post-query `events.retain(|e| req.calendar_id.is_empty() || e.calendar_id == req.calendar_id)` (or a store-level filter if you're already touching `list_calendar_events`). Add a test asserting an event in a different `calendar_id` is excluded. This closes the honesty foot-gun (CLI `--calendar` defaults to `"default"`, and the mock backend already filters by calendar_id, so mock-sync and store-read currently disagree).
Delivered via PR #215 (merged to dev). Adversarial review PASS — no double-count, windowing correct (removed a real fabrication bug in expand_occurrences), termination bounded, calendar_id filter applied. F1 (stale comment) fixed pre-merge; F2/F3 (low windowing/predicate edges) tracked as a follow-up. M1 (Finish Calendar) complete.
Delivered via PR #215 (merged to dev). Adversarial review PASS — no double-count, windowing correct (removed a real fabrication bug in expand_occurrences), termination bounded, calendar_id filter applied. F1 (stale comment) fixed pre-merge; F2/F3 (low windowing/predicate edges) tracked as a follow-up. M1 (Finish Calendar) complete.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Context
Story for milestone M1. Read
docs/STORY-WORKFLOW.mdfirst — it defines the process, gates, and patterns; this issue defines only the specific work.crates/nuncio-cal/src/rrule.rs'sRecurrenceEngine::expand_occurrencesis a real, fully-tested RFC 5545 RRULE expansion engine (handlesFREQ/INTERVAL/UNTIL/COUNT, caps output atMAX_EXPANSION_COUNT= 500, and correctly returns an error rather than a fabricated timestamp for an invalid rule) — but nothing in the codebase ever calls it outside its own unit tests.nuncio-store::db::DatabaseEnginestores each calendar event as exactly one row, including its rawrrulestring (crates/nuncio-store/src/db.rs:918-942), andlist_calendar_events(lines 949-996) filters byWHERE account_id = ? AND start_time <= ? AND end_time >= ?against that ONE stored row's own start/end — so a weekly-recurring event stored with its first occurrence's timestamps is only ever returned for query windows overlapping that first occurrence; every later occurrence is invisible. This story closes that gap: querying a window must materialize the recurring event's actual occurrences that fall inside it.Goal
When a stored calendar event carries a non-empty
rrule, querying an event window must return its real per-occurrence instances that overlap that window (viaRecurrenceEngine::expand_occurrences), not just the single raw stored row. A non-recurring event's behavior must be unchanged. This must NOT silently drop or duplicate events: a recurring event whose master row happens to also overlap the window must not appear both as the raw row AND as an expanded occurrence. An unparseablerrulemust surface as a genuine, visible error (or safely degrade to the single stored event, perRecurrenceEngine's own existing fallback behavior) — never a fabricated or duplicated event.Files to touch / patterns to mirror
crates/nuncio-store/src/db.rs— addpub async fn list_recurring_calendar_events(&self, account_id: &str) -> Result<Vec<nuncio_core::model::CalendarEvent>, DatabaseError>, mirroring the shape oflist_calendar_events(lines 949-996) but withWHERE account_id = ? AND rrule IS NOT NULLand no time-window filter (recurrence can validly start long before the query window and still produce occurrences inside it). Keeplist_calendar_eventsitself unchanged.crates/nunciod/src/grpc.rs— this is the layer that already depends on BOTHnuncio-storeandnuncio-cal(nuncio-storeitself does not and should not gain a dependency onnuncio-cal'sRecurrenceEngine— keep expansion out of the storage layer). InCalendarGrpcService::list_events(added by M1-S1), after fetching:let all_in_window = db.list_calendar_events(&req.account_id, req.start_window, req.end_window).await?;let recurring = db.list_recurring_calendar_events(&req.account_id).await?;compute the final event list as: non-recurring rows from
all_in_window(e.rrule.is_none()), PLUS every occurrence produced byRecurrenceEngine::expand_occurrences(&event, req.start_window, req.end_window)for each event inrecurring— this both fixes the missed-later-occurrences bug AND avoids double-counting a recurring master that also happened to land inall_in_window, since recurring rows are always sourced fromrecurring/expansion, never fromall_in_window. Extract this merge as a small, pure, directly unit-testable helper function (e.g.fn expand_events_for_window(all_in_window: Vec<CalendarEvent>, recurring: Vec<CalendarEvent>, start_window: i64, end_window: i64) -> Vec<CalendarEvent>) so its logic is tested without a database or gRPC server.RecurrenceEngine::expand_occurrencesreturnsResult<Vec<CalendarEvent>, CalendarError>— an unparseablerrulemust not silently vanish the event; either surface it as a per-event error (added to the RPC response is not supported by the current message shape — simplest correct behavior: fall back to including the raw stored master event when expansion errors, andtracing::warn!the parse failure, mirroring howFiltersGrpcService::reload_engine_from_storelogs rather than fails the whole RPC for a single bad rule atcrates/nunciod/src/grpc.rs:761-780) rather than making the entireListEventscall fail for one malformed recurring event among many.crates/nuncio-cal/src/rrule.rs— no change expected; only if you findexpand_occurrences's existing behavior (e.g. its exceptions/EXDATEhandling — check whetherEXDATEis parsed at all today, since the RRULE string alone doesn't carry cancelled instances) needs a genuine fix to correctly supportUNTIL/COUNT/exceptions per the Goal, do it here with new unit tests following the existing style (seeweekly_recurring_event_expands_multiple_instances,crates/nuncio-cal/src/rrule.rs:102-111).crates/nuncio-cal/src/rrule.rs's own unit tests if you change engine behavior; add new tests at thenunciod::grpclevel (or wherever the merge helper lives) proving: (a) a weekly event's occurrence 4 weeks out is returned when queried for that later window even though the stored master's own timestamps are 4 weeks earlier; (b) a non-recurring event's behavior is unchanged; (c) a recurring event is never double-returned when its master row's own start/end also happens to overlap the query window; (d) an event withUNTILcorrectly stops producing occurrences past the bound; (e) an unparseablerruledegrades to the raw event with a warning rather than failing the whole query.Implementation steps
CalendarGrpcService::list_events/GetEventexist (from M1-S1 — see Dependencies below). If M1-S1 has not yet merged, still complete steps 2-3 below (they are independently valuable and testable againstDatabaseEnginedirectly) and note in the PR that RPC-level wiring (step 4) is blocked pending M1-S1.DatabaseEngine::list_recurring_calendar_events; test it directly (ephemeral DB, a mix of recurring/non-recurring rows).expand_events_for_windowmerge helper (or equivalent) and its direct unit tests usingnuncio_cal::RecurrenceEngine+ fixtureCalendarEvents — no DB or network needed for these.CalendarGrpcService::list_events.RecurrenceEnginetests only if a genuine correctness gap (e.g.EXDATE/exception handling) is found; do not touch it otherwise.cargo verify.Acceptance criteria
DatabaseEngine::list_recurring_calendar_eventsadded and tested.start_time/end_time.UNTIL/COUNT-bounded recurrence correctly stops producing occurrences past the bound, proven by a test over a queried window that spans past the bound.rrulenever crashes or fails the whole query and never fabricates a bogus instance; document and test the exact degrade-and-warn behavior chosen.cargo verifygreen (fmt + clippy-D warningsall-targets + test-all), 0 ignored tests.Definition of done & process
Follow
docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in.rs/.proto); mock-only tests. Branchstory/m1-rrule-recurrenceoffdev; PR todevwith the gate output pasted as evidence; the PO reviews before the issue is closed.Dependencies / notes
CalendarGrpcService::list_eventsmust exist to wire the merge helper into it). The store-layer method (list_recurring_calendar_events) and the pure merge helper's unit tests do not depend on M1-S1 and can be built/reviewed independently if the two stories overlap in time — just coordinate the final RPC-wiring commit with whichever of the two PRs lands second.[PROTO]story — no proto changes.Added scope (deferred from the M1-S1 review of #185)
Since this story reworks
CalendarGrpcService::list_events, also apply thecalendar_idfilter that #185 left as a documented no-op: todayListEventsacceptsreq.calendar_idbut returns all of the account's in-window events regardless. Filter it — cheapest is a post-queryevents.retain(|e| req.calendar_id.is_empty() || e.calendar_id == req.calendar_id)(or a store-level filter if you're already touchinglist_calendar_events). Add a test asserting an event in a differentcalendar_idis excluded. This closes the honesty foot-gun (CLI--calendardefaults to"default", and the mock backend already filters by calendar_id, so mock-sync and store-read currently disagree).Delivered via PR #215 (merged to dev). Adversarial review PASS — no double-count, windowing correct (removed a real fabrication bug in expand_occurrences), termination bounded, calendar_id filter applied. F1 (stale comment) fixed pre-merge; F2/F3 (low windowing/predicate edges) tracked as a follow-up. M1 (Finish Calendar) complete.