Most scheduling UIs start with a calendar grid: columns for people, rows for 15-minute slots, coloured blocks on top. That works for booking. It works less well for running the day, which is what reception actually does from 8am to 6pm.
A day board is a different view of the same data: a list of today's appointments, grouped by practitioner, where the important thing is not where the block sits but what state it is in. Who has arrived? Who is in a room? Who is done but hasn't paid? This post walks through how to model that, with TypeScript examples.
Start with the data model, not the grid
The grid tempts you into thinking in slots. Don't. Store intervals and let the view decide how to draw them.
type Instant = string; // ISO 8601 in UTC, e.g. "2026-10-12T22:30:00Z"
interface Site {
id: string;
name: string;
timeZone: string; // IANA, e.g. "Australia/Melbourne" or "Australia/Brisbane"
}
interface Room {
id: string;
siteId: string;
name: string;
capacity: number; // usually 1; a gym area might allow more
}
interface Appointment {
id: string;
siteId: string;
practitionerId: string;
patientId: string;
roomId: string | null; // assigned on arrival in many clinics
start: Instant;
end: Instant;
status: AppointmentStatus;
statusHistory: StatusEvent[];
version: number; // optimistic concurrency
}
interface StatusEvent {
from: AppointmentStatus;
to: AppointmentStatus;
at: Instant;
byUserId: string;
}
A few decisions worth calling out:
- Times are UTC instants. The site owns the timezone. More on why below.
- Room is nullable. Plenty of clinics don't assign a room until the patient walks in. Forcing it at booking time creates fake data.
- Status history is append-only. When someone asks "why does this show as paid?", you want an answer.
-
versionexists because two people at the front desk will click the same appointment at the same time.
A state machine for patient status
The day board is really a view over a state machine. A minimal set of states:
type AppointmentStatus =
| "booked"
| "arrived"
| "in_treatment"
| "done"
| "paid"
| "cancelled"
| "no_show";
const transitions: Record<AppointmentStatus, AppointmentStatus[]> = {
booked: ["arrived", "cancelled", "no_show"],
arrived: ["in_treatment", "booked", "cancelled"], // "booked" = undo a mis-click
in_treatment: ["done", "arrived"],
done: ["paid", "in_treatment"],
paid: [],
cancelled: ["booked"],
no_show: ["booked"],
};
Notice the backward edges. Real clinics mis-click. If your machine only moves forward, staff will invent workarounds (cancel and rebook, which destroys history). Explicit undo transitions are better than an admin "force status" button.
paid is terminal here, but payment is arguably a separate axis. A patient can be done and owe money, or prepay before treatment. Once you hit that, split it:
interface AppointmentFlags {
clinicalStatus: AppointmentStatus; // booked → done
paymentStatus: "unpaid" | "part_paid" | "paid" | "no_charge";
claimStatus: "not_required" | "needs_claim" | "recorded";
}
Keeping claimStatus as its own field is useful even if you never integrate with an external claiming system: it just tracks whether someone still needs to deal with it.
The transition function stays boring on purpose:
class TransitionError extends Error {}
function transition(
appt: Appointment,
to: AppointmentStatus,
byUserId: string,
now: Instant,
): Appointment {
const allowed = transitions[appt.status];
if (!allowed.includes(to)) {
throw new TransitionError(`Cannot move ${appt.status} → ${to}`);
}
return {
...appt,
status: to,
version: appt.version + 1,
statusHistory: [
...appt.statusHistory,
{ from: appt.status, to, at: now, byUserId },
],
};
}
On the server, persist with a version check so the second click loses cleanly:
UPDATE appointments
SET status = $1, version = version + 1
WHERE id = $2 AND version = $3;
-- 0 rows updated → reload and show the current state
Handling double-booked rooms
Practitioner double-booking is usually prevented at booking time. Rooms are trickier, because rooms are often assigned late and appointments overrun.
The core check is interval overlap with half-open intervals ([start, end)), so a 9:00–9:30 and a 9:30–10:00 don't collide:
const overlaps = (a: { start: Instant; end: Instant },
b: { start: Instant; end: Instant }) =>
a.start < b.end && b.start < a.end; // ISO UTC strings compare lexically
function roomConflicts(
candidate: Appointment,
sameDay: Appointment[],
room: Room,
): Appointment[] {
if (!candidate.roomId) return [];
const active = sameDay.filter(
(a) =>
a.id !== candidate.id &&
a.roomId === candidate.roomId &&
!["cancelled", "no_show", "done", "paid"].includes(a.status) &&
overlaps(a, candidate),
);
return active.length >= room.capacity ? active : [];
}
Two design choices here:
- Warn, don't block. A hard constraint at the database level sounds safe, but at 10:40 with a patient standing at the desk, a modal saying "cannot save" is the worst outcome. Show the conflict on the board and let a human decide.
-
Status matters to conflicts. A room whose occupant is
doneis free, even if the booked end time hasn't passed. Conversely, anin_treatmentappointment past its end time is still occupying the room. For live occupancy, usemax(end, now)for anything still in treatment.
If you do want a database guarantee for future bookings, PostgreSQL exclusion constraints on a tstzrange work well, but scope them (e.g. only where status is booked) so the live day stays flexible.
Timezone gotchas for Australian clinics
Australia is a great test case for timezone bugs:
- AEST is UTC+10, AEDT is UTC+11. NSW, Victoria, the ACT and Tasmania switch in October and April.
- Queensland has no daylight saving. Brisbane stays on UTC+10 all year. A clinic group with sites in Brisbane and on the Gold Coast's NSW side (Tweed Heads) is an hour apart for half the year.
- South Australia and the Northern Territory sit on half-hour offsets (UTC+9:30, plus DST for SA only). Western Australia is UTC+8 with no DST.
Practical rules that save pain:
Never store "Australia" or "AEST" as a timezone. Store IANA names per site: Australia/Melbourne, Australia/Brisbane, Australia/Adelaide. Abbreviations are ambiguous and don't encode DST rules.
"Today" belongs to the site, not the server. Your server is probably in UTC. Querying "today's appointments" with a UTC midnight boundary will put early Melbourne mornings on the wrong day.
import { Temporal } from "@js-temporal/polyfill";
function dayBounds(date: string, timeZone: string) {
const day = Temporal.PlainDate.from(date);
const start = day.toZonedDateTime({ timeZone }).toInstant();
const end = day.add({ days: 1 }).toZonedDateTime({ timeZone }).toInstant();
return { start: start.toString(), end: end.toString() };
}
dayBounds("2026-10-04", "Australia/Melbourne");
// DST starts that day: the "day" is 23 hours long
Don't assume a day is 24 hours. On DST change days in the southern states, a day is 23 or 25 hours. Anything that builds a board by adding 24 * 60 * 60 * 1000 will drift.
Store recurring bookings as local wall time. "Every Tuesday at 9am" means 9am local, whatever the offset. Expand recurrences into instants using the site's timezone, never by adding fixed durations to a UTC instant.
Render with the site's zone, not the browser's. A practice manager in Melbourne viewing the Brisbane site should see Brisbane times, clearly labelled.
Putting it together
The day board then becomes a straightforward query: fetch appointments within dayBounds(today, site.timeZone), group by practitioner, sort by start, and decorate each row with its status, available transitions, and any room conflicts. No grid maths required, and the same data still powers a week or month calendar when you need one.
We use this approach in PhysioStack, a day-board-first practice system for Australian physio clinics. The bigger lesson applies to any operational scheduling tool, though: model the states people work with, keep times honest, and let the UI warn rather than refuse when reality gets messy.
Top comments (0)