ANNÉ
A trilingual booking platform for an independent hairdresser — where the hardest problems weren't bugs, they were wrong defaults deciding who chases whom.
The owner stopped re-checking every booking by hand; the calendar only offers time she actually has.
- Role
- Solo
- Timeline
- 2026
The business problem
ANNÉ is a booking platform for an independent hairdresser — trilingual (Ukrainian, Russian, English), around 6,000 lines, and in production. The interesting part isn't the size. It's that the hardest problems here weren't bugs at all — they were wrong defaults. Each one quietly decided who was chasing whom: the system keeping up with the owner, or the owner cleaning up after the system.
The constraints
- In production, against a livelihood. The calendar is the business. A wrong slot is either a client turned away at the door or an empty chair — the owner feels both.
- Solo, end to end — booking, admin, email, deploy.
- Trilingual from day one (UA/RU/EN), which made localization a real surface, not an afterthought.
What I built and why
A booking site where clients reserve and cancel, and an admin where the owner runs her actual week — opening and closing shifts, adding walk-ins by hand, seeing a client's history. Firestore for data, Resend for transactional email. The through-line across the whole build was correcting defaults so the software stops promising things on the owner's behalf.
Technical decisions, with the cost of each
Decision 1 — availability defaults to "closed", not "open".
The first version computed availability from a weekly template applied forward forever. The default was "open unless the owner blocks it" — so any day she didn't think to close, the site sold on her behalf. She asked me to take the site down: "it shows free slots, people call anyway, and I check everything by hand." I inverted the model — availability is assembled from openDays/{date} documents: no document, no day. The template became a button ("fill a normal week"), and the booking horizon is four weeks. The migration unrolled the next two weeks from the old template and preserved every time that already had a booking, so no live reservation vanished.
The cost: a default in the data schema is a product decision, not a technical detail. This one decided whether the system kept up with the owner, or the owner kept up with the system.
Decision 2 — the slot's own key is the lock.
Two requests could book the same slot: "is it free?" and "write it" were separate operations, and a second request fit between them — a classic read-modify-write with no atomicity. Instead of a transaction, I made each booking document's id {date}_{time} and write it with create(), which fails if the document already exists. Uniqueness comes from the key itself; the second concurrent request gets a 409.
The cost: manual entries the owner adds use an auto-generated id — so she can deliberately put two people in one slot when real life calls for it, while the public site still can't. The invariant lives where it matters and bends where a human needs it to.
Decision 3 — destructive scripts are safe by default.
Setting up a dev environment, npm run seed ran against production — the environment variable didn't propagate through npm — and overwrote the hand-tuned working hours. Firestore keeps no history, so I rebuilt the hours from indirect evidence: real bookings. If someone was booked Thursday at 10:30, that time was open. Everything a booking confirmed, I restored exactly. Then the script changed: it targets the dev project by default and refuses to touch anything else without an explicit --prod --project <id>, and development moved to a separate Firebase project entirely.
The cost: a destructive script must be safe by default, not rely on the operator remembering a flag. As long as safety depends on care, it's a matter of time, not discipline.
Decision 4 — the server/client boundary is a serialization boundary.
Adding one translation string returned a 500 on every admin page. The cause: a function had made it into the localization dictionary — a template for a WhatsApp message. The dictionary is passed from server components into client ones, and anything that crosses that boundary has to serialize. The fix was a plain string with a {service} placeholder, substituted at the call site — the same boundary I keep strict in Devotament, seen from the other side.
The cost: the server/client boundary is, before anything else, a serialization boundary — and the translation dictionary crosses it whole, so one non-serializable value takes down every page that reads it.
The result — honestly
What stayed in the system:
- Availability defaults to closed — the site never promises time the owner doesn't have.
- Slot uniqueness is guaranteed by the database key, not by application logic.
- Destructive scripts are safe by default, and production data is unreachable from a dev machine.
- The server/client boundary is treated as a serialization contract, not just a rendering one.
It's in production with real bookings flowing. I didn't wire up vanity metrics, so I won't quote any — the measure that mattered was the owner no longer re-checking the calendar by hand.