Skip to content

feat(adapters): recurrence pre-pass expanding RRULEs into engine inputs (TIN-1996 slice 1)#104

Merged
Jesssullivan merged 3 commits into
mainfrom
codex/tin-1996-recurrence-prepass
Jun 13, 2026
Merged

feat(adapters): recurrence pre-pass expanding RRULEs into engine inputs (TIN-1996 slice 1)#104
Jesssullivan merged 3 commits into
mainfrom
codex/tin-1996-recurrence-prepass

Conversation

@Jesssullivan

Copy link
Copy Markdown
Owner

Summary

Slice 1 of TIN-1996 (design spike): RFC 5545 recurrence enters the availability substrate as a pure pre-pass. New src/adapters/recurrence.ts exposes RecurringHoursRule / RecurringBlock and expandRecurrence(rules, range), which emits the engine's existing input types (HoursOverride, OccupiedBlock). getAvailableSlots / isSlotAvailable / getDatesWithAvailability signatures are untouched.

  • Optional peer: @tummycrypt/tinyland-calendar ^0.2.3 (peerDependenciesMeta optional; also a devDependency for tests). Loaded lazily via dynamic import inside expandRecurrence, mirroring the HomegrownAdapter auth-pg legacy-fallback pattern. Absent peer → typed, actionable RecurrencePeerUnavailableError; empty rule sets never touch the peer.
  • Subpath export: ./recurrencedist/adapters/recurrence.{js,d.ts}, mirroring ./capabilities wiring. Also re-exported from ./adapters. No BUILD.bazel change needed: the adapters ts_project globs src/adapters/**/*.ts, which picks up the new module (full MODULE.bazel bazel_dep wiring is slice 3 per the spike).
  • One deviation from the spike sketch: expandRecurrence returns a Promise (the spike sketched it sync) because the optional peer is loaded via dynamic import on first use — same posture as loadLegacyAuthPgSchemas.

Upstream fidelity gaps (spike risk #1 — confirmed, worked around honestly)

Verified against the published tinyland-calendar@0.2.3 tarball (dist/RecurrenceEngine.js):

  1. expandPattern (the private walk behind generateOccurrences) ignores BYDAY entirelyFREQ=WEEKLY;BYDAY=MO,WE,FR steps 7 days from DTSTART and emits one occurrence/week on DTSTART's weekday.
  2. parseRRule parses UNTIL with new Date('YYYYMMDDTHHMMSSZ') → Invalid Date for the RFC 5545 compact form, after which UNTIL is silently never enforced.
  3. parseRRule maps missing/unknown FREQ to daily and silently drops unknown tokens.

Per the spike's contingency, the kit uses the peer's RecurrenceEngine.parseRRule for tokenization but runs a local occurrence walk over a validated subset: FREQ=DAILY|WEEKLY|MONTHLY|YEARLY, INTERVAL, COUNT, UNTIL (compact + ISO, normalized locally), non-ordinal BYDAY for DAILY (filter) / WEEKLY (expansion), positive BYMONTHDAY for MONTHLY, WKST=MO, plus exdates. Anything outside the subset (BYSETPOS, BYMONTH, ordinal BYDAY, BYDAY with MONTHLY/YEARLY, sub-daily FREQ, non-MO WKST, unknown tokens) throws a typed UnsupportedRecurrenceErrorno silent wrong recurrence math. Once upstream exposes a correct expandPattern(pattern, dtstart, rangeStart, rangeEnd) (upstream ask tracked in TIN-1996), the local walk can be swapped out.

Timezone posture (spike risk #2): hours rules expand in pure calendar-date space (TZ remains the engine's job); RecurringBlock takes an optional timezone — when set, occurrences preserve DTSTART's wall-clock time across DST (RFC TZID semantics, reusing the engine's exported parseTimeInTz); when unset, fixed UTC cadence. Both behaviors are pinned by tests across the 2026-03-08 US DST boundary.

Tests (32 new, in src/adapters/__tests__/recurrence.test.ts)

  • Weekly BYDAY expansion incl. mid-week DTSTART and INTERVAL=2 week-bucketing
  • COUNT consumed from DTSTART (pre-range occurrences count), post-BYDAY semantics
  • Compact + date-only UNTIL (the exact forms upstream mis-parses), instant-precision UNTIL for blocks
  • exdates for hours (date) and blocks (exact instant + date)
  • DST boundary: wall-clock-preserving vs fixed-UTC block cadence
  • MONTHLY BYMONTHDAY, monthly-on-the-31st month skipping, YEARLY
  • 9 unsupported-construct rejections + input validation
  • Optional-peer absence: actionable typed error; empty rule sets never load the peer

Validation

  • pnpm check — 0 errors / 0 warnings (1955 files)
  • pnpm lint — clean
  • pnpm test:unit — 762 passed (27 files)
  • pnpm builddist/adapters/recurrence.{js,d.ts} emitted
  • publint — "All good!"

Review-panel self-check

  • Correctness: local walk validated against RFC 5545 semantics for the supported subset; COUNT/UNTIL exclusivity enforced; COUNT counts from DTSTART; EXDATE applied after COUNT; weeks bucket on WKST=MO; invalid month days skipped (Jan-31-monthly case tested). Unsupported constructs throw rather than approximate.
  • Blast radius: new file + re-export + package.json metadata only; zero edits to availability-engine.ts / homegrown.ts; rebases trivially over the 0.9.0 release PR (only potential skew is the package.json version field, which this diff does not touch).
  • Dependency hygiene: peer is optional and lazily imported; core/static import graph never touches it; consumers without the peer pay nothing until they call expandRecurrence with rules.
  • Honest limitations: no BYSETPOS/ordinal-BYDAY ("2nd Tuesday") support yet — needed for full Acuity parity, deferred until upstream RecurrenceEngine is fixed or slice 3 extends the local walk; multiple hours rules hitting the same date append (no dedup) — documented; date-only range bounds are interpreted as UTC days (callers can pass full ISO instants for precision).
  • Not merging: left open for review per operator instruction.

Roadmap (per spike phasing)

  • Slice 2: AvailabilitySource/BusySnapshot in core types; externalBusySources wiring into loadOccupied(); createCalDAVBusySource (sync-token + staleness semantics) behind a ./caldav subpath; mocked-CalendarClient integration tests.
  • Slice 3: MODULE.bazel/BUILD.bazel bazel_dep wiring, recurring-appointment write path (Acuity parity), upstream PRs (correct expandPattern, per-instance calendarPath, caldav-client peerDep metadata).

Refs TIN-1996

…ts (TIN-1996)

Add src/adapters/recurrence.ts: RecurringHoursRule / RecurringBlock types
and expandRecurrence(), which expands RFC 5545 recurrence rules into the
availability engine's existing input types (HoursOverride, OccupiedBlock).
Engine signatures untouched; recurrence stays a pure pre-pass.

RRULE parsing is delegated to the new optional peer dependency
@tummycrypt/tinyland-calendar (^0.2.3) via RecurrenceEngine.parseRRule,
loaded lazily with the same dynamic-import posture as the HomegrownAdapter
auth-pg schema fallback (actionable RecurrencePeerUnavailableError when
absent). The occurrence walk is local: at 0.2.3 the upstream walk ignores
BYDAY and mis-parses compact UNTIL, so the kit walks a validated RFC 5545
subset and throws UnsupportedRecurrenceError for anything it cannot expand
faithfully instead of shipping wrong recurrence math.

Adds the ./recurrence subpath export mirroring ./capabilities wiring.

Refs TIN-1996 (slice 1 of 3)
@Jesssullivan Jesssullivan merged commit 225df2d into main Jun 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant