Care Calendar: Recurring Appointments, Exceptions and Availability
HardAI-assisted coding · AI-Assisted Feature Work
Care Calendar: Recurring Appointments, Exceptions and Availability
You've joined the platform team at Riverside Family Clinic. carecal is the small service behind the front-desk booking screen and the shared staff calendar: clinicians, rooms and equipment each have a calendar, and an *event* books one or more of them. Last month the clinic moved off its old scheduling tool and the front desk has been flagging gaps ever since:
> "Our daily vaccine-fridge check shows up for every day except the last one. The biweekly
> diabetes group and the monthly 'last weekday' audit just say *not supported*, so we are retyping
> them by hand. And once those work, we need to cancel or move a single session without touching
> the rest, and then 'edit this and all following'."
The scheduling assistant (free/busy, conflicts, "find us a slot") is queued right behind. Everything is floating local time: a date plus a time of day on one wall clock. There are no time zones and no daylight-saving shifts.
Codebase
| File | What it does |
|---|---|
carecal/api.py | run(operations) entry point and the Dispatcher that parses and routes each operation |
carecal/service.py | CalendarService: create events, list occurrences (the one place an event and a date become an Instance) |
carecal/expansion.py | rule_dates: which dates a parsed rule generates inside a window |
carecal/availability.py | Availability: busy / conflicts / find_slot (stub) |
carecal/store.py | EventStore: events by id, in creation order |
carecal/models.py | Rule, Event and Instance records |
carecal/rrule.py | Parses and validates rule text into a Rule (known-good) |
carecal/fields.py | Validates the JSON objects clients send (known-good) |
carecal/dates.py | Date, clock and stamp helpers (known-good; read the docstrings, they say what each helper means) |
carecal/intervals.py | overlaps, merge_intervals, clip_interval (known-good) |
carecal/config.py, carecal/errors.py | Constants and every error code |
tests/ | Example tests (the daily UNTIL ones fail today) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh calendar and returns one result per operation. Every operation is [name, *args]. Failures are returned as "error: <code>", never raised. An operation that is not a non-empty list starting with a string returns "error: invalid arguments"; an unknown name returns "error: unknown operation" (checked before argument counts); a wrong number of arguments returns "error: invalid arguments".
Formats. Dates are YYYY-MM-DD and must lie in 1900-01-01 .. 2200-12-31. Clock times are HH:MM (00:00-23:59). Times in results are YYYY-MM-DDTHH:MM. An event's end is start + minutes and may fall on a later day. *Stamps* in the code are absolute minute numbers; intervals are half-open [start, end), so two intervals that merely touch do not overlap.
Event object (create; unknown keys are rejected):
| Key | Required | Meaning |
|---|---|---|
id | yes | non-empty string without @ |
date | yes | date of the first (anchor) occurrence, "DTSTART" |
start | yes | clock time every occurrence starts at |
minutes | yes | duration, integer 1-1440 (booleans are not integers) |
attendees | yes | non-empty list of distinct non-empty strings (clinicians, rooms, ...), case-sensitive |
title | no | any string, default "" |
rule | no | recurrence rule text, or null/absent for a one-off event |
An *instance* is one concrete occurrence. Its id is "<event id>@<original date>", where the original date is the date the recurrence rule generated; that id never changes, even if the occurrence is later moved.
Part 1 — Recurrence rules (FD-318, FD-327)
Goal: make create and occurrences honour the supported subset of iCalendar RRULEs. Today only DAILY is implemented and its UNTIL day is missing; WEEKLY and MONTHLY answer not supported.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
create | event object | true | invalid arguments, invalid rule, event exists |
occurrences | event_id, from_date, to_date | list of [instance_id, start, end, title] | invalid arguments, event not found |
occurrences lists the instances whose start date lies in [from_date, to_date] (both inclusive), ordered by start, then end, then instance id. from_date > to_date, a non-string or empty id or a malformed date is invalid arguments.
Rule text is KEY=VALUE pieces joined by ; in any order (FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH;COUNT=10). Syntax and validation are already implemented in rrule.parse_rule — read its docstring. Defaults it fills in: WEEKLY without BYDAY uses DTSTART's weekday; MONTHLY with neither BYDAY nor BYMONTHDAY uses DTSTART's day of the month. UNTIL is a plain date, like DTSTART.
What a rule generates (this is the part to implement):
- DAILY: DTSTART, then every
INTERVALdays. - WEEKLY: weeks run Monday to Sunday. Week 0 is the week containing DTSTART; the rule is active in weeks 0,
INTERVAL, 2·INTERVAL, ... and in an active week generates that week'sBYDAYweekdays. Days earlier than DTSTART are never generated. - MONTHLY: the rule is active in DTSTART's month and every
INTERVALmonths after it, and in an active month generates at most one date:
BYMONTHDAY=n: the n-th, if the month has one. A month without that day is skipped (it is not moved to the last day).BYMONTHDAY=31therefore hits only Jan, Mar, May, Jul, Aug, Oct, Dec.BYDAY=...;BYSETPOS=p: collect every date of the month whose weekday is inBYDAY, ascending; take the p-th (1 is the first,-1the last,-2the second to last). If the month has fewer than |p| such dates it is skipped.BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1is "last weekday of the month".
- DTSTART is not automatically an occurrence: only dates the rule generates count, so a weekly rule
BYDAY=FRanchored on a Wednesday starts that Friday, andBYMONTHDAY=15anchored on the 20th starts next month's 15th (or the 15thINTERVALmonths on). COUNT=nkeeps the first n dates of that sequence.UNTILkeeps dates on or before it (inclusive). With neither, the rule repeats until 2200-12-31, and nothing is ever generated after that.- A one-off event (no rule) has exactly one occurrence, on
date.
Example
[["create", {"id": "dm-group", "title": "Diabetes group", "date": "2025-01-08", "start": "09:00", "minutes": 30,
"attendees": ["room-2"], "rule": "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=6"}],
["create", {"id": "fridge", "date": "2025-01-31", "start": "07:30", "minutes": 15,
"attendees": ["nurse-pat"], "rule": "FREQ=MONTHLY"}],
["occurrences", "dm-group", "2025-01-01", "2025-03-01"],
["occurrences", "fridge", "2025-01-01", "2025-06-30"]]returns
[true, true,
[["dm-group@2025-01-08", "2025-01-08T09:00", "2025-01-08T09:30", "Diabetes group"],
["dm-group@2025-01-20", "2025-01-20T09:00", "2025-01-20T09:30", "Diabetes group"],
["dm-group@2025-01-22", "2025-01-22T09:00", "2025-01-22T09:30", "Diabetes group"],
["dm-group@2025-02-03", "2025-02-03T09:00", "2025-02-03T09:30", "Diabetes group"],
["dm-group@2025-02-05", "2025-02-05T09:00", "2025-02-05T09:30", "Diabetes group"],
["dm-group@2025-02-17", "2025-02-17T09:00", "2025-02-17T09:30", "Diabetes group"]],
[["fridge@2025-01-31", "2025-01-31T07:30", "2025-01-31T07:45", ""],
["fridge@2025-03-31", "2025-03-31T07:30", "2025-03-31T07:45", ""],
["fridge@2025-05-31", "2025-05-31T07:30", "2025-05-31T07:45", ""]]]The group starts on a Wednesday: Monday 6 January is before DTSTART, so week 0 only contributes the Wednesday; the next active week is two weeks later.
Part 2 — Cancel or move a single occurrence (FD-341)
Goal: exceptions that change one occurrence without touching the rule.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
exclude | event_id, date | true | invalid arguments, event not found, no such occurrence |
override | event_id, date, changes | true | invalid arguments, event not found, no such occurrence, occurrence excluded |
date is the occurrence's original date, the one in its instance id (iCalendar's RECURRENCE-ID).
Rules
datemust be a date the event's rule currently generates (for a one-off, itsdate); otherwiseno such occurrence. An excluded or moved occurrence still has its original date.excluderemoves that occurrence. Excluding it again is fine (true). It also discards any override of it.overridechanges one occurrence.changesis a non-empty object whose keys are amongtitle,date,start,minutes,attendees, validated likecreate(an empty or unknown-key object isinvalid arguments). The occurrence keeps its id;datemoves it to a new start date (any valid date, even before DTSTART or the same day as another occurrence). Overriding an excluded occurrence isoccurrence excluded.- Overrides on the same occurrence merge: a later key replaces the earlier value, other keys are kept.
- Exceptions never change which dates exist:
COUNTstill counts every date the rule generates, excluded or moved ones included, andUNTILis unaffected. occurrencesuses an instance's actual start date: a moved instance appears in the window containing its new date, not the one containing its original date.
Example
[["create", {"id": "standup", "title": "Standup", "date": "2025-03-03", "start": "09:00", "minutes": 30,
"attendees": ["team"], "rule": "FREQ=DAILY;COUNT=5"}],
["exclude", "standup", "2025-03-04"],
["override", "standup", "2025-03-05", {"date": "2025-03-06", "start": "14:00"}],
["occurrences", "standup", "2025-03-03", "2025-03-07"]]returns
[true, true, true,
[["standup@2025-03-03", "2025-03-03T09:00", "2025-03-03T09:30", "Standup"],
["standup@2025-03-06", "2025-03-06T09:00", "2025-03-06T09:30", "Standup"],
["standup@2025-03-05", "2025-03-06T14:00", "2025-03-06T14:30", "Standup"],
["standup@2025-03-07", "2025-03-07T09:00", "2025-03-07T09:30", "Standup"]]]Part 3 — Free/busy, conflicts and "find a slot" (FD-352)
Goal: the scheduling assistant's three questions, answered from the attendee lists. A person is busy whenever an instance they attend (as it actually stands, after exceptions) is happening. People are just names in attendees; an unknown name is simply free. All three take a whole-day window [from_date 00:00, to_date + 1 day 00:00) and an instance counts for the window when its interval overlaps it, including one that started the day before and runs past midnight.
| Operation | Arguments | Returns | Errors |
|---|---|---|---|
busy | person, from_date, to_date | [[start, end], ...] | invalid arguments |
conflicts | person, from_date, to_date | [[id_a, id_b], ...] | invalid arguments |
find_slot | attendees, minutes, from_date, to_date, day_start, day_end | [start, end] or null | invalid arguments |
busy: the person's instance intervals, clipped to the window, merged into disjoint intervals in ascending order. Intervals that overlap or touch are merged (09:00-09:30 and 09:30-10:00 give 09:00-10:00).conflicts: among the person's instances that overlap the window (not clipped), every pair whose intervals overlap by at least one minute (touching is not a conflict). Sort instances by start, then end, then id; list each pair once as[earlier, later]in that order, pairs ordered by earlier then later.find_slot:attendeesis a non-empty list of distinct names,minutes >= 1, andday_start < day_endare clock times. For each day fromfrom_datetoto_date, the day offers the window[day_start, day_end]. Return the earliest[start, end](end = start + minutes) lying entirely inside one day's window during which none of the attendees is busy; touching a busy interval is fine, and the start may be any minute. Days are tried in order;nullif nothing fits.invalid argumentscovers a bad person/name list, bad dates orfrom_date > to_date, non-integer or non-positiveminutes, andday_start >= day_end.
Example (all times on Monday 2025-03-03 unless stated)
[["create", {"id": "huddle", "title": "Huddle", "date": "2025-03-03", "start": "08:30", "minutes": 30,
"attendees": ["ng", "ortiz"], "rule": "FREQ=WEEKLY;BYDAY=MO,WE"}],
["create", {"id": "consult", "date": "2025-03-03", "start": "08:45", "minutes": 60, "attendees": ["ng"]}],
["create", {"id": "clinic", "date": "2025-03-03", "start": "10:00", "minutes": 120, "attendees": ["ortiz"]}],
["busy", "ng", "2025-03-03", "2025-03-05"],
["conflicts", "ng", "2025-03-03", "2025-03-05"],
["find_slot", ["ng", "ortiz"], 30, "2025-03-03", "2025-03-04", "08:00", "12:00"],
["find_slot", ["ng", "ortiz"], 60, "2025-03-03", "2025-03-04", "08:00", "12:00"]]returns
[true, true, true,
[["2025-03-03T08:30", "2025-03-03T09:45"], ["2025-03-05T08:30", "2025-03-05T09:00"]],
[["huddle@2025-03-03", "consult@2025-03-03"]],
["2025-03-03T08:00", "2025-03-03T08:30"],
["2025-03-04T08:00", "2025-03-04T09:00"]]Part 4 — "Edit this and all following" (FD-366, requirements change)
Until now an event was one record: one rule, one start time, one attendee list. The front desk now needs to change a series from one occurrence onwards (new time, new room, new rule) while the past stays exactly as it was. The event id must keep naming the whole series, so exclusions, overrides and every query keep working on it unchanged.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
split | event_id, from_date, changes | true | invalid arguments, event not found, no such occurrence, occurrence excluded, invalid rule |
changes is an object (possibly empty) with keys among title, start, minutes, attendees and rule (rule is a string or null), validated like create; anything else is invalid arguments.
Rules
from_datemust be an original date the series currently generates (no such occurrence) and must not be excluded (occurrence excluded). Split first, parse the new rule last: a badrulestring isinvalid ruleand changes nothing.- Before
from_datenothing changes: same dates, times, titles, attendees, and the same exceptions. That includes an occurrence with an earlier original date that was *moved* to or pastfrom_date: it stays as it is. - From
from_dateon the series is replaced by a new tail anchored atfrom_date(its DTSTART). Fields not inchangesare inherited from the part of the series that containedfrom_date. Exceptions (exclusions and overrides) whose original date isfrom_dateor later are discarded, and so is every part of the series that came afterfrom_date(from earlier splits). - The tail's rule: if
changeshas arulestring it replaces the rule entirely and is parsed withfrom_dateas DTSTART (same syntax rules ascreate, same defaults);"rule": nullmakes the tail a one-off onfrom_date. Otherwise the tail repeats the same rule with the same cadence (from_dateis an occurrence, so interval and weekday patterns line up), and aCOUNTis reduced by the number of dates the part beforefrom_dategenerated, counting excluded and moved ones. AnUNTILstays as it was. ACOUNTis per part: it limits the part that carries it. - If
from_dateis the first date of the part being split, nothing remains of that part; earlier parts (if any) are untouched and the tail follows them. - Instance ids remain
"<event id>@<original date>", using the dates the part that owns them generated. - Parts 1-3 behave exactly as before, per part:
excludeandoverrideaddress an original date in whichever part generated it;busy,conflictsandfind_slotsee each part's own attendees, times and durations.
Example
[["create", {"id": "yoga", "title": "Yoga", "date": "2025-03-03", "start": "17:00", "minutes": 60,
"attendees": ["room-1"], "rule": "FREQ=WEEKLY;BYDAY=MO;COUNT=6"}],
["exclude", "yoga", "2025-03-10"],
["split", "yoga", "2025-03-17", {"start": "18:00", "title": "Evening yoga"}],
["occurrences", "yoga", "2025-03-01", "2025-05-01"]]returns
[true, true, true,
[["yoga@2025-03-03", "2025-03-03T17:00", "2025-03-03T18:00", "Yoga"],
["yoga@2025-03-17", "2025-03-17T18:00", "2025-03-17T19:00", "Evening yoga"],
["yoga@2025-03-24", "2025-03-24T18:00", "2025-03-24T19:00", "Evening yoga"],
["yoga@2025-03-31", "2025-03-31T18:00", "2025-03-31T19:00", "Evening yoga"],
["yoga@2025-04-07", "2025-04-07T18:00", "2025-04-07T19:00", "Evening yoga"]]]The old part kept two dates (3 March and the excluded 10 March), so the tail inherits COUNT=4.
Part 5 — Decades of standing appointments (FD-371, stretch)
The clinic's oldest standing bookings (daily fridge checks, weekly rooms) were back-filled from 1900, and the assistant now runs hundreds of window queries against them, often decades after DTSTART.
Goal: ["count", event_id, from_date, to_date] returns how many instances occurrences would list for the same arguments (an integer; same errors and check order), and every operation must stay fast when a window is far from DTSTART. Hidden tests use series anchored in 1900 with windows in the 2100s, century-wide count windows, COUNT rules with thousands of dates, many such events, and hundreds of queries in one run; the whole suite has a few seconds. Walking a rule forward from DTSTART for every query is too slow, and so is building a century of instances just to count them.
Notes
dates.py,intervals.py,fields.pyandrrule.pyare known-good.dates.add_monthsis *billing* month arithmetic; read its docstring before reusing it.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later Parts must not break earlier ones; hidden tests re-check earlier behaviour after exceptions and splits.
- Parts 1-4 is a strong result. Part 5 is a stretch.
Example 1
- Input:
- [["create",{"id":"dm-group","title":"Diabetes group","date":"2025-01-08","start":"09:00","minutes":30,"attendees":["room-2"],"rule":"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=6"}],["create",{"id":"fridge","date":"2025-01-31","start":"07:30","minutes":15,"attendees":["nurse-pat"],"rule":"FREQ=MONTHLY"}],["occurrences","dm-group","2025-01-01","2025-03-01"],["occurrences","fridge","2025-01-01","2025-06-30"]]
- Output:
- [true,true,[["dm-group@2025-01-08","2025-01-08T09:00","2025-01-08T09:30","Diabetes group"],["dm-group@2025-01-20",...],["dm-group@2025-01-22",...],["dm-group@2025-02-03",...],["dm-group@2025-02-05",...],["dm-group@2025-02-17",...]],[["fridge@2025-01-31",...],["fridge@2025-03-31",...],["fridge@2025-05-31",...]]]
- Explanation
- The group starts on a Wednesday, so Monday 6 January (before DTSTART) is dropped and the next active week is two weeks later. The monthly series anchored on the 31st skips months that have no 31st instead of clamping.
Example 2
- Input:
- [["create",{"id":"standup","title":"Standup","date":"2025-03-03","start":"09:00","minutes":30,"attendees":["team"],"rule":"FREQ=DAILY;COUNT=5"}],["exclude","standup","2025-03-04"],["override","standup","2025-03-05",{"date":"2025-03-06","start":"14:00"}],["occurrences","standup","2025-03-03","2025-03-07"]]
- Output:
- [true,true,true,[["standup@2025-03-03","2025-03-03T09:00","2025-03-03T09:30","Standup"],["standup@2025-03-06","2025-03-06T09:00","2025-03-06T09:30","Standup"],["standup@2025-03-05","2025-03-06T14:00","2025-03-06T14:30","Standup"],["standup@2025-03-07","2025-03-07T09:00","2025-03-07T09:30","Standup"]]]
- Explanation
- The 4th is cancelled. The 5th is moved to the 6th at 14:00 but keeps its id, and appears in the 6th's results ordered by start time. COUNT=5 still ends on the 7th.
Example 3
- Input:
- [["create",{"id":"yoga","title":"Yoga","date":"2025-03-03","start":"17:00","minutes":60,"attendees":["room-1"],"rule":"FREQ=WEEKLY;BYDAY=MO;COUNT=6"}],["exclude","yoga","2025-03-10"],["split","yoga","2025-03-17",{"start":"18:00","title":"Evening yoga"}],["count","yoga","2025-03-01","2025-05-01"]]
- Output:
- [true,true,true,5]
- Explanation
- The old part keeps two of the six dates (3 March and the excluded 10 March), so the evening tail inherits COUNT=4: 17, 24, 31 March and 7 April. With the 3 March session that makes 5 instances.
Guidelines
- Python 3 standard library only
- All times are floating local time: dates and HH:MM clock times, no time zones or daylight saving
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Dates are limited to 1900-01-01 .. 2200-12-31 and no rule generates dates after 2200-12-31
- Instances are ordered by (start, end, instance id) everywhere they are listed
- Part 5 inputs anchor series in 1900, ask about the 2100s and run hundreds of queries in one run