diff --git a/app.py b/app.py index 68b1a8c..43c84ce 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.09.18.3" +BUILD_VERSION = "2026.09.18.4" # compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" diff --git a/hockey_data/service.py b/hockey_data/service.py index af38ee3..1b501ee 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -320,16 +320,25 @@ class HockeyDataService: on_date: date | None = None, game_ids: list[str] | tuple[str, ...] | set[str] | None = None, ) -> dict[str, Any]: - """Reconcile schedule scores from the authoritative per-game JSON cards. + """Reconcile schedule scores *and statuses* from per-game JSON cards. - The tournament schedule XML is useful for the calendar, but some Stat2TV - feeds update the final score there later than the individual match card. - A deep pass checks every started/undated game in the tournament. Hourly - maintenance only checks the recent window, which keeps API traffic bounded. + The tournament schedule XML remains the calendar source, while the match + JSON card is used to confirm the current score and canonical match state. + Status transitions are monotonic: scheduled -> live -> finished. This + prevents a delayed Stat2TV response from moving an already-live/finished + game backwards. A deep pass checks every started/undated game in the + tournament; normal maintenance keeps the request window bounded. """ tournament_id = str(tournament_external_id or "").strip() if not tournament_id: - return {"checked": 0, "updated": 0, "failed": 0, "errors": []} + return { + "checked": 0, + "updated": 0, + "score_updated": 0, + "status_updated": 0, + "failed": 0, + "errors": [], + } today = date.today() recent_from = today - timedelta(days=7) @@ -355,7 +364,7 @@ class HockeyDataService: if requested_game_ids: include = game_id in requested_game_ids elif on_date is not None: - # BUILD145: a schedule refresh for one date must reconcile + # BUILD146: a schedule refresh for one date must reconcile # every game shown on that date, not only the selected game. include = row.game_date == on_date elif deep: @@ -377,7 +386,7 @@ class HockeyDataService: semaphore = asyncio.Semaphore(4) errors: list[str] = [] - async def refresh_one(game_id: str) -> bool: + async def refresh_one(game_id: str) -> dict[str, bool]: endpoint = self._resolve_match_resource_endpoint( template, tournament_external_id=tournament_id, @@ -399,27 +408,31 @@ class HockeyDataService: select(Game).where(Game.external_id == game_id) ) if game is None or str(game.tournament_external_id) != tournament_id: - return False - before = ( + return {"changed": False, "score_changed": False, "status_changed": False} + before_score = ( game.home_score, game.away_score, game.score_raw, - game.status, - bool(game.approved), game.overtime_type, ) + before_status = (game.status, bool(game.approved)) self._apply_details_game_fields(game, details, now=now) - after = ( + after_score = ( game.home_score, game.away_score, game.score_raw, - game.status, - bool(game.approved), game.overtime_type, ) - return before != after + after_status = (game.status, bool(game.approved)) + score_changed = before_score != after_score + status_changed = before_status != after_status + return { + "changed": score_changed or status_changed, + "score_changed": score_changed, + "status_changed": status_changed, + } - async def guarded(game_id: str) -> bool | None: + async def guarded(game_id: str) -> dict[str, bool] | None: try: return await refresh_one(game_id) except Exception as error: @@ -432,16 +445,20 @@ class HockeyDataService: else [] ) failed = sum(1 for value in results if value is None) - updated = sum(1 for value in results if value is True) + updated = sum(1 for value in results if value and value.get("changed")) + score_updated = sum(1 for value in results if value and value.get("score_changed")) + status_updated = sum(1 for value in results if value and value.get("status_changed")) return { "checked": len(selected_game_ids), "updated": updated, + "score_updated": score_updated, + "status_updated": status_updated, "failed": failed, "errors": errors[:12], } async def refresh_schedule_catalog(self, *, deep: bool = False) -> dict[str, Any]: - """Refresh schedules, standings and authoritative per-game scores.""" + """Refresh schedules, standings and authoritative per-game scores/statuses.""" status = self.settings.status() started_at = self._utc_stamp() self._schedule_refresh_status.update({ @@ -2682,11 +2699,11 @@ class HockeyDataService: attempt["status"] = "success" attempts.append(attempt) - # BUILD145: XML refreshes the calendar, then every game displayed + # BUILD146: XML refreshes the calendar, then every game displayed # on the requested date is reconciled from its authoritative JSON - # card before the API returns. This prevents a mixed schedule where - # only the selected game has a current score. - score_reconcile = {"checked": 0, "updated": 0, "failed": 0, "errors": []} + # card before the API returns. Both score and canonical status are + # refreshed, so every card follows scheduled -> live -> finished. + score_reconcile = {"checked": 0, "updated": 0, "score_updated": 0, "status_updated": 0, "failed": 0, "errors": []} if reconcile_scores and on_date is not None and on_date <= date.today(): try: score_reconcile = await self.refresh_schedule_game_scores( @@ -2697,6 +2714,8 @@ class HockeyDataService: score_reconcile = { "checked": 0, "updated": 0, + "score_updated": 0, + "status_updated": 0, "failed": 1, "errors": [str(score_error)[:240]], } @@ -3981,6 +4000,76 @@ class HockeyDataService: ).strip() return home, away, raw, explicit or finish + @staticmethod + def _canonical_game_status_token(value: Any) -> str: + return "".join( + character + for character in str(value or "").strip().casefold() + if character.isalnum() + ) + + @classmethod + def _canonical_game_status_from_details( + cls, + *, + raw_status: str, + status_value: int, + current_status: str, + current_approved: bool, + ) -> tuple[str, bool]: + """Map Stat2TV game-card state to scheduled/live/finished. + + Stat2TV payloads can use several textual state names. Runtime components + intentionally receive only three stable values. Once a game advances to + live or finished, a stale response is not allowed to move it backwards. + """ + current_token = cls._canonical_game_status_token(current_status) + if current_approved or cls.is_terminal_game_status(current_status): + current = "finished" + elif current_token in {"live", "playing", "inprogress", "started", "ongoing"}: + current = "live" + else: + current = "scheduled" + + token = cls._canonical_game_status_token(raw_status) + if status_value >= 90 or cls.is_terminal_game_status(raw_status): + incoming = "finished" + else: + scheduled_tokens = { + "scheduled", "schedule", "upcoming", "notstarted", "prematch", + "pregame", "pending", "planned", "waiting", "ожидается", + "неначался", "предстоящий", "запланирован", + } + live_tokens = { + "live", "playing", "inprogress", "progress", "active", + "started", "ongoing", "game", "period", "intermission", + "break", "overtime", "shootout", "идет", "идёт", + "матчидет", "матчидёт", "перерыв", "период", + "овертайм", "буллиты", + } + if token in scheduled_tokens or any( + marker in token for marker in ("notstarted", "prematch", "pregame", "upcoming") + ): + incoming = "scheduled" + elif token in live_tokens or any( + marker in token + for marker in ("inprogress", "playing", "period", "intermission", "overtime", "shootout") + ): + incoming = "live" + elif status_value > 0: + # In known Stat2TV cards 90 is terminal; positive intermediate + # values represent a match that has already started. + incoming = "live" + elif status_value == 0: + incoming = "scheduled" + else: + # Unknown/empty card state: keep the already-normalised DB state. + incoming = current + + rank = {"scheduled": 0, "live": 1, "finished": 2} + canonical = incoming if rank[incoming] >= rank[current] else current + return canonical, canonical == "finished" + @classmethod def _apply_details_game_fields(cls, game: Game, details: ParsedMatchDetails, *, now: datetime) -> None: game_ru = details.game_ru or {} @@ -3993,13 +4082,20 @@ class HockeyDataService: game.score_raw = score_raw or f"{home}:{away}" if finish: game.overtime_type = finish - raw_status = str(game_ru.get("status") or game_en.get("status") or game.status or "").strip() - if raw_status: - game.status = raw_status - status_value = cls._manual_numeric(game_ru.get("statusvalue") or game_en.get("statusvalue"), -1) - finished = status_value >= 90 or cls.is_terminal_game_status(raw_status) - if finished: - game.approved = True + raw_status = str(game_ru.get("status") or game_en.get("status") or "").strip() + status_value = cls._manual_numeric( + game_ru.get("statusvalue") if game_ru.get("statusvalue") not in (None, "") + else game_en.get("statusvalue"), + -1, + ) + canonical_status, approved = cls._canonical_game_status_from_details( + raw_status=raw_status, + status_value=status_value, + current_status=game.status, + current_approved=bool(game.approved), + ) + game.status = canonical_status + game.approved = approved raw_date = str( game_ru.get("date") or game_ru.get("gamedate") diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index b3bc7ed..96ce978 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -1011,9 +1011,9 @@ renderTournamentList(); } if (state.selectedId) { - // BUILD145: once per minute refresh the selected date at the source first. - // The service also walks every game on that date and reconciles its score - // from the per-game JSON card before PostgreSQL is read back into Runtime. + // BUILD146: once per minute refresh score and status for every game on the selected date. + // The service walks every game on that date and reconciles both score and + // canonical status from the per-game JSON card before Runtime reads PostgreSQL. if (!document.hidden && state.gameDate === localDateIso()) { try { await refreshSelectedTournamentScheduleCache(); @@ -1469,9 +1469,9 @@ && !state.gamesAutoSyncAttempted.has(syncKey) && !state.scheduleSyncBusy ) { - // BUILD145: always do one quiet authoritative pass when a tournament is - // opened, even when an old schedule cache already exists. The server - // refreshes the XML and then reconciles every game on this date by JSON. + // BUILD146: always do one quiet authoritative pass when a tournament is + // opened. XML refreshes the calendar; every game on this date is then + // reconciled by JSON for score and scheduled/live/finished status. state.gamesAutoSyncAttempted.add(syncKey); try { await refreshSelectedTournamentScheduleCache(); @@ -1672,9 +1672,13 @@ function gameCard(game) { : ""; const scores = result.score_reconcile || {}; const scoreLabel = Number(scores.checked || 0) - ? ` · ${state.language === "en" ? "scores" : "счёт"} ${Number(scores.checked || 0) - Number(scores.failed || 0)}/${Number(scores.checked || 0)}` + ? ` · ${state.language === "en" ? "games checked" : "проверено игр"} ${Number(scores.checked || 0) - Number(scores.failed || 0)}/${Number(scores.checked || 0)}` : ""; - notify(`${t("gamesLoaded")}: ${visibleCount} · ${result.endpoint}${fallbackLabel}${scoreLabel}`); + const changedParts = []; + if (Number(scores.score_updated || 0)) changedParts.push(`${state.language === "en" ? "score" : "счёт"}: ${Number(scores.score_updated || 0)}`); + if (Number(scores.status_updated || 0)) changedParts.push(`${state.language === "en" ? "status" : "статус"}: ${Number(scores.status_updated || 0)}`); + const changedLabel = changedParts.length ? ` · ${changedParts.join(", ")}` : ""; + notify(`${t("gamesLoaded")}: ${visibleCount} · ${result.endpoint}${fallbackLabel}${scoreLabel}${changedLabel}`); } state.gamesAutoSyncAttempted.add(String(state.selectedId)); state.games = await request(gamesUrl()); diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index a81271a..4ede9fa 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -1,4 +1,4 @@ -// BUILD145: schedule tab remains permanent; selected tournament data is refreshed by tournament-menu.js. +// BUILD146: schedule tab remains permanent; score/status refresh is driven by tournament-menu.js. (() => { "use strict";