From 09aaa38bde7cca2ae065eaf2f7d31a242d3183cd Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 19 Jul 2026 23:14:17 +0200 Subject: [PATCH] =?UTF-8?q?Alt=20scratch-verifisert=20(5=20automatiserte?= =?UTF-8?q?=20sjekker=20inkl.=20faktisk=20sanntidsmottak=20over=20WebSocke?= =?UTF-8?q?t=20for=20b=C3=A5de=20score-innsending=20og=20walkover),=20type?= =?UTF-8?q?sjekket=20build=20OK.=20Ingen=20migrasjon,=20ingen=20ny=20Caddy?= =?UTF-8?q?-rute=20(gjenbruker=20/ws/*=20fra=20sist).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- ARCHITECTURE_DECISIONS.md | 41 ++++++++++ FEATURE_BACKLOG.md | 14 ++-- app/realtime.py | 47 +++++++++++ app/routers/messaging.py | 36 ++++++++ app/routers/scoring.py | 26 ++++-- app/routers/tournaments.py | 2 +- frontend/components/public-live.tsx | 123 +++++++++++++++++++++------- 8 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 app/realtime.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ebd0cfe..9c289a5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -296,7 +296,8 @@ "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/tournaments.py'\\).read\\(\\)\\)\")", "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/matches.py'\\).read\\(\\)\\)\")", "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/scoring.py'\\).read\\(\\)\\)\")", - "Bash(mkdir -p /opt/teecup/frontend/app/t/\\\\[id\\\\]/live)" + "Bash(mkdir -p /opt/teecup/frontend/app/t/\\\\[id\\\\]/live)", + "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/messaging.py'\\).read\\(\\)\\)\")" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 78c7efa..b4feded 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -1159,6 +1159,47 @@ funksjoner). Se CLAUDE.md-status for scratch-verifisering og utrulling. --- +## ADR-027: Sanntid for "Følg live"-siden + +Reist 2026-07-19, rett etter ADR-026 (tilskuer-rolle) — brukeren valgte min +egen anbefaling: `/t/[id]/live` krevde omlasting for å se nye resultater, +litt selvmotsigende for en side som heter "Følg live". Løser samtidig det +tidligere åpne "koble leaderboard til sanntid"-punktet i FEATURE_BACKLOG.md. + +**Gjenbruker WebSocket-mekanismen fra ADR-025 (meldinger), men "noe endret +seg, hent på nytt"-signal i stedet for å sende selve dataene** — å bygge/ +sende hele leaderboard+matchliste+scorekort-formen over WS ville duplisert +betydelig beregningslogikk (leaderboardets projeksjons-regnestykke, blind +draw-filtrering osv.). Klienten reagerer på signalet ved å kalle de samme +REST-endepunktene på nytt (ADR-026), akkurat som ved førstegangslasting — +kun for det som faktisk er synlig/åpent på skjermen (leaderboard alltid, +en økts matcher kun hvis økten er utvidet, et scorekort kun hvis det er +åpnet). + +**Ny, RUTEFRI modul `app/realtime.py`** for selve tilkoblingsregisteret og +kringkastingsfunksjonen — verken i `messaging.py`, `scoring.py` eller +`tournaments.py`. Årsak: `registration.py` (som eier selve +`/public/...`-endepunktene) importerer allerede fra `scoring.py`/ +`tournaments.py`/`matches.py`, og `messaging.py` importerer fra +`registration.py` — å plassere kringkastingsfunksjonen i noen av routerne +ville skapt en sirkulær import. `app/realtime.py` ligger bevisst BAK alle +routere i importgrafen. + +**Kringkastingen er lagt INN I de delte funksjonene selv** +(`recompute_and_cache_match_state`, `apply_concession`), ikke som noe +kallerne må huske å gjøre etterpå — samme selv-ansvarlig-mønster som andre +sentrale funksjoner i prosjektet. `apply_concession` fikk en ny påkrevd +`tournament_id`-parameter kun for dette formålet. + +**Samme kjente in-memory-per-prosess-begrensning som ADR-025** (se «Åpne +spørsmål» under) — trygt med dagens ene `teecup_api`-container. + +**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon, ingen ny Caddy-rute +(gjenbruker `/ws/*`-ruten fra ADR-025 uendret). Se CLAUDE.md-status for +scratch-verifisering og utrulling. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index b264069..a41a9ce 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -477,12 +477,14 @@ - «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit. ### Sanntid (WebSockets) -- **Status:** ✅ mekanisme LIVE 2026-07-19 (ADR-025), men KUN koblet til - meldinger ennå. WebSockets valgt (ikke polling) — se Kommunikasjon- - seksjonen. `app/routers/messaging.py` sitt in-memory tilkoblingsregister - og broadcast-mønster kan gjenbrukes for et live leaderboard senere (samme - backend-endepunkt finnes allerede), men det er IKKE gjort i denne runden - — egen, liten oppfølgingssak om/når etterspurt. +- **Status:** ✅ HELT FERDIG, LIVE 2026-07-19 (ADR-025 + ADR-027). Koblet + til BÅDE meldinger (ADR-025) OG `/t/[id]/live` (leaderboard/matcher/ + scorekort, ADR-027). Ny, rutefri delt modul `app/realtime.py` (unngår + sirkulær import mellom `messaging.py`/`registration.py`/`scoring.py`/ + `tournaments.py`) — sender et "noe endret seg"-signal (ikke selve + dataene) hver gang `recompute_and_cache_match_state`/`apply_concession` + kjører, klienten henter de vanlige REST-endepunktene på nytt. Samme + in-memory-per-prosess-begrensning som meldinger. ### Knockout / cup-turnering (egen turneringstype) - **Status:** 💤 utsatt (egen fremtidig type, ADR-011) diff --git a/app/realtime.py b/app/realtime.py new file mode 100644 index 0000000..50d4335 --- /dev/null +++ b/app/realtime.py @@ -0,0 +1,47 @@ +""" +Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer på den +offentlige "Følg live"-siden (ADR-027, oppfølging av ADR-025/026). + +Egen, RUTEFRI modul -- importerer INGEN router. Grunnen: både +`scoring.py`/`tournaments.py` (som endrer matchstatus) og `messaging.py` +(som eier selve WS-ruten, `/ws/public/tournaments/{id}/live`) trenger denne, +og `registration.py` importerer allerede fra scoring.py/tournaments.py/ +matches.py mens `messaging.py` importerer fra `registration.py` -- en +kringkastingsfunksjon plassert i en av routerne ville skapt en sirkulær +import. Denne modulen ligger BAK alle routere i importgrafen i stedet. + +In-memory PER PROSESS -- samme kjente begrensning som meldinger sin egen +kringkasting (`app/routers/messaging.py`): trygt med dagens ene +`teecup_api`-container, deles IKKE på tvers av flere prosesser/containere +(se ARCHITECTURE_DECISIONS.md sine "Åpne spørsmål"). + +Bevisst "noe endret seg, hent på nytt"-signal, IKKE selve dataene -- +i motsetning til meldinger (der selve meldingen kringkastes direkte), +ville det å bygge/sende hele leaderboard+matchliste+scorekort-formen her +duplisert betydelig beregningslogikk. Klienten reagerer ved å kalle de +samme REST-endepunktene på nytt, akkurat som ved førstegangslasting. +""" + +from collections import defaultdict + +from fastapi import WebSocket + +_tournament_live_sockets: dict[str, set[WebSocket]] = defaultdict(set) + + +def live_sockets_for(tournament_id: str) -> set[WebSocket]: + return _tournament_live_sockets[tournament_id] + + +async def broadcast_live_update(tournament_id: str) -> None: + sockets = _tournament_live_sockets.get(tournament_id) + if not sockets: + return + dead = [] + for ws in list(sockets): + try: + await ws.send_json({"type": "update"}) + except Exception: + dead.append(ws) + for ws in dead: + sockets.discard(ws) diff --git a/app/routers/messaging.py b/app/routers/messaging.py index 59d6c18..7465f80 100644 --- a/app/routers/messaging.py +++ b/app/routers/messaging.py @@ -31,6 +31,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user, get_curren from ..auth import get_current_user_from_websocket from ..db import org_connection, plain_connection from ..errors import app_error, translate_db_errors +from ..realtime import live_sockets_for from ..team_authz import is_org_admin, user_is_rostered_on_team from .. import storage from .registration import check_visibility, code_matches, is_participant, resolve_org @@ -418,3 +419,38 @@ async def feed_ws(websocket: WebSocket, tournament_id: str, code: str | None = N pass finally: _feed_sockets[tournament_id].discard(websocket) + + +@router.websocket("/ws/public/tournaments/{tournament_id}/live") +async def tournament_live_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None: + """ADR-027: "Følg live"-siden (leaderboard/matcher/scorekort, + registration.py) sin sanntid. Sender kun et "noe endret seg"-signal -- + se app/realtime.py sin moduldocstring for hvorfor -- klienten reagerer + ved å hente de vanlige REST-endepunktene på nytt. Samme visibility-sjekk + som selve de offentlige lese-endepunktene den speiler.""" + user = await get_current_user_from_websocket(websocket) + organization_id = await resolve_org(tournament_id) + async with org_connection(organization_id) as conn: + row = await conn.fetchrow( + "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id + ) + if row is None: + await websocket.close(code=4404) + return + if not code_matches(row["join_code"], code): + try: + await check_visibility(conn, row["visibility"], organization_id, tournament_id, user) + except Exception: + await websocket.close(code=4403) + return + + await websocket.accept() + sockets = live_sockets_for(tournament_id) + sockets.add(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + sockets.discard(websocket) diff --git a/app/routers/scoring.py b/app/routers/scoring.py index a2e2323..9363a77 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -50,6 +50,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection from ..errors import app_error, translate_db_errors from ..handicap import parse_allowance_config, relative_strokes_for_match +from ..realtime import broadcast_live_update from ..team_authz import user_is_match_participant, user_is_team_captain router = APIRouter() @@ -164,7 +165,8 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None: SELECT m.id::text, s.format, s.scoring_mode, s.hole_config::text AS hole_config, s.course_id::text AS course_id, s.allowance_override::text AS allowance_override, - s.points_per_match::float AS points_per_match + s.points_per_match::float AS points_per_match, + s.tournament_id::text AS tournament_id FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 FOR UPDATE OF m @@ -216,6 +218,11 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None: match_id, ) + # ADR-027: "Følg live"-siden lyttes til over WebSocket -- kringkast et + # "noe endret seg, hent på nytt"-signal til enhver som følger DENNE + # turneringen. Trygt å kalle uansett om noen faktisk lytter (no-op da). + await broadcast_live_update(match["tournament_id"]) + class ConcedeOut(BaseModel): match_id: str @@ -225,13 +232,16 @@ class ConcedeOut(BaseModel): leading_side: str -async def apply_concession(conn, match_id: str, conceding_side: str, points_per_match: float) -> ConcedeOut: +async def apply_concession( + conn, match_id: str, conceding_side: str, points_per_match: float, tournament_id: str +) -> ConcedeOut: """Setter matchen som avgjort ved walkover -- full poengsum til motstanderen, uansett hvor mange hull som ev. allerede er registrert (marginen betyr ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller MÅ selv ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er - avgjort FØR denne kalles -- se de to kallstedene (matches.py sin - concede_match, tournaments.py sin concede_tournament).""" + avgjort FØR denne kalles -- se de to kallstedene (denne filens + concede_match, tournaments.py sin concede_tournament). + `tournament_id` trengs KUN for ADR-027 sin "Følg live"-kringkasting.""" winner = "b" if conceding_side == "a" else "a" points_a = 0.0 if conceding_side == "a" else points_per_match points_b = points_per_match if conceding_side == "a" else 0.0 @@ -247,6 +257,7 @@ async def apply_concession(conn, match_id: str, conceding_side: str, points_per_ winner, match_id, ) + await broadcast_live_update(tournament_id) return ConcedeOut( match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner ) @@ -278,7 +289,8 @@ async def concede_match( """ SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, m.points_side_a::float AS points_side_a, - s.points_per_match::float AS points_per_match + s.points_per_match::float AS points_per_match, + s.tournament_id::text AS tournament_id FROM match m JOIN session s ON s.id = m.session_id WHERE m.id = $1 FOR UPDATE OF m @@ -304,7 +316,9 @@ async def concede_match( "Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.", ) - result = await apply_concession(conn, match_id, conceding_side, match["points_per_match"]) + result = await apply_concession( + conn, match_id, conceding_side, match["points_per_match"], match["tournament_id"] + ) return result diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 89c39c6..1e62b62 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -341,7 +341,7 @@ async def concede_tournament( ) for m in matches: side = "a" if m["team_a_id"] == body.conceding_team_id else "b" - await apply_concession(conn, m["id"], side, m["points_per_match"]) + await apply_concession(conn, m["id"], side, m["points_per_match"], tournament_id) return ConcedeTournamentOut(matches_conceded=len(matches)) diff --git a/frontend/components/public-live.tsx b/frontend/components/public-live.tsx index 97c59c6..929c243 100644 --- a/frontend/components/public-live.tsx +++ b/frontend/components/public-live.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import Link from "next/link" import { ArrowLeft, ChevronDown, ShieldAlert, Trophy } from "lucide-react" import { Wordmark } from "@/components/wordmark" @@ -75,14 +75,44 @@ function formatPoints(points: number) { return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") } +function liveWsUrl(tournamentId: string, code?: string): string { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const query = code ? `?code=${encodeURIComponent(code)}` : "" + return `${protocol}//${window.location.host}/ws/public/tournaments/${tournamentId}/live${query}` +} + export function PublicLive({ tournamentId, code }: { tournamentId: string; code?: string }) { const [loading, setLoading] = useState(true) const [accessError, setAccessError] = useState(null) const [tournamentName, setTournamentName] = useState("") const [leaderboard, setLeaderboard] = useState(null) const [sessions, setSessions] = useState([]) + const [refreshKey, setRefreshKey] = useState(0) const codeParam = code ? `?code=${encodeURIComponent(code)}` : "" + // Sanntid (ADR-027): et rent "noe endret seg"-signal over WebSocket -- + // klienten reagerer ved å telle opp refreshKey, som utløser refetch her + // OG i SessionRow/MatchRow under (kun for det som faktisk er åpent/synlig). + useEffect(() => { + const socket = new WebSocket(liveWsUrl(tournamentId, code)) + socket.onmessage = () => setRefreshKey((k) => k + 1) + return () => socket.close() + }, [tournamentId, code]) + + const isFirstRefresh = useRef(true) + useEffect(() => { + if (isFirstRefresh.current) { + isFirstRefresh.current = false + return + } + fetch(`/public/tournaments/${tournamentId}/leaderboard${codeParam}`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((lb: Leaderboard | null) => { + if (lb) setLeaderboard(lb) + }) + .catch(() => {}) + }, [refreshKey, tournamentId, codeParam]) + useEffect(() => { let cancelled = false async function load() { @@ -165,7 +195,8 @@ export function PublicLive({ tournamentId, code }: { tournamentId: string; code?