Case Study

Fade & Co. — multi-barber booking with a race-safe scheduler

A three-chair barbershop that takes online bookings, including natural-language requests. The non-trivial part isn't the form — it's making sure two people can never book the same chair at the same time.

Role
Solo — design, engineering, documentation
Stack
Next.js 15 · Supabase Postgres + btree_gist · Groq / Gemini · Resend · Vercel
Timeline
Built over 2 days
Try the live demoView source
Fade & Co. landing page with services, barbers, and hours
Three chairs, three barbers, five services. Book online in under a minute.

1. The problem

What a small barbershop booking flow actually needs is not just a form. It needs slot computation that respects each barber's hours, which services each barber offers, and one-off time-off — all in the shop's timezone, not the server's.

The hard part isn't the UI, it's correctness. Two people booking the same slot at the same instant is a race condition, and a naive “is this slot free?” check followed by an insert will silently lose it. Both requests read “free” before either writes. One customer shows up to a double-booked chair.

And “when is the shop open” is a question about the shop's timezone. The server runs on UTC, the shop runs on Asia/Kuala_Lumpur, and every conversion between the two is a place to be off by a day. I made timezone a first-class concept instead of scattering conversions through route handlers.

2. How it works

The customer picks a service and a barber. The frontend asks for slots; the server computes them from hours, existing bookings, and time-off with a pure function. The customer picks a time and posts the booking. The database itself enforces the race safety, and a confirmation email fires in the background.

Pick service + barber→GET /api/slots→computeAvailableSlots→Pick a time→POST /api/bookings→Exclusion constraint→201 or 409

One predicate runs everywhere — the algorithm, the constraint, and the time-off check all use half-open intervals:

// Same convention in the algorithm and the constraint:
// overlap  ⟺  aStart < bEnd && aEnd > bStart
// tstzrange(..., '[)')  ⟺  half-open, no off-by-one

3. Technical decisions

Race safety is a DB constraint, not an app check.

Every junior dev writes if (slot is free) { insert }. That's a check-then-act bug under concurrency. The correct primitive is an exclusion constraint — Postgres refuses the second insert at the storage layer, no matter how the requests interleave. The app catches error code 23P01 and returns a friendly 409.

constraint fade_appointments_no_overlap
  exclude using gist (
    barber_id with =,
    tstzrange(starts_at, ends_at, '[)') with &&
  ) where (status != 'cancelled')

Half-open intervals everywhere.

[start, end) in the constraint, in the availability algorithm, and in the time-off overlap check. The conventions have to match or you get phantom one-second overlaps — a 10:00 booking ending at 10:30 must not block a 10:30 start. Same predicate on every path.

The direct-POST bypass.

The slots route only returns what the algorithm considers valid. But nothing stopped a client from POSTing any time directly — a 3 AM slot, an off-grid :07 start, whatever. I only caught this in code review, after the route already worked. The fix is a shared server-side validator that re-checks the requested time against the barber's hours and the service mapping before insert. Client filters are UX; server validation is correctness.

waitUntil for the confirmation email.

The booking POST returns in ~300ms; the email fires after. On Vercel, a floating promise dies when the response is sent — the exact bug I'd already hit on an earlier project. waitUntil from @vercel/functions extends the function lifetime until the send settles. The sender returns a boolean and never throws, so email can never break a booking.

Natural-language booking shows its work.

Type “fade with Sam Saturday afternoon” and the AI returns a structured intent — service, barber, day, time, and a confidence score. The UI shows that interpretation before the slot list rather than jumping straight to times. If confidence is low or nothing matches, the card falls back gracefully instead of guessing.

POST /api/parse-booking { text: "fade with Sam Saturday afternoon" }
→ { service: "Fade", barber: "Sam", dayHint: "Saturday",
    timeHint: "afternoon", confidence: 0.99 }

Timezone is a first-class concept, not an afterthought.

All timestamps are stored UTC. Shop-local time comes from a SHOP_TIMEZONE variable through a single conversion module. Slot math runs in shop-local wall clock, then converts to UTC at the boundary. I verified the helpers return byte-identical results under three different server timezones. Correct by construction, not by accident.

4. The concurrency test

Terminal showing the concurrency test passing against production
Two POSTs, one slot, one winner.

The test fires two POST /api/bookings calls in the same tick via Promise.all, then asserts exactly one 201 and one 409 and that the database contains exactly one row for the slot. The screenshot above is a run against the deployed URL — same result as local. The database doesn't care where the request came from; the constraint is the constraint.

response A: 409 {"error":"slot_taken","message":"That slot was just taken. Please pick another time."}
response B: 201 {"ok":true,"reference_code":"FC-20261006-0005",...}
✅ Exactly one booking succeeded (ref: FC-20261006-0005)
✅ Second request rejected with 409 slot_taken
✅ DB has exactly 1 row for slot 2026-10-06T01:00:00.000Z
PASS

5. Design

Three projects into the portfolio, the visual language needed to diverge. The first two both read as SaaS — dark zinc, indigo, Inter. That's correct for a support tool and a B2B lead pipeline. Wrong for a neighborhood barbershop. Fade & Co. needed its own identity.

The storefront is warm cream, Instrument Serif for display, brass and amber accents, dark brown text. The admin wing stays dark but warm — dark walnut with cream text, same serif. Consistent building, two different rooms.

Contrast was measured, not eyeballed. Body text 15.49, secondary 5.45, buttons 6.79 — all AA. Cream palettes fail WCAG easily; a beautiful page nobody can read is a worse portfolio piece than a plain one.

The booking wizard with service selection
The wizard — warm, uncluttered, no cold SaaS chrome.

6. What I'd do differently

  • Customer-facing cancellation. v1 has admin-only cancel. The customer path is a two-click flow through an email link with a signed token — deliberately deferred, but it's the first thing a real shop would ask for.
  • Time-off management is a shared calendar, not a form. A shop with three barbers coordinates time off constantly. v1 treats it as per-barber blocks. A month grid where the owner drags across barbers is the right UX.
  • Rate limiting is a Map, not Redis. Same trade-off as the earlier project — fine for a demo, wrong for production.

7. Under the hood

Size
~6,600 app lines · 76 files · 9 commits
app/ lib/ components/ scripts/, excl. generated UI kit
Checks
tsc · build · seed · concurrency — all green
Time
All timestamps UTC; shop-local via SHOP_TIMEZONE
Database
btree_gist required for the exclusion constraint
Cost
Zero paid tools — Supabase, Groq, Gemini, Vercel, Resend, GitHub all on free tiers
Today's schedule showing three barber columns
Today's schedule — three barbers, one glance.
Services table in the admin dashboard
Delete protection — a service with future appointments can't be removed.
Parsed natural-language booking intent card
Natural-language input. The parse is shown, not hidden.

Built by Darvin Raj

Fade & Co. is a fictional barbershop. This is a portfolio piece — no real customers, no real bookings.

← Back to Fade & Co.