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). 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.
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 última carga: 15 partidos completados · 45 filas de parciales (15 × 3) · 0 descuadres · 0 partidos pendientes con sets distintos de NULL.
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 |
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.
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 8).
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. Deuda Técnica y Huecos Conocidos
Esta sección existe para que el documento sea utilizable y no promocional.
| Asunto | Estado | Riesgo |
|---|---|---|
| El flujo de cerrar sets del Admin nunca se probó con sesión iniciada | Abierto | 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 |
Contenido | Se mantiene al día por el flujo; decidir su destino en la migración |
| Migración completa a Supabase (equipos, jugadores, sedes) | Pospuesta a fin de temporada | Mientras tanto, todo cambio de calendario toca dos sitios |
| 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 |
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 |
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) |
| 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 |
| Camino crítico | 441.803 B · 128.924 B gzip |
| 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.
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.
