# Shop · Bookings calendar — Logic & flows

Demo = React+TS+Zustand at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`.
Ours = Yii2 (frontend portal, Tailwind aurora reskin).

The scheduling math is a **near 1:1 PHP port** of the demo's `lib/schedule.ts`. The big
structural divergence is the **business-day / overnight model** (demo) vs a plain
**calendar-day model** (ours), and how the move is **committed** (Zustand setter vs an
HTTP POST that also writes `agent_slots` + bumps `reschedule_count`).

---

## 1. View switching (List / Day / Month)

- **Demo** `portals/shop/Bookings.tsx:74` — `view` is local React state (`'list'|'day'|'month'`),
  default `'day'`. Switching is instant (no navigation). Month → click a day calls
  `onSelectDay` which sets `dayKey` + flips `view` to `'day'` (`Bookings.tsx:440-443`).
- **Ours** — three **separate server routes**. List = `/booking/index`
  (`BookingController::actionIndex` :49), Day/Month = `/booking/calendar?view=day|month&date=`
  (`actionCalendar` :370). The segmented control is plain links (`calendar.php:30-34`),
  so each switch is a full page reload. Month-cell click is an `<a>` to the day view
  (`_calendar_month.php:152`, url built in `BookingCalendarPresenter::monthGrid` :308).
- **Gap**: demo's List view is a unified solo+group table with search + status filter +
  expandable drawers (`Bookings.tsx:86-316`). Ours points "List" at the legacy
  `BookingController::actionIndex` DataTable — a different surface, no group rows. Out of
  scope for *calendar* parity but noted.

## 2. Day-view assembly

- **Demo** `DayCalendar.tsx`: `calendarColumns` (schedule.ts:269) → working active
  specialists + any specialist holding a booking that day (muted). `dayBounds`
  (schedule.ts:416) widens the shop window to cover overflowing bookings/shifts/time-off.
  Per column: `packLanes` (schedule.ts:374), `specialistAvailability` (:212),
  `timeOffForBusinessDay` (:195).
- **Ours** `BookingScheduleService::dayLayout` (:488) reproduces all of it server-side:
  `calendarColumns` (:384), per-column `availability` (:217) → `shadedGaps` (:230),
  `timeOffBlocks` (:157), `packLanes` (:438), and window widening (:558-573). The full
  precomputed payload is handed to `_calendar_day.php` which only applies the px formulas.
- **Match**: lane packing (greedy first-fit, per-cluster lane-count) is line-for-line
  equivalent (demo schedule.ts:374-410 ≈ presenter :138-187 / service :438-481).
  Window snap-to-hour matches (demo `winStart=floor/60`, ours :589-590).

## 3. Working hours / availability / shaded gaps

- **Demo**: `workingBlocks` from `specialist.workingHours[day].shifts` (schedule.ts:178);
  `subtractAll` removes time-off (:150); `shadedIntervals` in `SpecialistColumn.tsx:32`
  is the window-minus-free complement.
- **Ours**: `workingBlocks` reads `user_shift` rows whose `from_time` is prefixed with the
  system day-id `"<dayId>:HH:MM"` (:108-133); `subtract` (:194) mirrors `subtractOne`;
  `shadedGaps` (:230) mirrors `shadedIntervals`. Day-id mapping via
  `UserProfile::DAYS_MAP` (:99-103).
- **Gap (data model)**: demo working hours are a clean per-weekday `shifts[]`; ours are
  encoded in `user_shift.from_time` as a `day:HH:MM` string + a separate `to_time`. The
  port works but is brittle (string LIKE match, `false` case-sensitivity flag).

## 4. Overnight / business-day model — **DIVERGENCE**

- **Demo**: full business-day model (`schedule.ts:91-129`). A shop with `closeTime <= openTime`
  is overnight; small-hours bookings map to the **previous** day's session
  (`businessDayOf` :100), and all coordinates are continuous "business minutes" anchored at
  the viewed day's midnight (`toBusinessMinutes`/`fromBusinessMinutes` :109/:114). Overnight
  tails extend past 1440 everywhere.
- **Ours**: `dayLayout` keys everything off a plain calendar date (`booking_date LIKE 'date%'`,
  :501). `shopWindow`/`workingBlocks`/`timeOffBlocks` do add `+1440` for an overnight
  *end* (:148, :126, :177), so a single shift that crosses midnight renders. **But** a
  booking that lands in the small hours of the next calendar date is queried under *that*
  date, not folded back into the prior business day. `AgentsBookingsController` comments
  even call the grid "a calendar-day grid (00:00–24:00)" (:202-205).
- **Status**: PARTIAL — overnight *shift tails* render; true cross-midnight *session
  ownership* of bookings does not. Acceptable for non-overnight shops; a real overnight
  salon will see early-morning bookings on the wrong day.

## 5. Conflict detection (`checkPlacement`)

- **Demo** `schedule.ts:312` rejects in order: overlap → time-off → outside-availability →
  cannot-perform. `canPerform` checks `service.specialistIds` (:289).
- **Ours** `BookingScheduleService::checkPlacement` (:273) — identical order and logic.
  Overlap queries the agent's same-day active bookings (:278), derives each end from
  `to_hour` or `getScheduledDuration()` (:286-294). `canPerform` (:250) checks
  `user_shop_service` rows. `reasonMessage` (:327) ≈ demo `REASON_MSG` / `placementReasonText`.
- **Match**: strong. One nuance — demo overlap uses `bookingInterval` (start + summed
  service `totalDuration`, floored at 15min, schedule.ts:161-173); ours trusts the stored
  `to_hour`/`getScheduledDuration()`. Equivalent in practice.

## 6. Drag → reschedule / reassign

- **Demo** `DayCalendar.tsx:136 onDragEnd`: snap delta to slot step, clamp to window,
  `checkPlacement` (with `serviceIds` only on cross-column), then a **confirm dialog**
  spelling out old→new time (and specialist) + "the customer will automatically be
  notified", then `rescheduleBooking` / `reassignBooking` store setters + a success toast.
  dnd-kit, 4px activation so plain clicks still open the drawer (:129).
- **Ours** `_calendar_day.php:264-334`: native HTML5 drag (no dep). dragstart reads
  `data-bid/dur/agent/services`; drop computes snapped+clamped start, decides
  reschedule vs reassign by comparing target/source agent, POSTs to `/booking/reschedule`
  or `/booking/reassign`. **Reassign** shows a `window.confirm` (:317); **reschedule**
  commits with **no confirm**. On success → `location.reload()`.
- **Server** `BookingController::moveBooking` (:525): re-runs `checkPlacement` (authoritative
  guard), then in a transaction deletes `agent_slots`, updates the booking
  (`agent_id/booking_date/from_hour/to_hour`), **bumps `reschedule_count`** (:585), and
  recreates the slot mirror (:590-596).
- **Gaps**:
  1. **No reschedule confirm** on ours (demo confirms both). Reassign confirm is a bare
     `window.confirm` vs the demo's rich dialog.
  2. **No customer notification** fired on reschedule/reassign. Demo's confirm copy
     promises "the customer will automatically be notified" — ours sends nothing
     (`NotificationHelper` is only wired to cancel, BookingController:701). **This is a
     behavioural promise the demo makes that our backend does not keep.**
  3. Demo snaps the drag *preview* live (`snapVertical` modifier :131); ours only snaps
     on drop (preview is the browser's ghost image).

## 7. Empty-slot click → new walk-in

- **Demo** `SpecialistColumn.tsx:143` click on empty body → `onEmptyClick(specialistId, min)`
  → opens `NewBookingModal` pre-seeded with specialist + ISO time (DayCalendar:331/396).
- **Ours** `_calendar_day.php:337` click on empty body → navigates to
  `/booking/create?agent_id&date&method=walk_in_shop_portal&start=HH:MM` (full page, not a
  modal). `actionCreate` (:123) pre-fills from those query params and defaults `to_hour` +1h.
- **Status**: PARTIAL — same outcome, but a page nav vs an in-place modal.

## 8. Reschedule slot-picker (modal)

- **Demo**: `RescheduleModal` / `SlotPicker` — day navigator + grid of open slots.
- **Ours** `_reschedule_modal.php` — full slot-picker: prev/next day (clamped to today,
  :199-214), fetches `GET /booking/slots` (`actionSlots` :615 → `freeSlots` :348), pick →
  POST `/booking/reschedule`. **freeSlots** even adds an `isToday && start < now` filter
  (:360) the demo lacks. Good parity, arguably richer. **Note**: this modal exists but
  there is **no trigger wired** in `_calendar_day.php` (no `[data-reschedule-open]`) — the
  detail modal's reschedule button would need to emit it; verify it is reachable.

## 9. Time-off (block time) + removal

- **Demo** `NewTimeOffModal.tsx`: scope (specialist|shop), date, reason, all-day toggle
  (open→close window), from/to with overnight `+1440` (:61), note → `addTimeOff` store.
  Remove via the X on `TimeOffBlock` → confirm → `removeTimeOff` (DayCalendar:334).
- **Ours** `_timeoff_modal.php` + `actionTimeOff` (:640) → `AgentTimeOff` row; all-day uses
  the shop window (service `timeOffBlocks` :168-170). Removal: X button → confirm → POST
  `/booking/remove-time-off` (`actionRemoveTimeOff` :672, shop-scoped). Reasons list matches.
- **Match**: strong.

## 10. Now-line, zoom, fullscreen

- **Demo**: now-line only on the live business day, inside the window (DayCalendar:110-112);
  zoom = client-side `pxPerMin` multiplier via a vertical slider (:88, :367), clamp [0.6,2.4];
  fullscreen = `fixed inset-0`, Esc exits (:92-100).
- **Ours**: now-line computed server-side (`nowMin`, service :608-612), rendered
  `_calendar_day.php:200`. Zoom = **server reload** with `?zoom=` (clamped [0.6,2.4] service
  :509; calendar.php:47-49 buttons) — no live slider. Fullscreen = JS toggles `fixed inset-0`
  classes on the wrapper, Esc exits (calendar.php:216-238).
- **Gap**: zoom is a page reload (2 +/- buttons) vs the demo's live slider; functionally
  equivalent, less smooth.
