Files
AgendaPro/CLAUDE.md
T
AgendaPro DevandClaude Opus 5 4281567207 fix: two defects that only reproduce over a real network
Neither is visible against localhost, which is why both shipped.

**Install button never appeared on `/login`** — a regression from making the login a
lazy route. `beforeinstallprompt` fires once per page load and is never replayed;
`useInstallPrompt` attached its listener from a `useEffect`, i.e. at mount, and
`InstallAppPrompt` now mounts only after an extra round-trip for its chunk. Locally
that round-trip is a millisecond so the listener still won a race it should never
have been in. Over a real connection the event was long gone, so a user on a slow
link lost the install button entirely.

The listener now lives in `src/lib/installPrompt.ts` and registers when the module
evaluates — `main.tsx` imports it for its side effect before mounting React. The hook
only reads from that store. Reproduced deterministically by delaying
`/assets/LoginPage-*.js` by 1.5s via `route()`: absent before, present after (also at
a 4s delay). `test:pwa` against the HTTPS domain now passes.

**Revenue chart did not animate on a real iPhone.** Measured rather than guessed:
the path animation does run in WebKit, and it triggers with the chart 100% visible at
y=601..715 of an 844px viewport — so neither "broken" nor "fires too early". What
fits is that iOS Safari suspends `requestAnimationFrame` during momentum scrolling
while framer-motion interpolates against wall-clock time: the animation spends its
1.4s without painting a frame and snaps to the end on resume, which looks exactly
like it never ran.

The chart's three animations move to CSS keyframes, which keep their own timeline in
the engine. The component only decides *when* (a `useInView` setting
`data-ld-rev-visible`). `prefers-reduced-motion` resolves in CSS too, and still
resolves to the *drawn* state — a line left at `dashoffset: 1px` with no animation
would be invisible forever. Verified: `getAnimations()` returns a `CSSAnimation`, and
under reduced motion the line renders complete.

Not verified: no physical iPhone here, and Playwright WebKit on Windows does not
reproduce iOS's rAF suspension. This is the standard mitigation and changes nothing
on desktop, but on-device confirmation is still outstanding.

Verified: typecheck clean; landing 13/13, PWA passed, responsive 0 findings, visual
62 screens / 0 errors, unit 39/39, e2e 33/33, admin 17/17, booking 12/12.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 15:09:29 -06:00

387 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- **La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion.** iOS Safari
suspende `requestAnimationFrame` mientras dura el scroll por inercia, y framer-motion interpola
contra el reloj de pared: la animación gasta su duración sin pintar un fotograma y al reanudarse
salta al estado final. En un iPhone se ve como si nunca hubiera animado — fue un defecto reportado
desde producción, y ningún audit lo detecta porque en WebKit de escritorio sí anima.
[RevenueLineVisual](src/components/landing/visuals/RevenueLineVisual.tsx) solo decide *cuándo*
empieza (un `useInView` que pone `data-ld-rev-visible`); el *cómo* son los keyframes `ld-rev-*` de
[src/index.css](src/index.css), que llevan su propia línea de tiempo en el motor. El trazo usa
`pathLength={1}` para que dasharray/dashoffset sean fracciones. Si añades otra animación de trazo
de línea en la landing, hazla igual.
### 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`.
**`beforeinstallprompt` se captura a nivel de módulo, no en un hook.** El navegador lo dispara una
sola vez por carga y no lo repite. Como `LoginPage` es una ruta `lazy`, `InstallAppPrompt` monta
después de una ida y vuelta de red extra: un listener registrado al montar llega tarde y el botón
«Instalar AgendaMax» no aparece nunca. Por eso el listener vive en
[src/lib/installPrompt.ts](src/lib/installPrompt.ts), que `main.tsx` importa por su efecto **antes**
de montar React, y [useInstallPrompt](src/lib/useInstallPrompt.ts) solo lee de ese almacén. No
devuelvas el listener a un `useEffect`, y si vuelves eager alguna ruta no asumas que eso lo arregla.
Detalle de verificación: `npm run test:pwa` contra `localhost` **no** detecta ese defecto, porque el
chunk lazy llega antes que el evento. Solo se ve contra el dominio HTTPS real
(`PWA_BASE_URL=https://…`), donde la latencia abre la ventana. Si tocas el arranque del prompt,
prueba contra producción o retrasa el chunk a propósito con `route()`.
## 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.