feat: public landing page with magic login, brand palette and demo build flag

Adds a public funnel landing at `/` and moves the login to `/login`, rebuilt
around the panel's own colors instead of an invented palette.

Landing (`src/components/landing/`, one section per file, no props):
- Seven funnel sections composed by `LandingPage`. The hero's eight swatches are
  literally `PIE_COLORS` from `DashboardPage`, the same hex values the seed hands
  to avatars and services, so "your colors become your numbers" is literal.
- `BrandMark` becomes the single source for the logo, replicating
  `public/favicon.svg`. Blue is the action surface, orange only ever marks.
- `NotebookVisual` is the one deliberate exception to the palette: it is what the
  product replaces.
- Copy drops all system vocabulary; motion comes from `src/lib/motion.ts` with a
  single easing, and reduced-motion resolves `initial` to the final state so a
  never-firing `whileInView` cannot leave a section invisible forever.

Routing and bundle:
- `homePathFor` is the single definition of each role's destination.
- Landing, login, dashboard and calendar load lazily. Eager, the login dragged
  framer-motion (~40 KB gz) into every panel load and the landing downloaded
  recharts + FullCalendar (~187 KB gz) without charting anything. `clsx` is
  pinned to the `react` chunk because Rollup otherwise assigns it to `charts`,
  making the entry import 111 KB gz for a 200-byte utility.

Responsiveness (iPhone/iPad), verified with `npm run audit:responsive`:
- No touch form field below 16px, `dvh` height utilities, safe-area insets, and
  40px touch targets keyed off `pointer: coarse` rather than `sm:`.
- New `.ld-gutter`: `.safe-x` lives outside `@layer` and beats Tailwind's `px-*`,
  so it left the login's side padding at 0 on anything but an iPhone in
  landscape. No overflow check could see it — there was no overflow, just zero
  margin. The audit now guards it with a `gutter` check.
- `shell-height` no longer fires on pages that legitimately scroll; the static
  `raw-viewport-unit` scan covers those instead.

Production build:
- The Dockerfile now sets `VITE_DEMO_UI=1` as a build arg. `DEMO` is a build-time
  constant, so without it Vite eliminated the magic login and the "Ver como…"
  switcher: the deployed landing promised "no registration" and led to an empty
  form. Verified by building both ways and diffing the bundle.
- Service worker cache bumped to v2 so the orphaned pre-landing chunks get purged
  from returning visitors' caches.

Verified: typecheck clean; unit 39/39, e2e 33/33, admin 17/17, booking 12/12,
landing 13/13 (WebKit), PWA passed against the real production build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-07-28 14:25:58 -06:00
co-authored by Claude Opus 5
parent 0b466f33f3
commit 4c19244df9
53 changed files with 7310 additions and 525 deletions
+78
View File
@@ -43,3 +43,81 @@
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).
== DEMO EMAIL DOMAIN TASK ==
- Task 1: complete — source constants, README credentials, active fixtures, and executable plan login example updated. Commits 6355d18..50dea33; review clean.
- Task 2: complete — local data/agendapro.db migrated 2 users; second update changed 0; appointments 161 and clients 15 preserved; review clean.
- Task 3: complete with limitation — typecheck/build/unit/admin passed; auth paths passed; e2e/booking slot assertions remain blocked by pre-existing null working-hours data, not the email change; review approved.
- Task 4: complete — committed source/docs through 3e056a0, pushed to GitHub and Gitea, Coolify deployment wbnbq9b0kneba67sznu7p1qf finished, runtime container healthy.
- Task 5: complete — production health/page passed, new admin/owner logins returned 200, old-domain logins returned 401; active DB was fresh and seeded with new emails, so targeted SQL was a no-op.
- Final review: complete — final-fix review approved; regex acceptance fix review approved.
== AGENDAMAX PWA INSTALLABILITY TASK ==
- Task 1: complete — manifest, iOS metadata, reproducible Playwright-generated 192/512 PNG icons, and package scripts. Commits 9d2ab39..de947f9; review clean.
- Task 2: complete — versioned shell/static service worker and production-only registration. Commits de947f9..65233f2; review clean.
- Task 3: complete — install prompt hook/component, iOS Safari guide, safe-area styling, and Login/AppShell/AdminShell integration; review fixes added prompt guard, appinstalled precedence, close-button label, and compact mobile layout. Commits 65233f2..9ded129; review clean.
- Task 4: complete — production-server PWA endpoint/browser tests, online/offline API bypass checks, three-shell coverage, and install documentation; review clean. Commits 9ded129..43f5d13.
- Final fix wave: complete — credential-aware cache policy, AgendaMax-scoped cleanup, session dismissal persistence, configurable PWA test credentials, dialog semantics, and dismissal regression coverage. Commit 0b466f3; final whole-feature review approved.
== LANDING PÚBLICA + ACCESO DE UN CLIC (2026-07-28) ==
Spec: docs/superpowers/specs/2026-07-28-landing-funnel-bi-design.md
Plan: docs/superpowers/plans/2026-07-28-landing-funnel-bi.md
Ruteo: `/` sirve la landing (pública, React.lazy); el login pasa a `/login`. `homePathFor(user)`
centraliza el destino por rol. `/b/:slug` intacto, fuera del AuthProvider.
Dos iteraciones de diseño a pedido del usuario:
1. Primera versión oscura/sobria (criterio Apple). Entregada y verificada.
2. Rediseño a claro, vívido y animado, con copy humano para dueñas de salón y público de 50+
(guía de dolores: Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md, Q15/Q72/Q87:
hablar de lo que gana el cliente, no de lo que hace el sistema). Se eliminó todo vocabulario de
sistema del copy visible: ni «business intelligence», ni «datos», ni «métricas», ni «panel».
3. Paleta rebasada sobre los colores REALES del panel a pedido del usuario: las 8 muestras del
abanico del hero son PIE_COLORS de DashboardPage.tsx; la marca sale de public/favicon.svg vía
src/components/BrandMark.tsx (única fuente); CTA = brand-500→brand-700 como .btn-primary.
Defectos encontrados y corregidos durante la verificación (no estaban en el plan):
- El chunk de entrada arrastraba framer-motion (39 KB gz) a TODA carga del panel, porque LoginPage
era eager. Se hizo lazy junto con la landing.
- La landing descargaba recharts (111 KB gz) y FullCalendar (76 KB gz) sin usarlos, porque
DashboardPage y CalendarPage eran eager. Ambas a lazy, con Suspense en el <Outlet> de AppShell.
- `clsx` lo comparten lib/format.ts y recharts; Rollup lo asignaba al chunk `charts`, así que el
entry importaba 111 KB para una utilidad de 200 bytes. Fijado al chunk `react`.
- LOGIN CON EL TEXTO PEGADO AL BORDE en móvil: `.safe-x` está fuera de @layer y gana a `px-5`,
dejando el padding en 0 con inset 0 (todo lo que no sea iPhone landscape). Nueva clase `.ld-gutter`.
Ningún check existente lo detectaba (no hay desborde: hay cero margen) → añadido check `gutter`.
- `shell-height` del audit daba falso positivo en toda página que scrollea: comparaba #root contra
el viewport vía el fallback a body.firstElementChild. Acotado a [data-app-shell]; a cambio se
añadió el check estático `raw-viewport-unit` sobre las fuentes públicas (verificado inyectando
`min-h-screen` en ProofSection: dispara).
- Rótulo «Como una de tus muchachas» en /login con Diego Castillo en la lista debajo. Reescrito sin
asumir género («Como alguien de tu equipo»); igual en ObjectionsSection.
- Anclas bajo el nav fijo (scroll-mt-24); degradado del cierre invisible tras `-z-10`; etiquetas del
abanico ilegibles (eliminadas); avisos flotantes solapando el abanico (reubicados a las 3 zonas
libres) y detalle truncado (acortado).
Audits ajustados por el cambio de ruteo (`/``/login`): visual-audit.mjs, responsive-audit.mjs y
los SIETE `goto(baseUrl)` de pwa-e2e.mjs que esperan InstallAppPrompt. La landing se añadió a las
listas de páginas de los dos audits.
VERIFICACIÓN FINAL (todo en verde):
- typecheck: 0 errores
- test:landing (NUEVO, WebKit): 13/13 — ruteo, acceso de un clic, sin desborde a 390px y
prefers-reduced-motion sin contenido invisible en las 7 secciones
- audit:responsive: 0 hallazgos (9 dispositivos × 10 páginas)
- audit:visual: 62 pantallas, 0 fallos, 0 desborde, 0 errores de consola
- test:pwa sobre el build: passed
- test:unit 39/39 · test:e2e 33/33 · test:admin 17/17 · test:booking 12/12
- build: el chunk de entrada NO importa charts, framer ni calendar (verificado en dist/)
- WebKit a 390/440/744/820/1024/1440 en `/` y `/login`: sin desborde, sin campos <16px, sin
objetivos <40px, sin errores
Sin commits (política del repo). `npm run lint` sigue roto por falta de eslint.config.js (preexistente).
FOLLOW-UPS DIFERIDOS:
1. `/login` en desktop deja un vacío bajo la tarjeta del formulario (columnas de altura muy distinta).
No es un defecto medible; revisar si molesta en uso real.
2. La landing no tiene contenido de precios ni captura de leads: en fase demo la conversión es entrar
a la demo. Habrá que decidirlo antes de un lanzamiento real.
3. Seguridad sin cambios: `/login` expone contraseñas de cuentas existentes bajo la bandera DEMO.
Autorizado solo para esta fase; es lo único que separa esto de una fuga si se publica.
+26 -63
View File
@@ -1,74 +1,37 @@
# Task 1 Report - AgendaMax demo access references
# Task 1 Implementation Report
## Status
## Scope
Complete for the source and documentation layer. Local and production SQLite data were not touched.
Implemented only the static AgendaMax PWA contract from Task 1. Existing worktree changes and graphify outputs were not modified.
## Changed files
## Changed Files
Implementation commit `6355d18a07b5bef6fd3515b80a0f768b0ae1050b` (`docs: standardize AgendaMax demo access`) changed:
- `scripts/generate-pwa-icons.mjs`: reproducible Playwright generator using the existing `public/favicon.svg` as a base64 data URL.
- `public/icon-192.png`: generated 192x192 PNG install icon.
- `public/icon-512.png`: generated 512x512 PNG install icon.
- `public/manifest.webmanifest`: exact AgendaMax manifest metadata and icon declarations.
- `index.html`: manifest link, Apple install metadata, and `viewport-fit=cover`; existing favicon, theme, and description preserved.
- `package.json`: added `generate:pwa-icons` and `test:pwa` scripts.
- `README.md` - added an explicit `Contraseña` column with `demo1234` for all eight demo accounts; changed only Admin and Dueño to `@agendamax.demo`.
- `server/index.ts` - changed the fresh default owner email.
- `server/scripts/seed.ts` - changed fresh admin and owner emails; retained `demo1234`.
- `src/pages/LoginPage.tsx` - changed only the development default owner email.
- `admin-test.mjs`, `e2e-test.mjs`, `e2e-full.mjs`, `server/scripts/booking-e2e.mjs`, `visual-audit.mjs` - aligned active platform login fixtures and assertions.
- `docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md` - aligned its copy-pasteable booking login example.
## Verification
The current untracked Task 1 brief, design, and migration plan were not modified or staged. The completed Task 5 report was classified as historical and was not modified. No employee email domain or password was changed.
- `node scripts/generate-pwa-icons.mjs`: exit 0, no output.
- PNG signature/dimensions Node check: passed; `public/icon-192.png` is PNG 192x192 and `public/icon-512.png` is PNG 512x512.
- Reproducibility check: passed. SHA256 values remained stable across regeneration:
- 192 icon: `A5073D1EA7662F7E90D4E8E1DF12FF563A6DCC161FFCF6C4591D7C5F2695F6FE`
- 512 icon: `19914C9D4C3F626782C85920DAD9E16B069809B5D876A903BF88695E1F9F045F`
- `npm.cmd run typecheck`: exit 0.
- `npm.cmd run build`: exit 0; Vite transformed 2463 modules and emitted the production bundle.
- Dist asset/manifest check: passed; `dist/manifest.webmanifest`, `dist/icon-192.png`, and `dist/icon-512.png` exist and the manifest contract is valid.
- `git diff --check`: no whitespace errors. Git emitted only existing line-ending normalization warnings.
## TDD evidence
## Self-Review
### RED
The five executable login fixtures were updated before the source constants. A read-only source assertion was then run against `server/index.ts`, `server/scripts/seed.ts`, and `src/pages/LoginPage.tsx`. It failed as expected with `AssertionError` because `owner@agendamax.demo` and `[email protected]` were not yet present while the old source constants remained.
The focused runtime visual check was also attempted before source changes:
```text
npm run audit:visual
page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:5173/
```
This did not reach the application and did not touch the database.
### GREEN
After the minimal source and README edits, the corrected read-only assertion passed:
```text
source and active login fixtures: PASS
```
The final active-reference and README credential assertion also passed:
```text
active references and README credentials: PASS
```
## Tests and outputs
- `npm run typecheck` - passed, exit code 0; `tsc -b --noEmit` reported no errors.
- `npm run build` - passed, exit code 0; Vite transformed 2463 modules and built in 5.63s.
- `npm run test:unit` - passed: 39 tests, 39 pass, 0 fail.
- `npm run audit:visual` - unavailable: no server was listening on `localhost:5173` (`ERR_CONNECTION_REFUSED`).
- `npm run lint` - unavailable: ESLint 9.39.5 could not find `eslint.config.js`, `eslint.config.mjs`, or `eslint.config.cjs`.
- `rg -n ...` - unavailable because `rg` is not installed/on PATH in this environment. The equivalent scoped repository search found no old domain in active application, README, fixture, or executable plan references.
- `git diff --cached --check` - passed before the implementation commit.
The database-mutating API suites (`test:e2e`, `test:admin`, and `test:booking`) were not run because this task explicitly forbids touching the ignored local database. They remain for the post-migration verification task.
## Self-review
- The quick-login behavior is unchanged: `LoginPage` still loads `/api/auth/demo-users`, and the existing password and account-switching logic remain intact.
- Only platform-owned Admin and Dueño domains changed to `@agendamax.demo`; all employee domains remain on their existing business domains.
- All documented demo rows explicitly show `demo1234`, including every employee row.
- No migration, abstraction, production command, local database command, `.env.local.ps1`, or `data/` file was added or changed.
- The implementation commit contains exactly the ten intended Task 1 files and excludes the supplied untracked design/plan files.
- Remaining `@agendapro.demo` matches are intentional migration/history references in the new migration plan/spec and the completed Task 5 report.
- The browser-tab icon remains `/favicon.svg`.
- The manifest uses `/` for both `start_url` and `scope`, standalone display, portrait-primary orientation, the requested colors, and PNG icon declarations.
- The generator resolves paths from its own module location, closes pages and the browser, and produces deterministic 1x screenshots.
- No unrelated tracked or untracked worktree files were staged.
## Concerns
- Live/API and visual login verification is pending a running dev server and the later SQLite migration; those prerequisites were intentionally not started in this task.
- Lint remains unavailable until the repository ESLint 9 configuration is supplied or the project pins a compatible ESLint setup.
- The exact `rg` inventory command cannot be reproduced until ripgrep is installed/on PATH.
- `npm.cmd run test:pwa` was not run because `pwa-e2e.mjs` is intentionally a Task 4 deliverable and does not exist at this task boundary. The script is present as required for the later task.
+27 -50
View File
@@ -1,61 +1,38 @@
# Task 2 Report — scheduling.ts pure functions + unit tests
# Task 2 Implementation Report
## Status
✅ COMPLETE — all acceptance criteria met.
## Scope
## 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`.
Implemented only the production AgendaMax service worker and its production-only registration. Existing `.superpowers/sdd/*` changes and `graphify-out/*` outputs were preserved and were not staged.
## Changed Files
- `public/sw.js`: added versioned shell/static caching with `agendamax-shell-v1`, install/activate lifecycle handling, network-first navigation fallback, and cache-first handling limited to script, style, image, font, manifest, and worker requests.
- `src/main.tsx`: registered `/sw.js` after the React root render only when `import.meta.env.PROD` and service workers are supported.
## 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.cmd run typecheck`: exit 0.
### `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
```
```text
> [email protected] typecheck
> tsc -b --noEmit
```
**Summary line:** `# tests 15 | # pass 15 | # fail 0 | duration 201.8 ms`
- `npm.cmd run build`: exit 0. Vite transformed 2463 modules and emitted the production bundle.
- `Test-Path -LiteralPath "dist/sw.js"`: `True`; Vite copied the public worker to the production output.
- `git diff --check`: no whitespace errors. Git emitted only LF-to-CRLF normalization warnings for existing/changed working-tree files.
## 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
## 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.
- Cache name and shell URLs match the Task 2 contract exactly.
- Activation removes older cache names and claims clients after activation.
- Fetch handling rejects non-GET requests, cross-origin requests, and paths beginning with `/api/` before any response interception.
- Navigation requests use network-first behavior and fall back to the cached root document.
- Static caching is allowlisted by request destination; there is no catch-all cache path.
- Registration is deferred until `load`, uses `{ updateViaCache: "none" }`, and registration failure is non-fatal.
- Only `public/sw.js` and `src/main.tsx` are intended for the Task 2 commit.
## Concerns
None. Ready for Task 3.
- Validation is static/build-level only; browser service-worker runtime behavior is intentionally left to the later PWA end-to-end task.
- `git diff --check` reports normal line-ending normalization warnings from the existing PowerShell/Git configuration, not whitespace errors.
+134 -30
View File
@@ -1,41 +1,145 @@
# Task 3 Report — scheduling.ts DB functions
# Task 3 Report: Add Install Detection and UI
## 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"`.
## Status
## Typecheck
`npm run typecheck`**0 errors** (after the deviation below).
Implemented Task 3 only. The install hook detects standalone mode, Chromium's deferred install event, and iOS Safari instructions. The reusable prompt is rendered once in each required shell surface and uses the existing modal/button/icon patterns.
## test:unit
`npm run test:unit`**15/15 pass** (pure tests unaffected).
## Changed files
## 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.
- `src/lib/useInstallPrompt.ts`: added the browser-only install state hook, local deferred-event type, event listener lifecycle, install action, and dismissal behavior.
- `src/components/InstallAppPrompt.tsx`: added the reusable Chromium/iOS install action and iOS instructions modal.
- `src/index.css`: added `.safe-area-bottom` using `env(safe-area-inset-bottom)`.
- `src/pages/LoginPage.tsx`: added one compact install action below the login form.
- `src/components/AppShell.tsx`: added one install action to the mobile header.
- `src/components/AdminShell.tsx`: added one install action to the mobile header.
```
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
## Verification
### Typecheck
Command:
```text
npm.cmd run typecheck
```
`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.
Output:
## 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.
```text
> [email protected] typecheck
> tsc -b --noEmit
```
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.
Exit code: `0`.
### Production build
Command:
```text
npm.cmd run build
```
Output summary:
```text
Preflight OK: Node v26.4.0.
vite v5.4.21 building for production...
✓ 2465 modules transformed.
✓ built in 4.08s
```
Exit code: `0`.
### Diff validation
Command:
```text
git diff --check
```
Result: no whitespace errors. Git emitted only existing LF/CRLF conversion notices for worktree files.
## 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).
- The hook uses a local `BeforeInstallPromptEvent` interface and does not add an unsafe global declaration.
- `beforeinstallprompt` and `appinstalled` listeners are removed on unmount.
- Standalone detection suppresses the CTA, and unsupported browsers remain unchanged.
- iOS instructions are limited to Safari and the existing `Modal` owns backdrop/Escape close behavior.
- The safe-area utility is applied only inside the iOS modal content.
- Each shell renders exactly one prompt instance outside `SidebarContent`, avoiding duplicate listeners from the desktop/mobile sidebar duplication.
- The accessible action text remains `Instalar AgendaMax` in every supported prompt.
- No Task 4 tests or documentation changes were added.
## Concerns
- No browser-level visual/manual check was run; verification was limited to the required typecheck, production build, and diff inspection.
- `git diff --check` reports line-ending notices from the existing Windows worktree configuration, not whitespace failures.
## Preserved worktree changes
Existing `.superpowers/sdd/*` changes and untracked `graphify-out/*` artifacts were not reverted or staged.
## Review Fixes
- `src/lib/useInstallPrompt.ts`: added a ref-based in-flight guard so rapid clicks can invoke the native prompt only once. The deferred event is restored when `prompt()` or `userChoice` fails, the error is contained, and the state returns to `available` for retry. `appinstalled` now clears dismissal before setting `installed`.
- `src/components/Modal.tsx`: added `aria-label="Cerrar"` to the shared icon-only close button.
- `src/components/InstallAppPrompt.tsx`: added an explicit `compact` presentation. Header instances use a fixed icon button with a screen-reader label and tooltip, while Login keeps the full visible action label.
- `src/components/AppShell.tsx` and `src/components/AdminShell.tsx`: enabled the compact prompt presentation in the narrow mobile headers.
## Review Fix Verification
### Typecheck
Command:
```text
npm.cmd run typecheck
```
Output:
```text
> [email protected] typecheck
> tsc -b --noEmit
```
Exit code: `0`.
### Production build
Command:
```text
npm.cmd run build
```
Output summary:
```text
Preflight OK: Node v26.4.0.
vite v5.4.21 building for production...
✓ 2465 modules transformed.
✓ built in 3.95s
```
Exit code: `0`.
### Diff inspection
`git diff --check` reported no whitespace errors. The only messages were existing Windows LF/CRLF conversion notices. The implementation diff contains only the hook, shared prompt, shared modal accessibility, and two mobile header call-site fixes described above.
## Review Fix Self-Review
- A second click while `installInFlight.current` is true returns immediately, including clicks occurring after React has scheduled the deferred-event state update.
- Failed native prompt or choice promises no longer produce an unhandled rejection and restore the captured event for a retryable `available` state.
- A later `appinstalled` event overrides any prior dismissal and returns the hook state as `installed`.
- Both modal close mechanisms remain intact, and the icon-only close control now has an accessible name.
- Header install controls are compact without hiding the action from assistive technology; the full accessible name remains `Instalar AgendaMax`.
- No unrelated worktree files were modified or staged, and no Task 4 tests or documentation were added.
## Remaining Concerns
- No browser-level visual/manual check was run; verification is limited to typecheck, production build, and source diff inspection.
- The existing Windows worktree continues to emit LF/CRLF conversion notices during Git operations.
+68 -78
View File
@@ -1,89 +1,79 @@
# Task 4 Report — backend `booking.ts`: slots con working_hours + `pickBestSlotEmployee`
# Task 4 Report
**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
## 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).
- Added `pwa-e2e.mjs` with production endpoint, manifest, SPA fallback, Chromium install prompt, iOS instructions, standalone/unsupported states, service-worker readiness/source checks, and real browser `/api/health` request verification.
- Updated `README.md` with local production validation, Android/Chrome installation, iOS Safari instructions, standalone behavior, connectivity expectations, and Coolify HTTPS validation guidance.
## Verification
### `npm run typecheck`
```
> [email protected] typecheck
> tsc -b --noEmit
```
**0 errors.**
- `node --check pwa-e2e.mjs`: passed.
- `npm.cmd run typecheck`: passed.
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit reported 8 console 404 messages while still exiting 0.
- `git diff --check`: passed with no whitespace errors. Git emitted existing LF-to-CRLF working-copy warnings for tracked files.
### 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.
The first PWA run exposed a test-harness race where the synthetic event could fire before React registered its listener. The harness was corrected to dispatch 100 ms after `load`; the subsequent production run passed.
## 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.
- Endpoint checks use the configured `PWA_BASE_URL`, defaulting to `http://localhost:3000`.
- Manifest assertions cover standalone display, root start URL/scope, and both required icon sizes.
- Browser contexts are isolated for Chromium prompt, iOS Safari instructions, standalone mode, and unsupported/no-event mode.
- Service-worker readiness has a five-second bound, and the source guard checks cover `/api/` and non-GET requests.
- The browser API check observes a real request and validates its JSON response rather than using a fixture.
- No app authentication, tenant data, API route, database schema, or runtime behavior was changed.
## 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.
- The visual audit continues to record eight pre-existing 404 console messages on some authenticated/admin visits. They were not introduced by Task 4 and were not changed because the task explicitly limits scope to PWA verification and production documentation.
- The report itself and pre-existing `.superpowers/sdd/*` and `graphify-out/*` changes remain outside the Task 4 commit staging set.
## Review Fixes
- Added `fetchWithTimeout()` using `AbortController` for every direct endpoint/source fetch. The in-page `/api/health` request now uses its own bounded `AbortController` as well.
- Added exact-one accessible-action coverage for the login page, authenticated business shell, and authenticated admin shell. Business/admin flows use the existing demo credentials and a mobile viewport so their compact install controls are visible and accessible.
- Replaced fixed sleeps in standalone/unsupported checks with bounded DOM-state polling for zero matching install actions.
- Replaced iOS body-text checks with visible exact-text locators for `Compartir` and `Añadir a pantalla de inicio`.
- Relaxed service-worker source checks to tolerate whitespace and quote style while still requiring the pathname `/api/` bypass and non-GET guard.
- Tracked all browser contexts and closes them explicitly before closing the browser in `finally`; default UI/navigation timeouts are bounded.
## Review-Fix Verification
- `node --check pwa-e2e.mjs`: passed.
- `npm.cmd run typecheck`: passed.
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
- `npm.cmd run audit:visual`: passed; 58 screens/visits, 0 failed loads, 0 horizontal overflow issues. The audit still reports 8 pre-existing console 404 messages.
- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings.
## Review-Fix Self-review
- Direct network operations now have bounded completion, including response-body reads for endpoint/source assertions and the browser-side API response.
- Login, business, and admin each assert one and only one accessible `Instalar AgendaMax` action; the two shell checks exercise the authenticated UI path rather than assuming route markup.
- Negative install-state checks wait on the DOM condition with a timeout and fail non-zero if the condition never becomes true.
- All changes remain in the PWA test harness and this report; application behavior and unrelated tests are unchanged.
## Remaining Concerns
- The visual audit's 8 pre-existing 404 console messages remain outside Task 4 scope. They do not cause failed loads, overflow failures, or a non-zero audit exit.
## Service-worker Behavioral Fix
- Added a reload and `navigator.serviceWorker.controller` gate before behavioral API checks, ensuring requests run through an active, controlling worker.
- While online, the browser now verifies `GET /api/health` returns `200` and a safe invalid `POST /api/auth/login` returns `401`.
- With the Playwright context offline, both API requests must produce bounded network errors rather than cached responses. Connectivity is restored in a `finally` block.
## Service-worker Behavioral Verification
- `npm.cmd run typecheck`: passed.
- `npm.cmd run build`: passed; Vite produced the production bundle in `dist/`.
- `npm.cmd run test:pwa`: passed; output `PWA checks passed` against the built production server on port 3000.
- `git diff --check`: passed with no whitespace errors; Git only emitted existing LF-to-CRLF working-copy warnings.
## Service-worker Behavioral Self-review
- The online assertions validate real statuses (`200` and `401`) before offline mode is enabled, so a cached fixture cannot satisfy the positive path.
- Offline assertions require both requests to reject with a network-error result and do not accept a response status or body.
- The offline state is always reverted in `finally`, and the existing bounded request, navigation, worker-readiness, UI, install-surface, and cleanup checks remain intact.