4.7 KiB
2026-05-01 — Availability Algorithm
Same-day successor to
2026-05-01-schema-and-seed.md.
Milestone
The pure availability algorithm from Initial.md §5 is implemented and exhaustively tested. Closes Step 3 of Initial.md §9.
What landed
src/lib/availability.ts— pure module exportingfindSlots,therapistAvailable,roomAvailable,inWorkingHoursplus the*Statetypes the algorithm consumes.test/availability.test.ts— 28 tests acrossinWorkingHours,therapistAvailable, andfindSlotsintegration.
What's verified
pnpm test— 38/38 green (10 schema + 28 availability)pnpm lint— cleanpnpm exec tsc --noEmit— clean
Coverage of the algorithm
inWorkingHours (6): inside hours, before-open, exactly-at-close, non-working-day, DST spring-forward day, effectiveFrom bracket, cross-midnight rejected.
therapistAvailable (4): free in hours, booking-overlap blocks, BLOCK override blocks, EXTRA_HOURS opens out-of-hours.
findSlots integration (18): baseline grid math, empty therapist/room lists, therapist tag mismatch, room tag mismatch, ServiceTherapist allowlist, inactive therapist/room, BLOCK PTO, room buffer enforcement, room block, preferred-therapist filter, off-grid from rounds up, out-of-hours window, DST day, slot ordering, partial-availability slot still emitted with surviving candidates.
Decisions ratified
| Decision | Resolution |
|---|---|
| Pure / impure boundary | Algorithm is pure; DB loader is separate (not yet written) |
| Slot grid orientation | UTC, not local. DST handled implicitly |
| Slot granularity | Default 15 min; configurable via slotGranularityMin |
| Cross-midnight slots | Rejected outright |
| EXTRA_HOURS vs working hours | Union — slot is OK if either covers it |
| BLOCK vs everything | Always wins — any overlap kills the slot |
| Booking overlap semantics | Half-open [), matching the exclusion constraints |
| TZ extraction library | Intl.DateTimeFormat (not date-fns-tz toZonedTime) — see Gotcha 1 |
| Output shape | Slot returns all candidate therapists/rooms; assignment happens at commit time |
Gotchas hit
1. date-fns-tz v3 toZonedTime is system-TZ-dependent
toZonedTime(date, tz) returns a Date whose value is shifted such that getHours() (system-local) reads as the target zone's wall clock — but only if you call the system-local accessors, and only if you understand the shift. Calling .getUTCHours() on the result gives UTC, not the target. On an EDT developer machine targeting America/New_York, the call is a no-op and the bug is silent.
Fix: use Intl.DateTimeFormat with formatToParts directly. Cached per zone; ~50 LOC helper. Portable across system timezones, no library quirks.
date-fns-tz is still installed; we'll use formatInTimeZone for display formatting later (which uses Intl internally and is fine).
2. Intl.DateTimeFormat returns "24" for midnight in some locales with hour12: false
Quirky and well-documented. Normalized to 0 in the helper.
Repo deltas this session
src/lib/availability.ts NEW ~250 LOC
test/availability.test.ts NEW ~330 LOC
package.json + date-fns + date-fns-tz, + tsx
prisma/schema.prisma (full schema from earlier today)
prisma/migrations/ (full_schema migration from earlier today)
prisma/seed.ts, src/lib/seed.ts (from earlier today)
test/booking-exclusion.test.ts (refactored to fixtures, +3 FK tests)
docs/progress/ 2 new snapshots (this and schema-and-seed)
Open questions still unresolved
- Practice timezone (
APP_TZplaceholder) - Customer-visible brand name (TouchBase or other)
- Currency
- Stripe account ownership
Roadmap status (per Initial.md §9)
Spike: docker-compose + exclusion-constraint migration + testsdone 2026-04-30Schema + migrations + seeddone 2026-05-01Pure availability algorithm + testsdone 2026-05-01- next: DB loader for the algorithm + first end-to-end story (admin creates booking on behalf of customer, confirmation email sent)
- Public self-booking → Stripe deposits → reminders
Recommended next step
(a) DB loader — loadAvailabilityState(db, { from, to, serviceId }) that turns DB state into the TherapistState[] / RoomState[] the algorithm consumes. Roughly half a day. Unblocks any UI work that needs real availability data.
Then the first end-to-end story (Step 4) becomes very close — admin picks slot via findSlots, calls a createHold server action that inserts a Booking with HOLD status, transactional with payment-intent issuance later. Auth.js + UI can stay deferred since admin-only flow doesn't need public auth yet.