Seatly
unfinished · jun–jul 2026Restaurant reservations and table management. An operator describes their tables in a short setup wizard, and Seatly works out what can be booked, picks a table for every booking, and shows the day on a dashboard. Diners book through a hosted page. I built most of it in one week of June 2026, made one last commit in mid-July, and stopped before the clean-up pass.
I wanted availability worked out from the room rather than typed in as a number per slot: the tables, which ones can be pushed together, how long a party of each size usually stays. It is a solo project, planned in six phases, from foundation to a marketing site.
It is unfinished. Onboarding, the booking engine, the diner page and the dashboard all work locally. The roadmap marks the last two phases, guest profiles with automated reminders and the polish pass, as shipped, and the code for them is there, but I did not run the email or SMS paths for this page. The native app was not run either.
Each row is a group of identical tables: seats, how many, which section, and whether they can be joined or booked online. From that list the calculator works out five numbers, where a cover means one diner. The demo cafe has 22 tables and 54 chairs.
The 0.7 is a utilisation factor, because no room fills every seat on every turn. Covers per service is a planning number and is never stored. The pacing cap spreads those covers over the 15-minute slots in a service, so no 15-minute slot starts more than six covers. The largest party is the bigger of the biggest single table and all the joinable seats in one section: here, six joinable 2-seat tables in Main make 12.
The live summary on this step only shows tables and chairs. The suggested pacing cap and the largest party appear three steps later, on the rules form. Covers per service is never shown.
A booking request goes to the FastAPI service. It locks the venue's tables, re-checks the slot and the pacing cap under the lock, picks a table, and writes the booking. Each table's booked time is stored as a Postgres time range, and an exclusion constraint refuses any two ranges that overlap on the same table.
The demo bookings were placed by that engine through its own Python functions, not inserted by hand, so the table choices below are the engine's.
The repo has a load harness that runs the real booking code against a local database. On my run, 120 booking attempts, 64 at a time, asked one 8-table venue for the same slot: 8 got a table, 112 were told the slot was full, and no table was double-booked. With the lock bypassed on purpose, Postgres rejected the second overlapping booking on its own. The harness also checks that one busy venue does not block or fail bookings at the others.
view data
| booking | no other load | busy venue next door |
|---|---|---|
| median | 14 ms | 40 ms |
| second-slowest of 20 | 29 ms | 50 ms |
The Today view is worked out when the page loads, from the bookings and the table list: covers, who is seated, table occupancy, covers starting in each slot against the cap, and a list of things to watch, like an allergy note or a guest with a past no-show.
- The README still says Phase 1 and Auth.js. The code went well past that, and sign-in moved to Better Auth.
- The floor-area mode in the capacity doc, which estimates seats from square metres, was never built. The README says it was.
- The help chatbot planned for the dashboard does not exist.
- The pacing suggestion always assumes a 270-minute dinner service with 105-minute turns, even after a cafe has entered its real hours on the step before.
- The venue step creates the rules row with a pacing cap of 0, so the rules form shows 0 (no limit) instead of the suggested 6. The screenshot above shows it.
- Dates on the bookings page are formatted with the browser's locale on one side and the server's on the other, which React reports as a hydration mismatch.
- There are no tests for the TypeScript side. The 71 tests cover the Python service only.
Before any new feature, the TypeScript side needs tests of its own. A single test on the rules form would have caught the pacing default of 0.




