diff --git a/app.py b/app.py index 50b2d2e..68b1a8c 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.2" +BUILD_VERSION = "2026.09.18.3" # 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 7e2f728..af38ee3 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -317,6 +317,8 @@ class HockeyDataService: *, tournament_external_id: str, deep: bool = False, + 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. @@ -340,12 +342,23 @@ class HockeyDataService: .order_by(Game.game_date, Game.start_time, Game.id) ) ) - game_ids: list[str] = [] + requested_game_ids = { + str(value).strip() + for value in (game_ids or []) + if str(value or "").strip() + } + selected_game_ids: list[str] = [] for row in rows: game_id = str(row.external_id or "").strip() if not game_id: continue - if deep: + 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 + # every game shown on that date, not only the selected game. + include = row.game_date == on_date + elif deep: # Future matches have no meaningful score to reconcile yet. include = row.game_date is None or row.game_date <= today else: @@ -354,7 +367,7 @@ class HockeyDataService: and recent_from <= row.game_date <= recent_until ) if include: - game_ids.append(game_id) + selected_game_ids.append(game_id) settings = self.settings.public() template = str( @@ -413,11 +426,15 @@ class HockeyDataService: errors.append(f"{game_id}: {str(error)[:180]}") return None - results = await asyncio.gather(*(guarded(game_id) for game_id in game_ids)) if game_ids else [] + results = ( + await asyncio.gather(*(guarded(game_id) for game_id in selected_game_ids)) + if selected_game_ids + else [] + ) failed = sum(1 for value in results if value is None) updated = sum(1 for value in results if value is True) return { - "checked": len(game_ids), + "checked": len(selected_game_ids), "updated": updated, "failed": failed, "errors": errors[:12], @@ -487,6 +504,7 @@ class HockeyDataService: on_date=target_date, force_discovery=False, lightweight=True, + reconcile_scores=False, ) result["schedule_ok"] = True @@ -2342,8 +2360,13 @@ class HockeyDataService: existing.start_time = parsed_raw_time if item.start_datetime_raw: existing.start_datetime_raw = item.start_datetime_raw - existing.status = item.status - existing.approved = item.approved + # Never downgrade a game that has already been confirmed as finished. + # Some schedule XML snapshots briefly lag behind the individual game card. + existing_terminal = bool(existing.approved) or HockeyDataService.is_terminal_game_status(existing.status) + incoming_terminal = bool(item.approved) or HockeyDataService.is_terminal_game_status(item.status) + if not (existing_terminal and not incoming_terminal): + existing.status = item.status + existing.approved = item.approved existing.arena_external_id = item.arena_external_id existing.arena_ru = item.arena_ru @@ -2378,15 +2401,24 @@ class HockeyDataService: existing.away_team_city_ru = item.away_team_city_ru existing.away_team_city_en = item.away_team_city_en - existing.home_score = item.home_score - existing.away_score = item.away_score - existing.score_raw = item.score_raw - existing.overtime_type = item.overtime_type - existing.period_1_score = item.period_1_score - existing.period_2_score = item.period_2_score - existing.period_3_score = item.period_3_score - existing.period_4_score = item.period_4_score - existing.series_score = item.series_score + incoming_score_raw = str(item.score_raw or "").strip() + incoming_score_present = bool( + incoming_score_raw + or int(item.home_score or 0) != 0 + or int(item.away_score or 0) != 0 + ) + # A partial schedule response commonly omits the score entirely. In that + # case keep the last known score (often reconciled from the game JSON). + if incoming_score_present or not str(existing.score_raw or "").strip(): + existing.home_score = item.home_score + existing.away_score = item.away_score + existing.score_raw = item.score_raw + existing.overtime_type = item.overtime_type + existing.period_1_score = item.period_1_score + existing.period_2_score = item.period_2_score + existing.period_3_score = item.period_3_score + existing.period_4_score = item.period_4_score + existing.series_score = item.series_score existing.attendance = item.attendance existing.temporary_value = item.temporary_value existing.period_scores = item.period_scores @@ -2560,6 +2592,7 @@ class HockeyDataService: on_date: date | None = None, force_discovery: bool = False, lightweight: bool = False, + reconcile_scores: bool = True, ) -> dict[str, Any]: settings = self.settings.public() configured = str(settings.get("games_endpoint_template") or "").strip() @@ -2649,6 +2682,26 @@ class HockeyDataService: attempt["status"] = "success" attempts.append(attempt) + # BUILD145: 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": []} + if reconcile_scores and on_date is not None and on_date <= date.today(): + try: + score_reconcile = await self.refresh_schedule_game_scores( + tournament_external_id=tournament_external_id, + on_date=on_date, + ) + except Exception as score_error: + score_reconcile = { + "checked": 0, + "updated": 0, + "failed": 1, + "errors": [str(score_error)[:240]], + } + result["score_reconcile"] = score_reconcile + if template != configured and not lightweight: self.settings.update_public({ "games_endpoint_template": template, @@ -3874,6 +3927,25 @@ class HockeyDataService: except (TypeError, ValueError): return default + @staticmethod + def _details_score_present(game_ru: dict[str, Any], game_en: dict[str, Any]) -> bool: + """True only when the match card explicitly carries score information. + + Stat2TV occasionally returns a partial JSON card while a match is being + updated. Treating absent score fields as numeric zero used to overwrite a + valid cached score with 0:0. + """ + score_keys = ( + "score", "result", + "scoreA", "goalsA", "scA", + "scoreB", "goalsB", "scB", + ) + for source in (game_ru or {}, game_en or {}): + for key in score_keys: + if key in source and source.get(key) not in (None, ""): + return True + return False + @classmethod def _details_score(cls, game_ru: dict[str, Any], game_en: dict[str, Any]) -> tuple[int, int, str, str]: raw = str( @@ -3913,10 +3985,12 @@ class HockeyDataService: def _apply_details_game_fields(cls, game: Game, details: ParsedMatchDetails, *, now: datetime) -> None: game_ru = details.game_ru or {} game_en = details.game_en or game_ru + score_present = cls._details_score_present(game_ru, game_en) home, away, score_raw, finish = cls._details_score(game_ru, game_en) - game.home_score = home - game.away_score = away - game.score_raw = score_raw or (f"{home}:{away}" if home or away else "") + if score_present: + game.home_score = home + game.away_score = away + 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() diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index b380fe3..b3bc7ed 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -1011,10 +1011,25 @@ 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. + if (!document.hidden && state.gameDate === localDateIso()) { + try { + await refreshSelectedTournamentScheduleCache(); + } catch (_) {} + } if (state.view === "games") { state.games = await request(gamesUrl()); applyGamesData(); renderGames(); + } else { + // Runtime may be on the Schedule tab while the drawer itself is closed. + // Keep the selected-date payload current there as well. + try { + state.games = await request(gamesUrl()); + applyGamesData(); + } catch (_) {} } await loadTeamSchedule({ force: true }); await loadTournamentStandings({ sync: false }); @@ -1381,6 +1396,25 @@ + `&all_dates=true`; } + async function refreshSelectedTournamentScheduleCache({ forceDiscovery = false } = {}) { + if (!state.selectedId || state.scheduleSyncBusy || operatorSettingsEditingActive()) return null; + const configured = state.games?.meta?.credentials_configured + ?? state.navigation?.meta?.credentials_configured; + if (configured === false) return null; + state.scheduleSyncBusy = true; + try { + return await request( + `/api/hockey/tournaments/${encodeURIComponent(state.selectedId)}/games/sync` + + `?on_date=${encodeURIComponent(state.gameDate)}` + + `&force_discovery=${forceDiscovery ? "true" : "false"}` + + `&lightweight=true`, + { method: "POST", timeoutMs: 30000 } + ); + } finally { + state.scheduleSyncBusy = false; + } + } + function applyTeamScheduleData() { const current = state.teamSchedule && typeof state.teamSchedule === "object" ? state.teamSchedule @@ -1432,12 +1466,23 @@ if ( autoSync && state.games.meta.credentials_configured - && !state.games.meta.schedule_loaded && !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. state.gamesAutoSyncAttempted.add(syncKey); - await syncGames(false, { silentSuccess: true }); + try { + await refreshSelectedTournamentScheduleCache(); + state.games = await request(gamesUrl()); + applyGamesData(); + renderGames(); + await loadTeamSchedule({ force: true }); + } catch (_) { + // Cached schedule remains usable if Stat2TV is temporarily unavailable. + state.gamesAutoSyncAttempted.delete(syncKey); + } } } catch (error) { state.games = { meta: { date: state.gameDate, tournament_external_id: state.selectedId, count: 0 }, items: [] }; @@ -1625,7 +1670,11 @@ function gameCard(game) { const fallbackLabel = result.fallback_used ? (state.language === "en" ? " · tournament API fallback" : " · резервный API турнира") : ""; - notify(`${t("gamesLoaded")}: ${visibleCount} · ${result.endpoint}${fallbackLabel}`); + 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)}` + : ""; + notify(`${t("gamesLoaded")}: ${visibleCount} · ${result.endpoint}${fallbackLabel}${scoreLabel}`); } 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 2b37aca..a81271a 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -1,3 +1,4 @@ +// BUILD145: schedule tab remains permanent; selected tournament data is refreshed by tournament-menu.js. (() => { "use strict";