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:
parent
89136a1a83
commit
3fc0c7460c
8 changed files with 203 additions and 13 deletions
|
|
@ -37,7 +37,15 @@
|
|||
"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_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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
CLAUDE.md
54
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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 readOnly = completed
|
||||
|
||||
|
|
|
|||
|
|
@ -319,6 +319,11 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
|||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||
const [error, setError] = 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(() => {
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -573,6 +573,11 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||
const [error, setError] = 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(() => {
|
||||
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 (
|
||||
|
|
|
|||
Loading…
Reference in a new issue