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(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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
CLAUDE.md
54
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""
|
"""
|
||||||
Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer på 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 = []
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue