Skip to content

fix/barra tablero - #76

Merged
JoseSerpaCode merged 11 commits into
mainfrom
fix/barra-tablero
Aug 17, 2026
Merged

fix/barra tablero#76
JoseSerpaCode merged 11 commits into
mainfrom
fix/barra-tablero

Conversation

@JoseSerpaCode

Copy link
Copy Markdown
Owner
  • fix(tablero): la barra superior se amontonaba
  • refactor(i18n): partir ui.ts por dominio
  • fix(i18n): las claves que se arman sobre la marcha también existen
  • test: comprobar los contratos entre el navegador y la API
  • test(movil): proyecto de Playwright para teléfono
  • build: linter con Biome, en CI
  • build: quitar los 17 imports sin usar que quedaban
  • fix(fechas y tipos): una sola forma de leer la hora, y el tipo se ve
  • perf: dejar de gastar el cupo de salida en lo mismo dos veces
  • fix(burndown): enchufar las fotos diarias que llevaban doce versiones sueltas
  • feat(paleta): conectar el Cmd+K, que era una carcasa

JoseSerpaCode and others added 11 commits August 17, 2026 09:59
Tres cosas se juntaban en la misma fila:

  - El bloque de tiempo ponía la etiqueta y la cifra una al lado de la otra en
    14rem. «0h registradas — sin estimar» no cabía y se partía a mitad de frase,
    encima del rótulo. Ahora va etiqueta arriba, cifra abajo.
  - El título ocupaba dos líneas con un nombre de espacio largo y empujaba los
    controles fuera. Se recorta, con el nombre completo en el `title`; sigue
    entero en la barra lateral y en la pestaña.
  - Con el selector de orden que entró en la 1.24.0, a 1600px el botón «+ Issue»
    caía solo a una segunda fila. Con menos ancho de título y de tiempo vuelve a
    caber; a 1280 envuelve limpio en dos filas.

Un primer intento usó la variante `2xl:` para el ancho grande y no cambió nada,
porque ese punto de ruptura es 1536px y a 1600 seguía aplicando. Se vio volviendo
a capturar, no dándolo por bueno.

De paso, los botones de sprint decían `Start` / `Complete` / `Reopen` escritos a
mano en inglés. Ya pasan por traducción.

Verificado con capturas del navegador a 1280, 1600 y 1920.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Era un solo fichero de 2.048 líneas con 939 claves por idioma, y el segundo que
más cambiaba del repositorio: 38 veces en los últimos 400 commits. Cualquier
trabajo en paralelo acaba chocando ahí, porque dos ramas que añaden una clave
tocan la misma zona del mismo fichero.

Queda repartido en nueve dominios (common, auth, landing, board, workspace,
user, files, db, kb) por idioma, y `ui.ts` pasa a ser solo el ensamblador. Nada
de lo que se ve desde fuera cambia: `ui`, `useTranslations`, `languages` y el
tipo de las claves siguen igual, así que ningún componente se entera. El tipo de
`t()` sigue siendo la unión literal de las 939 claves porque cada fichero de
dominio lleva su `as const`.

El corte fue mecánico: se volcó el diccionario a JSON, se repartió por prefijo y
se comprobó clave a clave que las 939 de cada idioma siguen con el mismo valor.

Las dos pruebas que leían `ui.ts` partiéndolo por la cadena `  es: {` ya no
podían encontrar nada. Una fallaba —eso se ve— pero la otra **pasaba comparando
dos conjuntos vacíos**, que es peor, porque además da confianza. Ahora leen los
ficheros de dominio y llevan tres guardas nuevas:

  - exigen encontrar más de 500 claves, así que un futuro cambio de estructura
    vuelve a fallar en vez de aprobar en vacío;
  - comprueban que los dos idiomas tengan los mismos ficheros de dominio;
  - comprueban que cada clave viva en el mismo dominio en ambos, porque una
    clave en `board.ts` en inglés y en `common.ts` en español solo se encuentra
    a medias.

Las tres verificadas provocando el fallo: quitando una clave, moviéndola de
dominio y vaciando un fichero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Faltaban dos, y las dos se veían en pantalla:

  - **`status.review`.** La base guarda 'review' y la clave se llamaba
    'status.in_review'. `TaskTable` arma la clave desde el dato, así que pedía
    una que no existe y el hub y el panel del espacio enseñaban el texto
    `status.review` a la cara del usuario. Se renombra la clave para que case
    con el dato en vez de añadir un alias: así el caso especial desaparece.
  - **`type.epic`.** Epic es uno de los cuatro tipos de fábrica —la migración 38
    lo siembra en cada espacio— así que sale en el desplegable y en la tarjeta.
    Sin clave, se pintaba `type.epic`.

Ninguna de las dos la veía nada. El typecheck no, porque el argumento de una
plantilla es `string` y no una de las 939 claves literales. La prueba de paridad
tampoco, porque compara los dos idiomas **entre sí** y faltaban en los dos. Y
`useTranslations` devuelve la clave cuando no la encuentra: como una clave es
una cadena no vacía, el `|| respaldo` que venía detrás nunca llegaba a entrar.

Se añaden tres comprobaciones que enumeran los valores posibles **desde donde
viven de verdad** —las columnas de KanbanBoard, los tipos de fábrica de
issueTypes.ts, la lista de códigos de settings.astro— en vez de una lista
copiada en el test. Si mañana alguien añade una columna y se olvida de su
traducción, esto falla.

Y una prueba de navegador sobre el síntoma real: una tarea en revisión no puede
enseñar `status.review` en la tabla. Verificada contra el fallo: reintroducido,
falla y muestra el texto crudo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Es el fallo más caro del proyecto porque no lo ve nada: el typecheck no cruza
los dos lados —el cuerpo viaja como `JSON.stringify` de un objeto suelto—, el
endpoint responde igual, y el campo llega `undefined`. Ha pasado dos veces:
`targetSprintId` contra `target_sprint_id` al cerrar un sprint, y `workspaceId`
contra `workspace_id` al crear un ticket.

La primera versión de esta prueba pasaba en verde contra los dos fallos. Se
descubrió reintroduciéndolos a propósito, que es la única forma de saber si una
prueba prueba algo. Las dos causas eran distintas y ninguna era obvia:

  - El cuerpo del cierre de sprint no es un literal: se arma en una función y se
    esparce con `...cuerpo` en el `fetch`, muy lejos. Ahora, al ver un spread,
    se buscan las asignaciones de esa variable en todo el fichero.
  - `assign.ts` desestructura `const { label_id: labelId }`, así que la palabra
    `labelId` **sí** aparece en el fichero aunque la clave que acepta sea
    `label_id`. Buscar el nombre suelto daba verde. Ahora, cuando el endpoint
    desestructura, esa lista es la verdad; donde no —porque reenvía el cuerpo a
    un servicio— se cae a la búsqueda por nombre, laxa pero sin inventar avisos.

Otros dos ajustes salieron de avisos falsos, que en una prueba así son caros
porque enseñan a ignorarla: la ventana de contexto se cortaba con la llamada de
al lado y traía el cuerpo del `fetch` anterior; y `action_payload:
JSON.stringify({ url })` colaba `url` como clave de primer nivel hasta que se
balancearon las llaves.

Encontró una deriva real y se arregla: el tablero mandaba `workspaceId` a
`/api/sprints/[id]`, que nunca lo lee —deriva el espacio del propio sprint—.
Peso muerto que además tapaba el hueco donde se escondía el fallo original.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`playwright.config.ts` tenía Pixel 5 e iPhone 12 comentados desde siempre: cero
cobertura móvil. Se activa uno —Pixel 5, 393×851— acotado a `tests/e2e/movil/`.
No corre la suite entera en un teléfono, que duplicaría el tiempo de CI para
repetir comprobaciones que no dependen del tamaño de pantalla.

Las pruebas base recorren cuatro pantallas y comprueban que el contenido no sea
más ancho que la ventana, y que los diálogos tampoco lo sean al abrirse.

Lo que encontró, y va contra lo que parecía: **los diálogos no se desbordan**.
`#forge-confirm-modal` declara `w-[420px]` sin `max-w` y `#docs-modal` declara
650px, pero el navegador aplica a `dialog` un `max-width: calc(100% - 6px - 2em)`
por su cuenta y los deja en 355px. No había nada que arreglar ahí. La prueba se
queda para enterarse si algún día deja de ser cierto.

Lo que sí falta y queda marcado con `test.fail()`: **en un teléfono no hay forma
de buscar**. La búsqueda global se esconde por debajo de 640px y la paleta Cmd+K
necesita teclado. `test.fail()` afirma que hoy falla, así que cuando se arregle
la prueba fallará por pasar si no se quita la marca.

De paso, dos arreglos en pruebas propias que hacían inestable el conteo:

  - Un `test.skip` condicional hacía que la suite tuviera 185 o 186 pruebas
    según lo que hubieran dejado las demás. Con eso no se distingue «se saltó»
    de «desapareció», que es lo que hay que poder distinguir.
  - Una prueba daba por hecho que otra del mismo fichero había corrido antes.
    Con `fullyParallel` no hay orden: fallaba una de cada tres corridas. Ahora
    crea lo que necesita.

Tres corridas seguidas: 186 pasan, sin intermitencias.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No había ninguno configurado. Con varios trabajos en marcha a la vez eso son
varios estilos y, sobre todo, la basura que deja un refactor a medias sin que
nadie la vea.

Reglas mínimas y elegidas: imports sin usar, `==` en vez de `===`, claves
duplicadas en un objeto, casos duplicados en un `switch`, comparaciones de algo
consigo mismo, ternarios inútiles. Nada de estilo.

**El formateador va desactivado.** Formatear 220 ficheros ahora llenaría el
historial de cambios de espacios justo antes de empezar a mover código, y
haría ilegible cada diff posterior. Biome además solo entiende la parte de
script de un `.astro`, y aquí la mayoría del código vive ahí.

Quitados 34 imports y variables sin usar que ya estaban.

`noUnusedVariables` queda apagada a propósito: quedan 8 casos en código real que
hay que mirar uno a uno —algunos parecen desestructuraciones deliberadas— y
encenderla con eso dentro dejaría el CI en rojo desde el primer día, que es
justo como se aprende a ignorar un linter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Biome los marca como arreglo «no seguro» porque borrar un import puede cambiar
el comportamiento si el módulo tiene efectos secundarios al cargarse. Aquí
ninguno los tiene: son tipos y helpers que dejaron de usarse.

Comprobado después: typecheck 0, 263 unitarias y 186 e2e en verde. `npm run
lint` ya sale limpio, así que el paso de CI queda en verde desde el primer día.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tres cosas que el usuario veía mal y una que costaba red.

**Las fechas se leían de cinco formas distintas.** SQLite guarda
`'2026-08-17 14:13:06'` en UTC y sin decirlo, y `new Date(x)` sobre esa cadena
la interpreta como hora **local**. Convivían `new Date(x)`, `new Date(x+'Z')`,
`new Date(x.replace(' ','T')+'Z')` y dos variantes más. No era teoría: la misma
notificación mostraba una hora en el HTML servido y otra tras refrescarse por
fetch, y la diferencia era el desfase del navegador.

Queda un `src/lib/fechas.ts` con `deSQLite`, `fecha`, `fechaHora`, `relativo`,
`hoyLocal` y `vencida`. Lo ilegible devuelve `null` en vez de un `Invalid Date`
que se propaga y acaba pintando «NaN».

**Y todo el formato estaba clavado a `'en-US'`**, así que una interfaz en
español enseñaba «Aug 17, 2026» y «5m ago». El relativo lo hace ahora
`Intl.RelativeTimeFormat`, que traduce solo y además sabe decir «dentro de 3
días» en vez de «hace -3 días». Las cinco acciones de la actividad reciente
—`'moved issue'`, `'created issue'`…— estaban en inglés a mano y ahora pasan
por traducción.

`hoyLocal` merece su prueba: `toISOString().slice(0,10)` da el día en **UTC**,
así que en Bogotá a partir de las 19:00 ya dice mañana y marca como vencido lo
que vence justo hoy.

**La tabla de tareas pintaba la clave del tipo**, no su nombre: salía «task» en
minúscula sin traducir, y un tipo propio salía con su clave interna en vez del
nombre que le puso el equipo. Ahora resuelve nombre y color. El mapa lo pasa
quien llama, porque el hub mezcla espacios y cada uno tiene los suyos —una
consulta por espacio, no una por tarea—. Usa `mapaPorClave()`, que existía para
esto y no llamaba nadie.

**La franja de color de la tarjeta seguía con el mapa fijo** `bug` rojo /
`story` verde / el resto azul. Un tipo que el equipo pintó de morado salía azul,
mientras el punto de su insignia cuarenta líneas más abajo sí usaba el color
bueno. Va por variable CSS porque Tailwind genera sus clases leyendo el código y
un color de tiempo de ejecución nunca acaba en la hoja de estilos.

**Y cinco `data-*` menos por tarjeta.** `data-assignee`, `data-reporter` y
`data-active-session` no los leía nadie. `data-t-due` y `data-t-no-desc` eran la
**misma cadena repetida en cada tarjeta** —hasta cuatrocientas por tablero— para
leerla una vez al abrir un ticket; ahora las lleva el modal, que es quien las
usa. En un tablero lleno eso son decenas de kilobytes de HTML que se enviaban en
cada carga contra un cupo de 1 GB al mes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Producción tiene **1 GB de salida al mes**. Tres cosas se lo comían sin dar
nada a cambio.

**Caddy prohibía cachear los avatares.** `api/storage/[filename].ts` pone
`private, max-age=86400` —correcto y razonado: `private` ya impide que un CDN o
un proxy guarden un archivo con permisos—. El Caddyfile lo pisaba con
`private, no-store` sobre todo `/api/*`, y eso se lo prohíbe también al
navegador: cada avatar se volvía a descargar **en cada carga de página**. Con
hasta cien tarjetas por columna, eso se paga. Ahora `/api/storage/*` queda fuera
de esa regla y conserva sus 24 horas.

**El avatar del perfil se subía sin redimensionar.** Quien lo cambiaba desde su
propia página —en vez de desde /settings, que sí lo hace— mandaba la foto de
4 MB del móvil entera, y luego se sirve a 24×24 píxeles en cada tarjeta. El
servidor acepta hasta 10 MB sin procesar nada, así que el recorte tiene que
pasar en el navegador. El banner va a 1600 px y el avatar a 512.

**Once imágenes sin `loading="lazy"`**, incluida la del avatar de cada tarjeta
del tablero: hasta cuatrocientas descargas inmediatas al abrir. Todas llevan
ahora `lazy` y `decoding="async"`.

De paso, un aviso sobre el arreglo automático: la primera pasada añadía los
atributos **después** de la barra en las etiquetas `<img ... />`, dejando
`/ loading="lazy">`, que es marcado inválido. Se vio revisando el resultado, no
confiando en él.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… sueltas

El CHANGELOG de la v1.12.0 anunciaba que el burndown dejaba de recalcularse
desde el estado actual. `lib/sprintSnapshots.ts` se escribió, se probó y
**nunca lo llamó nadie**: sin disparador, `sprint_snapshots` estaba
permanentemente vacía, y el endpoint seguía con su `COUNT(*)` y su comentario
`// We mock the daily burndown history for MVP purposes`.

Es la única entrada del changelog que afirmaba algo que el código contradecía.
Queda corregida en su sitio, además de arreglada.

El motivo de fondo no era el coste de recalcular, era que **la historia
cambiaba**: si a un ticket le suben los puntos o se mueve de sprint, la curva de
la semana pasada se redibuja distinta hoy. Una gráfica de progreso que cambia
hacia atrás no sirve para mirar atrás.

Dos mitades, y faltaban las dos:

  - El endpoint lee ahora la serie real y la devuelve en `series`, conservando
    los dos campos que ya consumía la gráfica para no romperla.
  - La foto diaria se toma al arrancar y cada seis horas, junto a la limpieza de
    invitados, que es el mismo tipo de tarea y ya estaba ahí. No hace falta un
    cron aparte para una consulta por sprint activo.

Y se refresca la de hoy en cada carga de Métricas: el disparador deja la de cada
día, pero quien abre la pantalla a media tarde después de cerrar cinco tickets
tiene que verlos. `tomarFoto` es idempotente por día, así que eso no duplica ni
toca las anteriores.

El `setInterval` lleva `unref()`. Sin él un temporizador vivo mantiene el
proceso de Node arrancado y los tests que importan `db.ts` se quedan colgados al
terminar sin que nada explique por qué. Hay una comprobación que lo fija.

La prueba que importa no es que la cuenta salga bien —eso ya estaba— sino que
**la foto de ayer no cambia cuando cambian los datos de hoy**: se sube la
estimación de todo el sprint y la de ayer sigue diciendo lo que decía.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tenía un `<input>`, un contenedor de resultados y dos enlaces fijos en inglés.
`#cmd-k-input` no llevaba **ni un listener** en todo el proyecto —solo un
`.focus()` al abrirla— así que escribir dentro no hacía absolutamente nada. Y la
búsqueda global ya existía: `/api/sys/state`, la misma que usa la barra
superior desde hace tiempo.

Ahora busca de verdad, con las acciones rápidas apartándose mientras hay
resultados, navegación con flechas y Enter —una paleta que obliga a soltar el
teclado para elegir no es una paleta— y limpieza al cerrar.

Tres detalles que no se ven:

  - Cada respuesta lleva su número de orden. Sin eso, una consulta lenta de
    «inf» puede llegar después de la de «informe» y pintar sus resultados
    encima de los buenos.
  - El título se escapa antes de meterlo en el HTML: lo escribe otra persona.
    Hay una prueba que crea un espacio llamado `<img src=x onerror=...>` y
    comprueba que no aparece ningún `<img>` en los resultados.
  - Al cerrar se devuelve el foco al documento. Con Escape se quedaba dentro
    del diálogo ya cerrado, así que el atajo dejaba de funcionar y había que
    pulsar con el ratón en cualquier sitio para recuperarlo.

La prueba de reapertura falló una de cada tres corridas **de la suite completa**
y ninguna en solitario. No era el código: `keyboard.press` va a lo que tenga el
foco en ese instante, y bajo carga la ventana en la que el foco está en tránsito
se ensancha lo suficiente para perder la tecla. Dirigida al `<body>`, tres
corridas seguidas de las 192 en verde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JoseSerpaCode
JoseSerpaCode merged commit 7ee1ab2 into main Aug 17, 2026
@JoseSerpaCode
JoseSerpaCode deleted the fix/barra-tablero branch August 17, 2026 22:42
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