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]>
25 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. - La gráfica de ingresos es la excepción: se anima con CSS, no con framer-motion. iOS Safari
suspende
requestAnimationFramemientras 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 (unuseInViewque ponedata-ld-rev-visible); el cómo son los keyframesld-rev-*de src/index.css, que llevan su propia línea de tiempo en el motor. El trazo usapathLength={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 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.
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.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.