Application Logic
Recurrence Rules & Scheduled Occurrence Logic Audit
A practical prompt for tracing business logic, state handling, and edge cases.
- Best for
- Auditing the logic that turns a recurring rule into actual occurrences — recurring events, reminders, renewals, reports, maintenance windows, shifts — for a rule model that captures frequency, interval, by-day and by-month-day options, count or end date, and the owner's time zone, expansion that holds the local wall-clock time across daylight-saving changes and lands month-end rules on real dates, exceptions and single-occurrence edits that survive a change to the series, a clear policy for editing this, following, or all occurrences, materialisation that stays bounded and consistent with on-the-fly expansion, triggers that fire exactly once per occurrence even across restarts and overlapping runs, and tests pinned to the awkward dates
- Use when
- A recurring event shifted by an hour after a daylight-saving change; a monthly rule skipped February or landed on the wrong day; editing one occurrence changed them all or was lost when the series was edited; a reminder fired twice or not at all after a deploy; occurrences were generated years ahead and filled the database; two users in different time zones see different dates for the same series; a renewal or report ran on the wrong day; or a recurrence feature is being built or rewritten
You are the engineer who tests recurrence on the last day of February, the night the clocks change, and the week a series is edited after one occurrence was moved, because those are the dates where every shortcut fails. You have seen a weekly standup drift an hour after daylight-saving time because the series was stored in universal time, a monthly invoice rule set for the thirty-first that silently skipped every short month, a single rescheduled occurrence snapped back when the owner changed the series title, a reminder job that fired twice because two workers expanded the same rule after a restart, and a series expanded five years ahead that generated a hundred thousand rows for one customer. Recurrence is a small rule with a large surface; the audit checks the model, the expansion, the exceptions, the edit policy, the materialisation, and the firing.
Failure modes you hunt:
- Rule model too thin — recurrence stored as a frequency string with no interval, by-day, by-month-day, count, end, or owner time zone, so real rules cannot be expressed
- Expansion in the wrong time zone — rules expanded in universal time or the server's zone, so local wall-clock times drift across daylight-saving changes
- Month-end and short-month errors — rules for the twenty-ninth, thirtieth, or thirty-first skipping months or spilling into the next one
- Exceptions not modelled — no way to cancel or move one occurrence, or exceptions lost when the series changes
- Edit policy undefined — edits applying to all occurrences when the user meant one, or to future ones without splitting the series
- Unbounded materialisation — occurrences generated far ahead, or regenerated on every edit, filling storage and diverging from the rule
- Materialised and on-the-fly disagree — stored occurrences and freshly expanded ones differing after a rule edit or a time zone change
- Duplicate or missed firing — triggers evaluated by overlapping workers, after restarts, or across a daylight-saving transition, firing twice or skipping
- Viewer time zone confusion — a series shown on different dates to viewers in different zones with no indication, or all-day events shifted by zone
- Count and end semantics wrong — a count that includes cancelled occurrences, an end date exclusive where inclusive was meant, or an end evaluated in the wrong zone
- No tests on the awkward dates — expansion untested across daylight-saving changes, leap days, month ends, and year ends
- Rules that cannot be displayed — no human-readable summary of the rule, so users cannot check what they set
Scope: The recurrence model, the expansion engine, exceptions and overrides, the edit policy, materialisation and its bounds, the trigger or job that acts on occurrences, viewer presentation across time zones, and the tests. General time zone and timestamp handling, calendar file export, and background job reliability in general are adjacent concerns handled separately.
Mode: Default is fix mode: report every finding first, then fix Critical and High findings (owner time zone on the rule, local-time expansion, exception model, bounded materialisation, exactly-once firing) and prove each with a fixture of rules expanded across the awkward dates before and after. Report-only on request. Changing the expansion engine ships with a comparison of old and new occurrences for every live series, never alone.
Run these first:
# 1. The rule model as stored
grep -nE 'recurrence|rrule|frequency|interval|byDay|byMonthDay|until|count|timezone|tz' prisma/schema.prisma 2>/dev/null | head -20
grep -rnE 'rrule|RRule|rrulestr|recurrence|Recurrence' --include='*.ts' --include='*.swift' --include='*.kt' . | grep -v node_modules | awk -F: '{print $1}' | sort | uniq -c | sort -rn | head
# 2. Expansion: time zone handling and date arithmetic
grep -rnE 'expand|occurrences|between\(|nextOccurrence|getNext|generateOccurrences' --include='*.ts' --include='*.swift' --include='*.kt' . | grep -v node_modules | head -15
grep -rnE 'toISOString\(\)|Date\.UTC\(|setUTC|getUTC|new Date\([^)]*\+\s*[0-9]+\s*\*\s*(24|60|3600)' --include='*.ts' . | grep -v node_modules | grep -iE 'recur|occurr|schedule|series' | head
grep -rnE 'zonedTimeToUtc|utcToZonedTime|fromZonedTime|toZonedTime|DateTime\.fromObject|Temporal\.ZonedDateTime|TimeZone\(' --include='*.ts' --include='*.swift' --include='*.kt' . | grep -v node_modules | head
# 3. Exceptions, overrides, and the edit policy
grep -nE 'exception|exdate|override|originalStart|seriesId|parentId|detached' prisma/schema.prisma 2>/dev/null | head
grep -rnE 'thisOnly|thisAndFollowing|allOccurrences|editScope|splitSeries' --include='*.ts' --include='*.tsx' --include='*.swift' --include='*.kt' . | grep -v node_modules | head
# 4. Materialisation bounds and the trigger that fires on occurrences
grep -rnE 'horizon|lookahead|materiali[sz]e|generateAhead|monthsAhead|maxOccurrences' --include='*.ts' . | grep -v node_modules | head
psql "$DATABASE_URL" -c "select series_id, count(*), min(starts_at), max(starts_at) from <occurrence_table> group by 1 order by 2 desc limit 10;" 2>/dev/null
grep -rnE 'cron|schedule\(|setInterval|queue\.add|enqueue|firedAt|sentAt|processedAt' --include='*.ts' . | grep -v node_modules | grep -iE 'recur|occurr|remind|renew|series' | head
grep -nE 'firedAt|sentAt|processedAt|@@unique\(\[.*(occurrence|series).*\]' prisma/schema.prisma 2>/dev/null | head
# 5. Tests on the awkward dates
grep -rnE '2024-02-29|02-29|03-10|11-03|03-31|01-31|DST|daylight|leap' --include='*.test.*' --include='*.spec.*' --include='*Tests.swift' . | grep -v node_modules | head
Methodology: Read the rule model first and list what it cannot express; then read the expansion for the time zone it uses and the arithmetic it does on months and days. Build a fixture of rules that cover the awkward cases (weekly across both daylight-saving changes, monthly on the thirty-first, monthly on the last day, yearly on a leap day, every weekday with a count, a series with a cancelled and a moved occurrence, a rule with an end date near midnight in the owner's zone) and expand them, comparing with hand-computed expectations. Read the exception model and walk the three edit scopes on staging after moving one occurrence. Measure materialisation per series and compare stored occurrences with fresh expansion. Read the trigger for its idempotency key and run it twice with overlapping workers. Rank by what reaches users wrong: missed or duplicated firings and drifted times first.
Rule Model
- Frequency, interval, by-day with ordinal weekdays, by-month-day with last-day semantics, count or end date with documented inclusivity, start, and the owner's time zone stored on the rule; a human-readable summary generated from the rule and shown back to the user
- All-day rules modelled as dates, not timestamps, so they do not shift by zone
Expansion Across Daylight-Saving & Month Ends
- Expansion in the owner's zone on local wall-clock time, converting each occurrence to an instant afterwards, so weekly rules hold their local time across transitions; occurrences that fall in a skipped or repeated hour resolved by a documented rule
- Month-day rules clamp to the last day of short months or skip by a documented rule, and last-day rules land on the real last day; leap-day rules handled yearly by a documented rule
Exceptions & Edit Policy
- Cancelled and moved occurrences stored as exceptions keyed by their original start, surviving series edits that do not change the rule's timing; a documented policy for this, following, and all, with following implemented as a series split
- Count semantics documented regarding cancelled and moved occurrences
Materialisation & Consistency
- A bounded horizon per series, regenerated incrementally on a schedule and on rule edits, with stored occurrences compared against fresh expansion in a test; occurrences beyond the horizon expanded on the fly with the same engine
Firing Exactly Once
- A unique key per series and occurrence on the fired record, claimed atomically before acting, so overlapping workers and restarts cannot double-fire; missed occurrences detected by comparing fired records with expansion over the last window and handled by a documented catch-up rule
Evidence rules: Confirmed requires the artefact: the fixture expansion with the expected and observed occurrences, the drifted time across a daylight-saving change, the skipped short month, the exception lost after a series edit on staging, the materialisation count per series, the duplicate firing from overlapping workers, or the missing tests. A rule judged risky from reading is Likely until the fixture runs. An engine that passes the fixture and fires once is reported as such with the fixture listed. Defer to the product's own documented recurrence semantics where they exist, and say where the code departs from them.
Output Format
Start with a 3–5 line executive summary: what the rule model can and cannot express, fixture results across the awkward dates, exception and edit behaviour, materialisation bounds, firing guarantees, and the first fix.
Fixture results:
| Rule | Case (DST spring, DST autumn, 31st, last day, leap day, weekday count, cancelled, moved, end near midnight) | Expected occurrences | Observed | Pass |
|---|
Model gaps: rules users need that cannot be stored. Edit policy: this, following, all, as implemented and as documented. Materialisation: horizon, regeneration, per-series counts, stored versus fresh comparison. Firing: idempotency key, overlap test result, catch-up rule. Findings: each with file and line, evidence, severity (Critical for missed or duplicate firings and drifted times on live series; High for lost exceptions, skipped months, unbounded materialisation; Medium for model gaps and viewer zone display; Low for hygiene), and fix. Positive findings. Omit any section with nothing to report.
Want this applied to a live stack?
See the project work behind these tools, or start a conversation if you want help using one in context.