diff --git a/ai/tasks/done/TASK-055-fix-all-servers-historical-aggregate.md b/ai/tasks/done/TASK-055-fix-all-servers-historical-aggregate.md new file mode 100644 index 0000000..e39b08c --- /dev/null +++ b/ai/tasks/done/TASK-055-fix-all-servers-historical-aggregate.md @@ -0,0 +1,80 @@ +# TASK-055-fix-all-servers-historical-aggregate + +## Goal +Corregir el agregado histórico `all-servers` / `Totales / Todos` para que resumen, tops semanales y partidas recientes reflejen realmente la suma o agregación de los servidores con histórico disponible, en vez de devolver snapshots vacíos. + +## Context +La situación actual del histórico es esta: +- `comunidad-hispana-01` muestra datos correctos +- `comunidad-hispana-02` muestra datos correctos +- `Totales / Todos` aparece vacío +- `comunidad-hispana-03` todavía no debe considerarse error, porque no se ha ejecutado bootstrap para ese servidor + +Esto indica un fallo específico en la generación o persistencia del agregado lógico `all-servers`, no un problema general de la capa histórica. + +## Steps +1. Revisar la implementación actual del agregado lógico `all-servers` en: + - snapshots + - payloads + - rutas + - generación/prewarm +2. Verificar cómo se construyen actualmente: + - resumen global + - weekly leaderboards globales + - recent matches globales +3. Corregir la lógica para que `all-servers` agregue correctamente los servidores con histórico disponible. +4. Asegurar que el agregado: + - incluya `comunidad-hispana-01` + - incluya `comunidad-hispana-02` + - no dependa de que `comunidad-hispana-03` ya tenga histórico + - no colapse a vacío si uno de los servidores no tiene datos aún +5. Verificar que los snapshots globales se generen con datos reales y no se sobrescriban con vacíos. +6. Regenerar los snapshots necesarios de `all-servers`. +7. Validar que en la UI: + - `Totales / Todos` deje de mostrar 0 partidas / 0 jugadores + - los tops globales ya tengan datos + - recent matches globales ya tengan contenido si existe histórico en #01 y #02 +8. Documentar brevemente la semántica final del agregado global. +9. No tratar todavía `#03` como parte obligatoria del agregado si no hay bootstrap para ese servidor. +10. Al completar la implementación: + - dejar el repositorio consistente + - hacer commit + - hacer push al remoto si el entorno lo permite + +## Files to Read First +- AGENTS.md +- backend/README.md +- backend/app/historical_storage.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- backend/app/payloads.py +- backend/app/routes.py +- frontend/assets/js/historico.js +- frontend/historico.html + +## Expected Files to Modify +- backend/app/historical_storage.py +- backend/app/historical_snapshots.py +- backend/app/historical_snapshot_storage.py +- backend/app/payloads.py +- backend/app/routes.py +- backend/README.md +- opcionalmente frontend/assets/js/historico.js si hace falta ajustar cómo se interpreta el agregado global + +## Constraints +- No romper #01 ni #02. +- No exigir histórico de #03 para que `all-servers` funcione. +- No crear páginas nuevas. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en arreglar el agregado global. + +## Validation +- `Totales / Todos` deja de estar vacío. +- El resumen global tiene partidas, jugadores y mapas si #01 y #02 tienen histórico. +- Los tops globales funcionan. +- Las partidas recientes globales funcionan. +- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite. + +## Change Budget +- Preferir menos de 6 archivos modificados o creados. +- Preferir menos de 240 líneas cambiadas. diff --git a/ai/tasks/done/TASK-056-historical-empty-state-and-copy-polish.md b/ai/tasks/done/TASK-056-historical-empty-state-and-copy-polish.md new file mode 100644 index 0000000..f3f6af3 --- /dev/null +++ b/ai/tasks/done/TASK-056-historical-empty-state-and-copy-polish.md @@ -0,0 +1,73 @@ +# TASK-056-historical-empty-state-and-copy-polish + +## Goal +Mejorar los estados vacíos y el copy de la página histórica para que `comunidad-hispana-03` se muestre de forma honesta mientras no tenga bootstrap ejecutado, y para que la información de cobertura y fallback semanal suene más natural y menos técnica. + +## Context +Tras los últimos cambios, la página histórica ya funciona para #01 y #02, pero aún hay aspectos de UX/copy mejorables: +- `comunidad-hispana-03` aparece vacía, lo cual ahora mismo es normal porque no se ha ejecutado bootstrap para ese servidor +- algunos textos siguen sonando demasiado técnicos +- labels como `672,1 días registrados` no son la mejor forma de presentar cobertura histórica +- el texto del fallback semanal es correcto, pero demasiado largo y técnico + +## Steps +1. Revisar los textos actuales de: + - resumen + - badges de cobertura + - periodo registrado + - fallback semanal + - estados vacíos de resumen / tops / recientes +2. Hacer que `comunidad-hispana-03` muestre un estado explícito y honesto del tipo: + - sin histórico registrado todavía + - pendiente de bootstrap / pendiente de registro histórico + - o equivalente mejor, siempre claro y natural +3. Evitar que `#03` parezca un error roto si simplemente aún no se ha cargado histórico. +4. Revisar la forma de mostrar la cobertura histórica. Priorizar formulaciones más naturales como: + - cobertura histórica + - desde X hasta Y + - periodo registrado + sobre expresiones demasiado técnicas o poco naturales como el número decimal de días aislado. +5. Simplificar el texto del fallback semanal para que comunique: + - que se está mostrando la última semana cerrada + - porque la semana actual aún no tiene suficiente actividad + sin meter demasiado detalle técnico en el bloque principal +6. Mantener la estética actual de la página histórica. +7. No cambiar la lógica de negocio más allá de lo necesario para mostrar estados/copy correctos. +8. No crear páginas nuevas. +9. Al completar la implementación: + - dejar el repositorio consistente + - hacer commit + - hacer push al remoto si el entorno lo permite + +## Files to Read First +- AGENTS.md +- frontend/historico.html +- frontend/assets/js/historico.js +- frontend/assets/css/historico.css +- backend/app/payloads.py +- backend/README.md + +## Expected Files to Modify +- frontend/historico.html +- frontend/assets/js/historico.js +- frontend/assets/css/historico.css +- opcionalmente backend/app/payloads.py si hace falta ajustar labels o metadatos presentables +- opcionalmente backend/README.md si el comportamiento visible cambia y conviene dejarlo documentado + +## Constraints +- No romper la UI histórica que ya funciona para #01 y #02. +- No convertir esta task en un rediseño grande. +- No introducir frameworks nuevos. +- No hacer cambios destructivos. +- Mantener el trabajo centrado en estados vacíos y copy. + +## Validation +- `#03` deja de parecer un fallo ambiguo y muestra un estado vacío claro y honesto. +- La cobertura histórica se presenta de forma más natural. +- El texto del fallback semanal se entiende mejor. +- La UI sigue siendo coherente con la landing y con el resto de la página histórica. +- Los cambios quedan committeados y se hace push al remoto si el entorno lo permite. + +## Change Budget +- Preferir menos de 5 archivos modificados. +- Preferir menos de 220 líneas cambiadas. diff --git a/backend/README.md b/backend/README.md index a84a584..d4ee275 100644 --- a/backend/README.md +++ b/backend/README.md @@ -593,6 +593,9 @@ persistida por servidor para releer solo paginas recientes y absorber updates tardios sin reimportar todo el historico. Cuando una ejecucion termina correctamente, tambien recompone los snapshots historicos precalculados para el servidor afectado o para todos los servidores si la ingesta fue global. +Si la recomposicion se lanza para un servidor fisico concreto, el backend +rehace tambien el agregado logico `all-servers` para mantener `Todos` +alineado con `#01` y `#02` aunque `#03` siga sin bootstrap. El comando devuelve ademas un resumen de cobertura persistida por servidor. Esto ayuda a validar rapidamente cuantos matches reales quedaron importados, el rango diff --git a/backend/app/historical_snapshots.py b/backend/app/historical_snapshots.py index 92900a2..ab5da74 100644 --- a/backend/app/historical_snapshots.py +++ b/backend/app/historical_snapshots.py @@ -161,7 +161,7 @@ def build_all_historical_snapshots( db_path: Path | None = None, ) -> list[dict[str, object]]: """Build the full snapshot set for one server or for all configured servers.""" - target_server_keys = [server_key] if server_key else list_snapshot_server_keys(db_path=db_path) + target_server_keys = _resolve_snapshot_target_keys(server_key=server_key, db_path=db_path) snapshots: list[dict[str, object]] = [] for target_server_key in target_server_keys: snapshots.extend( @@ -340,6 +340,24 @@ def _build_recent_matches_snapshot( } +def _resolve_snapshot_target_keys( + *, + server_key: str | None, + db_path: Path | None = None, +) -> list[str]: + """Expand targeted rebuilds so the logical global aggregate stays in sync.""" + if not server_key: + return list_snapshot_server_keys(db_path=db_path) + + normalized_server_key = server_key.strip() + if not normalized_server_key: + return list_snapshot_server_keys(db_path=db_path) + if normalized_server_key == ALL_SERVERS_SLUG: + return [ALL_SERVERS_SLUG] + + return [normalized_server_key, ALL_SERVERS_SLUG] + + def _parse_optional_timestamp(value: object) -> datetime | None: if not isinstance(value, str) or not value.strip(): return None diff --git a/frontend/assets/js/historico.js b/frontend/assets/js/historico.js index 05ef83b..1f68c68 100644 --- a/frontend/assets/js/historico.js +++ b/frontend/assets/js/historico.js @@ -370,6 +370,7 @@ function isActiveLeaderboardRequest( } function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNode) { + const emptyState = getHistoricalEmptyState(activeServerSlug); if (result.status !== "fulfilled") { renderSummaryError(summaryNode); setRangeBadge(rangeNode, "Snapshot de resumen no disponible", false); @@ -381,12 +382,18 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo const payload = result.value?.data; const summary = payload?.item; - if (!payload?.found || !summary) { - renderSummaryEmpty(summaryNode); - setRangeBadge(rangeNode, "Sin snapshot de resumen", false); - noteNode.textContent = - "Todavia no existe un snapshot de resumen listo para el alcance seleccionado."; - setSnapshotMeta(snapshotMetaNode, "Snapshot de resumen pendiente de generacion."); + const hasHistoricalData = + Number(summary?.imported_matches_count ?? summary?.matches_count ?? 0) > 0; + if (!payload?.found || !summary || !hasHistoricalData) { + renderSummaryEmpty(summaryNode, emptyState.summaryMessage); + setRangeBadge(rangeNode, emptyState.rangeLabel, false); + noteNode.textContent = emptyState.summaryNote; + setSnapshotMeta( + snapshotMetaNode, + payload?.generated_at + ? buildSnapshotMetaText(payload, "Snapshot de resumen pendiente de generacion.") + : "Snapshot de resumen pendiente de generacion.", + ); return; } @@ -395,10 +402,10 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo const rangeLabel = buildCoverageBadgeLabel(coverage, { start: payload?.source_range_start || timeRange.start, end: payload?.source_range_end || timeRange.end, - }); + }, summary.server?.slug); setRangeBadge( rangeNode, - rangeLabel || "Cobertura registrada parcial", + rangeLabel || "Cobertura historica disponible", coverage.status === "week-plus" && !payload?.is_stale, ); noteNode.textContent = buildSummaryNote( @@ -419,11 +426,11 @@ function hydrateSummary(result, summaryNode, rangeNode, noteNode, snapshotMetaNo ), renderSummaryCard("Jugadores unicos", formatNumber(summary.unique_players)), renderSummaryCard( - "Periodo registrado", - formatCoverageDays(coverage.coverage_days), + "Cobertura historica", + buildCoveragePeriodLabel(coverage, timeRange, summary.server?.slug), ), - renderSummaryCard("Primera partida", formatTimestamp(coverage.first_match_at)), - renderSummaryCard("Ultima partida", formatTimestamp(coverage.last_match_at)), + renderSummaryCard("Inicio de registro", formatTimestamp(coverage.first_match_at)), + renderSummaryCard("Ultimo cierre", formatTimestamp(coverage.last_match_at)), renderSummaryCard( "Mapas frecuentes", formatTopMaps(summary.top_maps), @@ -442,6 +449,7 @@ function hydrateWeeklyLeaderboard( snapshotMetaNode, metricConfig, ) { + const targetServerSlug = result.value?.data?.server_slug || activeServerSlug; valueHeadingNode.textContent = metricConfig.valueHeading; if (result.status !== "fulfilled") { titleNode.textContent = metricConfig.title; @@ -461,14 +469,14 @@ function hydrateWeeklyLeaderboard( buildSnapshotMetaText(payload, "Snapshot semanal pendiente de generacion."), ); if (!payload?.found) { - setState(stateNode, metricConfig.emptyMessage); + setState(stateNode, buildLeaderboardEmptyMessage(metricConfig, targetServerSlug)); tableNode.hidden = true; return; } const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { - setState(stateNode, metricConfig.emptyMessage); + setState(stateNode, buildLeaderboardEmptyMessage(metricConfig, targetServerSlug)); tableNode.hidden = true; return; } @@ -490,6 +498,7 @@ function hydrateWeeklyLeaderboard( } function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { + const emptyState = getHistoricalEmptyState(activeServerSlug); if (result.status !== "fulfilled") { setState(stateNode, "No se pudieron cargar las partidas recientes.", true); setSnapshotMeta(snapshotMetaNode, "Error al leer el snapshot de partidas."); @@ -502,13 +511,13 @@ function hydrateRecentMatches(result, stateNode, listNode, snapshotMetaNode) { buildSnapshotMetaText(payload, "Snapshot de partidas pendiente de generacion."), ); if (!payload?.found) { - setState(stateNode, "Todavia no hay snapshot de partidas recientes disponible."); + setState(stateNode, emptyState.recentMessage); return; } const items = payload?.items; if (!Array.isArray(items) || items.length === 0) { - setState(stateNode, "Todavia no hay partidas recientes disponibles."); + setState(stateNode, emptyState.recentMessage); return; } @@ -557,8 +566,8 @@ function renderSummaryError(summaryNode) { summaryNode.innerHTML = renderSummaryCard("Estado", "Error al cargar el resumen"); } -function renderSummaryEmpty(summaryNode) { - summaryNode.innerHTML = renderSummaryCard("Estado", "Sin datos historicos suficientes"); +function renderSummaryEmpty(summaryNode, message = "Sin datos historicos suficientes") { + summaryNode.innerHTML = renderSummaryCard("Estado", message); } function renderSummaryCard(label, value) { @@ -655,16 +664,39 @@ function buildRangeLabel(start, end) { return `${formatTimestamp(start)} a ${formatTimestamp(end)}`; } -function buildCoverageBadgeLabel(coverage, timeRange) { - const coverageDays = formatCoverageDays(coverage?.coverage_days); - const rangeLabel = buildRangeLabel( - coverage?.first_match_at || timeRange?.start, - coverage?.last_match_at || timeRange?.end, - ); - if (coverageDays !== "Cobertura no disponible" && rangeLabel) { - return `${coverageDays} registrados`; +function buildCoverageBadgeLabel(coverage, timeRange, serverSlug) { + const rangeStart = coverage?.first_match_at || timeRange?.start; + const rangeEnd = coverage?.last_match_at || timeRange?.end; + if (!rangeStart && !rangeEnd) { + return serverSlug === "comunidad-hispana-03" + ? "Pendiente de historico" + : "Sin cobertura registrada"; } - return rangeLabel; + if (coverage?.status === "under-week") { + return "Cobertura inicial"; + } + if (coverage?.status === "week-plus") { + return "Cobertura historica"; + } + return "Periodo registrado"; +} + +function buildCoveragePeriodLabel(coverage, timeRange, serverSlug) { + const start = coverage?.first_match_at || timeRange?.start; + const end = coverage?.last_match_at || timeRange?.end; + if (start && end) { + return `Desde ${formatDateOnly(start)} hasta ${formatDateOnly(end)}`; + } + if (start) { + return `Desde ${formatDateOnly(start)}`; + } + if (end) { + return `Hasta ${formatDateOnly(end)}`; + } + if (serverSlug === "comunidad-hispana-03") { + return "Pendiente de bootstrap historico"; + } + return "Sin cobertura registrada"; } function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) { @@ -672,21 +704,25 @@ function buildSummaryNote(summaryBasis, weeklyWindowDays, coverage, serverSlug) summaryBasis === "snapshot-precomputed" ? "el snapshot precalculado del historico local" : "el historico persistido disponible"; - const weeklyWindowLabel = Number.isFinite(Number(weeklyWindowDays)) - ? `${weeklyWindowDays} dias` - : "la ultima semana"; const status = coverage?.status; + void weeklyWindowDays; + if (serverSlug === "comunidad-hispana-03" && status === "empty") { + return "Comunidad Hispana #03 todavia no tiene historico registrado. Este bloque se activara cuando se ejecute su primer bootstrap."; + } if (status === "under-week") { - return `Este bloque resume ${basisLabel}. Ahora mismo esa cobertura todavia no alcanza ${weeklyWindowLabel}.`; + return `Este bloque resume ${basisLabel}. La cobertura registrada todavia es inicial y puede crecer en los proximos dias.`; } if (serverSlug === "all-servers") { - return `Resumen global servido desde ${basisLabel}.`; + return `Resumen global servido desde ${basisLabel} y combinado solo con los servidores que ya tienen historico disponible.`; } return `Resumen servido desde ${basisLabel}.`; } function buildWeeklyWindowNote(payload) { if (!payload?.found) { + if ((payload?.server_slug || activeServerSlug) === "comunidad-hispana-03") { + return "El ranking semanal aparecera cuando Comunidad Hispana #03 tenga su primer bootstrap historico."; + } return "No existe un snapshot semanal listo para esta metrica en el alcance activo."; } @@ -694,24 +730,25 @@ function buildWeeklyWindowNote(payload) { const end = formatTimestamp(payload?.window_end); const windowLabel = payload?.window_label || "Semana activa"; if (payload?.uses_fallback) { - const minimumMatches = - payload?.sufficient_sample?.minimum_closed_matches || payload?.minimum_closed_matches || 0; const currentWeekMatches = payload?.current_week_closed_matches ?? payload?.sufficient_sample?.current_week_closed_matches ?? 0; - return `${windowLabel}: ${start} a ${end}. Se muestra temporalmente porque la semana actual solo tiene ${formatNumber(currentWeekMatches)} cierres y el minimo operativo es ${formatNumber(minimumMatches)}.`; + return `${windowLabel}: ${start} a ${end}. Se muestra la ultima semana cerrada porque la actual todavia solo suma ${formatNumber(currentWeekMatches)} cierres.`; } return `${windowLabel}: ${start} a ${end}.`; } function buildLeaderboardTitle(metricConfig, serverSlug) { - return `${metricConfig.title} · ${getHistoricalServerLabel(serverSlug)}`; + return `${metricConfig.title} - ${getHistoricalServerLabel(serverSlug)}`; } function buildRecentMatchesNote(serverSlug) { if (serverSlug === "all-servers") { - return "Lista de cierres ya registrados para todos los servidores."; + return "Lista de cierres ya registrados para los servidores con historico disponible."; + } + if (serverSlug === "comunidad-hispana-03") { + return "Este bloque quedara activo cuando Comunidad Hispana #03 tenga su primer registro historico."; } return `Lista de cierres ya registrados para ${getHistoricalServerLabel(serverSlug)}.`; } @@ -746,15 +783,19 @@ function formatTopMaps(topMaps) { .join(" / "); } -function formatCoverageDays(value) { - const parsedValue = Number(value); - if (!Number.isFinite(parsedValue) || parsedValue <= 0) { - return "Cobertura no disponible"; +function formatDateOnly(timestamp) { + if (!timestamp) { + return "Fecha no disponible"; } - return `${new Intl.NumberFormat("es-ES", { - maximumFractionDigits: 1, - minimumFractionDigits: parsedValue < 10 ? 1 : 0, - }).format(parsedValue)} dias`; + + const value = new Date(timestamp); + if (Number.isNaN(value.getTime())) { + return "Fecha no disponible"; + } + + return new Intl.DateTimeFormat("es-ES", { + dateStyle: "medium", + }).format(value); } function formatMatchResult(result) { @@ -893,3 +934,31 @@ function escapeHtml(value) { .replaceAll('"', """) .replaceAll("'", "'"); } + +function buildLeaderboardEmptyMessage(metricConfig, serverSlug) { + if (serverSlug === "comunidad-hispana-03") { + return "Comunidad Hispana #03 todavia no tiene historico registrado para mostrar este ranking."; + } + return metricConfig.emptyMessage; +} + +function getHistoricalEmptyState(serverSlug) { + if (serverSlug === "comunidad-hispana-03") { + return { + rangeLabel: "Pendiente de historico", + summaryMessage: "Sin historico registrado todavia", + summaryNote: + "Comunidad Hispana #03 sigue pendiente de bootstrap historico. Cuando se registren sus primeras partidas, aqui apareceran su cobertura y sus totales.", + recentMessage: + "Comunidad Hispana #03 todavia no tiene partidas historicas registradas.", + }; + } + + return { + rangeLabel: "Sin cobertura registrada", + summaryMessage: "Sin datos historicos suficientes", + summaryNote: + "Todavia no existe un snapshot de resumen listo para el alcance seleccionado.", + recentMessage: "Todavia no hay partidas recientes disponibles.", + }; +}