← Back to portfolio
BookHere Live at bookhere.site

BookHere

A booking platform for small service businesses, built end to end by one person. This is how it behaves under pressure, and why each rule has the shape it has.

What it is

A shop gets its own booking page. A customer picks a service, a day, and a time on a live calendar, fills a short form, and books. The shop runs the day from a studio, builds new client pages in five minutes from an office, and the same product ships as native apps for iPhone, iPad, Mac, and Android. There are two ways in: a business asks and I build the page for them, or a solo owner signs themselves up and goes live instantly.

Four surfaces, one spine: the customer page, the studio, the office, and the apps. A request enters through one Cloudflare Worker, and every rule that matters lives in the database, not the page. That single decision is the reason the rest of this holds together.

Counted, on 12 September 2026

27
database tables
138
database functions
125
migrations, applied in order
226
commits, first ten days
~70k
lines of code, solo
4
platforms shipped

The lines split into 21,555 of web, 12,390 of SQL, 32,967 of Swift, and 3,213 in the one Worker file. These are counts of the thing, not achievements. What they buy you is the next few sections.

Correctness under concurrency

Two people press the same 11:00

In the same instant, two customers try to book the same slot. A check-then-insert cannot hold that rule, because when each transaction looks, neither has committed, so both see the slot free and both write. The guarantee has to live in the database as a constraint, not in a function that looks first.

What is done: bh_no_double_booking is a GiST exclusion constraint over tstzrange(starts_at, ends_at, '[)'), which needs btree_gist because the artist id is a uuid. The database itself refuses the second overlapping write. bh_create_booking catches exactly that constraint name and re-raises anything else, so a clash that is not a booking clash is never dressed up as one.

The name of a service belongs to the booking

session_type_id was ON DELETE SET NULL, so deleting a service blanked the "what" on every booking ever made with it. It had already happened four times before it was caught.

What is done: a type_name column on the booking, a BEFORE DELETE trigger that back-fills it, and coalesce(t.name, b.type_name, 'Appointment') on every read. The argument is not technical: what was agreed is what was agreed, and deleting today's price list should not rewrite last month's history.

Security posture

The pages talk to Postgres directly from the browser with a publishable key printed in the source. That is normal for this stack, and it means the security cannot live in the page. It lives in the database.

Measurement, not assumption

This is the part I care about most. A protection you have not measured is a guess wearing a badge.

The rate limits that counted nothing

Every limiter lived in the Worker's own memory, which is per instance, so nothing ever accumulated. It was not argued about, it was measured: thirty wrong six-digit codes in a row at the office door came back thirty times as "Wrong code" and never once as "wait"; forty bookings in a burst got zero refusals. Every counter then moved into a database table, keyed per address so nobody else knocking can lock the owner out, and it was re-measured: ten bookings through, the eleventh refused.

And a refusal must not count itself. Every limiter had pushed the call into the window before deciding whether to allow it, so a refused call extended its own ban. Reload to check whether you are still blocked, and you have just made sure you are.

The clock that was losing its own evidence

52 of 78 reminder sweeps in six hours came back as timeouts on pg_net's five-second default. The work had finished each time, because the Worker runs to the end whether or not anybody is listening. What was lost was the proof the letters went out, and a sweep that had genuinely failed would have looked identical. Raised to 30 seconds, still inside the five-minute tick so a slow sweep cannot overlap the one behind it.

534 QR codes decoded, not looked at

The encoder was verified by decoding every code with an independently written reader, not by eye. It found three bugs, each of which produces a code that looks perfect and scans on nothing: the 15 format bits written least-significant-first, the second format copy starting one bit late, and missing alignment squares crossing the timing line, which breaks every version 7 and above. You cannot see any of these; you can only decode them.

Judgment calls

The database checks for truth; the browser checks for kindness. Every rule that matters is in Postgres. A check written in JavaScript is a courtesy to honest people; when the two disagree, the database wins.

A read is retried once, a write never. The retried reads come from a named list that can be checked, not guessed from a function name, because asking twice for a booking is how somebody ends up in the diary twice.

One clock per booking page, and it belongs to the shop. Showing each reader their own time zone sounds friendlier and is a trap: the page cannot know which zone the reader assumed, and the one screen whose job is to say when to turn up must not be ambiguous.

No calendar sync, on purpose. A two-way sync between two calendars that can both be edited is a source of double bookings, and a double booking is the one failure a booking product cannot have.

A client type is a row, not four edits. The differences between a tattoo shop and a teacher were once written into code in four places; forgetting one meant a page live and priced wrong. Now it is one insert. The rule that follows: if a new kind of client needs a code change, the table is missing a column, and the column is the fix.

Hard problems, from first principles

A QR encoder written from the standard

No library and no third-party service, so no client's address is handed to somebody else's analytics: byte mode, versions 1 to 10, Reed–Solomon over 0x11D, all eight masks scored against the standard's four penalty rules, then verified by the independent decoder above.

Where it honestly stands

BookHere is a live production system with real pages and real shops: 14 client pages, shops signed up, real bookings, and live billing. That is the true state, and I would rather write down what is real than round it up.

Read the faults and the open questions → Sanitized public repo: in progress
← Back to portfolio