pablogrois Claude Opus 4.8 commited on
Commit
5409901
·
1 Parent(s): e54caf0

Web: selector de liga/temporada (data completa) + post-partido funcional

Browse files

- routes_meta dataset-driven: ligas/temporadas/equipos/partidos salen del dataset
bundleado, intersecados con lo que tiene preprocessed en Azure ("data completa",
auto-configurable). datastore.available_league_seasons() lista Azure.
- web_meta.py: provee teams/matches/resolve por liga-temporada desde el bundle
(subset ahora incluye is_home para armar home/away).
- home.html: dropdowns de liga/temporada que recargan equipos y partidos; card de
post-partido funcional (elegís un partido JUGADO -> genera). Solo se ofrecen
partidos reales => no se puede generar uno inexistente.
- post_match habilitado en el endpoint /run (gateado por NN + match_id requerido);
post_match.generate devuelve ReportBundle para verse en la UI moderna.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/racing_reports/api/routes_meta.py CHANGED
@@ -1,35 +1,74 @@
1
- """Endpoints meta: ligas, equipos, partidos, salud."""
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
- import json
6
  from datetime import date
 
7
 
8
  from fastapi import APIRouter, Depends, HTTPException
9
 
10
- from racing_reports import data_cache, vendor_env
11
  from racing_reports.api.deps import require_auth
12
  from racing_reports.api.schemas import HealthResponse, MatchCandidate
13
  from racing_reports.config import DEFAULT_SETTINGS
14
  from racing_reports.datastore import DataStore
15
- from racing_reports.match_index import MatchIndex
16
  from racing_reports.utils import git_commit
17
- from pathlib import Path
18
 
19
  router = APIRouter(prefix="/api")
20
 
21
- # Las ligas y temporadas hoy son single: SSD 25-26. Multi-liga es para más
22
- # adelante; igual exponemos los endpoints para que el frontend no asuma.
23
- SUPPORTED_LEAGUES = ["Spanish Segunda Division"]
24
- SUPPORTED_SEASONS = {"Spanish Segunda Division": ["25-26"]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
  @router.get("/health", response_model=HealthResponse)
28
  def health() -> HealthResponse:
29
- # Check si el preprocessed default ya está en RAM (indica corrida "caliente")
30
  ds = DataStore()
31
- pre_path = ds.preprocessed_path(SUPPORTED_LEAGUES[0], SUPPORTED_SEASONS[SUPPORTED_LEAGUES[0]][0])
32
- cached = data_cache.is_cached(pre_path)
33
  return HealthResponse(
34
  ok=True,
35
  nn_enabled=DEFAULT_SETTINGS.enable_nn_predictions,
@@ -40,33 +79,28 @@ def health() -> HealthResponse:
40
 
41
  @router.get("/leagues", dependencies=[Depends(require_auth)])
42
  def leagues() -> list[str]:
43
- return list(SUPPORTED_LEAGUES)
44
 
45
 
46
  @router.get("/seasons", dependencies=[Depends(require_auth)])
47
  def seasons(league: str) -> list[str]:
48
- if league not in SUPPORTED_SEASONS:
 
49
  raise HTTPException(status_code=404, detail=f"Liga desconocida: {league}")
50
- return list(SUPPORTED_SEASONS[league])
51
 
52
 
53
  @router.get("/teams", dependencies=[Depends(require_auth)])
54
  def teams(league: str, season: str) -> list[str]:
55
- try:
56
- ids = json.loads(vendor_env.team_ids_path().read_text(encoding="utf-8"))
57
- except FileNotFoundError as exc:
58
- raise HTTPException(status_code=503, detail="team_ids no disponible") from exc
59
- return sorted(ids.keys())
60
 
61
 
62
  @router.get("/matches", dependencies=[Depends(require_auth)])
63
  def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
64
- idx = MatchIndex(DataStore())
65
- df = idx.build(league, season)
66
- if played_only:
67
- today = date.today().isoformat()
68
- df = df[df["date"] <= today]
69
- return df.sort_values("date", ascending=False).to_dict("records")
70
 
71
 
72
  @router.get(
@@ -75,24 +109,14 @@ def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
75
  dependencies=[Depends(require_auth)],
76
  )
77
  def resolve(league: str, season: str, team_a: str, team_b: str) -> list[MatchCandidate]:
78
- idx = MatchIndex(DataStore())
79
- df = idx.build(league, season)
80
- a, b = team_a.strip().casefold(), team_b.strip().casefold()
81
- pair = df[
82
- ((df["home_team"].str.casefold() == a) & (df["away_team"].str.casefold() == b))
83
- | ((df["home_team"].str.casefold() == b) & (df["away_team"].str.casefold() == a))
84
- ].copy()
85
- today = date.today().isoformat()
86
- out = []
87
- for _, m in pair.iterrows():
88
- status = "played" if str(m["date"]) and str(m["date"]) <= today else "future"
89
- out.append(
90
- MatchCandidate(
91
- match_id=str(m["match_id"]),
92
- date=str(m["date"] or ""),
93
- home_team=str(m["home_team"]),
94
- away_team=str(m["away_team"]),
95
- status=status,
96
- )
97
  )
98
- return out
 
 
1
+ """Endpoints meta: ligas, temporadas, equipos, partidos, salud.
2
+
3
+ Las ligas/temporadas salen del dataset de modelado bundleado (web_meta) y, si se
4
+ puede listar Azure, se filtran a las que tienen el preprocessed subido ("data
5
+ completa"). Así los selectores andan offline para todas las ligas del modelo y
6
+ sólo se ofrecen las que realmente pueden generar el reporte completo.
7
+ """
8
 
9
  from __future__ import annotations
10
 
11
+ import time
12
  from datetime import date
13
+ from pathlib import Path
14
 
15
  from fastapi import APIRouter, Depends, HTTPException
16
 
17
+ from racing_reports import data_cache, web_meta
18
  from racing_reports.api.deps import require_auth
19
  from racing_reports.api.schemas import HealthResponse, MatchCandidate
20
  from racing_reports.config import DEFAULT_SETTINGS
21
  from racing_reports.datastore import DataStore
 
22
  from racing_reports.utils import git_commit
 
23
 
24
  router = APIRouter(prefix="/api")
25
 
26
+ _AVAIL_TTL_SECONDS = 1800
27
+ _avail_cache: dict[str, object] = {"ts": 0.0, "value": None}
28
+
29
+
30
+ def _league_seasons() -> dict[str, list[str]]:
31
+ """{liga: [temporadas]} ofrecidas: dataset ∩ (disponible en Azure si se sabe)."""
32
+ in_dataset = web_meta.league_seasons() if web_meta.has_dataset() else {}
33
+ if not in_dataset:
34
+ return {}
35
+ now = time.time()
36
+ if now - float(_avail_cache["ts"]) > _AVAIL_TTL_SECONDS:
37
+ try:
38
+ _avail_cache["value"] = DataStore().available_league_seasons()
39
+ except Exception:
40
+ _avail_cache["value"] = None
41
+ _avail_cache["ts"] = now
42
+ avail = _avail_cache["value"]
43
+ if not avail:
44
+ # No se pudo listar Azure (dev / sin credenciales): ofrecer todo el dataset.
45
+ return in_dataset
46
+ out: dict[str, list[str]] = {}
47
+ for league, seasons in in_dataset.items():
48
+ a = avail.get(league)
49
+ if not a:
50
+ continue
51
+ keep = [s for s in seasons if s in a]
52
+ if keep:
53
+ out[league] = keep
54
+ # Si la intersección quedó vacía (p.ej. naming distinto), no dejar al usuario
55
+ # sin nada: caer al dataset completo.
56
+ return out or in_dataset
57
+
58
+
59
+ def _default_league_season() -> tuple[str, str]:
60
+ ls = _league_seasons()
61
+ if not ls:
62
+ return ("Spanish Segunda Division", "25-26")
63
+ league = "Spanish Segunda Division" if "Spanish Segunda Division" in ls else next(iter(ls))
64
+ return (league, ls[league][0])
65
 
66
 
67
  @router.get("/health", response_model=HealthResponse)
68
  def health() -> HealthResponse:
69
+ league, season = _default_league_season()
70
  ds = DataStore()
71
+ cached = data_cache.is_cached(ds.preprocessed_path(league, season))
 
72
  return HealthResponse(
73
  ok=True,
74
  nn_enabled=DEFAULT_SETTINGS.enable_nn_predictions,
 
79
 
80
  @router.get("/leagues", dependencies=[Depends(require_auth)])
81
  def leagues() -> list[str]:
82
+ return list(_league_seasons().keys())
83
 
84
 
85
  @router.get("/seasons", dependencies=[Depends(require_auth)])
86
  def seasons(league: str) -> list[str]:
87
+ ls = _league_seasons()
88
+ if league not in ls:
89
  raise HTTPException(status_code=404, detail=f"Liga desconocida: {league}")
90
+ return list(ls[league])
91
 
92
 
93
  @router.get("/teams", dependencies=[Depends(require_auth)])
94
  def teams(league: str, season: str) -> list[str]:
95
+ names = web_meta.teams(league, season)
96
+ if not names:
97
+ raise HTTPException(status_code=404, detail=f"Sin equipos para {league} {season}")
98
+ return names
 
99
 
100
 
101
  @router.get("/matches", dependencies=[Depends(require_auth)])
102
  def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
103
+ return web_meta.matches(league, season, played_only=played_only)
 
 
 
 
 
104
 
105
 
106
  @router.get(
 
109
  dependencies=[Depends(require_auth)],
110
  )
111
  def resolve(league: str, season: str, team_a: str, team_b: str) -> list[MatchCandidate]:
112
+ pairs = web_meta.resolve_pair(league, season, team_a, team_b)
113
+ return [
114
+ MatchCandidate(
115
+ match_id=m["match_id"],
116
+ date=m["date"],
117
+ home_team=m["home_team"],
118
+ away_team=m["away_team"],
119
+ status=m["status"],
 
 
 
 
 
 
 
 
 
 
 
120
  )
121
+ for m in pairs
122
+ ]
src/racing_reports/api/routes_reports.py CHANGED
@@ -14,7 +14,7 @@ from racing_reports.runner import ReportRunner
14
 
15
  router = APIRouter(prefix="/api/reports")
16
 
17
- SUPPORTED_REPORTS = {"pre_match", "block_zscore"} # post_match deshabilitado en MVP
18
 
19
 
20
  @router.get(
@@ -60,6 +60,11 @@ def run(report_type: str, payload: RunRequest) -> RunResponse:
60
  raise HTTPException(status_code=404, detail=f"Reporte desconocido: {report_type}")
61
  if not payload.team_a or not payload.team_b:
62
  raise HTTPException(status_code=400, detail="team_a y team_b son requeridos.")
 
 
 
 
 
63
 
64
  settings_blob = {}
65
  if report_type == "block_zscore":
 
14
 
15
  router = APIRouter(prefix="/api/reports")
16
 
17
+ SUPPORTED_REPORTS = {"pre_match", "block_zscore", "post_match"} # post_match requiere NN
18
 
19
 
20
  @router.get(
 
60
  raise HTTPException(status_code=404, detail=f"Reporte desconocido: {report_type}")
61
  if not payload.team_a or not payload.team_b:
62
  raise HTTPException(status_code=400, detail="team_a y team_b son requeridos.")
63
+ if report_type == "post_match" and not payload.match_id:
64
+ raise HTTPException(
65
+ status_code=400,
66
+ detail="Para el post-partido elegí un partido jugado (match_id requerido).",
67
+ )
68
 
69
  settings_blob = {}
70
  if report_type == "block_zscore":
src/racing_reports/datastore.py CHANGED
@@ -109,6 +109,31 @@ class DataStore:
109
  )
110
  return service.get_file_system_client(self.settings.azure_filesystem)
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  def _sync_named_csv(self, league: str, season: str, name: str, force: bool) -> Path:
113
  local = self._csv_path(league, season, name)
114
  if local.exists() and not force:
 
109
  )
110
  return service.get_file_system_client(self.settings.azure_filesystem)
111
 
112
+ def available_league_seasons(self) -> dict[str, list[str]] | None:
113
+ """{liga: [temporadas]} que tienen el preprocessed subido en Azure.
114
+
115
+ Devuelve None si no se puede listar (sin credenciales / dev local / error):
116
+ el caller debe interpretar None como "desconocido, no filtrar".
117
+ """
118
+ if self.local_data_dir is not None:
119
+ return None
120
+ name = self.settings.preprocessed_name
121
+ root = self.settings.azure_preprocessed_root.strip("/")
122
+ try:
123
+ fs = self._filesystem_client()
124
+ found: dict[str, set[str]] = {}
125
+ for p in fs.get_paths(path=root, recursive=True):
126
+ path_name = getattr(p, "name", "") or ""
127
+ rel = path_name[len(root):].lstrip("/") if path_name.startswith(root) else path_name
128
+ parts = rel.split("/")
129
+ if len(parts) >= 3 and parts[-1] == name:
130
+ found.setdefault(parts[-3], set()).add(parts[-2])
131
+ if not found:
132
+ return None
133
+ return {lg: sorted(ss, reverse=True) for lg, ss in sorted(found.items())}
134
+ except Exception:
135
+ return None
136
+
137
  def _sync_named_csv(self, league: str, season: str, name: str, force: bool) -> Path:
138
  local = self._csv_path(league, season, name)
139
  if local.exists() and not force:
src/racing_reports/reports/post_match.py CHANGED
@@ -12,6 +12,7 @@ from pathlib import Path
12
  import pandas as pd
13
 
14
  from racing_reports import vendor_env
 
15
 
16
 
17
  class MatchNotPlayedError(RuntimeError):
@@ -31,7 +32,7 @@ def generate(
31
  title: str,
32
  season: str | None = None,
33
  match_date: str | None = None,
34
- ) -> Path:
35
  gen = vendor_env.patch_helper_paths(
36
  preprocessed_csv=preprocessed_csv, matches_csv=matches_csv
37
  )
@@ -82,4 +83,19 @@ def generate(
82
  section = pm.build_match_section(match_cfg, df_model, df_match, zone_df, league)
83
  out_path.parent.mkdir(parents=True, exist_ok=True)
84
  pm.build_html([section], out_path, match_cfg["title"])
85
- return out_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  import pandas as pd
13
 
14
  from racing_reports import vendor_env
15
+ from racing_reports.reports.bundle import ReportBundle
16
 
17
 
18
  class MatchNotPlayedError(RuntimeError):
 
32
  title: str,
33
  season: str | None = None,
34
  match_date: str | None = None,
35
+ ) -> ReportBundle:
36
  gen = vendor_env.patch_helper_paths(
37
  preprocessed_csv=preprocessed_csv, matches_csv=matches_csv
38
  )
 
83
  section = pm.build_match_section(match_cfg, df_model, df_match, zone_df, league)
84
  out_path.parent.mkdir(parents=True, exist_ok=True)
85
  pm.build_html([section], out_path, match_cfg["title"])
86
+ return ReportBundle(
87
+ report_type="post_match",
88
+ html_path=out_path,
89
+ figures=[],
90
+ tables=[],
91
+ warnings=["Las 7 figuras del post-partido están embebidas: abrí 'Ver HTML completo'."],
92
+ meta={
93
+ "league": league,
94
+ "season": season or "",
95
+ "match_id": str(match_id),
96
+ "home_name": home_name,
97
+ "away_name": away_name,
98
+ "home_goals": home_goals,
99
+ "away_goals": away_goals,
100
+ },
101
+ )
src/racing_reports/web/app.py CHANGED
@@ -95,17 +95,19 @@ def login(request: Request, password: str = Form(...)):
95
 
96
  # ── home (SPA nuevo) ──────────────────────────────────────────────────────
97
  @app.get("/", response_class=HTMLResponse)
98
- def home(request: Request, league: str = DEFAULT_LEAGUE, season: str = DEFAULT_SEASON):
99
  if not _password_ok(request):
100
  return RedirectResponse(url="/login", status_code=303)
 
 
 
101
  return templates.TemplateResponse(
102
  request,
103
  "home.html",
104
  {
105
  "request": request,
106
- "league": league,
107
- "season": season,
108
- "teams": _team_list(),
109
  "nn_enabled": DEFAULT_SETTINGS.enable_nn_predictions,
110
  },
111
  )
 
95
 
96
  # ── home (SPA nuevo) ──────────────────────────────────────────────────────
97
  @app.get("/", response_class=HTMLResponse)
98
+ def home(request: Request, league: str | None = None, season: str | None = None):
99
  if not _password_ok(request):
100
  return RedirectResponse(url="/login", status_code=303)
101
+ from racing_reports.api.routes_meta import _default_league_season
102
+
103
+ def_league, def_season = _default_league_season()
104
  return templates.TemplateResponse(
105
  request,
106
  "home.html",
107
  {
108
  "request": request,
109
+ "league": league or def_league,
110
+ "season": season or def_season,
 
111
  "nn_enabled": DEFAULT_SETTINGS.enable_nn_predictions,
112
  },
113
  )
src/racing_reports/web/templates/home.html CHANGED
@@ -21,7 +21,6 @@
21
  <style>
22
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
23
  [x-cloak] { display: none !important; }
24
- /* contenido de tablas preview embebido vía x-html */
25
  .table-preview table { width:100%; border-collapse: collapse; font-size: 12px; }
26
  .table-preview th, .table-preview td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }
27
  .table-preview th { color: #9fb4ab; font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; }
@@ -31,16 +30,26 @@
31
 
32
  <!-- Header -->
33
  <header class="bg-gradient-to-r from-racing-700 to-ink-900 border-b border-racing-700/50">
34
- <div class="max-w-6xl mx-auto px-4 py-5 flex items-center justify-between">
35
  <div>
36
  <h1 class="text-2xl font-bold">⚽ Reportes Racing</h1>
37
- <p class="text-sm text-racing-200">Pre-partido y bloques · data en Azure, reportes on-demand</p>
38
  </div>
39
  <div class="flex gap-3 items-center text-sm">
40
- <span class="text-gray-400">Liga:</span>
41
- <span class="font-medium" x-text="league"></span>
42
- <span class="text-gray-400 ml-2">Temporada:</span>
43
- <span class="font-medium" x-text="season"></span>
 
 
 
 
 
 
 
 
 
 
44
  </div>
45
  </div>
46
  </header>
@@ -49,18 +58,18 @@
49
 
50
  <!-- Setup -->
51
  <section class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
52
- <h2 class="text-lg font-semibold text-racing-500 mb-4">1. Partido / Equipos</h2>
53
  <div class="grid sm:grid-cols-2 gap-4">
54
  <div>
55
  <label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo A</label>
56
  <select x-model="teamA" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
57
- {% for t in teams %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
58
  </select>
59
  </div>
60
  <div>
61
  <label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo B</label>
62
  <select x-model="teamB" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
63
- {% for t in teams %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
64
  </select>
65
  </div>
66
  </div>
@@ -76,7 +85,8 @@
76
  <div class="flex items-start justify-between mb-3 gap-4">
77
  <div>
78
  <h3 class="font-semibold">Reporte previo (head-to-head)</h3>
79
- <p class="text-xs text-gray-400 mt-1">15 figuras: zonas, presión, win/loss, local/visitante. Predicciones NN deshabilitadas en MVP.</p>
 
80
  </div>
81
  </div>
82
  <div class="flex flex-wrap gap-2">
@@ -90,15 +100,14 @@
90
  <span x-show="reports.pre_match.catalogOpen">Ocultar catálogo</span>
91
  </button>
92
  </div>
93
- <div x-show="reports.pre_match.catalogOpen" class="mt-4 grid sm:grid-cols-3 gap-2"
94
- x-transition.duration.150ms>
95
  <template x-for="fig in reports.pre_match.catalog?.figures || []" :key="fig.name">
96
  <button @click="runOne('pre_match', fig.name)"
97
- :disabled="fig.requires_nn"
98
- :class="fig.requires_nn ? 'opacity-40 cursor-not-allowed' : 'hover:border-racing-500'"
99
  class="text-left bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-xs">
100
  <div class="font-medium" x-text="fig.title"></div>
101
- <div class="text-gray-400 mt-1" x-text="fig.section + (fig.requires_nn ? ' · NN (no disponible)' : '')"></div>
102
  </button>
103
  </template>
104
  </div>
@@ -144,10 +153,43 @@
144
  </div>
145
  </article>
146
 
147
- <!-- Post-match card (deshabilitado en MVP) -->
148
- <article class="bg-ink-700 border border-ink-600 rounded-2xl p-5 opacity-60">
149
- <h3 class="font-semibold">Reporte post-partido</h3>
150
- <p class="text-xs text-gray-400 mt-1">Próximamente. Requiere artifacts del modelo NN que todavía no están en la web.</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  </article>
152
  </section>
153
 
@@ -155,7 +197,6 @@
155
  <section x-show="currentJob" class="space-y-4" x-transition.duration.200ms>
156
  <h2 class="text-lg font-semibold text-racing-500">3. Resultados</h2>
157
 
158
- <!-- Progreso -->
159
  <div x-show="jobStatus && jobStatus.status !== 'done' && jobStatus.status !== 'error'"
160
  class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
161
  <div class="flex items-center gap-3">
@@ -166,21 +207,16 @@
166
  </div>
167
  </div>
168
  <p class="text-xs text-gray-500 mt-3" x-show="!preprocessedCached">
169
- Primera corrida después de un cold start: tarda ~3-5 min (baja el preprocessed ~2.5 GB y lo deja en RAM).
170
- </p>
171
- <p class="text-xs text-gray-500 mt-3" x-show="preprocessedCached">
172
- Datos ya en RAM. Cada reporte tarda ~5-15 s.
173
  </p>
 
174
  </div>
175
 
176
- <!-- Error -->
177
- <div x-show="jobStatus?.status === 'error'"
178
- class="bg-red-950/40 border border-red-700/40 rounded-2xl p-5">
179
  <div class="font-medium text-red-300">Falló la generación</div>
180
  <pre class="text-xs text-red-200 mt-2 whitespace-pre-wrap" x-text="jobStatus?.error"></pre>
181
  </div>
182
 
183
- <!-- Done -->
184
  <template x-if="jobStatus?.status === 'done'">
185
  <div class="space-y-4">
186
  <template x-for="(bundle, reportType) in jobStatus.bundles" :key="reportType">
@@ -202,16 +238,12 @@
202
  </div>
203
  </div>
204
 
205
- <!-- Warnings -->
206
  <template x-if="bundle.warnings && bundle.warnings.length">
207
  <div class="bg-yellow-950/30 border border-yellow-700/40 rounded-lg p-3 mb-4 text-xs text-yellow-200">
208
- <template x-for="w in bundle.warnings" :key="w">
209
- <div x-text="w"></div>
210
- </template>
211
  </div>
212
  </template>
213
 
214
- <!-- Filtro visual: mostrar sólo la seleccionada si vino de runOne -->
215
  <div class="grid sm:grid-cols-2 gap-4">
216
  <template x-for="fig in visibleFigures(reportType, bundle.figures)" :key="fig.name">
217
  <figure class="bg-ink-800 border border-ink-600 rounded-xl p-3">
@@ -230,7 +262,6 @@
230
  </template>
231
  </div>
232
 
233
- <!-- Tablas -->
234
  <template x-if="bundle.tables.length">
235
  <div class="mt-6 space-y-3">
236
  <template x-for="table in bundle.tables" :key="table.name">
@@ -240,9 +271,7 @@
240
  <span class="font-medium" x-text="table.title"></span>
241
  <span class="text-xs text-gray-400 ml-2" x-text="table.section"></span>
242
  </div>
243
- <a :href="table.csv_url" download
244
- @click.stop
245
- class="text-racing-500 hover:underline text-xs">Descargar CSV</a>
246
  </summary>
247
  <div class="px-4 pb-4 overflow-x-auto table-preview" x-html="table.preview_html"></div>
248
  </details>
@@ -266,8 +295,13 @@
266
  return {
267
  league: {{ league|tojson }},
268
  season: {{ season|tojson }},
269
- teamA: 'Racing de Santander',
270
- teamB: 'Huesca',
 
 
 
 
 
271
  dateFrom: '',
272
  dateTo: '',
273
  nnEnabled: {{ ('true' if nn_enabled else 'false') }},
@@ -278,27 +312,63 @@
278
  },
279
  currentJob: null,
280
  jobStatus: null,
281
- onlyFigure: null, // si la corrida fue 'runOne', filtra UI
282
  pollTimer: null,
283
 
284
- init() { this._refreshHealth(); },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
  async _refreshHealth() {
287
  try {
288
  const r = await fetch('/api/health');
289
- if (r.ok) {
290
- const d = await r.json();
291
- this.preprocessedCached = !!d.preprocessed_cached;
292
- }
293
  } catch (_e) {}
294
  },
295
 
296
  reportTitle(id) {
297
- return ({
298
- pre_match: 'Reporte previo (head-to-head)',
299
- block_zscore: 'Bloques (z-score)',
300
- post_match: 'Reporte post-partido',
301
- })[id] || id;
302
  },
303
 
304
  visibleFigures(reportType, figs) {
@@ -310,51 +380,50 @@
310
  const r = this.reports[reportId];
311
  r.catalogOpen = !r.catalogOpen;
312
  if (r.catalogOpen && !r.catalog) {
313
- try {
314
- const resp = await fetch(`/api/reports/${reportId}/catalog`);
315
- r.catalog = await resp.json();
316
- } catch (e) {
317
- alert('Error cargando catálogo: ' + e);
318
- r.catalogOpen = false;
319
- }
320
  }
321
  },
322
 
323
- async runFull(reportId) { await this._run(reportId, null); },
324
  async runOne(reportId, figName) { await this._run(reportId, figName); },
325
 
326
  async _run(reportId, figName) {
327
  if (!this.teamA || !this.teamB) { alert('Elegí equipo A y equipo B.'); return; }
328
  if (this.teamA === this.teamB) { alert('Equipo A y B deben ser distintos.'); return; }
329
- const body = {
330
- league: this.league,
331
- season: this.season,
332
- team_a: this.teamA,
333
- team_b: this.teamB,
334
- filters: {
335
- date_from: this.dateFrom || null,
336
- date_to: this.dateTo || null,
337
- },
338
  only: figName ? [figName] : [],
339
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  let resp;
341
  try {
342
  resp = await fetch(`/api/reports/${reportId}/run`, {
343
- method: 'POST',
344
- headers: { 'content-type': 'application/json' },
345
- body: JSON.stringify(body),
346
  });
347
- } catch (e) {
348
- alert('Error de red: ' + e); return;
349
- }
350
  if (!resp.ok) {
351
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
352
- alert('Error: ' + err.detail);
353
- return;
354
  }
355
  const { job_id } = await resp.json();
356
  this.onlyFigure = figName;
357
- this._startPolling(job_id, figName ? `${this.reportTitle(reportId)} · ${figName}` : this.reportTitle(reportId));
358
  },
359
 
360
  _startPolling(jobId, title) {
@@ -363,9 +432,7 @@
363
  if (this.pollTimer) clearInterval(this.pollTimer);
364
  this.pollTimer = setInterval(() => this._poll(), 2000);
365
  this._poll();
366
- setTimeout(() => {
367
- document.querySelector('section > h2')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
368
- }, 100);
369
  },
370
 
371
  async _poll() {
@@ -375,11 +442,9 @@
375
  if (!r.ok) return;
376
  this.jobStatus = await r.json();
377
  if (['done', 'error'].includes(this.jobStatus.status)) {
378
- clearInterval(this.pollTimer);
379
- this.pollTimer = null;
380
- this._refreshHealth(); // cache RAM probablemente cambió
381
  }
382
- } catch (_e) { /* siguiente tick */ }
383
  },
384
  };
385
  }
 
21
  <style>
22
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
23
  [x-cloak] { display: none !important; }
 
24
  .table-preview table { width:100%; border-collapse: collapse; font-size: 12px; }
25
  .table-preview th, .table-preview td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: left; }
26
  .table-preview th { color: #9fb4ab; font-weight: 600; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; }
 
30
 
31
  <!-- Header -->
32
  <header class="bg-gradient-to-r from-racing-700 to-ink-900 border-b border-racing-700/50">
33
+ <div class="max-w-6xl mx-auto px-4 py-5 flex items-center justify-between flex-wrap gap-3">
34
  <div>
35
  <h1 class="text-2xl font-bold">⚽ Reportes Racing</h1>
36
+ <p class="text-sm text-racing-200">Previo, bloques y post-partido · data en Azure, reportes on-demand</p>
37
  </div>
38
  <div class="flex gap-3 items-center text-sm">
39
+ <div>
40
+ <label class="block text-[10px] uppercase tracking-wide text-gray-400">Liga</label>
41
+ <select x-model="league" @change="onLeagueChange()"
42
+ class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-1.5 min-w-[180px]">
43
+ <template x-for="lg in leagues" :key="lg"><option :value="lg" x-text="lg"></option></template>
44
+ </select>
45
+ </div>
46
+ <div>
47
+ <label class="block text-[10px] uppercase tracking-wide text-gray-400">Temporada</label>
48
+ <select x-model="season" @change="onSeasonChange()"
49
+ class="bg-ink-800 border border-ink-600 rounded-lg px-3 py-1.5">
50
+ <template x-for="s in seasons" :key="s"><option :value="s" x-text="s"></option></template>
51
+ </select>
52
+ </div>
53
  </div>
54
  </div>
55
  </header>
 
58
 
59
  <!-- Setup -->
60
  <section class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
61
+ <h2 class="text-lg font-semibold text-racing-500 mb-4">1. Equipos (previo / bloques)</h2>
62
  <div class="grid sm:grid-cols-2 gap-4">
63
  <div>
64
  <label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo A</label>
65
  <select x-model="teamA" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
66
+ <template x-for="t in teams" :key="t"><option :value="t" x-text="t"></option></template>
67
  </select>
68
  </div>
69
  <div>
70
  <label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Equipo B</label>
71
  <select x-model="teamB" class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2">
72
+ <template x-for="t in teams" :key="t"><option :value="t" x-text="t"></option></template>
73
  </select>
74
  </div>
75
  </div>
 
85
  <div class="flex items-start justify-between mb-3 gap-4">
86
  <div>
87
  <h3 class="font-semibold">Reporte previo (head-to-head)</h3>
88
+ <p class="text-xs text-gray-400 mt-1">Zonas, presión, win/loss, local/visitante. Predicción del modelo de ataque
89
+ <span x-show="nnEnabled" class="text-racing-200">activada</span><span x-show="!nnEnabled">deshabilitada (falta RR_ENABLE_NN_PREDICTIONS=1)</span>.</p>
90
  </div>
91
  </div>
92
  <div class="flex flex-wrap gap-2">
 
100
  <span x-show="reports.pre_match.catalogOpen">Ocultar catálogo</span>
101
  </button>
102
  </div>
103
+ <div x-show="reports.pre_match.catalogOpen" class="mt-4 grid sm:grid-cols-3 gap-2" x-transition.duration.150ms>
 
104
  <template x-for="fig in reports.pre_match.catalog?.figures || []" :key="fig.name">
105
  <button @click="runOne('pre_match', fig.name)"
106
+ :disabled="fig.requires_nn && !nnEnabled"
107
+ :class="(fig.requires_nn && !nnEnabled) ? 'opacity-40 cursor-not-allowed' : 'hover:border-racing-500'"
108
  class="text-left bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 text-xs">
109
  <div class="font-medium" x-text="fig.title"></div>
110
+ <div class="text-gray-400 mt-1" x-text="fig.section + (fig.requires_nn && !nnEnabled ? ' · NN (no disponible)' : '')"></div>
111
  </button>
112
  </template>
113
  </div>
 
153
  </div>
154
  </article>
155
 
156
+ <!-- Post-match card -->
157
+ <article class="bg-ink-700 border border-ink-600 rounded-2xl p-5" :class="nnEnabled ? '' : 'opacity-60'">
158
+ <div class="flex items-start justify-between mb-3 gap-4">
159
+ <div>
160
+ <h3 class="font-semibold">Reporte post-partido</h3>
161
+ <p class="text-xs text-gray-400 mt-1">Elegí un partido <strong>ya jugado</strong>: predicción pre-partido (point-in-time) vs lo que ocurrió, por zona.</p>
162
+ </div>
163
+ </div>
164
+
165
+ <template x-if="!nnEnabled">
166
+ <p class="text-xs text-yellow-200 bg-yellow-950/30 border border-yellow-700/40 rounded-lg p-3">
167
+ Deshabilitado: falta la variable <code>RR_ENABLE_NN_PREDICTIONS=1</code> en el Space.
168
+ </p>
169
+ </template>
170
+
171
+ <template x-if="nnEnabled">
172
+ <div>
173
+ <label class="block text-xs uppercase tracking-wide text-gray-400 mb-1">Partido jugado</label>
174
+ <select x-model="selectedMatchId"
175
+ class="w-full bg-ink-800 border border-ink-600 rounded-lg px-3 py-2 mb-3">
176
+ <option value="">— Elegí un partido —</option>
177
+ <template x-for="m in playedMatches" :key="m.match_id">
178
+ <option :value="m.match_id" x-text="`${m.date} · ${m.home_team} vs ${m.away_team}`"></option>
179
+ </template>
180
+ </select>
181
+ <div class="flex items-center gap-3">
182
+ <button @click="runPostMatch()"
183
+ :disabled="!selectedMatchId"
184
+ :class="!selectedMatchId ? 'opacity-40 cursor-not-allowed' : 'hover:bg-racing-600'"
185
+ class="bg-racing-500 text-racing-900 font-semibold px-4 py-2 rounded-lg text-sm">
186
+ Generar post-partido
187
+ </button>
188
+ <span class="text-xs text-gray-400" x-show="!playedMatches.length">No hay partidos jugados para esta liga/temporada.</span>
189
+ <span class="text-xs text-gray-400" x-show="playedMatches.length" x-text="`${playedMatches.length} partidos jugados disponibles`"></span>
190
+ </div>
191
+ </div>
192
+ </template>
193
  </article>
194
  </section>
195
 
 
197
  <section x-show="currentJob" class="space-y-4" x-transition.duration.200ms>
198
  <h2 class="text-lg font-semibold text-racing-500">3. Resultados</h2>
199
 
 
200
  <div x-show="jobStatus && jobStatus.status !== 'done' && jobStatus.status !== 'error'"
201
  class="bg-ink-700 border border-ink-600 rounded-2xl p-5">
202
  <div class="flex items-center gap-3">
 
207
  </div>
208
  </div>
209
  <p class="text-xs text-gray-500 mt-3" x-show="!preprocessedCached">
210
+ Primera corrida después de un cold start: tarda ~3-5 min (baja el preprocessed y lo deja en RAM).
 
 
 
211
  </p>
212
+ <p class="text-xs text-gray-500 mt-3" x-show="preprocessedCached">Datos ya en RAM. Cada reporte tarda ~5-15 s.</p>
213
  </div>
214
 
215
+ <div x-show="jobStatus?.status === 'error'" class="bg-red-950/40 border border-red-700/40 rounded-2xl p-5">
 
 
216
  <div class="font-medium text-red-300">Falló la generación</div>
217
  <pre class="text-xs text-red-200 mt-2 whitespace-pre-wrap" x-text="jobStatus?.error"></pre>
218
  </div>
219
 
 
220
  <template x-if="jobStatus?.status === 'done'">
221
  <div class="space-y-4">
222
  <template x-for="(bundle, reportType) in jobStatus.bundles" :key="reportType">
 
238
  </div>
239
  </div>
240
 
 
241
  <template x-if="bundle.warnings && bundle.warnings.length">
242
  <div class="bg-yellow-950/30 border border-yellow-700/40 rounded-lg p-3 mb-4 text-xs text-yellow-200">
243
+ <template x-for="w in bundle.warnings" :key="w"><div x-text="w"></div></template>
 
 
244
  </div>
245
  </template>
246
 
 
247
  <div class="grid sm:grid-cols-2 gap-4">
248
  <template x-for="fig in visibleFigures(reportType, bundle.figures)" :key="fig.name">
249
  <figure class="bg-ink-800 border border-ink-600 rounded-xl p-3">
 
262
  </template>
263
  </div>
264
 
 
265
  <template x-if="bundle.tables.length">
266
  <div class="mt-6 space-y-3">
267
  <template x-for="table in bundle.tables" :key="table.name">
 
271
  <span class="font-medium" x-text="table.title"></span>
272
  <span class="text-xs text-gray-400 ml-2" x-text="table.section"></span>
273
  </div>
274
+ <a :href="table.csv_url" download @click.stop class="text-racing-500 hover:underline text-xs">Descargar CSV</a>
 
 
275
  </summary>
276
  <div class="px-4 pb-4 overflow-x-auto table-preview" x-html="table.preview_html"></div>
277
  </details>
 
295
  return {
296
  league: {{ league|tojson }},
297
  season: {{ season|tojson }},
298
+ leagues: [],
299
+ seasons: [],
300
+ teams: [],
301
+ playedMatches: [],
302
+ selectedMatchId: '',
303
+ teamA: '',
304
+ teamB: '',
305
  dateFrom: '',
306
  dateTo: '',
307
  nnEnabled: {{ ('true' if nn_enabled else 'false') }},
 
312
  },
313
  currentJob: null,
314
  jobStatus: null,
315
+ onlyFigure: null,
316
  pollTimer: null,
317
 
318
+ async init() {
319
+ await this.loadLeagues();
320
+ await this.loadSeasons(false);
321
+ await this.loadTeamsAndMatches();
322
+ this._refreshHealth();
323
+ },
324
+
325
+ async _getJSON(url) {
326
+ const r = await fetch(url);
327
+ if (!r.ok) throw new Error((await r.json().catch(()=>({detail:r.statusText}))).detail);
328
+ return r.json();
329
+ },
330
+
331
+ async loadLeagues() {
332
+ try {
333
+ this.leagues = await this._getJSON('/api/leagues');
334
+ if (this.leagues.length && !this.leagues.includes(this.league)) this.league = this.leagues[0];
335
+ } catch (e) { this.leagues = this.league ? [this.league] : []; }
336
+ },
337
+
338
+ async loadSeasons(reset = true) {
339
+ try {
340
+ this.seasons = await this._getJSON(`/api/seasons?league=${encodeURIComponent(this.league)}`);
341
+ if (this.seasons.length && (reset || !this.seasons.includes(this.season))) this.season = this.seasons[0];
342
+ } catch (e) { this.seasons = this.season ? [this.season] : []; }
343
+ },
344
+
345
+ async loadTeamsAndMatches() {
346
+ const q = `league=${encodeURIComponent(this.league)}&season=${encodeURIComponent(this.season)}`;
347
+ try {
348
+ this.teams = await this._getJSON(`/api/teams?${q}`);
349
+ if (this.teams.length) {
350
+ if (!this.teams.includes(this.teamA)) this.teamA = this.teams[0];
351
+ if (!this.teams.includes(this.teamB)) this.teamB = this.teams[Math.min(1, this.teams.length - 1)];
352
+ }
353
+ } catch (e) { this.teams = []; }
354
+ try {
355
+ this.playedMatches = await this._getJSON(`/api/matches?${q}&played_only=true`);
356
+ } catch (e) { this.playedMatches = []; }
357
+ this.selectedMatchId = '';
358
+ },
359
+
360
+ async onLeagueChange() { await this.loadSeasons(true); await this.loadTeamsAndMatches(); },
361
+ async onSeasonChange() { await this.loadTeamsAndMatches(); },
362
 
363
  async _refreshHealth() {
364
  try {
365
  const r = await fetch('/api/health');
366
+ if (r.ok) { const d = await r.json(); this.preprocessedCached = !!d.preprocessed_cached; }
 
 
 
367
  } catch (_e) {}
368
  },
369
 
370
  reportTitle(id) {
371
+ return ({ pre_match: 'Reporte previo (head-to-head)', block_zscore: 'Bloques (z-score)', post_match: 'Reporte post-partido' })[id] || id;
 
 
 
 
372
  },
373
 
374
  visibleFigures(reportType, figs) {
 
380
  const r = this.reports[reportId];
381
  r.catalogOpen = !r.catalogOpen;
382
  if (r.catalogOpen && !r.catalog) {
383
+ try { r.catalog = await this._getJSON(`/api/reports/${reportId}/catalog`); }
384
+ catch (e) { alert('Error cargando catálogo: ' + e); r.catalogOpen = false; }
 
 
 
 
 
385
  }
386
  },
387
 
388
+ async runFull(reportId) { await this._run(reportId, null); },
389
  async runOne(reportId, figName) { await this._run(reportId, figName); },
390
 
391
  async _run(reportId, figName) {
392
  if (!this.teamA || !this.teamB) { alert('Elegí equipo A y equipo B.'); return; }
393
  if (this.teamA === this.teamB) { alert('Equipo A y B deben ser distintos.'); return; }
394
+ await this._post(reportId, {
395
+ league: this.league, season: this.season,
396
+ team_a: this.teamA, team_b: this.teamB,
397
+ filters: { date_from: this.dateFrom || null, date_to: this.dateTo || null },
 
 
 
 
 
398
  only: figName ? [figName] : [],
399
+ }, figName, figName ? `${this.reportTitle(reportId)} · ${figName}` : this.reportTitle(reportId));
400
+ },
401
+
402
+ async runPostMatch() {
403
+ if (!this.selectedMatchId) { alert('Elegí un partido jugado.'); return; }
404
+ const m = this.playedMatches.find(x => x.match_id === this.selectedMatchId);
405
+ if (!m) { alert('Ese partido no existe para esta liga/temporada.'); return; }
406
+ await this._post('post_match', {
407
+ league: this.league, season: this.season,
408
+ team_a: m.home_team, team_b: m.away_team,
409
+ match_id: m.match_id, filters: {}, only: [],
410
+ }, null, `Post-partido · ${m.home_team} vs ${m.away_team}`);
411
+ },
412
+
413
+ async _post(reportId, body, figName, title) {
414
  let resp;
415
  try {
416
  resp = await fetch(`/api/reports/${reportId}/run`, {
417
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
 
 
418
  });
419
+ } catch (e) { alert('Error de red: ' + e); return; }
 
 
420
  if (!resp.ok) {
421
  const err = await resp.json().catch(() => ({ detail: resp.statusText }));
422
+ alert('Error: ' + err.detail); return;
 
423
  }
424
  const { job_id } = await resp.json();
425
  this.onlyFigure = figName;
426
+ this._startPolling(job_id, title);
427
  },
428
 
429
  _startPolling(jobId, title) {
 
432
  if (this.pollTimer) clearInterval(this.pollTimer);
433
  this.pollTimer = setInterval(() => this._poll(), 2000);
434
  this._poll();
435
+ setTimeout(() => { document.querySelector('section > h2')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100);
 
 
436
  },
437
 
438
  async _poll() {
 
442
  if (!r.ok) return;
443
  this.jobStatus = await r.json();
444
  if (['done', 'error'].includes(this.jobStatus.status)) {
445
+ clearInterval(this.pollTimer); this.pollTimer = null; this._refreshHealth();
 
 
446
  }
447
+ } catch (_e) {}
448
  },
449
  };
450
  }
src/racing_reports/web_meta.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Metadata para la web (ligas, temporadas, equipos, partidos) derivada del
2
+ dataset de modelado bundleado — sin depender de Azure para *seleccionar*.
3
+
4
+ El dataset (`vendor/data/modeling/attack_prediction_dataset.parquet`) trae, por
5
+ partido y equipo: league, season, fecha, team_name, opponent_name, is_home,
6
+ matchId. Con eso armamos los selectores y la lista de partidos jugados para
7
+ cualquiera de las ligas que el modelo soporta, offline.
8
+
9
+ La GENERACIÓN de reportes sigue necesitando el preprocessed (Azure); por eso
10
+ `available_league_seasons()` puede intersecar con lo que realmente está subido.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import threading
16
+ from datetime import date
17
+ from functools import lru_cache
18
+
19
+ import pandas as pd
20
+
21
+ from racing_reports import vendor_env
22
+
23
+ _LOCK = threading.Lock()
24
+ _CACHE: dict[str, object] = {}
25
+
26
+ _DATASET_PATH = vendor_env.DATA_DIR / "modeling" / "attack_prediction_dataset.parquet"
27
+ _COLS = ["matchId", "league", "season", "fecha", "team_name", "opponent_name", "is_home"]
28
+
29
+
30
+ def _load() -> pd.DataFrame:
31
+ """Carga (cacheada) las columnas meta del dataset de modelado."""
32
+ with _LOCK:
33
+ df = _CACHE.get("df")
34
+ if df is None:
35
+ cols = [c for c in _COLS if c]
36
+ df = pd.read_parquet(_DATASET_PATH, columns=cols)
37
+ df["fecha"] = pd.to_datetime(df["fecha"], errors="coerce")
38
+ df["league"] = df["league"].astype(str)
39
+ df["season"] = df["season"].astype(str)
40
+ _CACHE["df"] = df
41
+ return df
42
+
43
+
44
+ def has_dataset() -> bool:
45
+ return _DATASET_PATH.exists()
46
+
47
+
48
+ def league_seasons() -> dict[str, list[str]]:
49
+ """{liga: [temporadas]} presentes en el dataset (orden temporada desc)."""
50
+ df = _load()
51
+ out: dict[str, list[str]] = {}
52
+ for league, sub in df.groupby("league"):
53
+ out[str(league)] = sorted(sub["season"].unique().tolist(), reverse=True)
54
+ return dict(sorted(out.items()))
55
+
56
+
57
+ def teams(league: str, season: str) -> list[str]:
58
+ """Equipos de esa liga/temporada (nombres ordenados)."""
59
+ df = _load()
60
+ sub = df[(df["league"] == league) & (df["season"] == str(season))]
61
+ names = set(sub["team_name"].dropna().astype(str)) | set(sub["opponent_name"].dropna().astype(str))
62
+ return sorted(names)
63
+
64
+
65
+ def matches(league: str, season: str, played_only: bool = False) -> list[dict]:
66
+ """Lista de partidos (home/away/fecha/estado) de esa liga/temporada."""
67
+ df = _load()
68
+ sub = df[(df["league"] == league) & (df["season"] == str(season)) & (df["is_home"] == True)] # noqa: E712
69
+ sub = sub.drop_duplicates("matchId")
70
+ today = pd.Timestamp(date.today())
71
+ rows: list[dict] = []
72
+ for _, r in sub.sort_values("fecha", ascending=False).iterrows():
73
+ d = r["fecha"]
74
+ played = pd.notna(d) and d <= today
75
+ if played_only and not played:
76
+ continue
77
+ rows.append({
78
+ "match_id": str(r["matchId"]),
79
+ "date": d.strftime("%Y-%m-%d") if pd.notna(d) else "",
80
+ "home_team": str(r["team_name"]),
81
+ "away_team": str(r["opponent_name"]),
82
+ "status": "played" if played else "future",
83
+ })
84
+ return rows
85
+
86
+
87
+ def resolve_pair(league: str, season: str, team_a: str, team_b: str) -> list[dict]:
88
+ """Partidos entre A y B (en cualquier orden) en esa liga/temporada."""
89
+ a, b = team_a.strip().casefold(), team_b.strip().casefold()
90
+ out = []
91
+ for m in matches(league, season):
92
+ h, w = m["home_team"].casefold(), m["away_team"].casefold()
93
+ if {h, w} == {a, b}:
94
+ out.append(m)
95
+ return out
tests/test_web_app.py CHANGED
@@ -11,16 +11,31 @@ from racing_reports.web import app as web_app
11
 
12
 
13
  def test_home_is_spa_with_teams_and_report_cards():
14
- """El nuevo home es un SPA que consume /api/. Verificamos que el HTML
15
- contiene los selects de equipos y las cards de los reportes."""
16
  client = TestClient(web_app.app)
17
  resp = client.get("/")
18
  assert resp.status_code == 200
19
- assert "Racing de Santander" in resp.text # desde vendor/data/team_ids_ssd.json
 
20
  assert "Reporte previo" in resp.text
21
  assert "Bloques" in resp.text
22
- assert "post-partido" in resp.text # card deshabilitada
23
- assert "/api/reports/" in resp.text # alpine consume la API
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  def test_legacy_home_still_works():
 
11
 
12
 
13
  def test_home_is_spa_with_teams_and_report_cards():
14
+ """El nuevo home es un SPA que consume /api/. Equipos/ligas/partidos se cargan
15
+ client-side; verificamos los selectores y las cards de reportes."""
16
  client = TestClient(web_app.app)
17
  resp = client.get("/")
18
  assert resp.status_code == 200
19
+ assert 'x-model="league"' in resp.text # selector de liga
20
+ assert 'x-model="season"' in resp.text # selector de temporada
21
  assert "Reporte previo" in resp.text
22
  assert "Bloques" in resp.text
23
+ assert "post-partido" in resp.text # card de post-partido
24
+ assert "runPostMatch" in resp.text # flujo de post-partido funcional
25
+ assert "/api/leagues" in resp.text # alpine consume la API meta
26
+ assert "/api/reports/" in resp.text
27
+
28
+
29
+ def test_meta_endpoints_are_dataset_driven():
30
+ """leagues/seasons/teams/matches salen del dataset bundleado (offline)."""
31
+ from racing_reports.api import routes_meta as rm
32
+ ls = rm._league_seasons()
33
+ assert ls, "debe haber al menos una liga/temporada"
34
+ league = next(iter(ls))
35
+ season = ls[league][0]
36
+ assert rm.teams(league, season), "debe listar equipos"
37
+ # matches puede estar vacío para temporadas sin jugados, pero no debe romper
38
+ assert isinstance(rm.matches(league, season, played_only=True), list)
39
 
40
 
41
  def test_legacy_home_still_works():
vendor/data/modeling/attack_prediction_dataset.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:885726cff0233d7ab8a2c6fea06d4d66c7707bf60c1b40b7a6560874e6904164
3
- size 10009594
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:05b43be8a91cea8400eef47d55ea5dffb69809cfe73c8d26937c9f7955139d06
3
+ size 10011206