# Logic parity — scheduling / availability algebra

**Demo (canonical):** `/private/tmp/Navagoo_MI_dev/navagoo-app/src/lib/schedule.ts`
**Ours:** `frontend/components/BookingScheduleService.php`

The demo is a pure functional layer over a Zustand `DataState` slice. Ours is a static-method
service over real Yii2 ActiveRecord models (`UserShift`, `AgentTimeOff`, `Booking`, `Shop`,
`UserShopService`, `User/UserProfile`). The two implement the *same algebra* —
`availability = working shifts − time-off`, lane packing, four-reason placement check — but
differ in two structural ways: **(a) the business-day / overnight coordinate model**, and
**(b) how a booking's duration is obtained** (derived vs. stored).

---

## 1. Time helpers

| Demo | Ours |
|---|---|
| `hhmmToMin` (schedule.ts:48) | `clockToMin` (BSS.php:50) — also tolerates `H:MM` |
| — | `wallToMin` (BSS.php:63) — parses 12h `"03:15 AM"` from `shop.open_at/close_at` via `strtotime` |
| `snapToStep` (schedule.ts:82) | `snap` (BSS.php:90) — identical round-to-step |
| `weekdayToken` sun-first token (schedule.ts:77) | `dayIdFor` (BSS.php:99) — maps `date('w')` → `UserProfile::DAYS_MAP` (Sat=1..Fri=7 system id) |
| `minutesFromMidnight` (schedule.ts:54) | inlined via `date('G')*60+date('i')` |
| — | `minuteLabel` / `minToClock` (BSS.php:76,83) — label + write helpers (demo formats in `format.ts`, out of scope) |

Note ours stores working hours as `from_time = "dayId:HH:MM"` and queries with a
`LIKE 'dayId:%'` (BSS.php:111–114) — a string-keyed weekday model with no sun-first token array.

## 2. Business-day / overnight model — **MAJOR DIVERGENCE**

The demo's central abstraction (schedule.ts:7–17, 91–129) is **"business minutes"**: every
coordinate is measured from the *viewed business day's* midnight, so a 01:00 booking belonging
to an overnight session sits at minute 1500 on the prior day's grid.

- `isOvernight` (schedule.ts:93): `closeTime <= openTime`.
- `businessDayOf` (schedule.ts:100): small-hours timestamps before `closeTime` map to the **previous** calendar date.
- `toBusinessMinutes` / `fromBusinessMinutes` (schedule.ts:109,114): convert ISO ↔ continuous minutes, rolling the date across midnight (DST-safe).
- `timeOffForBusinessDay` and all `*OnBusinessDay` selectors key off `businessDayOf`, so overnight time-off / bookings land on the correct session.

**Ours has NO business-day layer.** `workingBlocks`/`shopWindow`/`timeOffBlocks` extend `endMin`
past 1440 when `end <= start` (BSS.php:126–128, 148–150, 177–179) — so an overnight *shift* renders
correctly *within one grid* — but bookings and time-off are matched by a literal SQL
`booking_date LIKE 'Y-m-d%'` / `off_date = 'Y-m-d'` (BSS.php:159, 280, 392, 501). A booking whose
clock time is 01:00 of the *next* calendar date is **not** pulled onto the prior business day, and
its `from_hour` minute (60) is **not** shifted to 1500. So overnight *appointments* (as opposed to
overnight shift shading) are mis-placed / dropped. Status: **partial**.

## 3. Booking duration — derived vs. stored — **MAJOR DIVERGENCE**

- Demo: `bookingMinutes` (schedule.ts:161) = Σ `totalDuration(service)` over the booking's lines, floored to 15. End time is **always derived, never stored** (header comment lines 3–5: "finance stays untouched"). `bookingInterval` (schedule.ts:170) = `[start, start+bookingMinutes]`.
- Ours: end comes from the **stored** `to_hour` column; only when `to_hour` is missing/≤ start does it fall back to `getScheduledDuration()` (a `to_hour−from_hour` diff, base/Booking.php:404) or a hard-coded **60-minute** default (BSS.php:286–294, 525–528). There is **no Σ-service-duration** path. `MIN_DURATION = 15` exists (BSS.php:33) but is only applied in `freeSlots`, not to rendered blocks.

Consequence: if `to_hour` is wrong/absent the demo still draws the true service length; ours draws 60 min. Status: **partial**.

## 4. Interval algebra (subtract / overlap)

Direct ports, equivalent:
- `subtractOne`/`subtractAll` (schedule.ts:133,150) ≡ `subtract` (BSS.php:194) — same split-on-overlap, same `endMin > startMin` filter.
- `overlaps` half-open test (schedule.ts:88) ≡ `overlaps` (BSS.php:263).
- `specialistAvailability` (schedule.ts:212) ≡ `availability` (BSS.php:217) — working shifts minus (specialist + shop-wide) time-off, sorted. **done.**
- Ours adds `shadedGaps` (BSS.php:230) — complement of free blocks within the window, used for muted shading. The demo computes shading differently in the component (renders raw shifts/time-off); no exact analog, but functionally covered. **done (ours superset).**

## 5. Time-off scoping

- Demo `timeOffForBusinessDay` (schedule.ts:195): `scope === 'shop'` OR `specialistId` match; no arg → all blocks.
- Ours `timeOffBlocks` (BSS.php:157): `agent_id IS NULL` (shop-wide) OR `agent_id = $agentId`; no arg → all. Plus an `all_day` flag that expands to the shop window (BSS.php:168–170) — the demo has no all-day flag (a shop-scope block with explicit start/end). Equivalent scoping; ours adds all-day convenience. **done.**

## 6. Placement check (four reasons, ordered)

`checkPlacement` is a faithful port. Rejection order **overlap → time-off → outside-availability → cannot-perform** matches exactly (schedule.ts:312 vs BSS.php:273).
- (a) overlap vs other same-agent active bookings — demo excludes `cancelled` + `ignoreBookingId`; ours uses `Booking::statusesFilter()` active set + `ignoreBookingId` (BSS.php:278–298).
- (b) time-off overlap — identical.
- (c) inside a raw working shift — identical containment test.
- (d) `canPerform` — demo: `service.specialistIds` empty OR includes id (schedule.ts:289); ours: every `service_id` ∈ the agent's `user_shop_service` rows (BSS.php:250). **Subtle difference:** demo treats "no linked specialists" as *anyone can do it*; ours requires an explicit `user_shop_service` row for every service (no "unrestricted" shortcut). Status of (d): **partial** (stricter semantics).

`reasonMessage` (BSS.php:327) ≡ `placementReasonText` (schedule.ts:352) — same four strings, ours bilingual via `Yii::t`. **done.**

## 7. Lane packing

`packLanes` (schedule.ts:374) vs ours (BSS.php:438) — both greedy first-fit, cluster-on-gap so a
standalone booking renders full width, both set `{lane, lanes}`. Equivalent. **done.**

## 8. Calendar columns

`calendarColumns` (schedule.ts:269) vs ours (BSS.php:384): active specialists working that day, PLUS
any specialist holding a booking that day rendered `muted`. Ours additionally **sorts** working-first
(BSS.php:408) and filters by `User::USER_TYPE_AGENT + shop_id`. Equivalent + ours sorts. **done.**

## 9. Day bounds / window widening

Demo `dayBounds` (schedule.ts:416): shop window widened to cover any booking/shift/time-off that
overflows. Ours folds this into `dayLayout` (BSS.php:558–593): widens `winStart/winEnd` over blocks,
shifts and time-off, then **snaps to whole hours** (demo does not snap to hours — that's an ours-only
presentation choice). Equivalent core. **done.**

## 10. Free-slot search — **NEW on OUR side**

`freeSlots` (BSS.php:348) generates concrete start-times (step from `shop.slot_time_step`, fits
duration, today-cutoff via `nowMin`, re-runs `checkPlacement` per candidate). **The demo has no
slot-picker** — it is a drag-and-drop calendar; placement is validated on drop/create
(store.ts:1337,1388,1424,1574,1624 call `checkPlacement`), never enumerated. This is an
ours-only addition serving our reschedule/new-booking flows. Not a gap — note as additive.

## 11. dayLayout payload — **NEW on OUR side**

`dayLayout` (BSS.php:488) assembles a view-ready payload (px-per-min zoom, hours axis, now-line,
reschedule/reassign URLs, per-block status meta/colors/URLs, muted columns, shaded gaps). The demo
keeps logic in `lib/` and lets React components assemble rendering. No demo analog; ours-only glue.
