diff --git a/.claude/settings.json b/.claude/settings.json index 9c43370..e601e52 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -37,7 +37,15 @@ "Bash(grep -n \"participantLabel\\(\\\\|export function\\\\|useState %{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)" + "Bash(curl -s -o /dev/null -w \"https://teecup.teeoff.no/my-rounds -> %{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)", + "Bash(python3 test_round_realtime.py)", + "Bash(pip install *)", + "Bash(python3 -c \"import websockets; print\\(websockets.__version__\\)\")", + "Bash(python3 -m pip --version)", + "Bash(python3 -m ensurepip --user)", + "Bash(curl -sS https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" -H \"Connection: Upgrade\" -H \"Upgrade: websocket\" -H \"Sec-WebSocket-Version: 13\" -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" https://teecup.teeoff.no/ws/rounds/00000000-0000-0000-0000-000000000000/live)", + "Bash(curl -s -H \"Connection: Upgrade\" -H \"Upgrade: websocket\" -H \"Sec-WebSocket-Version: 13\" -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" https://teecup.teeoff.no/ws/rounds/00000000-0000-0000-0000-000000000000/live)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 376137a..f865dff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3031,6 +3031,60 @@ Ferdig og verifisert: --build teecup_api teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`/`/my-rounds` → 200, `teeoff.no` upåvirket. +- **Sanntid for frittstående runder, BYGGET OG SCRATCH-VERIFISERT + (2026-07-26), samme dag som medspiller-søket over:** brukeren spurte om + spillere ser i sanntid at en annen har registrert en score — svaret var + nei (kun engangs-henting ved lasting), og brukeren ba om at det bygges, + gjenbruk av det etablerte "noe endret seg, hent på nytt"-WebSocket- + mønsteret fra ADR-027 ("Følg live" for turneringer). + `app/realtime.py` utvidet (fortsatt rutefri, se moduldoc) med en andre, + parallell kringkastings-registry for runder (`live_sockets_for_round`/ + `broadcast_round_update`, delt `_broadcast()`-hjelpefunksjon for å unngå + duplisert kringkastingslogikk). Nytt `@router.websocket("/ws/rounds/ + {round_id}/live")` i `rounds.py` — ALDRI anonym tilgang (ulikt + turnering-live), krever eier ELLER lenket medspiller + (`_get_accessible_round_or_404`, samme sjekk som resten av + medspiller-utvidelsen). Kringkasting lagt inn i `update_hole`, + `complete_round`, `add_participant`, `remove_guest_participant`, + `update_round` og `delete_round` — alt som endrer noe de andre på + skjermen bør få vite om. Ingen ny Caddy-endring nødvendig (`/ws/*` er + allerede en wildcard-rute fra ADR-025). + **Reelt funn under scratch-testing, ikke en bug, men verdt å dokumentere:** + Starlette avviser en WebSocket FØR `.accept()` alltid som en bar HTTP + 403 under selve håndtrykket — de tre distinkte lukkekodene (4401/4403/ + 4404) jeg satte når til `websocket.close()` server-side, men skiller seg + IKKE fra hverandre i klientens håndtrykk-avvisning (alle tre ga HTTP 403 + i en ekte WS-klienttest, ikke bare curl). Selve sikkerheten (tilkobling + korrekt avvist i alle tre tilfeller) er upåvirket — samme underliggende + Starlette-oppførsel gjelder trolig også den eksisterende turnering-live- + ruten (ADR-027), bare ikke tidligere testet med en ekte WS-klient på + dette presisjonsnivået. + **Frontend:** `round-detail.tsx` åpner en WebSocket ved montering, + refetcher runden ved signal og henter aktiv spillers hull DIREKTE på + nytt (ingen mellomsteg med tom stat som ville blinket for spilleren som + selv nettopp registrerte et slag) — andre spilleres hull-cache droppes + i stedet, hentes friskt ved neste fanebytte. Bruker en ref for å unngå + at socket-en kobles til/fra ved hvert fanebytte. `round-stats.tsx`/ + `round-scorecard.tsx` (rene lesevisninger) fikk en enklere + `refreshKey`-basert variant, samme mønster som `public-live.tsx` fra + ADR-027. + **Scratch-verifisert grundig, 10/10 sjekker, ekte WebSocket-klient (ikke + bare REST):** ekte kringkasting bekreftet begge veier — eierens åpne + socket mottok et signal da medspilleren registrerte et slag via REST, + OG medspillerens åpne socket mottok et signal da eieren fullførte runden + — ikke bare at REST-svarene så riktige ut. Alle tre avvisningstilfellene + (uautentisert, urelatert fremmed, ukjent runde-id) korrekt avvist. + `test_isolation.sql` 12/12 uendret (ingen migrasjon). Ekte typesjekket + produksjonsbuild kompilerte rent. + **Rullet ut live 2026-07-26**, bruker bekreftet eksplisitt: ingen + migrasjon, `docker compose up -d --build teecup_api teecup_frontend`. + Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` + upåvirket. Verifisert presist at `/ws/rounds/*`-ruten faktisk når + FastAPI: et ekte WS-håndtrykk-forsøk mot en ukjent runde-id over + produksjons-https ga FastAPI sin egen JSON-`{"detail":"Not Found"}`, + ikke Next.js sin HTML-404 — samme verifiseringsmønster som ADR-027s + tournament-live-rute. + Neste steg: 0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):** varsler (in-app varslingssenter) + leaderboard-BACKENDEN for diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 7375d49..dc14865 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -2208,6 +2208,20 @@ DERES egen fremdrift, ikke eierens), en helt urelatert bruker fortsatt deltakertyper. `test_isolation.sql` 12/12 uendret. Ekte typesjekket produksjonsbuild kompilerte rent. +### Oppfølging samme dag: sanntid — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-26 + +Brukeren spurte om spillere ser i sanntid at en annen registrerer en +score — svaret var nei, og brukeren ba om at det bygges. Gjenbrukte det +etablerte WebSocket-mønsteret fra ADR-027 ("Følg live" for turneringer): +et rent "noe endret seg"-signal, klienten reagerer med de vanlige REST- +kallene. Nytt `/ws/rounds/{id}/live` i `rounds.py`, ALDRI anonym tilgang +(krever eier eller lenket medspiller), kringkasting lagt inn i alle +skrivende rundeendepunkter. Se ARCHITECTURE_DECISIONS.md/CLAUDE.md for +full detalj, inkl. et reelt Starlette-funn (alle avvisningskoder +kollapser til HTTP 403 i selve håndtrykket — sikkerheten er upåvirket). +Scratch-verifisert med en EKTE WebSocket-klient (10/10 sjekker) — ekte +kringkasting bekreftet begge veier, ikke bare REST-svar. + --- ## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget diff --git a/app/realtime.py b/app/realtime.py index 50d4335..eaf7f7a 100644 --- a/app/realtime.py +++ b/app/realtime.py @@ -1,6 +1,8 @@ """ -Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer på den -offentlige "Følg live"-siden (ADR-027, oppfølging av ADR-025/026). +Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer -- den +offentlige "Følg live"-siden (ADR-027, oppfølging av ADR-025/026) OG +frittstående runder (2026-07-26, oppfølging av ADR-033/036 sin +medspiller-utvidelse). Egen, RUTEFRI modul -- importerer INGEN router. Grunnen: både `scoring.py`/`tournaments.py` (som endrer matchstatus) og `messaging.py` @@ -9,6 +11,8 @@ 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. +`rounds.py` er ikke del av den kjeden, men gjenbruker samme modul for +konsistens (kun ett sted i kodebasen som eier WebSocket-kringkasting). In-memory PER PROSESS -- samme kjente begrensning som meldinger sin egen kringkasting (`app/routers/messaging.py`): trygt med dagens ene @@ -27,6 +31,7 @@ from collections import defaultdict from fastapi import WebSocket _tournament_live_sockets: dict[str, set[WebSocket]] = defaultdict(set) +_round_live_sockets: dict[str, set[WebSocket]] = defaultdict(set) def live_sockets_for(tournament_id: str) -> set[WebSocket]: @@ -34,7 +39,19 @@ def live_sockets_for(tournament_id: str) -> set[WebSocket]: async def broadcast_live_update(tournament_id: str) -> None: - sockets = _tournament_live_sockets.get(tournament_id) + await _broadcast(_tournament_live_sockets, tournament_id) + + +def live_sockets_for_round(round_id: str) -> set[WebSocket]: + return _round_live_sockets[round_id] + + +async def broadcast_round_update(round_id: str) -> None: + await _broadcast(_round_live_sockets, round_id) + + +async def _broadcast(registry: dict[str, set[WebSocket]], key: str) -> None: + sockets = registry.get(key) if not sockets: return dead = [] diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 2a6d743..430b835 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -30,13 +30,14 @@ import math from datetime import date, datetime from typing import Literal -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from pydantic import BaseModel, Field from .. import teeoff_client -from ..auth import CurrentUser, get_current_user +from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket from ..db import plain_connection from ..errors import app_error, translate_db_errors +from ..realtime import broadcast_round_update, live_sockets_for_round from handicap_engine import ( adjusted_gross_score, allocate_strokes_by_index, @@ -855,7 +856,10 @@ async def update_round( p["id"], hole_number, par_by_hole[hole_number], index_by_hole[hole_number], ) - return await _load_round_out(conn, round_id, user.user_id) + result = await _load_round_out(conn, round_id, user.user_id) + + await broadcast_round_update(round_id) + return result @router.delete("/rounds/{round_id}", status_code=204) @@ -863,6 +867,9 @@ async def delete_round(round_id: str, user: CurrentUser = Depends(get_current_us async with plain_connection() as conn: await _get_owned_round_or_404(conn, round_id, user.user_id) await conn.execute("DELETE FROM round WHERE id = $1", round_id) + # Varsler evt. tilkoblede medspillere om at runden er borte -- deres + # neste refetch vil da naturlig treffe 404 og vise en tydelig feil. + await broadcast_round_update(round_id) # --------------------------------------------------------------------------- @@ -961,7 +968,8 @@ async def add_participant( """, participant_id, ) - return RoundParticipantOut(**dict(row)) + await broadcast_round_update(round_id) + return RoundParticipantOut(**dict(row)) class ParticipantUpdate(BaseModel): @@ -1017,6 +1025,7 @@ async def remove_guest_participant( if row["is_owner"]: raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.") await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id) + await broadcast_round_update(round_id) # --------------------------------------------------------------------------- @@ -1249,6 +1258,7 @@ async def update_hole( by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)} strokes_received = by_hole[hole_number] + await broadcast_round_update(round_id) return RoundHoleOut(**dict(row), strokes_received=strokes_received) @@ -1302,10 +1312,45 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_ ) await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id) - return await _load_round_out(conn, round_id, user.user_id) + result = await _load_round_out(conn, round_id, user.user_id) + + await broadcast_round_update(round_id) + return result def _course_handicap_from_row(p) -> int: return course_handicap( p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"] ) + + +# --------------------------------------------------------------------------- +# Sanntid (2026-07-26, oppfølging av ADR-027) -- "noe endret seg, hent på +# nytt"-signal for round-detail.tsx/round-stats.tsx/round-scorecard.tsx. +# Ulikt turnering-live (ADR-027) er en runde ALDRI anonymt tilgjengelig -- +# krever alltid en ekte, autorisert sesjon (eier ELLER lenket medspiller). +# --------------------------------------------------------------------------- + +@router.websocket("/ws/rounds/{round_id}/live") +async def round_live_ws(websocket: WebSocket, round_id: str) -> None: + user = await get_current_user_from_websocket(websocket) + if user is None: + await websocket.close(code=4401) + return + async with plain_connection() as conn: + try: + await _get_accessible_round_or_404(conn, round_id, user.user_id) + except HTTPException as exc: + await websocket.close(code=4404 if exc.status_code == 404 else 4403) + return + + await websocket.accept() + sockets = live_sockets_for_round(round_id) + sockets.add(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + sockets.discard(websocket) diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 8f19f39..203d5ec 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -322,6 +322,34 @@ export function RoundDetail({ roundId }: { roundId: string }) { } }, [activePlayerId, holesByParticipant, loadHoles]) + // Alltid gjeldende activePlayerId tilgjengelig INNI WebSocket-handleren + // under, uten å måtte koble socket-en til/fra ved hvert fanebytte (som en + // vanlig closure over `activePlayerId` i effektens dependency-array ville + // krevd -- ville ødelagt WS-tilkoblingen hver gang brukeren bytter fane). + const activePlayerIdRef = useRef(null) + useEffect(() => { + activePlayerIdRef.current = activePlayerId + }, [activePlayerId]) + + // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"- + // signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST- + // kallene den uansett gjør ved førstegangslasting. Aktiv spillers hull + // hentes på nytt DIREKTE (overskriver når svaret kommer, ingen mellomsteg + // med tom stat som ville blinket til for spilleren som selv nettopp + // registrerte et slag) -- de ANDRE spillernes cache droppes i stedet, slik + // at et senere fanebytte henter friskt fremfor en stale verdi. + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) + socket.onmessage = () => { + void loadRound() + const active = activePlayerIdRef.current + setHolesByParticipant((prev) => (active && prev[active] ? { [active]: prev[active] } : {})) + if (active) void loadHoles(active) + } + return () => socket.close() + }, [roundId, loadRound, loadHoles]) + const completed = round?.completed_at != null const readOnly = completed diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx index 02249de..6b0102e 100644 --- a/frontend/components/round-scorecard.tsx +++ b/frontend/components/round-scorecard.tsx @@ -319,6 +319,11 @@ export function RoundScorecard({ roundId }: { roundId: string }) { const [holes, setHoles] = useState(null) const [error, setError] = useState(null) const [viewerId, setViewerId] = useState(null) + // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"- + // signal over WebSocket -- bumper denne, som er med i begge fetch- + // effektene under, slik at de henter på nytt akkurat som ved + // førstegangslasting (samme mønster som public-live.tsx). + const [refreshKey, setRefreshKey] = useState(0) useEffect(() => { let cancelled = false @@ -333,6 +338,13 @@ export function RoundScorecard({ roundId }: { roundId: string }) { } }, []) + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) + socket.onmessage = () => setRefreshKey((k) => k + 1) + return () => socket.close() + }, [roundId]) + useEffect(() => { let cancelled = false async function load() { @@ -363,7 +375,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) { return () => { cancelled = true } - }, [roundId, viewerId]) + }, [roundId, viewerId, refreshKey]) useEffect(() => { if (!participantId) return @@ -378,7 +390,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) { return () => { cancelled = true } - }, [roundId, participantId]) + }, [roundId, participantId, refreshKey]) if (error) { return ( diff --git a/frontend/components/round-stats.tsx b/frontend/components/round-stats.tsx index 9c5e1bf..75c3f37 100644 --- a/frontend/components/round-stats.tsx +++ b/frontend/components/round-stats.tsx @@ -573,6 +573,11 @@ export function RoundStats({ roundId }: { roundId: string }) { const [holes, setHoles] = useState(null) const [error, setError] = useState(null) const [viewerId, setViewerId] = useState(null) + // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"- + // signal over WebSocket -- bumper denne, som er med i begge fetch- + // effektene under, slik at de henter på nytt akkurat som ved + // førstegangslasting (samme mønster som public-live.tsx). + const [refreshKey, setRefreshKey] = useState(0) useEffect(() => { let cancelled = false @@ -587,6 +592,13 @@ export function RoundStats({ roundId }: { roundId: string }) { } }, []) + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) + socket.onmessage = () => setRefreshKey((k) => k + 1) + return () => socket.close() + }, [roundId]) + useEffect(() => { let cancelled = false async function load() { @@ -615,7 +627,7 @@ export function RoundStats({ roundId }: { roundId: string }) { return () => { cancelled = true } - }, [roundId, viewerId]) + }, [roundId, viewerId, refreshKey]) useEffect(() => { if (!participantId) return @@ -630,7 +642,7 @@ export function RoundStats({ roundId }: { roundId: string }) { return () => { cancelled = true } - }, [roundId, participantId]) + }, [roundId, participantId, refreshKey]) if (error) { return (