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]>
23 KiB
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:sqlitenativo: no hay dependencia de compilación nativa, pero tampoco funciona en Node < 22.5.scripts/preflight.mjsvalida esto antes dedev,buildystart. - Windows/PowerShell: si
npm.ps1está bloqueado por execution policy, usanpm.cmd. .npmrcapunta la caché de npm a.cache/npmdentro del proyecto (el caché global no siempre es escribible en las máquinas de este proyecto). Instala connpm run setup(npm ci), no connpm install, para no mutar el lockfile.scripts/run-tsx.mjsenvuelve atsxcon un fallback deos.userInfo(); todo script de servidor se lanza a través de él, nunca connpx tsxdirecto.
Comandos
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):
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
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:
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.src/— SPA React 18 + Vite + Tailwind. En dev, Vite proxya/apial:3000.shared/types.ts— los tipos TS que cruzan cliente/servidor. Actualízalo junto con el esquema.- Aliases:
@/*→src/*,@server/*→server/*(definidos entsconfig.json; Vite solo resuelve@). - En producción
server/index.tssirvedist/estático con fallback SPA (app.get("*")) que excluye/api/. Los TS del servidor se ejecutan directo contsx— no hay paso de compilación de backend (elDockerfilehacenpx 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:
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:
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). authRequired rehidrata req.user desde la DB en
cada request. El cliente guarda el token en localStorage bajo ap_token
(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 abre data/agendapro.db (WAL + foreign_keys = ON) y exporta:
SCHEMA— el esquema de instalación nueva (CREATE TABLE IF NOT EXISTS …).runMigrations()— llamamigrateV1ToV2()→migrateV2ToV3()→migrateV3ToV4()en ese orden, cada una idempotente y guardada porgetMeta("schema_version"). Ojo: en el archivomigrateV3ToV4está definida antes demigrateV2ToV3; el orden de ejecución es el derunMigrations, 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 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.
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ónicoYYYY-MM-DD HH:MM:SS.- Por eso server/lib/time.ts tiene dos familias de helpers:
bizDayBoundsIso()para comparar contrastart_atcrudo, ybizDayBoundsSqlite()para comparar contradatetime(columna). Elegir mal produce rangos que no matchean nada. - La tz sale de
businesses.timezone, con defaultAmerica/Mexico_City(verbizTz()en 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 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) y POST /api/public/:slug/book
(server/routes/booking.ts) — deben mantener esta secuencia dentro de
runInTransaction (BEGIN IMMEDIATE … COMMIT / ROLLBACK):
- resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es
employee→autoAssign), isAvailable(db, empId, startMs, endMs); si falla, lanzar{ status: 409, error: … },INSERTde 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.
Frontend
Rutas públicas y protegidas (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 es el único cliente HTTP de la app autenticada: un
request<T>()que inyecta el bearer y normaliza errores aError & { status }. Añade endpoints ahí, nofetchsuelto en componentes. El flujo público usa src/lib/publicApi.ts aparte. - React Query con
staleTime: 15_000, sin refetch al enfocar,retry: 1(src/main.tsx). AppShellpara negocio,AdminShellpara plataforma;/b/:slug(reservas públicas) se monta fuera delAuthProvideren 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.tssepara FullCalendar, recharts, react y react-query en chunks manuales. - Estilos de formulario (
.input,.select,.textarea) viven en un@layer componentsdesrc/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/textareaconfont-size < 16pxy 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 y va por tipo de puntero, no por ancho: un iPad también se toca con el dedo. No se resuelve conmaximum-scale=1porque eso rompe el pinch-zoom de accesibilidad. - Alturas de viewport con
dvh, nunca100vha secas.100vhen iOS no descuenta la barra de URL dinámica. Usa.h-screen-safe/.min-h-screen-safe/.max-h-screen-safe, que declaranvhcomo fallback ydvhencima. viewport-fit=coverobliga a descontar los insets del sistema. El chrome (headers, sidebars, bottom-sheets) usa.safe-top,.safe-xy.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 breakpointssm:. Unsm: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-btnes 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-childsolo 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 fijardisplay: inline-flex, convirtió losNavLinkdel menú lateral en inline y los repartió en dos columnas en iPad Pro. Dos corolarios:.icon-btnno tocadisplay(está fuera de@layery ganaría ahidden/lg:block, sacando el botón de colapsar en el móvil), y centra el icono conmargin-inline: autosobre elsvg, porque el preflight de Tailwind lo deja endisplay: blocky así ignoratext-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-xconpx-*en el mismo elemento..safe-xestá declarada fuera de@layeren src/index.css, así que gana a las utilidades de Tailwind. Como resuelveenv(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ónpadding-inline: max(1.25rem, var(--safe-left))de.ld-section), que resuelve las dos cosas en una declaración.npm run audit:responsivelo vigila con el checkgutteren las páginas públicas. - El check
shell-heightsolo aplica a páginas con shell de altura fija ([data-app-shell], o seaAppShellyAdminShell). Una landing o un login scrollean a propósito y son legítimamente más altos que el viewport; compararlos contrainnerHeightno mide un defecto, mide que existe scroll. Para esas páginas el equivalente es el check estáticoraw-viewport-unit, que buscah-screen/100vhcrudos en las fuentes. - Nada debe poder desplazar la página en horizontal.
bodyllevaoverflow-x: hiddencomo red de seguridad, pero las causas se arreglan en origen: por ejemplo, ungridsingrid-cols-1explícito dimensiona su columna implícita amax-contenty la estiraba 23px más que la pantalla.
Landing pública y marca
La landing vive en src/components/landing/, una sección por archivo, todas
sin props ni estado compartido; 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 — 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, que
replica
public/favicon.svg(cuadrado#3b66ff, renglones blancos, punto#f17616). Si tocas esos colores, toca también el favicon ynpm run generate:pwa-icons, o la pestaña deja de coincidir. - El azul hace de superficie de acción (
.ld-ctausa brand-500 → brand-700, los dos tonos que.btn-primaryya usa en normal y hover) y el naranja marca, nunca es fondo de botón. Es la misma lógica del favicon. NotebookVisuales 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 con un único easing. Con
prefers-reduced-motionlosinitialresuelven al estado final, no se acortan: uninitial={{opacity:0}}cuyowhileInViewnunca 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 (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, 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 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 mideinnerHeight - rect.topy se pasa como número; verCAL_BOTTOM_GAP/CAL_MIN_HEIGHTen src/pages/CalendarPage.tsx. ElResizeObserverobserva la barra de filtros, no elbody: el shell esoverflow-hiddende altura fija, así que el body nunca cambia de tamaño y observarlo no vuelve a disparar. - No pongas
contentHeightfijo..fc-timegrid-slotmide 2.4rem y la grilla de 08:00-21:00 son 26 slots (~1000px), así que uncontentHeighten 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.mdlista los follow-ups diferidos. AUDIT.mddocumenta 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 skillgraphify): úsalo para orientarte antes de leer código a ciegas; refréscalo congraphify updatesi quedó desactualizado. data/,dist/,screenshots/,.cache/,*.logy.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.