Calendar recurrence: RFC5545 RRULE expansion with timezone/DST handling
| Tier | content |
| Role | unclassified (baselined) |
| Path | crates/content/calendar-recurrence |
| Edition | 2021 |
| Targets | content_calendar_recurrence, integration |
| Public items | 11 across 2 modules |
| Tests | 28 |
What it is for
# content-calendar-recurrence
Calendar recurrence library crate: RFC5545 RRULE expansion with proper timezone/DST handling.
Architecture
This crate handles recurring events WITHOUT materializing instances:
- Series: A template encounter + RRULE + timezone
- Exceptions: Skip or override specific instances
- Expansion: Compute instances on-demand via
expand_occurrences()
Key Design Decisions
1. Timezone + Local Timestamp: Store dtstart_local (TIMESTAMP without TZ) plus an IANA timezone name (e.g., America/Chicago). This correctly handles DST: "Every Tuesday at 7pm" stays at 7pm local time even when clocks change.
2. No Materialized Instances: We don't pre-generate occurrence rows. Instead, expand_occurrences() computes them on-demand for a given date range.
3. Exceptions Table: Skip or override specific instances without breaking the series pattern.
4. Real FKs: Series references an encounter as template. No polymorphic linking.
Example
use content_calendar_recurrence::{CalendarSeries, Occurrence, expand_occurrences};
use chrono::{NaiveDateTime, Utc};
// Create a weekly series
let series = CalendarSeries::new(
encounter_template_id,
"FREQ=WEEKLY;BYDAY=TU,TH",
"America/Chicago",
NaiveDateTime::parse_from_str("2026-01-20 19:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
);
// Expand for January-March 2026
let from = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
let to = Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap();
let occurrences = expand_occurrences(&series, &, from, to)?;
Capabilities
Occurrence
RRULE expansion with proper timezone/DST handling.
| Item |
|---|
pub struct Occurrence |
fn expand_occurrences(series : & CalendarSeries, exceptions : & SeriesException, from : DateTime <Utc>, to : DateTime <Utc>,) -> Result <Vec <Occurrence>, RecurrenceError> |
postgres (other)
PostgreSQL storage for calendar series and exceptions.
| Item |
|---|
async fn create_series(pool : & PgPool, series : & CalendarSeries) -> Result <(), RecurrenceError> |
async fn get_series(pool : & PgPool, id : SeriesId) -> Result <CalendarSeries, RecurrenceError> |
async fn get_series_by_encounter(pool : & PgPool, encounter_id : Uuid,) -> Result <Option <CalendarSeries>, RecurrenceError> |
async fn update_series(pool : & PgPool, series : & CalendarSeries) -> Result <(), RecurrenceError> |
async fn delete_series(pool : & PgPool, id : SeriesId) -> Result <(), RecurrenceError> |
async fn create_exception(pool : & PgPool, exception : & SeriesException,) -> Result <(), RecurrenceError> |
async fn get_exceptions(pool : & PgPool, series_id : SeriesId,) -> Result <Vec <SeriesException>, RecurrenceError> |
async fn delete_exception(pool : & PgPool, series_id : SeriesId, original_start_local : NaiveDateTime,) -> Result <(), RecurrenceError> |
async fn list_series(pool : & PgPool) -> Result <Vec <CalendarSeries>, RecurrenceError> |
How to use it
No examples/ target and no doctest in this crate's rustdoc. The tests listed under Verification are the closest executable usage.
Module structure
content_calendar_recurrence
expandpostgres
flowchart TD n_content_calendar_recurrence["content_calendar_recurrence"] n_content_calendar_recurrence --> n_expand["expand"] n_content_calendar_recurrence --> n_postgres["postgres"]
Public surface
`expand`
| Item | What it is |
|---|---|
pub struct Occurrence | A single occurrence of a recurring event. |
fn expand_occurrences(series : & CalendarSeries, exceptions : & SeriesException, from : DateTime <Utc>, to : DateTime <Utc>,) -> Result <Vec <Occurrence>, RecurrenceError> | Expand a recurrence rule into concrete occurrences within a date range |
`postgres`
| Item | What it is |
|---|---|
async fn create_series(pool : & PgPool, series : & CalendarSeries) -> Result <(), RecurrenceError> | Create a new calendar series. |
async fn get_series(pool : & PgPool, id : SeriesId) -> Result <CalendarSeries, RecurrenceError> | Get a calendar series by ID. |
async fn get_series_by_encounter(pool : & PgPool, encounter_id : Uuid,) -> Result <Option <CalendarSeries>, RecurrenceError> | Get a calendar series by encounter template ID. |
async fn update_series(pool : & PgPool, series : & CalendarSeries) -> Result <(), RecurrenceError> | Update a calendar series. |
async fn delete_series(pool : & PgPool, id : SeriesId) -> Result <(), RecurrenceError> | Delete a calendar series. |
async fn create_exception(pool : & PgPool, exception : & SeriesException,) -> Result <(), RecurrenceError> | Create a series exception. |
async fn get_exceptions(pool : & PgPool, series_id : SeriesId,) -> Result <Vec <SeriesException>, RecurrenceError> | Get exceptions for a series. |
async fn delete_exception(pool : & PgPool, series_id : SeriesId, original_start_local : NaiveDateTime,) -> Result <(), RecurrenceError> | Delete a series exception. |
async fn list_series(pool : & PgPool) -> Result <Vec <CalendarSeries>, RecurrenceError> | List all series (for admin/debugging). |
Re-exports. Exported here, defined elsewhere.
| Export | Defined in |
|---|---|
PartyId | identity_parties::PartyId |
RecurrenceError | error::RecurrenceError |
{CalendarSeries,ExceptionAction,SeriesException,SeriesId} | models::{CalendarSeries,ExceptionAction,SeriesException,SeriesId} |
{create_exception,create_series,delete_exception,delete_series,get_exceptions,get_series,get_series_by_encounter,list_series,update_series,} | postgres::{create_exception,create_series,delete_exception,delete_series,get_exceptions,get_series,get_series_by_encounter,list_series,update_series,} |
{expand_occurrences,Occurrence} | expand::{expand_occurrences,Occurrence} |
Boundary
Reaches into foundation, identity.
Shares tier content with 5 other crates: content-assets, content-calendar, content-cms, content-directory-listing, content-notes.
_What this crate deliberately does NOT own is a judgment. No committed registry records one for it, so none is stated here._
Where it sits
| Tier (ontology) | content |
| Architectural role (taxonomy) | unclassified (baselined) |
| Location | crates/content/calendar-recurrence |
| Vocabulary in force (lexicon) | current |
Tier flow. Which tiers this crate's own edges cross.
flowchart LR n_content["content"] --> n_foundation["foundation"] n_content["content"] --> n_identity["identity"]
Dependencies
Runtime, in this workspace.
| Crate | Tier | Optional | Only on |
|---|---|---|---|
| `foundation-basemodels` | foundation | no | always |
| `identity-parties` | identity | no | always |
Runtime, from outside the workspace.
| Crate | Requirement | Features | Optional | Only on |
|---|---|---|---|---|
async-trait | ^0.1 | — | no | always |
chrono | ^0.4 | serde | no | always |
chrono-tz | ^0.10 | — | no | always |
rrule | ^0.13 | — | no | always |
serde | ^1 | derive | no | always |
serde_json | ^1 | — | no | always |
sqlx | ^0.8 | runtime-tokio, postgres, chrono, uuid, json | yes | always |
thiserror | ^2 | — | no | always |
uuid | ^1 | v4, serde | no | always |
Development, from outside the workspace.
| Crate | Requirement | Features | Optional | Only on |
|---|---|---|---|---|
dotenvy | ^0.15 | — | no | always |
tokio | ^1 | full | no | always |
tokio-test | ^0.4 | — | no | always |
Build. None.
Depended on by. 2 workspace crates.
Signal flow — what reaches this crate, and what it reaches.
flowchart LR n_application_calendar_recurrence["application-calendar-recurrence"] -->|uses| SELF n_foundation_test_support["foundation-test-support"] -->|uses| SELF SELF["content-calendar-recurrence"] SELF -->|runtime| n_foundation_basemodels["foundation-basemodels"] SELF -->|runtime| n_identity_parties["identity-parties"] classDef self fill:#1f883d,stroke:#1f883d,color:#fff; class SELF self;
Feature flags
| Feature | Enables | On by default |
|---|---|---|
default | — | yes |
postgres | dep:sqlx | no |
flowchart LR n_default["default"] n_postgres["postgres"] --> n_dep_sqlx["dep:sqlx"]
Targets
| Kind | Name | Source |
|---|---|---|
| lib | content_calendar_recurrence | `src/lib.rs` |
| test | integration | `tests/integration.rs` |
Error model
No public error type was detected: no public item declares a type named *Error, and no public signature returns one.
Operational characteristics
| Property | Evidence |
|---|---|
| async public surface | yes |
| async runtime | none detected |
| database access | yes |
| network I/O | none detected |
| unsafe code | none detected |
| environment variables | none detected |
No unsafe block, unsafe fn, unsafe impl or unsafe trait was found by the parser anywhere in this crate's source.
Configuration
No environment variable is read with a literal name anywhere in this crate. A variable whose key is computed at run time cannot be listed here, and is not claimed to be absent.
Related capabilities
2 workspace crates depend on this one: application-calendar-recurrence, foundation-test-support.
Verification
| Kind | Count |
|---|---|
| Unit tests | 10 |
| Integration tests | 18 |
| Examples | 0 |
| Doctests | 0 |
Evidence by module. How often each public module is named by something executable.
| Module | Tests | Examples | Consumers |
|---|---|---|---|
expand | 2 | 0 | 0 |
postgres | 9 | 0 | 0 |
What the tests establish, by name:
test_cascade_delete_exceptions—tests/integration.rstest_create_override_exception—tests/integration.rstest_create_series—tests/integration.rstest_create_skip_exception—tests/integration.rstest_delete_exception—tests/integration.rstest_delete_series—tests/integration.rstest_duplicate_exception_fails—tests/integration.rstest_expand_across_dst_fall_back—tests/integration.rstest_expand_across_dst_spring_forward—tests/integration.rstest_expand_weekly_occurrences—tests/integration.rstest_expand_with_override_exception—tests/integration.rstest_expand_with_skip_exception—tests/integration.rstest_get_series_by_encounter—tests/integration.rstest_get_series_by_encounter_not_found—tests/integration.rstest_get_series_not_found—tests/integration.rstest_invalid_timezone_error—tests/integration.rstest_list_series—tests/integration.rstest_update_series—tests/integration.rstest_dst_spring_forward—src/expand.rstest_expand_weekly_series—src/expand.rstest_expand_with_override_exception—src/expand.rstest_expand_with_skip_exception—src/expand.rstest_invalid_timezone—src/expand.rstest_exports—src/lib.rstest_exception_action_parsing—src/models.rstest_exception_skip—src/models.rstest_series_builder—src/models.rstest_exception_action_roundtrip—src/postgres.rs
Documentation coverage
| Measure | Documented | Total |
|---|---|---|
| Public items with rustdoc | 11 | 11 |
Public modules with a //! block | 2 | 2 |
pie showData
title Public items with rustdoc
"Documented" : 11
"No rustdoc detected" : 0
Metrics
| Metric | Value |
|---|---|
| Rust source files | 5 |
| Source lines | 915 |
| Code lines | 632 |
| Public API items | 11 |
| Public modules | 2 |
| Tests | 28 |
| Examples | 0 |
| Cargo features | 2 |
| Direct runtime dependencies | 11 |
| Workspace reverse dependencies | 2 |
pie showData
title Public API by kind
"function" : 10
"struct" : 1
pie showData
title Rust source composition
"Code" : 632
"Blank or comment" : 283
Generation
Rendered by tools-corpus corpus readme from repository evidence alone, renderer schema 2, lexicon current. No model, network service or database was consulted. Regenerate with tools-corpus corpus readme --write; verify with --check.