Appointment booking looks simple until you try to build it.
At first, the problem appears to be:
- Read the business opening hours.
- Remove times that are already booked.
- Show what remains.
That version works for a demo. It does not survive contact with a real therapist, tutor, salon or consultant.
While building OpenSlot, I found that availability is less like a calendar and more like a small rules engine. A time is bookable only when several independent constraints agree.
A slot is more than a start time
A useful slot needs at least:
- a start and end time
- the service duration
- a staff member
- the business time zone
- minimum booking notice
- buffers before or after the appointment
- working hours and breaks
- time off
- existing bookings
I keep the service duration separate from its buffers. A 60-minute consultation with a 15-minute clean-up period occupies 75 minutes of the diary, but the customer should still see a 60-minute service.
A simplified rule might look like this:
function canBook(slot, service, schedule, existingBookings) {
const occupiedUntil = addMinutes(
slot.start,
service.duration + service.bufferAfter
);
return (
isInsideWorkingHours(slot.start, occupiedUntil, schedule) &&
hasEnoughNotice(slot.start, service.minimumNotice) &&
!overlapsTimeOff(slot.start, occupiedUntil, schedule.timeOff) &&
!overlapsBooking(slot.start, occupiedUntil, existingBookings)
);
}
The code is not the difficult part. The difficult part is agreeing on what every boundary means.
If one appointment ends at 11:00, can the next start at 11:00? Usually yes, unless a buffer applies. Are end times inclusive or exclusive? Pick one convention and use it everywhere. I use half-open ranges: the start is included and the end is not.
That makes adjacent bookings legal without special cases:
09:00 <= appointment < 10:00
10:00 <= next appointment < 11:00
Generate candidates, then reject them
I had better results by generating possible start times first and passing each one through the rules.
For example, a staff member works from 09:00 to 17:00 and the booking interval is 30 minutes. Generate 09:00, 09:30, 10:00 and so on. Then reject candidates that fail a constraint.
This is easier to reason about than trying to build one enormous database query that accounts for every rule at once. It also makes tests readable:
expect(slotsFor("2026-09-11")).not.toContain("09:00"); // time off
expect(slotsFor("2026-09-11")).toContain("11:30"); // available
expect(slotsFor("2026-09-11")).not.toContain("16:30"); // runs past closing
The last case catches an easy mistake. A 60-minute service cannot start at 16:30 just because the start time falls within working hours.
Time zones are a display concern and a business rule
Store timestamps in UTC, but do not pretend that solves time zones.
The business defines availability in its own local time. A customer may view that availability from somewhere else. Daylight-saving changes mean “every Monday at 09:00” cannot safely be represented as “add seven lots of 24 hours”.
The process I use is:
- Interpret working hours in the business time zone.
- Build the local date and time for the requested day.
- Convert candidate timestamps to UTC for comparisons and storage.
- Display them in the correct customer or business context.
DST transition days deserve explicit tests. They are rare enough to be forgotten and common enough to break production calendars twice a year.
Preventing double bookings requires an atomic write
Removing occupied times from the page is only the first defence.
Two customers can load the same available slot, wait, and submit within milliseconds of each other. Both browsers are telling the truth based on the data they received.
The server must check availability again while creating the booking. That check and insert need to happen atomically, using a transaction, lock or database constraint appropriate to the schema.
The important rule is simple:
The availability shown in the browser is helpful. The database is authoritative.
I also use an idempotency key around booking creation. If a customer double-clicks or a request is retried, the same intent should not create two appointments.
Payment status is not booking status
A paid booking can be confirmed, pending, cancelled or refunded. A confirmed booking can be free or paid later.
Combining these into one status field creates awkward states surprisingly quickly. I keep them separate:
booking_status: pending | confirmed | cancelled
payment_status: not_required | pending | paid | refunded | failed
This makes webhook handling much less fragile and keeps the diary understandable when a payment provider is slow.
The boring cases are the real product
The happy path makes a good screenshot. The product is everything around it:
- a staff member takes the afternoon off
- a service needs a longer buffer
- a customer retries payment
- the clocks change
- two people choose the same time
- an appointment is moved rather than cancelled
That is why booking software is a good engineering exercise. The interface can remain simple only because the underlying rules are precise.
I built OpenSlot around that idea: customers should see a clear choice of useful times, while the business keeps control of the rules behind them.
Top comments (0)