Files
touchbase/docs/progress/2026-05-01-availability.md
2026-05-01 18:32:01 -04:00

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 exporting findSlots, therapistAvailable, roomAvailable, inWorkingHours plus the *State types the algorithm consumes.
  • test/availability.test.ts28 tests across inWorkingHours, therapistAvailable, and findSlots integration.

What's verified

  • pnpm test38/38 green (10 schema + 28 availability)
  • pnpm lint — clean
  • pnpm 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

  1. Practice timezone (APP_TZ placeholder)
  2. Customer-visible brand name (TouchBase or other)
  3. Currency
  4. Stripe account ownership

Roadmap status (per Initial.md §9)

  1. Spike: docker-compose + exclusion-constraint migration + tests done 2026-04-30
  2. Schema + migrations + seed done 2026-05-01
  3. Pure availability algorithm + tests done 2026-05-01
  4. next: DB loader for the algorithm + first end-to-end story (admin creates booking on behalf of customer, confirmation email sent)
  5. Public self-booking → Stripe deposits → reminders

(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.