feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed

- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
This commit is contained in:
AgendaPro Dev
2026-07-27 10:11:16 -06:00
parent 4227db0c8b
commit d796538fd9
57 changed files with 2477 additions and 322 deletions
Binary file not shown.
+45
View File
@@ -0,0 +1,45 @@
# SDD Progress Ledger — Auto-assign specialist + booking redesign
- **Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md`
- **Spec:** `docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md`
- **Branch:** `feat/auto-assign-specialist`
- **Base commit:** `e8d2435`
- **Commit policy:** NO commits during execution (repo policy: only on explicit request). Verify per task via typecheck + tests. Reviewer reads changed files + plan task.
- **Pre-existing WIP (do NOT clobber):** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, FullCalendar section of `src/index.css` (~lines 136-160). Use targeted `edit` only.
## Tasks
- Task 1: complete — db.ts (migrateV3ToV4 + runMigrations call), shared/types.ts (Business/Employee/WorkingHoursMap). Review: APPROVED, no findings. typecheck 0 errors, schema_version=4, backfill verified.
- Task 2: complete — server/lib/scheduling.ts (pure helpers, no imports/DB), server/lib/scheduling.test.ts (15 tests), package.json test:unit. Review: APPROVED. test:unit 15/15. Minor (non-blocking): add direct test for hasConflict positive case, clamp01, parseWorkingHours malformed-shape, specialtyMatch substring branch — defer to final review.
- Task 3: complete — appended DB fns to scheduling.ts (getCandidates, getExistingBusy, isAvailable, rankOne, pickBestSlotEmployee, autoAssign, runInTransaction). Plan typo `c.info.id``c.id` fixed & verified. Review: APPROVED. typecheck 0, test:unit 15/15, tx smoke ok. Low-note: getExistingBusy scopes by start_at within day (cross-midnight appts not caught — neutralized by working-hours guard; revisit if 24h scheduling added).
- Task 4: complete — booking.ts publicBusiness (+auto_assign_specialist,working_hours) + slots endpoint uses real working_hours + pickBestSlotEmployee. POST /book untouched. Review: APPROVED. typecheck 0, slots smoke ok. Info-notes: local overlapsRange dup of overlaps (plan-permitted); per-slot SELECT name could be hoisted (pre-existing).
- Task 5: complete — booking.ts POST /book rewritten: runInTransaction + autoAssign (when emp omitted or auto_assign_specialist=1) + isAvailable guard → 409 on conflict; 201 includes employee_id+reasons. Review: APPROVED. typecheck 0, test:unit 15/15, integration a)201 b)409 c)201-survivor d)409-autoAssign-null all pass. Sound tx guard.
- Task 6: complete — appointments.ts POST / wrapped in runInTransaction, autoAssign fallback (replaced LIMIT 1), isAvailable guard → 409. Two plan-text deviations (move emp resolution inside tx; Number(business_id)) verified correct. Review: APPROVED. typecheck 0, test:unit 15/15, conflict smoke 409/201 pass. FOLLOW-UP: e2e-test.mjs fixtures (2026-09-15T11:00Z=05:00 local, 2026-09-20 Sun) now fail under working-hours enforcement — fixture-side, will fix in Task 7.
- Task 7: complete — settings.ts (+auto_assign_specialist,working_hours, JSON/0-1 coercion), employees.ts (+specialties/working_hours/efficiency_score), booking-e2e.mjs (new), e2e-test.mjs slot-aware fixture fix (no guards weakened). Review: APPROVED. typecheck 0, test:unit 15/15, test:e2e 25/25, test:booking 9/9.
== BACKEND COMPLETE (Tasks 1-7). Algorithm + guards + APIs all verified green. ==
- Task 8: complete — index.css wrapped .input/.select/.textarea in @layer components; BookingPage DetailsForm 3× pl-9→pl-10. Verified directly by controller (build green, @layer confirmed at index.css:272, WIP untouched). Icon/placeholder overlap fixed app-wide.
- Task 9: complete — publicApi.ts types (PublicBusiness.auto_assign_specialist; BookPayload.employee_id optional; BookResponse +employee_id/reasons). Verified directly (typecheck 0, no consumer breakage).
- Task 10: complete — src/components/MonthCalendar.tsx (42-cell month grid, nav bounded by min/max, brand selected, accent avail dot, brand today dot). Verified directly (typecheck 0, build green). Will be integration-tested in Task 11.
- Task 11: complete — BookingPage DateTimePicker rewritten: md+ 2-col (MonthCalendar left + grouped slots right), SlotGroup Mañana/Tarde, mobile stacked, slot button styles unchanged, max optional. Review: APPROVED. typecheck 0, build ok. AM/PM noon boundary verified.
- Task 12: complete — BookingPage wizard rewired to dynamic 3/4 steps via stepsFor(autoAssign)+currentOriginal; main max-w-5xl only on Horario; handleBook sends employee_id undefined when autoAssign; Confirmation shows reasons badges. Review: APPROVED. typecheck 0, build ok, HTTP smoke both flows + 409. NOTE: `npm run lint` can't run repo-wide (pre-existing ESLint9 flat-config gap, no eslint.config.js) — pre-existing, will report at Task 15.
- Task 13: complete — src/lib/workingHours.ts (DEFAULT_WH/DAYS/parseWh) + SettingsPage auto-assign checkbox + 7-day working-hours editor. Review: APPROVED. typecheck 0, build ok, runtime e2e (PATCH→GET persist, Sat 10-14 slots appear, toggle 3/4 steps).
- Task 14: complete — EmployeesPage EmployeeModal +specialties chips +efficiency slider +working-hours editor (inherit toggle). Payload specialties[]/efficiency Number/working_hours null|map. Review: APPROVED. typecheck 0, build ok. Low-notes: all-closed employee flips back to inherit (benign); case-sensitive dedup (acceptable). Runtime persistence smoke deferred to Task 15.
== ALL 14 IMPLEMENTATION TASKS COMPLETE + REVIEWED. ==
== TASK 15: FINAL VERIFICATION ==
- typecheck: 0 errors
- test:unit: 15/15
- build: OK (2462 modules)
- test:e2e: 25/25
- test:booking: 9/9 (auto-assign 201+reasons, double-book 409, auto_assign_specialist exposed)
- employee persistence runtime check (Task 14 deferred): specialties/efficiency=87/working_hours custom→null(inherit) all persist ✓
- lint: PRE-EXISTING repo-wide failure (no eslint.config.js, ESLint 9.39 requires flat config) — NOT introduced by this feature.
- FINAL WHOLE-BRANCH REVIEW: READY TO MERGE. Core guarantee (no double-booking from public flow) verified end-to-end (sync handler + BEGIN IMMEDIATE + hasConflict + same-tx INSERT). No cross-task integration issues.
== POST-MERGE FOLLOW-UPS (non-blocking, deferred) ==
1. getExistingBusy: switch WHERE to overlap-based (end_at > dayStart AND start_at < dayEnd) — closes cross-midnight gap if 24h scheduling added.
2. Add 4 unit-test cases (hasConflict positive, clamp01, parseWh malformed, specialtyMatch substring).
3. Replace local overlapsRange with imported overlaps.
4. Hoist per-slot employee-name SELECT out of slots loop.
5. MonthCalendar nav buttons → h-11 w-11 (44px touch target).
+50
View File
@@ -0,0 +1,50 @@
# Task 1 Report — Migración V3→V4 + tipos compartidos
## Files changed
- `H:\MegaSync\Proyectos\AgendaPro\server\db.ts`
- Added `migrateV3ToV4()` immediately before `export function runMigrations()`.
- Registered `migrateV3ToV4();` as the last call inside `runMigrations()` (after `migrateV2ToV3();`).
- `H:\MegaSync\Proyectos\AgendaPro\shared\types.ts`
- Replaced `Business` interface to add booking fields (`booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`) and v4 fields (`auto_assign_specialist`, `working_hours`).
- Added `WorkingDay` and `WorkingHoursMap` types after the `Business` interface.
- Added `specialties`, `working_hours`, `efficiency_score` to the `Employee` interface (after `service_ids?`).
## Verification
### `npm run typecheck` output (last line)
```
> [email protected] typecheck
> tsc -b --noEmit
```
Exit code 0 — **0 errors**. (Output was just the script banner + the tsc command echo; tsc prints nothing on success.)
### tsx column check output (verbatim)
```
businesses has auto_assign_specialist: true
businesses has working_hours: true
employees has specialties: true
employees has working_hours: true
employees has efficiency_score: true
schema_version: [Object: null prototype] { value: '4' }
business working_hours backfilled: true
```
The check was run twice (second run for idempotency): identical output both times, no errors, schema_version stable at `4`.
## Deviations from the plan
- None in the actual code edits. The `migrateV3ToV4` function, the `runMigrations` body, and all type additions match the plan verbatim.
- Verification detail: the plan's one-liner `npx tsx -e "..."` command failed under Windows PowerShell 5.1 because PowerShell parsed the embedded double-quoted SQL string (`"SELECT value FROM meta WHERE key='schema_version'"`) as terminating the outer `-e` argument, producing an "Unterminated string literal" esbuild error. To get a clean result without altering any project code, the check was executed via a throwaway `.mjs` file placed in the pre-approved temp dir (`C:\Users\URIELJ~1\AppData\Local\Temp\opencode`) that imports `server/db.ts` via an absolute `file:///H:/MegaSync/Proyectos/AgendaPro/server/db.ts` URL. The file was deleted after running. No project files were created or modified beyond `server/db.ts` and `shared/types.ts`.
## Self-review
- **All `Business` fields from the plan added?** Yes — interface body matches the plan exactly, including `booking_enabled`, `cancel_window_hours`, `cancel_penalty_pct`, `require_deposit`, `deposit_pct`, `timezone`, `auto_assign_specialist`, `working_hours`. The DB already has the v3 booking columns from `migrateV2ToV3`, so the type is now honest vs. the DB.
- **Does `migrateV3ToV4` guard with `schema_version >= "4"`?** Yes — first line of the function is `if (getMeta("schema_version") >= "4") return;`, mirroring the v2/v3 pattern. String comparison is safe here because the values are single-digit ASCII numerics ("3" < "4").
- **Is the backfill idempotent?** Yes, in two layers:
1. The schema-version guard short-circuits the entire function on subsequent runs.
2. Even if that guard were bypassed, the backfill UPDATE is gated by `WHERE working_hours IS NULL`, so businesses whose `working_hours` was already filled (e.g. by the user via PATCH `/settings`) are never overwritten. Confirmed by the second run producing identical output.
- **Column-add idempotency:** Each ALTER is guarded by `columnExists(...)` — re-running against a DB that already has the column is a no-op.
- **Backfill content matches the plan's default** (`1..5 = 09:00-20:00`, `6,7 = null`), which replicates the previously hard-coded hours used by `booking.ts` pre-redesign.
- **Pre-existing WIP untouched:** `src/components/AppointmentModal.tsx`, `src/pages/CalendarPage.tsx`, and the FullCalendar block of `src/index.css` were not modified. Only `server/db.ts` and `shared/types.ts` were edited.
- **No commit was made** (per repo policy).
## Concerns
None. Typecheck is clean and the migration ran end-to-end against the real `data/agendapro.db`, producing exactly the expected column set and backfilled data.
+54
View File
@@ -0,0 +1,54 @@
# Task 10 Report — Componente `MonthCalendar`
## Status
✅ DONE
## Files changed
- **Created:** `src/components/MonthCalendar.tsx` (verbatim from plan, Task 10 Step 1).
No other files modified.
## Verification
| Check | Command | Result |
|---|---|---|
| Typecheck | `npm run typecheck` | ✅ 0 errors |
| Build | `npm run build` | ✅ Success (`vite v5.4.21`, `built in 5.40s`) |
## Self-review
**Props signature:** `MonthCalendar({ value, min, max, availableDates, onSelect })` — matches spec exactly.
- `value: string` (ISO YYYY-MM-DD)
- `min?: string`, `max?: string` (ISO bounds, optional)
- `availableDates?: Set<string>` (dates with open slots)
- `onSelect: (iso: string) => void`
**6-row / 42-cell grid:** ✅ Yes. `cells` is computed via `useMemo` with a fixed `for (let i = 0; i < 42; i++)` loop, starting from the Monday on/before the 1st of the cursor month (`lead = (first.getDay() + 6) % 7`). Always renders 42 buttons in a `grid-cols-7` → 6 rows × 7 cols.
**Past days disabled relative to `min`:** ✅ Yes. `selectable(d)` compares `atMidnight(d)` against `minDate` (also normalized to midnight) — strictly less → `false` → button `disabled`. Out-of-month past cells inherit the disabled state too.
**Selecting a day calls `onSelect` with ISO YYYY-MM-DD:** ✅ Yes. `onClick={() => onSelect(dIso)}` where `dIso = toIso(d)` returns `${yyyy}-${mm}-${dd}` zero-padded.
**Accent dot shows for `availableDates`:** ✅ Yes. When `availableDates?.has(dIso)` and the day isn't selected, an absolutely-positioned `bg-accent-500` 1.5×1.5 dot is rendered at `bottom-1`.
**Today dot (`brand-400`) under control:** ✅ Bonus check — when `dIso === todayIso` and not selected, a `bg-brand-400` dot is rendered at `top-1` (distinct position from the accent dot to avoid overlap).
**Selected-day highlight (`brand-500`):** ✅ Yes. When `dIso === value`, classes include `bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30`.
**Navigation bounded by min/max:** ✅ Yes.
- `canPrev = !minDate || <last day of previous month> >= firstOfMonth` — disables when the entire previous month is before `min`.
- `canNext = !maxDate || <last day of next+1 month> <= <last day of max month>` — disables when the entire next month is beyond `max`.
- The disabled attribute and `disabled:opacity-40` style visually communicate the bound.
**Weekend tint:** ✅ Yes. Sáb/Dom (`getDay() === 0 || 6`) in-month, non-selected, selectable cells get `text-slate-500` (vs `text-slate-700` for weekdays).
**Touch targets ≥44px:** ✅ Plausible. Each cell uses `aspect-square` inside a 7-col grid with `gap-1`; on the intended two-column desktop layout (parent column ~`minmax(0,1fr)` of `max-w-5xl`) cells exceed 44px. On narrow mobile (full width) they're well above 44px. The nav buttons are `h-9 w-9` (36px) — slightly under 44px, but they're secondary controls; main interaction is the date grid. Flagging as a minor concern.
**Client-only / SSR-safe:** ✅ Yes. Uses `new Date()` at render and inside `useState`/`useMemo` initializers — fine for a Vite SPA (no SSR).
**Named export:** ✅ Yes. `export function MonthCalendar(...)` — a named export (the task brief says "default-style named export", which is what we have; consumer in Task 11 imports it as `import { MonthCalendar } from "../../components/MonthCalendar";`).
## Concerns
1. **Nav buttons 36px (`h-9 w-9`):** slightly below the 44px touch-target guideline. Acceptable since they're secondary controls and the spec/plan code is verbatim; calling out for awareness only. No change made (would deviate from plan).
2. **`canNext` bound check is conservative:** it compares full-month ranges, so when `max` falls mid-month the button stays enabled for that partial month (correct — user can still navigate to it) and only disables once the *entire* next month is past `max`. Behavior matches the plan's intent.
3. **No keyboard arrow navigation (Home/End/PgUp/PgDn/Arrows):** out of scope for the plan; the buttons are real `<button>`s so Tab+Enter works, and disabled days are correctly removed from the tab order via the `disabled` attribute.
+40
View File
@@ -0,0 +1,40 @@
# Task 11 — `DateTimePicker` 2-col + month calendar + Mañana/Tarde grouping
## File changed
- `src/pages/public/BookingPage.tsx`
- Added `import { MonthCalendar } from "../../components/MonthCalendar";` (line 28).
- Rewrote `DateTimePicker` (now lines 556639): 2-column grid on `md+`, vertical stack below `md`, new optional `max?: string` prop, slots grouped via new `SlotGroup` subcomponent.
- Added new `SlotGroup` helper (lines 641681).
## Verification results
### `npm run typecheck`
```
> tsc -b --noEmit
```
**0 errors.** The existing caller of `DateTimePicker` (in `BookingPage`) does not pass `max` — confirms `max` is optional and not breaking the caller.
### `npm run build`
```
✓ 2461 modules transformed.
✓ built in 4.98s
```
**Succeeds.** Production bundle emitted.
### Visual smoke
Not executed interactively (subagent has no browser). Validated indirectly via successful `vite build` (2461 modules transformed, including the rewritten `BookingPage` and the imported `MonthCalendar`). Task 12 is responsible for wiring `max`/`employeeId` end-to-end and for any final interactive smoke; the unit of work in this task compiles and type-checks cleanly against the rest of the project.
## Self-review
| Check | Result |
|---|---|
| 2-col layout only on `md+` | ✅ Outer wrapper uses `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8`. Below `md` no grid is applied. |
| Mobile stacked vertically | ✅ Right column wrapper has `mt-4 md:mt-0` so on mobile the calendar card is followed by the slots card with vertical spacing; the outer div is a plain block (no `flex`/`grid`) below `md`. |
| Slot button markup unchanged | ✅ Identical classes (`grid grid-cols-3 gap-2 sm:grid-cols-4`; button `rounded-xl border px-2 py-2.5 text-sm font-bold transition-all`; active `border-brand-500 bg-brand-500 text-white shadow-soft`; inactive `border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50`), same `key={`${slot.iso}-${slot.employee_id}`}`, same `onClick={() => onPick(slot)}`. Only the grouping wrapper (`SlotGroup`) is new. |
| Morning/afternoon split correct, incl. 12 PM boundary | ✅ Algorithm: parse `(\d{1,2}):(\d{2})``h`; `pm = /PM/i`; `hour24 = pm && h!==12 ? h+12 : !pm && h===12 ? 0 : h`; bucket by `hour24 < 12`. Verified cases: `12:00 AM` → h=12, !pm, h===12 → 0 → **Mañana**. `11:30 AM` → 11 → **Mañana**. `12:00 PM` → h=12, pm, h===12 (no +12) → 12 → **Tarde**. `12:30 PM` → 12 → **Tarde**. `1:00 PM` → 13 → **Tarde**. `11:59 PM` → 23 → **Tarde**. Boundary at noon is correct (12:00 PM is the first Tarde slot). |
| `max` is optional | ✅ Declared `max?: string`. Typecheck with caller that omits `max` passes. |
| No commit performed | ✅ No `git commit` run. |
| Scope respected (only `BookingPage.tsx`, only `DateTimePicker` + import + `SlotGroup`) | ✅ No other functions touched (`TopBar`, `ActionBar`, `Confirmation`, `EmployeeList`, `ServiceGrid`, `DetailsForm` unchanged; Task 8's `pl-10` untouched). |
## Concerns
- None. Implementation matches the plan verbatim. Interactive browser smoke deferred to the orchestrator / Task 12 (which will also pass `max` and `employeeId` to the picker and exercise the full wizard).
+63
View File
@@ -0,0 +1,63 @@
# Task 12 — Wizard 3/4 pasos + Confirmation con reasons + anclar main ancho
## File changed
- `src/pages/public/BookingPage.tsx` (only file touched)
## Steps applied (19)
1. Replaced static `STEPS` with `FULL_STEPS` const + `stepsFor(autoAssign)` helper that drops the "Especialista" step when auto-assign is on (returns `{n, label, original}` items).
2. Added `maxBookableDate()` helper next to `todayStr` (today + 3 months, ISO `YYYY-MM-DD`).
3. In `BookingPage`, computed `autoAssign`, `steps` (useMemo on autoAssign), `stepCount`, and `currentOriginal` (find by `n === step`, read `.original`) **before** `slotsQuery` so the closure can see it.
4. `slotsQuery.enabled` now uses `currentOriginal === 3` (was `step >= 3`).
5. `goNext` / `goBack` / `pickSlot` use `stepCount` (no hard-coded `4`).
6. `canContinue`, `primaryLabel`, `onPrimary` switch on `currentOriginal`. `handleBook` sends `employee_id: autoAssign ? undefined : selectedSlot.employee_id`.
7. Rewrote the main `return (...)` to render sections by `currentOriginal`; `main` widens to `max-w-5xl` only when `currentOriginal === 3`, otherwise `max-w-2xl`; `DateTimePicker` gets `max={maxBookableDate()}`; the Especialista row in Datos is hidden when `autoAssign`.
8. Updated `TopBar` + `ActionBar` signatures to accept `stepCount` + `steps`; both widened from `max-w-3xl`/`max-w-2xl``max-w-5xl`. TopBar now reads "Paso X de {stepCount}" and iterates the dynamic `steps` array (including the mobile `X / {stepCount}` chip).
9. `Confirmation` renders `appointment.reasons` as `brand` badges (with Sparkles icon) guarded by `reasons && reasons.length > 0`, placed under the Especialista row.
Also updated the two secondary `TopBar` call sites (disabled-booking screen and `Confirmation`) to pass `stepCount={4} steps={[]}``step={0}` hides the indicator block, so an empty array is safe there.
## Verification
### typecheck
```
> tsc -b --noEmit
(no output — 0 errors)
```
First pass surfaced one error: `ActionBar`'s `stepCount` param was unused (TS6133). Fixed by surfacing it in the empty-state label: `Paso {step} de {stepCount} · Continúa para reservar`.
### build
```
> tsc -b && vite build
✓ 2461 modules transformed.
✓ built in 5.16s
```
### lint
```
> eslint . --ext ts,tsx
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
```
Pre-existing env issue (ESLint 9 with no flat config in the repo). Not introduced by this task; no `react-hooks/exhaustive-deps` warnings would be reachable until the config is repaired. The `useMemo(() => stepsFor(autoAssign), [autoAssign])` dep array is correct.
### Runtime smoke (server already running on :5173)
- Slug: `lumiere-estetica-spa`. Default `auto_assign_specialist=0`.
- **OFF flow**: GET `/api/public/<slug>``auto_assign_specialist=0`. Slots for service 6 tomorrow returned 10 slots. POST `/book` without `employee_id` → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`, `client_created=true`. (Server still auto-ranked because the client didn't pick one — that's the unchanged 4-step path.)
- **ON flow**: PATCH `/api/settings {auto_assign_specialist:1}`. GET public → `auto_assign_specialist=1` (correctly exposed). POST `/book` without `employee_id` at a free slot → 201, `emp_id=2 Mateo Herrera`, `reasons=["Disponible"]`.
- Double-booking guard: re-POSTing the first booked slot → 409 `Esa hora acaba de ocuparse, elige otra.`
- Restored `auto_assign_specialist=0` afterwards.
Visual confirmation of step count / layout width was done by code inspection (no headless browser available in this subagent): `stepsFor(false)` yields 4 items; `stepsFor(true)` yields 3 items (Especialista filtered). The TopBar "Paso X de {stepCount}" and the mobile "X / {stepCount}" both read from the dynamic count, so 4 vs 3 follows automatically. `main`'s width is selected by `currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"`, which is true only on the Horario section regardless of the 3- or 4-step mapping.
## Deviations
- `ActionBar`'s previously-unused `stepCount` prop (the spec required ActionBar to accept it, but the plan's literal snippet didn't read it) is now surfaced in the empty-state copy. This both satisfies TS6133 and improves UX consistency.
- No commit performed (per task constraints).
## Self-review
- **OFF = 4 steps?** Yes — `stepsFor(false)` maps all 4 `FULL_STEPS` to `{1:Servicio, 2:Especialista, 3:Horario, 4:Datos}` with `original` matching `n`. `currentOriginal` drives section rendering, so the 4-step Servicio→Especialista→Horario→Datos flow is preserved exactly. The "Cualquiera" path still hits the server ranking (server-side, unchanged here).
- **ON = 3 steps?** Yes — `stepsFor(true)` filters out Especialista, renumbering to `{1:Servicio (orig 1), 2:Horario (orig 3), 3:Datos (orig 4)}`. The `currentOriginal === 2` branch (Especialista) never matches because no step maps to original 2.
- **currentOriginal before slotsQuery?** Yes — declared at line 83, slotsQuery at line 90 references it in `enabled` at line 93.
- **handleBook sends undefined employee_id when autoAssign?** Yes — line 198 `employee_id: autoAssign ? undefined : selectedSlot.employee_id`. `BookPayload.employee_id` is optional, so this typechecks.
- **Confirmation shows reasons badges?** Yes — guarded by `appointment.reasons && appointment.reasons.length > 0`, renders `<span>` chips with Sparkles icon under the Especialista row.
- **main widened only on Horario?** Yes — `cn("…", currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl")`. Horario is always original 3 in both flows.
- **Disabled-booking screen, loading/error states, reset() preserved?** Yes — only the TopBar call in the disabled screen was updated to pass the new required props; the rest of that branch is untouched. Loading/error branches and `reset()` are unchanged.
- **No git commit.** Confirmed — only file edits + npm runs.
+41
View File
@@ -0,0 +1,41 @@
# Task 13 Report — SettingsPage (auto-assign + working hours editor)
## Files changed
1. **CREATED** `src/lib/workingHours.ts` — exports `DEFAULT_WH`, `DAYS`, `parseWh`. Pure data/helpers, no React/UI.
2. **MODIFIED** `src/pages/SettingsPage.tsx`:
- Added `Wand2`, `Clock` to the lucide-react imports.
- Imported `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`.
- Added local `wh` state (`useState<Record<number, {start,end}|null>>(DEFAULT_WH)`).
- Replaced the `useEffect` so it now also calls `setWh(parseWh(data.settings.working_hours))`.
- Extended the PATCH `api.settings.update` payload with `auto_assign_specialist` (0/1) and `working_hours: wh` (object).
- Added, inside the "Reservas online" card (after the URL/slug block): a "Asignar especialista automáticamente" checkbox (Wand2 icon) and a "Horario del negocio" block rendering `<WorkingHoursEditor value={wh} onChange={setWh} />`.
- Added a local `WorkingHoursEditor` subcomponent (per-day on/off + start/end `<input type="time">`, "Cerrado" label when off).
## Typecheck / build
- `npm run typecheck` → 0 errors.
- `npm run build` → succeeded (vite build OK, all chunks emitted).
## Runtime smoke
- **Not executed in this subagent session** (subagent has no browser; dev server would need to be started manually by the orchestrator). The plan's Task 13 Step 4 lists this as the runtime check:
- Boot `npm run dev`, login owner → `/settings`.
- Toggle the auto-assign checkbox; edit working hours (e.g. Saturday on 10:0014:00); Save; reload.
- Expect: GET `/settings` reflects `auto_assign_specialist=1` and `working_hours='{"1":{"start":"09:00","end":"20:00"},...,6:{...},7:null}'` (or whatever was entered).
- Expect on `/b/<slug>`: wizard collapses to 3 steps (Servicio/Horario/Datos) when toggle is on, and the slot list is constrained to the configured working hours (e.g. Saturday shows only 10:0014:00 slots).
- **Backend prerequisites (must already be in place for persistence to work):** Task 7 must have run — `FIELDS` whitelist in `server/routes/settings.ts` includes `auto_assign_specialist` and `working_hours`, both SELECTs return them, and the PATCH handler serializes the object → JSON string and coerces the boolean to 0/1. If Task 7 has NOT been applied yet, the PATCH will silently drop both fields (whitelist) and persistence will not occur.
## Deviations
- **Improvement applied (per task spec):** `DEFAULT_WH`/`DAYS`/`parseWh` live in `src/lib/workingHours.ts` from the start, imported by SettingsPage — instead of being defined inline here and refactored out in Task 14. Task 14 (EmployeeModal) will import the same helpers.
- No other deviations from plan Task 13. `WorkingHoursEditor` stays a local component inside SettingsPage (not exported from `workingHours.ts`).
## Self-review
- **Does the PATCH send `working_hours` as an object?** Yes — `working_hours: wh` where `wh` is a `Record<number, {start,end}|null>`. Backend (Task 7 Step 1) is responsible for `JSON.stringify`-ing it before SQL. Since `api.settings.update` is typed `any`, TS does not complain.
- **Does `parseWh` handle the JSON-string returned by GET?** Yes — when `v` is a string it `JSON.parse`s it (catching syntax errors → null), and when null/non-object it falls back to `{...DEFAULT_WH}`. It also tolerates partial maps (missing day keys → `null`).
- **Does the editor toggle per day?** Yes — `toggle(n)` flips `value[n]` between `null` and `{start:"09:00", end:"18:00"}`; when off, the day row shows "Cerrado".
- **First-load safety:** `wh` initialises from `DEFAULT_WH`, so even before the GET resolves the editor renders sane values (LunVie 0920, Sáb/Dom closed) rather than crashing on `undefined`.
- **Scope discipline:** only `src/lib/workingHours.ts` (new) and `src/pages/SettingsPage.tsx` were touched. `EmployeesPage.tsx`, `BookingPage.tsx`, CSS, and any backend files were left alone.
- **No commit performed** (per CRITICAL constraint #1).
## Concerns / handoff notes for orchestrator
1. **Backend coupling:** this task is only the UI half. Persistence depends on Task 7 having extended `FIELDS` + SELECTs + the serialization block in `server/routes/settings.ts`. Verify Task 7 landed before doing the runtime smoke.
2. **`auto_assign_specialist` initial value from GET:** backend returns it as 0/1 (INTEGER). The checkbox does `!!f.auto_assign_specialist` which coerces correctly. PATCH sends `f.auto_assign_specialist ? 1 : 0`, also correct.
3. **Visual check:** the new "auto-assign" checkbox + working-hours block sit inside the existing `space-y-4 p-5 pt-0` container of the "Reservas online" card, so vertical rhythm is preserved without further CSS work.
+36
View File
@@ -0,0 +1,36 @@
# Task 14 — `EmployeeModal`: especialidades, horario, eficiencia
## File changed
- `src/pages/EmployeesPage.tsx` — extended `EmployeeModal` with three new controls (specialties chips, efficiency range, working hours editor with inherit toggle); added import of `DEFAULT_WH`, `DAYS`, `parseWh` from `../lib/workingHours`; extended `useEffect` sync and mutation payload.
No other files touched. `src/lib/workingHours.ts` was already created in Task 13 and is only imported.
## Typecheck / build
- `npm run typecheck`**0 errors**.
- `npm run build`**succeeds** (2462 modules transformed, 5.11s).
- `npm run lint` → fails with a pre-existing repo-level config error (`ESLint couldn't find an eslint.config.(js|mjs|cjs)` — ESLint 9 migration not done). Not caused by this task; same result on `main`.
## Runtime smoke
Not executed manually (no browser session in this subagent run). Static verification instead:
- Backend wiring confirmed in `server/routes/employees.ts`:
- POST accepts `specialties`, `working_hours`, `efficiency_score` (line 76).
- PATCH handles `working_hours === null` → stored as `null` (line 119) — exactly the "inherit from business" semantics the UI sends.
- `efficiency_score` coerced via `Number(...)` server-side; `specialties` JSON-stringified if array.
- GET `/employees` selects these columns (Task 7), so reopening the modal will prefill via the `useEffect` sync branch.
- The `Employee` type in `shared/types.ts` declares all three fields optional (lines 5355), so the prefill reads (`employee.specialties`, `employee.efficiency_score`, `employee.working_hours`) typecheck cleanly.
Recommended manual follow-up (per task spec): boot `npm run dev`, owner → `/employees`, edit an employee — add "Corte" + "Coloración", set efficiency 85, uncheck inherit and set custom hours, save, reopen to confirm persistence; toggle inherit back on, save, confirm `working_hours` column is `null` in DB.
## Deviations
- Plan Step 1 said to *create* `src/lib/workingHours.ts` and refactor SettingsPage. Per the task brief, the file already exists (Task 13) and SettingsPage already imports from it — so I only added the import to `EmployeesPage.tsx`. No other files modified.
- Lint not run green (pre-existing repo config issue, not introduced here).
## Self-review
- **Payload shape:**
- `specialties` sent as a `string[]` (the state is `string[]`, no manual serialization). ✓
- `efficiency_score` sent as `number` (`efficiency` is `number`, initialized via `Number(e.target.value)`). ✓
- `working_hours` sent as `null` when `inheritHours === true`, otherwise the live `wh` map. ✓ Matches the backend's `working_hours === null ? null : …` branch.
- **Edit-mode prefill:** `useEffect[open, employee]` reads `employee.specialties` (with `Array.isArray` guard), `employee.efficiency_score` (with `typeof === "number"` guard, default 50), and `parseWh(employee.working_hours)` plus a `hasOwn` check so a stored `null`/empty map flips `inheritHours` back to `true` and resets `wh` to `DEFAULT_WH`. ✓
- **Duplicate prevention in chips input:** `setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])])``Set` dedupes by value; case-sensitive (so "Corte" ≠ "corte"), which matches how the backend stores them. ✓
- **Reset on close/new:** the `else` branch of the `useEffect` clears `specialties`, `specialtyInput`, `efficiency`, `inheritHours`, and `wh` — no stale state leaks between create/edit sessions. ✓
- **Type safety on `wh[n]`:** narrowed with `!(wh[n])` / `wh[n]!.start` and an explicit type cast on the spread (`as { start: string; end: string }`) to avoid `null`-widening complaints — typecheck confirms.
+61
View File
@@ -0,0 +1,61 @@
# Task 2 Report — scheduling.ts pure functions + unit tests
## Status
✅ COMPLETE — all acceptance criteria met.
## Files changed
- **Created** `server/lib/scheduling.ts` — 15 pure exports (types + functions), zero imports. ~95 lines.
- **Created** `server/lib/scheduling.test.ts` — 15 `test()` blocks using `node:test` + `node:assert/strict`.
- **Modified** `package.json` — added `"test:unit": "node --import tsx --test server/lib/scheduling.test.ts"` to `scripts`.
## Verification
### `npm run typecheck` → 0 errors
```
> [email protected] typecheck
> tsc -b --noEmit
(clean — no diagnostics)
```
`allowImportingTsExtensions: true` is set in `tsconfig.json`, so `import { ... } from "./scheduling.ts"` resolves correctly under tsc.
### `npm run test:unit` → all PASS
```
✔ parseWorkingHours: json válido → mapa 1..7
✔ parseWorkingHours: null/invalid → null
✔ isoDayOfWeek: lunes=1, domingo=7
✔ getWorkingHoursForDate: empleado tiene prioridad sobre negocio
✔ getWorkingHoursForDate: día cerrado → null
✔ normalizeText/tokens: quita acentos y lowercase
✔ specialtyMatch: coincidencia exacta de etiqueta → 1
✔ specialtyMatch: etiqueta con acento/case → 1
✔ specialtyMatch: sin etiquetas → 0.5
✔ specialtyMatch: etiqueta no relacionada → 0.5
✔ overlaps: bordes inclusivos de no-traslape
✔ hasConflict: lista vacía → false
✔ scoreCandidate: pesos 50/30/20
✔ scoreCandidate: mayor efficiency → mayor score
✔ scoreCandidate: menor load (más disponible) → mayor score
tests 15
pass 15
fail 0
duration_ms 201.8453
```
**Summary line:** `# tests 15 | # pass 15 | # fail 0 | duration 201.8 ms`
## Deviations from plan
- **None.** The plan said "≈14 tests" but the spec actually contains 15 `test()` blocks — all 15 are present and pass. Code is verbatim from the plan (only the pure functions; no DB imports added — those belong to Task 3).
## Self-review
- **All 15 tests present?** ✅ Counted 15 `test(` calls in the file; runner reports `tests 15 / pass 15`.
- **`scoreCandidate` verifies 50/30/20 weights?** ✅ The "pesos 50/30/20" test asserts three exact points on the response surface:
- `(1, 1, 0)` → 100 (50·1 + 30·1 + 20·1)
- `(0, 0, 1)` → 0 (all terms zero)
- `(0.5, 0, 1)` → 25 (only specialty term contributes: 50·0.5 = 25)
These three points uniquely pin the 50/30/20 coefficients — any other weights would fail at least one assertion. The two follow-up tests ("mayor efficiency", "menor load") additionally confirm the monotonic direction of each knob.
- **`overlaps` boundary case verified?** ✅ The "bordes inclusivos de no-traslape" test explicitly asserts `overlaps(100, 200, 200, 300) === false` — i.e. one window ending exactly when another starts is **not** an overlap. Two true-overlap cases (forward and reverse order) are also covered.
- **No DB leakage?** ✅ `scheduling.ts` has zero `import` statements and does not reference `DatabaseSync`, `db`, or any module. Task 3 can append the DB section cleanly.
## Concerns
None. Ready for Task 3.
+41
View File
@@ -0,0 +1,41 @@
# Task 3 Report — scheduling.ts DB functions
## Files changed
- `server/lib/scheduling.ts` — appended DB-backed functions from Task 3 plan (`CandidateInfo`, `safeArr`, `getCandidates`, `getExistingBusy`, `isAvailable`, `rankOne`, `pickBestSlotEmployee`, `AutoAssignResult`, `AutoAssignCtx`, `autoAssign`, `runInTransaction`) + `import type { DatabaseSync } from "node:sqlite"`.
## Typecheck
`npm run typecheck`**0 errors** (after the deviation below).
## test:unit
`npm run test:unit`**15/15 pass** (pure tests unaffected).
## tx smoke-test output
Temp file `_tx-smoke.mjs` (deleted after run). It imports `runInTransaction` and `db`, exercises happy path + throw path, and verifies a real `UPDATE` was rolled back.
```
PASS runInTransaction returns fn value (42)
PASS no active tx after commit
PASS runInTransaction rethrows on error
PASS no active tx after rollback
PASS rollback undid the UPDATE
5/5 passed
EXIT=0
```
`PRAGMA active_transaction` was used (newer node:sqlite exposes it; falls back to a no-row / 0 object) to confirm no dangling transaction after both commit and rollback. The real-state rollback check (UPDATE `_tx_probe SET v=999` inside a throwing fn) proves the ROLLBACK is functional, not just emitted.
## Deviations
1. **Plan typo fix in `autoAssign` (line 256).** The plan literally wrote:
```ts
if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) {
```
but inside `autoAssign` the loop variable `c` is a `CandidateInfo` (no `.info` field), so `c.info.id` is a type error (TS2339). Fixed to `c.id < best.info.id`. `best` is still a `RankedCandidate` so `best.info.id` is correct. The semantic intent (tie-break by lowest employee id) is preserved exactly. Note: the sibling function `pickBestSlotEmployee` uses `c.id` correctly in the plan — confirming the autoAssign line was a typo, not an intentional shape.
No other deviations. The `import type { DatabaseSync }` is placed mid-file (after `scoreCandidate`), which is valid because ES module `import` declarations are hoisted; `tsc -b --noEmit` and `tsx` both accept it.
## Self-review
- **Does `autoAssign` return `null` when no candidate is free?** YES. Two null paths:
1. `candidates.length === 0` → immediate `return null` (line 245).
2. After the loop, `if (!best) return null` (line 260) covers the case where every candidate was filtered out (closed that day, slot outside working window, or has a conflict).
- **Does it respect per-employee working hours?** YES. For each candidate it calls `getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs))` — employee override wins, business is the fallback, and a closed day returns `null` → candidate is `continue`d. Then the slot must satisfy `ctx.startMs >= wh.startMs && ctx.endMs <= wh.endMs`, otherwise the candidate is skipped.
- **Does `runInTransaction` roll back on throw?** YES. Verified by smoke test: a thrown error inside `fn` causes `db.exec("ROLLBACK")` to run, the error is re-thrown, and a real `UPDATE` made inside the throwing fn is undone (the probe row retained its pre-tx value).
+89
View File
@@ -0,0 +1,89 @@
# Task 4 Report — backend `booking.ts`: slots con working_hours + `pickBestSlotEmployee`
**Branch:** `feat/auto-assign-specialist`
**Date:** 2026-07-26
**Plan:** `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` (Task 4)
## Files changed
- `server/routes/booking.ts` — only file modified by this task. Diff: **+56 / 31** lines (`git diff --stat`).
No other files touched. `POST /:slug/book` left exactly as-is (Task 5 territory).
## What changed
1. **Imports (top of file).** Added scheduling imports from `../lib/scheduling.ts`:
`parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy, pickBestSlotEmployee, type WorkingHoursMap`.
2. **`publicBusiness(slug)`** now SELECTs two extra columns: `auto_assign_specialist, working_hours` (so the public business payload exposes them for the frontend).
3. **`GET /:slug/slots`** computation block fully rewritten:
- `bizWh = parseWorkingHours(biz.working_hours)` — real business working-hours map.
- When `employeeId` is **absent** (`useRanking = !employeeId`): pre-fetches ranked `CandidateInfo[]` via `getCandidates(...)` and the per-employee busy map via `getExistingBusy(...)`, then for every grid step calls `pickBestSlotEmployee(...)` to attach the best-ranked free specialist.
- When `employeeId` is **provided**: resolves the per-employee working-hours window (falling back to business hours) and uses the existing busy list with the local `overlapsRange` helper.
- Day window comes from `getWorkingHoursForDate(...)`**no more hardcoded `09:00`/`20:00`**.
- If the resolved window is `null` (business closed that day) → `res.json({ slots: [], service: {...} })`, no throw.
- 30-min grid, `t + dur*60000 <= dayEnd` end-clamp, `t < now+30min` skip, 40-slot cap, and the response shape `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` are all preserved.
4. Added a tiny module-local `overlapsRange(a,b,c,d)` helper (equivalent to `overlaps` in scheduling.ts; the plan suggested this to keep imports minimal).
## Verification
### `npm run typecheck`
```
> [email protected] typecheck
> tsc -b --noEmit
```
**0 errors.**
### Smoke test (live server on :3000, seeded business `lumiere-estetica-spa`)
Wrote a throwaway `.mjs` script (deleted after) that logs in as owner, reads `/api/settings` for the slug, then hits `/api/public/<slug>` and `/api/public/<slug>/slots`.
**Public business now exposes the new fields:**
```
[info] public.business.auto_assign_specialist = 0
[info] public.business.working_hours = string
[ok] publicBusiness() exposes auto_assign_specialist + working_hours
```
**Slots — auto-assign path (no `employee_id`, uses `pickBestSlotEmployee`):**
Service `id=6` "Corte + arreglo de barba" (Barbería, 45 min), date `2026-07-27` (Monday, dow=1).
```
[info] slots status=200 count=18
[info] service in resp = { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 }
[sample] first slot = { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
[sample] last slot = { time: '07:00 p.m.', iso: '2026-07-28T01:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
[ok] all 18 slots have employee_id+employee_name and iso within 09:00-20:00 (inWindow=true)
```
- 18 slots, each carries `employee_id` + `employee_name` (resolved by `pickBestSlotEmployee`).
- Window strictly within the seed's Lun-Vie 09:0020:00.
- Only Mateo Herrera offers service #6 in the seed, so the ranker correctly resolves every slot to him.
**Closed-day path (Saturday, default seed = closed):**
```
[info] Saturday 2026-08-01 slots count=0 (expected 0 if Sat closed)
[ok] closed day → { slots: [] } with service object (no throw)
```
The endpoint returns `{ slots: [], service: {...} }` — no exception, matching constraint #5.
**Specific-employee path (`employee_id=2`, the only employee offering svc #6):**
```
status: 200 count: 18
all match eid: true
first: { time: '09:00 a.m.', iso: '2026-07-27T15:00:00.000Z', employee_id: 2, employee_name: 'Mateo Herrera' }
service in resp: { id: 6, name: 'Corte + arreglo de barba', price: 280, duration_min: 45 }
```
Every slot's `employee_id === 2`; service object included.
## Deviations from the plan
1. **Dropped `isoDateStr` from the Task-4 import list.** The plan's Task-4 import block includes `isoDateStr`, but the Task-4 code never references it, and `tsconfig.json` has `"noUnusedLocals": true` — keeping it would fail the `npm run typecheck` verification step. Task 5 ("Ampliar imports de scheduling") explicitly adds more imports and can re-introduce `isoDateStr` at that point when it's actually used by the `POST /book` rewrite. Functionality identical.
2. **Renamed `const any = …` → `const anyEmp = …`** in the fallback query inside the candidates block. `any` is a reserved-ish TypeScript type name and using it as a value identifier is confusing and risky under strict mode. Purely cosmetic; behavior identical.
## Self-review
- **Does the day window come from `working_hours` (not the old hardcoded 09/20)?** Yes. The old `new Date(${date}T09:00:00)` / `T20:00:00` literals are gone. The window now comes from `getWorkingHoursForDate(singleEmpWh | null, bizWh, dateObj)`, which reads `parseWorkingHours(biz.working_hours)`. Confirmed empirically: a Saturday (closed in seed) returns 0 slots, while a Monday returns 18 slots within 09:0020:00.
- **When `employeeId` is null, does it use `pickBestSlotEmployee`?** Yes. `useRanking = !employeeId` gates the branch; when true, the per-slot resolver is `chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, {name, category}, slotStart, slotEnd)`. The previous "first free employee" inner loop is removed.
- **Response shape preserved?** Yes — `{ slots: [{time, iso, employee_id, employee_name}], service: {id, name, price, duration_min} }` verified in all three paths (ranking, specific-employee, closed-day).
- **30-min grid, 40-slot cap, skip-past/<30min-from-now?** All preserved (loop body unchanged in spirit; observed 18 slots, well under 40).
- **Closed day → `{slots: []}` + service object, no throw?** Confirmed.
- **POST /book untouched?** Yes — diff shows the entire POST handler is byte-identical to the pre-task version.
## Concerns
- The seed only has one employee (Mateo Herrera) offering service #6, so the ranker resolves every slot to the same person. To genuinely exercise the multi-candidate ranking in integration, Task 7's `booking-e2e.mjs` (or a manual second employee with overlapping service + different `efficiency_score` / `specialties`) would be needed. Out of scope for Task 4.
- `biz.working_hours` is returned to the frontend as a raw JSON **string** (not parsed). Frontend will need to `JSON.parse` it — that's expected per `shared/types.ts` (`working_hours?: WorkingHoursMap | string | null`) and consistent with how Settings already returns it. No action needed here.
- A leftover orphan `tsx watch` process from an initial failed `Start-Process` attempt was killed during cleanup; the user's main `npm run dev` (concurrently) was left untouched and is still serving on :3000.
+66
View File
@@ -0,0 +1,66 @@
# Task 5 Report — backend `booking.ts` `POST /book` con autoAssign + guard transaccional
## Status
**DONE** — all verification green; no commit (per repo policy + dispatch constraint).
## Files changed
- `server/routes/booking.ts` — only the `POST /:slug/book` handler rewritten + 3 symbols added to the existing scheduling import. **No other file touched by this task.**
## What changed
### Import (line 3-7)
Added `autoAssign, isAvailable, runInTransaction` to the existing scheduling import list (Task 4 had already imported `parseWorkingHours`, `getWorkingHoursForDate`, `getCandidates`, `getExistingBusy`, `pickBestSlotEmployee`, `WorkingHoursMap`).
### `POST /:slug/book` handler (lines 125-214)
Replaced the old handler (which did a naive `SELECT … LIMIT 1` to pick any employee and had no conflict guard) with the transactional, auto-assigning version per the plan verbatim:
1. **Resolve specialist inside `runInTransaction(db, …)`** (BEGIN IMMEDIATE … COMMIT/ROLLBACK):
- `autoAssignOn = !!biz.auto_assign_specialist`
- `clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null`
- If `!clientChose` → call `autoAssign(db, ctx)` with `bizWh = parseWorkingHours(biz.working_hours)`. If it returns null (no candidate free) → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`.
- If `clientChose` → validate the employee offers the service (`employee_services`), then `isAvailable(db, empId, startMs, endMs)` guard; on conflict → `throw { status: 409, error: "ESE_HORARIO_OCUPADO" }`. `reasons = ["Tu especialista elegido"]`.
2. **Client resolution preserved verbatim**: lookup by `phone` (priority) else `email`, else `INSERT … tags='Online' RETURNING id`.
3. **INSERT appointments** uses `new Date(endMs/startMs).toISOString().replace(/\.\d{3}Z$/, "Z")` for both ISOs (matches existing convention; confirmed in smoke response `2026-07-27T15:00:00Z`).
4. **201 response** now includes `appointment.employee_id` (number) and `appointment.reasons: string[]` alongside the previous fields.
5. **catch block**: if `e && typeof e === "object" && e.status``res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error })`; otherwise `throw e` (rethrow genuine errors → handled by Express error middleware).
## Verification
### 1. `npm run typecheck` → **0 errors** ✓
### 2. Integration smoke (temp `.mjs`, deleted after)
Booted a **fresh one-shot `tsx server/index.ts`** on `PORT=3007 HOST=127.0.0.1` (did NOT touch the user's existing dev server on port 3000, which is a non-watch `tsx server/index.ts` running stale code). Login `[email protected]`/`demo1234` → slug from `/api/settings` → first service from `/api/public/<slug>`.
- **(a) auto-assign (no `employee_id`):**
`POST /api/public/<slug>/book { service_id, start_at:<iso>, client:{name:"Auto Test", phone:"+525500009991"} }`
**201**, body:
```json
{"appointment":{"id":442,"start_at":"2026-07-27T15:00:00Z","end_at":"2026-07-27T15:45:00Z","price":280,"service_name":"Corte + arreglo de barba","employee_id":2,"employee_name":"Mateo Herrera","reasons":["Buena disponibilidad"]},"business":{"name":"Lumière Estética & Spa","currency_symbol":"$"},"client_created":true}
```
✓ `employee_id` is a real number (2). ✓ `reasons` is a non-empty array (["Buena disponibilidad"]).
- **(b) double-booking guard (same slot + same `employee_id=2`):**
`POST /api/public/<slug>/book { service_id, employee_id:2, start_at:<same iso>, client:{name:"Auto Test 2", phone:"+525500009992"} }`
→ **409**, body: `{"error":"Esa hora acaba de ocuparse, elige otra."}` ✓ friendly message present.
The slots pre-fetch used `GET /api/public/<slug>/slots?service_id=X&date=2026-07-27` → returned a ranked slot (iso `2026-07-27T15:00:00.000Z`, pre-assigned emp=2), confirming the slots endpoint from Task 4 still works.
### 3. `npm run test:unit` → **15/15 PASS** ✓ (no regression)
## Self-review
- **Is the whole booking + guard inside the transaction?** Yes. `runInTransaction(db, () => { … })` wraps: specialist resolution (autoAssign OR employee-services check OR isAvailable guard), client resolution, and the `INSERT INTO appointments`. Any thrown `{status,error}` triggers ROLLBACK before the catch translates it to HTTP.
- **Does autoAssign run when `employee_id` is omitted?** Yes. `clientChose` is null when `employee_id` is absent (or when `auto_assign_specialist` is on, regardless of `employee_id`), and the `if (!empId)` branch calls `autoAssign(db, ctx)`. Verified by smoke (a): no `employee_id` sent → got back `employee_id=2` picked by the algorithm.
- **Does the 409 path work?** Yes — verified two ways: (i) `autoAssign` returning null yields 409 (the "no specialist available" branch), and (ii) the `isAvailable` guard yields 409 on a concrete double-book (smoke b returned exactly 409 with the friendly message, and the ROLLBACK prevented the second INSERT).
- **Does a client-chosen employee still get isAvailable-guarded?** Yes. When `auto_assign_specialist` is off and `employee_id` is provided, the `else` branch runs: employee_services validation, then `if (!isAvailable(db, empId, startMs, endMs)) throw {status:409,…}`. Smoke (b) exercised exactly this path (client chose emp=2, slot was already taken by smoke a → 409).
- **Constraint: `auto_assign_specialist=1` overrides client choice.** Yes — `clientChose = !autoAssignOn && employee_id ? … : null` is null when `autoAssignOn`, so even an explicit `employee_id` is ignored and autoAssign runs. (Not exercised live in the smoke because the seed has `auto_assign_specialist=0`; the logic is straight from the plan.)
- **Preserved client-resolution logic?** Yes — identical phone-then-email lookup with `tags='Online'` on create.
- **ISO format?** Yes — `.replace(/\.\d{3}Z$/, "Z")` applied to both `startIso` and `endIso`; confirmed in the 201 response (`...T15:00:00Z`, no millis).
- **Scope discipline?** Yes — only `POST /:slug/book` and the import list touched. Slots endpoint, `publicBusiness`, `publicBusiness`-exposing `GET /:slug`, and `overlapsRange` helper are untouched.
- **No commit made?** Confirmed — `git log` shows latest commit is still `e8d2435` (v1.0 base); all work is in the working tree.
## Deviations
None. The handler is transcribed verbatim from the plan (lines 776-866 of the plan). The import addition merges into the existing multi-line import rather than adding a second `import { autoAssign, isAvailable, runInTransaction } …` line (the plan's Step 1 phrasing is "Añadir a la lista de imports de Task 4" — i.e. extend the existing list — which is what I did; a duplicate import statement would have been a TypeScript error).
## Concerns
- **Dev-server caveat (informational, not a defect):** the user's currently running dev server on port 3000 (PID 15992) is `tsx server/index.ts` (NOT `tsx watch`), so it has NOT picked up my booking.ts change. It will need a restart to serve the new code. I ran my smoke against a separate one-shot instance on port 3007 (now killed) to verify without disturbing the user's process. All my temp processes and log files have been cleaned up; port 3000 still belongs to the user's original process.
- **DB side-effect:** the smoke test inserted 2 appointments (ids ~442) and 2 clients into the dev SQLite DB. This is expected per the plan's verification steps (the plan's own e2e script and seed note acknowledge this). A `RESET_DB=1 npm run seed` will reset if a clean DB is desired.
+59
View File
@@ -0,0 +1,59 @@
# Task 6 — backend `appointments.ts` conflict guard + autoAssign fallback
## Files changed
- `server/routes/appointments.ts`
- Added import: `import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";`
- Rewrote the `POST /` handler tail (from the old `// Resolve employee` comment through the handler's closing `})`) so that:
- `start`/`end`/`startMs`/`endMs`/`finalStatus`/`price` are computed first (outside the tx).
- The employee resolution + guard + INSERT + completed→ticket + `joinAppointment` all run inside `runInTransaction(db, …)`.
- Employee resolution keeps the same priority chain (`employee_id` from body → `employee.role` self-assign → `autoAssign` fallback). The old `LIMIT 1` lookup over `employee_services` is removed; when no employee is resolved, `autoAssign(db, {…})` picks the best available specialist.
- Before the INSERT, `isAvailable(db, empId, startMs, endMs)` is checked; on conflict it throws `{status:409, error:"Ese horario ya está ocupado para el especialista"}`.
- The catch translates any `{status,error}` throw into `err(res, status, error)` and rethrows everything else.
- `GET /`, `GET /:id`, `PATCH /:id`, `POST /:id/complete`, `DELETE /:id`, `GET /:id/cancellation-policy` are **untouched**. `joinAppointment` and `computeCommission` are untouched.
## Deviations from the plan's literal text
1. **Replaced a wider window than "lines 129-167".** The plan said to replace from `const start = new Date(start_at);` (line 129) through the handler end, but the verbatim block it provided *re-resolves* `empId` inside the transaction. If I had kept the original outer `empId` resolution (lines 116-127, including the `LIMIT 1` lookup and the early `return err(res,400,…)`), the outer code would (a) shadow the inner `empId`, (b) leave the new `autoAssign` path as dead code, and (c) directly contradict the task summary ("when still no employee, call `autoAssign(...)` instead of the old `LIMIT 1` lookup"). So I replaced the outer block too. This matches the verbatim block's own comment ("Resolver empleado (igual que antes, pero usando autoAssign si ninguno)") and the task summary.
2. **`businessId: Number(req.user!.business_id)` instead of `businessId: req.user!.business_id`.** `AuthedRequest.user.business_id` is typed `number | null` (from `shared/types.ts`), but `AutoAssignCtx.businessId` requires `number`. `Number(...)` is safe because `authRequired` guarantees an authenticated user with a business. This is a literal-text bug in the plan; without the coercion `tsc` errors with TS2322.
## Verification
### `npm run typecheck`
**PASS — 0 errors.** (After the `Number(...)` coercion; raw plan text produced `TS2322: Type 'number | null' is not assignable to type 'number'`.)
### `npm run test:unit`
**PASS — 15/15.** `scheduling.test.ts` unchanged and green.
### `npm run test:e2e`
**FAIL — but NOT a code bug; it is the seed/test-data concern the task anticipated.**
- The failure is at `e2e-test.mjs:46` ("create appointment"): `POST /api/appointments { service_id:1, client_id:1, start_at:"2026-09-15T11:00:00Z" }` returns **400** `{"error":"No hay empleado asignado a este servicio"}` (i.e. `autoAssign` returned `null`), which makes line 47 throw a `TypeError` reading `.id` off `undefined`.
- The other 16 checks up to that point all PASS (login, /me, business, employees+stats, services, clients+stats, appointments joined, dashboard ×9, tickets).
**Root cause (verified):** timezone mismatch between the e2e fixture and the new working-hours enforcement.
- Server TZ: `America/Mexico_City` (UTC6).
- Fixture slot `2026-09-15T11:00:00Z` = **05:00 local** — before the business's 09:00 opening (MonFri 09:0020:00).
- `autoAssign``getWorkingHoursForDate` builds the day window in local time (09:0020:00 local) and filters every candidate whose slot doesn't fit; at 05:00 local nobody qualifies → `autoAssign` returns `null` → handler throws `{status:400, "No hay empleado asignado a este servicio"}`.
- The legacy `LIMIT 1` lookup never checked working hours, so this exact fixture used to pass; the new code enforces the plan's hard constraint.
This is the "seed/test-data concern, not a code defect" case the task description calls out (it anticipated a 409, but the same principle applies to the 400 from the stricter autoAssign). The fix is data-side — either run the server in UTC, or update `e2e-test.mjs` to use in-hours ISO slots (the plan's own Task 7 `booking-e2e.mjs` already does this by fetching slots from `/slots`). **The guard was not weakened.**
### Conflict smoke (temp `.mjs`, created/verified/deleted)
Using an in-hours slot `2027-02-01T17:00:00Z` (= 11:00 local):
- Create #1 (`service_id:1`, owner, no `employee_id`): **201**, `autoAssign` placed it on `employee_id:1`.
- Create #2 (same `employee_id:1` + same slot): **409** `{"error":"Ese horario ya está ocupado para el especialista"}`.
- Create #3 (same `employee_id:1` + 2 h later, non-overlapping): **201**.
All three behave exactly as required.
## Self-review
- **Guard inside tx?** Yes — `isAvailable(db, empId, startMs, endMs)` runs inside `runInTransaction(db, …)`, before the INSERT. Throwing inside rolls back via the helper's ROLLBACK path.
- **autoAssign fallback when no employee?** Yes — after the body's `employee_id` and the employee-role self-assign, if `!empId`, the handler loads `businesses.working_hours`, parses it, and calls `autoAssign(db, {businessId, serviceId, serviceName, serviceCategory, startMs, endMs, bizWh})`. `empId = aa?.employeeId ?? null`; if still null, throws `{status:400,…}`.
- **Employee-role self-assign preserved?** Yes — `if (!empId && req.user!.role === "employee" && req.user!.employee_id) empId = req.user!.employee_id;` is intact.
- **Owner with no employee_id → autoAssign (not LIMIT 1)?** Yes — the old `SELECT … FROM employee_services … LIMIT 1` block is removed.
- **completed→ticket still works?** Yes — the `if (finalStatus === "completed") { INSERT INTO tickets … }` block is preserved verbatim inside the tx, keyed on the freshly `RETURNING *`-ed `inserted.id`.
- **Price override preserved?** Yes — `price = price_override !== undefined ? Number(price_override) : service.price` computed before the tx and passed into the INSERT.
- **Client resolution preserved?** Yes — untouched above the replaced block (existing `client_id` or `new_client` create).
- **`joinAppointment` on result?** Yes — called on `inserted` inside the tx, then returned; `res.status(201).json({ appointment: r })` outside.
- **Other endpoints untouched?** Yes — GET/PATCH/DELETE/complete/cancellation-policy are byte-identical to before.
- **Response on success?** Yes — `res.status(201).json({ appointment: r })` unchanged.
- **No `git commit` run?** Correct — no commit, no stage.
+38
View File
@@ -0,0 +1,38 @@
# Task 7 — backend settings + employees (campos nuevos) + script de integración
## Status
**COMPLETE** — all verification green, no commits made.
## Files changed
- `server/routes/settings.ts` — extended `FIELDS` with `auto_assign_specialist`, `working_hours`; added both columns to GET and final-PATCH SELECTs; serialized `working_hours` to JSON when received as object; coerced `auto_assign_specialist` to `0`/`1`.
- `server/routes/employees.ts``POST /` and `PATCH /:id` now accept and persist `specialties`, `working_hours`, `efficiency_score` (JSON-serialized arrays/maps, `Number(...)` coercion for efficiency).
- `server/scripts/booking-e2e.mjs` — NEW. Slot-aware integration test covering auto-assign, double-booking 409, and `auto_assign_specialist` exposure on the public business endpoint.
- `package.json` — added `"test:booking": "node server/scripts/booking-e2e.mjs"`.
- `e2e-test.mjs` — fixture adaptation only: added `findSlot(slug, serviceId, maxDays=30)` helper that queries `/public/<slug>/slots` for the next 30 days and returns the first available slot iso; replaced the two hard-coded `start_at` ISO strings (`2026-09-15T11:00:00Z`, `2026-09-20T10:00:00Z`) with slot-aware values. Added a `GET /settings` call after login to obtain the business `slug`. Employee-service fallback: tries service_id 5 first, falls back to 6 (both offered only by Mateo Herrera, so the self-assign assertion still holds).
## Verification results
| Check | Result |
|---|---|
| `npm run typecheck` | **0 errors** |
| `npm run test:unit` | **15/15 passed** |
| `npm run test:e2e` | **25/25 passed** (was failing before the fixture fix) |
| `npm run test:booking` | **9/9 passed** |
## Manual API check (temp .mjs, run + deleted)
- `PATCH /settings { auto_assign_specialist: 1, working_hours: {1:{start:"09:00",end:"13:00"}, ...} }` → PATCH response reflected `auto_assign_specialist = 1` and `working_hours` as a JSON string. `GET /settings` returned the same. `working_hours` parsed back to `{1:{start:"09:00",end:"13:00"}, 3:null, ...}`.
- `PATCH /employees/:id { specialties: ["Cabello","Coloración"], efficiency_score: 87, working_hours: {...} }` → reflected `specialties = ["Cabello","Coloración"]`, `efficiency_score = 87` with `typeof === "number"`, and `working_hours` JSON. `GET /employees/:id` confirmed persistence. Restored afterwards.
## Self-review
- **settings serializes `working_hours`?** YES — `merged.working_hours = JSON.stringify(merged.working_hours)` when not already a string; stored as TEXT, returned verbatim by both SELECTs.
- **settings coerces `auto_assign_specialist` to 0/1?** YES — `merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0`.
- **employees coerces `efficiency_score` to `Number`?** YES — both POST (`typeof === "number" ? efficiency_score : 50`) and PATCH (`Number(efficiency_score)` with fallback to `existing.efficiency_score ?? 50`). Confirmed via manual check: returned value has `typeof === "number"`.
- **employees handles `specialties`/`working_hours` JSON?** YES — POST stringifies the array/object; PATCH merges with existing and accepts string, object/array, or `null` (for `working_hours`).
- **booking-e2e covers auto-assign + 409 + `auto_assign_specialist` exposure?** YES — step 4 books without `employee_id` and asserts the response contains `employee_id` + non-empty `reasons` (auto-assign); step 5 re-books the same slot/employee and asserts `409`; step 6 toggles `auto_assign_specialist` to 1 via PATCH and asserts the public `/public/<slug>` response exposes `business.auto_assign_specialist === 1`.
- **e2e-test now passes without weakening guards?** YES — no guards were changed (only `settings.ts`, `employees.ts`, `e2e-test.mjs`, new `booking-e2e.mjs`, and `package.json` were touched; `booking.ts`, `appointments.ts`, `scheduling.ts`, `db.ts`, `types.ts` untouched). The fix is purely test-fixture: instead of hard-coding out-of-working-hours ISO strings (`11:00Z` ≈ 05:00 local, and `2026-09-20` is Sunday — both correctly rejected by the new working-hours enforcement), the test now fetches a real available slot from the same public endpoint that respects the guards. All 25 original assertions preserved; 4 new helper assertions added (slot lookups + slug).
## Deviations
- **e2e-test.mjs employee-service fallback (svc 5 → svc 6).** The plan does not mandate this; the additional sub-task says "If service_id 5 has no slots, pick another service the employee offers". Implemented as: try service 5 across 30 days; if null, try service 6. In practice service 5 always yields slots in the seed data, so the fallback never fires — but it makes the test robust to seed variation. The self-assign assertion still holds because both services 5 and 6 are offered exclusively by Mateo Herrera.
- **`findSlot` searches 30 days** (vs. a single weekday in the suggested approach) — gives the fixture more headroom to dodge dense seed bookings without weakening any guard.
## Concerns
None.
+126
View File
@@ -0,0 +1,126 @@
# Task 8 — Fix de iconos solapados (`@layer components` + `pl-10`)
## Files changed
1. `src/index.css` — wrapped the `.input, .select, .textarea { ... }` block AND its `:focus` rule in `@layer components { ... }` (now lines 272292).
2. `src/pages/public/BookingPage.tsx` — in `DetailsForm`, changed the three `className="input pl-9"``className="input pl-10"` (Nombre, Teléfono, Correo; lines 664, 678, 692).
## Verification results
| Check | Result |
|---|---|
| `npm run typecheck` | ✅ 0 errors (`tsc -b --noEmit` exited clean) |
| `npm run build` | ✅ Succeeded in 5.06s — `dist/assets/index-*.css` emitted (36.19 kB). Confirms `@layer components` is valid CSS and Tailwind processes it. |
| Grep: `padding: 0.55rem 0.75rem` outside `@layer components` | ✅ Only occurrence is on **line 280**, which is INSIDE the `@layer components { ... }` block (lines 272292). No bare occurrence remains. |
| Grep: BookingPage `input pl-` | ✅ All three now read `input pl-10` (lines 664, 678, 692). No `pl-9` remains. |
## Untouched (pre-existing WIP, not modified by this task)
### FullCalendar section in `src/index.css` (lines 131163) — unchanged
Shown verbatim from the file after my edit:
```css
.fc-event {
border: none !important;
border-radius: 0.45rem !important;
padding: 2px 5px !important;
font-size: 0.74rem !important;
font-weight: 600 !important;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s;
overflow: hidden;
}
.fc-event:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px -4px rgba(0, 0, 0, 0.25);
}
.fc-daygrid-event {
margin-top: 2px;
}
.fc .fc-timegrid-slot {
height: 2.4rem;
}
.fc-timegrid-event {
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.fc-timegrid-event-harness {
margin-right: 2px !important;
}
/* When slotEventOverlap is false, FullCalendar puts a small gap between
side-by-side events; tighten it a touch so each event gets more readable
width without bleeding into its neighbour. */
.fc .fc-timegrid-col-events {
column-gap: 2px;
}
```
> Note: `git diff src/index.css` does show these FullCalendar lines as additions vs. HEAD, but they were **already present in the working tree before Task 8 began** (pre-existing WIP on the branch). My `edit` call only targeted the `.input/.select/.textarea` block — its `oldString`/`newString` did not include any FullCalendar text — so I did not modify this section.
### `src/components/AppointmentModal.tsx` and `src/pages/CalendarPage.tsx`
Listed as modified in `git status` (pre-existing WIP from earlier tasks on this branch). I did not open, read, or edit either file during Task 8 — my only tool calls against the repo were the two `edit` operations on `src/index.css` and `src/pages/public/BookingPage.tsx`, plus read-only verification commands.
## Diff of MY two files (only the parts I changed)
```diff
--- a/src/index.css
+++ b/src/index.css
@@ -258,24 +269,26 @@ body {
background: #fecaca;
}
-.input,
-.select,
-.textarea {
- width: 100%;
- ...
- outline: none;
-}
-.input:focus,
-.select:focus,
-.textarea:focus {
- border-color: #3b66ff;
- box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
+@layer components {
+ .input,
+ .select,
+ .textarea {
+ width: 100%;
+ ...
+ outline: none;
+ }
+ .input:focus,
+ .select:focus,
+ .textarea:focus {
+ border-color: #3b66ff;
+ box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
+ }
}
--- a/src/pages/public/BookingPage.tsx
+++ b/src/pages/public/BookingPage.tsx
@@ -661,7 +661,7 @@
- className="input pl-9"
+ className="input pl-10"
@@ -675,7 +675,7 @@
- className="input pl-9"
+ className="input pl-10"
@@ -689,7 +689,7 @@
- className="input pl-9"
+ className="input pl-10"
```
## Self-review
- **Does `@layer components` now wrap the input rule?**
YES. `src/index.css` lines 272292 open with `@layer components {` and close with `}`; the `.input/.select/.textarea` block and the `:focus` rule are both inside it, indented one level.
- **Will `pl-9`/`pl-10` utilities now override the component `padding` shorthand?**
YES. Tailwind emits its utilities through `@tailwind utilities;` (line 3 of `index.css`), which places them in the `utilities` CSS cascade layer. Per the CSS cascade-layer ordering rule, declarations in a layer declared **later** beat declarations in an **earlier** layer at equal specificity, regardless of source order. The layer order established by the three `@tailwind` directives is `base → components → utilities`, so the `utilities` layer (containing `.pl-10 { padding-left: 2.5rem }`) wins over the `components` layer (containing `.input { padding: 0.55rem 0.75rem }`). Before this change, `.input` was declared outside any layer, which made it an *unlayered* style — and unlayered styles beat layered styles in the cascade, so the utility was losing. Wrapping in `@layer components` fixes this app-wide for every `.input`/`.select`/`.textarea` that uses `pl-*`/`pr-*` (LoginPage, BookingPage, etc.).
- **Why `pl-10` (2.5rem = 40px) instead of `pl-9` (2.25rem = 36px)?**
Icons sit at `left-3` (12px) and are `h-4 w-4` (16px), so they occupy roughly 1228px of horizontal space. `pl-9` left only an 8px gap between icon edge and text start, which clipped ascenders/descenders and made placeholders appear to overlap the icon. `pl-10` gives a comfortable 12px gap (text starts at 40px, icon ends ~28px). Combined with the layer fix above, the utility now actually applies.
## Concerns
None. No commit performed (per instructions). Build and typecheck both green. Pre-existing WIP files left exactly as found.
```
```
+64
View File
@@ -0,0 +1,64 @@
# Task 9 Report — `publicApi.ts` tipos actualizados
## Status
**DONE** — typecheck passes with 0 errors.
## File changed
- `src/lib/publicApi.ts` (only this file; no consumers touched)
## Typecheck result
```
$ npm run typecheck
> [email protected] typecheck
> tsc -b --noEmit
```
**0 errors.** The changes are additive/loosening, so no consumer breakage:
- `PublicBusiness.auto_assign_specialist` (added required field) is safe — the only consumer reading `PublicBusiness` is `BookingPage.tsx`, which does not yet destructure this field (it'll be added in Task 12). Adding a required field to an interface does not error on existing readers; it only errors on object *literals* that lack it, and there are no literals of type `PublicBusiness` in the codebase (they come from `request<PublicBusinessResponse>`).
- `BookPayload.employee_id` made optional → strictly loosening; existing callers passing `employee_id` still typecheck.
- `BookResponse.appointment` widened with `employee_id`/`reasons` → existing readers of `employee_name` still work; new readers will be added in Task 12.
No consumer errors observed.
## Interface diffs (verbatim from plan)
### 1. `PublicBusiness` — added `auto_assign_specialist`
```diff
require_deposit: boolean;
deposit_pct: number;
+ auto_assign_specialist: number | boolean;
}
```
### 2. `BookPayload` — `employee_id` now optional
```diff
export interface BookPayload {
service_id: number;
- employee_id: number;
+ employee_id?: number;
start_at: string;
client: BookClient;
}
```
### 3. `BookResponse.appointment` — added `employee_id` + `reasons`
```diff
export interface BookResponse {
appointment: {
id: number;
start_at: string;
price: number;
service_name: string;
+ employee_id: number;
employee_name: string;
+ reasons: string[];
};
business: { name: string; currency_symbol: string };
client_created: boolean;
}
```
## Consumer errors
None. (Plan's Step 2 note had anticipated possible errors in `BookingPage.tsx`, but none materialized — the optional `employee_id` and the widened `BookResponse` are backward-compatible with the current consumer code, which will be properly updated in Tasks 1112.)
## Concerns
None. Ready for Tasks 1012 to consume these new types.