тест 8
This commit is contained in:
@@ -924,12 +924,14 @@ def create_hockey_router(
|
||||
external_id: str,
|
||||
tournament_id: str = Query(..., min_length=1, max_length=64),
|
||||
language: str = Query("ru", pattern="^(ru|en)$"),
|
||||
opening: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return await service.sync_selected_game_details(
|
||||
tournament_external_id=tournament_id,
|
||||
game_external_id=external_id,
|
||||
language=language,
|
||||
opening=opening,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
|
||||
@@ -3484,6 +3484,7 @@ class HockeyDataService:
|
||||
language: str = "ru",
|
||||
allow_create: bool = False,
|
||||
force_optional_shots: bool = False,
|
||||
skip_optional_shots: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
settings = self.settings.public()
|
||||
templates = {
|
||||
@@ -3590,15 +3591,16 @@ class HockeyDataService:
|
||||
now=now,
|
||||
)
|
||||
|
||||
shots_meta: dict[str, Any] = {"available": False}
|
||||
try:
|
||||
shots_meta = await self._sync_optional_shots(
|
||||
tournament_external_id=tournament_external_id,
|
||||
game_external_id=game_external_id,
|
||||
force=force_optional_shots,
|
||||
)
|
||||
except Exception as error:
|
||||
warnings.append(f"Дополнительные данные shots недоступны: {error}")
|
||||
shots_meta: dict[str, Any] = {"available": False, "skipped": bool(skip_optional_shots)}
|
||||
if not skip_optional_shots:
|
||||
try:
|
||||
shots_meta = await self._sync_optional_shots(
|
||||
tournament_external_id=tournament_external_id,
|
||||
game_external_id=game_external_id,
|
||||
force=force_optional_shots,
|
||||
)
|
||||
except Exception as error:
|
||||
warnings.append(f"Дополнительные данные shots недоступны: {error}")
|
||||
|
||||
payload = self.game_details(game_external_id, language=language)
|
||||
if payload is None:
|
||||
@@ -3948,33 +3950,40 @@ class HockeyDataService:
|
||||
tournament_external_id: str,
|
||||
game_external_id: str,
|
||||
language: str = "ru",
|
||||
opening: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
schedule: dict[str, Any] | None = None
|
||||
schedule_warning = ""
|
||||
try:
|
||||
schedule = await self.sync_selected_game(
|
||||
tournament_external_id=tournament_external_id,
|
||||
game_external_id=game_external_id,
|
||||
language=language,
|
||||
)
|
||||
except Exception as error:
|
||||
if self.game(game_external_id, language=language) is None:
|
||||
raise
|
||||
schedule_warning = str(error)
|
||||
if opening:
|
||||
# BUILD110: the operator clicked a row that already came from the
|
||||
# local schedule. Do not spend the critical roster-loading window on
|
||||
# another schedule round-trip; live polling refreshes it afterwards.
|
||||
schedule = self.game(game_external_id, language=language)
|
||||
else:
|
||||
try:
|
||||
schedule = await self.sync_selected_game(
|
||||
tournament_external_id=tournament_external_id,
|
||||
game_external_id=game_external_id,
|
||||
language=language,
|
||||
)
|
||||
except Exception as error:
|
||||
if self.game(game_external_id, language=language) is None:
|
||||
raise
|
||||
schedule_warning = str(error)
|
||||
|
||||
details_warning = ""
|
||||
try:
|
||||
# Opening a match must not depend on every optional Stat2TV resource.
|
||||
# Some schedule entries exist before/without a full match JSON card.
|
||||
# Keep the operator UI usable and enrich the selected match when the
|
||||
# card becomes available instead of failing the whole selection.
|
||||
# Opening a match waits only for the core card/rosters. Optional
|
||||
# shots are deliberately deferred to live/background refresh so a
|
||||
# slow auxiliary feed cannot keep the loading overlay open.
|
||||
payload = await asyncio.wait_for(
|
||||
self.sync_match_details(
|
||||
tournament_external_id=tournament_external_id,
|
||||
game_external_id=game_external_id,
|
||||
language=language,
|
||||
allow_create=True,
|
||||
force_optional_shots=True,
|
||||
force_optional_shots=not opening,
|
||||
skip_optional_shots=opening,
|
||||
),
|
||||
timeout=12.0,
|
||||
)
|
||||
|
||||
@@ -1483,18 +1483,19 @@ function gameCard(game) {
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichOpenedGame(id, tournamentId, generation) {
|
||||
async function enrichOpenedGame(id, tournamentId, generation, { opening = false } = {}) {
|
||||
try {
|
||||
const loaded = await request(
|
||||
`/api/hockey/games/${encodeURIComponent(id)}/details/sync`
|
||||
+ `?tournament_id=${encodeURIComponent(tournamentId)}`
|
||||
+ `&language=${encodeURIComponent(state.language)}`,
|
||||
{ method: "POST", timeoutMs: 16000 }
|
||||
+ `&language=${encodeURIComponent(state.language)}`
|
||||
+ `&opening=${opening ? "true" : "false"}`,
|
||||
{ method: "POST", timeoutMs: opening ? 14000 : 16000 }
|
||||
);
|
||||
if (
|
||||
generation !== state.gameOpenGeneration
|
||||
|| String(state.selectedGameId || "") !== String(id)
|
||||
) return;
|
||||
) return false;
|
||||
if (loaded?.game) state.selectedGame = loaded.game;
|
||||
state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null;
|
||||
applyGameData();
|
||||
@@ -1503,18 +1504,46 @@ function gameCard(game) {
|
||||
if (loaded?.details_sync_failed || loaded?.degraded) {
|
||||
console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []);
|
||||
}
|
||||
return Boolean(loaded?.game);
|
||||
} catch (error) {
|
||||
// Full match JSON, rosters, shots and directories are enrichment data.
|
||||
// Their absence must never undo a match already selected by the operator.
|
||||
console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRuntimeMatchSpecificData() {
|
||||
// BUILD110: patchData() performs a deep merge. A schedule row normally has
|
||||
// no home.players/away.players fields, so without an explicit reset the
|
||||
// previous match roster survives until the new details request completes.
|
||||
window.UIBuilderRuntime?.patchData?.({
|
||||
hockey: {
|
||||
selected_game: null,
|
||||
home: null,
|
||||
away: null,
|
||||
referees: [],
|
||||
match_details: null,
|
||||
game_control: null,
|
||||
selected_penalties: { home: null, away: null },
|
||||
active_penalties: { home: null, away: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cachedMatchHasRosters(payload) {
|
||||
if (!payload?.game || !payload?.details?.loaded) return false;
|
||||
const home = Array.isArray(payload.game?.home?.players) ? payload.game.home.players.length : 0;
|
||||
const away = Array.isArray(payload.game?.away?.players) ? payload.game.away.players.length : 0;
|
||||
return (home + away) > 0;
|
||||
}
|
||||
|
||||
async function openGame(id, { manual = false } = {}) {
|
||||
if (state.gameOpenBusy) return;
|
||||
state.lastAttemptedGameId = String(id || "");
|
||||
state.lastGameOpenError = "";
|
||||
state.gameOpenBusy = true;
|
||||
stopSelectedGamePolling();
|
||||
const generation = ++state.gameOpenGeneration;
|
||||
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
|
||||
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
|
||||
@@ -1576,6 +1605,16 @@ function gameCard(game) {
|
||||
localStorage.setItem(storage.tournament, state.selectedId);
|
||||
}
|
||||
|
||||
// Read only the selected game from the local DB while the session is being
|
||||
// created. This never contacts Stat2TV and lets a previously loaded match
|
||||
// restore its own roster immediately, without exposing another game's data.
|
||||
const cachedDetailsPromise = manual
|
||||
? Promise.resolve(null)
|
||||
: request(
|
||||
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`,
|
||||
{ timeoutMs: 2000 }
|
||||
).catch(() => null);
|
||||
|
||||
updateMatchLoading(t("loadingMatchSession"));
|
||||
const previousToken = localStorage.getItem(storage.session);
|
||||
if (previousToken) {
|
||||
@@ -1633,13 +1672,47 @@ function gameCard(game) {
|
||||
localStorage.setItem(storage.gameDate, state.gameDate);
|
||||
}
|
||||
|
||||
// Publish the selected match immediately. Do not wait for lineups, shots,
|
||||
// standings or any remote Stat2TV dependency before the operator can work.
|
||||
// Prefer cached details for this exact game. The Runtime was explicitly
|
||||
// cleared above, so even a missing cache can only show an empty new match,
|
||||
// never the previous match roster.
|
||||
// The new session is confirmed. From this point the old match-specific
|
||||
// Runtime payload must not survive even for a single render.
|
||||
clearRuntimeMatchSpecificData();
|
||||
|
||||
const cachedDetails = await cachedDetailsPromise;
|
||||
const cachedRostersReady = cachedMatchHasRosters(cachedDetails);
|
||||
if (cachedDetails?.game) {
|
||||
state.selectedGame = cachedDetails.game;
|
||||
state.selectedMatchDetails = cachedDetails.details || null;
|
||||
}
|
||||
|
||||
applyGameData(session);
|
||||
renderSelectedChip();
|
||||
startSelectedGamePolling();
|
||||
setOpen(false);
|
||||
|
||||
// For a first-time match, keep the loading overlay until the critical
|
||||
// roster card has arrived. Mapping and optional shots remain non-blocking.
|
||||
// If this game's own cached roster already exists, let the operator work
|
||||
// immediately and refresh it in the background.
|
||||
if (!manual && !cachedRostersReady) {
|
||||
updateMatchLoading(t("loadingMatchDetails"));
|
||||
await enrichOpenedGame(String(id), state.selectedId, generation, { opening: true });
|
||||
if (
|
||||
generation === state.gameOpenGeneration
|
||||
&& String(state.selectedGameId || "") === String(id)
|
||||
) startSelectedGamePolling();
|
||||
} else if (!manual) {
|
||||
void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true })
|
||||
.finally(() => {
|
||||
if (
|
||||
generation === state.gameOpenGeneration
|
||||
&& String(state.selectedGameId || "") === String(id)
|
||||
) startSelectedGamePolling();
|
||||
});
|
||||
} else {
|
||||
startSelectedGamePolling();
|
||||
}
|
||||
|
||||
updateMatchLoading(t("loadingMatchFinishing"));
|
||||
loadTournamentStandings({ sync: false }).catch(() => {});
|
||||
if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {});
|
||||
@@ -1655,10 +1728,6 @@ function gameCard(game) {
|
||||
const awayName = state.selectedGame?.away?.name || "—";
|
||||
notify(`${t("gameOpened")}: ${homeName} — ${awayName}${loadedText}`);
|
||||
if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; });
|
||||
|
||||
// Enrich in the background. If a particular match has no full JSON card,
|
||||
// the already-open schedule match remains active and usable.
|
||||
void enrichOpenedGame(String(id), state.selectedId, generation);
|
||||
} catch (error) {
|
||||
state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка");
|
||||
if (state.view === "games") renderGames();
|
||||
|
||||
Reference in New Issue
Block a user