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]>
387 lines
25 KiB
Markdown
387 lines
25 KiB
Markdown
# 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.
|