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
+363
View File
@@ -0,0 +1,363 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> El código, la UI y los mensajes de error de este repo están en **español**. Mantén ese idioma en
> strings visibles al usuario y en mensajes de error de la API.
## Requisitos del entorno
- **Node.js >= 22.5** (recomendado 24+). El servidor usa `node:sqlite` nativo: no hay dependencia
de compilación nativa, pero tampoco funciona en Node < 22.5. `scripts/preflight.mjs` valida esto
antes de `dev`, `build` y `start`.
- Windows/PowerShell: si `npm.ps1` está bloqueado por execution policy, usa **`npm.cmd`**.
- `.npmrc` apunta la caché de npm a `.cache/npm` dentro del proyecto (el caché global no siempre es
escribible en las máquinas de este proyecto). Instala con `npm run setup` (`npm ci`), no con
`npm install`, para no mutar el lockfile.
- `scripts/run-tsx.mjs` envuelve a `tsx` con un fallback de `os.userInfo()`; todo script de servidor
se lanza a través de él, nunca con `npx tsx` directo.
## Comandos
```bash
npm run setup # npm ci con caché local (instalación recomendada)
npm run dev # preflight + server (:3000) y vite (:5173) en paralelo
npm run dev:background # lo mismo, desacoplado; logs en .cache/runtime/dev.*.log
npm run build # tsc -b && vite build -> dist/
npm start # producción: sirve API + dist/ en :3000
npm run typecheck # tsc -b --noEmit <-- este es el gate real de calidad
npm run seed # regenera la DB demo
```
Overrides de puerto sin tocar código (preflight aborta si el puerto está ocupado):
```bash
PORT=3001 VITE_PORT=5174 API_URL=http://127.0.0.1:3001 npm run dev
$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev
```
`RESET_DB=1` borra `data/agendapro.db` al arrancar y vuelve a sembrar desde cero.
### Tests
```bash
npm run test:unit # node:test sobre server/lib/{scheduling,time,metrics}.test.ts (puro, sin servidor)
npm run test:e2e # API: login, CRUD, dashboard, roles, aislamiento -> requiere `npm run dev`
npm run test:admin # consola admin: alta con plantilla, reset-demo, cascade -> requiere `npm run dev`
npm run test:booking # booking público: auto-asignación y 409 -> requiere `npm run dev`
npm run audit:visual # Playwright: 10 páginas x 4 viewports -> screenshots/
npm run test:pwa # requiere `npm run build` + `npm start` (golpea :3000)
```
Detalle importante: los `.mjs` de e2e/admin/booking/visual apuntan a **`http://localhost:5173`**
(pasan por el proxy de Vite), mientras que `pwa-e2e.mjs` apunta a **`:3000`** porque necesita el
build servido y el service worker (que solo se registra en `import.meta.env.PROD`).
`PWA_BASE_URL` permite apuntarlo a otro host; la validación real de instalabilidad exige un dominio
**HTTPS**, no una IP.
Un solo test unitario:
```bash
node --import tsx --test server/lib/scheduling.test.ts
node --import tsx --test --test-name-pattern="autoAssign" server/lib/scheduling.test.ts
```
**`npm run lint` está roto** (ESLint 9 requiere `eslint.config.js` y el repo no tiene ninguno; es un
hueco preexistente). No lo uses como señal de verificación: usa `typecheck` + los tests.
## Arquitectura
### Forma general
Un solo proceso Node sirve la API y, en producción, el frontend compilado:
- `server/` — Express + SQLite (`node:sqlite`). Los routers se montan en [server/index.ts](server/index.ts).
- `src/` — SPA React 18 + Vite + Tailwind. En dev, Vite proxya `/api` al `:3000`.
- `shared/types.ts` — los tipos TS que cruzan cliente/servidor. **Actualízalo junto con el esquema.**
- Aliases: `@/*``src/*`, `@server/*``server/*` (definidos en `tsconfig.json`; Vite solo resuelve `@`).
- En producción `server/index.ts` sirve `dist/` estático con fallback SPA (`app.get("*")`) que excluye
`/api/`. Los TS del servidor se ejecutan directo con `tsx` — no hay paso de compilación de backend
(el `Dockerfile` hace `npx tsx server/index.ts`).
### Multi-tenancy: la invariante central
Cada negocio es un tenant. Salvo `businesses` y `users`, **toda** tabla lleva `business_id`, y toda
query debe filtrar por `req.user!.business_id`. Nunca aceptes un `business_id` que venga del body.
El patrón canónico está en todas las rutas:
```ts
db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id)
```
Tres roles (`users.role`): `admin` (administrador de plataforma, `business_id` NULL, opera todos los
negocios vía `/api/admin`), `owner` y `employee`. Middlewares en [server/lib/auth.ts](server/lib/auth.ts):
`authRequired`, `ownerOnly`, `adminOnly`, más el helper `err(res, status, msg)`.
`src/App.tsx` esconde rutas por rol, pero eso es solo UX: **la autorización real vive en los
middlewares del servidor** y los tests de e2e/admin verifican el aislamiento (403 / datos de otro
tenant no visibles).
### Autenticación (demo, deliberadamente trivial)
El token **es el id del usuario en texto plano** y las contraseñas se comparan sin hashear
([server/routes/auth.ts](server/routes/auth.ts)). `authRequired` rehidrata `req.user` desde la DB en
cada request. El cliente guarda el token en `localStorage` bajo `ap_token`
([src/lib/api.ts](src/lib/api.ts)). Esto habilita el "Ver como…" del `DemoSwitcher`.
No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `api.ts`, el
`AuthProvider` y los `.mjs` de test a la vez.
### Esquema y migraciones
[server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta:
- `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`).
- `runMigrations()` — llama `migrateV1ToV2()``migrateV2ToV3()``migrateV3ToV4()` **en ese orden**,
cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4`
está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del
archivo. Versión actual: **4**.
Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV4ToV5()` nueva que
haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`.
Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable.
### Siembra y plantillas
`server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma
(`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière".
`seedBusiness({ businessId, template, ownerEmail, ownerName })` de
[server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el
alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`,
`blank`) están en [server/lib/templates.ts](server/lib/templates.ts).
Cuentas demo (todas con contraseña `demo1234`): `[email protected]`, `[email protected]`, y
seis empleados `*@lumiere.mx`. Los `.mjs` de test dependen de estos emails y del dominio
`agendamax.demo`.
### Zonas horarias: no uses `date('now')`
Es el error más fácil de cometer aquí. Los negocios son de México (UTC-6), así que el `date('now')`
de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla:
- Las citas se guardan en UTC con formato ISO-Z **`YYYY-MM-DDTHH:MM:SSZ`** (sin milisegundos).
- `datetime(columna)` de SQLite devuelve el formato canónico **`YYYY-MM-DD HH:MM:SS`**.
- Por eso [server/lib/time.ts](server/lib/time.ts) tiene **dos familias** de helpers:
`bizDayBoundsIso()` para comparar contra `start_at` crudo, y `bizDayBoundsSqlite()` para comparar
contra `datetime(columna)`. Elegir mal produce rangos que no matchean nada.
- La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en
[server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante
explícito, por eso son testeables (`server/lib/time.test.ts`).
### Agendado, auto-asignación y guard anti doble-reserva
[server/lib/scheduling.ts](server/lib/scheduling.ts) está partido en dos mitades a propósito:
primero helpers **puros** (`parseWorkingHours`, `specialtyMatch`, `hasConflict`, `scoreCandidate`,
`getWorkingHoursForDate`…) cubiertos por `scheduling.test.ts`, y después los que tocan la DB
(`getCandidates`, `getExistingBusy`, `isAvailable`, `pickBestSlotEmployee`, `autoAssign`,
`runInTransaction`). Si añades lógica pura, ponla en la primera mitad y testéala.
Las **dos** rutas que crean citas — `POST /api/appointments`
([server/routes/appointments.ts](server/routes/appointments.ts)) y `POST /api/public/:slug/book`
([server/routes/booking.ts](server/routes/booking.ts)) — deben mantener esta secuencia dentro de
`runInTransaction` (`BEGIN IMMEDIATE``COMMIT` / `ROLLBACK`):
1. resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es `employee``autoAssign`),
2. `isAvailable(db, empId, startMs, endMs)`; si falla, lanzar `{ status: 409, error: … }`,
3. `INSERT` de la cita en la **misma** transacción.
Sacar el chequeo de la transacción reabre la carrera de doble reserva. Los errores se lanzan como
objetos `{ status, error }` y el handler los traduce con `err()`.
Limitación conocida: `getExistingBusy` acota por día vía `start_at`, así que **no detecta citas que
cruzan medianoche**; hoy queda neutralizado por el guard de horario laboral. Revisítalo si alguna vez
se permite agendar 24h.
`businesses.working_hours` y `employees.working_hours` guardan un JSON `Record<1..7, {start,end}|null>`
(1=Lunes … 7=Domingo); `null` en el empleado significa "hereda del negocio". El lado cliente lo
maneja en [src/lib/workingHours.ts](src/lib/workingHours.ts).
### Frontend
Rutas públicas y protegidas ([src/App.tsx](src/App.tsx)):
```
/ → LandingPage público; con sesión redirige según rol
/login → LoginPage formulario + acceso de un clic a las cuentas demo
/b/:slug → BookingPage fuera del AuthProvider; no asumas usuario
/dashboard… → panel sin sesión → /login
```
`homePathFor(user)` es la **única** definición de a dónde va cada rol (`admin``/admin`,
`owner``/dashboard`, `employee``/calendar`). No la repliques.
**Cuatro páginas se cargan con `React.lazy`** y el motivo es medible, no estético:
| Lazy | Por qué |
|---|---|
| `LandingPage`, `LoginPage` | Son las únicas que usan `framer-motion` (~40 KB gz). Con el login eager, Rollup mete `lib/motion` en el chunk de entrada y el panel paga la librería en cada carga. |
| `DashboardPage`, `CalendarPage` | Son las únicas que importan `recharts` (~111 KB gz) y FullCalendar (~76 KB gz). Eager, la landing las descargaba sin graficar ni agendar nada. |
Su frontera de `Suspense` está en el `<Outlet>` de `AppShell` (fallback `RouteSpinner`).
`clsx` está fijado al chunk `react` en `vite.config.ts` a propósito: lo comparten `lib/format.ts` y
recharts, y sin fijarlo Rollup lo asigna al chunk `charts`, de modo que el chunk de entrada acaba
importando 111 KB de recharts para obtener una utilidad de 200 bytes. Verificable: `npm run build` y
comprobar que `index-*.js` no importe `charts-*` ni `framer-*`.
- [src/lib/api.ts](src/lib/api.ts) es el **único** cliente HTTP de la app autenticada: un `request<T>()`
que inyecta el bearer y normaliza errores a `Error & { status }`. Añade endpoints ahí, no `fetch`
suelto en componentes. El flujo público usa [src/lib/publicApi.ts](src/lib/publicApi.ts) aparte.
- React Query con `staleTime: 15_000`, sin refetch al enfocar, `retry: 1` ([src/main.tsx](src/main.tsx)).
- `AppShell` para negocio, `AdminShell` para plataforma; `/b/:slug` (reservas públicas) se monta
**fuera** del `AuthProvider` en [src/App.tsx](src/App.tsx) — no asumas usuario en ese árbol.
- Calendario con FullCalendar. En móvil abre en vista Día + vista Lista; Semana/Mes se reservan a
tablet/desktop a propósito (columnas aplastadas). `vite.config.ts` separa FullCalendar, recharts,
react y react-query en chunks manuales.
- Estilos de formulario (`.input`, `.select`, `.textarea`) viven en un `@layer components` de
`src/index.css`; si los sacas de la capa, la especificidad de Tailwind los pisa.
### Responsividad (iPhone / iPad) — invariantes que no hay que romper
Objetivo: iPhone 12 (390px), iPhone 16 Pro Max (440px) e iPad (744-1180px). Verificable con
`npm run audit:responsive` (WebKit real; requiere `npm run dev`). Reglas que sostienen el
comportamiento actual:
- **Ningún campo de formulario por debajo de 16px en táctil.** Mobile Safari hace auto-zoom al
enfocar un `input`/`select`/`textarea` con `font-size < 16px` y **no revierte** al desenfocar: la
vista queda escalada y corrida. Era la causa del "arranca con zoom y desplazada" al tocar el campo
de correo. La regla vive en `@media (pointer: coarse)` en [src/index.css](src/index.css) y va por
tipo de puntero, no por ancho: un iPad también se toca con el dedo. No se resuelve con
`maximum-scale=1` porque eso rompe el pinch-zoom de accesibilidad.
- **Alturas de viewport con `dvh`, nunca `100vh` a secas.** `100vh` en iOS no descuenta la barra de
URL dinámica. Usa `.h-screen-safe` / `.min-h-screen-safe` / `.max-h-screen-safe`, que declaran
`vh` como fallback y `dvh` encima.
- **`viewport-fit=cover` obliga a descontar los insets del sistema.** El chrome (headers, sidebars,
bottom-sheets) usa `.safe-top`, `.safe-x` y `.safe-area-bottom`, apoyadas en las variables
`--safe-*` de `:root`. Sin ellas el contenido queda bajo la Dynamic Island o el home indicator.
- **Los objetivos táctiles se deciden por `pointer: coarse`, no por breakpoints `sm:`.** Un `sm:` deja
fuera a las tablets, que son táctiles. Hay dos utilidades: `.tap-target` (40px de alto, para chips
densos) y `.icon-btn` (40×40, para botones de solo icono).
- **`.icon-btn` es una clase explícita a propósito; no intentes detectar los botones de icono por
selector.** Se probó `button:has(> svg:only-child)` y es una trampa: `:only-child` solo mira hijos
**elemento**, así que un botón o enlace con etiqueta (`<Plus/> Nueva cita`) también encaja, porque el
texto es un nodo de texto. Esa regla, al fijar `display: inline-flex`, convirtió los `NavLink` del
menú lateral en inline y los repartió en dos columnas en iPad Pro. Dos corolarios: `.icon-btn`
**no** toca `display` (está fuera de `@layer` y ganaría a `hidden`/`lg:block`, sacando el botón de
colapsar en el móvil), y centra el icono con `margin-inline: auto` sobre el `svg`, porque el
preflight de Tailwind lo deja en `display: block` y así ignora `text-align`.
- **Scrollbars personalizadas solo en `pointer: fine`.** Una barra de 10px en táctil roba ancho al
layout (el ancho útil bajaba de 390 a 380px) y descuadra los cálculos de 100%.
- **No combines `.safe-x` con `px-*` en el mismo elemento.** `.safe-x` está declarada **fuera** de
`@layer` en [src/index.css](src/index.css), así que gana a las utilidades de Tailwind. Como resuelve
`env(safe-area-inset-left, 0px)`, en cualquier dispositivo que no sea un iPhone en landscape deja el
padding lateral en **0** y el texto pega con el borde. Ni el check de overflow ni el de zoom lo
detectan, porque no hay desborde: hay cero margen. Usa `.ld-gutter` (o el patrón
`padding-inline: max(1.25rem, var(--safe-left))` de `.ld-section`), que resuelve las dos cosas en una
declaración. `npm run audit:responsive` lo vigila con el check `gutter` en las páginas públicas.
- **El check `shell-height` solo aplica a páginas con shell de altura fija** (`[data-app-shell]`, o sea
`AppShell` y `AdminShell`). Una landing o un login scrollean a propósito y son legítimamente más
altos que el viewport; compararlos contra `innerHeight` no mide un defecto, mide que existe scroll.
Para esas páginas el equivalente es el check estático `raw-viewport-unit`, que busca `h-screen`/`100vh`
crudos en las fuentes.
- **Nada debe poder desplazar la página en horizontal.** `body` lleva `overflow-x: hidden` como red
de seguridad, pero las causas se arreglan en origen: por ejemplo, un `grid` sin `grid-cols-1`
explícito dimensiona su columna implícita a `max-content` y la estiraba 23px más que la pantalla.
### Landing pública y marca
La landing vive en [src/components/landing/](src/components/landing/), una sección por archivo, todas
sin props ni estado compartido; [src/pages/LandingPage.tsx](src/pages/LandingPage.tsx) solo las
compone. El test de aceptación es `npm run test:landing` (`landing-e2e.mjs`, WebKit), y depende de
estos atributos de datos — si los quitas, se rompe: `data-ld-section` (deben ser **7**),
`data-ld-hero-title`, `data-ld-closing-cta`, `data-magic-login` + `data-role`.
**La paleta no se inventa: se hereda del panel.** Los tokens `--ld-*` de `src/index.css` apuntan a los
colores que la app ya usa. En particular, las ocho muestras del abanico del hero son `PIE_COLORS` de
[src/pages/DashboardPage.tsx](src/pages/DashboardPage.tsx) — los mismos hex de los servicios en la dona
y de los avatares que reparte el seed. Por eso el hilo de la página («sus colores se vuelven sus
números») es literal. Si cambias `PIE_COLORS`, cambia también el abanico.
- La marca vive en **un** sitio: [src/components/BrandMark.tsx](src/components/BrandMark.tsx), que
replica `public/favicon.svg` (cuadrado `#3b66ff`, renglones blancos, punto `#f17616`). Si tocas esos
colores, toca también el favicon y `npm run generate:pwa-icons`, o la pestaña deja de coincidir.
- El azul hace de superficie de acción (`.ld-cta` usa brand-500 → brand-700, los dos tonos que
`.btn-primary` ya usa en normal y hover) y el naranja marca, nunca es fondo de botón. Es la misma
lógica del favicon.
- **`NotebookVisual` es la única excepción** y es deliberada: papel crema, renglones y grafito. Es lo
que el producto reemplaza; si se pareciera al producto, la sección dejaría de contar un cambio.
- Tipografía: Inter de cuerpo (la misma del panel) y **Fraunces** solo en titulares. El cuerpo va a
17px mínimo con interlínea 1.6 porque el público objetivo incluye personas de 50+.
- Las animaciones salen de [src/lib/motion.ts](src/lib/motion.ts) con un único easing. Con
`prefers-reduced-motion` los `initial` resuelven al **estado final**, no se acortan: un
`initial={{opacity:0}}` cuyo `whileInView` nunca corre deja el bloque invisible para siempre. El
test lo verifica recorriendo las siete secciones.
### Barra lateral
Un único menú vertical, colapsable a solo iconos en `lg+` mediante
[src/lib/useSidebarCollapsed.ts](src/lib/useSidebarCollapsed.ts) (persistido en `localStorage` bajo
`ap_sidebar_collapsed`, sincronizado entre pestañas). Colapsada mide 68px y devuelve **188px** al
contenido, que es lo que más se nota en un iPad Pro portrait. `AppShell` y `AdminShell` comparten el
patrón: `sidebarContent(mini)` en lugar de un JSX fijo, porque el panel móvil siempre se muestra
completo aunque la de escritorio esté colapsada. En modo `mini` cada entrada conserva `title` para no
perder su nombre accesible al quedarse sin texto.
### Calendario responsivo
Es la parte que más se ha roto históricamente. Tres tamaños vía
[src/lib/useBreakpoint.ts](src/lib/useBreakpoint.ts), alineados con los media queries del CSS:
| | phone (≤640) | tablet (641-1024) | desktop (≥1025) |
|---|---|---|---|
| Vista inicial | `timeGridDay` | `timeGridThreeDay` | `timeGridWeek` |
| Toolbar | prev/next/hoy + Día/Lista | + 3 días/Semana | + Mes |
El corte tablet/desktop está en 1024/1025 y **no coincide con el `lg:` de Tailwind** (también 1024):
un iPad Pro portrait mide justo 1024px, así que a ese ancho hay barra lateral fija *y* vista de 3
días. Es intencionado — con la barra desplegada quedan ~712px de calendario, y 7 columnas ahí son
~100px por día. Si cambias uno de los dos umbrales, cambia también el otro
([src/index.css](src/index.css) tiene los media queries de tablet).
- **La altura del calendario se calcula contra el viewport, no con `height="100%"`.** FullCalendar mide
su contenedor una sola vez al montar y solo re-mide en resize de ventana, así que se quedaba con
valores viejos cuando el layout cambiaba después (en un iPhone 12 daba 360px dentro de un contenedor
de 525px). Se mide `innerHeight - rect.top` y se pasa como número; ver `CAL_BOTTOM_GAP` /
`CAL_MIN_HEIGHT` en [src/pages/CalendarPage.tsx](src/pages/CalendarPage.tsx). El `ResizeObserver`
observa la **barra de filtros**, no el `body`: el shell es `overflow-hidden` de altura fija, así que
el body nunca cambia de tamaño y observarlo no vuelve a disparar.
- **No pongas `contentHeight` fijo.** `.fc-timegrid-slot` mide 2.4rem y la grilla de 08:00-21:00 son 26
slots (~1000px), así que un `contentHeight` en píxeles hacía el calendario 1.4× más alto que la
pantalla y arrastraba la página. El calendario debe scrollear **dentro** de su tarjeta.
- **La tablet usa 3 días a propósito.** Con 7 columnas en 820px y varios especialistas a la misma hora,
`slotEventOverlap={false}` parte cada evento en columnas de ~30px y los títulos quedan en `"A.."`.
- No añadas bloques (avisos, empty states) como hermanos del calendario dentro de su tarjeta: con la
altura ya fijada no caben y se solapan con la grilla.
### PWA
`public/sw.js` cachea solo el app shell y **hace bypass explícito de `/api/`** y de cualquier request
no-GET o cross-origin. Si tocas el service worker, conserva ese bypass (hay un test dedicado en
`pwa-e2e.mjs`) y sube el sufijo de `CACHE_NAME` (`agendamax-shell-vN`) para que el cleanup de
`activate` purgue el anterior. Se registra únicamente en producción. Iconos: `npm run generate:pwa-icons`.
## Convenciones de trabajo
- **No hagas commits salvo que se pidan explícitamente** (política registrada en
`.superpowers/sdd/progress.md`). La verificación por tarea es typecheck + tests, no un commit.
- Este repo se desarrolla con un flujo spec-driven: las especificaciones y planes viven en
`docs/superpowers/{specs,plans}/` y la bitácora de ejecución con hallazgos de review en
`.superpowers/sdd/`. Léelos antes de retomar un feature a medias — `progress.md` lista los
follow-ups diferidos.
- `AUDIT.md` documenta las brechas funcionales frente al producto real que se está emulando; sirve de
backlog de producto.
- Existe un grafo de conocimiento en `graphify-out/` (ver el skill `graphify`): úsalo para orientarte
antes de leer código a ciegas; refréscalo con `graphify update` si quedó desactualizado.
- `data/`, `dist/`, `screenshots/`, `.cache/`, `*.log` y `.opencode/` están gitignorados y son
regenerables; no los versiones ni los tomes como fuente de verdad.
## Seguridad
Esto es una **demo**: token trivial, contraseñas en claro, sin rate-limiting ni sesiones reales, y
`cors()` abierto. La validación de entrada es manual y ad-hoc en cada handler: `zod` figura en
`dependencies` pero **no se importa en ningún archivo**. Antes de un despliegue real haría falta
hashing (bcrypt), JWT firmados, validación estricta, rate-limiting y HTTPS. Tenlo presente antes de
proponer este código para producción.