diff --git a/docs/phase-3-summary.md b/docs/phase-3-summary.md
new file mode 100644
index 0000000..b0dbd82
--- /dev/null
+++ b/docs/phase-3-summary.md
@@ -0,0 +1,125 @@
+# NextChair — Phase 3 Summary
+### Booking & Scheduling Engine
+
+Status: **Complete and verified.** Typecheck, lint, and `next build` are clean. 59 unit tests and 24 integration tests (against a real, throwaway Postgres) pass, including a genuine concurrency test that fires two simultaneous booking requests at the same slot and asserts exactly one succeeds. Per your instructions, no customer booking pages, barber calendar UI, owner dashboard, loyalty UI, or notification UI were built — the only UI is one unstyled internal test harness at `/dev/scheduling-test`, used to manually exercise the engine end-to-end in a real browser.
+
+---
+
+## 1. Architecture: six services, not one
+
+Your brief's `BookingService` and `SchedulingService` descriptions overlapped ("coordinates the booking workflow" vs. "coordinates availability and appointments, prevents double bookings"). I resolved that by giving `SchedulingService` one narrow, specific job — the transactional commit — and having `BookingService` own the workflow around it:
+
+```
+src/services/scheduling/
+ slot-calculator.ts # pure, DB-free window math (the core algorithm)
+ business-rules.service.ts # pure BookingRule evaluation (notice, buffers, caps)
+ staff-availability.service.ts # DB: one staff member's free windows for a day/range
+ availability.service.ts # DB: customer-facing queries + full slot diagnosis
+ scheduling.service.ts # DB: the ONE place a booking is actually committed
+ booking.service.ts # DB: create/reschedule/cancel workflow orchestration
+ appointment.service.ts # DB: retrieval + status-transition state machine
+ types.ts # shared TimeWindow/ValidationResult vocabulary
+```
+
+| Service | Owns | Does NOT own |
+|---|---|---|
+| `BusinessRulesService` | Evaluating one rule at a time (notice, advance window, daily caps, effective buffer) against plain inputs | Fetching anything from the database |
+| `StaffAvailabilityService` | One staff member's free time windows (working hours ∩ business hours − breaks − leave − buffered appointments) | Business hours themselves, requested services, or other staff |
+| `AvailabilityService` | Combining staff + services + rules into "what can a customer actually book" (dates/slots/barbers/services), and diagnosing *why* one specific candidate slot fails | Committing anything |
+| `SchedulingService` | The transactional double-booking guard — nothing else | Validation, workflow, business rules |
+| `BookingService` | The create/reschedule/cancel workflow: call Availability to validate, call Scheduling to commit, pick a staff member for "any barber" | Availability math or conflict detection itself |
+| `AppointmentService` | Retrieval and the status-transition state machine (check-in/start/complete/no-show) | Anything about booking, rescheduling, or availability |
+
+`slot-calculator.ts` is deliberately the only place with real algorithmic complexity, and it's 100% pure — no Prisma import at all — which is why it carries the bulk of the unit tests (24 of the 59).
+
+---
+
+## 2. Availability is never stored — verified, not just asserted
+
+Every availability answer (`getAvailableDates`, `getAvailableTimeSlots`, `getAvailableStaff`) is computed live from `BusinessHours` + `WorkingHours` + `Break`/`BreakDay` + `TimeOff` + non-cancelled `Appointment` rows on every call. There is no cache table, no materialized slot list, nothing to go stale. The cost of that is real DB round-trips per query — `getStaffFreeWindowsForRange` batches all four lookups per staff member into one round-trip *for the whole requested date range* (not once per day) specifically so a 60-day "which dates have openings" scan doesn't turn into hundreds of queries; see the comment in `staff-availability.service.ts`.
+
+---
+
+## 3. Prisma schema changes
+
+Kept deliberately minimal, per your instruction:
+
+- **`AppointmentStatus` enum renamed** to match your exact list: `PENDING_APPROVAL → PENDING`, `ARRIVED → CHECKED_IN` (the rest — `CONFIRMED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED`, `NO_SHOW` — already matched).
+- **`Appointment` gained cancellation-tracking fields**: `cancelledAt`, `cancelledByUserId` (→ `User`, nullable, `onDelete: SetNull`). `cancellationReason` already existed from Phase 2.
+- **`BookingRule` gained `maxAppointmentsPerStaffPerDay`** (`Int?`), distinct from the existing business-wide `maxAppointmentsPerDay`.
+
+No other models changed. The migration (`prisma/migrations/20260802165351_booking_engine/`) was generated non-interactively via `prisma migrate diff` (Prisma 7 removed the classic `migrate dev --create-only` path used by CI-style, non-interactive environments — see the "Prisma 7, again" note below) and applied + verified against a real database.
+
+---
+
+## 4. The status transition state machine
+
+```
+PENDING ──→ CONFIRMED ──→ CHECKED_IN ──→ IN_PROGRESS ──→ COMPLETED
+ │ │ │
+ ↓ ↓ ↓
+CANCELLED CANCELLED CANCELLED
+ │
+ ↓
+ NO_SHOW
+```
+
+`NO_SHOW` is reachable only from `CONFIRMED` — once an appointment is `CHECKED_IN`, the customer demonstrably showed up, so "no-show" no longer makes sense as a next state. `COMPLETED`, `CANCELLED`, and `NO_SHOW` are terminal — nothing transitions out of them. This is enforced by `appointment.service.ts`'s `canTransition`/`validateTransition`, applied inside a transaction in every status-changing call, and has full unit-test coverage of every legal and illegal transition (`appointment.service.test.ts`).
+
+---
+
+## 5. Validation — specific, not generic
+
+Your brief listed seven distinct things to check before a booking, and asked for "meaningful validation errors" — so `AvailabilityService.validateBookingSlot` diagnoses a candidate slot in a specific order, each with its own error code, rather than one generic "unavailable":
+
+`BUSINESS_CLOSED` → `STAFF_NOT_WORKING` → `STAFF_ON_LEAVE` → `STAFF_ON_BREAK` → `INSUFFICIENT_DURATION` (the services genuinely don't fit in the remaining open time, computed *without* existing appointments) → `BUSINESS_DAILY_LIMIT_REACHED` / `STAFF_DAILY_LIMIT_REACHED` → `SLOT_UNAVAILABLE` (fits in principle, but something else is already booked there). Plus `BELOW_MIN_NOTICE` and `BEYOND_MAX_ADVANCE`, checked alongside the rest. All 18 codes are in `types.ts` as a literal union, not a loose string, so a caller can exhaustively switch on them.
+
+This diagnosis is a best-effort read for a good error message — it can be (rarely) stale by the time a booking actually commits, same as any read-then-write. The transactional commit in `SchedulingService` is what's actually authoritative.
+
+---
+
+## 6. Double-booking prevention — the part I verified hardest
+
+Two layers, both necessary:
+
+1. **An explicit overlap re-check**, inside the transaction, against the target staff member's other non-cancelled appointments — mirrors the exact buffer logic `AvailabilityService` uses, so a slot that was offered can't be rejected here for a *different* reason than why it wouldn't have been offered.
+2. **`SERIALIZABLE` transaction isolation** (`Prisma.TransactionIsolationLevel.Serializable`) as the real guarantee — standard SQL, supported by both Postgres and MySQL (unlike a Postgres-only exclusion constraint, which would have broken your database-portability goal). Under a genuine race, Postgres itself rejects one of the two concurrent transactions with a serialization failure; my code catches that and turns it into the same `SLOT_UNAVAILABLE` response a normal validation failure would produce — the caller never sees a raw database error.
+
+**This is the one place in the whole phase where I found and fixed a real bug by actually testing it, not by reasoning about it.** My first version of the double-booking test passed, but a *later* full-suite run failed intermittently with an unhandled `DriverAdapterError`. Prisma 7's new TypeScript-native client-engine-runtime (used because of the driver-adapter architecture — see below) propagates a serialization conflict as the driver adapter's own `DriverAdapterError` (`cause.kind === "TransactionWriteConflict"`, Postgres SQLSTATE `40001`), not as the classic `PrismaClientKnownRequestError` with code `P2034` that my first version only checked for. Whichever of the two competing transactions happened to lose the race *inside* my own application-level overlap check got a clean `DoubleBookingError`; the one that lost the race at the *database* level (a true concurrent-write conflict my query-level check couldn't see) crashed instead. I only found this because I ran the integration suite multiple times rather than trusting one green run — it's now fixed to catch both error shapes and re-verified clean across 5 consecutive runs. See the comment in `scheduling.service.ts`.
+
+---
+
+## 7. Assumptions made this phase
+
+- **Effective buffer = business-wide baseline + the largest requested service's own buffer** (not a replacement, and not summed across every service in a multi-service booking — just the max). A colour treatment's extra processing buffer stacks on top of the shop's standard gap; two services in one appointment don't double that extra buffer.
+- **"Update booking" (from your API list) means notes only.** Time or staff changes go through `rescheduleBooking` instead, which is a distinct, fully re-validated workflow — letting a generic "update" silently move an appointment's time without the same validation would undermine the whole point of having a separate reschedule path.
+- **Cancellation is never blocked by the cancellation-window rule.** `cancelBooking` always succeeds (down to the appointment's own status making it eligible) and returns `wasLateCancellation: boolean` so a caller can show "this was a late cancellation" — refusing to let someone cancel an appointment they don't want seemed like the wrong call in the absence of any payments/fees system to actually enforce a penalty.
+- **"Any available barber" picks whoever has the fewest appointments that day** among staff free at the requested slot, to spread bookings out rather than always favouring the first staff member in the list. A more sophisticated strategy (least-idle-time, staff preference weighting) is a reasonable Phase-5-or-later refinement, not a Phase 3 concern.
+- **A day never crosses midnight.** All window math clamps to `[0, 1440]` minutes; a barbershop open past midnight isn't modelled. Reasonable for the domain, worth confirming before this ever needs to change.
+- **Guest customers aren't created by this phase.** `BookingService.createBooking` requires an existing `customerId` — resolving *who* is booking (guest-details capture, account matching) is explicitly a Phase 1/booking-UI concern per the implementation plan, not this engine's job.
+
+---
+
+## 8. A Prisma 7 detail worth flagging again
+
+Generating a schema migration *non-interactively* changed in Prisma 7: `prisma migrate dev` refuses to run at all outside a real TTY once it detects a diff it would normally ask you to confirm (here, the `AppointmentStatus` enum rename). The CI-safe replacement is `prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`, writing the output into a manually-created migration folder, then applying it with `prisma migrate deploy`. Both are non-interactive by design. Documented here because it's the kind of thing that looks like a broken tool the first time you hit it.
+
+---
+
+## 9. What was actually verified (not just built)
+
+1. **59 unit tests**, zero database — `slot-calculator.ts` (window intersection/subtraction/merging, grid-aligned slot generation), `business-rules.service.ts` (every rule check plus edge-of-boundary cases), `appointment.service.ts` (every legal and illegal status transition), and `business-time.ts` (including two tests that assert *specific* UTC instants either side of the actual 2026 UK daylight-saving transition — a naive "always UTC+0" implementation would fail these).
+2. **24 integration tests**, real Postgres (a throwaway Docker container, migrated, torn down afterward) — availability calculation, business-closed days, staff working hours, breaks, time off (both "blocks the day" and "doesn't block an unrelated day"), multi-service duration combination and price/duration snapshotting, buffer enforcement, every booking-rule validation code, double-booking rejection (both a simple sequential case and the real concurrency case), rescheduling (including "doesn't conflict with its own original slot" and "rejects a completed appointment"), and cancellation (status/fields set correctly, never deletes, frees the slot, rejects cancelling something already completed).
+3. **Manually, in a real browser**, against the same throwaway database: opened `/dev/scheduling-test`, selected a service, saw the calendar correctly gray out Sundays (business closed) and highlight real openings, selected a date, saw real time slots generated (10:00–18:30, matching the seeded business hours and 30-minute grid), booked one with "Anyone available" (which correctly auto-assigned a specific barber), and drove it through Check In (confirmed the status pill updated to "Arrived" after a real server round-trip).
+
+## 10. Running it yourself
+
+```bash
+npm run test # 59 unit tests, no database needed
+npm run test:integration # 24 integration tests — needs DATABASE_URL pointed at a real Postgres
+npm run dev # then visit /dev/scheduling-test
+```
+
+---
+
+Stopping here as instructed — no customer booking pages, barber calendar, owner dashboard, loyalty UI, or notifications. Ready for direction on what's next.
diff --git a/package-lock.json b/package-lock.json
index 98cf19e..0ff28b4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.28.0",
+ "luxon": "^3.7.2",
"next": "^16.2.12",
"next-auth": "^5.0.0-beta.32",
"pg": "^8.22.0",
@@ -32,6 +33,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6",
+ "@types/luxon": "^3.7.3",
"@types/node": "^26.1.2",
"@types/pg": "^8.20.3",
"@types/react": "^19.2.18",
@@ -45,7 +47,8 @@
"prisma": "^7.9.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.4",
- "typescript": "^6.0.3"
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.10"
}
},
"node_modules/@alloc/quick-lru": {
@@ -2014,6 +2017,16 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@oxc-project/types": {
+ "version": "0.142.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+ "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
@@ -2970,6 +2983,319 @@
}
}
},
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
+ "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz",
+ "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz",
+ "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz",
+ "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz",
+ "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz",
+ "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz",
+ "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz",
+ "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz",
+ "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz",
+ "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz",
+ "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz",
+ "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz",
+ "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "2.0.0-alpha.3",
+ "@emnapi/runtime": "2.0.0-alpha.3",
+ "@napi-rs/wasm-runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "2.0.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "2.0.0-alpha.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz",
+ "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
+ "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz",
+ "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz",
+ "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -3300,6 +3626,17 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
"node_modules/@types/d3-array": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz",
@@ -3389,6 +3726,13 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3424,6 +3768,13 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@types/luxon": {
+ "version": "3.7.3",
+ "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.3.tgz",
+ "integrity": "sha512-pE7BSbKHiojpl4v7iEzdfFLXXJaH5RPJxI3Wr4x3HU59l83PJfhcUx4mGPWq245+DCAENAzRF/+ZoYr2tJCe/g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "26.1.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
@@ -4251,6 +4602,119 @@
"node": ">=12"
}
},
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -4484,6 +4948,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4749,6 +5223,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5439,6 +5923,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -5994,6 +6485,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -6004,6 +6505,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exsolve": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
@@ -7655,6 +8166,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -8083,6 +8603,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@@ -8886,6 +9420,40 @@
"devOptional": true,
"license": "Unlicense"
},
+ "node_modules/rolldown": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz",
+ "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.142.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.1",
+ "@rolldown/binding-darwin-arm64": "1.2.1",
+ "@rolldown/binding-darwin-x64": "1.2.1",
+ "@rolldown/binding-freebsd-x64": "1.2.1",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.1",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.1",
+ "@rolldown/binding-linux-arm64-musl": "1.2.1",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.1",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-gnu": "1.2.1",
+ "@rolldown/binding-linux-x64-musl": "1.2.1",
+ "@rolldown/binding-openharmony-arm64": "1.2.1",
+ "@rolldown/binding-wasm32-wasi": "1.2.1",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.1",
+ "@rolldown/binding-win32-x64-msvc": "1.2.1"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -9213,6 +9781,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -9261,6 +9836,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
@@ -9499,6 +10081,23 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
+ "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -9547,6 +10146,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+ "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9872,6 +10481,480 @@
}
}
},
+ "node_modules/vite": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
+ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.23",
+ "rolldown": "~1.2.0",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/vitest/node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9977,6 +11060,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/package.json b/package.json
index 24d2746..a2eb00f 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,10 @@
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
- "prisma:seed": "tsx prisma/seed.ts"
+ "prisma:seed": "tsx prisma/seed.ts",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:integration": "vitest run --config vitest.integration.config.mts"
},
"repository": {
"type": "git",
@@ -34,6 +37,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.28.0",
+ "luxon": "^3.7.2",
"next": "^16.2.12",
"next-auth": "^5.0.0-beta.32",
"pg": "^8.22.0",
@@ -46,6 +50,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6",
+ "@types/luxon": "^3.7.3",
"@types/node": "^26.1.2",
"@types/pg": "^8.20.3",
"@types/react": "^19.2.18",
@@ -59,6 +64,7 @@
"prisma": "^7.9.1",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.4",
- "typescript": "^6.0.3"
+ "typescript": "^6.0.3",
+ "vitest": "^4.1.10"
}
}
diff --git a/prisma/migrations/20260802165351_booking_engine/migration.sql b/prisma/migrations/20260802165351_booking_engine/migration.sql
new file mode 100644
index 0000000..6bf7a7e
--- /dev/null
+++ b/prisma/migrations/20260802165351_booking_engine/migration.sql
@@ -0,0 +1,21 @@
+-- AlterEnum
+BEGIN;
+CREATE TYPE "AppointmentStatus_new" AS ENUM ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'NO_SHOW');
+ALTER TABLE "public"."Appointment" ALTER COLUMN "status" DROP DEFAULT;
+ALTER TABLE "Appointment" ALTER COLUMN "status" TYPE "AppointmentStatus_new" USING ("status"::text::"AppointmentStatus_new");
+ALTER TYPE "AppointmentStatus" RENAME TO "AppointmentStatus_old";
+ALTER TYPE "AppointmentStatus_new" RENAME TO "AppointmentStatus";
+DROP TYPE "public"."AppointmentStatus_old";
+ALTER TABLE "Appointment" ALTER COLUMN "status" SET DEFAULT 'CONFIRMED';
+COMMIT;
+
+-- AlterTable
+ALTER TABLE "Appointment" ADD COLUMN "cancelledAt" TIMESTAMP(3),
+ADD COLUMN "cancelledByUserId" TEXT;
+
+-- AlterTable
+ALTER TABLE "BookingRule" ADD COLUMN "maxAppointmentsPerStaffPerDay" INTEGER;
+
+-- AddForeignKey
+ALTER TABLE "Appointment" ADD CONSTRAINT "Appointment_cancelledByUserId_fkey" FOREIGN KEY ("cancelledByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a3aaf35..6c3b269 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -61,10 +61,16 @@ enum StaffStatus {
INACTIVE
}
+/// PENDING -> CONFIRMED -> CHECKED_IN -> IN_PROGRESS -> COMPLETED is the
+/// happy path. CANCELLED is reachable from PENDING/CONFIRMED/CHECKED_IN.
+/// NO_SHOW is reachable only from CONFIRMED (once CHECKED_IN, the customer
+/// did show up). COMPLETED/CANCELLED/NO_SHOW are terminal. The transition
+/// map itself lives in services/scheduling/appointment.service.ts, not
+/// here — this enum is just the vocabulary.
enum AppointmentStatus {
- PENDING_APPROVAL
+ PENDING
CONFIRMED
- ARRIVED
+ CHECKED_IN
IN_PROGRESS
COMPLETED
CANCELLED
@@ -226,15 +232,19 @@ model BookingRule {
businessId String @unique
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
- minNoticeMinutes Int @default(120)
- maxAdvanceDays Int @default(60)
- cancellationWindowHours Int @default(24)
- rescheduleWindowHours Int @default(24)
- slotIntervalMinutes Int @default(30)
- bufferBeforeMinutes Int @default(0)
- bufferAfterMinutes Int @default(5)
- /// null = no daily cap.
- maxAppointmentsPerDay Int?
+ minNoticeMinutes Int @default(120)
+ maxAdvanceDays Int @default(60)
+ cancellationWindowHours Int @default(24)
+ rescheduleWindowHours Int @default(24)
+ slotIntervalMinutes Int @default(30)
+ bufferBeforeMinutes Int @default(0)
+ bufferAfterMinutes Int @default(5)
+ /// null = no daily cap. Business-wide total across all staff.
+ maxAppointmentsPerDay Int?
+ /// null = no per-staff cap. Distinct from maxAppointmentsPerDay — this
+ /// limits how many appointments any single staff member can take in a
+ /// day, independent of the shop-wide total.
+ maxAppointmentsPerStaffPerDay Int?
customerCanChooseStaff Boolean @default(true)
allowGuestBooking Boolean @default(true)
@@ -281,6 +291,9 @@ model User {
staffProfile StaffProfile?
notifications Notification[]
notificationPreferences NotificationPreference[]
+ /// Whoever actually cancelled an appointment — the customer themselves,
+ /// or the staff member who did it on their behalf.
+ cancelledAppointments Appointment[] @relation("AppointmentCancelledBy")
}
model Account {
@@ -577,8 +590,14 @@ model Appointment {
startAt DateTime
endAt DateTime
- notes String? @db.Text
+ notes String? @db.Text
+
+ /// Cancellation is a status transition, never a delete — these three
+ /// fields are only ever set together, when status becomes CANCELLED.
cancellationReason String?
+ cancelledAt DateTime?
+ cancelledByUserId String?
+ cancelledByUser User? @relation("AppointmentCancelledBy", fields: [cancelledByUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
diff --git a/src/app/dev/scheduling-test/page.tsx b/src/app/dev/scheduling-test/page.tsx
new file mode 100644
index 0000000..b4856f9
--- /dev/null
+++ b/src/app/dev/scheduling-test/page.tsx
@@ -0,0 +1,55 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { prisma } from "@/lib/db/prisma";
+import { SchedulingTestClient } from "./scheduling-test-client";
+
+export const metadata: Metadata = { title: "Scheduling Engine — Dev Test" };
+// Always reflect live DB state — this is a manual test harness, not a page that should serve a build-time snapshot.
+export const dynamic = "force-dynamic";
+
+/**
+ * Internal-only harness for manually exercising the booking engine
+ * (Phase 3) — not a customer-facing screen, not linked from any nav. Uses
+ * the seeded demo business/staff/customer from prisma/seed.ts. See
+ * docs/phase-3-summary.md.
+ */
+export default async function SchedulingTestPage() {
+ const business = await prisma.business.findUnique({
+ where: { slug: "blake-heritage-barbershop" },
+ select: { id: true, name: true, timezone: true },
+ });
+ if (!business) {
+ notFound();
+ }
+
+ const [services, staff, customer] = await Promise.all([
+ prisma.service.findMany({
+ where: { businessId: business.id, isActive: true },
+ orderBy: { displayOrder: "asc" },
+ }),
+ prisma.staffProfile.findMany({
+ where: { businessId: business.id },
+ include: { user: { select: { name: true } } },
+ }),
+ prisma.customerProfile.findFirst({
+ where: { email: "alex.chen@example.com" },
+ }),
+ ]);
+
+ if (!customer) {
+ return (
+
+ No seeded customer found — run npm run prisma:seed first.
+
+ );
+ }
+
+ return (
+ ({ id: s.id, name: s.name, durationMinutes: s.durationMinutes, price: s.price.toString() }))}
+ staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff member" }))}
+ customerId={customer.id}
+ />
+ );
+}
diff --git a/src/app/dev/scheduling-test/scheduling-test-client.tsx b/src/app/dev/scheduling-test/scheduling-test-client.tsx
new file mode 100644
index 0000000..dc49f5b
--- /dev/null
+++ b/src/app/dev/scheduling-test/scheduling-test-client.tsx
@@ -0,0 +1,289 @@
+"use client";
+
+import { useEffect, useState, useTransition } from "react";
+import {
+ getAvailableDatesAction,
+ getAvailableTimeSlotsAction,
+} from "@/features/booking/actions/availability";
+import {
+ cancelBookingAction,
+ createBookingAction,
+ rescheduleBookingAction,
+} from "@/features/booking/actions/booking";
+import {
+ checkInAction,
+ completeAppointmentAction,
+ listAppointmentsAction,
+ markNoShowAction,
+ startAppointmentAction,
+} from "@/features/booking/actions/appointment";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import { MonthCalendar } from "@/components/ui/month-calendar";
+import { Select } from "@/components/ui/select";
+import { StatusPill } from "@/components/ui/status-pill";
+import type { AppointmentStatus } from "@/types/domain";
+
+interface Service {
+ id: string;
+ name: string;
+ durationMinutes: number;
+ price: string;
+}
+interface Staff {
+ id: string;
+ name: string;
+}
+interface TimeSlot {
+ startMinutes: number;
+ startAt: string;
+ endAt: string;
+ staffOptions: string[];
+}
+
+function toYMD(date: Date): string {
+ return date.toISOString().slice(0, 10);
+}
+
+const EMPTY_SET: Set = new Set();
+const EMPTY_SLOTS: TimeSlot[] = [];
+
+const STATUS_MAP: Record = {
+ PENDING: "pending_approval",
+ CONFIRMED: "confirmed",
+ CHECKED_IN: "arrived",
+ IN_PROGRESS: "in_progress",
+ COMPLETED: "completed",
+ CANCELLED: "cancelled",
+ NO_SHOW: "no_show",
+};
+
+export function SchedulingTestClient(props: {
+ business: { id: string; name: string; timezone: string };
+ services: Service[];
+ staff: Staff[];
+ customerId: string;
+}) {
+ const { business, services, staff, customerId } = props;
+ const [isPending, startTransition] = useTransition();
+
+ const [selectedServiceIds, setSelectedServiceIds] = useState(services[0] ? [services[0].id] : []);
+ const [selectedStaffId, setSelectedStaffId] = useState("any");
+ const [month, setMonth] = useState(() => new Date());
+ const [availableDates, setAvailableDates] = useState>(new Set());
+ const [selectedDate, setSelectedDate] = useState();
+ const [slots, setSlots] = useState([]);
+ const [notes, setNotes] = useState("");
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const [appointments, setAppointments] = useState([]);
+ const [message, setMessage] = useState(null);
+
+ const refreshAppointments = () => {
+ listAppointmentsAction({ businessId: business.id, businessTimezone: business.timezone }).then(setAppointments);
+ };
+
+ useEffect(() => {
+ refreshAppointments();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Available dates for the visible month. When nothing is selected there's
+ // nothing to fetch — `effectiveAvailableDates` below derives the empty
+ // case at render time rather than this effect resetting state
+ // synchronously (which cascades an extra render for no reason).
+ useEffect(() => {
+ if (selectedServiceIds.length === 0) return;
+ const from = new Date(month.getFullYear(), month.getMonth(), 1);
+ const to = new Date(month.getFullYear(), month.getMonth() + 1, 0);
+ getAvailableDatesAction({
+ businessId: business.id,
+ serviceIds: selectedServiceIds,
+ staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
+ fromDate: toYMD(from),
+ toDate: toYMD(to),
+ }).then((res) => setAvailableDates(new Set(res.dates)));
+ }, [business.id, month, selectedServiceIds, selectedStaffId]);
+ const effectiveAvailableDates = selectedServiceIds.length === 0 ? EMPTY_SET : availableDates;
+
+ // Time slots for the selected date — same derive-at-render-time pattern.
+ useEffect(() => {
+ if (!selectedDate || selectedServiceIds.length === 0) return;
+ getAvailableTimeSlotsAction({
+ businessId: business.id,
+ serviceIds: selectedServiceIds,
+ staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
+ date: toYMD(selectedDate),
+ }).then((res) => setSlots(res.slots));
+ }, [business.id, selectedDate, selectedServiceIds, selectedStaffId]);
+ const effectiveSlots = !selectedDate || selectedServiceIds.length === 0 ? EMPTY_SLOTS : slots;
+
+ const toggleService = (id: string) => {
+ setSelectedServiceIds((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
+ };
+
+ const book = (slot: TimeSlot) => {
+ startTransition(async () => {
+ const result = await createBookingAction({
+ businessId: business.id,
+ customerId,
+ serviceIds: selectedServiceIds,
+ staffId: selectedStaffId === "any" ? undefined : selectedStaffId,
+ startAt: slot.startAt,
+ notes: notes || undefined,
+ });
+ if (result.success) {
+ setMessage(`Booked: ${result.appointment.id}`);
+ setNotes("");
+ setSelectedDate(undefined);
+ refreshAppointments();
+ } else {
+ setMessage(`Failed: ${result.validation.issues.map((i) => `${i.code} — ${i.message}`).join("; ")}`);
+ }
+ });
+ };
+
+ const cancel = (appointmentId: string) => {
+ startTransition(async () => {
+ const result = await cancelBookingAction({ appointmentId, cancelledByUserId: customerId, reason: "Dev test" });
+ setMessage(result.success ? "Cancelled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
+ refreshAppointments();
+ });
+ };
+
+ const reschedule = (appointmentId: string) => {
+ const input = window.prompt("New start time (ISO, e.g. 2026-08-20T10:00:00.000Z):");
+ if (!input) return;
+ startTransition(async () => {
+ const result = await rescheduleBookingAction({ appointmentId, startAt: input });
+ setMessage(result.success ? "Rescheduled" : `Failed: ${result.validation.issues.map((i) => i.message).join("; ")}`);
+ refreshAppointments();
+ });
+ };
+
+ const runTransition = (fn: (id: string) => Promise<{ success: boolean }>, appointmentId: string) => {
+ startTransition(async () => {
+ const result = await fn(appointmentId);
+ setMessage(result.success ? "Status updated" : "Transition rejected");
+ refreshAppointments();
+ });
+ };
+
+ return (
+
+
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+ 1. Services
+
+ {services.map((s) => (
+ toggleService(s.id)}
+ />
+ ))}
+
+
+ 2. Barber
+
+
+
+
+ 3. Date
+ !effectiveAvailableDates.has(toYMD(date))}
+ />
+ Selectable dates are ones the engine reports as having openings.
+
+
+ {selectedDate && (
+
+ 4. Time
+ {effectiveSlots.length === 0 ? (
+ No available slots this day for this selection.
+ ) : (
+
+ {effectiveSlots.map((slot) => (
+
+ ))}
+
+ )}
+ setNotes(e.target.value)}
+ placeholder="Notes (optional)"
+ className="w-full rounded-lg border border-line px-3 py-2 text-sm"
+ />
+
+ )}
+
+
+ Appointments
+ {appointments.length === 0 && None yet.
}
+ {appointments.map((a) => (
+
+
+
+ {new Date(a.startAt).toLocaleString("en-GB", { timeZone: business.timezone })}
+
+
+ {a.staff.user.name} · {a.services.map((s: { service: { name: string } }) => s.service.name).join(" + ")}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/booking/actions/appointment.ts b/src/features/booking/actions/appointment.ts
new file mode 100644
index 0000000..de8875e
--- /dev/null
+++ b/src/features/booking/actions/appointment.ts
@@ -0,0 +1,49 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { prisma } from "@/lib/db/prisma";
+import * as AppointmentService from "@/services/scheduling/appointment.service";
+
+export async function getAppointmentAction(appointmentId: string) {
+ return AppointmentService.getAppointmentById(prisma, appointmentId);
+}
+
+export async function listAppointmentsAction(params: Parameters[1]) {
+ return AppointmentService.listAppointments(prisma, params);
+}
+
+// Next's Server Actions compiler statically analyses "use server" files for
+// exported `async function` declarations — a `const` bound to a factory's
+// return value (however trivial) isn't recognized as a callable action, so
+// each one below is a real function declaration even though the bodies
+// are identical apart from which AppointmentService transition they call.
+
+export async function checkInAction(appointmentId: string) {
+ const result = await AppointmentService.checkIn(prisma, appointmentId);
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+export async function startAppointmentAction(appointmentId: string) {
+ const result = await AppointmentService.start(prisma, appointmentId);
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+export async function completeAppointmentAction(appointmentId: string) {
+ const result = await AppointmentService.complete(prisma, appointmentId);
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+export async function markNoShowAction(appointmentId: string) {
+ const result = await AppointmentService.markNoShow(prisma, appointmentId);
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+export async function confirmAppointmentAction(appointmentId: string) {
+ const result = await AppointmentService.confirm(prisma, appointmentId);
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
diff --git a/src/features/booking/actions/availability.ts b/src/features/booking/actions/availability.ts
new file mode 100644
index 0000000..c245d62
--- /dev/null
+++ b/src/features/booking/actions/availability.ts
@@ -0,0 +1,48 @@
+"use server";
+
+import { prisma } from "@/lib/db/prisma";
+import * as AvailabilityService from "@/services/scheduling/availability.service";
+import { fail } from "@/services/scheduling/types";
+import {
+ getAvailableBarbersSchema,
+ getAvailableDatesSchema,
+ getAvailableServicesSchema,
+ getAvailableTimeSlotsSchema,
+} from "../schemas";
+
+/**
+ * Thin wrappers over AvailabilityService — no business logic here, only
+ * input parsing and the client↔server boundary. See
+ * docs/implementation-plan.md §11 ("Server Actions... orchestrate the
+ * appropriate domain services").
+ */
+
+export async function getAvailableServicesAction(input: unknown) {
+ const parsed = getAvailableServicesSchema.safeParse(input);
+ if (!parsed.success) return { services: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+ const services = await AvailabilityService.getAvailableServices(prisma, parsed.data);
+ return { services };
+}
+
+export async function getAvailableBarbersAction(input: unknown) {
+ const parsed = getAvailableBarbersSchema.safeParse(input);
+ if (!parsed.success) return { staff: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+ const staff = await AvailabilityService.getAvailableStaff(prisma, parsed.data);
+ return { staff };
+}
+
+export async function getAvailableDatesAction(input: unknown) {
+ const parsed = getAvailableDatesSchema.safeParse(input);
+ if (!parsed.success) {
+ return { dates: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+ }
+ return AvailabilityService.getAvailableDates(prisma, parsed.data);
+}
+
+export async function getAvailableTimeSlotsAction(input: unknown) {
+ const parsed = getAvailableTimeSlotsSchema.safeParse(input);
+ if (!parsed.success) {
+ return { slots: [], validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+ }
+ return AvailabilityService.getAvailableTimeSlots(prisma, parsed.data);
+}
diff --git a/src/features/booking/actions/booking.ts b/src/features/booking/actions/booking.ts
new file mode 100644
index 0000000..04f2c60
--- /dev/null
+++ b/src/features/booking/actions/booking.ts
@@ -0,0 +1,56 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { prisma } from "@/lib/db/prisma";
+import * as BookingService from "@/services/scheduling/booking.service";
+import { fail } from "@/services/scheduling/types";
+import { cancelBookingSchema, createBookingSchema, rescheduleBookingSchema, updateBookingNotesSchema } from "../schemas";
+
+export async function createBookingAction(input: unknown) {
+ const parsed = createBookingSchema.safeParse(input);
+ if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+
+ const result = await BookingService.createBooking(prisma, {
+ ...parsed.data,
+ startAt: new Date(parsed.data.startAt),
+ });
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+export async function rescheduleBookingAction(input: unknown) {
+ const parsed = rescheduleBookingSchema.safeParse(input);
+ if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+
+ const result = await BookingService.rescheduleBooking(prisma, {
+ ...parsed.data,
+ startAt: new Date(parsed.data.startAt),
+ });
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+export async function cancelBookingAction(input: unknown) {
+ const parsed = cancelBookingSchema.safeParse(input);
+ if (!parsed.success) return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+
+ const result = await BookingService.cancelBooking(prisma, parsed.data);
+ if (result.success) revalidatePath("/dev/scheduling-test");
+ return result;
+}
+
+/** "Update booking" — notes only. Time/staff changes go through reschedule (a distinct, re-validated workflow), not this. */
+export async function updateBookingNotesAction(input: unknown) {
+ const parsed = updateBookingNotesSchema.safeParse(input);
+ if (!parsed.success) {
+ return { success: false as const, validation: fail({ code: "INVALID_INPUT", message: parsed.error.message }) };
+ }
+
+ const appointment = await prisma.appointment.update({
+ where: { id: parsed.data.appointmentId },
+ data: { notes: parsed.data.notes },
+ include: { services: { include: { service: true } }, staff: { include: { user: true } }, customer: true },
+ });
+ revalidatePath("/dev/scheduling-test");
+ return { success: true as const, appointment };
+}
diff --git a/src/features/booking/schemas.ts b/src/features/booking/schemas.ts
new file mode 100644
index 0000000..cc9ee2f
--- /dev/null
+++ b/src/features/booking/schemas.ts
@@ -0,0 +1,60 @@
+import { z } from "zod";
+
+/**
+ * Input shapes for the booking Server Actions. Kept separate from the
+ * scheduling services' own TS types deliberately — these describe what
+ * crosses the client→server boundary as JSON (dates as ISO strings, no
+ * Date objects), the services describe the domain.
+ */
+
+export const getAvailableServicesSchema = z.object({
+ businessId: z.string(),
+ categoryId: z.string().optional(),
+ staffId: z.string().optional(),
+});
+
+export const getAvailableBarbersSchema = z.object({
+ businessId: z.string(),
+ serviceIds: z.array(z.string()).min(1),
+});
+
+export const getAvailableDatesSchema = z.object({
+ businessId: z.string(),
+ serviceIds: z.array(z.string()).min(1),
+ staffId: z.string().optional(),
+ fromDate: z.string(),
+ toDate: z.string(),
+});
+
+export const getAvailableTimeSlotsSchema = z.object({
+ businessId: z.string(),
+ serviceIds: z.array(z.string()).min(1),
+ staffId: z.string().optional(),
+ date: z.string(),
+});
+
+export const createBookingSchema = z.object({
+ businessId: z.string(),
+ customerId: z.string(),
+ serviceIds: z.array(z.string()).min(1),
+ staffId: z.string().optional(),
+ startAt: z.iso.datetime(),
+ notes: z.string().optional(),
+});
+
+export const rescheduleBookingSchema = z.object({
+ appointmentId: z.string(),
+ staffId: z.string().optional(),
+ startAt: z.iso.datetime(),
+});
+
+export const cancelBookingSchema = z.object({
+ appointmentId: z.string(),
+ cancelledByUserId: z.string(),
+ reason: z.string().optional(),
+});
+
+export const updateBookingNotesSchema = z.object({
+ appointmentId: z.string(),
+ notes: z.string().max(2000),
+});
diff --git a/src/lib/time/__tests__/business-time.test.ts b/src/lib/time/__tests__/business-time.test.ts
new file mode 100644
index 0000000..29b171c
--- /dev/null
+++ b/src/lib/time/__tests__/business-time.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import { Weekday } from "@/generated/prisma/enums";
+import {
+ addDays,
+ calendarDateOf,
+ compareCalendarDates,
+ eachCalendarDate,
+ isAfterCalendarDate,
+ isBeforeCalendarDate,
+ minutesFromMidnight,
+ todayIn,
+ wallClockToInstant,
+ weekdayOf,
+} from "../business-time";
+
+describe("weekdayOf", () => {
+ it("maps calendar dates to the correct Weekday", () => {
+ // 2026-08-03 is a Monday.
+ expect(weekdayOf("2026-08-03")).toBe(Weekday.MONDAY);
+ expect(weekdayOf("2026-08-04")).toBe(Weekday.TUESDAY);
+ expect(weekdayOf("2026-08-08")).toBe(Weekday.SATURDAY);
+ expect(weekdayOf("2026-08-09")).toBe(Weekday.SUNDAY);
+ });
+});
+
+describe("wallClockToInstant / minutesFromMidnight round-trip", () => {
+ it("round-trips a plain wall-clock time in a fixed-offset zone", () => {
+ const instant = wallClockToInstant("2026-01-15", 9 * 60 + 30, "Europe/London");
+ expect(minutesFromMidnight(instant, "Europe/London")).toBe(9 * 60 + 30);
+ expect(calendarDateOf(instant, "Europe/London")).toBe("2026-01-15");
+ });
+
+ it("produces the documented UTC instant for a known GMT (winter) date", () => {
+ // 2026-01-15 is outside BST — Europe/London is UTC+0.
+ const instant = wallClockToInstant("2026-01-15", 10 * 60, "Europe/London");
+ expect(instant.toISOString()).toBe("2026-01-15T10:00:00.000Z");
+ });
+});
+
+describe("DST correctness (Europe/London BST transition, last Sunday of March 2026)", () => {
+ it("the same wall-clock time yields UTC instants shifted by the DST offset change", () => {
+ // Before the 2026-03-29 transition: Europe/London is GMT (UTC+0).
+ const beforeTransition = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
+ expect(beforeTransition.toISOString()).toBe("2026-03-28T10:00:00.000Z");
+
+ // After the transition: Europe/London is BST (UTC+1).
+ const afterTransition = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
+ expect(afterTransition.toISOString()).toBe("2026-03-30T09:00:00.000Z");
+
+ // A naive "always UTC+0" implementation would put these exactly 48h
+ // apart; DST-correct math puts them 47h apart because a wall-clock
+ // hour was skipped.
+ const diffHours = (afterTransition.getTime() - beforeTransition.getTime()) / 3_600_000;
+ expect(diffHours).toBe(47);
+ });
+
+ it("minutesFromMidnight recovers the same local wall-clock time on both sides of the transition", () => {
+ const before = wallClockToInstant("2026-03-28", 10 * 60, "Europe/London");
+ const after = wallClockToInstant("2026-03-30", 10 * 60, "Europe/London");
+ expect(minutesFromMidnight(before, "Europe/London")).toBe(10 * 60);
+ expect(minutesFromMidnight(after, "Europe/London")).toBe(10 * 60);
+ });
+});
+
+describe("calendar date arithmetic", () => {
+ it("addDays is pure calendar-date math, independent of any zone", () => {
+ expect(addDays("2026-02-27", 1)).toBe("2026-02-28");
+ expect(addDays("2026-02-28", 1)).toBe("2026-03-01"); // 2026 is not a leap year
+ expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
+ });
+
+ it("compares calendar dates correctly", () => {
+ expect(compareCalendarDates("2026-01-01", "2026-01-02")).toBeLessThan(0);
+ expect(compareCalendarDates("2026-01-02", "2026-01-01")).toBeGreaterThan(0);
+ expect(compareCalendarDates("2026-01-01", "2026-01-01")).toBe(0);
+ expect(isBeforeCalendarDate("2026-01-01", "2026-01-02")).toBe(true);
+ expect(isAfterCalendarDate("2026-01-02", "2026-01-01")).toBe(true);
+ });
+
+ it("eachCalendarDate yields an inclusive range and nothing when from > to", () => {
+ expect([...eachCalendarDate("2026-01-01", "2026-01-03")]).toEqual([
+ "2026-01-01",
+ "2026-01-02",
+ "2026-01-03",
+ ]);
+ expect([...eachCalendarDate("2026-01-03", "2026-01-01")]).toEqual([]);
+ });
+
+ it("eachCalendarDate guards against absurdly large ranges", () => {
+ expect(() => [...eachCalendarDate("2026-01-01", "2030-01-01")]).toThrow();
+ });
+});
+
+describe("todayIn", () => {
+ it("returns a well-formed calendar date", () => {
+ expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+});
diff --git a/src/lib/time/business-time.ts b/src/lib/time/business-time.ts
new file mode 100644
index 0000000..0ef5b30
--- /dev/null
+++ b/src/lib/time/business-time.ts
@@ -0,0 +1,120 @@
+import { DateTime } from "luxon";
+import { Weekday } from "@/generated/prisma/enums";
+
+/**
+ * All scheduling math (working hours, breaks, booking rules) happens in
+ * two currencies: a `CalendarDate` ("2026-08-12", no time zone attached —
+ * a calendar date is a calendar date regardless of zone) and
+ * minutes-since-local-midnight (0–1439) for wall-clock times within a
+ * day. Appointments themselves are stored as UTC `Date` instants (see
+ * prisma/schema.prisma, Appointment.startAt/endAt). This module is the
+ * ONLY place that converts between the two, via Luxon, so DST transitions
+ * are handled correctly in exactly one place instead of scattered
+ * `getHours()`/`setHours()` calls that silently assume the server's own
+ * time zone. See docs/implementation-plan.md §14.
+ */
+
+export type CalendarDate = string; // "YYYY-MM-DD"
+
+const ISO_WEEKDAY_ORDER: Weekday[] = [
+ Weekday.MONDAY,
+ Weekday.TUESDAY,
+ Weekday.WEDNESDAY,
+ Weekday.THURSDAY,
+ Weekday.FRIDAY,
+ Weekday.SATURDAY,
+ Weekday.SUNDAY,
+];
+
+function requireISODate(dt: DateTime, context: string): string {
+ const iso = dt.toISODate();
+ if (!iso) throw new Error(`business-time: invalid date in ${context}`);
+ return iso;
+}
+
+/** Which Weekday a calendar date falls on, independent of time zone (a calendar date has no time-of-day ambiguity). */
+export function weekdayOf(date: CalendarDate): Weekday {
+ const dt = DateTime.fromISO(date, { zone: "utc" });
+ return ISO_WEEKDAY_ORDER[dt.weekday - 1]!;
+}
+
+/** Minutes since local midnight (0–1439) for a UTC instant, as observed in `zone`. */
+export function minutesFromMidnight(instant: Date, zone: string): number {
+ const dt = DateTime.fromJSDate(instant, { zone });
+ return dt.hour * 60 + dt.minute;
+}
+
+/** The business-local calendar date a UTC instant falls on. */
+export function calendarDateOf(instant: Date, zone: string): CalendarDate {
+ return requireISODate(DateTime.fromJSDate(instant, { zone }), "calendarDateOf");
+}
+
+/**
+ * Combine a calendar date + minutes-from-midnight (a business-local wall
+ * clock time) into the correct UTC instant. DST-safe: Luxon resolves the
+ * wall clock against the zone's actual offset for that specific date, not
+ * a fixed offset — the same "10:00" on either side of a DST transition
+ * produces different UTC instants, correctly.
+ */
+export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
+ const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
+ return dt.toJSDate();
+}
+
+export function todayIn(zone: string): CalendarDate {
+ return requireISODate(DateTime.now().setZone(zone), "todayIn");
+}
+
+/** The UTC instant at which `date` begins in `zone` — the inclusive lower bound for "appointments that fall on this business day". */
+export function startOfBusinessDay(date: CalendarDate, zone: string): Date {
+ return wallClockToInstant(date, 0, zone);
+}
+
+/** The UTC instant at which `date` ends in `zone` (= the start of the next day) — the exclusive upper bound for "appointments that fall on this business day". */
+export function endOfBusinessDay(date: CalendarDate, zone: string): Date {
+ return wallClockToInstant(date, 24 * 60, zone);
+}
+
+/**
+ * `TimeOff`/date-only columns (`@db.Date`) are compared as UTC midnight
+ * regardless of business time zone — a day off is the same calendar day
+ * everywhere, it isn't a wall-clock-in-a-zone concept the way appointment
+ * times are. Always go through this helper when reading or writing a
+ * `@db.Date` column so the convention stays consistent both directions.
+ */
+export function calendarDateToUtcMidnight(date: CalendarDate): Date {
+ return new Date(`${date}T00:00:00.000Z`);
+}
+
+/** Pure calendar-date arithmetic — deliberately zone-independent (adding whole days never crosses a DST boundary ambiguity the way wall-clock-to-instant conversion can). */
+export function addDays(date: CalendarDate, days: number): CalendarDate {
+ return requireISODate(DateTime.fromISO(date, { zone: "utc" }).plus({ days }), "addDays");
+}
+
+export function compareCalendarDates(a: CalendarDate, b: CalendarDate): number {
+ return a < b ? -1 : a > b ? 1 : 0;
+}
+
+export function isBeforeCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
+ return compareCalendarDates(a, b) < 0;
+}
+
+export function isAfterCalendarDate(a: CalendarDate, b: CalendarDate): boolean {
+ return compareCalendarDates(a, b) > 0;
+}
+
+export function diffMinutes(a: Date, b: Date): number {
+ return (a.getTime() - b.getTime()) / 60_000;
+}
+
+/** Every CalendarDate from `from` to `to` inclusive. Throws if the range is absurdly large, as a guard against a caller accidentally passing unbounded input. */
+export function* eachCalendarDate(from: CalendarDate, to: CalendarDate): Generator {
+ if (isAfterCalendarDate(from, to)) return;
+ let cursor = from;
+ let guard = 0;
+ while (!isAfterCalendarDate(cursor, to)) {
+ if (++guard > 366) throw new Error("eachCalendarDate: range exceeds 366 days");
+ yield cursor;
+ cursor = addDays(cursor, 1);
+ }
+}
diff --git a/src/services/scheduling/__tests__/appointment.service.test.ts b/src/services/scheduling/__tests__/appointment.service.test.ts
new file mode 100644
index 0000000..3c09415
--- /dev/null
+++ b/src/services/scheduling/__tests__/appointment.service.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { AppointmentStatus } from "@/generated/prisma/enums";
+import { canTransition, validateTransition } from "../appointment.service";
+
+const ALL_STATUSES = Object.values(AppointmentStatus);
+
+describe("canTransition", () => {
+ it("allows the documented happy path", () => {
+ expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CONFIRMED)).toBe(true);
+ expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN)).toBe(true);
+ expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.IN_PROGRESS)).toBe(true);
+ expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.COMPLETED)).toBe(true);
+ });
+
+ it("allows cancellation from PENDING, CONFIRMED, and CHECKED_IN", () => {
+ expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CANCELLED)).toBe(true);
+ expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED)).toBe(true);
+ expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED)).toBe(true);
+ });
+
+ it("allows NO_SHOW only from CONFIRMED — once checked in, the customer clearly did show up", () => {
+ expect(canTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.NO_SHOW)).toBe(true);
+ expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.NO_SHOW)).toBe(false);
+ expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.NO_SHOW)).toBe(false);
+ expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.NO_SHOW)).toBe(false);
+ });
+
+ it("treats COMPLETED, CANCELLED, and NO_SHOW as terminal — no transitions out", () => {
+ for (const terminal of [AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW]) {
+ for (const target of ALL_STATUSES) {
+ expect(canTransition(terminal, target)).toBe(false);
+ }
+ }
+ });
+
+ it("never allows a no-op transition to the same status", () => {
+ for (const status of ALL_STATUSES) {
+ expect(canTransition(status, status)).toBe(false);
+ }
+ });
+
+ it("disallows skipping straight from PENDING to CHECKED_IN or later", () => {
+ expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.CHECKED_IN)).toBe(false);
+ expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.IN_PROGRESS)).toBe(false);
+ expect(canTransition(AppointmentStatus.PENDING, AppointmentStatus.COMPLETED)).toBe(false);
+ });
+
+ it("disallows going backwards", () => {
+ expect(canTransition(AppointmentStatus.IN_PROGRESS, AppointmentStatus.CHECKED_IN)).toBe(false);
+ expect(canTransition(AppointmentStatus.CHECKED_IN, AppointmentStatus.CONFIRMED)).toBe(false);
+ expect(canTransition(AppointmentStatus.COMPLETED, AppointmentStatus.IN_PROGRESS)).toBe(false);
+ });
+});
+
+describe("validateTransition", () => {
+ it("returns a valid result for an allowed transition", () => {
+ const result = validateTransition(AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN);
+ expect(result.valid).toBe(true);
+ expect(result.issues).toEqual([]);
+ });
+
+ it("returns a specific error code for a disallowed transition", () => {
+ const result = validateTransition(AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("INVALID_STATUS_TRANSITION");
+ });
+});
diff --git a/src/services/scheduling/__tests__/booking-engine.integration.test.ts b/src/services/scheduling/__tests__/booking-engine.integration.test.ts
new file mode 100644
index 0000000..e371740
--- /dev/null
+++ b/src/services/scheduling/__tests__/booking-engine.integration.test.ts
@@ -0,0 +1,600 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { Weekday } from "@/generated/prisma/enums";
+import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time";
+import type { CalendarDate } from "@/lib/time/business-time";
+import { getAvailableDates, getAvailableStaff, getAvailableTimeSlots, validateBookingSlot } from "../availability.service";
+import { cancelBooking, createBooking, rescheduleBooking } from "../booking.service";
+import {
+ cleanupTestBusiness,
+ createTestBusiness,
+ createTestCustomer,
+ createTestStaff,
+ prisma,
+} from "./fixtures";
+import type { TestBusiness } from "./fixtures";
+
+/** The next date (at least `minDaysAhead` out) that falls on `target` — avoids hardcoding a calendar date that could go stale or land on a closed day depending on when the suite runs. */
+function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate {
+ let date = addDays(todayIn(timezone), minDaysAhead);
+ for (let i = 0; i < 8; i++) {
+ if (weekdayOf(date) === target) return date;
+ date = addDays(date, 1);
+ }
+ throw new Error("unreachable");
+}
+
+describe("booking engine (integration)", () => {
+ let business: TestBusiness;
+ let monday: CalendarDate;
+ let sunday: CalendarDate;
+
+ beforeEach(async () => {
+ business = await createTestBusiness();
+ monday = nextWeekday(business.timezone, Weekday.MONDAY);
+ sunday = addDays(monday, 6); // the business is closed on Sunday — see fixtures.ts
+ });
+
+ afterEach(async () => {
+ await cleanupTestBusiness(business);
+ });
+
+ describe("availability calculation", () => {
+ it("offers slots for a working staff member on a working day", async () => {
+ const { staffId } = await createTestStaff(business);
+
+ const { slots, validation } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ date: monday,
+ });
+
+ expect(validation.valid).toBe(true);
+ expect(slots.length).toBeGreaterThan(0);
+ expect(slots[0]!.staffOptions).toContain(staffId);
+ // 09:00 business open, 30 min slot interval — the very first slot should be 09:00.
+ expect(slots[0]!.startMinutes).toBe(9 * 60);
+ });
+
+ it("lists a date as available only if it actually has openings", async () => {
+ await createTestStaff(business);
+
+ const { dates } = await getAvailableDates(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ fromDate: monday,
+ toDate: sunday,
+ });
+
+ expect(dates).toContain(monday);
+ expect(dates).not.toContain(sunday); // business closed Sundays
+ });
+
+ it("getAvailableStaff only returns staff who can perform every requested service", async () => {
+ const { staffId } = await createTestStaff(business);
+ // A second staff member who can only do haircuts, not beard trims.
+ const role = await prisma.role.create({ data: { businessId: business.businessId, name: "Junior" } });
+ const user = await prisma.user.create({
+ data: {
+ email: `junior-${Date.now()}@test.local`,
+ staffProfile: {
+ create: {
+ businessId: business.businessId,
+ roleId: role.id,
+ services: { create: [{ serviceId: business.haircutServiceId }] },
+ },
+ },
+ },
+ include: { staffProfile: true },
+ });
+ business.createdUserIds.push(user.id);
+
+ const staffForHaircutOnly = await getAvailableStaff(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ });
+ const staffIds = staffForHaircutOnly.map((s) => s.id);
+ expect(staffIds).toContain(staffId);
+ expect(staffIds).toContain(user.staffProfile!.id);
+
+ const staffForBoth = await getAvailableStaff(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
+ });
+ const bothIds = staffForBoth.map((s) => s.id);
+ expect(bothIds).toContain(staffId);
+ expect(bothIds).not.toContain(user.staffProfile!.id); // junior can't do beard trims
+ });
+ });
+
+ describe("business closed", () => {
+ it("offers no slots on a day the business is closed", async () => {
+ await createTestStaff(business);
+
+ const { slots } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ date: sunday,
+ });
+
+ expect(slots).toEqual([]);
+ });
+
+ it("validateBookingSlot reports BUSINESS_CLOSED for a Sunday booking attempt", async () => {
+ const { staffId } = await createTestStaff(business);
+ const startAt = wallClockToInstant(sunday, 10 * 60, business.timezone);
+
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt,
+ });
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.map((i) => i.code)).toContain("BUSINESS_CLOSED");
+ });
+ });
+
+ describe("staff working hours", () => {
+ it("STAFF_NOT_WORKING when the staff member doesn't work that day, even though the business is open", async () => {
+ const { staffId } = await createTestStaff(business, { workingDays: [Weekday.TUESDAY] }); // never works Monday
+ const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
+
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt,
+ });
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.map((i) => i.code)).toContain("STAFF_NOT_WORKING");
+ });
+ });
+
+ describe("staff breaks", () => {
+ it("excludes a lunch break from available slots and flags a candidate inside it", async () => {
+ const { staffId } = await createTestStaff(business);
+ const lunchBreak = await prisma.break.create({
+ data: {
+ staffId,
+ label: "Lunch",
+ startMinutes: 12 * 60,
+ endMinutes: 13 * 60,
+ days: { create: [{ dayOfWeek: Weekday.MONDAY }] },
+ },
+ });
+
+ const { slots } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ date: monday,
+ });
+ const duringLunch = slots.some((s) => s.startMinutes >= 12 * 60 && s.startMinutes < 13 * 60);
+ expect(duringLunch).toBe(false);
+
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt: wallClockToInstant(monday, 12 * 60 + 15, business.timezone),
+ });
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_BREAK");
+
+ await prisma.break.delete({ where: { id: lunchBreak.id } });
+ });
+ });
+
+ describe("time off", () => {
+ it("blocks the whole day when the staff member has approved leave", async () => {
+ const { staffId } = await createTestStaff(business);
+ await prisma.timeOff.create({
+ data: {
+ staffId,
+ startDate: new Date(`${monday}T00:00:00.000Z`),
+ endDate: new Date(`${monday}T00:00:00.000Z`),
+ status: "APPROVED",
+ },
+ });
+
+ const { slots } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ date: monday,
+ });
+ expect(slots).toEqual([]);
+
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ expect(validation.issues.map((i) => i.code)).toContain("STAFF_ON_LEAVE");
+ });
+
+ it("does not block a day the leave doesn't cover", async () => {
+ const { staffId } = await createTestStaff(business);
+ const tuesday = addDays(monday, 1);
+ await prisma.timeOff.create({
+ data: {
+ staffId,
+ startDate: new Date(`${tuesday}T00:00:00.000Z`),
+ endDate: new Date(`${tuesday}T00:00:00.000Z`),
+ status: "APPROVED",
+ },
+ });
+
+ const { slots } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ date: monday,
+ });
+ expect(slots.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("multiple services", () => {
+ it("combines durations and requires a window long enough for all requested services", async () => {
+ await createTestStaff(business);
+
+ const { slots: singleService } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId], // 30 min
+ date: monday,
+ });
+ const { slots: combined } = await getAvailableTimeSlots(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId, business.beardTrimServiceId], // 30 + 15 = 45 min
+ date: monday,
+ });
+
+ expect(combined[0]!.endMinutes - combined[0]!.startMinutes).toBe(45);
+ // The combined-duration slot list can't offer more start times deep near closing than the shorter single-service one.
+ const lastSingle = singleService[singleService.length - 1]!.startMinutes;
+ const lastCombined = combined[combined.length - 1]!.startMinutes;
+ expect(lastCombined).toBeLessThanOrEqual(lastSingle);
+ });
+
+ it("creates one AppointmentService row per requested service, with price/duration snapshotted", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ const result = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId, business.beardTrimServiceId],
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+
+ expect(result.success).toBe(true);
+ if (!result.success) return;
+ expect(result.appointment.services).toHaveLength(2);
+ const total = result.appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
+ expect(total).toBe(30); // £20 haircut + £10 beard trim
+ const durationTotal = result.appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
+ expect(durationTotal).toBe(45);
+ });
+ });
+
+ describe("buffer times", () => {
+ it("blocks a slot that would start inside another appointment's buffer", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ // Give this business a 15-minute buffer after every appointment.
+ await prisma.bookingRule.update({
+ where: { businessId: business.businessId },
+ data: { bufferAfterMinutes: 15 },
+ });
+
+ const first = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId], // 09:00–09:30
+ startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
+ });
+ expect(first.success).toBe(true);
+
+ // 09:30 is technically free of the first appointment itself, but inside its 15-min buffer.
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt: wallClockToInstant(monday, 9 * 60 + 30, business.timezone),
+ });
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
+
+ // 09:45 is clear of the buffer.
+ const { validation: laterValidation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt: wallClockToInstant(monday, 9 * 60 + 45, business.timezone),
+ });
+ expect(laterValidation.valid).toBe(true);
+ });
+ });
+
+ describe("booking validation", () => {
+ it("rejects a booking below the minimum notice period", async () => {
+ const { staffId } = await createTestStaff(business);
+ await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { minNoticeMinutes: 120 } });
+
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt: new Date(Date.now() + 5 * 60_000), // 5 minutes from now
+ });
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.map((i) => i.code)).toContain("BELOW_MIN_NOTICE");
+ });
+
+ it("rejects a booking beyond the maximum advance window", async () => {
+ const { staffId } = await createTestStaff(business);
+ await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAdvanceDays: 7 } });
+
+ const farFuture = addDays(todayIn(business.timezone), 60);
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: [business.haircutServiceId],
+ staffId,
+ startAt: wallClockToInstant(farFuture, 10 * 60, business.timezone),
+ });
+
+ expect(validation.valid).toBe(false);
+ expect(validation.issues.map((i) => i.code)).toContain("BEYOND_MAX_ADVANCE");
+ });
+
+ it("rejects once the business's daily appointment cap is reached", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+ await prisma.bookingRule.update({ where: { businessId: business.businessId }, data: { maxAppointmentsPerDay: 1 } });
+
+ const first = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 9 * 60, business.timezone),
+ });
+ expect(first.success).toBe(true);
+
+ const second = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 11 * 60, business.timezone),
+ });
+ expect(second.success).toBe(false);
+ if (second.success) return;
+ expect(second.validation.issues.map((i) => i.code)).toContain("BUSINESS_DAILY_LIMIT_REACHED");
+ });
+
+ it("rejects services that don't belong to the business", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { validation } = await validateBookingSlot(prisma, {
+ businessId: business.businessId,
+ serviceIds: ["not-a-real-service-id"],
+ staffId,
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ expect(validation.valid).toBe(false);
+ });
+ });
+
+ describe("double booking prevention", () => {
+ it("rejects a second booking for the exact same staff/time", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+ const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
+
+ const first = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt,
+ });
+ const second = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt,
+ });
+
+ expect(first.success).toBe(true);
+ expect(second.success).toBe(false);
+ if (second.success) return;
+ expect(second.validation.issues.map((i) => i.code)).toContain("SLOT_UNAVAILABLE");
+ });
+
+ it("under real concurrency, exactly one of two simultaneous requests for the same slot succeeds", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+ const startAt = wallClockToInstant(monday, 14 * 60, business.timezone);
+
+ const [a, b] = await Promise.all([
+ createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt,
+ }),
+ createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt,
+ }),
+ ]);
+
+ const successes = [a, b].filter((r) => r.success);
+ const failures = [a, b].filter((r) => !r.success);
+ expect(successes).toHaveLength(1);
+ expect(failures).toHaveLength(1);
+
+ const appointmentCount = await prisma.appointment.count({
+ where: { businessId: business.businessId, staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] } },
+ });
+ expect(appointmentCount).toBe(1);
+ });
+ });
+
+ describe("rescheduling", () => {
+ it("moves the appointment to the new time and applies the same validation as a new booking", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ const created = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ expect(created.success).toBe(true);
+ if (!created.success) return;
+
+ const rescheduled = await rescheduleBooking(prisma, {
+ appointmentId: created.appointment.id,
+ startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
+ });
+ expect(rescheduled.success).toBe(true);
+ if (!rescheduled.success) return;
+ expect(rescheduled.appointment.id).toBe(created.appointment.id);
+ expect(rescheduled.appointment.startAt.toISOString()).toBe(
+ wallClockToInstant(monday, 15 * 60, business.timezone).toISOString(),
+ );
+ });
+
+ it("does not conflict with itself when rescheduled to overlap its own original time", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ const created = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ expect(created.success).toBe(true);
+ if (!created.success) return;
+
+ // "Reschedule" to 10 minutes later — overlaps its own current slot, must not be treated as a conflict with itself.
+ const rescheduled = await rescheduleBooking(prisma, {
+ appointmentId: created.appointment.id,
+ startAt: wallClockToInstant(monday, 10 * 60 + 10, business.timezone),
+ });
+ expect(rescheduled.success).toBe(true);
+ });
+
+ it("rejects rescheduling a completed appointment", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId } = await createTestCustomer(business);
+
+ const created = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ if (!created.success) throw new Error("setup failed");
+ await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
+
+ const rescheduled = await rescheduleBooking(prisma, {
+ appointmentId: created.appointment.id,
+ startAt: wallClockToInstant(monday, 15 * 60, business.timezone),
+ });
+ expect(rescheduled.success).toBe(false);
+ });
+ });
+
+ describe("cancellation", () => {
+ it("never deletes — sets status, cancelledAt, cancelledByUserId, and reason", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId, userId: customerUserId } = await createTestCustomer(business);
+
+ const created = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ if (!created.success) throw new Error("setup failed");
+
+ const result = await cancelBooking(prisma, {
+ appointmentId: created.appointment.id,
+ cancelledByUserId: customerUserId,
+ reason: "Change of plans",
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.appointment?.status).toBe("CANCELLED");
+ expect(result.appointment?.cancelledByUserId).toBe(customerUserId);
+ expect(result.appointment?.cancellationReason).toBe("Change of plans");
+ expect(result.appointment?.cancelledAt).toBeInstanceOf(Date);
+
+ // Still in the database — not deleted.
+ const stillExists = await prisma.appointment.findUnique({ where: { id: created.appointment.id } });
+ expect(stillExists).not.toBeNull();
+ });
+
+ it("frees the slot up for a new booking", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId, userId: customerUserId } = await createTestCustomer(business);
+ const startAt = wallClockToInstant(monday, 10 * 60, business.timezone);
+
+ const created = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt,
+ });
+ if (!created.success) throw new Error("setup failed");
+
+ await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
+
+ const rebooked = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt,
+ });
+ expect(rebooked.success).toBe(true);
+ });
+
+ it("rejects cancelling an already-completed appointment", async () => {
+ const { staffId } = await createTestStaff(business);
+ const { customerId, userId: customerUserId } = await createTestCustomer(business);
+
+ const created = await createBooking(prisma, {
+ businessId: business.businessId,
+ customerId,
+ staffId,
+ serviceIds: [business.haircutServiceId],
+ startAt: wallClockToInstant(monday, 10 * 60, business.timezone),
+ });
+ if (!created.success) throw new Error("setup failed");
+ await prisma.appointment.update({ where: { id: created.appointment.id }, data: { status: "COMPLETED" } });
+
+ const result = await cancelBooking(prisma, { appointmentId: created.appointment.id, cancelledByUserId: customerUserId });
+ expect(result.success).toBe(false);
+ });
+ });
+});
diff --git a/src/services/scheduling/__tests__/business-rules.service.test.ts b/src/services/scheduling/__tests__/business-rules.service.test.ts
new file mode 100644
index 0000000..fc7a183
--- /dev/null
+++ b/src/services/scheduling/__tests__/business-rules.service.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vitest";
+import {
+ checkBusinessDailyLimit,
+ checkMaxAdvance,
+ checkMinNotice,
+ checkStaffDailyLimit,
+ combinedServiceDuration,
+ computeEffectiveBuffer,
+ isLateCancellation,
+ isLateReschedule,
+} from "../business-rules.service";
+
+describe("checkMinNotice", () => {
+ const now = new Date("2026-08-10T10:00:00Z");
+
+ it("fails when the candidate start is too soon", () => {
+ const candidate = new Date("2026-08-10T10:30:00Z"); // 30 min from now
+ const result = checkMinNotice(candidate, now, 120);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("BELOW_MIN_NOTICE");
+ });
+
+ it("passes when the candidate start is far enough ahead", () => {
+ const candidate = new Date("2026-08-10T13:00:00Z"); // 3h from now
+ expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
+ });
+
+ it("passes exactly at the boundary", () => {
+ const candidate = new Date("2026-08-10T12:00:00Z"); // exactly 120 min
+ expect(checkMinNotice(candidate, now, 120).valid).toBe(true);
+ });
+});
+
+describe("checkMaxAdvance", () => {
+ it("fails when the date is beyond the max advance window", () => {
+ const result = checkMaxAdvance("2026-11-01", "2026-08-10", 60);
+ expect(result.valid).toBe(false);
+ expect(result.issues[0]?.code).toBe("BEYOND_MAX_ADVANCE");
+ });
+
+ it("passes when the date is within the window", () => {
+ expect(checkMaxAdvance("2026-08-20", "2026-08-10", 60).valid).toBe(true);
+ });
+
+ it("passes exactly at the boundary", () => {
+ expect(checkMaxAdvance("2026-10-09", "2026-08-10", 60).valid).toBe(true); // exactly 60 days
+ });
+});
+
+describe("checkBusinessDailyLimit / checkStaffDailyLimit", () => {
+ it("null limit means unlimited", () => {
+ expect(checkBusinessDailyLimit(999, null).valid).toBe(true);
+ expect(checkStaffDailyLimit(999, null).valid).toBe(true);
+ });
+
+ it("fails once the existing count reaches the limit", () => {
+ expect(checkBusinessDailyLimit(10, 10).valid).toBe(false);
+ expect(checkStaffDailyLimit(5, 5).valid).toBe(false);
+ });
+
+ it("passes below the limit", () => {
+ expect(checkBusinessDailyLimit(9, 10).valid).toBe(true);
+ expect(checkStaffDailyLimit(4, 5).valid).toBe(true);
+ });
+});
+
+describe("computeEffectiveBuffer", () => {
+ it("sums the business baseline with the largest per-service buffer", () => {
+ const result = computeEffectiveBuffer(
+ { bufferBeforeMinutes: 5, bufferAfterMinutes: 5 },
+ [
+ { bufferBeforeMinutes: 0, bufferAfterMinutes: 15 }, // e.g. colour treatment
+ { bufferBeforeMinutes: 0, bufferAfterMinutes: 0 },
+ ],
+ );
+ expect(result).toEqual({ beforeMinutes: 5, afterMinutes: 20 });
+ });
+
+ it("returns just the business baseline when no service adds a buffer", () => {
+ expect(computeEffectiveBuffer({ bufferBeforeMinutes: 0, bufferAfterMinutes: 5 }, [])).toEqual({
+ beforeMinutes: 0,
+ afterMinutes: 5,
+ });
+ });
+});
+
+describe("isLateCancellation / isLateReschedule", () => {
+ const now = new Date("2026-08-10T10:00:00Z");
+
+ it("flags a cancellation inside the free window as late", () => {
+ const appointmentStart = new Date("2026-08-10T20:00:00Z"); // 10h away, window is 24h
+ expect(isLateCancellation(appointmentStart, now, 24)).toBe(true);
+ });
+
+ it("does not flag a cancellation outside the free window", () => {
+ const appointmentStart = new Date("2026-08-12T10:00:00Z"); // 48h away
+ expect(isLateCancellation(appointmentStart, now, 24)).toBe(false);
+ });
+
+ it("applies the same logic to reschedules with their own window", () => {
+ const appointmentStart = new Date("2026-08-10T11:00:00Z"); // 1h away, window is 24h
+ expect(isLateReschedule(appointmentStart, now, 24)).toBe(true);
+ });
+});
+
+describe("combinedServiceDuration", () => {
+ it("sums durations across multiple services", () => {
+ expect(combinedServiceDuration([{ durationMinutes: 30 }, { durationMinutes: 15 }])).toBe(45);
+ });
+
+ it("returns 0 for an empty list", () => {
+ expect(combinedServiceDuration([])).toBe(0);
+ });
+});
diff --git a/src/services/scheduling/__tests__/fixtures.ts b/src/services/scheduling/__tests__/fixtures.ts
new file mode 100644
index 0000000..0023ab1
--- /dev/null
+++ b/src/services/scheduling/__tests__/fixtures.ts
@@ -0,0 +1,164 @@
+import "dotenv/config";
+import { PrismaClient } from "@/generated/prisma/client";
+import { PrismaPg } from "@prisma/adapter-pg";
+import { Weekday } from "@/generated/prisma/enums";
+
+/**
+ * Integration-test fixtures — real Postgres, real transactions. Not used
+ * by the unit test suite (`npm test`); only by `npm run test:integration`.
+ * See docs/phase-3-summary.md for how to point this at a database.
+ */
+
+const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
+export const prisma = new PrismaClient({ adapter });
+
+let counter = 0;
+function uniqueId(prefix: string): string {
+ counter += 1;
+ return `${prefix}-${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+export interface TestBusiness {
+ businessId: string;
+ timezone: string;
+ categoryId: string;
+ haircutServiceId: string;
+ beardTrimServiceId: string;
+ createdUserIds: string[];
+}
+
+/** Mon–Sat 09:00–18:00, Sunday closed. Booking rule defaults are deliberately permissive (no min notice, no daily caps) so most tests don't have to fight them — tests that specifically exercise a rule override it. */
+export async function createTestBusiness(): Promise {
+ const slug = uniqueId("test-biz");
+ const timezone = "Europe/London";
+
+ const business = await prisma.business.create({
+ data: {
+ slug,
+ name: "Test Barbershop",
+ timezone,
+ hours: {
+ create: [
+ { dayOfWeek: Weekday.MONDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ { dayOfWeek: Weekday.TUESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ { dayOfWeek: Weekday.WEDNESDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ { dayOfWeek: Weekday.THURSDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ { dayOfWeek: Weekday.FRIDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ { dayOfWeek: Weekday.SATURDAY, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
+ { dayOfWeek: Weekday.SUNDAY, isClosed: true },
+ ],
+ },
+ bookingRule: {
+ create: {
+ minNoticeMinutes: 0,
+ maxAdvanceDays: 365,
+ slotIntervalMinutes: 30,
+ bufferBeforeMinutes: 0,
+ bufferAfterMinutes: 0,
+ },
+ },
+ },
+ });
+
+ const category = await prisma.serviceCategory.create({
+ data: { businessId: business.id, name: "Hair" },
+ });
+
+ const haircut = await prisma.service.create({
+ data: {
+ businessId: business.id,
+ categoryId: category.id,
+ name: "Haircut",
+ durationMinutes: 30,
+ price: 20,
+ },
+ });
+
+ const beardTrim = await prisma.service.create({
+ data: {
+ businessId: business.id,
+ categoryId: category.id,
+ name: "Beard Trim",
+ durationMinutes: 15,
+ price: 10,
+ },
+ });
+
+ return {
+ businessId: business.id,
+ timezone,
+ categoryId: category.id,
+ haircutServiceId: haircut.id,
+ beardTrimServiceId: beardTrim.id,
+ createdUserIds: [],
+ };
+}
+
+/** A staff member who works Mon–Sat 09:00–18:00 (matching the business), can perform both fixture services. */
+export async function createTestStaff(
+ business: TestBusiness,
+ opts: { workingDays?: Weekday[] } = {},
+): Promise<{ staffId: string; userId: string }> {
+ const workingDays = opts.workingDays ?? [
+ Weekday.MONDAY,
+ Weekday.TUESDAY,
+ Weekday.WEDNESDAY,
+ Weekday.THURSDAY,
+ Weekday.FRIDAY,
+ Weekday.SATURDAY,
+ ];
+
+ const role = await prisma.role.create({
+ data: { businessId: business.businessId, name: uniqueId("Barber") },
+ });
+
+ const user = await prisma.user.create({
+ data: {
+ email: `${uniqueId("staff")}@test.local`,
+ name: "Test Barber",
+ staffProfile: {
+ create: {
+ businessId: business.businessId,
+ roleId: role.id,
+ workingHours: {
+ create: Object.values(Weekday).map((dayOfWeek) =>
+ workingDays.includes(dayOfWeek)
+ ? { dayOfWeek, startMinutes: 9 * 60, endMinutes: 18 * 60 }
+ : { dayOfWeek, isOff: true },
+ ),
+ },
+ services: {
+ create: [{ serviceId: business.haircutServiceId }, { serviceId: business.beardTrimServiceId }],
+ },
+ },
+ },
+ },
+ include: { staffProfile: true },
+ });
+
+ business.createdUserIds.push(user.id);
+ return { staffId: user.staffProfile!.id, userId: user.id };
+}
+
+export async function createTestCustomer(business: TestBusiness): Promise<{ customerId: string; userId: string }> {
+ const email = `${uniqueId("customer")}@test.local`;
+ const user = await prisma.user.create({
+ data: {
+ email,
+ name: "Test Customer",
+ customerProfile: {
+ create: { firstName: "Test", lastName: "Customer", email, isGuest: false },
+ },
+ },
+ include: { customerProfile: true },
+ });
+ business.createdUserIds.push(user.id);
+ return { customerId: user.customerProfile!.id, userId: user.id };
+}
+
+export async function cleanupTestBusiness(business: TestBusiness): Promise {
+ await prisma.business.delete({ where: { id: business.businessId } }).catch(() => {});
+ if (business.createdUserIds.length > 0) {
+ await prisma.user.deleteMany({ where: { id: { in: business.createdUserIds } } }).catch(() => {});
+ }
+}
diff --git a/src/services/scheduling/__tests__/slot-calculator.test.ts b/src/services/scheduling/__tests__/slot-calculator.test.ts
new file mode 100644
index 0000000..10c08c0
--- /dev/null
+++ b/src/services/scheduling/__tests__/slot-calculator.test.ts
@@ -0,0 +1,177 @@
+import { describe, expect, it } from "vitest";
+import {
+ computeFreeWindows,
+ expandWithBuffer,
+ generateSlotStarts,
+ intersectWindows,
+ mergeWindows,
+ subtractWindows,
+} from "../slot-calculator";
+import type { TimeWindow } from "../types";
+
+const w = (startMinutes: number, endMinutes: number): TimeWindow => ({ startMinutes, endMinutes });
+
+describe("mergeWindows", () => {
+ it("merges overlapping and touching windows, drops invalid/zero-length ones", () => {
+ expect(mergeWindows([w(0, 60), w(30, 90), w(90, 120)])).toEqual([w(0, 120)]);
+ expect(mergeWindows([w(0, 30), w(40, 60)])).toEqual([w(0, 30), w(40, 60)]);
+ expect(mergeWindows([w(10, 10), w(20, 15)])).toEqual([]); // zero-length / inverted dropped
+ });
+
+ it("sorts unordered input before merging", () => {
+ expect(mergeWindows([w(100, 120), w(0, 30)])).toEqual([w(0, 30), w(100, 120)]);
+ });
+});
+
+describe("subtractWindows", () => {
+ it("removes a window entirely contained within a base window, splitting it in two", () => {
+ expect(subtractWindows([w(0, 120)], [w(30, 60)])).toEqual([w(0, 30), w(60, 120)]);
+ });
+
+ it("removes from the start and end", () => {
+ expect(subtractWindows([w(0, 120)], [w(0, 30)])).toEqual([w(30, 120)]);
+ expect(subtractWindows([w(0, 120)], [w(90, 120)])).toEqual([w(0, 90)]);
+ });
+
+ it("removes the whole window when the cut fully covers it", () => {
+ expect(subtractWindows([w(30, 60)], [w(0, 120)])).toEqual([]);
+ });
+
+ it("no-ops when there's no overlap", () => {
+ expect(subtractWindows([w(0, 60)], [w(60, 120)])).toEqual([w(0, 60)]);
+ expect(subtractWindows([w(60, 120)], [w(0, 60)])).toEqual([w(60, 120)]);
+ });
+
+ it("applies multiple cuts, including overlapping ones", () => {
+ expect(subtractWindows([w(0, 480)], [w(60, 90), w(70, 120), w(300, 330)])).toEqual([
+ w(0, 60),
+ w(120, 300),
+ w(330, 480),
+ ]);
+ });
+});
+
+describe("intersectWindows", () => {
+ it("intersects business hours with staff hours", () => {
+ expect(intersectWindows([w(600, 1140)], [w(540, 1080)])).toEqual([w(600, 1080)]);
+ });
+
+ it("returns nothing when there's no overlap", () => {
+ expect(intersectWindows([w(0, 60)], [w(120, 180)])).toEqual([]);
+ });
+});
+
+describe("computeFreeWindows", () => {
+ const fullDayOpen = w(9 * 60, 18 * 60); // 09:00–18:00
+
+ it("returns nothing if the business is closed", () => {
+ expect(
+ computeFreeWindows({ businessOpen: null, staffWorking: fullDayOpen, breaks: [], onLeave: false, busy: [] }),
+ ).toEqual([]);
+ });
+
+ it("returns nothing if the staff member isn't scheduled to work", () => {
+ expect(
+ computeFreeWindows({ businessOpen: fullDayOpen, staffWorking: null, breaks: [], onLeave: false, busy: [] }),
+ ).toEqual([]);
+ });
+
+ it("returns nothing if the staff member is on approved leave, even if otherwise free", () => {
+ expect(
+ computeFreeWindows({
+ businessOpen: fullDayOpen,
+ staffWorking: fullDayOpen,
+ breaks: [],
+ onLeave: true,
+ busy: [],
+ }),
+ ).toEqual([]);
+ });
+
+ it("intersects business and staff hours when staff works shorter hours", () => {
+ const result = computeFreeWindows({
+ businessOpen: w(9 * 60, 18 * 60),
+ staffWorking: w(10 * 60, 16 * 60),
+ breaks: [],
+ onLeave: false,
+ busy: [],
+ });
+ expect(result).toEqual([w(10 * 60, 16 * 60)]);
+ });
+
+ it("subtracts a lunch break", () => {
+ const result = computeFreeWindows({
+ businessOpen: fullDayOpen,
+ staffWorking: fullDayOpen,
+ breaks: [w(12 * 60, 13 * 60)],
+ onLeave: false,
+ busy: [],
+ });
+ expect(result).toEqual([w(9 * 60, 12 * 60), w(13 * 60, 18 * 60)]);
+ });
+
+ it("subtracts buffered busy time from existing appointments", () => {
+ const result = computeFreeWindows({
+ businessOpen: fullDayOpen,
+ staffWorking: fullDayOpen,
+ breaks: [],
+ onLeave: false,
+ busy: [w(10 * 60 - 5, 10 * 60 + 45 + 5)], // 10:00–10:45 appointment with 5min buffer each side
+ });
+ expect(result).toEqual([w(9 * 60, 10 * 60 - 5), w(10 * 60 + 50, 18 * 60)]);
+ });
+
+ it("combines breaks and busy time in one pass", () => {
+ const result = computeFreeWindows({
+ businessOpen: fullDayOpen,
+ staffWorking: fullDayOpen,
+ breaks: [w(12 * 60, 13 * 60)],
+ onLeave: false,
+ busy: [w(9 * 60, 9 * 60 + 30)],
+ });
+ expect(result).toEqual([w(9 * 60 + 30, 12 * 60), w(13 * 60, 18 * 60)]);
+ });
+});
+
+describe("generateSlotStarts", () => {
+ it("generates grid-aligned starts that fit the requested duration", () => {
+ // 09:00–10:00 window, 30 min slots, 30 min service → 09:00 and 09:30 only.
+ expect(generateSlotStarts([w(540, 600)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540, 570]);
+ });
+
+ it("excludes a start that would run past the end of the window", () => {
+ // 09:00–09:45 window, 30 min slots, 30 min service → only 09:00 fits (09:30+30=10:00 doesn't).
+ expect(generateSlotStarts([w(540, 585)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([540]);
+ });
+
+ it("aligns to the global grid, not the window's own start", () => {
+ // Window starts at 09:05 (e.g. after a short buffer); with a 30-min grid the first candidate is 09:30, not 09:05.
+ expect(generateSlotStarts([w(545, 630)], { slotIntervalMinutes: 30, durationMinutes: 30 })).toEqual([570, 600]);
+ });
+
+ it("handles a multi-service duration longer than the slot interval", () => {
+ // 09:00–10:30, 15-min grid, 45-min combined service duration.
+ const starts = generateSlotStarts([w(540, 630)], { slotIntervalMinutes: 15, durationMinutes: 45 });
+ expect(starts).toEqual([540, 555, 570, 585]); // last start 09:45 -> ends 10:30 exactly
+ });
+
+ it("returns nothing when the window is shorter than the requested duration", () => {
+ expect(generateSlotStarts([w(540, 560)], { slotIntervalMinutes: 15, durationMinutes: 30 })).toEqual([]);
+ });
+
+ it("rejects non-positive interval or duration", () => {
+ expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 0, durationMinutes: 30 })).toThrow();
+ expect(() => generateSlotStarts([w(0, 60)], { slotIntervalMinutes: 30, durationMinutes: 0 })).toThrow();
+ });
+});
+
+describe("expandWithBuffer", () => {
+ it("pads both sides", () => {
+ expect(expandWithBuffer(w(600, 630), { beforeMinutes: 5, afterMinutes: 10 })).toEqual(w(595, 640));
+ });
+
+ it("clamps at day boundaries instead of going negative or past 1440", () => {
+ expect(expandWithBuffer(w(0, 30), { beforeMinutes: 15, afterMinutes: 0 })).toEqual(w(0, 30));
+ expect(expandWithBuffer(w(1420, 1440), { beforeMinutes: 0, afterMinutes: 30 })).toEqual(w(1420, 1440));
+ });
+});
diff --git a/src/services/scheduling/appointment.service.ts b/src/services/scheduling/appointment.service.ts
new file mode 100644
index 0000000..164d4bd
--- /dev/null
+++ b/src/services/scheduling/appointment.service.ts
@@ -0,0 +1,125 @@
+import type { PrismaClient } from "@/generated/prisma/client";
+import { AppointmentStatus } from "@/generated/prisma/enums";
+import { endOfBusinessDay, startOfBusinessDay } from "@/lib/time/business-time";
+import type { CalendarDate } from "@/lib/time/business-time";
+import { fail, ok } from "./types";
+import type { ValidationResult } from "./types";
+
+/**
+ * Day-of-operations concerns: retrieving appointments and moving them
+ * through their status lifecycle (check-in, start, complete, no-show).
+ * Deliberately has nothing to do with booking/rescheduling/availability —
+ * see booking.service.ts for that. An appointment already exists by the
+ * time anything in this file touches it.
+ */
+
+const TRANSITIONS: Record = {
+ PENDING: [AppointmentStatus.CONFIRMED, AppointmentStatus.CANCELLED],
+ CONFIRMED: [AppointmentStatus.CHECKED_IN, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW],
+ CHECKED_IN: [AppointmentStatus.IN_PROGRESS, AppointmentStatus.CANCELLED],
+ IN_PROGRESS: [AppointmentStatus.COMPLETED],
+ COMPLETED: [],
+ CANCELLED: [],
+ NO_SHOW: [],
+};
+
+export function canTransition(from: AppointmentStatus, to: AppointmentStatus): boolean {
+ return TRANSITIONS[from].includes(to);
+}
+
+export function validateTransition(from: AppointmentStatus, to: AppointmentStatus): ValidationResult {
+ if (!canTransition(from, to)) {
+ return fail({
+ code: "INVALID_STATUS_TRANSITION",
+ message: `An appointment can't move from ${from} to ${to}.`,
+ });
+ }
+ return ok();
+}
+
+const APPOINTMENT_INCLUDE = {
+ services: { include: { service: true } },
+ staff: { include: { user: true } },
+ customer: true,
+} as const;
+
+export async function getAppointmentById(prisma: PrismaClient, id: string) {
+ return prisma.appointment.findUnique({ where: { id }, include: APPOINTMENT_INCLUDE });
+}
+
+export interface ListAppointmentsParams {
+ businessId?: string;
+ staffId?: string;
+ customerId?: string;
+ statuses?: AppointmentStatus[];
+ fromDate?: CalendarDate;
+ toDate?: CalendarDate;
+ businessTimezone?: string; // required if fromDate/toDate given
+}
+
+export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
+ const dateFilter =
+ params.fromDate && params.toDate && params.businessTimezone
+ ? {
+ startAt: {
+ gte: startOfBusinessDay(params.fromDate, params.businessTimezone),
+ lt: endOfBusinessDay(params.toDate, params.businessTimezone),
+ },
+ }
+ : {};
+
+ return prisma.appointment.findMany({
+ where: {
+ ...(params.businessId ? { businessId: params.businessId } : {}),
+ ...(params.staffId ? { staffId: params.staffId } : {}),
+ ...(params.customerId ? { customerId: params.customerId } : {}),
+ ...(params.statuses ? { status: { in: params.statuses } } : {}),
+ ...dateFilter,
+ },
+ include: APPOINTMENT_INCLUDE,
+ orderBy: { startAt: "asc" },
+ });
+}
+
+export type TransitionResult =
+ | { success: true; appointment: NonNullable>> }
+ | { success: false; validation: ValidationResult };
+
+async function transitionStatus(prisma: PrismaClient, appointmentId: string, to: AppointmentStatus): Promise {
+ return prisma.$transaction(async (tx) => {
+ const appointment = await tx.appointment.findUnique({ where: { id: appointmentId } });
+ if (!appointment) {
+ return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
+ }
+
+ const check = validateTransition(appointment.status, to);
+ if (!check.valid) return { success: false, validation: check };
+
+ const updated = await tx.appointment.update({
+ where: { id: appointmentId },
+ data: { status: to },
+ include: APPOINTMENT_INCLUDE,
+ });
+ return { success: true, appointment: updated };
+ });
+}
+
+export function checkIn(prisma: PrismaClient, appointmentId: string) {
+ return transitionStatus(prisma, appointmentId, AppointmentStatus.CHECKED_IN);
+}
+
+export function start(prisma: PrismaClient, appointmentId: string) {
+ return transitionStatus(prisma, appointmentId, AppointmentStatus.IN_PROGRESS);
+}
+
+export function complete(prisma: PrismaClient, appointmentId: string) {
+ return transitionStatus(prisma, appointmentId, AppointmentStatus.COMPLETED);
+}
+
+export function markNoShow(prisma: PrismaClient, appointmentId: string) {
+ return transitionStatus(prisma, appointmentId, AppointmentStatus.NO_SHOW);
+}
+
+export function confirm(prisma: PrismaClient, appointmentId: string) {
+ return transitionStatus(prisma, appointmentId, AppointmentStatus.CONFIRMED);
+}
diff --git a/src/services/scheduling/availability.service.ts b/src/services/scheduling/availability.service.ts
new file mode 100644
index 0000000..e2803e4
--- /dev/null
+++ b/src/services/scheduling/availability.service.ts
@@ -0,0 +1,568 @@
+import type { PrismaClient } from "@/generated/prisma/client";
+import { Weekday } from "@/generated/prisma/enums";
+import {
+ calendarDateOf,
+ calendarDateToUtcMidnight,
+ eachCalendarDate,
+ endOfBusinessDay,
+ minutesFromMidnight,
+ startOfBusinessDay,
+ todayIn,
+ wallClockToInstant,
+ weekdayOf,
+} from "@/lib/time/business-time";
+import type { CalendarDate } from "@/lib/time/business-time";
+import {
+ checkBusinessDailyLimit,
+ checkMaxAdvance,
+ checkMinNotice,
+ checkStaffDailyLimit,
+ combinedServiceDuration,
+ computeEffectiveBuffer,
+} from "./business-rules.service";
+import type { BookingRuleConfig } from "./business-rules.service";
+import { computeFreeWindows, generateSlotStarts } from "./slot-calculator";
+import {
+ businessOpenByWeekdayFrom,
+ getStaffFreeWindowsForRange,
+} from "./staff-availability.service";
+import { fail, mergeValidation, ok } from "./types";
+import type { ServiceRequirement, TimeWindow, ValidationResult } from "./types";
+
+/**
+ * The customer-facing query layer: "what services/barbers/dates/times can
+ * actually be booked right now". Built entirely on StaffAvailabilityService
+ * (per-staff free windows) and BusinessRulesService (booking-window/limit
+ * checks) — this module owns combining them across staff and services, not
+ * the underlying per-staff or per-rule logic.
+ */
+
+export interface AvailableService {
+ id: string;
+ categoryId: string;
+ name: string;
+ durationMinutes: number;
+ price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
+ bufferBeforeMinutes: number;
+ bufferAfterMinutes: number;
+}
+
+export async function getAvailableServices(
+ prisma: PrismaClient,
+ params: { businessId: string; categoryId?: string; staffId?: string },
+): Promise {
+ const services = await prisma.service.findMany({
+ where: {
+ businessId: params.businessId,
+ isActive: true,
+ allowOnlineBooking: true,
+ ...(params.categoryId ? { categoryId: params.categoryId } : {}),
+ ...(params.staffId ? { staff: { some: { staffId: params.staffId } } } : {}),
+ },
+ orderBy: [{ displayOrder: "asc" }, { name: "asc" }],
+ });
+
+ return services.map((s) => ({
+ id: s.id,
+ categoryId: s.categoryId,
+ name: s.name,
+ durationMinutes: s.durationMinutes,
+ price: s.price.toString(),
+ bufferBeforeMinutes: s.bufferBeforeMinutes,
+ bufferAfterMinutes: s.bufferAfterMinutes,
+ }));
+}
+
+export interface AvailableStaffMember {
+ id: string;
+ name: string;
+ avatarUrl: string | null;
+}
+
+/** Staff who can perform every requested service (a StaffService row must exist for each), are active, and are bookable online. */
+export async function getAvailableStaff(
+ prisma: PrismaClient,
+ params: { businessId: string; serviceIds: string[] },
+): Promise {
+ if (params.serviceIds.length === 0) return [];
+
+ const staff = await prisma.staffProfile.findMany({
+ where: {
+ businessId: params.businessId,
+ status: "ACTIVE",
+ isBookableOnline: true,
+ AND: params.serviceIds.map((serviceId) => ({ services: { some: { serviceId } } })),
+ },
+ include: { user: { select: { name: true, image: true } } },
+ });
+
+ return staff.map((s) => ({
+ id: s.id,
+ name: s.user.name ?? "Staff member",
+ avatarUrl: s.user.image,
+ }));
+}
+
+export interface ResolvedBookingContext {
+ business: { id: string; timezone: string };
+ bookingRule: BookingRuleConfig;
+ services: ServiceRequirement[];
+ totalDurationMinutes: number;
+ effectiveBuffer: { beforeMinutes: number; afterMinutes: number };
+ eligibleStaffIds: string[];
+}
+
+const DEFAULT_BOOKING_RULE: BookingRuleConfig = {
+ minNoticeMinutes: 120,
+ maxAdvanceDays: 60,
+ cancellationWindowHours: 24,
+ rescheduleWindowHours: 24,
+ slotIntervalMinutes: 30,
+ bufferBeforeMinutes: 0,
+ bufferAfterMinutes: 5,
+ maxAppointmentsPerDay: null,
+ maxAppointmentsPerStaffPerDay: null,
+ customerCanChooseStaff: true,
+ allowGuestBooking: true,
+ requireApproval: false,
+};
+
+/**
+ * Shared setup for every availability query and for booking validation:
+ * resolve the business, its booking rules, the requested services, and
+ * which staff can actually perform all of them. Throws (via a
+ * ValidationResult the caller should check) rather than silently
+ * returning an empty result, so callers get a meaningful reason.
+ */
+export async function resolveBookingContext(
+ prisma: PrismaClient,
+ params: { businessId: string; serviceIds: string[]; staffId?: string },
+): Promise<{ context: ResolvedBookingContext | null; validation: ValidationResult }> {
+ if (params.serviceIds.length === 0) {
+ return { context: null, validation: fail({ code: "NO_SERVICES_SELECTED", message: "Select at least one service." }) };
+ }
+
+ const business = await prisma.business.findUnique({
+ where: { id: params.businessId },
+ select: { id: true, timezone: true, bookingRule: true },
+ });
+ if (!business) {
+ return { context: null, validation: fail({ code: "BUSINESS_NOT_FOUND", message: "Business not found." }) };
+ }
+
+ const services = await prisma.service.findMany({
+ where: { id: { in: params.serviceIds }, businessId: params.businessId, isActive: true },
+ });
+ if (services.length !== params.serviceIds.length) {
+ return {
+ context: null,
+ validation: fail({
+ code: "SERVICES_SPAN_MULTIPLE_BUSINESSES",
+ message: "One or more selected services are not available at this business.",
+ }),
+ };
+ }
+
+ const serviceRequirements: ServiceRequirement[] = services.map((s) => ({
+ serviceId: s.id,
+ durationMinutes: s.durationMinutes,
+ bufferBeforeMinutes: s.bufferBeforeMinutes,
+ bufferAfterMinutes: s.bufferAfterMinutes,
+ price: s.price.toString(),
+ }));
+
+ let eligibleStaffIds: string[];
+ if (params.staffId) {
+ const staffServiceCount = await prisma.staffService.count({
+ where: { staffId: params.staffId, serviceId: { in: params.serviceIds } },
+ });
+ const canPerformAll = staffServiceCount === params.serviceIds.length;
+ if (!canPerformAll) {
+ return {
+ context: null,
+ validation: fail({
+ code: "STAFF_CANNOT_PERFORM_SERVICE",
+ message: "This staff member doesn't offer one or more of the selected services.",
+ }),
+ };
+ }
+ eligibleStaffIds = [params.staffId];
+ } else {
+ const eligible = await getAvailableStaff(prisma, { businessId: params.businessId, serviceIds: params.serviceIds });
+ eligibleStaffIds = eligible.map((s) => s.id);
+ }
+
+ const bookingRule: BookingRuleConfig = business.bookingRule ?? DEFAULT_BOOKING_RULE;
+
+ return {
+ context: {
+ business: { id: business.id, timezone: business.timezone },
+ bookingRule,
+ services: serviceRequirements,
+ totalDurationMinutes: combinedServiceDuration(serviceRequirements),
+ effectiveBuffer: computeEffectiveBuffer(bookingRule, serviceRequirements),
+ eligibleStaffIds,
+ },
+ validation: ok(),
+ };
+}
+
+export interface AvailableDatesParams {
+ businessId: string;
+ serviceIds: string[];
+ staffId?: string;
+ fromDate: CalendarDate;
+ toDate: CalendarDate;
+}
+
+export async function getAvailableDates(
+ prisma: PrismaClient,
+ params: AvailableDatesParams,
+): Promise<{ dates: CalendarDate[]; validation: ValidationResult }> {
+ const { context, validation } = await resolveBookingContext(prisma, params);
+ if (!context || !validation.valid) return { dates: [], validation };
+ if (context.eligibleStaffIds.length === 0) {
+ return { dates: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
+ }
+
+ const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
+ const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
+
+ const today = todayIn(context.business.timezone);
+ const now = new Date();
+
+ const perStaffWindows = await Promise.all(
+ context.eligibleStaffIds.map((staffId) =>
+ getStaffFreeWindowsForRange(prisma, {
+ staffId,
+ fromDate: params.fromDate,
+ toDate: params.toDate,
+ businessTimezone: context.business.timezone,
+ businessOpenByWeekday,
+ effectiveBuffer: context.effectiveBuffer,
+ }),
+ ),
+ );
+
+ const businessCounts = await countAppointmentsPerDate(prisma, {
+ businessId: context.business.id,
+ fromDate: params.fromDate,
+ toDate: params.toDate,
+ businessTimezone: context.business.timezone,
+ });
+ const staffCounts = await Promise.all(
+ context.eligibleStaffIds.map((staffId) =>
+ countAppointmentsPerDate(prisma, {
+ businessId: context.business.id,
+ staffId,
+ fromDate: params.fromDate,
+ toDate: params.toDate,
+ businessTimezone: context.business.timezone,
+ }),
+ ),
+ );
+
+ const availableDates: CalendarDate[] = [];
+
+ for (const date of eachCalendarDate(params.fromDate, params.toDate)) {
+ if (!checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays).valid) continue;
+
+ const businessCount = businessCounts.get(date) ?? 0;
+ if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) continue;
+
+ const hasAnyFittingSlot = context.eligibleStaffIds.some((staffId, i) => {
+ const staffCount = staffCounts[i]?.get(date) ?? 0;
+ if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) return false;
+
+ const freeWindows = perStaffWindows[i]?.get(date) ?? [];
+ const starts = generateSlotStarts(freeWindows, {
+ slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
+ durationMinutes: context.totalDurationMinutes,
+ });
+ return starts.some((startMinutes) => {
+ const instant = wallClockToInstant(date, startMinutes, context.business.timezone);
+ return checkMinNotice(instant, now, context.bookingRule.minNoticeMinutes).valid;
+ });
+ });
+
+ if (hasAnyFittingSlot) availableDates.push(date);
+ }
+
+ return { dates: availableDates, validation: ok() };
+}
+
+export interface TimeSlotOption {
+ startMinutes: number;
+ endMinutes: number;
+ startAt: string; // ISO instant
+ endAt: string;
+ staffOptions: string[];
+}
+
+export interface AvailableTimeSlotsParams {
+ businessId: string;
+ serviceIds: string[];
+ staffId?: string;
+ date: CalendarDate;
+}
+
+export async function getAvailableTimeSlots(
+ prisma: PrismaClient,
+ params: AvailableTimeSlotsParams,
+): Promise<{ slots: TimeSlotOption[]; validation: ValidationResult }> {
+ const { context, validation } = await resolveBookingContext(prisma, params);
+ if (!context || !validation.valid) return { slots: [], validation };
+ if (context.eligibleStaffIds.length === 0) {
+ return { slots: [], validation: fail({ code: "STAFF_CANNOT_PERFORM_SERVICE", message: "No staff can perform the selected services." }) };
+ }
+
+ const today = todayIn(context.business.timezone);
+ const now = new Date();
+
+ const advanceCheck = checkMaxAdvance(params.date, today, context.bookingRule.maxAdvanceDays);
+ if (!advanceCheck.valid) return { slots: [], validation: advanceCheck };
+
+ const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
+ const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours);
+
+ const businessCount = (
+ await countAppointmentsPerDate(prisma, {
+ businessId: context.business.id,
+ fromDate: params.date,
+ toDate: params.date,
+ businessTimezone: context.business.timezone,
+ })
+ ).get(params.date) ?? 0;
+ if (!checkBusinessDailyLimit(businessCount, context.bookingRule.maxAppointmentsPerDay).valid) {
+ return { slots: [], validation: ok() }; // no slots, but this isn't a validation error for the query itself
+ }
+
+ const byStartMinute = new Map();
+
+ for (const staffId of context.eligibleStaffIds) {
+ const staffCount =
+ (
+ await countAppointmentsPerDate(prisma, {
+ businessId: context.business.id,
+ staffId,
+ fromDate: params.date,
+ toDate: params.date,
+ businessTimezone: context.business.timezone,
+ })
+ ).get(params.date) ?? 0;
+ if (!checkStaffDailyLimit(staffCount, context.bookingRule.maxAppointmentsPerStaffPerDay).valid) continue;
+
+ const windowsByDate = await getStaffFreeWindowsForRange(prisma, {
+ staffId,
+ fromDate: params.date,
+ toDate: params.date,
+ businessTimezone: context.business.timezone,
+ businessOpenByWeekday,
+ effectiveBuffer: context.effectiveBuffer,
+ });
+ const freeWindows = windowsByDate.get(params.date) ?? [];
+ const starts = generateSlotStarts(freeWindows, {
+ slotIntervalMinutes: context.bookingRule.slotIntervalMinutes,
+ durationMinutes: context.totalDurationMinutes,
+ });
+
+ for (const startMinutes of starts) {
+ const startAt = wallClockToInstant(params.date, startMinutes, context.business.timezone);
+ if (!checkMinNotice(startAt, now, context.bookingRule.minNoticeMinutes).valid) continue;
+
+ const endMinutes = startMinutes + context.totalDurationMinutes;
+ const existing = byStartMinute.get(startMinutes);
+ if (existing) {
+ existing.staffOptions.push(staffId);
+ } else {
+ byStartMinute.set(startMinutes, {
+ startMinutes,
+ endMinutes,
+ startAt: startAt.toISOString(),
+ endAt: wallClockToInstant(params.date, endMinutes, context.business.timezone).toISOString(),
+ staffOptions: [staffId],
+ });
+ }
+ }
+ }
+
+ const slots = [...byStartMinute.values()].sort((a, b) => a.startMinutes - b.startMinutes);
+ return { slots, validation: ok() };
+}
+
+export interface ValidateBookingSlotParams {
+ businessId: string;
+ serviceIds: string[];
+ /** Must already be a specific staff member — BookingService resolves "any available barber" to one before calling this. */
+ staffId: string;
+ startAt: Date;
+ /** When re-validating a reschedule, exclude the appointment being moved from every conflict/limit check. */
+ excludeAppointmentId?: string;
+}
+
+export interface ValidatedBookingSlot {
+ validation: ValidationResult;
+ context: ResolvedBookingContext | null;
+ endAt: Date | null;
+}
+
+/**
+ * Diagnoses a single candidate (business, services, staff, start time) in
+ * full, producing the specific, distinct error the brief calls for
+ * ("business is open", "barber is working", "not on a break", "not on
+ * leave", "requested time is available", "services fit", "booking rules
+ * satisfied") rather than one generic "unavailable". This is a
+ * synchronous-feeling *diagnosis* for good error messages; the
+ * authoritative, race-safe check that actually guards against double
+ * booking still happens inside SchedulingService's transaction at commit
+ * time — this function can be (rarely) stale by the time a booking
+ * actually commits, same as any other read-then-write.
+ */
+export async function validateBookingSlot(
+ prisma: PrismaClient,
+ params: ValidateBookingSlotParams,
+): Promise {
+ const { context, validation: contextValidation } = await resolveBookingContext(prisma, {
+ businessId: params.businessId,
+ serviceIds: params.serviceIds,
+ staffId: params.staffId,
+ });
+ if (!context || !contextValidation.valid) return { context: null, validation: contextValidation, endAt: null };
+
+ const { timezone } = context.business;
+ const date = calendarDateOf(params.startAt, timezone);
+ const startMinutes = minutesFromMidnight(params.startAt, timezone);
+ const endMinutes = startMinutes + context.totalDurationMinutes;
+ const endAt = wallClockToInstant(date, endMinutes, timezone);
+ const now = new Date();
+ const today = todayIn(timezone);
+
+ const ruleChecks = mergeValidation(
+ checkMinNotice(params.startAt, now, context.bookingRule.minNoticeMinutes),
+ checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays),
+ );
+
+ const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } });
+ const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date));
+
+ if (!businessOpen || startMinutes < businessOpen.startMinutes || endMinutes > businessOpen.endMinutes) {
+ return {
+ context,
+ endAt,
+ validation: mergeValidation(ruleChecks, fail({ code: "BUSINESS_CLOSED", message: "The business is closed at this time." })),
+ };
+ }
+
+ const [workingHours, breakDays, activeTimeOff, businessCount, staffCount] = await Promise.all([
+ prisma.workingHours.findUnique({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: weekdayOf(date) } } }),
+ prisma.breakDay.findMany({ where: { dayOfWeek: weekdayOf(date), break: { staffId: params.staffId } }, include: { break: true } }),
+ prisma.timeOff.findFirst({
+ where: {
+ staffId: params.staffId,
+ status: "APPROVED",
+ startDate: { lte: calendarDateToUtcMidnight(date) },
+ endDate: { gte: calendarDateToUtcMidnight(date) },
+ },
+ }),
+ countAppointmentsPerDate(prisma, {
+ businessId: context.business.id,
+ fromDate: date,
+ toDate: date,
+ businessTimezone: timezone,
+ excludeAppointmentId: params.excludeAppointmentId,
+ }),
+ countAppointmentsPerDate(prisma, {
+ businessId: context.business.id,
+ staffId: params.staffId,
+ fromDate: date,
+ toDate: date,
+ businessTimezone: timezone,
+ excludeAppointmentId: params.excludeAppointmentId,
+ }),
+ ]);
+
+ const staffWorking: TimeWindow | null =
+ workingHours && !workingHours.isOff && workingHours.startMinutes !== null && workingHours.endMinutes !== null
+ ? { startMinutes: workingHours.startMinutes, endMinutes: workingHours.endMinutes }
+ : null;
+
+ if (!staffWorking) {
+ return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_NOT_WORKING", message: "This staff member isn't scheduled to work at this time." })) };
+ }
+
+ if (activeTimeOff) {
+ return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_LEAVE", message: "This staff member is on approved leave that day." })) };
+ }
+
+ const breaks: TimeWindow[] = breakDays.map((bd) => ({ startMinutes: bd.break.startMinutes, endMinutes: bd.break.endMinutes }));
+ const onBreak = breaks.some((b) => startMinutes < b.endMinutes && endMinutes > b.startMinutes);
+ if (onBreak) {
+ return { context, endAt, validation: mergeValidation(ruleChecks, fail({ code: "STAFF_ON_BREAK", message: "This staff member is on a break during part of this time." })) };
+ }
+
+ // Theoretical window: hours ∩ working hours, minus breaks, WITHOUT existing appointments — distinguishes
+ // "the services just don't fit in the remaining open time" from "someone else booked over this".
+ const theoreticalWindows = computeFreeWindows({ businessOpen, staffWorking, breaks, onLeave: false, busy: [] });
+ const fitsTheoretically = theoreticalWindows.some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
+ if (!fitsTheoretically) {
+ return {
+ context,
+ endAt,
+ validation: mergeValidation(ruleChecks, fail({ code: "INSUFFICIENT_DURATION", message: "The selected services don't fit before this staff member's hours end." })),
+ };
+ }
+
+ const businessCapCheck = checkBusinessDailyLimit(businessCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerDay);
+ const staffCapCheck = checkStaffDailyLimit(staffCount.get(date) ?? 0, context.bookingRule.maxAppointmentsPerStaffPerDay);
+
+ const actualWindows = await getStaffFreeWindowsForRange(prisma, {
+ staffId: params.staffId,
+ fromDate: date,
+ toDate: date,
+ businessTimezone: timezone,
+ businessOpenByWeekday: { get: (w: Weekday) => (w === weekdayOf(date) ? businessOpen : null) },
+ effectiveBuffer: context.effectiveBuffer,
+ excludeAppointmentId: params.excludeAppointmentId,
+ });
+ const fitsActually = (actualWindows.get(date) ?? []).some((w) => startMinutes >= w.startMinutes && endMinutes <= w.endMinutes);
+ const slotCheck = fitsActually ? ok() : fail({ code: "SLOT_UNAVAILABLE", message: "This time was just booked — please choose another." });
+
+ return {
+ context,
+ endAt,
+ validation: mergeValidation(ruleChecks, businessCapCheck, staffCapCheck, slotCheck),
+ };
+}
+
+async function countAppointmentsPerDate(
+ prisma: PrismaClient,
+ params: {
+ businessId: string;
+ staffId?: string;
+ fromDate: CalendarDate;
+ toDate: CalendarDate;
+ businessTimezone: string;
+ excludeAppointmentId?: string;
+ },
+): Promise