# Business rules — scheduling / availability algebra

Numbered, implementable rules the demo `lib/schedule.ts` encodes, each with our status.

## Coordinate / time model

1. **Working hours are minutes-from-midnight.** `'HH:mm' → h*60+m`.
   Demo `hhmmToMin` schedule.ts:48 · ours `clockToMin` BSS.php:50. **done.**

2. **Slot snapping rounds to the nearest step.** `step<=0 ⇒ no snap`.
   Demo `snapToStep` schedule.ts:82 · ours `snap` BSS.php:90. **done.**
   Step source: demo `shop.slotStepMin` (types.ts:68, default 15); ours `shop.slot_time_step ?? 15` (BSS.php:351,494).

3. **Overnight session = `closeTime <= openTime`.** The close time belongs to the *next*
   calendar day; the window's `endMin` extends past 1440.
   Demo `isOvernight`/`shopWindow` schedule.ts:93,124 · ours `shopWindow` BSS.php:141 (window only). **partial** — ours extends the *window* but not booking/time-off coordinates.

4. **Business-day attribution:** on an overnight shop, a timestamp before `closeTime` belongs to the
   PREVIOUS business day, and is plotted at `prev-midnight + 1440 + minutes`.
   Demo `businessDayOf`/`toBusinessMinutes` schedule.ts:100,109 · ours **MISSING** — bookings/time-off matched by literal calendar date (BSS.php:159,280). **missing.**

5. **An overnight working shift's tail extends past midnight** (`end += 1440` when `end<=start`).
   Demo `workingBlocks` schedule.ts:185 · ours BSS.php:126. **done** (this part exists independent of rule 4).

## Booking duration

6. **A booking's scheduled length = Σ of its services' `totalDuration` (incl. free lines), floored to 15 min.**
   End time is **derived, never stored**; the finance ledger is never touched by scheduling.
   Demo `bookingMinutes`/`bookingInterval` schedule.ts:161,170 · ours **DIVERGENT**: end = stored
   `to_hour`, else `getScheduledDuration()` diff, else hard **60-min** default (BSS.php:286–294,525–528).
   No Σ-service path; the 15-min floor is applied only in `freeSlots` (BSS.php:352), not to rendered blocks. **partial.**

## Availability algebra

7. **A specialist's free time on a day = working shifts − time-off** (specialist's own + shop-wide),
   computed by interval subtraction, sorted by start.
   Demo `specialistAvailability` schedule.ts:212 · ours `availability` BSS.php:217. **done.**

8. **Time-off scope:** a block applies if it is shop-wide OR belongs to the queried specialist.
   Demo `scope==='shop' || specialistId===id` schedule.ts:207 · ours `agent_id IS NULL || agent_id=id` BSS.php:161. **done.**

9. **All-day time-off** (ours-only flag) expands to the whole shop window.
   Ours BSS.php:168–170. Demo models this as a shop-window-spanning block (no flag). **done (ours superset).**

## Placement / conflict rules (ordered)

10. **Rejection order is fixed: overlap → time-off → outside-availability → cannot-perform.**
    Demo `checkPlacement` schedule.ts:312 · ours BSS.php:273. **done.**

11. **Overlap rule:** a proposed `[start,start+duration]` may not half-open-overlap any *other*
    non-cancelled booking of the same specialist that day; the dragged booking (`ignoreBookingId`)
    is excluded. Demo schedule.ts:317–328 · ours BSS.php:278–298 (active set = `Booking::statusesFilter()`). **done.**

12. **Time-off rule:** the proposed block may not overlap any applicable time-off. schedule.ts:330–334 · BSS.php:300–305. **done.**

13. **Inside-shift rule:** the proposed block must be fully contained in one raw working shift
    (`start>=shiftStart && end<=shiftEnd`). schedule.ts:336–340 · BSS.php:307–317. **done.**

14. **Can-perform rule:** when service ids are supplied (reassign), the specialist must be able to
    perform every selected service.
    - Demo: a service with **no** linked specialists is performable by **anyone** (`linked.length===0 || linked.includes(id)`, schedule.ts:289). 
    - Ours: requires an explicit `user_shop_service` row for **every** service id — no "unrestricted" shortcut (BSS.php:250–258).
    **partial** — semantics differ for services with no specialist linkage.

15. **Placement is validated on create AND on move/reassign**, not enumerated.
    Demo store calls `checkPlacement` in create + drag + group flows (store.ts:1337,1388,1424,1574,1624).
    Ours calls it in `freeSlots` per-candidate (BSS.php:363) and is exposed for reschedule/reassign
    endpoints (`rescheduleUrl`/`reassignUrl`, BSS.php:631–632). **done** (different invocation pattern; see rule 19).

## Calendar composition

16. **Day columns = active specialists working that day + any specialist with a booking that day (muted).**
    Demo `calendarColumns` schedule.ts:269 · ours BSS.php:384 (scoped to `USER_TYPE_AGENT`+shop, working-first sort). **done.**

17. **Muted columns are not drop targets** and their bookings are not draggable.
    Demo `muted` flag schedule.ts:261 · ours `draggable = isDraggable(status) && !muted` BSS.php:552. **done.**

18. **Only SCHEDULED / ACCEPTED bookings are draggable.**
    Ours `isDraggable` BSS.php:42. Demo gates drag on status in components (cancelled excluded from the
    grid entirely). **done** (ours makes the rule explicit).

19. **Lane packing:** concurrent bookings of one specialist render side-by-side; clusters separated by a
    gap reset to full width. Demo `packLanes` schedule.ts:374 · ours BSS.php:438. **done.**

20. **Day window widens to cover any overflowing booking/shift/time-off** so nothing is clipped.
    Demo `dayBounds` schedule.ts:416 · ours folds into `dayLayout` (BSS.php:558–593) and additionally
    **snaps to whole hours** (ours-only presentation rule). **done.**

## Ours-only rules (NEW vs demo)

21. **Free-slot enumeration:** open start-times are generated at `slot_time_step` granularity,
    must fit the duration inside a free block, and (for *today*) must be ≥ the current minute.
    Ours `freeSlots` BSS.php:348–376. **No demo equivalent** (demo uses drag-to-place). Additive.

22. **Zoom clamp** `0.6–2.4` and px-per-minute scaling for the day grid. Ours `dayLayout` BSS.php:509. Additive (presentation).
