# Reschedule / Reassign + Slot Picker — Logic Parity

Demo = React+TS+Zustand at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`.
Ours = Yii2 (`frontend/`). All citations are `file:line`.

## 1. Free-slot computation (the engine)

### Demo
- `lib/schedule.ts` is the pure scheduling core. Free slots derive from
  **working shifts − time-off − existing bookings − past times (today only)**.
- `computeSlots()` in `SlotPicker.tsx:34-58`: for each free block from
  `specialistAvailability()`, step from `ceil(blockStart/step)*step` by
  `shop.slotStepMin || 15`, skip `start < nowMin` when the day is today,
  then run `checkPlacement()` per candidate; keep only `ok` ones.
- `specialistAvailability()` (`schedule.ts`): `subtractAll(workingBlocks, timeOff)`,
  sorted. `workingBlocks` reads `sp.workingHours[day].shifts`, overnight-aware
  (`end<=start ⇒ +1440`).
- `checkPlacement()` (`schedule.ts`) rejection order: **overlap → time-off →
  outside-availability → cannot-perform**. Returns `{ok, reason, collidingId}`.
  `cannot-perform` only fires when `serviceIds` is passed (reassign).
- Business-day model: `businessDayOf` / `toBusinessMinutes` / `fromBusinessMinutes`
  handle overnight shops (small hours belong to the prior session). Slot `value`
  is a `YYYY-MM-DDTHH:mm` datetime-local string.

### Ours
- `BookingScheduleService.php` is a near-1:1 PHP port.
  - `freeSlots()` `:348-376` — same `ceil(blockStart/step)*step` stepping,
    same `start < nowMin` today-guard (`:360`), same per-candidate
    `checkPlacement()` filter. Step from `shop.slot_time_step || 15` (`:351`).
  - `availability()` `:217-227` = `subtract(workingBlocks, timeOffBlocks)`.
  - `workingBlocks()` `:108-133` reads `user_shift` (`from_time` = `dayId:HH:MM`),
    overnight-aware (`:126-128`).
  - `checkPlacement()` `:273-325` — **identical rejection order**
    overlap → time-off → outside-availability → cannot-perform; `cannot-perform`
    gated on `$serviceIds !== null` (`:320`).
- **Gap — no business-day / overnight slot search.** `freeSlots` works in
  "minutes from midnight" on a single calendar date string; overlap query is
  `LIKE booking_date date%` (`:280`). The demo's overnight slot value can roll
  to the next calendar morning via `fromBusinessMinutes`; ours emits
  `value = "$date $HH:MM"` (`:368`) and cannot represent a slot whose clock time
  is past midnight on the next calendar day. Acceptable if Navagoo shops are not
  overnight, but it is a real divergence from the ported engine.
- Slot `value` shape differs: ours is `"Y-m-d H:i"` (space), demo is
  `"Y-m-dTHH:mm"` (T). Ours also carries explicit `from`/`to`/`label`; demo
  computes the label client-side.

## 2. Reschedule flow (same specialist, time-move)

### Demo
- `RescheduleModal` (`modals.tsx:263-348`): renders `SlotPicker` with
  `ignoreBookingId=booking.id` and `defaultDayKey = businessDayOf(appointmentDate)`.
- A **live placement preview** runs in the component (`modals.tsx:282-302`):
  `checkPlacement(... ignoreBookingId)` → maps `reason` to a localized
  `conflictMsg` shown in a red banner; the Reschedule button is disabled while
  `!dt || !placement.ok`.
- Confirm (`modals.tsx:304-309`) calls store `rescheduleBooking(id, iso)` +
  success toast. The Reschedule entry is only offered when
  `canRescheduleStatus(status, config.rescheduleStatuses)` is true
  (`modals.tsx:63,128`).
- **Store `rescheduleBooking`** (`store.ts:1374-1413`) is the authoritative net:
  re-checks `canRescheduleStatus` (`:1381`) AND re-runs `checkPlacement` (`:1388`);
  refuses (`return {}`) on either failure. Pushes an activity entry. No charge
  re-derivation (end time is always derived).

### Ours
- `_reschedule_modal.php` is a vanilla-JS slot picker opened from any
  `[data-reschedule-open]` trigger (seeds id/agent/duration/services/date).
  Picking a slot POSTs `{id,date,from,to}` to `/booking/reschedule` and reloads.
- `actionReschedule()` → `moveBooking(false)` (`BookingController.php:506-609`).
  Writes inside a transaction: deletes `AgentSlots`, updates booking
  `agent_id/booking_date/from_hour/to_hour`, increments `reschedule_count`,
  recreates the slot mirror. Re-runs `checkPlacement` server-side (`:569`).
- **Gaps:**
  1. **No status/permission gate.** Demo refuses to reschedule unless the
     booking status is in `config.rescheduleStatuses` (default `['scheduled']`),
     enforced in BOTH the UI and the store net. `moveBooking()` has **no such
     check** — any existing booking can be moved by id. `isDraggable()` exists
     (`BookingScheduleService.php:42-45`, scheduled+accepted) and gates the
     drag handle, but the reschedule endpoint itself does not call it. This is
     a correctness/permission divergence.
  2. **No live conflict preview / disabled-confirm.** The demo shows the red
     reason banner and disables Reschedule before submit. Ours only learns of
     a conflict from the POST response (`alert`).
  3. Confirmation is a hard `location.reload()` + `alert()` on error, vs. the
     demo's toast + in-place store update.

## 3. Reassign flow (different specialist)

### Demo
- There is **no dedicated Reassign modal**. Reassign happens by **dragging a
  booking card across specialist columns** in `DayCalendar.tsx` →
  `reassignBooking(bookingId, targetSpecialist, newIso)` (`DayCalendar.tsx:204`).
  Drag handles are gated by `canRescheduleStatus` (`SpecialistColumn.tsx:235`).
- **Store `reassignBooking`** (`store.ts:1414-1455`): same `canRescheduleStatus`
  gate (`:1419`), then `checkPlacement` **with `serviceIds`** (`:1431-1438`) so a
  cross-specialist move also verifies can-perform. Updates `specialistId`
  (+ optional date) and pushes activity. No charge re-derivation (`store.ts:184-185`).

### Ours
- `actionReassign()` → `moveBooking(true)` (`BookingController.php:515-517`).
  Reads `agent_id` from POST, builds `serviceIds` from the booking's
  `bookingServices` (`:562-567`), runs `checkPlacement` with services (`:569`)
  so can-perform is verified. The reassign endpoint is wired into the Day
  calendar drag via `reassignUrl` (`BookingScheduleService.php:632`).
- **Gaps:** same missing status/permission gate as reschedule (no
  `canRescheduleStatus` equivalent before the write). The drag handle is gated
  by `isDraggable()` (scheduled+accepted) on the client, but the endpoint trusts
  the POST.

## 4. Slot-picker UX logic (Book now / Earliest / Next available)

### Demo `SlotPicker.tsx`
- **Book now** (`:120-137`): the next slot boundary from `simNow` (round UP),
  shown only if free for the full duration; a glowing pill above the card.
- **Earliest** (`:148-160`): scans up to 56 days for the first day with any
  opening; tags that day's first slot "Earliest".
- **Next available** (`:170-180`): when the chosen day is empty, scans the next
  8 weeks for the next open day and offers a one-tap jump.
- Empty/off states distinguish "specialist is off this day" vs "no openings for
  a {duration} service" (`:230-237`).
- `defaultDayKey` clamps to today; prev arrow disabled at today.

### Ours `_reschedule_modal.php`
- Has: day prev/next navigator, prev disabled at/before today (`:118`),
  clamp-to-today on open (`:193`), per-day slot fetch via `/booking/slots`,
  an empty state (`No open times this day.` + `calendar-off` icon).
- **Missing entirely:** Book now, Earliest tag, Next-available jump, the
  off-day-vs-no-openings distinction, and a tappable calendar/date-input (the
  date pill is display-only — no `showPicker`).

## Summary of logic divergences
1. **No status/permission gate** on reschedule OR reassign write paths (most
   important — a non-`scheduled` booking can be moved). (`moveBooking`)
2. **No business-day/overnight handling** in slot search (single-date model).
3. **No live conflict preview** in the reschedule modal.
4. Slot-picker conveniences (Book now / Earliest / Next available / calendar
   open / off-day messaging) are absent.
