The Pool Rota That Fills Itself. A staff shift app built in six days.
How a six-day build replaced a swimming pool’s paper rota and group-chat chaos with a pocket app where teachers and lifeguards pick their own shifts — and the software refuses to let the dangerous mistakes happen.
- Published
- 10 June 2026
- Build time
- Six days
- Runs on
- One Docker container, $6 a month

A busy public swimming pool runs on half-hour blocks. Every block needs a lifeguard on poolside — that’s a safety requirement, not a preference. On top of that, the swim school runs classes all evening: ten levels plus Parents & Toddlers, each with its own staffing rule, taught by part-timers who often hold more than one qualification and whose lifeguard certificates expire.
Until recently all of that lived where it lives at thousands of pools: a printed grid in the staff room, a group chat full of “can anyone take Tuesday 6pm?”, and one coordinator holding the whole thing in their head. This is the story of the small, deliberately boring app that replaced it — 75 commits across six days, three runtime dependencies, audited end-to-end five times, and now live in production for real staff.
- 6 daysidea → production
- 75commits · 54 on the busiest day
- $6/mototal hosting cost
The job: who’s on poolside at 6:30?
Strip the problem to its bones and a rota is a grid of promises: this person, this half hour, this role. Everything that makes it hard is what happens to that grid under pressure. Two people promise the same slot. Someone promises a slot they’re not qualified for. A lifeguard’s certificate quietly lapses in week three. A class gets cancelled and its promises linger like ghosts. The coordinator is the only error-checker, and the coordinator is human.
That last clause did most of the work. The interesting engineering in this project isn’t a clever algorithm — it’s a long list of small refusals: an approve button that disables itself when a clash exists, a booking that returns “that shift was just taken” instead of a polite lie, a schedule editor that counts the orphans before it lets you create them.
A timetable in, a rota out
The app’s one organising idea: the weekly class timetable is data, and the rota is generated from it. An admin describes the schedule once — “Level 5 runs Monday and Wednesday at 17:00, one teacher” — and the system materialises every individual, bookable person-shift 26 weeks into the future, rolling the horizon forward every day on its own. Lifeguard cover isn’t even described per class: a lifeguard shift simply exists for every half hour the pool is open.
Each generated shift is then a tiny state machine that staff and admins push around. The week timetable view paints every block with its state, so a glance tells you where the gaps are:
Requests flow one way and approvals flow back: staff tap, admins confirm. Before any shift changes hands the server re-checks the two rules that matter — is this person qualified for this role, and are they already booked at this time — and the second rule is enforced in a way that survives two people tapping at the same instant. More on that in Part seven, because it turned out to be the hardest thing in the whole build.
Three taps to a shift
Staff log in and land on their week: a compact seven-day grid of their own shifts — green when approved, amber while pending, the buoy for lifeguard duty, L5-style codes for classes — plus a live count of what’s open to grab. If their lifeguard training is within thirty days of expiring, a banner sits at the top of the page where it cannot be ignored. The data behind that banner has teeth, too: training status is computed server-side and surfaces in the admin’s compliance report.


Tap an open block and you’ve requested it — the qualification and clash checks run before the server says yes, and the admins are notified instantly. A day view presents the same shifts as tappable cards for people who think in lists rather than grids. And the profile page closes the compliance loop: staff record their own training expiry date, see their qualified roles, and manage their password.
The admin cockpit
Admins get everything staff get, plus four tabs: Approvals · Reports · Manage · Rota. Their home page leads with the thing that needs attention — the size of the approvals backlog — and one tap lands them in the queue.


Three details in this queue earn their keep. If a request now clashes with a shift the person has since been approved for, the row says so inline and the approve button is disabled — the bad state is unrepresentable. Declines carry a reason, delivered to the person as a direct message rather than a mystery. And contested slots show who else has requested them, so the admin is choosing, not guessing.
The rota builder — the spreadsheet, retired
For the manager who’d rather place people than wait for requests, the Rota tab is the power tool: pick a person, tick any number of cells across the week — across roles, with an “All roles” view that colour-badges every cell — and assign them all in one shot. Anything that can’t be assigned comes back as a detailed skip report: already taken, not qualified, clashes with their 17:00 — with times. Never a silent partial success.

The schedule editor — where the rota comes from
Manage → Classes is the single source of truth everything generates from: every class, its staffing rule, its weekly sessions with − / + steppers. And because six months of future shifts already exist, every edit here is a small migration problem — handled honestly. Remove a session and the editor counts the already-generated shifts that no longer match, tells you how many have real people booked on them, and offers the humane default: keep the booked ones as stand-alone one-offs, clear the rest, notify everyone affected. Automatically.

Reports that answer the actual questions
Outstanding lists every unfilled shift for the next month, with CSV export for the committee. Coverage compresses each day into a single bar and a percentage — the five-second health check. Training is the compliance view: every lifeguard, sorted so expired and expiring float to the top. And an Activity log records every action in the system — who approved what, who deleted what, when.
The group chat moved in
Staff were always going to coordinate by chat — the only question was whether the chat would live next to the rota or against it. So it moved in: an All Staff channel everyone belongs to, role-linked channels (add someone to the Lifeguard role and they’re in the Lifeguards channel automatically, remove the role and they’re out), and a private Message Admins line for every member of staff. Delivery is real-time, with unread badges on the tab bar.
The quiet win is that the conversation and the audit trail became the same thing. Declined requests arrive with their reasons. Removed shifts arrive with their reasons. When a schedule change cancels someone’s bookings, the apology arrives itemised — date by date — in the same thread they’d use to ask about it. Deleted channels are archived with their history, not erased.
The machine underneath — deliberately boring
The entire backend is FastAPI and SQLite: about 2,600 lines of Python in five files, with passwords hashed and session tokens signed using only the standard library. The frontend is a single vanilla-JavaScript file, one stylesheet, and a 48-line service worker. No React, no bundler, no node_modules, no build step. The complete dependency list fits on three lines:
fastapi # the APIuvicorn # the servertzdata # so "today" means today in the UK, not in UTCIt’s still a real app on the phone, because it’s a progressive web app: installable to the home screen with its own icon, app shell cached so it opens instantly and survives flaky leisure-centre wifi — while rota data itself always comes fresh from the network. No app store, no review queue. Shipping an update is bumping one version string; the count of those bumps is a brutally honest release counter, and it currently reads 62.
- ~6,900lines of code, total
- 55API endpoints
- 62front-end releases in six days
Two thumbs, one shift
Here is the hardest problem in the build, and it looks like nothing at all. The app’s core promise is who holds which shift — and the moment it’s most useful is the moment the new rota opens and everyone grabs at once. Which is exactly when naive code corrupts it.
Saturday 9:00, two phones
The original booking code did what almost every web app does — and it’s wrong.
Emma and James both tap the same open lifeguard shift within a few milliseconds. The server, handling both requests in parallel, runs the same three steps for each: read the shift — check it’s open — write your name on it. Both reads happen before either write. So both checks pass. Both writes succeed, the second silently overwriting the first. Both phones say “Requested ✓”. Only James actually holds the shift; Emma finds out at the poolside, or never.
The fix is two disciplines, applied to every state change in the app:
BEGIN IMMEDIATE -- claim the database's write lock up front, -- so concurrent bookings queue instead of racingUPDATE slots SET assigned_user = emma, status = 'requested'WHERE id = 4117 AND status = 'open' -- only if still open→ 0 rows changed? Tell the truth: "That shift was just taken."Lose the race now and you get an honest message instead of a false success. The same pattern guards request, approve, assign, bulk-assign, delete, restore, release and decline — every door to the rota, not just the front one.
And it isn’t taken on faith. The test suite boots a real server and fires genuinely simultaneous requests — threads released by a starting gun — at one slot: six users must produce exactly one success and five polite refusals. A person double-tapping two overlapping slots must end up holding exactly one. Those tests failed against the original code, and they’ve pinned the fix down ever since.
Audited five times in a week
Build fast and trust the result — the only way to have both is to re-read everything with hostile eyes, repeatedly. The app got five full audit passes during the six days, each one a fresh read of the entire codebase, each finding fixed (or explicitly accepted, in writing) before moving on. The trajectory is the point:
| Pass | Headline findings | Outcome |
|---|---|---|
| 1–2 | The booking races of Part seven; no login rate-limiting; default admin credentials shipping; timezone drift; bad input returning server errors; zero tests | all fixed · 7 tests |
| 3 | Deleted shifts still counted in coverage — and could still be claimed; schedule edits stranding orphaned future shifts; proxy and origin-exposure issues on the production deployment | all fixed · 8 tests |
| 4 | Deactivating a staff member left their future shifts as phantom cover; the slot-generation horizon could stall; five smaller validation gaps | all fixed · 13 tests |
| 5 | One unescaped tooltip (a stored-XSS sliver — the only miss among 134 escaped outputs); the last two unguarded admin races; a channel-membership leak; retired roles still generating shifts | all fixed · 16 tests |
What five passes buy, concretely: the first audit found “two people can both be told they own the same shift.” The fifth found “a tooltip doesn’t escape quote marks.” That gradient — from integrity-breaking to edge-case — is what done looks like for an app this size.
- 5full audit passes
- 38findings raised & closed
- 16tests, incl. true-parallel races
Six days, day by day
- Day 1 — Thu 4 JuneThe whole skeleton in one commit. FastAPI + SQLite backend with stdlib auth, the shift model, weekly templates, demo data — and a working PWA: login, calendar, request-and-approve, icons, offline shell.
- Day 2 — Fri 5 JuneRethink and messaging. Per-lane modelling dropped for the cleaner class-schedule system; home page redesigned; the full chat layer lands — channels, DMs, unread badges.
- Day 3 — Sat 6 JunePolish. The compact “my week” grid that became the home page’s signature.
- Day 5 — Mon 8 JuneThe monster: 54 commits. Admin overhaul; the rota builder with bulk-assign and skip reports; clash warnings; the activity log; the All-Staff channel; audits one and two with the concurrency fixes; production hardening — rate limiting, forced password change, security headers; the rebrand to a venue-neutral Staff Pool Rota; and the production launch behind Cloudflare, that evening.
- Day 6 — Tue 9 JuneThe living schedule. Delete-shifts mode with soft-delete and restore; the schedule editor with steppers; orphan reconciliation with “keep booked shifts”; widescreen and print rota; audits three and four. Thirteen tests green.
- Day 7 — Wed 10 JuneAudit five — this post. The tooltip XSS, the last races, the membership leak, retired-role generation: found, fixed, tested. Sixteen green.
Running it for real
Production is one Docker container behind Caddy on a $6-a-month droplet, fronted by Cloudflare on strict TLS with an origin certificate. The proxy adds the security-header set; login rate-limiting reads the real client address through Cloudflare so it can’t be spoofed around; the entire database is one SQLite file, which makes the backup strategy copy one file. A fresh install is safe by default — demo credentials hidden, seeded admins forced to change their password on first login. And deploying a change is one line:
ssh droplet "cd swim-rota && git pull && docker compose build && docker compose up -d"One honest limitation, accepted in writing: the real-time chat fan-out and the rate limiter live in process memory, so the app runs a single worker — a deliberate ceiling that one pool’s traffic doesn’t begin to approach. The multi-pool version (Postgres, a shared message broker) is documented but intentionally unbuilt. Knowing where your ceiling is beats pretending you don’t have one.
What we’d want you to take away
Next on the list, in order: push reminders (“you’re on poolside in two hours” — and “your training expires in 30 days” before it becomes a staffing problem); staff-to-staff shift swaps with admin sign-off; and class registers, which the schedule already knows enough to generate.
But the real takeaway is about proportion. The glamour in this project is the phone app — the week grid, the colour language, the chat. The value turned out to be in the refusals: the conditional write that tells the truth under a race, the approve button that disables itself, the schedule editor that counts orphans before creating them, the audit that re-reads everything a fifth time and finds one unescaped tooltip. Rotas are promises. Software that keeps promises is mostly software that refuses, politely, to break them.
Built with
- FastAPI
- SQLite
- Vanilla JavaScript
- Caddy
- Docker
- Cloudflare
Built with FastAPI, SQLite and vanilla JavaScript; Caddy and Docker in production behind Cloudflare. All screenshots are the live app populated with demonstration data; names shown are demo accounts. The full audit reports — all five passes, findings and fixes — live alongside this post in the project repository.
Have a project in mind? We would like to hear about it.
- Book a callTwenty minutes on a video call, at a time that suits youBook a call
- Phone07515 730686
- Emailhello@clifton-media.co.uk
- BasedMatlock, Derbyshire






