Saltar al contenido
Inicio
EN
Escríbeme
La plataforma del torneo el día de la fase final: la portada con las dos semifinales por jugar.

Arquitectura técnica

Plataforma en vivo para una liga de volleyball

autor
David Ameth Martínez Sánchez
cliente
Liga de volleyball · Iglesia Adventista, Distrito Eclesiástico de Río Hato
naturaleza
Pro bono, un solo mantenedor
estado
Temporada cerrada; archivo estático desde el 28 de septiembre de 2026
versión
1.1 · 2 de octubre de 2026 (corrige el estado de la 1.0)

En resumen

  • La plataforma que publicó en vivo los resultados de una liga de volleyball de siete equipos, de junio a septiembre de 2026; hoy es un archivo estático.
  • El marcador y sus parciales se escribían por separado: los sets se derivan de los parciales y un chequeo frena la carga ante cualquier descuadre.
  • Al cerrar la temporada: 21 partidos y 63 parciales, sin un solo descuadre.
  • React, TypeScript, Supabase y Vercel, con pruebas en Playwright.

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:

  1. Realtime de Postgres — canal public-live, suscrito a UPDATE sobre matches e INSERT/UPDATE sobre set_scores. Es el camino rápido durante un partido en vivo.
  2. 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.
  3. 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

  1. Acta en la canchapapel
  2. llega por WhatsApp en texto libre, escrita por quien organiza y juega a la vez
  3. Validaciónregla del deuce · orden local/visitante
  4. orden invertido — se voltea por nombre de equipo, nunca por posición
  5. SQL de cinco pasosUPDATE matches · INSERT set_scores
  6. dos hechos en sentencias separadas, así que pueden contradecirse
  7. Chequeo de coherencia sets guardados contra parciales discrepancia → la carga se detiene
  8. cero filas es la única salida que permite continuar
  9. jornadas.statusderivado de los partidos, nunca fijado a mano
El camino que recorre un resultado desde el papel hasta la base, y el punto donde se detiene si los dos hechos no coinciden. El chequeo corre sobre todos los partidos completados del torneo, no solo sobre los recién cargados.

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 escribiendo transform. 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 su skewX— tienen que incluir scale(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 null y 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.
  • /admin va con carga diferida: 27 kB que la afición ya no descarga.
  • manualChunks separa 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:

  • html2canvas no soporta clip-path. Las bandas diagonales de las tarjetas se hacen con linear-gradient de parada dura.
  • Maquetar en columna flex, nunca con posiciones absolutas. Un intento con bottom fijos desbordaba 25 px que overflow: hidden recortaba en silencio, comiéndose la URL del pie. Hay que comprobar scrollHeight === clientHeight antes de dar por buena una captura.
  • Esperar a document.fonts.ready antes 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

  1. 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.
  2. Verificar contra producción, no contra el servidor de desarrollo.
  3. 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. Devuelve false aunque la fuente esté cargada y pintándose, porque Google Fonts sirve la familia partida en subconjuntos unicode y el método solo da true si están todos.
  • El navegador no descarga una fuente hasta que la necesita para pintar. Medir justo después de document.fonts.ready llega 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.