Concept project · runs in your browserTypeScript · React.js · Next.js · zod
Clinic Booking System Demo
Book an appointment at a fictional clinic: pick a service, a staff member or anyone, a day and a free time shown in your own time zone. Then make two bookings race for the same slot, reschedule or cancel, and switch to the admin calendar to drag appointments around. The dev panel shows every request and the SQL behind it.
Interactive demo
## How it works
Free slots come from each staff member's weekly hours, minus breaks, days off, existing appointments and a one-hour lead time. Working hours are defined in clinic time (Asia/Karachi), every instant is stored in UTC, and the slot buttons are formatted in whichever zone you choose, so a visitor in London sees London times for the same rows.
Booking does not check availability first and then insert. It just inserts. The table has EXCLUDE USING gist (staff_id WITH =, during WITH &&) WHERE (status <> 'cancelled'), so an overlapping row fails with error 23P01, which the API turns into 409 Conflict. Because the constraint ignores cancelled rows, cancelling frees the slot at once.
Rescheduling is one transaction: lock the row, update its range, commit. If the new time overlaps, the update fails, the transaction rolls back and the original time is still booked. Drag a booking onto another one in the admin calendar to see it.
## View source
The code running in the window above, exactly as deployed.
app/lab/booking-system/booking.ts302 lines
/**
* Booking domain logic: time zones, working hours, free-slot calculation and the
* "no overlapping appointments" rule. Free of React so it reads like the NestJS
* AvailabilityService it stands in for. In production the overlap rule is a
* PostgreSQL exclusion constraint; here `insertAppointment` enforces the same rule
* and throws the same error code (23P01) so the API layer can map it to 409.
*/
export const BUSINESS_TZ = "Asia/Karachi";
export const SLOT_STEP_MIN = 15;
/** Customers cannot book less than an hour ahead. */
export const LEAD_TIME_MS = 60 * 60_000;
export const MIN = 60_000;
export type Service = { id: number; name: string; durationMin: number; price: number };
/** Minutes from midnight, business time. */
export type Span = readonly [number, number];
export type Staff = {
id: number;
name: string;
title: string;
serviceIds: number[];
/** Index = weekday (0 = Sunday). null = not working that day. */
hours: (Span | null)[];
breakAt: Span | null;
/** YYYY-MM-DD in business time. */
daysOff: string[];
};
export type Status = "confirmed" | "cancelled";
export type Appointment = {
id: number;
staffId: number;
serviceId: number;
customer: string;
email: string;
/** UTC epoch ms, half-open [start, end) like tstzrange(start, end, '[)'). */
start: number;
end: number;
status: Status;
createdAt: number;
source: "online" | "front-desk";
};
export type Db = { staff: Staff[]; appointments: Appointment[]; nextId: number };
export const SERVICES: readonly Service[] = [
{ id: 1, name: "Consultation", durationMin: 30, price: 2500 },
{ id: 2, name: "Scale & polish", durationMin: 45, price: 4500 },
{ id: 3, name: "Filling", durationMin: 60, price: 7000 },
{ id: 4, name: "Teeth whitening", durationMin: 90, price: 15000 },
{ id: 5, name: "Physiotherapy session", durationMin: 60, price: 5000 },
{ id: 6, name: "Follow-up check", durationMin: 15, price: 1000 },
];
export const serviceById = (id: number) => SERVICES.find((s) => s.id === id);
export const bookingRef = (id: number) => `BK-${id}`;
/* ------------------------------------------------------------------ */
/* Time zones */
/* ------------------------------------------------------------------ */
const fmtCache = new Map<string, Intl.DateTimeFormat>();
function fmt(tz: string, opts: Intl.DateTimeFormatOptions, locale = "en-GB") {
const key = `${locale}|${tz}|${JSON.stringify(opts)}`;
let f = fmtCache.get(key);
if (!f) {
f = new Intl.DateTimeFormat(locale, { timeZone: tz, ...opts });
fmtCache.set(key, f);
}
return f;
}
/** Offset of `tz` from UTC at a given instant, in minutes (Karachi: +300). Handles DST zones too. */
export function tzOffsetMin(tz: string, utc: number): number {
const parts = fmt(tz, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(new Date(utc));
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? 0);
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
return Math.round((asUtc - Math.floor(utc / 1000) * 1000) / MIN);
}
/** "2026-11-12" + 570 minutes in Asia/Karachi -> UTC epoch ms. */
export function zonedToUtc(day: string, minuteOfDay: number, tz: string): number {
const [y, m, d] = day.split("-").map(Number) as [number, number, number];
const naive = Date.UTC(y, m - 1, d, 0, minuteOfDay);
// Two passes so a DST change between the guess and the answer is still handled.
let utc = naive - tzOffsetMin(tz, naive) * MIN;
utc = naive - tzOffsetMin(tz, utc) * MIN;
return utc;
}
/** Calendar day (YYYY-MM-DD) of an instant in a time zone. */
export function dayInTz(utc: number, tz: string): string {
return fmt(tz, { year: "numeric", month: "2-digit", day: "2-digit" }, "en-CA").format(new Date(utc));
}
/** Minutes since local midnight of an instant in a time zone. */
export function minuteInTz(utc: number, tz: string): number {
const p = fmt(tz, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(new Date(utc));
return Number(p.find((x) => x.type === "hour")?.value ?? 0) * 60 + Number(p.find((x) => x.type === "minute")?.value ?? 0);
}
export function addDays(day: string, n: number): string {
const [y, m, d] = day.split("-").map(Number) as [number, number, number];
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
}
export function weekday(day: string): number {
const [y, m, d] = day.split("-").map(Number) as [number, number, number];
return new Date(Date.UTC(y, m - 1, d)).getUTCDay();
}
export const fmtTime = (utc: number, tz: string) =>
fmt(tz, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(utc));
export const fmtDate = (utc: number, tz: string) =>
fmt(tz, { weekday: "short", day: "numeric", month: "short" }).format(new Date(utc));
export const fmtDay = (day: string) =>
fmt("UTC", { weekday: "short", day: "numeric", month: "short" }).format(new Date(`${day}T12:00:00Z`));
export const tzLabel = (tz: string, utc: number) =>
fmt(tz, { timeZoneName: "short" }).formatToParts(new Date(utc)).find((p) => p.type === "timeZoneName")?.value ?? tz;
export const fmtMinutes = (m: number) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
export const fmtPrice = (n: number) => `Rs ${n.toLocaleString("en-US")}`;
/* ------------------------------------------------------------------ */
/* The overlap rule (exclusion constraint) */
/* ------------------------------------------------------------------ */
/** Mirrors a PostgreSQL error as node-postgres surfaces it. */
export class PgError extends Error {
constructor(
public code: string,
public constraint: string,
message: string,
) {
super(message);
}
}
/** `during && other.during` for half-open ranges: touching ends (10:00–10:30, 10:30–11:00) do not overlap. */
export const overlaps = (aStart: number, aEnd: number, bStart: number, bEnd: number) => aStart < bEnd && bStart < aEnd;
/**
* EXCLUDE USING gist (staff_id WITH =, during WITH &&) WHERE (status <> 'cancelled')
* Returns the row that blocks [start, end) for this staff member, if any.
*/
export function conflictFor(db: Db, staffId: number, start: number, end: number, ignoreId?: number) {
return db.appointments.find(
(a) => a.id !== ignoreId && a.staffId === staffId && a.status !== "cancelled" && overlaps(start, end, a.start, a.end),
);
}
function assertRow(db: Db, row: Pick<Appointment, "staffId" | "start" | "end">, ignoreId?: number) {
if (!(row.end > row.start)) {
throw new PgError("23514", "appointments_during_check", 'new row violates check constraint "appointments_during_check"');
}
const hit = conflictFor(db, row.staffId, row.start, row.end, ignoreId);
if (hit) {
throw new PgError(
"23P01",
"appointments_no_overlap",
`conflicting key value violates exclusion constraint "appointments_no_overlap" (conflicts with id ${hit.id})`,
);
}
}
/** INSERT INTO appointments … — throws PgError 23P01 on overlap, exactly like the database would. */
export function insertAppointment(db: Db, row: Omit<Appointment, "id">): { db: Db; row: Appointment } {
assertRow(db, row);
const created: Appointment = { ...row, id: db.nextId };
return { db: { ...db, appointments: [...db.appointments, created], nextId: db.nextId + 1 }, row: created };
}
/** UPDATE appointments SET staff_id, during … WHERE id — the constraint is re-checked against every other row. */
export function updateAppointment(db: Db, id: number, patch: Partial<Appointment>): { db: Db; row: Appointment } {
const current = db.appointments.find((a) => a.id === id);
if (!current) throw new PgError("P0002", "", "no rows");
const next = { ...current, ...patch };
if (next.status !== "cancelled") assertRow(db, next, id);
return { db: { ...db, appointments: db.appointments.map((a) => (a.id === id ? next : a)) }, row: next };
}
/* ------------------------------------------------------------------ */
/* Working hours and free slots */
/* ------------------------------------------------------------------ */
export type Slot = { start: number; end: number; staffId: number };
/** Why a staff member cannot take [start, end) — or null if the time is inside their working hours. */
export function outsideHours(staff: Staff, start: number, end: number): string | null {
const day = dayInTz(start, BUSINESS_TZ);
if (staff.daysOff.includes(day)) return `${staff.name} is off on ${fmtDay(day)}.`;
const hours = staff.hours[weekday(day)];
if (!hours) return `${staff.name} does not work on ${fmtDay(day).split(" ")[0]}s.`;
const from = minuteInTz(start, BUSINESS_TZ);
const to = from + (end - start) / MIN;
if (from < hours[0] || to > hours[1]) {
return `Outside ${staff.name}'s hours (${fmtMinutes(hours[0])}–${fmtMinutes(hours[1])}).`;
}
if (staff.breakAt && from < staff.breakAt[1] && staff.breakAt[0] < to) return `That overlaps ${staff.name}'s break.`;
return null;
}
/**
* Free slots for one service on one business day: working hours, minus breaks and days off,
* minus active appointments, minus anything inside the lead time. With staffId "any", each
* start time is offered once and given to the least busy qualified person that day.
*/
export function freeSlots(db: Db, serviceId: number, staffId: number | "any", day: string, now: number): Slot[] {
const service = serviceById(serviceId);
if (!service) return [];
const dur = service.durationMin;
const candidates = db.staff.filter((s) => s.serviceIds.includes(serviceId) && (staffId === "any" || s.id === staffId));
const load = (sid: number) =>
db.appointments.filter((a) => a.staffId === sid && a.status !== "cancelled" && dayInTz(a.start, BUSINESS_TZ) === day).length;
const byStart = new Map<number, Slot>();
for (const s of candidates) {
const hours = s.hours[weekday(day)];
if (!hours || s.daysOff.includes(day)) continue;
for (let m = hours[0]; m + dur <= hours[1]; m += SLOT_STEP_MIN) {
if (s.breakAt && m < s.breakAt[1] && s.breakAt[0] < m + dur) continue;
const start = zonedToUtc(day, m, BUSINESS_TZ);
const end = start + dur * MIN;
if (start < now + LEAD_TIME_MS) continue;
if (conflictFor(db, s.id, start, end)) continue;
const prev = byStart.get(start);
if (!prev || load(s.id) < load(prev.staffId)) byStart.set(start, { start, end, staffId: s.id });
}
}
return [...byStart.values()].sort((a, b) => a.start - b.start);
}
/* ------------------------------------------------------------------ */
/* Seed data (fictional) */
/* ------------------------------------------------------------------ */
const NAMES = ["Areeba N.", "Usman T.", "Maria J.", "Danish Q.", "Sana R.", "Imran L.", "Fatima Z.", "Kamran H.", "Zoya A.", "Bilal M."];
/** Small deterministic PRNG so the seed looks random but is the same every reset. */
function prng(seed: number) {
let t = seed;
return () => {
t = (t + 0x6d2b79f5) | 0;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
export function seed(now: number): Db {
const today = dayInTz(now, BUSINESS_TZ);
const weekdays: Span = [9 * 60, 17 * 60];
const staff: Staff[] = [
{ id: 1, name: "Dr. Ayesha K.", title: "Dentist", serviceIds: [1, 2, 4, 6], hours: [null, weekdays, weekdays, weekdays, weekdays, weekdays, null], breakAt: [13 * 60, 14 * 60], daysOff: [addDays(today, 3)] },
{ id: 2, name: "Dr. Omar F.", title: "Dentist", serviceIds: [1, 2, 3, 6], hours: [null, [10 * 60, 18 * 60], [10 * 60, 18 * 60], [10 * 60, 18 * 60], [10 * 60, 18 * 60], [10 * 60, 18 * 60], [10 * 60, 14 * 60]], breakAt: [14 * 60, 14 * 60 + 30], daysOff: [] },
{ id: 3, name: "Nadia S.", title: "Hygienist", serviceIds: [2, 4], hours: [null, null, [9 * 60, 15 * 60], [9 * 60, 15 * 60], [9 * 60, 15 * 60], [9 * 60, 15 * 60], [9 * 60, 15 * 60]], breakAt: null, daysOff: [] },
{ id: 4, name: "Hamza T.", title: "Physiotherapist", serviceIds: [1, 5, 6], hours: [null, [12 * 60, 20 * 60], null, [12 * 60, 20 * 60], null, [12 * 60, 20 * 60], [9 * 60, 13 * 60]], breakAt: [16 * 60, 16 * 60 + 30], daysOff: [] },
];
let db: Db = { staff, appointments: [], nextId: 1001 };
const rnd = prng(42);
for (let d = -1; d < 8; d++) {
const day = addDays(today, d);
for (const s of staff) {
const hours = s.hours[weekday(day)];
if (!hours || s.daysOff.includes(day)) continue;
const wanted = 2 + Math.floor(rnd() * 4);
for (let i = 0; i < wanted * 3 && db.appointments.filter((a) => a.staffId === s.id && dayInTz(a.start, BUSINESS_TZ) === day).length < wanted; i++) {
const serviceId = s.serviceIds[Math.floor(rnd() * s.serviceIds.length)]!;
const dur = serviceById(serviceId)!.durationMin;
const steps = Math.floor((hours[1] - hours[0] - dur) / SLOT_STEP_MIN);
const m = hours[0] + Math.floor(rnd() * (steps + 1)) * SLOT_STEP_MIN;
const start = zonedToUtc(day, m, BUSINESS_TZ);
const end = start + dur * MIN;
if (outsideHours(s, start, end)) continue;
const who = NAMES[Math.floor(rnd() * NAMES.length)]!;
try {
db = insertAppointment(db, {
staffId: s.id,
serviceId,
customer: who,
email: `${who.split(" ")[0]!.toLowerCase()}@example.com`,
start,
end,
status: rnd() < 0.08 ? "cancelled" : "confirmed",
createdAt: now - 3 * 86_400_000,
source: rnd() < 0.5 ? "online" : "front-desk",
}).db;
} catch {
/* overlap: the "constraint" said no, try another time */
}
}
}
}
return db;
}app/lab/booking-system/api.ts266 lines
/**
* The API layer, written the way the NestJS BookingsController + BookingsService would be:
* a ValidationPipe (zod here, class-validator in Nest), a RolesGuard for admin routes, and
* services that run SQL inside transactions. Each handler returns the HTTP status, the JSON
* body and the SQL statements the real service would send to PostgreSQL, which the dev panel shows.
*/
import { z } from "zod";
import {
BUSINESS_TZ,
MIN,
PgError,
SLOT_STEP_MIN,
bookingRef,
dayInTz,
freeSlots,
insertAppointment,
minuteInTz,
outsideHours,
serviceById,
updateAppointment,
type Appointment,
type Db,
} from "./booking";
export type Role = "customer" | "admin";
export type ApiResult<T = unknown> = { status: number; body: T; sql: string[]; db: Db };
export const SCHEMA_SQL = `CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE appointments (
id bigserial PRIMARY KEY,
staff_id int NOT NULL REFERENCES staff(id),
service_id int NOT NULL REFERENCES services(id),
customer text NOT NULL,
email citext NOT NULL,
during tstzrange NOT NULL, -- [start, end), stored in UTC
status text NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'cancelled')),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT appointments_during_check CHECK (NOT isempty(during)),
-- Two active rows for the same staff member may never overlap.
-- Enforced by a GiST index, so it holds even under concurrent inserts.
CONSTRAINT appointments_no_overlap
EXCLUDE USING gist (staff_id WITH =, during WITH &&)
WHERE (status <> 'cancelled')
);`;
const iso = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z");
const range = (a: number, b: number) => `tstzrange('${iso(a)}', '${iso(b)}', '[)')`;
const q = (s: string) => `'${s.replace(/'/g, "''")}'`;
function pgToHttp(e: unknown, sql: string[], db: Db): ApiResult<{ statusCode: number; error: string; message: string }> {
if (e instanceof PgError && e.code === "23P01") {
sql.push(`-- ERROR 23P01: ${e.message}`, "ROLLBACK;");
return { status: 409, body: { statusCode: 409, error: "Conflict", message: "That slot was just taken. Please pick another time." }, sql, db };
}
if (e instanceof PgError) {
sql.push(`-- ERROR ${e.code}: ${e.message}`, "ROLLBACK;");
return { status: 422, body: { statusCode: 422, error: "Unprocessable Entity", message: e.message }, sql, db };
}
throw e;
}
const forbidden = (db: Db): ApiResult<{ statusCode: number; error: string; message: string }> => ({
status: 403,
body: { statusCode: 403, error: "Forbidden", message: "RolesGuard: this route requires role 'admin'." },
sql: ["-- request rejected by RolesGuard before any query ran"],
db,
});
function badRequest(db: Db, issues: z.ZodIssue[]): ApiResult<{ statusCode: number; error: string; message: string[] }> {
return {
status: 400,
body: { statusCode: 400, error: "Bad Request", message: issues.map((i) => `${i.path.join(".") || "body"}: ${i.message}`) },
sql: ["-- rejected by ValidationPipe, no query ran"],
db,
};
}
/** Business-rule checks shared by create and reschedule (the DB constraint covers overlaps). */
function checkRules(db: Db, staffId: number, serviceId: number, start: number, now: number): string | null {
const staff = db.staff.find((s) => s.id === staffId);
const service = serviceById(serviceId);
if (!staff || !service) return "Unknown staff member or service.";
if (!staff.serviceIds.includes(serviceId)) return `${staff.name} does not offer ${service.name}.`;
if (minuteInTz(start, BUSINESS_TZ) % SLOT_STEP_MIN !== 0) return `Start times must be on a ${SLOT_STEP_MIN}-minute boundary.`;
if (start < now) return "That time is in the past.";
return outsideHours(staff, start, start + service.durationMin * MIN);
}
/* ------------------------------------------------------------------ */
/* GET /api/availability */
/* ------------------------------------------------------------------ */
export function getAvailability(db: Db, p: { serviceId: number; staffId: number | "any"; day: string }, now: number) {
const slots = freeSlots(db, p.serviceId, p.staffId, p.day, now);
const staffFilter = p.staffId === "any" ? `staff_id = ANY($staff_offering_service)` : `staff_id = ${p.staffId}`;
return {
status: 200,
body: { day: p.day, timeZone: BUSINESS_TZ, slots: slots.map((s) => ({ start: iso(s.start), staffId: s.staffId })) },
sql: [
`SELECT staff_id, during FROM appointments`,
` WHERE ${staffFilter} AND status <> 'cancelled'`,
` AND during && tstzrange(('${p.day}'::date)::timestamp AT TIME ZONE '${BUSINESS_TZ}',`,
` ('${p.day}'::date + 1)::timestamp AT TIME ZONE '${BUSINESS_TZ}');`,
`-- working hours minus these rows -> ${slots.length} free slot(s), computed in AvailabilityService`,
],
db,
slots,
};
}
/* ------------------------------------------------------------------ */
/* POST /api/bookings */
/* ------------------------------------------------------------------ */
export const CreateBookingDto = z.object({
serviceId: z.number().int().positive(),
staffId: z.number().int().positive(),
start: z.number().int(),
customer: z.string().trim().min(2, "Please enter your name (2+ characters).").max(80),
email: z.string().trim().email("Please enter a valid email address."),
});
export type CreateBooking = z.infer<typeof CreateBookingDto>;
export function createBooking(db: Db, input: unknown, now: number, source: Appointment["source"] = "online", tx = "") {
const parsed = CreateBookingDto.safeParse(input);
if (!parsed.success) return badRequest(db, parsed.error.issues);
const b = parsed.data;
const rule = checkRules(db, b.staffId, b.serviceId, b.start, now);
if (rule) {
return { status: 422, body: { statusCode: 422, error: "Unprocessable Entity", message: rule }, sql: ["-- business rule failed in BookingsService, no query ran"], db };
}
const end = b.start + serviceById(b.serviceId)!.durationMin * MIN;
const sql = [
`${tx}BEGIN;`,
`${tx}INSERT INTO appointments (staff_id, service_id, customer, email, during)`,
`${tx} VALUES (${b.staffId}, ${b.serviceId}, ${q(b.customer)}, ${q(b.email)}, ${range(b.start, end)})`,
`${tx} RETURNING id;`,
];
// No "SELECT … then INSERT" check here on purpose: the exclusion constraint is the single source of truth.
try {
const res = insertAppointment(db, { ...b, end, status: "confirmed", createdAt: now, source });
sql.push(`${tx}-- id = ${res.row.id}`, `${tx}COMMIT;`);
return { status: 201, body: { id: res.row.id, ref: bookingRef(res.row.id), start: iso(b.start), end: iso(end), staffId: b.staffId }, sql, db: res.db };
} catch (e) {
const r = pgToHttp(e, sql, db);
r.sql = r.sql.map((l) => (l.startsWith(tx) ? l : `${tx}${l}`));
return r;
}
}
/**
* Two customers press "Book" on the same slot at the same moment. Both transactions start;
* the second INSERT blocks on the GiST index until the first commits, then fails with 23P01.
*/
export function simulateRace(db: Db, input: CreateBooking, now: number) {
const a = createBooking(db, { ...input, customer: `${input.customer}` }, now, "online", "[tx A] ");
const b = createBooking(a.db, { ...input, customer: "Other visitor", email: "other@example.com" }, now, "online", "[tx B] ");
const sql = [
"[tx A] BEGIN;",
"[tx B] BEGIN;",
...a.sql.filter((l) => !/BEGIN|COMMIT|ROLLBACK/.test(l)),
...b.sql.filter((l) => /INSERT|VALUES|RETURNING/.test(l)),
"[tx B] -- waits: tx A holds a conflicting entry in the GiST index",
...a.sql.filter((l) => /COMMIT|ROLLBACK/.test(l)),
...b.sql.filter((l) => /ERROR|COMMIT|ROLLBACK|-- id/.test(l)),
];
return { a, b, sql, db: b.db };
}
/* ------------------------------------------------------------------ */
/* PATCH /api/bookings/:id/cancel and /reschedule */
/* ------------------------------------------------------------------ */
type Caller = { role: Role; email?: string };
function ownerCheck(db: Db, id: number, caller: Caller) {
const row = db.appointments.find((a) => a.id === id);
if (!row) return { status: 404, body: { statusCode: 404, error: "Not Found", message: `Booking ${id} not found.` }, sql: [`SELECT … FROM appointments WHERE id = ${id}; -- 0 rows`], db };
// Customers manage a booking through the emailed link; the email must match. Admins can manage any.
if (caller.role !== "admin" && caller.email?.toLowerCase() !== row.email.toLowerCase()) {
return { status: 403, body: { statusCode: 403, error: "Forbidden", message: "This booking belongs to someone else." }, sql: ["-- BookingOwnerGuard rejected the request"], db };
}
return row;
}
export function cancelBooking(db: Db, id: number, caller: Caller): ApiResult {
const row = ownerCheck(db, id, caller);
if ("body" in row) return row;
const sql = [`UPDATE appointments SET status = 'cancelled'`, ` WHERE id = ${id} AND status = 'confirmed';`];
if (row.status === "cancelled") {
sql.push("-- 0 rows updated (already cancelled), still 200: cancelling is idempotent");
return { status: 200, body: { id, status: "cancelled" }, sql, db };
}
const res = updateAppointment(db, id, { status: "cancelled" });
sql.push("-- 1 row; the partial constraint ignores cancelled rows, so the slot is free immediately");
return { status: 200, body: { id, status: "cancelled" }, sql, db: res.db };
}
export function rescheduleBooking(db: Db, id: number, patch: { start: number; staffId?: number }, caller: Caller, now: number): ApiResult {
const row = ownerCheck(db, id, caller);
if ("body" in row) return row;
if (row.status === "cancelled") {
return { status: 409, body: { statusCode: 409, error: "Conflict", message: "A cancelled booking cannot be moved." }, sql: [], db };
}
const staffId = patch.staffId ?? row.staffId;
const rule = checkRules(db, staffId, row.serviceId, patch.start, now);
if (rule) {
return { status: 422, body: { statusCode: 422, error: "Unprocessable Entity", message: rule }, sql: ["-- business rule failed, no query ran; the old time is kept"], db };
}
const end = patch.start + (row.end - row.start);
const sql = [
"BEGIN;",
`SELECT id, during FROM appointments WHERE id = ${id} FOR UPDATE;`,
`UPDATE appointments SET staff_id = ${staffId}, during = ${range(patch.start, end)}`,
` WHERE id = ${id};`,
];
try {
const res = updateAppointment(db, id, { start: patch.start, end, staffId });
sql.push("COMMIT;");
return { status: 200, body: { id, ref: bookingRef(id), start: iso(patch.start), end: iso(end), staffId }, sql, db: res.db };
} catch (e) {
const r = pgToHttp(e, sql, db);
r.sql.push("-- the UPDATE never happened, so the original time is still booked");
return r;
}
}
/* ------------------------------------------------------------------ */
/* Admin routes (RolesGuard: admin) */
/* ------------------------------------------------------------------ */
export function adminDay(db: Db, day: string, role: Role) {
if (role !== "admin") return { ...forbidden(db), rows: [] as Appointment[] };
const rows = db.appointments.filter((a) => dayInTz(a.start, BUSINESS_TZ) === day).sort((a, b) => a.start - b.start);
return {
status: 200,
body: { day, count: rows.length },
sql: [
`SELECT a.*, s.name AS staff FROM appointments a JOIN staff s ON s.id = a.staff_id`,
` WHERE a.during && tstzrange(('${day}'::date)::timestamp AT TIME ZONE '${BUSINESS_TZ}',`,
` ('${day}'::date + 1)::timestamp AT TIME ZONE '${BUSINESS_TZ}')`,
` ORDER BY lower(a.during); -- ${rows.length} rows`,
],
db,
rows,
};
}
export function toggleDayOff(db: Db, staffId: number, day: string, role: Role): ApiResult {
if (role !== "admin") return forbidden(db);
const staff = db.staff.find((s) => s.id === staffId);
if (!staff) return { status: 404, body: { statusCode: 404, error: "Not Found", message: "No such staff member." }, sql: [], db };
const off = staff.daysOff.includes(day);
const nextStaff = { ...staff, daysOff: off ? staff.daysOff.filter((d) => d !== day) : [...staff.daysOff, day] };
return {
status: 200,
body: { staffId, day, off: !off },
sql: off
? [`DELETE FROM staff_days_off WHERE staff_id = ${staffId} AND day = '${day}';`]
: [`INSERT INTO staff_days_off (staff_id, day) VALUES (${staffId}, '${day}') ON CONFLICT DO NOTHING;`, "-- existing appointments stay; the front desk calls those customers"],
db: { ...db, staff: db.staff.map((s) => (s.id === staffId ? nextStaff : s)) },
};
}app/lab/booking-system/Demo.tsx537 lines
"use client";
import { useEffect, useRef, useState } from "react";
import {
BUSINESS_TZ,
SERVICES,
bookingRef,
dayInTz,
fmtDay,
fmtPrice,
fmtTime,
seed,
serviceById,
tzLabel,
type Db,
type Slot,
} from "./booking";
import {
CreateBookingDto,
adminDay,
cancelBooking,
createBooking,
getAvailability,
rescheduleBooking,
simulateRace,
toggleDayOff,
type Role,
} from "./api";
import { AdminCalendar } from "./AdminCalendar";
import { DevPanel, type LogEntry } from "./DevPanel";
import { SlotPicker } from "./SlotPicker";
const KEY = "lab:booking-system:v1";
type Mine = { id: number; email: string };
type Saved = { db: Db; mine: Mine[] };
type Notice = { tone: "ok" | "err"; text: string } | null;
type Tab = "book" | "mine" | "admin";
const input = "mt-1 w-full rounded-md border border-border bg-bg-elev px-3 py-2 text-sm outline-none focus:border-accent";
const chip = "rounded-md border px-3 py-1.5 text-sm";
export default function Demo() {
const [db, setDb] = useState<Db | null>(null);
const [mine, setMine] = useState<Mine[]>([]);
const [now, setNow] = useState(0);
const [role, setRole] = useState<Role>("customer");
const [tab, setTab] = useState<Tab>("book");
const [devOpen, setDevOpen] = useState(true);
const [log, setLog] = useState<LogEntry[]>([]);
const [zones, setZones] = useState<string[]>([BUSINESS_TZ, "Europe/London", "America/New_York"]);
const [tz, setTz] = useState(BUSINESS_TZ);
const [notice, setNotice] = useState<Notice>(null);
const logId = useRef(0);
// booking flow
const [serviceId, setServiceId] = useState<number | null>(null);
const [staffSel, setStaffSel] = useState<number | "any">("any");
const [day, setDay] = useState("");
const [slot, setSlot] = useState<Slot | null>(null);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [confirmed, setConfirmed] = useState<{ id: number; raceLoser?: boolean } | null>(null);
// my bookings / admin
const [moving, setMoving] = useState<number | null>(null);
const [moveDay, setMoveDay] = useState("");
const [adminDate, setAdminDate] = useState("");
// Load after mount: the server render has no clock or storage, which keeps hydration stable.
useEffect(() => {
const t = Date.now();
let saved: Saved | null = null;
try {
const raw = localStorage.getItem(KEY);
if (raw) saved = JSON.parse(raw) as Saved;
} catch {
/* blocked or corrupted storage: start from the seed */
}
const today = dayInTz(t, BUSINESS_TZ);
setNow(t);
setDb(saved?.db?.appointments ? saved.db : seed(t));
setMine(saved?.mine ?? []);
setDay(today);
setMoveDay(today);
setAdminDate(today);
const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (local) setZones((z) => (z.includes(local) ? z : [z[0]!, local, ...z.slice(1)]));
}, []);
useEffect(() => {
if (!db) return;
try {
localStorage.setItem(KEY, JSON.stringify({ db, mine } satisfies Saved));
} catch {
/* ignore */
}
}, [db, mine]);
const record = (e: Omit<LogEntry, "id" | "role"> & { role?: Role }) =>
setLog((l) => [{ ...e, role: e.role ?? role, id: ++logId.current }, ...l].slice(0, 40));
if (!db) {
return (
<div className="p-6 font-mono text-sm text-fg-muted" aria-busy="true">
loading clinic calendar…
</div>
);
}
const staffFor = (sid: number) => db.staff.filter((s) => s.serviceIds.includes(sid));
const staffName = (id: number) => db.staff.find((s) => s.id === id)?.name ?? "?";
function fetchSlots(next: { serviceId: number; staffId: number | "any"; day: string }) {
if (!db) return;
const r = getAvailability(db, next, now);
record({ method: "GET", path: `/api/availability?serviceId=${next.serviceId}&staffId=${next.staffId}&date=${next.day}`, status: r.status, sql: r.sql, note: `${r.slots.length} free slots` });
}
function pickService(id: number) {
setServiceId(id);
setStaffSel("any");
setSlot(null);
setConfirmed(null);
fetchSlots({ serviceId: id, staffId: "any", day });
}
function pickStaff(id: number | "any") {
setStaffSel(id);
setSlot(null);
if (serviceId) fetchSlots({ serviceId, staffId: id, day });
}
function pickDay(d: string) {
setDay(d);
setSlot(null);
if (serviceId) fetchSlots({ serviceId, staffId: staffSel, day: d });
}
function onBook(e: React.FormEvent) {
e.preventDefault();
if (!slot || !serviceId) return;
const body = { serviceId, staffId: slot.staffId, start: slot.start, customer: name, email };
// Same DTO the server validates with, so messages match before and after the round trip.
const check = CreateBookingDto.safeParse(body);
if (!check.success) {
setErrors(Object.fromEntries(check.error.issues.map((i) => [String(i.path[0]), i.message])));
return;
}
setErrors({});
setBusy(true);
window.setTimeout(() => {
setBusy(false);
const r = createBooking(db!, body, now);
record({ method: "POST", path: "/api/bookings", status: r.status, sql: r.sql });
if (r.status === 201) {
const id = (r.body as { id: number }).id;
setDb(r.db);
setMine((m) => [...m, { id, email: check.data.email }]);
setConfirmed({ id });
setSlot(null);
setNotice({ tone: "ok", text: `Booked: ${bookingRef(id)}.` });
} else {
const msg = (r.body as { message: string | string[] }).message;
setNotice({ tone: "err", text: `${r.status}: ${Array.isArray(msg) ? msg.join(" ") : msg}` });
setSlot(null);
}
}, 450);
}
function onRace() {
if (!slot || !serviceId) return;
const you = CreateBookingDto.safeParse({ serviceId, staffId: slot.staffId, start: slot.start, customer: name, email });
const data = you.success ? you.data : { serviceId, staffId: slot.staffId, start: slot.start, customer: "Demo visitor", email: "visitor@example.com" };
const r = simulateRace(db!, data, now);
record({
method: "POST",
path: "/api/bookings ×2 (concurrent)",
status: r.b.status,
sql: r.sql,
note: `tx A → ${r.a.status}, tx B → ${r.b.status}. Exactly one booking was written.`,
});
if (r.a.status === 201) {
const id = (r.a.body as { id: number }).id;
setDb(r.db);
setMine((m) => [...m, { id, email: data.email }]);
setConfirmed({ id, raceLoser: true });
setSlot(null);
setNotice({ tone: "ok", text: `Race finished: you got ${bookingRef(id)}, the other request got 409 Conflict.` });
}
}
function apply(r: { status: number; body: unknown; sql: string[]; db: Db }, method: LogEntry["method"], path: string, okText: string, asRole?: Role) {
record({ method, path, status: r.status, sql: r.sql, role: asRole });
if (r.status < 300) {
setDb(r.db);
setNotice({ tone: "ok", text: okText });
return true;
}
const msg = (r.body as { message?: string | string[] }).message;
setNotice({ tone: "err", text: `${r.status}: ${Array.isArray(msg) ? msg.join(" ") : (msg ?? "Request failed")}` });
return false;
}
function switchTab(t: Tab) {
setTab(t);
if (t === "admin") {
const r = adminDay(db!, adminDate, role);
record({ method: "GET", path: `/api/admin/appointments?date=${adminDate}`, status: r.status, sql: r.sql });
}
}
function switchRole(r: Role) {
setRole(r);
setNotice({ tone: "ok", text: r === "admin" ? "Signed in as front desk (admin)." : "Signed in as a customer." });
if (tab === "admin") {
const res = adminDay(db!, adminDate, r);
setLog((l) => [{ id: ++logId.current, method: "GET" as const, path: `/api/admin/appointments?date=${adminDate}`, status: res.status, sql: res.sql, role: r }, ...l].slice(0, 40));
}
}
function reset() {
const t = Date.now();
const today = dayInTz(t, BUSINESS_TZ);
setNow(t);
setDb(seed(t));
setMine([]);
setLog([]);
setServiceId(null);
setSlot(null);
setConfirmed(null);
setDay(today);
setAdminDate(today);
setMoving(null);
setErrors({});
setNotice({ tone: "ok", text: "Demo reset to the seeded clinic." });
}
const service = serviceId ? serviceById(serviceId) : undefined;
const confirmedRow = confirmed ? db.appointments.find((a) => a.id === confirmed.id) : undefined;
const myRows = mine
.map((m) => ({ m, a: db.appointments.find((a) => a.id === m.id) }))
.filter((x): x is { m: Mine; a: NonNullable<typeof x.a> } => !!x.a)
.sort((x, y) => x.a.start - y.a.start);
return (
<div className={`grid ${devOpen ? "lg:grid-cols-[minmax(0,1fr)_24rem]" : ""}`}>
<div className="min-w-0">
{/* toolbar */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3 sm:px-6">
<div role="tablist" aria-label="Demo sections" className="flex gap-1 rounded-md bg-bg-muted p-1">
{(
[
["book", "Book"],
["mine", `My bookings${mine.length ? ` (${mine.length})` : ""}`],
["admin", "Admin calendar"],
] as const
).map(([t, label]) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
onClick={() => switchTab(t)}
className={`rounded px-2.5 py-1 text-sm ${tab === t ? "bg-bg text-fg shadow-sm" : "text-fg-muted hover:text-fg"}`}
>
{label}
</button>
))}
</div>
<label className="ml-auto flex items-center gap-1.5 text-xs text-fg-muted">
Role
<select value={role} onChange={(e) => switchRole(e.target.value as Role)} className="rounded border border-border bg-bg px-1.5 py-1 text-xs text-fg">
<option value="customer">customer</option>
<option value="admin">admin (front desk)</option>
</select>
</label>
<button type="button" onClick={() => setDevOpen((o) => !o)} aria-pressed={devOpen} className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent">
{devOpen ? "hide" : "show"} dev panel
</button>
<button type="button" onClick={reset} className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-danger hover:text-danger">
reset demo
</button>
</div>
<p role="status" aria-live="polite" className={`min-h-[1.75rem] px-4 pt-2 text-sm sm:px-6 ${notice?.tone === "err" ? "text-danger" : "text-accent"}`}>
{notice?.text}
</p>
{tab === "book" && (
<div className="space-y-6 p-4 pt-2 sm:p-6 sm:pt-2">
<p className="text-sm text-fg-muted">
<strong className="text-fg">Acme Family Clinic</strong> · dental and physio · open in {BUSINESS_TZ} ({tzLabel(BUSINESS_TZ, now)})
</p>
{confirmedRow ? (
<section aria-label="Booking confirmed" className="rounded-md border border-accent/50 bg-accent-soft p-5">
<p className="font-mono text-xs text-accent">201 Created</p>
<h3 className="mt-1 text-xl font-semibold">You are booked. Reference {bookingRef(confirmedRow.id)}</h3>
<p className="mt-2 text-sm">
{serviceById(confirmedRow.serviceId)?.name} with {staffName(confirmedRow.staffId)} on {fmtDay(dayInTz(confirmedRow.start, tz))} at{" "}
<strong>{fmtTime(confirmedRow.start, tz)}</strong> ({tzLabel(tz, confirmedRow.start)})
{tz !== BUSINESS_TZ && <> · {fmtTime(confirmedRow.start, BUSINESS_TZ)} clinic time</>}.
</p>
{confirmed?.raceLoser && (
<p className="mt-2 text-sm text-fg-muted">
A second request for the same slot ran at the same moment. The database let exactly one through; the other got <code>409 Conflict</code>. See the dev panel.
</p>
)}
<p className="mt-2 text-xs text-fg-muted">In production a confirmation email carries a signed link to cancel or reschedule.</p>
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" onClick={() => switchTab("mine")} className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90">
Manage booking
</button>
<button type="button" onClick={() => setConfirmed(null)} className="rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent">
Book another
</button>
</div>
</section>
) : (
<>
<fieldset>
<legend className="font-mono text-sm font-semibold">
<span className="text-accent">1.</span> Service
</legend>
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{SERVICES.map((s) => (
<button
key={s.id}
type="button"
onClick={() => pickService(s.id)}
aria-pressed={serviceId === s.id}
className={`rounded-md border p-3 text-left ${serviceId === s.id ? "border-accent bg-accent-soft" : "border-border bg-bg-elev hover:border-accent"}`}
>
<span className="block text-sm font-medium">{s.name}</span>
<span className="font-mono text-xs text-fg-muted">
{s.durationMin} min · {fmtPrice(s.price)}
</span>
</button>
))}
</div>
</fieldset>
{service && (
<fieldset>
<legend className="font-mono text-sm font-semibold">
<span className="text-accent">2.</span> With
</legend>
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={() => pickStaff("any")} aria-pressed={staffSel === "any"} className={`${chip} ${staffSel === "any" ? "border-accent bg-accent-soft" : "border-border hover:border-accent"}`}>
Anyone available
</button>
{staffFor(service.id).map((s) => (
<button key={s.id} type="button" onClick={() => pickStaff(s.id)} aria-pressed={staffSel === s.id} className={`${chip} ${staffSel === s.id ? "border-accent bg-accent-soft" : "border-border hover:border-accent"}`}>
{s.name} <span className="text-xs text-fg-muted">· {s.title}</span>
</button>
))}
</div>
</fieldset>
)}
{service && (
<fieldset>
<div className="flex flex-wrap items-end justify-between gap-2">
<legend className="font-mono text-sm font-semibold">
<span className="text-accent">3.</span> Date and time
</legend>
<label className="text-xs text-fg-muted">
Show times in{" "}
<select value={tz} onChange={(e) => setTz(e.target.value)} className="rounded border border-border bg-bg px-1.5 py-1 text-xs text-fg">
{zones.map((z) => (
<option key={z} value={z}>
{z === BUSINESS_TZ ? `${z} (clinic)` : z}
</option>
))}
</select>
</label>
</div>
<div className="mt-2">
<SlotPicker db={db} serviceId={service.id} staffSel={staffSel} day={day} onDay={pickDay} tz={tz} now={now} selected={slot} onPick={setSlot} idPrefix="book" />
</div>
</fieldset>
)}
{service && slot && (
<form onSubmit={onBook} noValidate className="space-y-3 rounded-md border border-border bg-bg-elev p-4">
<h3 className="font-mono text-sm font-semibold">
<span className="text-accent">4.</span> Your details
</h3>
<p className="text-sm">
{service.name} with <strong>{staffName(slot.staffId)}</strong>, {fmtDay(dayInTz(slot.start, tz))} {fmtTime(slot.start, tz)}–{fmtTime(slot.end, tz)} ({tzLabel(tz, slot.start)}) · {fmtPrice(service.price)}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="text-sm font-medium">Name</span>
<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="name" className={input} aria-invalid={!!errors.customer} aria-describedby={errors.customer ? "err-name" : undefined} />
{errors.customer && (
<span id="err-name" className="mt-1 block text-xs text-danger">
{errors.customer}
</span>
)}
</label>
<label className="block">
<span className="text-sm font-medium">Email</span>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="email" placeholder="you@example.com" className={input} aria-invalid={!!errors.email} aria-describedby={errors.email ? "err-email" : undefined} />
{errors.email && (
<span id="err-email" className="mt-1 block text-xs text-danger">
{errors.email}
</span>
)}
</label>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="submit" disabled={busy} className="rounded-md bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90 disabled:opacity-60">
{busy ? "Booking…" : "Confirm booking"}
</button>
<button type="button" onClick={onRace} disabled={busy} className="rounded-md border border-border px-3 py-2 font-mono text-xs hover:border-accent">
simulate 2 people booking this slot at once
</button>
</div>
</form>
)}
</>
)}
</div>
)}
{tab === "mine" && (
<div className="space-y-3 p-4 pt-2 sm:p-6 sm:pt-2">
{myRows.length === 0 && <p className="rounded-md border border-dashed border-border p-4 text-sm text-fg-muted">No bookings made in this browser yet. Book something first.</p>}
{myRows.map(({ m, a }) => {
const s = serviceById(a.serviceId)!;
const cancelled = a.status === "cancelled";
return (
<article key={a.id} className="rounded-md border border-border bg-bg-elev p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="font-semibold">
{bookingRef(a.id)} · {s.name}
</h3>
<span className={`font-mono text-xs ${cancelled ? "text-danger" : "text-accent"}`}>{a.status}</span>
</div>
<p className={`mt-1 text-sm ${cancelled ? "text-fg-muted line-through" : ""}`}>
{fmtDay(dayInTz(a.start, tz))} {fmtTime(a.start, tz)} ({tzLabel(tz, a.start)}) with {staffName(a.staffId)}
</p>
{!cancelled && (
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
setMoving(moving === a.id ? null : a.id);
setMoveDay(dayInTz(a.start, BUSINESS_TZ));
}}
aria-expanded={moving === a.id}
className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent"
>
reschedule
</button>
<button
type="button"
onClick={() => apply(cancelBooking(db, a.id, { role: "customer", email: m.email }), "PATCH", `/api/bookings/${a.id}/cancel`, `${bookingRef(a.id)} cancelled. The slot is free again.`, "customer")}
className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-danger hover:text-danger"
>
cancel
</button>
</div>
)}
{moving === a.id && !cancelled && (
<div className="mt-4 border-t border-border pt-4">
<p className="mb-2 text-xs text-fg-muted">Pick a new time with {staffName(a.staffId)}. The old time stays booked unless the move succeeds.</p>
<SlotPicker
db={db}
serviceId={a.serviceId}
staffSel={a.staffId}
day={moveDay}
onDay={(d) => {
setMoveDay(d);
fetchSlots({ serviceId: a.serviceId, staffId: a.staffId, day: d });
}}
tz={tz}
now={now}
days={10}
selected={null}
idPrefix={`mv-${a.id}`}
onPick={(sl) => {
const ok = apply(
rescheduleBooking(db, a.id, { start: sl.start, staffId: sl.staffId }, { role: "customer", email: m.email }, now),
"PATCH",
`/api/bookings/${a.id}/reschedule`,
`${bookingRef(a.id)} moved to ${fmtDay(dayInTz(sl.start, tz))} ${fmtTime(sl.start, tz)}.`,
"customer",
);
if (ok) setMoving(null);
}}
/>
</div>
)}
</article>
);
})}
</div>
)}
{tab === "admin" && (
<AdminCalendar
db={db}
role={role}
now={now}
day={adminDate}
onDay={(d) => {
setAdminDate(d);
const r = adminDay(db, d, role);
record({ method: "GET", path: `/api/admin/appointments?date=${d}`, status: r.status, sql: r.sql });
}}
onMove={(id, start, staffId) =>
apply(
rescheduleBooking(db, id, { start, staffId }, { role }, now),
"PATCH",
`/api/admin/appointments/${id}`,
`${bookingRef(id)} moved to ${fmtTime(start, BUSINESS_TZ)} with ${staffName(staffId)}.`,
)
}
onCancel={(id) => apply(cancelBooking(db, id, { role }), "PATCH", `/api/admin/appointments/${id}/cancel`, `${bookingRef(id)} cancelled.`)}
onToggleOff={(sid) => apply(toggleDayOff(db, sid, adminDate, role), "PATCH", `/api/admin/staff/${sid}/days-off/${adminDate}`, `Updated ${staffName(sid)}'s day off. Public availability changes immediately.`)}
onSwitchRole={() => switchRole("admin")}
/>
)}
</div>
{devOpen && (
<div className="border-t border-border bg-bg-elev p-3 lg:border-t-0 lg:border-l">
<p className="mb-2 px-1 font-mono text-xs text-fg-muted">dev panel: the request and SQL behind each action</p>
<DevPanel log={log} onClear={() => setLog([])} />
</div>
)}
</div>
);
}app/lab/booking-system/SlotPicker.tsx119 lines
"use client";
import { useMemo } from "react";
import { BUSINESS_TZ, addDays, dayInTz, fmtDay, fmtTime, freeSlots, minuteInTz, tzLabel, type Db, type Slot } from "./booking";
type Props = {
db: Db;
serviceId: number;
staffSel: number | "any";
day: string;
onDay: (day: string) => void;
tz: string;
now: number;
days?: number;
selected: Slot | null;
onPick: (slot: Slot) => void;
idPrefix: string;
};
const PARTS = [
{ label: "Morning", test: (m: number) => m < 12 * 60 },
{ label: "Afternoon", test: (m: number) => m >= 12 * 60 && m < 17 * 60 },
{ label: "Evening", test: (m: number) => m >= 17 * 60 },
];
/** Date strip + free time buttons. Times are shown in the visitor's chosen zone; the server works in UTC. */
export function SlotPicker({ db, serviceId, staffSel, day, onDay, tz, now, days = 14, selected, onPick, idPrefix }: Props) {
const today = dayInTz(now, BUSINESS_TZ);
const strip = useMemo(
() =>
Array.from({ length: days }, (_, i) => {
const d = addDays(today, i);
return { day: d, free: freeSlots(db, serviceId, staffSel, d, now).length };
}),
[db, serviceId, staffSel, today, now, days],
);
const slots = useMemo(() => freeSlots(db, serviceId, staffSel, day, now), [db, serviceId, staffSel, day, now]);
const nextWithSlots = strip.find((s) => s.day > day && s.free > 0);
const staffName = (id: number) => db.staff.find((s) => s.id === id)?.name ?? "";
const differs = tz !== BUSINESS_TZ;
return (
<div className="space-y-4">
<div role="group" aria-label="Choose a date" className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{strip.map((s) => {
const [wd, dn, mo] = fmtDay(s.day).split(" ");
const active = s.day === day;
return (
<button
key={s.day}
type="button"
onClick={() => onDay(s.day)}
aria-pressed={active}
aria-label={`${fmtDay(s.day)}, ${s.free} free times`}
className={`flex min-w-[4.25rem] shrink-0 flex-col items-center rounded-md border px-2 py-1.5 text-xs ${
active ? "border-accent bg-accent-soft text-fg" : "border-border hover:border-accent"
} ${s.free === 0 ? "opacity-60" : ""}`}
>
<span className="text-fg-muted">{wd?.replace(",", "")}</span>
<span className="text-base font-semibold">{dn}</span>
<span className="text-fg-muted">{mo}</span>
<span className={`mt-0.5 font-mono text-[10px] ${s.free ? "text-accent" : "text-fg-muted"}`}>{s.free ? `${s.free} free` : "full"}</span>
</button>
);
})}
</div>
{slots.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-4 text-sm text-fg-muted">
<p>No free times on {fmtDay(day)}. Staff may be off, fully booked or it is too close to now.</p>
{nextWithSlots && (
<button type="button" onClick={() => onDay(nextWithSlots.day)} className="mt-2 rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent hover:text-accent">
Jump to {fmtDay(nextWithSlots.day)} →
</button>
)}
</div>
) : (
<div className="space-y-3">
{PARTS.map((p) => {
const list = slots.filter((s) => p.test(minuteInTz(s.start, tz)));
if (!list.length) return null;
return (
<div key={p.label} role="group" aria-labelledby={`${idPrefix}-${p.label}`}>
<h4 id={`${idPrefix}-${p.label}`} className="mb-1.5 font-mono text-xs text-fg-muted">
{p.label} <span className="opacity-70">({tzLabel(tz, list[0]!.start)})</span>
</h4>
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-2">
{list.map((s) => {
const active = selected?.start === s.start && selected.staffId === s.staffId;
return (
<button
key={`${s.start}-${s.staffId}`}
type="button"
onClick={() => onPick(s)}
aria-pressed={active}
title={`${staffName(s.staffId)}${differs ? ` · clinic time ${fmtTime(s.start, BUSINESS_TZ)}` : ""}`}
className={`rounded-md border px-2 py-1.5 text-center font-mono text-sm ${
active ? "border-accent bg-accent text-accent-fg" : "border-border bg-bg-elev hover:border-accent"
}`}
>
{fmtTime(s.start, tz)}
{differs && (
<span className={`block text-[10px] ${active ? "text-accent-fg/80" : "text-fg-muted"}`}>
{fmtTime(s.start, BUSINESS_TZ)} clinic
</span>
)}
</button>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
);
}app/lab/booking-system/AdminCalendar.tsx269 lines
"use client";
import { useState } from "react";
import {
BUSINESS_TZ,
MIN,
addDays,
bookingRef,
dayInTz,
fmtDay,
fmtMinutes,
fmtTime,
minuteInTz,
serviceById,
weekday,
type Appointment,
type Db,
} from "./booking";
import type { Role } from "./api";
const START = 8 * 60;
const END = 20 * 60;
const PX = 1; // pixels per minute
const SNAP = 15;
type Props = {
db: Db;
role: Role;
now: number;
day: string;
onDay: (day: string) => void;
onMove: (id: number, start: number, staffId: number) => void;
onCancel: (id: number) => void;
onToggleOff: (staffId: number) => void;
onSwitchRole: () => void;
};
type Drag = { id: number; x0: number; y0: number; dx: number; dy: number };
export function AdminCalendar({ db, role, now, day, onDay, onMove, onCancel, onToggleOff, onSwitchRole }: Props) {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [drag, setDrag] = useState<Drag | null>(null);
const [showCancelled, setShowCancelled] = useState(true);
if (role !== "admin") {
return (
<div className="m-4 rounded-md border border-danger/40 p-5 sm:m-6" role="alert">
<p className="font-mono text-sm text-danger">403 Forbidden</p>
<p className="mt-2 text-sm text-fg-muted">
The admin calendar is behind a <code>RolesGuard</code>. You are signed in as a customer, so the API refused the
request before running any query. Open the dev panel to see it.
</p>
<button type="button" onClick={onSwitchRole} className="mt-3 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90">
Switch to admin (front desk)
</button>
</div>
);
}
const today = dayInTz(now, BUSINESS_TZ);
const rows = db.appointments.filter((a) => dayInTz(a.start, BUSINESS_TZ) === day && (showCancelled || a.status !== "cancelled"));
const selected = db.appointments.find((a) => a.id === selectedId) ?? null;
const active = rows.filter((a) => a.status !== "cancelled");
const hours = Array.from({ length: (END - START) / 60 + 1 }, (_, i) => START + i * 60);
const nowMin = day === today ? minuteInTz(now, BUSINESS_TZ) : null;
const staffName = (id: number) => db.staff.find((s) => s.id === id)?.name ?? "?";
function finishDrag(e: React.PointerEvent, a: Appointment) {
if (!drag || drag.id !== a.id) return;
const deltaMin = Math.round(drag.dy / PX / SNAP) * SNAP;
const col = document
.elementsFromPoint(e.clientX, e.clientY)
.find((el): el is HTMLElement => el instanceof HTMLElement && !!el.dataset.staffCol);
const staffId = col ? Number(col.dataset.staffCol) : a.staffId;
setDrag(null);
if (deltaMin !== 0 || staffId !== a.staffId) onMove(a.id, a.start + deltaMin * MIN, staffId);
}
function onKey(e: React.KeyboardEvent, a: Appointment) {
if (a.status === "cancelled") return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
onMove(a.id, a.start + (e.key === "ArrowUp" ? -SNAP : SNAP) * MIN, a.staffId);
}
}
return (
<div className="space-y-4 p-4 sm:p-6">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => onDay(addDays(day, -1))} aria-label="Previous day" className="rounded border border-border px-2 py-1 font-mono text-sm hover:border-accent">
←
</button>
<button type="button" onClick={() => onDay(today)} className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent">
today
</button>
<button type="button" onClick={() => onDay(addDays(day, 1))} aria-label="Next day" className="rounded border border-border px-2 py-1 font-mono text-sm hover:border-accent">
→
</button>
<h3 className="ml-1 font-semibold">{fmtDay(day)}</h3>
<span className="font-mono text-xs text-fg-muted">
{active.length} booked · clinic time ({BUSINESS_TZ})
</span>
<label className="ml-auto flex items-center gap-1.5 text-xs text-fg-muted">
<input type="checkbox" checked={showCancelled} onChange={(e) => setShowCancelled(e.target.checked)} />
show cancelled
</label>
</div>
<p className="text-xs text-fg-muted">
Drag a booking to move it (up/down for time, sideways for another staff member), or focus it and press ↑/↓ to move by 15 minutes.
Dropping onto someone else's booking is refused by the database.
</p>
<div className="max-h-[36rem] overflow-auto rounded-md border border-border">
<div className="grid min-w-[40rem]" style={{ gridTemplateColumns: `3.25rem repeat(${db.staff.length}, minmax(8.5rem, 1fr))` }}>
<div className="sticky top-0 z-20 border-b border-border bg-bg-muted" />
{db.staff.map((s) => {
const h = s.hours[weekday(day)];
const off = s.daysOff.includes(day);
const n = active.filter((a) => a.staffId === s.id).length;
return (
<div key={s.id} className="sticky top-0 z-20 border-b border-l border-border bg-bg-muted px-2 py-1.5">
<p className="truncate text-sm font-medium">{s.name}</p>
<p className="font-mono text-[10px] text-fg-muted">
{off ? "day off" : h ? `${fmtMinutes(h[0])}–${fmtMinutes(h[1])}` : "not working"} · {n} booked
</p>
{h && (
<button type="button" onClick={() => onToggleOff(s.id)} className="mt-0.5 font-mono text-[10px] text-accent hover:underline">
{off ? "undo day off" : "mark day off"}
</button>
)}
</div>
);
})}
<div className="relative" style={{ height: (END - START) * PX }}>
{hours.map((m) => (
<span key={m} className="absolute right-1 -translate-y-1/2 font-mono text-[10px] text-fg-muted" style={{ top: (m - START) * PX }}>
{m < END ? fmtMinutes(m) : ""}
</span>
))}
</div>
{db.staff.map((s) => {
const h = s.hours[weekday(day)];
const off = s.daysOff.includes(day) || !h;
const closed: (readonly [number, number])[] = off ? [[START, END]] : [[START, h[0]], [h[1], END], ...(s.breakAt ? [s.breakAt] : [])];
return (
<div key={s.id} data-staff-col={s.id} className="relative border-l border-border" style={{ height: (END - START) * PX }}>
{hours.map((m) => (
<div key={m} className="absolute inset-x-0 border-t border-border/60" style={{ top: (m - START) * PX }} aria-hidden="true" />
))}
{closed
.filter(([a, b]) => b > a)
.map(([a, b]) => (
<div
key={`${a}-${b}`}
aria-hidden="true"
className="absolute inset-x-0 bg-bg-muted/80 [background-image:repeating-linear-gradient(45deg,transparent_0_6px,var(--border)_6px_7px)]"
style={{ top: (Math.max(a, START) - START) * PX, height: (Math.min(b, END) - Math.max(a, START)) * PX }}
/>
))}
{nowMin !== null && nowMin >= START && nowMin <= END && (
<div aria-hidden="true" className="absolute inset-x-0 z-10 border-t-2 border-danger" style={{ top: (nowMin - START) * PX }} />
)}
{rows
.filter((a) => a.staffId === s.id)
.map((a) => {
const top = (minuteInTz(a.start, BUSINESS_TZ) - START) * PX;
const height = ((a.end - a.start) / MIN) * PX;
const cancelled = a.status === "cancelled";
const d = drag && drag.id === a.id ? drag : null;
const isDrag = d !== null;
const service = serviceById(a.serviceId);
return (
<button
key={a.id}
type="button"
aria-label={`${bookingRef(a.id)} ${a.customer}, ${service?.name}, ${fmtTime(a.start, BUSINESS_TZ)} to ${fmtTime(a.end, BUSINESS_TZ)}${cancelled ? ", cancelled" : ""}. Arrow keys move by 15 minutes.`}
onClick={() => setSelectedId(a.id)}
onKeyDown={(e) => onKey(e, a)}
onPointerDown={(e) => {
if (cancelled || e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag({ id: a.id, x0: e.clientX, y0: e.clientY, dx: 0, dy: 0 });
}}
onPointerMove={(e) => {
if (d) setDrag({ ...d, dx: e.clientX - d.x0, dy: e.clientY - d.y0 });
}}
onPointerUp={(e) => finishDrag(e, a)}
onPointerCancel={() => setDrag(null)}
className={`absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight ${
cancelled
? "z-0 border-dashed border-border bg-bg text-fg-muted line-through opacity-70"
: "z-10 cursor-grab touch-none border-accent/50 bg-accent-soft text-fg hover:border-accent active:cursor-grabbing"
} ${selectedId === a.id ? "ring-2 ring-accent" : ""} ${isDrag ? "z-30 opacity-90 shadow-lg" : ""}`}
style={{
top,
height: Math.max(height - 2, 14),
transform: d ? `translate(${d.dx}px, ${Math.round(d.dy / PX / SNAP) * SNAP * PX}px)` : undefined,
}}
>
<span className="font-mono">{fmtTime(a.start, BUSINESS_TZ)}</span> {a.customer}
{height >= 30 && <span className="block truncate text-fg-muted">{service?.name}</span>}
</button>
);
})}
</div>
);
})}
</div>
</div>
{selected ? (
<section aria-label="Selected booking" className="rounded-md border border-border bg-bg-elev p-4 text-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h4 className="font-semibold">
{bookingRef(selected.id)} · {selected.customer}
</h4>
<span className={`font-mono text-xs ${selected.status === "cancelled" ? "text-danger" : "text-accent"}`}>{selected.status}</span>
</div>
<dl className="mt-2 grid grid-cols-[6rem_1fr] gap-x-3 gap-y-1 text-fg-muted">
<dt>Service</dt>
<dd className="text-fg">{serviceById(selected.serviceId)?.name}</dd>
<dt>When</dt>
<dd className="text-fg">
{fmtDay(dayInTz(selected.start, BUSINESS_TZ))}, {fmtTime(selected.start, BUSINESS_TZ)}–{fmtTime(selected.end, BUSINESS_TZ)}
</dd>
<dt>With</dt>
<dd className="text-fg">{staffName(selected.staffId)}</dd>
<dt>Email</dt>
<dd className="truncate text-fg">{selected.email}</dd>
<dt>Source</dt>
<dd className="text-fg">{selected.source}</dd>
</dl>
{selected.status !== "cancelled" && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => onMove(selected.id, selected.start - SNAP * MIN, selected.staffId)} className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent">
−15 min
</button>
<button type="button" onClick={() => onMove(selected.id, selected.start + SNAP * MIN, selected.staffId)} className="rounded border border-border px-2 py-1 font-mono text-xs hover:border-accent">
+15 min
</button>
<label className="flex items-center gap-1.5 text-xs">
<span className="text-fg-muted">Move to</span>
<select
value={selected.staffId}
onChange={(e) => onMove(selected.id, selected.start, Number(e.target.value))}
className="rounded border border-border bg-bg px-1.5 py-1 text-xs"
>
{db.staff.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
<button type="button" onClick={() => onCancel(selected.id)} className="ml-auto rounded border border-border px-2 py-1 font-mono text-xs hover:border-danger hover:text-danger">
Cancel booking
</button>
</div>
)}
</section>
) : (
<p className="text-sm text-fg-muted">Select a booking to see its details.</p>
)}
</div>
);
}app/lab/booking-system/DevPanel.tsx79 lines
"use client";
import { useState } from "react";
import { SCHEMA_SQL, type Role } from "./api";
export type LogEntry = {
id: number;
method: "GET" | "POST" | "PATCH";
path: string;
status: number;
role: Role;
sql: string[];
note?: string;
};
// Always-dark terminal, so fixed colours instead of theme tokens.
const statusTone = (s: number) => (s < 300 ? "text-[#4ade80]" : s < 500 && s !== 409 && s !== 403 ? "text-[#fbbf24]" : "text-[#f87171]");
const sqlTone = (l: string) =>
/ERROR/.test(l)
? "text-[#f87171]"
: /ROLLBACK/.test(l)
? "text-[#fb923c]"
: /^(\[tx .\] )?(BEGIN|COMMIT)/.test(l)
? "text-[#c084fc]"
: /^(\[tx .\] )?\s*--/.test(l)
? "text-[#71717a]"
: "text-[#93c5fd]";
export function DevPanel({ log, onClear }: { log: LogEntry[]; onClear: () => void }) {
const [tab, setTab] = useState<"requests" | "schema">("requests");
return (
<section aria-label="Developer panel" className="flex h-full min-h-0 flex-col rounded-md border border-[#27272a] bg-[#0b0b0d] font-mono text-[12px] leading-relaxed text-[#d4d4d8]">
<div className="flex items-center gap-1 border-b border-[#27272a] px-2 py-1.5">
{(["requests", "schema"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setTab(t)}
aria-pressed={tab === t}
className={`rounded px-2 py-0.5 ${tab === t ? "bg-[#27272a] text-[#fafafa]" : "text-[#a1a1aa] hover:text-[#fafafa]"}`}
>
{t === "requests" ? `requests (${log.length})` : "schema.sql"}
</button>
))}
{tab === "requests" && log.length > 0 && (
<button type="button" onClick={onClear} className="ml-auto rounded px-2 py-0.5 text-[#a1a1aa] hover:text-[#fafafa]">
clear
</button>
)}
</div>
{tab === "schema" ? (
<pre className="max-h-[34rem] overflow-auto p-3 whitespace-pre text-[#93c5fd]">{SCHEMA_SQL}</pre>
) : (
<ol role="log" aria-live="polite" className="max-h-[34rem] min-h-40 space-y-3 overflow-y-auto p-3">
{log.length === 0 && <li className="text-[#71717a]">$ waiting for requests… pick a service to start.</li>}
{log.map((e) => (
<li key={e.id} className="border-b border-[#1f1f23] pb-2 last:border-0">
<div className="flex flex-wrap gap-x-2">
<span className="text-[#fafafa]">{e.method}</span>
<span className="break-all text-[#e4e4e7]">{e.path}</span>
<span className={statusTone(e.status)}>{e.status}</span>
<span className="text-[#71717a]">role={e.role}</span>
</div>
{e.note && <div className="text-[#a1a1aa]">» {e.note}</div>}
<pre className="mt-1 overflow-x-auto whitespace-pre">
{e.sql.map((l, i) => (
<span key={i} className={`block ${sqlTone(l)}`}>
{l}
</span>
))}
</pre>
</li>
))}
</ol>
)}
</section>
);
}