Skip to content

chore(proyecto): adaptar al estándar con pnpm, seguridad y revisión completa - #1

Merged
Aimarmndz merged 118 commits into
mainfrom
chore/adaptacion-estandar
Sep 25, 2026
Merged

Aimarmndz merged 118 commits into
mainfrom
chore/adaptacion-estandar

Conversation

@Aimarmndz

Copy link
Copy Markdown
Owner

Qué cambia

Adaptación de orgtable al estándar de los proyectos: pnpm, seguridad, revisión completa del código y documentación. 118 commits temáticos; el porqué de cada decisión está en docs/DECISIONES.md (registro nuevo, más recientes arriba).

Gestor de paquetes y dependencias

  • npm → pnpm 11 (packageManager fijado, pnpm-lock.yaml, allowBuilds solo para Prisma). Dockerfile con Corepack, CI con pnpm/action-setup y Node 22.
  • Dependencias subidas dentro de su versión mayor (next 16.3.6, Prisma 7.10, React 19.3…) + overrides para transitivas: pnpm audit pasa de 63 avisos (2 críticos) a 0. Un aviso ignorado y justificado (deepmerge-ts, solo en el CLI de Prisma).

Seguridad

  • CSP con nonce por petición (proxy.ts, app/lib/csp.ts), HSTS y CSP cerrada para /api y los .svg.
  • Límite de login no eludible con X-Forwarded-For (TRUSTED_PROXY_HOPS) + límite por cuenta; sin enumeración de emails por tiempo; la auditoría ya no guarda el email tecleado.
  • Cookie Secure según el protocolo real: la instalación por http://IP:3000 vuelve a permitir el login.
  • Restablecer la contraseña revoca también los tokens de API.
  • IDOR de etiquetas entre tableros; un miembro expulsado deja de estar asignado y de recibir avisos; setup sin carrera de dos administradores.
  • Validación de fechas, descripciones y claves de posición; los adjuntos se borran del disco al borrar tarjetas, listas o tableros.

Interfaz y accesibilidad

  • Móvil a 375 px sin desbordamiento; contraste AA; tarjetas y listas operables con teclado (abrir, mover, confirmar); modales con <dialog> nativo.
  • Confirmación en línea al borrar tableros, listas y tarjetas; avisos de error anunciados; título por página.
  • Fechas en es-ES: vencimientos como fecha de calendario (UTC), fechas del servidor en ORGTABLE_TIMEZONE (por defecto Atlantic/Canary), horas del cliente en la zona del navegador.

Base de datos

  • Migración 20260924000000_position_collate_c: las columnas position pasan a COLLATE "C" para que el orden fractional sea correcto también en Postgres con glibc. Se aplica sola al arrancar el contenedor; la reversión está documentada en el propio SQL.

Tiempo real

  • Streams SSE que liberan suscripciones y revalidan el acceso; puente LISTEN/NOTIFY que reconecta; resincronización del tablero y de la campana tras reconectar.

Operación y documentación

  • backup.sh/restore.sh/update.sh fiables (backup verificado, restauración con confirmación, backup antes de actualizar); CRON_SECRET llega al contenedor; imagen con el código de solo lectura.
  • README con la estructura estándar; documentación alineada con la licencia propietaria vigente (LICENSE), ADR 0005 sustituido por el ADR 0016.
  • CI: shellcheck, comprobación de variables en el compose, control de referencias a la licencia y pnpm audit.

Variables de entorno nuevas

Todas documentadas en .env.example y en el README:

  • TRUSTED_PROXY_HOPS (por defecto 0; 1 detrás de Caddy/nginx).
  • ORGTABLE_TIMEZONE (por defecto Atlantic/Canary).
  • CRON_SECRET ya existía; ahora install.sh la genera y el compose la pasa al contenedor.

Verificado

  • pnpm lint, pnpm typecheck, 224 tests unitarios y 212 de integración (PostgreSQL real) en verde; pnpm build sin avisos; pnpm audit limpio.
  • Imagen Docker: arranca, aplica migraciones, /api/health ok, cron 401/200 según el secreto, usuario sin privilegios.
  • Navegador (Playwright, escritorio y 375 px, tema claro y oscuro): arrastre con ratón y teclado, modal, comentarios, confirmaciones de borrado, tiempo real entre pestañas, sin errores de consola ni violaciones de CSP.

Pendiente de decidir

  • Canal real de reporte de seguridad (SECURITY.md lleva un TODO).
  • renameBoard/renameList/renameCard/deleteLabel no se usan en la interfaz: conectarlas o borrarlas.
  • Revisar los textos visibles nuevos (confirmaciones, avisos, etiquetas accesibles).
  • Montar tests e2e en el repo (Playwright + axe).
  • Datos antiguos en producción (asignaciones huérfanas, emails en AuditLog, ficheros huérfanos): no se limpian aquí.

Al desplegar

scripts/update.sh (hace copia antes) o docker compose build && docker compose up -d. Añadir TRUSTED_PROXY_HOPS=1 al .env si hay proxy inverso.

QUÉ CAMBIA
- pnpm-lock.yaml generado con `pnpm import` desde package-lock.json (mismas
  versiones resueltas); se borra package-lock.json.
- packageManager fijado a pnpm@11.24.0.
- pnpm-workspace.yaml con allowBuilds: solo @prisma/engines y prisma pueden
  ejecutar scripts de instalación.
- Dockerfile: stage base con Corepack e instalación con --frozen-lockfile.
- CI: pnpm/action-setup, Node 22 y paso de pnpm audit (prod, nivel high).
- Documentación: comandos npm/npx sustituidos por pnpm.

VERIFICADO
- pnpm lint, typecheck, 23 tests unitarios y 20 de integración en verde.
- docker build completo y arranque del contenedor: migrate deploy y
  /api/health responden.

PENDIENTE
- pnpm audit informa de 63 avisos (2 críticos en next); se corrigen en el
  commit siguiente.
QUÉ CAMBIA
- Subidas dentro de la misma versión mayor: next y eslint-config-next
  16.3.6, Prisma 7.10.0, React 19.3.0, zod 4.6.5, pg 8.23.0, jose 6.2.12,
  Tailwind 4.3.3, Vitest 4.1.11 y tipos.
- overrides en pnpm-workspace.yaml para transitivas: brace-expansion,
  browserslist y mysql2.
- auditConfig ignora GHSA-ggr8-5vv4-36mx (deepmerge-ts en @prisma/config):
  solo fusiona la configuración local y el parche exige una mayor.

POR QUÉ
pnpm audit daba 63 avisos, 2 críticos y 30 altos, sobre todo en next.

VERIFICADO
- pnpm audit: 0 avisos (1 ignorado y documentado).
- lint, typecheck, 23 tests unitarios, 20 de integración y build en verde.
QUÉ CAMBIA
- Barra superior: en móvil «Administración» pasa a icono con aria-label,
  el nombre de la instancia se trunca y se reducen los huecos.
- Cabecera del tablero con flex-wrap para que «Miembros» no quede fuera.
- Opción activa del conmutador Tablero/Calendario con tokens del tema
  (antes blanco sobre blanco en modo oscuro).
- El área autenticada usa flex-1: el fondo del tablero llega al final.
- Mes del calendario con first-letter:uppercase («Septiembre de 2026»).

VERIFICADO
Playwright a 375 px: desbordamiento horizontal 0 en /boards, tablero,
/settings, /admin/users, /admin/audit y /search (antes 76 px en todas).
Capturas en modo oscuro y claro a 1440 px.
… teclado

QUÉ CAMBIA
- AddListForm y AddCardForm descartan envíos mientras la acción anterior
  sigue en curso (ref pending + preventDefault en onSubmit).
- DndContext recibe un id estable (useId) para que aria-describedby
  coincida entre servidor y cliente.
- KeyboardSensor con sortableKeyboardCoordinates: las tarjetas y listas
  se pueden mover con el teclado.

POR QUÉ
En el navegador se crearon dos listas iguales con 48 ms de diferencia y la
consola mostraba un error de hidratación de dnd-kit en cada carga.

VERIFICADO
Playwright: tres Enter seguidos crean una sola lista; consola sin errores.
QUÉ CAMBIA
- proxy.ts genera un nonce por petición y la CSP (app/lib/csp.ts): scripts
  propios con nonce y strict-dynamic, sin eval en producción,
  frame-ancestors, object-src, base-uri y form-action cerrados.
- El layout raíz aplica el nonce al script inline del tema.
- next.config.ts: HSTS (max-age=31536000, sin includeSubDomains), CSP
  default-src 'none' para /api y Permissions-Policy sin interest-cohort.
- Tests unitarios de las directivas que no se pueden relajar.

TRAMPAS
- Sin upgrade-insecure-requests: rompería las instalaciones por http://IP.
- style-src-attr 'unsafe-inline' es necesario por los atributos style que
  React pinta en el servidor.
- En desarrollo los <style> van sin nonce (Turbopack y el overlay).

VERIFICADO
Playwright contra el build de producción: cero violaciones de CSP, tema
oscuro aplicado y tiempo real entre pestañas funcionando. 29 tests
unitarios, lint y typecheck en verde.
QUÉ CAMBIA
- Migración 20260924000000_position_collate_c: List, Card, Checklist y
  ChecklistItem.position pasan a TEXT COLLATE "C". Reconstruye
  Card_listId_position_idx; no reescribe valores.
- Comentario en schema.prisma (List.position): Prisma no modela la
  collation y migrate diff no ve deriva.
- ADR 0002 ampliado en «Consecuencias» con el porqué y la consulta para
  detectar claves ya repetidas.
- Test de integración position-collation.int.test.ts.

POR QUÉ
Las claves de fractional indexing solo se ordenan bien byte a byte. En
una BD glibc (imagen Debian de postgres del CI, Ubuntu, servicios
gestionados) en_US.utf8 deja 'Zz' después de 'a0l': la tarjeta
arrastrada al principio reaparecía al final y createCard/createList
generaban claves repetidas al tomar como última una vecina equivocada.

VERIFICADO
- lint, typecheck, test unitarios y de integración (orgtable_l9_test).
- migrate diff contra el esquema: sin diferencias.
- En una BD temporal ICU sin el arreglo los tres tests fallan; con
  COLLATE "C" pasan. En alpine (musl) solo falla la comprobación de
  collation, porque allí el orden ya era byte a byte.

TRAMPAS
- Las instalaciones glibc que ya tengan claves repetidas las conservan;
  reasignarlas es decisión del propietario, con copia previa.
- Toda columna de posición nueva necesita COLLATE "C" a mano.
QUÉ CAMBIA
- ADR 0002 y docs/DECISIONES.md: el fallo por collation lingüística
  también afecta a addChecklist/addChecklistItem y aparece solo con altas
  al final (tras 'aZ' viene 'aa', que en_US.utf8 ordena antes de
  'aB'...'aZ'; desde el elemento 38 se repite 'aa').
- Consulta de detección de claves repetidas ampliada a Checklist
  ("cardId") y ChecklistItem ("checklistId").
- Test de integración: createChecklist y addChecklistItem con 37 claves
  a0...aa no repiten claves (requireBoardAccess mockeado solo en ese
  archivo, porque las acciones exigen sesión).

VERIFICADO
- lint, typecheck, test unitarios e integración (orgtable_l9_test).
- Con Checklist/ChecklistItem.position en COLLATE "en-US-x-icu" el test
  nuevo falla; con COLLATE "C" pasa. BD de pruebas restaurada a "C".

TRAMPAS
- El comentario de la migración ya aplicada no se toca para no cambiar su
  checksum; el porqué completo queda en el ADR 0002.
QUÉ CAMBIA
- next.config.ts: experimental.serverActions.bodySizeLimit y
  proxyClientMaxBodySize a 11 MB.
- docs/security.md: límite documentado como conocido (global y previo a
  la autenticación) y fila de cabeceras actualizada con CSP y HSTS.
- test/next-config.test.ts: límite por encima de MAX_ATTACHMENT_BYTES más
  la sobrecarga multipart, límite del proxy alineado y cabeceras fijas en
  /, /login y /api/v1.

POR QUÉ
Next corta el cuerpo de una Server Action en 1 MB antes de ejecutarla:
los adjuntos (10 MB) y las importaciones (5 MB) de más de 1 MB fallaban.
El proxy guarda solo 10 MB del cuerpo y lo demás llegaba truncado.

VERIFICADO
pnpm lint, typecheck, test y test:integration. Falta la prueba manual
con build + start (subir 3 MB y 10,5 MB), que hace el integrador.
Cubre el matcher de proxy.ts (páginas sí; /api, estáticos y .svg no),
que /login y / con sesión reciben la CSP con el mismo nonce que se
reenvía al layout en x-nonce, y la redirección a /login sin sesión.
QUÉ CAMBIA
- app/layout.tsx: plantilla de título «%s · orgtable» y meta description
  «Tableros Kanban self-hosted, una alternativa a Trello».
- /login («Iniciar sesión») y /setup («Configuración inicial») exportan
  su título.
- Login: el párrafo deja de decir open-source y el pie «Open source ·
  AGPL-3.0» pasa a «Kanban · Self-hosted».
- test/metadata.test.ts: títulos resueltos, description y HTML del login
  sin open source ni AGPL.

POR QUÉ
Todas las rutas compartían el título (WCAG 2.4.2) y la interfaz afirmaba
una licencia AGPL que no es la vigente (LICENSE, propietaria).

VERIFICADO
pnpm lint, typecheck y test. Falta comprobar en el navegador que
document.title cambia al navegar (lo hace el integrador con build).
QUÉ CAMBIA
- app/lib/prisma.ts: buildPoolConfig() con connectionTimeoutMillis 5 s e
  idleTimeoutMillis 30 s; onPoolError registra solo el mensaje del error.
- El PrismaClient se guarda en globalThis también en producción.
- test/integration/prisma-pool.int.test.ts: timeout con el pool agotado y
  mismo cliente entre dos evaluaciones del módulo en producción.

POR QUÉ
Con los valores por defecto de pg, una petición sin conexión libre
esperaba para siempre (Prisma 7 ya no aplica connect_timeout). Turbopack
evalúa el módulo una vez por capa y en producción se abrían dos pools.

VERIFICADO
pnpm lint, typecheck, test y test:integration contra orgtable_l5_test.
El test del singleton falla con la condición anterior. Falta comprobar
tras build + start que pg_stat_activity no duplica conexiones.
QUÉ CAMBIA
- app/lib/prisma.ts: idleTimeoutMillis vuelve a 10 s, el valor por
  defecto de pg-pool (el comentario decía que 30 s era el de pg). El
  comentario de onPoolError explica su motivo real: el adapter ya
  escucha el evento 'error' del pool; el manejador deja el fallo en el
  log, solo con el mensaje.
- test/integration/prisma-pool.int.test.ts: nuevo test que envuelve
  PrismaPg con vi.doMock y comprueba que el cliente exportado se crea
  con buildPoolConfig() y que onPoolError registra solo el mensaje.
- docs/DECISIONES.md: entrada del pool corregida.

POR QUÉ
El test de timeout sobrescribe el config y construye su propio
PrismaPg: seguía en verde si createPrismaClient() volvía a
new PrismaPg({ connectionString }).

VERIFICADO
pnpm lint, typecheck, test y test:integration contra
orgtable_l5_test. El test nuevo falla con el cableado anterior.
QUÉ CAMBIA
- next.config.ts: regla /:file(.*\\.svg) con default-src 'none';
  style-src 'unsafe-inline'; frame-ancestors 'none'.
- test/next-config.test.ts: headersFor resuelve las reglas con
  buildCustomRoute del router de Next (entiende parámetros con regex)
  y comprueba /file.svg e /icons/logo.svg.
- docs/security.md y docs/DECISIONES.md: CSP de los .svg documentada.

POR QUÉ
proxy.ts excluye los .svg de su matcher y next.config.ts solo ponía CSP
a /api: los SVG de public/ se servían sin CSP y, abiertos
directamente, podían ejecutar scripts en el origen de la app.

VERIFICADO
pnpm lint, typecheck, test y test:integration. Los casos .svg fallan
sin la regla. Falta comprobar con build + start que curl -I /file.svg
devuelve la cabecera (lo hace el integrador).

TRAMPAS
file.svg, globe.svg, next.svg, vercel.svg y window.svg de public/ son
restos de la plantilla de Next sin uso en app/; no se borran.
QUÉ CAMBIA
- Nuevo helper puro firstParam() en app/lib/search-params.ts que devuelve
  el primer valor cuando la clave se repite en la URL.
- /search y /board/[id] tipan searchParams como string | string[] y lo
  usan para q y card.

POR QUÉ
Con /search?q=a&q=b, q llegaba como array y q.trim() lanzaba un error
que tumbaba la página.

VERIFICADO
test/search-params.test.ts (string, array, array vacío y undefined),
pnpm lint y pnpm typecheck.
QUÉ CAMBIA
- Nuevo componente cliente DeleteBoardButton: el ✕ pasa a «¿Borrar? Sí /
  No» en línea y solo «Sí» envía deleteBoard. Foco en «No», Escape cancela
  y «Sí» se desactiva mientras se envía.
- /boards solo lo pinta si el usuario es el dueño del tablero.
- Exportar y borrar se ven siempre en pantallas sin hover y con el foco
  dentro de la tarjeta (antes solo con el ratón encima).

POR QUÉ
Un solo clic, o un toque en la esquina en táctil donde el botón era
invisible, borraba el tablero en cascada. A los miembros se les mostraba
un botón que no hacía nada.

VERIFICADO
test/delete-board-button.test.ts (el estado inicial no tiene formulario
ni botón submit), pnpm lint y pnpm typecheck. La interacción del segundo
clic y la vista táctil no se han probado en navegador.
QUÉ CAMBIA
- globals.css: --danger claro 0.55→0.52, --success claro 0.57→0.48 y
  --danger oscuro 0.70→0.71 (DESIGN.md al día).
- Placeholder de Input/Textarea a text-ink-muted (antes /70, ~3,2:1).
- aria-label en el campo de nuevo tablero y en el de importar JSON;
  role="alert" en el error de importación.
- title con el nombre completo de la instancia, que se trunca en móvil.

POR QUÉ
--success daba 3,86:1 sobre el fondo y 3,72:1 en sus avisos; --danger
quedaba en 4,48:1 en sus avisos y 4,15:1 en la insignia de vencida. Los
campos sin etiqueta y el error mudo no llegaban al lector de pantalla.

VERIFICADO
Nuevo test/contrast.test.ts que lee los tokens del CSS y exige 4,5:1 en
los dos temas (falla con el --success anterior). pnpm lint, pnpm
typecheck y pnpm test. No se ha revisado en navegador ni con lector de
pantalla.
QUÉ CAMBIA
- /boards («Tus tableros») y /search («Búsqueda») exportan metadata. La
  consulta de búsqueda no entra en el título.
- /board/[id] añade generateMetadata con el nombre del tablero, leído con
  getBoardTitleForUser (React.cache). Esa función y getBoardWithLists
  comparten el filtro boardAccessWhere (dueño o miembro). Sin acceso, o si
  el tablero no existe, el título es «Tablero».

POR QUÉ
Todas las rutas tenían el mismo <title>. El título del tablero se resuelve
aparte de la página, así que sin la misma guarda filtraría el nombre de un
tablero ajeno.

VERIFICADO
test/integration/board-metadata.int.test.ts (dueño y miembro ven el
nombre, no miembro e id inexistente ven «Tablero»), pnpm lint, pnpm
typecheck, pnpm test y pnpm test:integration.
QUÉ CAMBIA
- DeleteBoardButton guarda una ref al ✕ y un indicador que solo activa
  la cancelación («No» o Escape); un useEffect sobre confirming lo
  consume y devuelve el foco al ✕. «Sí» sigue enviando igual.

POR QUÉ
Al cancelar, el formulario se desmontaba con el foco dentro y este caía
al <body>: con teclado o lector de pantalla se perdía el sitio en la
rejilla de tableros, y el ✕ quedaba con opacity-0 al no haber foco en
la tarjeta.

VERIFICADO
test/delete-board-button-focus.test.ts (arnés local de hooks, sin
jsdom): falla con la versión anterior y comprueba que el montaje
inicial no roba el foco. pnpm lint, pnpm typecheck, pnpm test y pnpm
test:integration. No probado en navegador ni con lector de pantalla.
QUÉ CAMBIA
- El modal es un <dialog> nativo abierto con showModal(): fondo inerte,
  foco inicial dentro, Escape por onCancel, clic fuera cierra y el foco
  vuelve a la tarjeta al cerrar. Nombre accesible con aria-labelledby.
- La hora de los comentarios se pinta solo tras hidratar, en la zona del
  navegador y es-ES, con <time dateTime>. El vencimiento de solo lectura
  se muestra legible (es-ES, zona UTC) en vez del ISO crudo.
- Los toggles de etiqueta e ítem avisan con role="alert" si fallan; los
  errores internos de Next se relanzan con unstable_rethrow.
- Borrar tarjeta pide un segundo clic en línea («¿Borrar la tarjeta?»).
- Botones de borrar visibles con foco de teclado y en pantallas táctiles.
- Etiquetas en los campos (label de la descripción, aria-label en el
  resto), role="alert" en el error de adjuntos, etiquetas no
  seleccionadas sin opacidad (contraste AA) y con aria-pressed, colores
  nombrados en español y «N de M completadas».

POR QUÉ
Hallazgos del lote 11 de la revisión: foco sin gestionar, error de
hidratación al abrir por ?card=, contraste ~2,2:1, borrado sin
confirmación y fallos silenciosos.

VERIFICADO
pnpm lint, typecheck, test (nuevo test/card-modal-helpers.test.ts) y
test:integration en verde. Las clases nuevas de Tailwind se generan.
El comportamiento del diálogo en el navegador no se ha probado a mano.
QUÉ CAMBIA
- El aviso de fallo al cambiar una etiqueta sale bajo la sección
  Etiquetas; el de los ítems, bajo los ítems de cada checklist (estado
  propio en ChecklistBlock). Antes ambos salían arriba del modal.
- La etiqueta no seleccionada vuelve al relleno de color: solo se quita
  opacity-55. El estado se distingue por el ✓, el anillo y aria-pressed.
- El fundido del fondo usa backdrop:motion-safe:, porque la regla de
  prefers-reduced-motion de globals.css no cubre ::backdrop.
- Se quita el test «usa el locale es-ES» (con «es» Node da el mismo
  texto) y el comentario ya no atribuye a estos tests la hidratación.

POR QUÉ
Revisión del lote 11: con el modal desplazado el aviso de un ítem no se
veía, el nuevo estilo de etiqueta era un cambio de diseño no pedido y el
fondo animaba aunque el usuario pidiera movimiento reducido.

VERIFICADO
pnpm lint, typecheck, test e integración en verde. Compilado el CSS con
@tailwindcss/postcss: la clase genera ::backdrop dentro de
@media (prefers-reduced-motion: no-preference). La colocación de los
avisos no tiene test automático (no hay jsdom).
QUÉ CAMBIA
- notify() devuelve Promise<boolean>: true en cuanto la fila se guarda (aunque
  falle después la publicación en el bus, que va en su propio try) y false si
  la inserción falla.
- El catch vacío pasa a registrar con console.error el tipo, los ids y el
  código de error, nunca el título ni el cuerpo.

POR QUÉ
El fallo se tragaba en silencio y el cron de recordatorios contaba como
creado un aviso que no existía.

VERIFICADO
test/integration/notifications.int.test.ts: con un userId inexistente
devuelve false, no crea filas y el log no contiene el texto.
QUÉ CAMBIA
- Nuevo removeMemberFromBoard (app/lib/services/members.ts): borra la
  membresía y las asignaciones del usuario en ese tablero en una transacción.
- removeBoardMember y updateBoardMemberRole no auditan ni notifican si no
  cambió ninguna fila (usuario ajeno, expulsión repetida o mismo rol).
- addComment solo avisa a asignados que siguen siendo miembros, lo que
  neutraliza las asignaciones huérfanas que ya existen.
- Las tres acciones de miembros usan revalidateBoard (revalida y publica en
  el bus de tiempo real) en vez de revalidatePath.
- addBoardMember devuelve values con lo enviado cuando hay error.

POR QUÉ
Un miembro expulsado seguía asignado y recibía el título de las tarjetas y el
texto de los comentarios. Con un userId ajeno se creaban notificaciones y
auditorías de cambios que no habían ocurrido. Los demás miembros no veían en
vivo los cambios de miembros.

VERIFICADO
test/integration/members.int.test.ts (servicio, conteos de Notification y
AuditLog, eventos del bus, values del formulario) y un caso nuevo en
collaboration.int.test.ts con una asignación huérfana. Las acciones se
prueban con vi.mock de requireBoardAccess local a cada archivo.

TRAMPAS
Las asignaciones huérfanas previas siguen en la BD; limpiarlas lo decide el
propietario.
QUÉ CAMBIA
- El inicio del día se calcula en UTC y la fecha del aviso se formatea con
  es-ES y timeZone UTC: dueDate es una fecha de calendario a medianoche UTC.
- Las vencidas solo se avisan si vencieron en los últimos 7 días, y una sola
  vez a partir del día UTC siguiente al vencimiento. «Vence pronto» sigue
  siendo como mucho un aviso diario.
- Solo se avisa a asignados que siguen siendo miembros del tablero.
- created solo suma los avisos que notify() guardó.
- Tests nuevos del endpoint cron (503, 401 con secretos de otra longitud o
  esquema, 200 idempotente) sin tocar route.ts.
- El test de recordatorios usa un único now por test en vez de uno calculado
  al importar, para no romperse si cruza la medianoche UTC.

POR QUÉ
Una tarjeta vencida avisaba cada día para siempre y la pasada crecía sin
tope. Con el servidor en una zona negativa, una tarjeta que vence hoy salía
como vencida y con el día anterior en el texto.

VERIFICADO
reminders.int.test.ts (TZ=America/New_York, vencida avisada una sola vez,
ventana de 7 días, asignado expulsado) y cron-reminders.int.test.ts. Los
casos nuevos de recordatorios fallan con el código anterior.
QUÉ CAMBIA
- El panel pasa a un <dialog> abierto con showModal(): foco inicial dentro,
  resto de la página inerte, Escape y clic en el fondo llaman a onClose, y al
  desmontar se devuelve el foco a quien lo tenía. Se quita el listener de
  Escape en window.
- aria-labelledby y aria-describedby con useId hacia el título y la
  descripción.
- El error se anuncia con role="alert" y el mensaje de éxito dentro de una
  región role="status" siempre montada.
- Email y rol usan defaultValue con los values que devuelve la acción al
  fallar, así el email no se vacía.
- Errata en un comentario: «revuelve» por «resuelve».

POR QUÉ
El div role="dialog" no gestionaba el foco ni tenía nombre accesible, y los
avisos del formulario no llegaban a los lectores de pantalla.

VERIFICADO
lint y typecheck. El comportamiento de values está cubierto en
members.int.test.ts.

TRAMPAS
Falta la verificación manual con teclado y lector de pantalla (Tab en bucle,
Escape, foco devuelto); no hay infraestructura e2e.
QUÉ CAMBIA
- ADR 0015: nota «Modificado el 2026-09-24» que remite a docs/reminders.md
  y docs/DECISIONES.md.
- docs/roadmap.md y CLAUDE.md: «idempotente por día» se sustituye por un
  enlace a docs/reminders.md.

POR QUÉ
Seguían describiendo la dedup diaria antigua, que contradice la política
actual (día UTC, un único aviso de vencida, ventana de 7 días). La política
queda documentada en un solo sitio.
QUÉ CAMBIA
- Nuevo app/lib/realtime/sse.ts (createSseResponse) que comparten los
  streams del tablero y de notificaciones: limpieza idempotente
  (latido, suscripción al bus y cierre) desde el abort, cancel(), un
  enqueue fallido, la revalidación y la vida máxima.
- Si la petición llega ya abortada, 204 sin suscribirse.
- En cada latido de 25 s se comprueba que la sesión no está revocada
  ni caducada y, en el tablero, que se sigue siendo miembro; si no, o
  si la BD falla, se cierra el stream.
- Vida máxima de 15 min para forzar la reautorización con la cookie.

POR QUÉ
Un abort durante los await de autorización o un enqueue que lanzaba
dejaba el intervalo y el listener del bus vivos para siempre. Y una
sesión revocada o una persona expulsada seguían recibiendo eventos,
en contra del ADR 0008.

VERIFICADO
test/integration/realtime.int.test.ts: 401/403, eventos recibidos,
abort, petición ya abortada, cancel del body, revocación, expulsión y
vida máxima; tras cerrar no quedan listeners ni temporizadores.
lint, typecheck, tests unitarios y de integración en verde.
QUÉ CAMBIA
- Si LISTEN falla o la conexión da error o se cierra, el cliente pg
  se cierra y se registra con console.error (sin DATABASE_URL).
- La promesa de arranque vive en globalThis y publishCrossInstance la
  espera: ya no se pierden eventos mientras el puente arranca.
- Reconexión con backoff exponencial de 1 a 30 s, un único
  temporizador y solo si quedan suscriptores.
- parseBridgePayload valida el NOTIFY con zod; lo inválido se descarta
  con console.warn indicando solo la longitud.
- Los .catch de publicación registran kind e id en vez de callar.

POR QUÉ
Con el puente activado (ORGTABLE_REALTIME_PG_NOTIFY=1), un fallo dejaba
la conexión abierta y cada SSE abría otra hasta agotar el pool, sin
dejar rastro. Un payload sin id se reemitía en "board:undefined".

VERIFICADO
test/realtime-bus.test.ts con pg simulado: end() y console.error al
fallar LISTEN, backoff 1 s / 2 s, sin reintentos sin suscriptores,
reconexión tras error, publicación durante el arranque y descarte de
payloads inválidos. No probado contra una caída real de Postgres.
QUÉ
- setup-wizard.md sustituye el fragmento antiguo de la transacción (lanzaba
  ALREADY_SETUP y no tenía lock) por los pasos de completeInstanceSetup:
  pg_advisory_xact_lock, recomprobación y SetupOutcome sin lanzar.
- Añade el corte temprano con isSetupComplete() en la acción y renumera los
  pasos.
- La «triple defensa» deja de presentar la recomprobación como suficiente:
  solo protege porque el lock serializa los envíos.

POR QUÉ
docs/api/server-actions.md ya lo contaba así y los dos documentos se
contradecían; quien siguiera setup-wizard.md podía quitar el lock y
reintroducir la carrera del doble administrador. El porqué se enlaza a la
entrada de docs/DECISIONES.md en vez de repetirlo.

VERIFICADO
Contrastado con app/lib/services/setup.ts y app/lib/actions/setup.ts.
pnpm lint, pnpm typecheck y pnpm test pasan.
QUÉ
- El DragOverlay del calendario se pinta con createPortal a document.body,
  montado solo en el navegador (useSyncExternalStore) y siempre, para que
  termine la animación de soltar.

POR QUÉ
- El panel del calendario lleva backdrop-blur-sm. backdrop-filter lo
  convierte en el bloque contenedor de los descendientes fixed, así que la
  copia (fixed, con coordenadas de la ventana) salía desplazada por la
  cabecera, volvía a un punto equivocado al soltar y la recortaba el
  overflow-y-auto.

VERIFICADO
- pnpm lint, pnpm typecheck y pnpm test en verde.
- Test nuevo test/calendar-view.test.ts: el render de servidor no toca
  document (falla con ReferenceError si se quita la guarda).
- Sin comprobar a mano en el navegador (con ratón y con teclado).
QUÉ
- La cabecera del calendario pasa a flex-wrap y el aviso role="alert" usa
  order-last basis-full, igual que el aviso de movimiento del tablero.

POR QUÉ
- Entre el título del mes y la navegación, a 375 px al aviso le quedaban
  unos 10 px: se partía palabra a palabra, se salía sobre los botones y la
  cabecera crecía unos 150 px.

VERIFICADO
- pnpm lint, pnpm typecheck y pnpm test en verde.
- Sin comprobar a mano a 375 px (no hay DOM en los tests).
QUÉ
- Si la pastilla se puede arrastrar, su onKeyUp cancela Espacio
  (preventSpaceActivation). Sin permiso de edición no cambia nada.

POR QUÉ
- dnd-kit solo cancela el keydown de Espacio al coger y al soltar, pero un
  <button> nativo se activa en el keyup (Firefox lo hace aunque el keydown
  esté cancelado): coger o soltar la pastilla podía abrir además el modal.

VERIFICADO
- pnpm lint, pnpm typecheck y pnpm test en verde.
- Tests nuevos en test/calendar-view.test.ts: Espacio se cancela; Intro y
  las flechas no.
- Sin comprobar a mano en Firefox.
QUÉ
- El aviso «No se pudo cambiar la fecha…» se describe en su propia fila,
  bajo el mes y la navegación, y no «junto al mes».
- Se explica por qué la copia del DragOverlay se pinta en un portal a
  document.body: el panel lleva backdrop-blur y el backdrop-filter crea un
  bloque contenedor que desplazaría la copia fixed.

POR QUÉ
Los commits 118c681 y 2ca0dc7 cambiaron ese comportamiento sin actualizar
docs/calendar.md, donde se documenta.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test pasan. Solo cambia documentación.
QUÉ
El <select name="role"> del formulario de invitar lleva ahora una key con
el rol devuelto por la acción, y ese rol solo se reutiliza si está en
ASSIGNABLE_BOARD_ROLES (si no, vuelve a Editor).

POR QUÉ
React restablece el formulario tras la acción, pero no aplica el nuevo
defaultValue a un <select> ya montado. Tras un error («no existe ninguna
cuenta…») el rol volvía a Editor sin avisar y, al corregir el email, la
persona entraba con permisos de escritura aunque se hubiera elegido
Lector. Es el mismo patrón que create-user-form.tsx y create-token-form.tsx.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. test/members-panel.test.ts
comprueba en el HTML la opción marcada tras un error y el retorno a
Editor con un rol no asignable. El remontaje por key no se ve sin DOM
(el proyecto no tiene jsdom): queda sin test automático.
QUÉ
El aviso role="alert" del formulario de invitar se retira mientras se
envía (state.error && !pending), como en login-form.tsx y setup-form.tsx.

POR QUÉ
Con el nodo siempre montado, un segundo error con el mismo texto (p. ej.
invitar dos veces a alguien que ya es miembro) no cambiaba nada en el DOM
y el lector de pantalla no lo anunciaba, así que no se sabía si el segundo
intento había fallado.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. test/members-panel.test.ts
comprueba que el aviso está con el error y desaparece mientras pending.
QUÉ
El ::backdrop del diálogo de miembros usa
backdrop:motion-safe:animate-[overlay-in_150ms_ease-out], igual que
card-modal.tsx.

POR QUÉ
La regla prefers-reduced-motion de globals.css no cubre ::backdrop, así
que el fundido del fondo se veía aunque el sistema pidiera reducir el
movimiento.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. test/members-panel.test.ts
comprueba la clase del diálogo en el HTML.
QUÉ
Entrada nueva sobre el rol al invitar tras un error (key validada contra
ASSIGNABLE_BOARD_ROLES), el aviso retirado mientras se envía y el fondo
con motion-safe. Se corrigen las dos líneas de la entrada «Panel de
miembros con <dialog> nativo» que ya no describían el código.

POR QUÉ
Los tres commits anteriores del panel no registraron sus decisiones y la
entrada vigente contradecía el código (defaultValue sin key, aviso
siempre montado).
QUÉ
El aviso role="alert" de crear usuario, crear token e importar tablero se
retira mientras se envía (state.error && !pending), como en login-form.tsx
y members-panel.tsx. En crear usuario, aria-describedby del email solo
apunta al aviso cuando está visible.

POR QUÉ
Con el nodo siempre montado, un segundo error con el mismo texto (crear
dos veces un usuario con un email existente, importar dos veces el mismo
JSON inválido) no cambiaba el DOM y el lector de pantalla no lo
anunciaba. Completa el hallazgo 14, que solo se había aplicado al panel
de miembros.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. test/form-error-reannounce.test.ts
falla con el código anterior (4 de 4) y pasa con el nuevo.
QUÉ
- Al pulsar «No» en la confirmación de borrar lista, el foco vuelve al ✕
  de esa lista en vez de caer en <body>.
- Escape dentro de la confirmación la cancela y no se propaga a otros
  manejadores del tablero.

POR QUÉ
El formulario de confirmación se desmontaba con el foco dentro y quien
usaba teclado perdía su sitio en el tablero. Se aplica el mismo patrón que
DeleteBoardButton: ref al ✕ y un efecto que le devuelve el foco solo tras
cancelar, para que al montar el tablero ningún ✕ robe el foco.

VERIFICADO
test/list-column-delete.test.ts (arnés de hooks sin DOM, como el de
delete-board-button): 3 de sus 4 casos fallan sin el arreglo. lint,
typecheck y pnpm test en verde.
QUÉ
El «Sí» de la confirmación de borrar lista anuncia «Sí, borrar la lista X
y todas sus tarjetas» en vez de «y sus N tarjetas».

POR QUÉ
Con filtros activos BoardView pasa a ListColumn solo las tarjetas
visibles: una lista con 12 tarjetas y una visible anunciaba «y sus 1
tarjetas» (cifra falsa y plural erróneo), y al confirmar se borraban las
12 en cascada. Se descarta pasar el total real desde BoardView (decisión
en docs/DECISIONES.md).

VERIFICADO
Nuevo caso en test/list-column-delete.test.ts, que falla sin el arreglo.
lint, typecheck y pnpm test en verde.
QUÉ
- keepEventSourceOpen cambia onClosed por probe + onAccessDenied. Al quedar
  CLOSED, probeStatus hace un GET al mismo endpoint que se aborta al llegar
  las cabeceras, y solo con 401/403 se refresca para redirigir.
- Ante 5xx, error de red o fallo de la comprobación solo se reintenta con
  backoff; al reabrirse, onReconnect resincroniza.

POR QUÉ
En Next 16 un router.refresh() cuya petición RSC recibe una respuesta no OK
hace una navegación completa. Con el 502 del proxy durante update.sh, o un
500 por un fallo pasajero de la BD, cada pestaña acababa en la página de
error y se perdía lo no guardado.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. Tests nuevos: 5xx y error de red no
avisan, 401/403 sí, desmontaje durante la comprobación, y probeStatus
contra un servidor HTTP real (estado sin esperar al cuerpo del stream).

PENDIENTE
docs/realtime.md aún describe el refresh al quedar CLOSED.
QUÉ
scheduleRefresh (app/lib/realtime/reconnect.ts) es un debounce de 200 ms a
nivel de módulo que comparten BoardRealtime y NotificationsBell para
eventos, reconexiones y acceso denegado. Sustituye al temporizador local
de cada componente. No se cancela al desmontar para no anular el refresh
que pidió el otro componente.

POR QUÉ
router.refresh() repinta todo el árbol, layout incluido. En /board/[id]
los dos streams caen y vuelven a la vez (vida máxima de 15 min, reinicio,
suspensión), así que cada reconexión hacía dos renders completos del
tablero en lugar de uno.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. Test nuevo: dos peticiones dentro
de la ventana dan un solo refresh y una fuera de ella da otro.
QUÉ
- keepEventSourceOpen pasa una AbortSignal a probe y la hace competir con
  un temporizador de PROBE_TIMEOUT_MS (10 s). Al vencer, aborta la señal y
  reintenta con backoff como ante un error de red, sin onAccessDenied.
- El desmontaje aborta la comprobación en curso.
- probeStatus(url, signal) devuelve null si la señal se aborta.

POR QUÉ
El reintento solo se programaba al terminar probe(), y fetch no tiene
timeout en el navegador. Si el proxy retenía la conexión o el servidor no
respondía (p. ej. esperando al pool de la BD), la fuente no se recreaba
nunca y el tiempo real se perdía hasta recargar la pestaña.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test. Tests nuevos: probe que no resuelve
nunca (tras el plazo se recrea la fuente sin avisar), aborto al desmontar,
y probeStatus con la señal abortada contra un servidor HTTP real. Los
cuatro fallan con el código anterior.
QUÉ
docs/realtime.md describía que, al quedar CLOSED, el cliente hacía
router.refresh(). Ahora explica probeStatus, que solo 401/403 llaman a
onAccessDenied (refresh agrupado con scheduleRefresh), que 5xx, errores de
red y fallos de la comprobación solo reintentan con backoff de 3 s a 30 s,
el plazo de 10 s de la comprobación y el debounce compartido con la
campana. Se quita el «pendiente» de docs/DECISIONES.md.

POR QUÉ
La documentación de referencia del tiempo real contradecía el código desde
el cambio que dejó de refrescar a ciegas: en Next 16 un refresh con
respuesta no OK hace una navegación completa a la página de error.

VERIFICADO
Contrastado con app/lib/realtime/reconnect.ts, board-realtime.tsx y
notifications-bell.tsx.
QUÉ
- El test de formatDueDay fija TZ=America/New_York con vi.stubEnv, llama a
  vi.resetModules() e importa board-filters de forma dinámica.
- afterEach usa vi.unstubAllEnvs() en vez de reasignar process.env.TZ.

POR QUÉ
- El formateador de board-filters es de módulo y fija la zona al importarse.
  Con el import estático se quedaba con la zona de la máquina (UTC en CI) y
  el test pasaba aunque se quitara `timeZone: "UTC"`.
- Reasignar TZ = undefined guardaba el texto "undefined", que Node trata como
  UTC para el resto del archivo.

VERIFICADO
- Quitando temporalmente `timeZone: "UTC"` el test falla con «23 sept» (TZ=UTC).
- Un test temporal posterior confirma que TZ ya no queda definida.
QUÉ
- El test «sin cuenta tarda del mismo orden que con cuenta» pasa a espiar
  bcrypt.compare y comprueba que verifyPasswordOrDummy(…, null) compara una
  vez contra DUMMY_PASSWORD_HASH.

POR QUÉ
- Medía una sola llamada de cada tipo y exigía dummy > real * 0.5: una pausa
  del GC o un runner de CI cargado lo hacía fallar sin cambios de código.
- La propiedad se cubre de forma determinista: el espía asegura que se hace el
  trabajo de bcrypt y el primer test (60 caracteres, mismo coste) que ese
  trabajo cuesta lo mismo que con un hash real.

VERIFICADO
- Haciendo que verifyPasswordOrDummy devuelva false sin comparar cuando no hay
  cuenta, el test falla («called 0 times»).
- labels: se borra el envoltorio signIn(), que solo llamaba a loginAs(), y su
  comentario sobre una cookie que ya gestiona el stub compartido.
- Se borran los vi.unstubAllEnvs() de siete archivos que ya no usan
  vi.stubEnv (labels, positions, cards-due-date, attachments-delete,
  attachments-download, notifications-read y realtime), y los imports que
  quedaban sin uso.

Verificado: eslint y los 7 archivos de integración en verde (61 tests).
QUÉ
- board-metadata, members, collaboration, tokens, admin-users y
  position-collation dejan de hacer vi.mock("@/app/lib/dal") y usan
  loginAs() con usuarios sembrados. admin-users solo sustituye redirect().
- position-collation ya no devuelve el usuario inexistente "test-user": las
  acciones de checklist se llaman con la sesión del propietario.
- members añade un test: un EDITOR o un VIEWER no puede invitar, cambiar
  roles ni expulsar, y no queda notificación ni auditoría.
- docs/DECISIONES.md: la entrada del mecanismo de sesión deja de decir
  «todos» sin serlo y recoge el cambio.

POR QUÉ
- Los comentarios de esos mocks decían que el stub de next/headers no tiene
  cookies, lo que es falso desde d1e0cd0.
- Con el DAL sustituido, actAs() devolvía el rol que pedía el test y la
  guarda de las acciones de miembros no se probaba.

VERIFICADO
- pnpm lint, pnpm typecheck y pnpm test:integration (32 archivos, 210 tests).
- Quitando temporalmente la guarda canManageBoard de actions/members.ts, los
  dos tests nuevos fallan.
QUÉ
- «Integración continua» incluye los tres pasos que van primero en ci.yml
  (shellcheck, variables del compose y referencias de licencia), con el
  comando para reproducirlos en local, y renumera la lista a 12 pasos.
- Las tablas por archivo, a las que les faltaban 34 de 58 tests, se cambian
  por tablas por área que nombran todos los archivos actuales y remiten a
  `ls test test/integration` como inventario.
- La descripción de los unitarios deja de prometer «lógica pura»:
  ops-scripts lanza bash con un docker falso, storage escribe en un
  directorio temporal y los de interfaz renderizan con react-dom/server.

POR QUÉ
- Un fallo en los pasos nuevos del CI no estaba explicado en la
  documentación, y las tablas por archivo se desfasaban con cada lote.

VERIFICADO
- Un script compara las tablas con `test/*.test.ts` y
  `test/integration/*.int.test.ts`: no falta ni sobra ningún archivo.
- El comando de `docker compose config` documentado muestra las tres
  variables. shellcheck no está instalado en esta máquina: no se ejecutó.
QUÉ
- vitest.config.ts: el comentario ya no dice que los unitarios cubren
  solo lógica pura; dice que no usan base de datos ni red y remite a
  docs/testing.md.
- CLAUDE.md: «Unitarios (lógica pura, sin BD)» pasa a «(sin BD ni red)».

POR QUÉ
docs/testing.md ya explica que no todos son lógica pura (ops-scripts
lanza bash con un docker falso, storage escribe en un directorio
temporal). Los otros dos sitios lo contradecían; ahora el detalle vive
solo en docs/testing.md.

VERIFICADO
pnpm lint, pnpm typecheck y pnpm test (26 archivos, 177 tests) en verde.
QUÉ
- pickClientIp quita el puerto de "a.b.c.d:puerto" y "[v6]:puerto" antes
  de validar con isIP (sin cortar IPv6 sin corchetes).
- Nueva resolveClientIp (pura) que devuelve la IP o el motivo de no tenerla.
- pickClientIp avisa por console.warn una vez por motivo y proceso cuando
  TRUSTED_PROXY_HOPS > 0 y no hay IP, sin incluir la cabecera ni IPs.

POR QUÉ
Con proxies que añaden el puerto (Azure Application Gateway, IIS ARR) el
valor no pasaba isIP: el login se quedaba sin límite por IP y la auditoría
sin IP, sin ningún aviso.

VERIFICADO
pnpm lint, pnpm typecheck, pnpm test (tests nuevos de puerto, motivos y
aviso único) y pnpm test:integration.

PENDIENTE
Documentar el aviso en la tabla de TRUSTED_PROXY_HOPS de docs/operations.md.
…a ui

QUÉ
- updateCard devuelve un estado para useActionState: {} si guarda, o el
  error con la descripción y la fecha enviadas para rehidratar el formulario.
- El modal muestra el error, desactiva el botón mientras guarda y limita el
  textarea con maxLength={CARD_DESCRIPTION_MAX}.
- El servidor normaliza \r\n a \n antes de validar, porque maxLength cuenta
  cada salto de línea como un carácter.
- Una descripción igual a la guardada no se valida ni se reescribe: solo se
  actualiza la fecha.

POR QUÉ
Con más de 5000 caracteres la acción volvía sin guardar ni avisar y el
reseteo del formulario borraba lo escrito. Las tarjetas con descripciones
anteriores al límite ya no podían cambiar el vencimiento desde el modal.

VERIFICADO
pnpm lint, pnpm typecheck, pnpm test y pnpm test:integration (casos nuevos:
error con 5001 caracteres, CRLF que mide 5000 en la UI, descripción heredada
larga). El comportamiento del formulario en el navegador no se ha probado
(no hay e2e).

PENDIENTE
- docs/card-detail.md sigue describiendo updateCard(formData).
- El modal importa validators.ts y arrastra zod al bundle del cliente.
QUÉ
- El aviso [client-ip] decía «Solo se avisa una vez por proceso», pero sale
  una vez por motivo y proceso (hasta tres textos distintos). Ahora lo dice.
- docs/operations.md: la fila N > 0 de TRUSTED_PROXY_HOPS explica que se
  acepta IP:puerto y [v6]:puerto, qué pasa si no hay IP válida (sin límite
  por IP, AuditLog.ip vacío) y el aviso del log.
- docs/testing.md: la fila de test/client-ip.test.ts incluye puerto,
  motivos y aviso único.
- docs/DECISIONES.md: quita el «queda pendiente» ya resuelto.

POR QUÉ
Con el texto anterior el operador podía tomar un segundo aviso con otro
motivo por un fallo nuevo, y sin la documentación no sabía qué significaba.

VERIFICADO
pnpm lint, pnpm typecheck, pnpm test (el test del aviso comprueba el texto)
y pnpm test:integration.
QUÉ
- CARD_DESCRIPTION_MAX pasa a app/lib/limits.ts, sin importaciones. El
  modal de tarjeta lo importa de ahí; validators.ts lo importa y lo
  reexporta, así que esquemas y acciones no cambian.
- Nuevo test/client-imports.test.ts: falla si un componente "use client"
  importa zod o validators.ts, o si limits.ts gana importaciones.
- docs/card-detail.md: la sección de descripción y vencimiento describe
  useActionState, updateCard(prev, formData): Promise<CardDetailsState>,
  el maxLength, la normalización de \r\n, la regla de la descripción sin
  cambios y los mensajes de error. Ya no dice que una fecha inválida se
  guarda como null.
- docs/testing.md y docs/DECISIONES.md al día.

POR QUÉ
El modal era el único componente de cliente que importaba validators.ts:
metía zod en el bundle del tablero solo por un número, y validators.ts usa
Buffer, que no existe en el navegador. La documentación seguía con la firma
anterior de updateCard.

VERIFICADO
pnpm lint, pnpm typecheck, pnpm test (el test nuevo falla con la
importación anterior) y pnpm test:integration. pnpm build no se ha
ejecutado en este cambio.
QUÉ CAMBIA
boardKeyboardCoordinates resuelve también izquierda/derecha de una tarjeta:
salta a la columna vecina (tomando como referencia el borde de su propia
columna) y, dentro de ella, a la tarjeta más cercana en vertical o a la
columna si está vacía.

POR QUÉ
Con sortableKeyboardCoordinates la flecha izquierda elegía la columna
propia de la tarjeta, cuyo borde queda 8 px antes, y la tarjeta volvía a
su sitio sin moverse.

VERIFICADO
Tests nuevos en test/board-collision.test.ts (fallan con el código
anterior); 224 tests unitarios, lint y typecheck en verde. Playwright:
la tarjeta pasa a una columna vacía a la izquierda, vuelve a la derecha
y el cambio queda en la base de datos.
@Aimarmndz
Aimarmndz merged commit c4a19a2 into main Sep 25, 2026
1 check passed
@Aimarmndz
Aimarmndz deleted the chore/adaptacion-estandar branch September 25, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant