Este documento organiza el sistema por dominios de arquitectura, no por cronología de desarrollo. Los incidentes se documentan donde pertenecen —integridad de datos, resiliencia, verificación— y no como una lista de bugs corregidos, porque en este sistema casi ninguno fue un descuido de código: fueron supuestos equivocados sobre el dominio o sobre la cadena humana que alimenta los datos. Las decisiones de diseño y las alternativas descartadas viven en los Registros de Decisiones Arquitectónicas (Apéndice C). Desde la versión 1.1, el cuerpo describe el sistema en vivo que funcionó de junio a septiembre de 2026, conservado en git con la etiqueta
v1-en-vivo; la Sección 8 cuenta cómo se cerró. La cronología completa, sesión por sesión, se mantiene en el repositorio del proyecto.
Resumen ejecutivo
La web publica en tiempo real los resultados de una liga de volleyball de siete equipos que juega siete jornadas entre junio y septiembre. No es un CRUD sobre una tabla de partidos: el sistema resuelve tres problemas que no se ven desde fuera.
El primero es de confianza en los datos. El marcador de un partido y sus parciales son dos hechos que se escriben por separado y pueden contradecirse; el ranking depende del conteo de sets, así que una discrepancia silenciosa mueve posiciones reales. El sistema deriva los sets de los parciales en vez de leerlos, y expone la discrepancia en vez de taparla.
El segundo es de cadena humana. Los resultados llegan por WhatsApp desde una cancha al aire libre, escritos por quien está organizando y jugando a la vez, y con frecuencia en orden invertido respecto a cómo la base tiene registrado el partido. La carga se diseñó asumiendo ese ruido, no la ausencia de él.
El tercero es de contexto de uso: la afición abre la web desde el celular, en canchas sin buena señal. Eso condiciona el reparto del bundle, la cantidad de fuentes que se descargan y la decisión de qué se anima y qué no.
La temporada cerró el 26 de septiembre de 2026 con la fase final, y dos días después la web dejó de ser un sistema en vivo: no se migró a la base, como estaba previsto, sino al revés, y hoy es un archivo estático que no depende de ningún servicio (Sección 8). El documento conserva el presente de la arquitectura en vivo porque es lo que explica sus decisiones; donde algo cambió al cerrar, lo dice.
1. Arquitectura Core
1.1 Modelo de datos
Cinco entidades en Postgres (Supabase), más una tabla de control de acceso:
tournament
│
├── teams ──── players
│
└── jornadas ──── matches ──── set_scores
│
└── winner_id → teams
| Tabla | Campos relevantes |
|---|---|
teams |
id, tournament_id, name, seed |
players |
id, team_id, name, number, photo_url, is_active |
jornadas |
id, number, name, date, city, status |
matches |
id, jornada_id, team1_id, team2_id, sets_team1/2, winner_id, status, position_in_jornada, started_at, peak_viewers |
set_scores |
match_id, set_number, points_team1/2, winner_id |
admin_users |
control de escritura (ver Sección 3) |
Volumen total del torneo: 7 equipos · 53 jugadores · 7 jornadas · 21 partidos · 63 filas de parciales (21 × 3). Es una base pequeña; la complejidad no está en la escala sino en las reglas.
Una decisión de esquema que se retuvo a propósito: matches.sets_team1/2 es
redundante — el conteo de sets se puede derivar de set_scores. Se conserva porque el
panel de Admin necesita mostrar lo que hay guardado, no lo que debería haber, para que
una discrepancia sea visible y arreglable. La redundancia es el mecanismo de detección,
no un descuido de normalización. Ver Sección 2.2.
1.2 Arquitectura híbrida: dos fuentes de verdad, deliberadamente
Este es el rasgo estructural más importante del sistema y el que más confusión causa si no se conoce:
| Dato | Vive en | Se cambia con |
|---|---|---|
| Partidos, marcadores, parciales, estado en vivo | Supabase | SQL o el panel de Admin |
Emparejamientos (team1_id / team2_id) |
Supabase | SQL |
| Equipos, jugadores, fotos | src/data/index.ts (puente local) |
un deploy |
| Fechas y sedes de cada jornada | src/data/index.ts |
un deploy |
db.ts solo lee id y seed de teams; el resto de la ficha del equipo es local.
⚠️ La trampa que esto genera, ya documentada por experiencia: las sedes son locales pero los emparejamientos son remotos. Cuando la liga rehízo el calendario, cambiar solo el código dejó la web con las sedes nuevas y los cruces viejos. Cualquier cambio de calendario tiene que tocar los dos sitios.
La migración completa a Supabase está decidida pero pospuesta a fin de temporada (ADR-002).
1.3 Capa de acceso a datos y actualización en vivo
src/lib/db.ts expone fetchMatches(), fetchAllSetPoints(),
fetchSetScoresForMatch(), updateMatch() y updatePeakViewers().
La portada mantiene tres mecanismos de refresco simultáneos, por razones distintas:
- Realtime de Postgres — canal
public-live, suscrito aUPDATEsobrematcheseINSERT/UPDATEsobreset_scores. Es el camino rápido durante un partido en vivo. - Sondeo cada 10 segundos — red de seguridad. En una cancha con señal intermitente el websocket se cae sin avisar; el sondeo garantiza que el marcador converja aunque el canal muera.
- Canal de presencia — cuenta espectadores conectados en tiempo real y, si el número
supera el máximo histórico de un partido en curso, lo persiste en
matches.peak_viewers.
1.4 Derivación de estado: la jornada activa no se guarda, se calcula
src/lib/jornadas.ts expone getJornadaActiva() y getJornadaEstado(). El estado de
una jornada —próxima, en curso, completada— se deriva de sus partidos, no se lee de
ningún campo.
Regla de la jornada activa, en orden: la del partido en vivo → la primera pendiente cuya fecha no haya pasado (el mismo día cuenta) → si todas pasaron, la primera pendiente → si no queda nada, la última.
Esto tiene una consecuencia de diseño que se eligió a conciencia: una jornada ya jugada pero sin resultados cargados no ancla la portada. La web sigue mostrando lo que viene, no lo que quedó pendiente de capturar. La jornada sin cargar sigue accesible desde la pestaña Jornadas.
Corolario incómodo: existe una columna jornadas.status en la base que nadie lee.
Se mantiene al día como paso obligatorio del flujo de carga por si la migración futura la
necesita, pero el estado real lo calcula la app. Una columna que nadie lee es una columna
que miente en silencio: durante meses dijo upcoming en las siete jornadas.
1.5 Ranking: sistema FIVB adaptado
El torneo juega 3 sets siempre, no al mejor de 5, así que el reparto de puntos FIVB no calza uno a uno y se adaptó:
| Resultado | Ganador | Perdedor |
|---|---|---|
| 3-0 | 3 pts | 0 pts |
| 2-1 | 2 pts | 1 pt |
Desempates, en orden: partidos ganados → ratio de sets (SG÷SC) → ratio de puntos (PA÷PC).
Que sean ratios y no totales es esencial y contraintuitivo: con descansos rotativos los equipos no llevan los mismos partidos jugados, y cualquier total absoluto premia al que jugó más. Con totales, un equipo con 155 puntos anotados y 0-3 en sets le ganaba el desempate a otro con 131 y 1-1, solo por haber jugado un partido más.
calcStandings() cuenta los sets desde los parciales y solo cae de vuelta a
matches.sets_team1/2 si no hay parciales o si dan empate (imposible en un partido
cerrado, luego indica parciales incompletos).
2. Arquitectura de Datos e Integridad
En este sistema el riesgo no es la escala: son 21 partidos. El riesgo es que el dato que llega es humano y que dos hechos relacionados se escriben en sentencias separadas.
2.1 El flujo real de carga de un resultado
- Acta en la canchapapel
- llega por WhatsApp en texto libre, escrita por quien organiza y juega a la vez
- Validaciónregla del deuce · orden local/visitante
- orden invertido — se voltea por
nombrede equipo, nunca por posición - SQL de cinco pasosUPDATE matches · INSERT set_scores
- dos hechos en sentencias separadas, así que pueden contradecirse
- Chequeo de coherencia sets guardados contra parciales discrepancia → la carga se detiene
- cero filas es la única salida que permite continuar
- jornadas.statusderivado de los partidos, nunca fijado a mano
El orden invertido no es la excepción, es la norma. Medido sobre las dos últimas
jornadas: 4 de 6 partidos llegaron nombrados al revés de como la base los tiene
registrados. La respuesta de ingeniería no fue pedir disciplina al reportante, sino
quitarle importancia al orden: los parciales se declaran en un VALUES por nombre de
equipo y una cláusula CASE los voltea según lo que diga la base.
JOIN parciales p
ON (j5.n1 = p.eq_a AND j5.n2 = p.eq_b)
OR (j5.n1 = p.eq_b AND j5.n2 = p.eq_a)
...
CASE WHEN j5.n1 = p.eq_a THEN p.pts_a ELSE p.pts_b END
Con esto, sets_team1/2 y winner_id quedan derivados de los parciales, no escritos
a mano. Un error de transcripción deja de poder entrar por esa vía.
2.2 El chequeo de coherencia como parte del flujo
matches y set_scores se escriben en sentencias separadas, así que pueden
desincronizarse. El flujo incluye una consulta obligatoria que debe devolver cero
filas: compara sets_team1/2 contra el conteo real de parciales ganados, sobre todos
los partidos completados del torneo, no solo los recién cargados.
Estado verificado tras la carga del 9 de agosto: 15 partidos completados · 45 filas de parciales (15 × 3) · 0 descuadres · 0 partidos pendientes con sets distintos de NULL. Medido otra vez sobre el archivo congelado, al cerrar la temporada: 21 de 21 partidos completados · 63 parciales · 0 descuadres.
2.3 El paso que se descuadra solo
UPDATE matches no toca jornadas.status. Se corrige con una sentencia que deriva el
estado de los partidos en vez de fijar el número a mano, de modo que la misma sentencia
sirva siempre y nunca marque una jornada a medias:
UPDATE jornadas j SET status = 'completed'
WHERE j.status <> 'completed'
AND EXISTS (SELECT 1 FROM matches m WHERE m.jornada_id = j.id)
AND NOT EXISTS (SELECT 1 FROM matches m
WHERE m.jornada_id = j.id AND m.status <> 'completed');
Comportamiento observado y correcto: con una jornada a medias devuelve UPDATE 0.
3. Seguridad y Fronteras de Confianza
El sistema es de lectura pública y escritura restringida. La clave anónima de Supabase viaja en el bundle del cliente —es su diseño, no una filtración— y toda la seguridad real recae en las políticas de fila.
3.1 Fronteras
| Frontera | Riesgo | Defensa |
|---|---|---|
| Navegador → Supabase (lectura) | La clave anónima es pública por diseño | RLS: lectura permitida solo sobre las tablas del torneo; ningún dato personal más allá de nombre y foto del jugador |
| Navegador → Supabase (escritura) | Cualquiera con la clave anónima podría intentar escribir | RLS: la escritura exige sesión autenticada presente en admin_users. La clave anónima es físicamente incapaz de escribir |
| Panel de Admin | Acceso no autorizado al editor de marcadores | Supabase Auth con sesión persistente; dos cuentas verificadas |
| Bucket de fotos | Bucket público | Solo fotos de jugadores; el patrón de nombres (seed-numero.png) no expone datos personales |
| Analítica | Registro por visita = dato personal | No se guarda ninguno. Ver ADR-003 |
| Interfaz pública | Proclamar un campeón antes de tiempo | Candado por dato, no por permiso. Ver 3.2 |
| Contador de espectadores | update_peak_viewers es SECURITY DEFINER y ejecutable sin sesión — se salta las políticas de fila |
Riesgo aceptado y acotado. Ver 3.4 |
3.2 El candado del podio
Las tarjetas para compartir el 1º, 2º y 3º puesto solo existen con el torneo terminado, y la condición no es una fecha ni una bandera manual:
const torneoTerminado = todos.length > 0 && todos.every(m => m.done)
Se eligió así para no depender de contar jornadas ni de jornadas.status —la columna que
nadie lee—. Sin este candado, cualquiera podría generar y difundir una tarjeta
proclamando campeón en la tercera jornada. Por eso el texto dice “Clasificación final” y
no “tras la jornada N”.
Consecuencia asumida: no se pueden probar en producción hasta que el torneo cierre.
Para verlas antes se fuerza la condición en local y se deshace; no existe ninguna puerta
trasera del tipo ?podio=1 en producción, a propósito.
3.3 Datos personales
La liga es de una iglesia y los jugadores incluyen menores. El sistema guarda nombre, número y foto, nada más: ni contacto, ni edad, ni ubicación. La decisión de no registrar visitas individuales en una tabla propia fue explícitamente por la Ley 81 de 2019 de protección de datos de Panamá, además del riesgo de exponer una tabla pública a escritura basura.
3.4 El único camino de escritura sin autenticar, y por qué se acepta
public.update_peak_viewers(match_id, viewer_count) tiene que ser ejecutable por el
rol anónimo: la portada la llama sin sesión para registrar el pico de espectadores de un
partido en vivo. Al ser SECURITY DEFINER se salta las políticas de fila, así que es
la única vía por la que alguien sin cuenta puede escribir en la base.
El linter de Supabase la marca, y seguirá marcándola. No es un defecto pendiente: es un riesgo evaluado y contenido por cuatro límites dentro de la propia función.
| Límite | Efecto |
|---|---|
SET search_path TO 'public' |
Cierra el ataque serio contra funciones SECURITY DEFINER: manipular la resolución de nombres para que la función ejecute otra cosa |
AND status = 'in_progress' |
Solo alcanza partidos en curso. Un partido cerrado es intocable |
peak_viewers IS NULL OR viewer_count > peak_viewers |
Solo sube. No se puede borrar ni reducir un pico ya registrado |
| Tope de 500 y descarte de valores menores que 1 | Acota el valor por arriba y por abajo |
Superficie real que queda: durante un partido en vivo, alguien puede inflar el contador hasta 500. El daño máximo es un número de vanidad exagerado en la tarjeta del partido, mientras dura ese partido. No hay pérdida de datos, ni acceso a otras tablas, ni escalada de privilegios.
Alternativa descartada: mover la función a un esquema no expuesto, como se hizo con
is_admin(). Aquí no sirve — la web pública necesita justamente el endpoint REST.
3.5 Un aviso del linter que hay que ignorar a conciencia
public.admin_users tiene RLS activo y ninguna política. El linter lo señala como
rls_enabled_no_policy porque normalmente indica un descuido. Aquí es deliberado y es el
estado más seguro posible: sin políticas, nadie alcanza la tabla por la API.
Verificado con la clave pública: la lectura devuelve [] y el INSERT es rechazado con
42501 · new row violates row-level security policy.
private.is_admin() la consulta por dentro como SECURITY DEFINER, saltándose RLS, que
es justo para lo que existe esa función.
⚠️ Añadirle una política para callar el aviso expondría la lista de correos de los administradores. Es un aviso que se ignora, no que se resuelve.
3.6 Cadena de conexión para respaldos
El respaldo automático (Sección 8.4) necesita una cadena que no es la que ofrece la
interfaz por defecto. En el panel de Supabase está detrás del botón Connect de la
barra superior — no en Settings, donde estuvo en versiones anteriores.
Tiene que ser el Session pooler, y se reconoce por dos señas: puerto 5432 y host
pooler.supabase.com. Las otras dos opciones no sirven:
| Opción | Por qué no |
|---|---|
| Transaction pooler (puerto 6543) | No soporta pg_dump |
Conexión directa (db.<ref>.supabase.co) |
Solo IPv6; los runners de GitHub Actions son IPv4 |
4. Patrones de Resiliencia
Cuatro situaciones que, contadas por encima, parecen bugs. Contadas con precisión, son supuestos equivocados sobre el dominio.
4.1 El sistema asumía “al mejor de 3” y la liga juega “3 sets siempre”
El panel de Admin no podía guardar un 3-0. Dos sitios daban por hecho el formato al
mejor de 3: el cierre de set terminaba el partido cuando alguien llegaba a 2 sets, y la
validación de guardado exigía exactamente hi === 2, rechazando 3-0 y 0-3 con “Resultado
inválido”.
El fallo llevaba meses sin dar la cara porque los dos partidos 3-0 que ya existían habían entrado por SQL, esquivando el editor. Un caso de manual de cómo una vía alternativa de escritura oculta un defecto en la principal.
Reglas vigentes: el partido cierra al cerrar el tercer set; los finales válidos son
3-0, 2-1, 1-2 y 0-3 (s1 + s2 === 3 && s1 !== s2). Un 2-0 pasa a ser inválido, que es lo
correcto en esta liga.
4.2 Cerrar un set dos veces sumaba dos sets
El cierre de set hacía sets + 1. Volver a un set ya cerrado para corregir un parcial
—algo que el selector permite, y hasta recarga los puntos— lo contaba otra vez. No
había forma de corregir un set sin descuadrar el partido, y es muy probablemente el
origen de un descuadre real que hubo que arreglar por SQL.
La corrección no fue restar en el sitio adecuado: fue cambiar el modelo de escritura.
Ahora se guarda el parcial, se relee set_scores y se cuenta. Cerrar un set pasa
a ser idempotente, y el conteo no puede separarse de los parciales porque se calcula de
ellos. Es la misma regla que usa la tabla de posiciones — una sola definición de “cuántos
sets ganó este equipo” en todo el sistema.
4.3 Mostrar la discrepancia en vez de corregirla en silencio
reconciliarSets() devuelve los partidos con los sets contados desde los parciales, y la
portada se lo pasa a Inicio, Jornadas y la tarjeta de compartir, para que las tres vistas
y el ranking muestren siempre el mismo número.
Se aplica en la portada y no en db.ts a propósito. El panel de Admin llama a la
misma función de lectura, y el editor tiene que seguir enseñando lo que está guardado:
si se le esconde la discrepancia, nadie puede arreglarla. Por la misma razón, al guardar
un partido completado con parciales cargados se bloquea el guardado si el conteo no
coincide, con el detalle del descuadre, en vez de ajustarlo por debajo.
⚠️ Una restricción de alcance importante: reconciliarSets() solo toca partidos
terminados. En uno en curso los parciales incluyen el set que se está jugando, y
contarlo lo daría por ganado a quien va arriba — el marcador en vivo mentiría.
4.4 La documentación se desincroniza en silencio de la base
Cuando un resultado ya cargado se corrige en Supabase, la corrección no llega sola a la documentación del proyecto, y los dos registros se contradicen sin que nada falle. Ocurrió: un partido se corrigió en la base y el documento siguió afirmando el resultado viejo durante una semana.
Con el ranking anterior la diferencia era cosmética. Con FIVB, donde el conteo de sets es el puntaje, una discrepancia así mueve posiciones reales.
Regla operativa derivada: ante cualquier duda sobre un resultado, leer la base o la
web, nunca el documento; y si aparece una diferencia, asumir que el documento es el viejo
antes de concluir que hay un fallo de datos. Las capturas fechadas de docs/screenshots/
funcionan como registro histórico y permiten datar cuándo cambió un dato.
5. Sistema de Diseño y Capa de Presentación
5.1 Identidad y geometría
Paleta “Torneo de Fe”: #091428 #0F1E35 #1B5BA8 #E8620A #F5A623 #F8F9FA.
El rasgo de identidad es una diagonal a 45° exactos, presente en la cuña naranja del header, las líneas de velocidad animadas del fondo y los chips inclinados.
Que sean 45° reales tiene una consecuencia geométrica que condiciona todo el header: la
diagonal avanza 1 px hacia la izquierda por cada píxel de alto. Cuanto más alto el
header, más ancho se come abajo. La cuña se construye con aspect-ratio: 1/1 +
skewX(-45deg) anclado abajo-derecha, y no con clip-path en porcentajes, porque con
porcentajes el ángulo dependía de la altura y salía casi vertical.
⚠️ Quien pone el techo al tamaño del título no es el título, es el subtítulo. Al crecer el título el header se hace más alto, y a 45° eso corre el borde naranja justo hacia donde está el subtítulo. Los márgenes contra la cuña están medidos en ocho anchos (320 a 1280 px), todos positivos, con el peor caso en una franja estrecha alrededor de 352 px.
5.2 Temas
Dos temas completos vía variables CSS: oscuro (predeterminado) y claro. El header y el
pie mantienen su azul en ambos modos mediante una clase force-dark que reasigna los
tokens de color localmente — así el contraste del texto no depende del tema y la página
queda cerrada arriba y abajo con el mismo azul.
El panel de Admin es siempre oscuro por la misma vía.
5.3 Sistema de movimiento
Antes cada animación inventaba su curva y su duración. Hoy hay dos curvas y no se añade una tercera sin motivo:
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* entrar y salir */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* moverse por pantalla */
| Momento | Duración | Propósito |
|---|---|---|
| Pulsar un botón | 160 ms | Confirmar que la interfaz oyó el toque |
| Modales — entrada | 220 ms | Consistencia espacial |
| Modales — salida | 160 ms | Simétrica: sale por donde entró |
| Llegada de los datos | 200 ms | Puente entre el esqueleto y el contenido |
| Reordenamiento de la tabla | 320 ms | Evitar el teletransporte |
Se anima solo transform y opacity. Todo respeta prefers-reduced-motion
conservando fundidos y color y quitando el desplazamiento: menos movimiento, no cero.
Tres detalles de implementación que no son evidentes:
- El press se aplica con una variable (
--press), no escribiendotransform. Así se compone con transformaciones que el elemento ya tenga. El estilo inline gana a cualquier clase, de modo que los elementos con transform inline —las pestañas de jornada, con suskewX— tienen que incluirscale(var(--press, 1))ellos mismos. - La salida de un modal no se puede animar sin ayuda: React desmonta el nodo en
cuanto el estado pasa a
nully el elemento ya no existe cuando empezaría la animación. Un hook mantiene el contenido montado el tiempo exacto de la salida. La entrada, en cambio, se resuelve solo en CSS con@starting-style. - El reordenamiento de la tabla usa FLIP sobre
transform, y no anima la primera carga: pasar de cero partidos a la tabla completa recoloca las siete filas a la vez, y eso es el arranque, no una noticia. Un guardián distingue ambos casos.
En reposo, el panel de un modal tiene transform: none y no scale(1), porque
html2canvas captura nodos que están dentro de esos paneles y una transformación activa
en un ancestro le complica el render.
5.4 Tipografía
| Uso | Fuente | Pesos |
|---|---|---|
| Titulares | Saira Condensed | 700, 800 |
| Texto de cuerpo | IBM Plex Sans | 400, 500, 600, 700 |
| Marcador del Admin | Bebas Neue | 400 |
Tres decisiones detrás de esa tabla:
Bebas Neue solo la carga el Admin. Se pedía desde index.html, así que se la
descargaba también quien solo venía a ver la tabla. Ahora la inyecta el panel al montarse.
La clase se llama font-texto, no por el nombre de la fuente. Nombrarla font-inter
hizo que al cambiar de fuente la clase mintiera en todo el proyecto.
⚠️ Las itálicas de los titulares son sintéticas. Saira Condensed no tiene cara itálica; el navegador inclina las letras rectas sin redibujarlas, lo que deforma las curvas y desiguala el grosor en las diagonales. Se mantiene a conciencia —en tipografía deportiva la inclinación mecánica es un recurso legítimo— y queda anotado como deuda abierta con dos alternativas evaluadas (Sección 9).
6. Rendimiento y Entrega
6.1 Reparto del bundle
Motivado por una restricción del contexto de uso, no por una métrica abstracta: canchas al aire libre con señal variable. Antes todo viajaba en un solo archivo.
| Antes | Después | |
|---|---|---|
| Camino crítico de la afición | 670.085 B | 441.803 B (−34 %) |
| Comprimido (gzip) | 181.022 B | 128.924 B (−29 %) |
Tres medidas:
html2canvas(47 kB gzip) se importa de forma diferida dentro del manejador de compartir: solo baja cuando alguien genera el PNG./adminva con carga diferida: 27 kB que la afición ya no descarga.manualChunkssepara React y Supabase. Esto no reduce la primera descarga; sirve para que al desplegar un cambio de la app, quien ya visitó reutilice ~112 kB gzip de su caché.
6.2 Generación de imágenes para compartir
Dos tarjetas (resultado de partido y podio) se capturan con html2canvas a escala 3-4×
para producir PNG de 1080×1920, el formato de Estados de WhatsApp.
Restricciones aprendidas, todas por haberlas incumplido antes:
html2canvasno soportaclip-path. Las bandas diagonales de las tarjetas se hacen conlinear-gradientde parada dura.- Maquetar en columna flex, nunca con posiciones absolutas. Un intento con
bottomfijos desbordaba 25 px queoverflow: hiddenrecortaba en silencio, comiéndose la URL del pie. Hay que comprobarscrollHeight === clientHeightantes de dar por buena una captura. - Esperar a
document.fonts.readyantes de dibujar. Si no, se congela la fuente de respaldo dentro de un PNG que ya se mandó por WhatsApp y no hay vuelta atrás. - Los nombres de esta liga son largos. Nunca colocarlos junto al marcador ni con recorte por elipsis. Verificar siempre con “Centinelas de Luz”.
6.3 Analítica
Vercel Web Analytics con un evento a medida al cambiar de pestaña. Sin cookies, ~1 kB, y no se registra ninguna visita individual (ADR-003).
7. Metodología de Verificación
El proyecto lo mantiene una sola persona y lo ve la iglesia entera, así que no hay revisión por pares. La verificación tiene que ser medible, no visual.
7.1 Reglas
- Medir, no opinar. Márgenes contra la cuña en ocho anchos; contraste leído del DOM; los doce casos de validación de sets; el ancho renderizado del título.
- Verificar contra producción, no contra el servidor de desarrollo.
- Comprobar que un recurso externo cargó, no que la página se ve plausible.
7.2 La lección que originó la regla 3
La tipografía de toda la identidad nunca cargó durante meses. La petición a Google Fonts pedía itálicas de una familia que no las tiene y devolvía 400; el navegador bloqueaba la hoja entera. El fallo sobrevivió a cuatro etapas de trabajo visual, mediciones de márgenes y capturas de pantalla, porque todo lo medido —colores, geometría, distancias— era correcto: la página se veía plausible con la fuente de respaldo y la itálica sintetizada.
La prueba definitiva no fue mirar: fue medir el ancho de “VOLLEYBALL” en producción y ver que coincidía exactamente con el de la fuente del sistema.
7.3 Trampas de verificación de fuentes
Dos falsos negativos confirmados, ambos capaces de hacer creer que una fuente correcta está rota:
document.fonts.check()miente. Devuelvefalseaunque la fuente esté cargada y pintándose, porque Google Fonts sirve la familia partida en subconjuntos unicode y el método solo datruesi están todos.- El navegador no descarga una fuente hasta que la necesita para pintar. Medir justo
después de
document.fonts.readyllega antes que la descarga.
Lo único fiable: comparar el ancho de un texto renderizado con "Fuente", monospace
contra el mismo texto con monospace a secas. Si miden distinto, la fuente está puesta.
7.4 Verificación de animaciones
En un panel de navegador que no compone frames, las transiciones CSS no avanzan y los valores computados salen congelados en su estado inicial. No es un fallo de la aplicación. Cuando el panel sí compone, la forma de obtener un fotograma exacto es pausar la animación con la API de animaciones y fijar su tiempo:
document.getAnimations().forEach(a => { a.pause(); a.currentTime = 450 })
8. Cierre de Temporada: del Sistema en Vivo al Archivo
La temporada terminó el 26 de septiembre de 2026 con la fase final en Churube: dos semifinales, el partido por el tercer puesto y la final, jugados sin anotar los puntos. Dos días después, el 28, la web dejó de ser un sistema en vivo.
8.1 Congelar en vez de migrar
El ADR-002 dejaba para el final del torneo una migración: llevar equipos, jugadores y
sedes a Supabase para tener una sola fuente de verdad. Se hizo lo contrario. Todo lo
que vivía en la base bajó al código: partidos, parciales, MVP y testimonios publicados
pasaron a src/data/archivo-2026.json, y las 45 fotos de jugador (29,8 MB) salieron de
Supabase Storage hacia public/jugadores/. La capa de acceso a datos (src/lib/db.ts)
conservó sus firmas de lectura y ahora lee del archivo, así que ninguna página tuvo que
cambiar su forma de pedir los datos.
El resultado es una web que no depende de nada vivo: si la base se pausa o se borra, la web no se entera. La trampa de los dos sitios que el ADR-002 aceptaba desapareció porque ya solo hay uno.
8.2 Qué se retiró
| Pieza | En vivo | En el archivo |
|---|---|---|
| Datos | Supabase, con tiempo real y sondeo cada 10 s | Un JSON fijo en el repositorio: 25 partidos (21 de liga + 4 de la fase final), 63 parciales, 2 MVP y 2 testimonios |
| Panel de Admin | /admin, con sesión |
Retirado; /admin redirige a la portada |
| Testimonios | Formulario, moderación y publicación | Solo los 2 publicados, en solo lectura |
| Contadores | Espectadores y compartidas | Retirados |
@supabase/supabase-js |
En el bundle | Fuera |
| JavaScript público | unos 470 kB | 258 kB |
8.3 Qué deja de aplicar en este documento
La web archivada no escribe en ningún sitio, así que las fronteras de escritura de la
Sección 3, el flujo de carga de la Sección 2 y el panel de Admin describen la versión en
vivo y no la actual: no hay clave que proteger ni panel que atacar. Lo que sigue vigente
sin cambios es el cálculo —los sets derivados de los parciales, la puntuación FIVB y los
desempates de la Sección 1.5—, porque la tabla se sigue calculando, ahora sobre el
archivo. La API pública del torneo (api.davidameth.dev) exporta sus datos de ese mismo
archivo.
8.4 Lo que sigue vivo, a propósito
La base de Supabase no se borró: conserva todos los datos de la temporada. El
respaldo automático sigue corriendo dos veces por semana (domingos y miércoles a las
09:00 UTC) y cumple dos funciones a la vez: versiona un volcado de la base en el
repositorio y la mantiene despierta, porque el plan gratuito pausa los proyectos tras 7
días de poca actividad. Corre dos veces y no una para que la pausa nunca quede a cero
días de margen. La versión en vivo, con el Admin y el formulario, se recupera desde la
etiqueta v1-en-vivo.
⚠️ GitHub desactiva los cron de un repositorio tras 60 días sin actividad. Este se mantiene porque el propio respaldo hace commits; si alguna vez dejara de hacerlos, el respaldo y la base se apagarían juntos, sin aviso.
9. Deuda Técnica y Huecos Conocidos
Esta sección existe para que el documento sea utilizable y no promocional. Desde la versión 1.1, las filas que el archivo cerró (Sección 8) se marcan en vez de borrarse.
| Asunto | Estado | Riesgo |
|---|---|---|
| El flujo de cerrar sets del Admin nunca se probó con sesión iniciada | Cerrado por el archivo | El Admin se retiró el 28/09; si se recupera desde v1-en-vivo, el hueco vuelve con él. Alto si se vuelve a usar el panel en una jornada. Se verificó la lógica de validación y que la ruta carga, pero no el flujo completo autenticado |
| Itálicas sintéticas en los titulares | Aceptado a conciencia | Estético. Alternativas evaluadas con cursiva real: Barlow Condensed, Archivo Narrow |
| Fuentes servidas desde Google | Abierto | Dependencia externa que ya falló una vez. Auto-alojarlas quita dos peticiones bloqueantes |
jornadas.status: columna que nadie lee |
Cerrado por el archivo | Sigue en la base, sin efecto en la web archivada. No hubo migración que decidiera su destino |
| Migración completa a Supabase (equipos, jugadores, sedes) | Sustituida por el archivo | No se migró: se congeló al revés (Sección 8.1), y la trampa de los dos sitios desapareció |
| Favicon | Entregado, con techo | Funciona y tiene identidad propia, pero a 16 px no se lee como lo que representa. Techo del tamaño, no del dibujo |
Capturas de docs/screenshots/ desactualizadas |
Parcial | Varias muestran la fuente de respaldo anterior al arreglo |
| Protección contra contraseñas filtradas | No resoluble en el plan actual · sin efecto mientras el Admin siga retirado | La comprobación contra HaveIBeenPwned es exclusiva del plan Pro. El aviso del linter seguirá apareciendo. Sustitutos gratuitos y más efectivos con solo dos cuentas: longitud mínima de 12 (hoy 6), requisitos de caracteres, exigir contraseña actual al cambiarla y cambio seguro de contraseña |
players en Supabase tiene 48 filas; el puente local declara 53 |
Abierto, fuera de la web | Importa si la base se vuelve a usar: arrancaría con 48 de 53. No afecta a la web — los jugadores se leen de src/data/index.ts, no de Supabase. Pero un restore desde el respaldo arrancaría incompleto, y la migración de fin de temporada partiría de datos equivocados. Detectado al revisar el primer volcado: la distribución por equipo tampoco coincide (Supabase 8·7·7·7·7·6·6; documentada 9·8·9·7·6·7·7 — ningún equipo tiene 9 en la base). Cuadrarlo ANTES de migrar |
Políticas de escritura declaradas como ALL |
Sin efecto en la web archivada: nada la lee | ALL incluye SELECT, así que cada lectura pública puede acabar evaluando is_admin() sin necesidad. Acotarlas a INSERT, UPDATE, DELETE es más limpio y barato; se deja para después de la temporada |
Apéndice A — Cronología condensada
| Etapa | Contenido | Dónde vive en este documento |
|---|---|---|
| 1–4 | Rediseño: pestañas, inicio unificado, identidad visual | Sección 5.1 |
| 5–6 | Geometría del header, calendario nuevo | Sección 5.1 / 1.2 |
| 7 | Reparto del bundle | Sección 6.1 |
| 8 | La fuente que nunca cargó | Sección 7.2 |
| 9 | Paso al sistema FIVB | Sección 1.5 + ADR-001 |
| 10 | Cierre del tamaño del título | Sección 5.1 |
| 11 | Tarjetas de podio | Sección 3.2 / 6.2 |
| 12 | Arreglos de integridad en el Admin | Sección 4.1 / 4.2 |
| 13 | Sistema de movimiento y tipografía | Sección 5.3 / 5.4 + ADR-005 |
| 14 | Fase final y podio sin marcador (26–28 sep) | Sección 8 |
| 15 | Archivo estático: la web deja la base (28 sep) | Sección 8 + revisión del ADR-002 |
La cronología completa, sesión por sesión, se mantiene junto al código del proyecto.
Apéndice B — Referencia rápida
| Categoría | Dato |
|---|---|
| Frontend | React 18 · Vite 5 · TypeScript 6 · Tailwind 3 |
| Backend | Supabase (Postgres + Auth + Realtime + Storage) hasta el 28/09/2026; desde entonces, un archivo JSON en el repositorio |
| Despliegue | Vercel · SPA con reescritura a index.html |
| Enrutado | react-router-dom 7 (/ público, /admin diferido) |
| Imágenes para compartir | html2canvas (carga diferida) |
| Analítica | Vercel Web Analytics · sin cookies |
| Pruebas | Playwright |
| Volumen | 7 equipos · 53 jugadores · 7 jornadas · 21 partidos · 63 parciales · fase final de 4 partidos sin marcador |
| Camino crítico | 441.803 B · 128.924 B gzip (en vivo) |
| JavaScript público del archivo | 258 kB (en vivo: unos 470 kB) |
| Curvas de movimiento | --ease-out · --ease-in-out |
| Tipografía | Saira Condensed (titulares) · IBM Plex Sans (cuerpo) · Bebas Neue (solo Admin) |
| Costo de operación | $0/mes (tiers gratuitos de Vercel y Supabase) |
Apéndice C — Registros de Decisiones Arquitectónicas
ADR-001 — Sistema de puntuación de la tabla
Contexto. El ranking ordenaba por puntos anotados. Un equipo invicto aparecía quinto solo por haber descansado una jornada — el ranking no reflejaba la realidad deportiva.
Opciones consideradas.
- Puntos anotados (vigente hasta entonces). Premia el volumen, no el rendimiento.
- Victoria = 2, derrota = 0. Simple, pero no distingue al que pelea un set del que cae barrido.
- FIVB adaptado a 3 sets fijos: 3-0 → 3/0, 2-1 → 2/1.
Decisión. FIVB adaptado.
Consecuencias. El conteo de sets deja de ser decorativo y pasa a ser el puntaje,
lo que elevó la criticidad de la integridad de set_scores (Sección 2) y obligó a derivar
los sets de los parciales. Los empates a puntos pasan a ser la norma, lo que hizo
necesaria una función que explique qué criterio rompió cada empate. Los desempates se
definieron por ratio y no por total, contra la intuición.
ADR-002 — Arquitectura híbrida: equipos y jugadores en el código
Contexto. Partidos y marcadores viven en Supabase desde el principio. Equipos, jugadores, fotos, fechas y sedes viven en un archivo del repositorio.
Opciones consideradas.
- Migrar todo a Supabase ahora. Una sola fuente de verdad, sin la trampa de los dos sitios.
- Mantener el puente local hasta fin de temporada.
Decisión. Mantener el puente hasta el cierre de temporada.
Consecuencias. Se acepta a sabiendas una trampa real: las sedes son locales y los emparejamientos remotos, así que un cambio de calendario que toque solo el código deja la web coherente a medias. Ya ocurrió una vez. A cambio, durante la temporada en curso los datos que casi nunca cambian no dependen de disponibilidad de red ni de una migración a mitad de campeonato. Se revisa al terminar el torneo.
Revisión — 28 de septiembre de 2026. Al terminar el torneo no se migró hacia Supabase: se hizo lo contrario. Los datos de la base bajaron al código y la web dejó de leerla (Sección 8.1). La trampa de los dos sitios desapareció porque ya solo hay uno.
ADR-003 — Analítica sin datos personales
Contexto. Interesa el alcance agregado —visitas, país, dispositivo—, no el comportamiento individual. La liga incluye menores.
Opciones consideradas.
- Google Analytics. Requiere banner de cookies, pesa más y lo bloquean los adblockers.
- Tabla propia de visitas en Supabase. Datos personales bajo la Ley 81 de 2019 de Panamá y una tabla pública expuesta a escritura basura.
- Vercel Web Analytics.
Decisión. Vercel Web Analytics.
Consecuencias. Sin cookies y sin banner. No existe registro por visita, así que tampoco existe la pregunta de qué hacer con él. Se renuncia al análisis individual, que no se necesita. Requiere activar la función en el panel del proveedor: sin eso el script da 404 y los eventos se quedan encolados en el cliente sin error visible.
ADR-004 — Quién carga los resultados
Contexto. El panel de Admin se construyó para que la organizadora de la liga cargara los resultados. En la práctica ella juega y además organiza, y no alcanzaba a subir los puntos.
Opciones consideradas.
- Insistir en el panel. Conserva la validación integrada y no depende de una persona con acceso a la base.
- Cargar por SQL con validación previa.
Decisión. Carga por SQL, con el panel operativo como vía alternativa.
Consecuencias. Se gana fiabilidad de entrega y se pierde la validación automática del editor, que hay que reponer manualmente: regla del deuce, orden local/visitante y chequeo de coherencia forman ahora parte del procedimiento escrito (Sección 2.1). Efecto lateral descubierto después: la vía SQL había estado ocultando un defecto del editor —los partidos 3-0 entraban por SQL, así que nadie notó que el panel los rechazaba (Sección 4.1). Una vía alternativa de escritura enmascara defectos en la principal.
ADR-005 — Tipografía de texto de cuerpo
Contexto. El cuerpo usaba Inter. Correcta pero sin carácter, y presente en una enorme cantidad de sitios.
Restricción no negociable. La tabla usa cifras tabulares en todos los marcadores para que las columnas queden alineadas. Cualquier reemplazo debe tener cifras de ancho fijo o la tabla se desalinea — restricción que descarta buena parte de las alternativas.
Opciones consideradas. Se compararon cinco familias con el contenido real del torneo —la tabla de posiciones, un marcador y texto de lectura— midiendo empíricamente si sus cifras eran de ancho fijo: Inter, Barlow, IBM Plex Sans, Archivo y Public Sans. Las cinco pasaron la prueba, así que la decisión quedó como estética.
Decisión. IBM Plex Sans.
Consecuencias. Plex llega hasta el peso 700 y no tiene 800; el único texto de cuerpo que pedía más se ajustó. Queda avisado en la configuración para que nadie pida un peso inexistente y el navegador lo finja engordando el trazo. El panel de Admin hereda la fuente nueva para su texto normal. Barlow quedó como segunda opción por una razón de sistema: es hermana de Barlow Condensed, y adoptarla habría permitido una sola familia para titulares y cuerpo.
ADR-006 — Qué se anima y qué no
Contexto. La interfaz tenía animaciones decorativas permanentes pero ningún movimiento donde ayudaría a entender lo que pasa: los modales aparecían y desaparecían de golpe, y la tabla reordenaba sus filas por teletransporte.
Opciones consideradas. Se auditó la interfaz completa buscando dónde faltaba movimiento, y a cada candidato se le aplicó el mismo filtro: frecuencia de uso, propósito nombrable —confirmar, dar continuidad espacial, hacer legible un cambio de estado, evitar un salto brusco— y si compite con datos que la persona está leyendo.
Decisión. Se implementaron cinco. Se descartaron a propósito la transición al
cambiar de pestaña (es la navegación principal; un fundido retrasa lo que la persona acaba
de pedir), el contador animado en la tabla (son datos que se leen y se comparan), y la
animación de la tarjeta de podio (html2canvas captura ese nodo y podría hacerlo a media
animación, produciendo una imagen distinta cada vez).
Consecuencias. El movimiento con más significado del sistema es el reordenamiento de la tabla: que un equipo suba tres puestos es la noticia de la jornada y antes ocurría en un fotograma que nadie alcanzaba a ver. A cambio, hay que mantener el guardián que impide animar la carga inicial, y todo movimiento nuevo debe pasar el mismo filtro en vez de añadirse porque quede bien.
