Live. Kort oppsummert: spillere ser nå i sanntid når noen registrerer en score, endrer en medspiller, eller fullfører runden — samme lette WebSocket-mønster som "Følg live" på turneringer. Ingen migrasjon, teeoff.no upåvirket, verifisert med en ekte WebSocket-klient (ikke bare REST) at kringkastingen faktisk fungerer begge veier.

This commit is contained in:
Erol Haagenrud 2026-07-26 08:49:37 +02:00
parent 89136a1a83
commit 3fc0c7460c
8 changed files with 203 additions and 13 deletions

View file

@ -37,7 +37,15 @@
"Bash(grep -n \"participantLabel\\(\\\\|export function\\\\|useState<ApiRound\\\\|fetch\\(\\\\`/rounds\" /opt/teecup/frontend/components/round-scorecard.tsx)", "Bash(grep -n \"participantLabel\\(\\\\|export function\\\\|useState<ApiRound\\\\|fetch\\(\\\\`/rounds\" /opt/teecup/frontend/components/round-scorecard.tsx)",
"Bash(python3 test_round_coplayer.py)", "Bash(python3 test_round_coplayer.py)",
"Bash(python3 test_round_coplayer_incomplete.py)", "Bash(python3 test_round_coplayer_incomplete.py)",
"Bash(curl -s -o /dev/null -w \"https://teecup.teeoff.no/my-rounds -> %{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)"
] ]
} }
} }

View file

@ -3031,6 +3031,60 @@ Ferdig og verifisert:
--build teecup_api teecup_frontend`. Begge containere boot-et rent, --build teecup_api teecup_frontend`. Begge containere boot-et rent,
`/health`/`/dashboard`/`/my-rounds` → 200, `teeoff.no` upåvirket. `/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: Neste steg:
0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):** 0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):**
varsler (in-app varslingssenter) + leaderboard-BACKENDEN for varsler (in-app varslingssenter) + leaderboard-BACKENDEN for

View file

@ -2208,6 +2208,20 @@ DERES egen fremdrift, ikke eierens), en helt urelatert bruker fortsatt
deltakertyper. `test_isolation.sql` 12/12 uendret. Ekte typesjekket deltakertyper. `test_isolation.sql` 12/12 uendret. Ekte typesjekket
produksjonsbuild kompilerte rent. 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 ## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget

View file

@ -1,6 +1,8 @@
""" """
Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer den Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer -- den
offentlige "Følg live"-siden (ADR-027, oppfølging av ADR-025/026). 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 Egen, RUTEFRI modul -- importerer INGEN router. Grunnen: både
`scoring.py`/`tournaments.py` (som endrer matchstatus) og `messaging.py` `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 matches.py mens `messaging.py` importerer fra `registration.py` -- en
kringkastingsfunksjon plassert i en av routerne ville skapt en sirkulær kringkastingsfunksjon plassert i en av routerne ville skapt en sirkulær
import. Denne modulen ligger BAK alle routere i importgrafen i stedet. 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 In-memory PER PROSESS -- samme kjente begrensning som meldinger sin egen
kringkasting (`app/routers/messaging.py`): trygt med dagens ene kringkasting (`app/routers/messaging.py`): trygt med dagens ene
@ -27,6 +31,7 @@ from collections import defaultdict
from fastapi import WebSocket from fastapi import WebSocket
_tournament_live_sockets: dict[str, set[WebSocket]] = defaultdict(set) _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]: 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: 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: if not sockets:
return return
dead = [] dead = []

View file

@ -30,13 +30,14 @@ import math
from datetime import date, datetime from datetime import date, datetime
from typing import Literal 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 pydantic import BaseModel, Field
from .. import teeoff_client 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 ..db import plain_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from ..realtime import broadcast_round_update, live_sockets_for_round
from handicap_engine import ( from handicap_engine import (
adjusted_gross_score, adjusted_gross_score,
allocate_strokes_by_index, 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], 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) @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: async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id) await _get_owned_round_or_404(conn, round_id, user.user_id)
await conn.execute("DELETE FROM round WHERE id = $1", round_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,6 +968,7 @@ async def add_participant(
""", """,
participant_id, participant_id,
) )
await broadcast_round_update(round_id)
return RoundParticipantOut(**dict(row)) return RoundParticipantOut(**dict(row))
@ -1017,6 +1025,7 @@ async def remove_guest_participant(
if row["is_owner"]: if row["is_owner"]:
raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.") raise app_error(400, "VALIDATION_FAILED", "Kan ikke fjerne runde-eieren.")
await conn.execute("DELETE FROM round_participant WHERE id = $1", participant_id) 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)} by_hole = {r["hole_number"]: a for r, a in zip(all_indexes, allocation)}
strokes_received = by_hole[hole_number] strokes_received = by_hole[hole_number]
await broadcast_round_update(round_id)
return RoundHoleOut(**dict(row), strokes_received=strokes_received) 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) 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: def _course_handicap_from_row(p) -> int:
return course_handicap( return course_handicap(
p["handicap_index_snapshot"], p["slope_rating_snapshot"], p["course_rating_snapshot"], p["tee_par_snapshot"] 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)

View file

@ -322,6 +322,34 @@ export function RoundDetail({ roundId }: { roundId: string }) {
} }
}, [activePlayerId, holesByParticipant, loadHoles]) }, [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<string | null>(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 completed = round?.completed_at != null
const readOnly = completed const readOnly = completed

View file

@ -319,6 +319,11 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
const [holes, setHoles] = useState<ApiHole[] | null>(null) const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null) const [viewerId, setViewerId] = useState<string | null>(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(() => { useEffect(() => {
let cancelled = false 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function load() {
@ -363,7 +375,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [roundId, viewerId]) }, [roundId, viewerId, refreshKey])
useEffect(() => { useEffect(() => {
if (!participantId) return if (!participantId) return
@ -378,7 +390,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [roundId, participantId]) }, [roundId, participantId, refreshKey])
if (error) { if (error) {
return ( return (

View file

@ -573,6 +573,11 @@ export function RoundStats({ roundId }: { roundId: string }) {
const [holes, setHoles] = useState<ApiHole[] | null>(null) const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null) const [viewerId, setViewerId] = useState<string | null>(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(() => { useEffect(() => {
let cancelled = false 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function load() {
@ -615,7 +627,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [roundId, viewerId]) }, [roundId, viewerId, refreshKey])
useEffect(() => { useEffect(() => {
if (!participantId) return if (!participantId) return
@ -630,7 +642,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [roundId, participantId]) }, [roundId, participantId, refreshKey])
if (error) { if (error) {
return ( return (