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

25 KiB
Raw Blame History

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

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 /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:

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() — 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 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ónico YYYY-MM-DD HH:MM:SS.
  • Por eso 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). 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 IMMEDIATECOMMIT / ROLLBACK):

  1. resolver el empleado (explícito → auto-asignar a sí mismo si el usuario es employeeautoAssign),
  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.

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 a Error & { status }. Añade endpoints ahí, no fetch suelto 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).
  • AppShell para negocio, AdminShell para plataforma; /b/:slug (reservas públicas) se monta fuera del AuthProvider en 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 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, 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/, 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 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 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 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, 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 (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 mide innerHeight - rect.top y se pasa como número; ver CAL_BOTTOM_GAP / CAL_MIN_HEIGHT en 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, que main.tsx importa por su efecto antes de montar React, y useInstallPrompt 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.