# Shop · Specialist time-off — Logic & flows

Canonical = React demo at `/private/tmp/Navagoo_MI_dev/navagoo-app/src`. Ours = Yii2 (frontend portal).

## Data shape

| | Demo | Ours |
|---|---|---|
| Entity | `TimeOff` (`types.ts:370`) | `AgentTimeOff` (`common/models/AgentTimeOff.php:30`) + table `m260622_130000_create_agent_time_off.php` |
| Scope | `scope: 'specialist' \| 'shop'` + optional `specialistId` (`types.ts:367,373-374`) | `agent_id` NULL = shop-wide, else specialist (`AgentTimeOff.php:14-16`) |
| Time storage | LOCAL ISO `start` / `end` timestamps (business-minute aware, overnight crosses calendar date) | `off_date` (Y-m-d) + `from_hour`/`to_hour` (`HH:MM`) + `all_day` flag |
| Reason | enum `break/vacation/sick/holiday/closed/other` (`types.ts:368`) | identical keys in `AgentTimeOff::REASONS` (`:32-39`) |
| Note | `note?: string` (`types.ts`) | `note` varchar(255) |
| allDay | `allDay?` spans shop open→close (`types.ts:379`) | `all_day` tinyint; from/to ignored when set |

**Key modelling divergence (functionally equivalent):** the demo stores absolute ISO start/end so an overnight block is just `end` on the next calendar day; ours stores wall-clock `from_hour`/`to_hour` on a single `off_date` and reconstructs the overnight crossing at read time (`end <= start → end += 1440`, `BookingScheduleService.php:177-179`). Both produce the same business-minute interval.

## Create flow

**Demo** — `NewTimeOffModal.tsx`:
- Defaults: scope `specialist`, first active specialist, `defaultDate`, 13:00→14:00, reason `break`, allDay off (`:39-46`).
- `valid = date && (scope==='shop' || specialistId) && (allDay || start !== end)` (`:48`).
- On submit (`:50-77`): if allDay → `shopWindow(shop)` → `fromBusinessMinutes(date, win.start/end)`; else `hhmmToMin`, and if `end <= start` add 1440 (overnight) before converting to ISO. Calls `addTimeOff` (`store.ts:1695`), toasts, closes.

**Ours** — `_timeoff_modal.php` + `BookingController::actionTimeOff()` (`:640-669`):
- Same default field values (13:00/14:00, reason break) (`_timeoff_modal.php:91,95,71`).
- Client builds a URLSearchParams POST; whole-shop sends blank `agent_id` (`:178`).
- Controller forces `shop_id` from session identity, nulls hours when all_day, validates that a per-specialist `agent_id` belongs to THIS shop's agents (`:658-663`), then `save()`. The overnight `end+=1440` and allDay→shop-window expansion happen later at read time in `timeOffBlocks` (`BookingScheduleService.php:168-179`), NOT at write time.

### Validation parity gap
The demo blocks submit when `!allDay && start === end` (`NewTimeOffModal.tsx:48`). Ours has **no equal-times guard** — neither client JS (`_timeoff_modal.php:166-207`) nor `AgentTimeOff::rules()` (`:54-69`) rejects `from_hour === to_hour`. A zero-length block can be saved. `timeOffBlocks` would emit `startMin === endMin`, harmless to layout but still a stored junk row. **Partial.**

## Read / availability computation

**Demo** — `lib/schedule.ts`:
- `timeOffForBusinessDay(s, shopId, key, specialistId?)` (`:195-209`): filters by shop, by `businessDayOf(start)` matching the viewed business day, and (when a specialist is given) `scope==='shop' || specialistId===…`. No specialist → returns all blocks both scopes.
- `specialistAvailability` (`:212-223`) = `subtractAll(workingBlocks − timeOffIntervals)`.
- `subtractOne`/`subtractAll` interval algebra (`:133-151`).

**Ours** — `BookingScheduleService::timeOffBlocks($shopId, $date, $agentId=null)` (`:157-191`):
- SQL filter `shop_id + off_date`; with agentId → `agent_id IS NULL OR agent_id = :id` (`:159-162`) — exact match to the demo's scope filter.
- `availability()` (`:217-227`) = `subtract(workingBlocks, timeOffBlocks)`; `subtract()` (`:194-214`) is a line-for-line port of `subtractOne`/`subtractAll`.
- `shadedGaps()` (`:230-245`) is the complement used for out-of-shift shading (demo derives free blocks and shades the gaps in the column component).

**Overnight / business-day divergence:** the demo keys time-off by `businessDayOf(t.start)` so a block created after midnight maps back to the prior session. Ours keys purely on the stored `off_date` column and never reassigns a post-midnight block to the previous business day — there is no `businessDayOf` equivalent. For same-day blocks this is identical; for a block authored as belonging to a previous overnight session it can differ. Low practical impact (the create modal always writes the picked `off_date`), noted as a partial.

## Conflict detection (booking placement)

**Demo** `checkPlacement` (`schedule.ts:312-348`) rejection order: overlap → time-off → outside-availability → cannot-perform. Time-off check at `:331-334`.
**Ours** `checkPlacement` (`BookingScheduleService.php:273+`) same order; time-off loop at `:301`. Reason strings: demo `placementReasonText` (`:352-365`) vs ours `reasonMessage` (`:327`). Parity: done.

## Remove flow

**Demo** — `DayCalendar.tsx:334-339`: `onRemoveTimeOff` shows a `confirm({title:'Remove this block?', danger})`, then `removeTimeOff(id)` (`store.ts:1715`, filters the array). Per-block X button surfaced on hover by `TimeOffBlock.tsx:49-59`.
**Ours** — `_calendar_day.php:154-158` renders the same hover X (`data-timeoff-remove`); `calendar.php:208-213` binds it: `confirm('Remove this block?')` then `ngPost('/booking/remove-time-off', {id})`. Controller `actionRemoveTimeOff` (`:672-684`) scopes the delete to the session shop (`findOne(['id'=>id,'shop_id'=>shopId])`). Parity: done.

## Rendering

Demo `TimeOffBlock.tsx` (hatched 45° block, label = `note || titleCase(reason)`, time label, min-height clamp) is reproduced server-side in `_calendar_day.php:133-161` (45° repeating-linear-gradient, label = reason label, time label, 22px min height). Parity: done (label source differs slightly — see ui.md).
