diff --git a/docs/progress/2026-05-01-availability.md b/docs/progress/2026-05-01-availability.md new file mode 100644 index 0000000..5f753d7 --- /dev/null +++ b/docs/progress/2026-05-01-availability.md @@ -0,0 +1,88 @@ +# 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.ts` — **28 tests** across `inWorkingHours`, `therapistAvailable`, and `findSlots` integration. + +## What's verified + +- `pnpm test` — **38/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 + +## 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.