# Shop · Bookings calendar — Parity matrix

Demo (canonical) = `/private/tmp/Navagoo_MI_dev/navagoo-app/src`. Ours = Yii2 frontend.
Refs are `file:line`. BSS = `frontend/components/BookingScheduleService.php`,
BC = `frontend/controllers/BookingController.php`,
day = `frontend/views/booking/_calendar_day.php`,
cal = `frontend/views/booking/calendar.php`.

| # | Demo behavior | Demo ref | Our ref | Status | Note |
|---|---|---|---|---|---|
| 1 | List/Day/Month view switcher | Bookings.tsx:74,325-333 | cal:30-34 | partial | Ours = 3 server routes (reload); "List" = legacy DataTable, not the unified solo+group table |
| 2 | Day = vertical gantt, specialists as columns | DayCalendar.tsx | day:74-211 + BSS::dayLayout:488 | done | Full server-side port |
| 3 | Columns: working specialists + booking-holders muted | schedule.ts:269-285 | BSS:384-410; day:96-117 | done | Muted = not a drop target both sides |
| 4 | Lane-packing of overlapping blocks (per-cluster) | schedule.ts:374-410 | BSS:438-481 / Presenter:138-187 | done | Equivalent greedy first-fit |
| 5 | Out-of-shift / unavailable shading | SpecialistColumn.tsx:32-42,155-175 | BSS::shadedGaps:230; day:119-126 | done | Hatch styling matches |
| 6 | Hover slot highlight w/ target time | SpecialistColumn.tsx:180-192 | — | missing | No cursor-follow preview |
| 7 | Appointment block: status tint + 4px bar + ring, compact mode | AppointmentBlock.tsx:63-115 | day:164-197 | done | Px formulas + compact (<38px) identical |
| 8 | Drag to reschedule (same column) | DayCalendar.tsx:136-207 | day:264-334; BC::moveBooking:525 | done | Native DnD vs dnd-kit; server re-validates |
| 9 | Drag to reassign (cross column) + can-perform | DayCalendar.tsx:143; schedule.ts:342 | day:311; BC:515; BSS::canPerform:250 | done | — |
| 10 | Live drag preview snaps to slot step | DayCalendar.tsx:131-134 | — | partial | Ours snaps only on drop (browser ghost) |
| 11 | Reschedule **and** reassign confirm dialog (rich) | DayCalendar.tsx:171-197 | day:317 | partial | Ours: bare confirm on reassign only; reschedule silent |
| 12 | "Customer will be notified" on move | DayCalendar.tsx:191-195 | — | missing | BC::moveBooking fires no NotificationHelper |
| 13 | checkPlacement order overlap→timeoff→avail→perform | schedule.ts:312-347 | BSS:273-325 | done | Line-for-line |
| 14 | Server re-validates placement (authoritative) | store.ts:1381-1396 | BC:569 | done | — |
| 15 | Only reschedulable statuses draggable | SpecialistColumn.tsx:235; store.ts:1381 | BSS::isDraggable:42; day:175,183 | partial | Hard-coded 2 statuses; **server move doesn't re-check status** |
| 16 | Empty-slot click → new walk-in (pre-filled) | SpecialistColumn.tsx:143; DayCalendar.tsx:396 | day:337-348; BC::actionCreate:123 | partial | Page nav, not in-place modal |
| 17 | Reschedule slot-picker modal (day nav + open slots) | RescheduleModal/SlotPicker | _reschedule_modal.php; BC::actionSlots:615; BSS::freeSlots:348 | done | Ours richer (past-time filter); verify a trigger opens it |
| 18 | Block-time modal (scope/date/reason/all-day/note) | NewTimeOffModal.tsx | _timeoff_modal.php; BC::actionTimeOff:640 | done | — |
| 19 | All-day = open→close window | NewTimeOffModal.tsx:54-57 | BSS:168-170 | done | — |
| 20 | Remove time-off block (X + confirm) | DayCalendar.tsx:334; TimeOffBlock.tsx:49 | day:154-158; cal:206-214; BC::actionRemoveTimeOff:672 | done | Shop-scoped delete |
| 21 | Now-line on live day, within window | DayCalendar.tsx:110-112 | BSS:608-612; day:200-204 | done | Server-computed |
| 22 | Vertical zoom | DayCalendar.tsx:358-383 | cal:91-102; BSS:509 | partial | Server reload buttons, not a live slider |
| 23 | Fullscreen toggle (Esc exits) | DayCalendar.tsx:92-100,209-216 | cal:105-108,216-238 | done | — |
| 24 | Per-status count cards (day) + colour legend | DayCalendar.tsx:246-271 | cal:75-81 | partial | Ours = static legend, no per-day counts |
| 25 | "No one working this day" empty state w/ block-time CTA | DayCalendar.tsx:293-298 | day:63-72 | partial | Weaker copy; conflates with "no specialists exist" |
| 26 | Month grid: status count pills, today, click→day | Calendar.tsx MonthCalendar | _calendar_month.php; Presenter::monthGrid:268 | done | Ours hard Mon-first; adds legend row |
| 27 | RTL via logical CSS | (demo logical CSS) | day/cal logical props throughout | done | — |
| 28 | Business-day / overnight session ownership | schedule.ts:91-129 | BSS partial (:148,:126,:177) | partial | Overnight shift tails render; cross-midnight booking ownership not folded back |
| 29 | Booking detail modal (status timeline + transitions) | BookingDetailModal | _detail_modal.php; BC::actionDetailPartial:430, actionTransition:445 | done | Adjacent feature; reachable from blocks via data-detail-url |
| 30 | Duration floor keeps tiny bookings clickable | schedule.ts:166 (15min) | day:171 (22px) | partial | Px floor only; 0-dur → 60min fallback |
| 31 | Shop-scoped reads/writes (multi-tenant) | n/a (single-tenant) | BC:374,433,538,540; ABC:308 | done | Ours-only hardening |
| 32 | `reschedule_count` + agent_slots mirror on move | n/a | BC:585,577-598 | done | Ours-only (correct for our schema) |
| 33 | VAT-inclusive 15%, schedule layer ledger-free | schedule.ts:1-17 | BC:194 | done | Move endpoints never touch amounts; VAT rate hard-coded vs getVatRate() |

## New in the dev demo (absent or weaker on our side)
- Live cursor-following **hover slot highlight** with target time (#6).
- **Live drag-preview snapping** (#10).
- **Reschedule confirm** + rich confirm copy + **"customer notified"** promise/notification (#11, #12).
- **Per-status count cards** in the day toolbar (#24).
- Smooth **zoom slider** (#22).
- Muted-column **Inactive vs Off** distinction (ui.md gap 4).
- Group-booking visuals on calendar blocks (Users badge) + unified solo+group **List** view
  (#1) — broader Bookings feature, not just the calendar.

## Server-side risks to fix (ours)
- **#15 — `BC::moveBooking` does not re-check `isDraggable(status)`** before writing; a crafted
  POST could move a completed/cancelled booking. Add a status guard alongside the placement check.
- **#12 — no customer notification** on reschedule/reassign, contradicting the UX promise.

Area score: 78%

---

## Verified verdict (adversarial)

Independent re-read of both the demo and our code (file:line below). The analyst's
matrix is largely accurate and already conservative on the partial/missing rows. The
adversarial pass **confirmed** every partial/missing call and surfaced three additional
behavioral divergences not separately scored.

### Confirmed AS-CLAIMED
- **checkPlacement order** (overlap → time-off → outside-availability → cannot-perform)
  matches the demo line-for-line. BSS:273-325 vs `lib/schedule.ts checkPlacement`.
- **Lane-packing**: greedy first-fit, per-cluster lane count, identical sort + flush logic.
  BSS:438-481 / Presenter:138-187 vs `packLanes` in schedule.ts.
- **#15 partial — status not re-checked on move**: CONFIRMED. `moveBooking` (BC:525-609)
  calls `checkPlacement` but never `isDraggable($model->status)`; a crafted POST to
  `/booking/reschedule|reassign` can move a COMPLETED/CANCELLED booking. Real server-side gap.
- **#12 missing — no notification on move**: CONFIRMED. `moveBooking` fires no
  NotificationHelper, while the demo dialog (DayCalendar.tsx:191-195) promises
  "The customer will automatically be notified." The infra exists and IS used elsewhere
  (`actionCancel` BC:701 → `shopCancelBooking`), so the omission is deliberate, not absent infra.
- **#11 partial — confirm dialog**: CONFIRMED. Demo shows a rich confirm for BOTH
  reschedule and reassign (DayCalendar.tsx:163-205). Ours: bare `window.confirm` on
  **reassign only** (_calendar_day.php:317); reschedule commits silently → `location.reload()`.
- **#6 / #10 missing — live hover/drag preview**: CONFIRMED. Demo uses dnd-kit with a
  `snapVertical` modifier (cursor-following snap). Ours computes the snapped start only on
  `drop` (_calendar_day.php:303-305). No cursor-follow preview, no hover target-time chip.
- **#16 partial — empty-slot create**: CONFIRMED. Click on a column body navigates full-page
  to `actionCreate` with `?agent_id&date&start` (_calendar_day.php:337-348; BC:123-153),
  not an in-place modal.
- **#17 done — reschedule modal IS wired**: CONFIRMED. Trigger lives in
  `_detail_modal.php:207` (`data-reschedule-open`); modal listens at `_reschedule_modal.php:182`;
  both rendered by calendar.php. Flow: block → detail modal → Reschedule → slot-picker. Works.
- **#21 done — now-line**: CONFIRMED. Server computes `nowMin` only when viewing today and
  within window (BSS:607-612), rendered at _calendar_day.php:200-204.
- **#28 partial — overnight**: CONFIRMED. Shift/window overnight TAILS extend past 1440
  (BSS:126-128,148-150), but there is NO `businessDayOf`/`toBusinessMinutes` equivalent —
  cross-midnight bookings (a 2 AM appointment) are matched by `booking_date LIKE 'date%'`
  and are NOT folded into the prior business day's session as the demo does.

### NEW divergences found (not separately scored by the analyst)
- **canPerform — unassigned-service semantics differ (edge case).** Demo treats a service
  with `specialistIds` empty/undefined as performable by ANY specialist (types.ts:226;
  schedule.ts canPerform). Ours (BSS:250-259) requires an explicit `user_shop_service` row;
  a service with zero linked agents → `canPerform` returns **false for everyone**, so a
  reassign of a booking that includes such a service is wrongly rejected. Reschedule
  (same column) is unaffected. → narrows #9/#13 to "done with edge-case caveat".
- **Column membership lacks an active-status gate.** Demo working columns require
  `sp.status === 'active' && isWorkingOn` (schedule.ts calendarColumns); muted captures BOTH
  off-day and inactive. Ours (BSS:399-405) includes every shop agent with `muted = !working`
  — an **inactive** specialist who has a shift would render as a non-muted (drop-target)
  column. Drop-target-vs-muted behavior still matches for off-day agents, so #3 stays done,
  but the inactive/off distinction (ui.md gap 4) is the underlying cause.
- **VAT rate hard-coded — confirmed real.** `actionCreate` writes `$model->vat = $subtotal * 0.15`
  (BC:194) while `common\models\ShopService::getVatRate()` exists and is used elsewhere
  (AgentsBookingsController). The calendar **move** path is correctly ledger-free, so #33's
  "schedule layer is ledger-free" holds; the hard-code lives in create, adjacent to this area.

### Corrected rows (delta vs analyst JSON)
| # | Feature | Analyst | Verified | Why |
|---|---|---|---|---|
| 2 | Day vertical gantt | done | done | px formulas (0.8 / 10 / 22) confirmed |
| 3 | Working + muted columns | done | done (caveat) | no active-status gate; inactive-working agent is a live column |
| 4 | Lane-packing | done | done | first-fit + per-cluster confirmed |
| checkPlacement | order | done | done | line-for-line |
| 9/13 | reassign can-perform | done | done (caveat) | unassigned-service → wrongly blocks reassign |
| 6 | Hover/drag preview | missing | missing | confirmed |
| 11 | Confirm dialog | partial | partial | confirmed (reassign-only, silent reschedule) |
| 12 | Notify on move | missing | missing | confirmed; infra exists but unused |
| 15 | Status guard on move | partial | partial | confirmed server gap |
| 16 | Empty-slot create | partial | partial | confirmed full-page nav |
| 17 | Reschedule modal | done | done | trigger wiring verified |
| 28 | Overnight ownership | partial | partial | no businessDayOf fold-back |
| 33 | VAT / ledger-free | done | done (caveat) | move path clean; create hard-codes 0.15 |

### Adjusted area score: **76%**
(−2 from the analyst's 78 for the two newly-surfaced behavioral divergences — canPerform
unassigned-service and the missing active-status column gate — neither of which was
separately weighted. All partial/missing rows independently confirmed; no overclaim
required a partial→done or done→partial flip beyond adding edge-case caveats.)
