Fix historical aggregate snapshots and empty states

This commit is contained in:
devRaGonSa
2026-03-23 14:48:11 +01:00
parent 7a002a0e72
commit f35ce58a84
5 changed files with 289 additions and 46 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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('"', "&quot;")
.replaceAll("'", "&#39;");
}
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.",
};
}