RotaDesk: Shift Swaps, Approvals and Three-Way Cycles
MediumAI-assisted coding · AI-Assisted Debugging
RotaDesk: Shift Swaps at Memorial General
You've joined the team that builds RotaDesk, the nurse scheduling app at Memorial General Hospital. Nurses can put a shift on the swap marketplace; a colleague answers by either *taking* it (a giveaway) or *offering one of their own shifts in return* (a trade). Whether a swap is allowed depends on qualifications, rest, weekly hours and overlaps. The unit's charge nurses have complained that RotaDesk's "is this swap OK?" check lets through swaps that leave nurses with 7 hours between shifts, and blocks swaps that are fine. Three tickets are queued: fix the check, add the charge-nurse approval workflow, then support three-way swap cycles.
Codebase
| File | What it does |
|---|---|
rota/api.py | run(operations) entry point and the Dispatcher that routes each operation |
rota/roster.py | Roster: nurses, shifts, and a per-nurse chronological index of who works what |
rota/rules.py | The validation rules: qualifications, overlap, rest, weekly hours |
rota/swaps.py | Marketplace: listings, building the *moves* of a swap, validating them |
rota/approvals.py | Placeholder for the approval workflow (Part 2) |
rota/timeutil.py | Time parsing, week arithmetic, H:MM formatting |
rota/models.py | Nurse, Shift, Listing, Move records |
rota/config.py | Policy constants (10 h rest, 48 h weekly cap, unit qualifications, id prefixes) |
rota/errors.py | RotaError and every error code |
tests/ | Existing unit and end-to-end tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh hospital and returns one result per operation. Every operation is [name, *args]. Errors in looking things up or in permissions are returned as "error: <code>" strings, never raised. Rule violations found while validating a swap are different: they are returned as {"errors": [message, ...]} (see below). Times are written "YYYY-MM-DD HH:MM", hospital-local wall-clock time (no time zones, no daylight saving). A shift ends on the later day when it crosses midnight.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
add_nurse | nurse_id, quals, charge | true | nurse exists |
add_shift | shift_id, unit, start, end, nurse_id_or_null | true | shift exists, unknown unit, invalid times, nurse not found |
add_pattern | prefix, unit, start, end, nurse_id, count, every_days | count | shift exists, unknown unit, invalid times, nurse not found |
roster | nurse_id | the nurse's shift ids, chronological (ties by shift id) | nurse not found |
post | nurse_id, shift_id | listing id "L-1", "L-2", ... | nurse not found, shift not found, not owner, already posted |
preview_take | listing_id, nurse_id | "ok" or {"errors": [...]} | listing not found, listing closed, nurse not found, own listing |
preview_trade | listing_id, nurse_id, offered_shift_id | "ok" or {"errors": [...]} | the four above, then shift not found, not owner |
- A malformed argument (wrong count, wrong type, empty id, a time that doesn't parse,
count < 1,count > 50000,every_days < 1) returns"error: invalid arguments"before anything else is checked. An unknown operation name returns"error: unknown operation". qualsis a list of qualification names,chargea boolean. Valid units and the qualifications each requires are inrota/config.py. A nurse must hold all of a unit's qualifications.invalid times:endis not afterstart, or the shift is longer than 16 hours (exactly 16:00 is fine).add_patterncreates shifts<prefix>-1...<prefix>-<count>(all-or-nothing), the first atstart/endand each next oneevery_daysdays later. A shift added with anullnurse is unassigned and can't be posted.postanswersnot ownerwhen the shift isn't currently the nurse's. A shift can have only one open listing at a time. A rejectedpostdoes not use up a listing id.- A nurse can't answer their own listing (
own listing). For a trade,offered_shift_idmust be currently owned by the nurse answering.
Worked example. Ben isn't TRIAGE-qualified, and his own shift s2 starts 9 h 59 min after s1 ends.
[["add_nurse", "ana", ["ACLS", "TRIAGE"], false],
["add_nurse", "ben", ["ACLS"], false],
["add_shift", "s1", "ER", "2025-03-03 07:00", "2025-03-03 19:00", "ana"],
["add_shift", "s2", "ER", "2025-03-04 04:59", "2025-03-04 13:00", "ben"],
["post", "ana", "s1"],
["preview_take", "L-1", "ben"]]returns
[true, true, true, true, "L-1",
{"errors": ["ben: missing qualification TRIAGE for s1",
"ben: only 9:59 rest between s1 and s2"]}]Part 1: Fix the swap check (INC-2210, INC-2237)
Goal: preview_take and preview_trade must report exactly the violations a swap would cause. A swap is valid when, after it has happened, every nurse who *receives* a shift satisfies the rules below with respect to that shift. Reports from the floor: a nurse with exactly 10 hours between two shifts was refused; a nurse was cleared for a Sunday-night shift and ended up over the weekly cap; two colleagues could not trade shifts that happen at the same time even though each leaves the slot they hold; and previewing the same swap twice gave two different answers.
Rules
- Qualifications: the receiver must hold every qualification of the shift's unit.
- Overlap: the received shift must not intersect any other shift the receiver works. Two shifts intersect only if each starts strictly before the other ends; shifts that merely touch don't overlap.
- Rest: between the received shift and every other shift the receiver works, the gap (start of the later minus end of the earlier) must be at least 10 hours. Exactly 10:00 is fine, 9:59 is not, and touching shifts (gap
0:00) are a rest violation, not an overlap. Overlapping pairs are reported only as overlaps. - Weekly hours: weeks run from Monday 00:00 to the next Monday 00:00. A shift's minutes count toward the week they fall in, so a shift from Sunday 19:00 to Monday 07:00 counts 5:00 toward the week that ends and 7:00 toward the next one. For each week in which the received shift has at least one minute (a shift ending exactly at Monday 00:00 belongs only to the week before), the receiver's total minutes in that week (including the received shift) may be at most 48:00; 48:00 is fine, 48:01 is not.
- After the swap: shifts the receiver gives away in the same swap don't count for rules 2-4. A trade is judged this way, and so is any swap in which a nurse both gives and receives.
- Only pairs and weeks that involve the received shift are examined. Violations that already exist elsewhere in a nurse's roster are not the swap's problem.
- A preview never changes anything: asking twice gives the same answer and
rosteris unaffected.
Messages and order. {"errors": [...]} lists every violation. Nurses are reported in the order of the moves; a giveaway has one receiver, and for a trade the poster (who receives the offered shift) comes first and the taker second. For each nurse, in this order:
| Rule | Message | Order inside the rule |
|---|---|---|
| qualifications | {nurse}: missing qualification {QUAL} for {shift} | one per missing qualification, alphabetical |
| overlap | {nurse}: {shift} overlaps {other} | other shifts chronological (by start, then id) |
| rest | {nurse}: only {H:MM} rest between {earlier} and {later} | other shifts chronological |
| weekly | {nurse}: weekly hours {H:MM} exceed cap 48:00 for week of {YYYY-MM-DD} | earlier week first |
{H:MM} is hours without padding and minutes with two digits (9:59, 0:00, 49:00). The week date is that week's Monday. In the rest message {earlier} and {later} are the two shift ids in chronological order, whichever one is the received shift.
tests/ contains failing tests for the first and last reports. The weekly-cap and trade reports are covered only by the hidden suite.
Part 2: Charge-nurse approval (INC-2301)
Goal: a swap no longer happens just because it is valid. take and trade now *propose* a swap that a charge nurse must approve; nothing moves until then. The previews from Part 1 stay exactly as they are.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
take | listing_id, nurse_id | swap id "sw-1", "sw-2", ... or {"errors": [...]} | same as preview_take |
trade | listing_id, nurse_id, offered_shift_id | same | same as preview_trade |
approve | swap_id, approver_id | "ok" or {"errors": [...]} | swap not found, swap not pending, nurse not found, not a charge nurse, approver is a participant |
reject | swap_id, approver_id | "ok" | same as approve |
swap_status | swap_id | {"status": ..., "errors": [...]} | swap not found |
Rules
takeandtradevalidate exactly like the previews. If there are violations, return{"errors": [...]}and create nothing (no swap id is used). Otherwise the swap is filed aspendingwith the next id (sw-1,sw-2, ... one counter shared by every kind of swap).- Pending swaps reserve nothing. Shifts stay with their owners, the listing stays open, and validation always uses the real current schedule. Any number of pending swaps can involve the same shift or answer the same listing.
approve: the approver must be a nurse withcharge = truewho is not the giver or the receiver of any move in the swap. Then the swap is validated again against the schedule as it is now (other swaps may have been approved since it was proposed):
- if there are violations, the swap becomes
voidwith those messages as itserrors, nothing moves, andapprovereturns{"errors": [...]}; - otherwise every shift in the swap moves to its receiver, the swap becomes
approved, andapprovereturns"ok".
- When a swap is approved, (a) the open listing of every shift that moved is closed (answering it gives
listing closed; the shift's new owner maypostit again) and (b) every other pending swap that involves any of the moved shifts becomesvoidwith the single errorshift {id} was reassigned by {swap_id}, where{id}is the lowest shift id (plain string order) that the two swaps share. Pending swaps that share no shift are not touched, but rule 3 will re-validate them when they are approved. rejectruns the same checks asapproveand then marks the swaprejected. Nothing moves and the listing stays open.swap_statusreturns the status (pending,approved,rejectedorvoid);errorsis empty except for avoidswap, where it holds the messages from rule 3 or 4.- A queue can hold thousands of pending swaps, and nurses' histories can span years. Approvals and proposals must stay fast on long rosters.
Example (after add_nurse for ana, ben, cy with ["ACLS","TRIAGE"] and kim with the same qualifications and charge = true; s1 is an ER shift of Ana's on 2025-03-03 07:00-19:00)
[["post", "ana", "s1"],
["take", "L-1", "ben"],
["take", "L-1", "cy"],
["approve", "sw-1", "kim"],
["swap_status", "sw-2"],
["take", "L-1", "cy"],
["roster", "ben"]]returns
["L-1", "sw-1", "sw-2", "ok",
{"status": "void", "errors": ["shift s1 was reassigned by sw-1"]},
"error: listing closed", ["s1"]]Part 3: Three-way swap cycles (INC-2388)
Goal: three nurses who want to rotate shifts (Ana's goes to Ben, Ben's to Cy, Cy's to Ana) can't do it with giveaways and trades, so RotaDesk gets a cycle operation. The cycle is proposed as one unit and is all-or-nothing: it is valid only if the schedule *after every shift has moved* is valid for everyone.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
cycle | legs | swap id or {"errors": [...]} | invalid cycle, nurse not found, shift not found, not owner |
Rules
legsis a list of[nurse_id, shift_id]pairs. Each listed nurse gives the shift in her pair to the next nurse in the list, and the last nurse gives hers to the first. So[["ana","s1"],["ben","s2"],["cy","s3"]]sendss1to Ben,s2to Cy ands3to Ana.invalid cycleiflegsis not a list of 3 to 6 pairs of non-empty strings, or a nurse or a shift appears twice. After that,nurse not found(first missing nurse in list order), thenshift not found(first in list order), thennot owner(first leg whose nurse doesn't own her shift). A wrong number of top-level arguments isinvalid arguments.- Shifts need not be posted, and listings are not used.
- Validation uses the Part 1 rules with all moves applied at once: for every nurse, the shift she gives away is gone and the shift she receives is in. Do not check legs one after another against the current schedule: a nurse may receive a shift that overlaps the one she is giving up. Violations are reported by receiving nurse in the order of
legs(the first nurse receives the last leg's shift), each nurse's messages in the Part 1 order. If there are any, return{"errors": [...]}and create nothing. - Otherwise the cycle is filed as a pending swap (shared
sw-Ncounter) and from there on behaves like any other swap: Part 2's approval, re-validation, closing of listings, voiding of other swaps andswap_statusapply unchanged. The approver may not be any of the cycle's nurses. On approval all shifts move together.
Example (Ana, Ben and Cy all work 07:00-19:00 that Monday in the ER; kim is a charge nurse)
[["cycle", [["ana", "s1"], ["ben", "s2"], ["cy", "s3"]]],
["approve", "sw-1", "kim"],
["roster", "ana"], ["roster", "ben"], ["roster", "cy"]]returns ["sw-1", "ok", ["s3"], ["s1"], ["s2"]]. Each nurse's new shift overlaps the one she gives up, which is fine because that one is gone.
Example 1
- Input:
- [["add_nurse","ana",["ACLS","TRIAGE"],false],["add_nurse","ben",["ACLS"],false],["add_shift","s1","ER","2025-03-03 07:00","2025-03-03 19:00","ana"],["add_shift","s2","ER","2025-03-04 04:59","2025-03-04 13:00","ben"],["post","ana","s1"],["preview_take","L-1","ben"]]
- Output:
- [true,true,true,true,"L-1",{"errors":["ben: missing qualification TRIAGE for s1","ben: only 9:59 rest between s1 and s2"]}]
- Explanation
- Ben lacks TRIAGE and his own shift starts 9 h 59 min after the one he would take ends. Qualification errors come before rest errors.
Example 2
- Input:
- [...nurses ana, ben, cy and charge nurse kim, shift s1 owned by ana..., ["post","ana","s1"],["take","L-1","ben"],["take","L-1","cy"],["approve","sw-1","kim"],["swap_status","sw-2"],["take","L-1","cy"],["roster","ben"]]
- Output:
- ["L-1","sw-1","sw-2","ok",{"status":"void","errors":["shift s1 was reassigned by sw-1"]},"error: listing closed",["s1"]]
- Explanation
- Both takes are accepted because pending swaps reserve nothing. Approving the first moves s1 to Ben, voids the second swap and closes the listing.
Example 3
- Input:
- [...ana, ben, cy, kim; s1, s2, s3 all 07:00-19:00 on the same Monday, one each..., ["cycle",[["ana","s1"],["ben","s2"],["cy","s3"]]],["approve","sw-1","kim"],["roster","ana"],["roster","ben"],["roster","cy"]]
- Output:
- ["sw-1","ok",["s3"],["s1"],["s2"]]
- Explanation
- Ana's shift goes to Ben, Ben's to Cy, Cy's to Ana. Each overlaps the shift the nurse gives up, which is fine because the cycle is validated on the schedule after all moves.
Guidelines
- Python 3 standard library only
- All times are integer minutes internally; there is no time zone or daylight-saving handling
- Every operation returns exactly one value; lookup and permission failures are "error: <code>" strings, rule violations are {"errors": [...]} objects; nothing is raised
- Part 2 and 3 inputs build rosters with thousands of shifts per nurse from a few add_pattern operations