A week you can publish, checked before you send it.
List the people who work and the shifts a normal week needs. Shiftwright fills what it can, then checks the finished week against the rest and hours rules — including the statutory ones — and tells you what is stopping it.
When a shift cannot be covered it does not say "coverage gap". It says which people could have taken it and the exact rule that rules each of them out:
Nobody else can cover Open: 1 more needed. Ana: already on Close · Ben: already on Close · Fay: would reach 32h against their own limit of 24h
Everything runs on your machine. The rota stays in the browser.
A twenty-person rota takes three to five hours a week to build, and the usual tools are a spreadsheet or a per-head monthly subscription. Neither checks the rules that carry legal weight:
- A spreadsheet will not warn you when two shifts overlap, when booked leave clashes with a shift, or when someone is scheduled without the rest period the Working Time Regulations require.
- Dedicated rota products detect double-bookings, coverage gaps and overtime cost. Their feature lists do not mention rest periods at all.
So the constraint most likely to cost you is the one nobody checks.
Rules ship as defaults you can see and change. Press What rules does it check? in the app to read them.
| Rule | Adults | Under 18 |
|---|---|---|
| Rest between working days | 11 hours | 12 hours |
| Unbroken rest in the week | 24 hours | 48 hours |
| Break during a shift | 20 min after 6 h | 30 min after 4.5 h |
| Weekly hours | 48 | 40 |
Source: the Working Time Regulations as summarised by gov.uk, checked 10 September 2026.
It also checks what you declare: availability windows, per-person hour limits, skills a shift requires, and how many people each shift needs.
Shiftwright checks the rules you declare. It does not certify anyone as compliant, and it is not legal advice. A personal hour limit may be stricter than the statutory one; it is never allowed to be looser.
The part that fills the week and the part that judges it are separate, and the judge never trusts the filler. A rota is handed to the verifier as a finished thing — auto-filled, hand-edited, or a mixture — and judged by the same rules either way. A manager's override is held to exactly the standard the solver is.
Two kinds of finding, kept apart:
- A breach is a rule broken by an assignment that exists. It blocks publishing, and it is not a warning you can click past.
- An uncovered shift is a slot with nobody in it. It also blocks publishing — and the useful part is the per-person reason, not the gap.
The solver itself is a plain greedy pass: hardest shift first, least-loaded eligible person. It does not backtrack. It does not need to, because being wrong is always detected.
- It will not tell anyone their shifts. No app, no notifications, no staff logins. The rota comes out as a file you send. That is a real hole and it is deliberate: messaging people is a service, and this runs on one machine.
- It will not publish a week with a broken rule. You can export a draft, and the file is named so it cannot be mistaken for a checked one.
- It will not score a rota. It is legal or it is not.
- No cost or payroll, no holiday accrual, no clock-in, no multi-site.
- One week at a time, starting on a Monday. Shifts crossing midnight need the next day's date on the end time.
Python 3.11. Nothing else — no dependencies at all.
python run.pyThen open http://127.0.0.1:3290/. Press Load an example week to see a seven-person café rota fill and verify.
- Add your staff. Tick under 18 where it applies; give people their skills and any personal hour limit.
- Add the shifts a normal week needs — day, times, how many people, and any skill required.
- Press Fill the week.
- Click any cell to see who could cover it and why the others cannot. Put someone on or take them off; the whole week re-checks immediately.
- When it reads Ready to publish, export the CSV.
Keyboard: Tab moves through the grid, Enter or Space selects a cell,
Delete clears it.
config.json (copy config.example.json if you do not have one):
{
"host": "127.0.0.1",
"port": 3290,
"max_staff": 200,
"max_shifts": 500,
"max_input_chars": 500000
}SHIFTWRIGHT_HOST and SHIFTWRIGHT_PORT override the file. A week beyond a
limit is refused with the limit named, never silently truncated.
There are no secrets in this file and none anywhere else: Shiftwright has no account, no API key and no outbound request.
- The rota lives in your browser and is sent only to the program on your own machine.
- No outbound request is made by any part of Shiftwright. An acceptance test parses every module to confirm no network client is imported.
- Nothing is written to disk. Another test confirms no file-writing call exists in the package.
- The server keeps nothing between requests — there is no session, no database, no history. Close the tab and the week is gone.
- No staff name reaches the log; the server logs nothing at all.
CSV with CRLF line endings:
Day,Date,Shift,Start,End,Hours,Needs,Skill,Assigned
Monday,2026-09-14,Open,07:00,15:00,8,2,,Ana; Ben
Monday,2026-09-14,Close,14:00,22:00,8,2,keyholder,Cy; Dee
A week exported before it verifies is named rota-draft.csv; a checked one is
rota-verified.csv.
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
The page |
GET |
/api/health |
{"status": "ok"} |
GET |
/api/rules |
The rule defaults and where they come from |
POST |
/api/check |
{"week": …} → the rota and its report |
POST |
/api/fill |
{"week": …, "keep_existing": true} |
POST |
/api/publish |
{"week": …} → CSV, or 409 with the reason |
The week travels with every request; the server holds nothing. A week with
problems still answers 200 — it is a result, not an error. 409 is only a
refused publish.
python -m pytest75 tests: unit tests for the rules, the week builder and the rest arithmetic; a slice test for the central claim, which fails if an uncovered shift ever stops naming its reasons; integration tests for the seams between configuration, server, API, solver and verifier; and acceptance tests, one per MVP acceptance criterion, through the real HTTP server.
The suite is mutation-checked: disabling the daily-rest check, letting a breach publish, dropping the per-candidate reasons, and ignoring weekly rest each make it fail.
One property is asserted across six staffing shapes: the filler may leave a shift uncovered, but may never produce a week the verifier rejects. It used to — weekly rest was checked per assignment but not prospectively, so someone could be spread across all seven days with every individual assignment legal and the finished week not. Playwright found it on a seven-person example that the eight-person test fixture had enough slack to hide.
Measured end to end through the HTTP API:
| Week | Slots | Fill | Re-check after an edit |
|---|---|---|---|
| 8 staff, 14 shifts | 28 | 5 ms | 1 ms |
| 20 staff, 28 shifts | 56 | 10 ms | 2 ms |
| 40 staff, 42 shifts | 84 | 30 ms | 2 ms |
| 80 staff, 56 shifts | 168 | 117 ms | 3 ms |
All covered, no breaches, at every size.
MIT. See LICENSE.
