Book an ANU room

A working slice of ANU room booking, built for the one person who deals with it most and is served worst by it: a society events director. They do not want a timetabling system. They want three things, quickly — which venues they can ask for, what is already taken, and a straight answer when the room they wanted is gone.

So the app is one page. A form that books a room, a board of what is booked grouped by room and then by date, and a refusal that appears inline the moment a booking clashes. Six real ANU venues are seeded — Marie Reay, Manning Clark, the Haydon-Allen Tank, Hanna Neumann, Copland — because the rooms are ANU’s, not the app’s: nothing here creates, renames or removes one. Capacities are indicative. They are there so “will this fit?” is a question you can answer before you ask for the room, not a number to quote at anybody.

Everything persists in SQLite on a Fly volume, so a booking survives a reload, a machine restart, and a redeploy.

The one rule

A room cannot be double-booked. A booking that overlaps an existing one for the same room on the same date is refused, and the refusal names what it hit: which room, when it is taken, which society has it, and what for.

That matters more than it sounds. “That time is unavailable” is a refusal, not a message — the reader still has to go and find out what they ran into, and in the real system that means an email and a two-day wait. Naming the clash is the difference between being blocked and being told.

The rule lives on the server, not in the form. A booking is the half-open interval [start, end), so 14:00–16:00 and 16:00–17:00 are back to back rather than a clash — a rule that refused that would be unusable for exactly the room everyone wants. The check and the insert run in one transaction, because two requests that each read “nothing booked” and then each write would be individually correct and jointly a double booking.

Dates and times are stored as ISO 8601 strings, and that is load-bearing rather than tidy: zero-padded, they sort lexicographically in the same order they run chronologically, so the overlap test is one WHERE clause with no parsing in it.

What good looks like here

Good here is a refusal you can act on, and a claim you can check. Those two ideas decide everything else.

Some of it is enforced, and the checks are in spec/:

Some of it is judgement, and no check will catch it:

What I chose not to build

Running it

pnpm install
pnpm dev          # http://localhost:4321/ — no base path
pnpm check        # typecheck, lint, then the spec against the built server
pnpm check:viewports   # needs Chrome; not in `check` on purpose

The spec tests boot the built server with a throwaway database, so they assert what actually ships rather than what the dev server does.