Alt scratch-verifisert (5 automatiserte sjekker inkl. faktisk sanntidsmottak over WebSocket for både score-innsending og walkover), typesjekket build OK. Ingen migrasjon, ingen ny Caddy-rute (gjenbruker /ws/* fra sist).
This commit is contained in:
parent
254cb92c7a
commit
09aaa38bde
8 changed files with 248 additions and 44 deletions
|
|
@ -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/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/matches.py'\\).read\\(\\)\\)\")",
|
||||||
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/scoring.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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -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å)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
|
|
@ -477,12 +477,14 @@
|
||||||
- «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit.
|
- «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit.
|
||||||
|
|
||||||
### Sanntid (WebSockets)
|
### Sanntid (WebSockets)
|
||||||
- **Status:** ✅ mekanisme LIVE 2026-07-19 (ADR-025), men KUN koblet til
|
- **Status:** ✅ HELT FERDIG, LIVE 2026-07-19 (ADR-025 + ADR-027). Koblet
|
||||||
meldinger ennå. WebSockets valgt (ikke polling) — se Kommunikasjon-
|
til BÅDE meldinger (ADR-025) OG `/t/[id]/live` (leaderboard/matcher/
|
||||||
seksjonen. `app/routers/messaging.py` sitt in-memory tilkoblingsregister
|
scorekort, ADR-027). Ny, rutefri delt modul `app/realtime.py` (unngår
|
||||||
og broadcast-mønster kan gjenbrukes for et live leaderboard senere (samme
|
sirkulær import mellom `messaging.py`/`registration.py`/`scoring.py`/
|
||||||
backend-endepunkt finnes allerede), men det er IKKE gjort i denne runden
|
`tournaments.py`) — sender et "noe endret seg"-signal (ikke selve
|
||||||
— egen, liten oppfølgingssak om/når etterspurt.
|
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)
|
### Knockout / cup-turnering (egen turneringstype)
|
||||||
- **Status:** 💤 utsatt (egen fremtidig type, ADR-011)
|
- **Status:** 💤 utsatt (egen fremtidig type, ADR-011)
|
||||||
|
|
|
||||||
47
app/realtime.py
Normal file
47
app/realtime.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -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 ..auth import get_current_user_from_websocket
|
||||||
from ..db import org_connection, plain_connection
|
from ..db import org_connection, plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
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 ..team_authz import is_org_admin, user_is_rostered_on_team
|
||||||
from .. import storage
|
from .. import storage
|
||||||
from .registration import check_visibility, code_matches, is_participant, resolve_org
|
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
|
pass
|
||||||
finally:
|
finally:
|
||||||
_feed_sockets[tournament_id].discard(websocket)
|
_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)
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
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
|
from ..team_authz import user_is_match_participant, user_is_team_captain
|
||||||
|
|
||||||
router = APIRouter()
|
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,
|
SELECT m.id::text, s.format, s.scoring_mode,
|
||||||
s.hole_config::text AS hole_config, s.course_id::text AS course_id,
|
s.hole_config::text AS hole_config, s.course_id::text AS course_id,
|
||||||
s.allowance_override::text AS allowance_override,
|
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
|
FROM match m JOIN session s ON s.id = m.session_id
|
||||||
WHERE m.id = $1
|
WHERE m.id = $1
|
||||||
FOR UPDATE OF m
|
FOR UPDATE OF m
|
||||||
|
|
@ -216,6 +218,11 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
|
||||||
match_id,
|
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):
|
class ConcedeOut(BaseModel):
|
||||||
match_id: str
|
match_id: str
|
||||||
|
|
@ -225,13 +232,16 @@ class ConcedeOut(BaseModel):
|
||||||
leading_side: str
|
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,
|
"""Setter matchen som avgjort ved walkover -- full poengsum til motstanderen,
|
||||||
uansett hvor mange hull som ev. allerede er registrert (marginen betyr
|
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
|
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
|
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
|
avgjort FØR denne kalles -- se de to kallstedene (denne filens
|
||||||
concede_match, tournaments.py sin concede_tournament)."""
|
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"
|
winner = "b" if conceding_side == "a" else "a"
|
||||||
points_a = 0.0 if conceding_side == "a" else points_per_match
|
points_a = 0.0 if conceding_side == "a" else points_per_match
|
||||||
points_b = points_per_match if conceding_side == "a" else 0.0
|
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,
|
winner,
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
|
await broadcast_live_update(tournament_id)
|
||||||
return ConcedeOut(
|
return ConcedeOut(
|
||||||
match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner
|
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,
|
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,
|
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
|
FROM match m JOIN session s ON s.id = m.session_id
|
||||||
WHERE m.id = $1
|
WHERE m.id = $1
|
||||||
FOR UPDATE OF m
|
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.",
|
"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
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,7 @@ async def concede_tournament(
|
||||||
)
|
)
|
||||||
for m in matches:
|
for m in matches:
|
||||||
side = "a" if m["team_a_id"] == body.conceding_team_id else "b"
|
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))
|
return ConcedeTournamentOut(matches_conceded=len(matches))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ChevronDown, ShieldAlert, Trophy } from "lucide-react"
|
import { ArrowLeft, ChevronDown, ShieldAlert, Trophy } from "lucide-react"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
|
@ -75,14 +75,44 @@ function formatPoints(points: number) {
|
||||||
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
|
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 }) {
|
export function PublicLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accessError, setAccessError] = useState<string | null>(null)
|
const [accessError, setAccessError] = useState<string | null>(null)
|
||||||
const [tournamentName, setTournamentName] = useState("")
|
const [tournamentName, setTournamentName] = useState("")
|
||||||
const [leaderboard, setLeaderboard] = useState<Leaderboard | null>(null)
|
const [leaderboard, setLeaderboard] = useState<Leaderboard | null>(null)
|
||||||
const [sessions, setSessions] = useState<ApiSession[]>([])
|
const [sessions, setSessions] = useState<ApiSession[]>([])
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -165,7 +195,8 @@ export function PublicLive({ tournamentId, code }: { tournamentId: string; code?
|
||||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span aria-hidden="true" className="size-1.5 animate-pulse rounded-full bg-primary" />
|
||||||
Følg live
|
Følg live
|
||||||
</span>
|
</span>
|
||||||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
|
||||||
|
|
@ -190,6 +221,7 @@ export function PublicLive({ tournamentId, code }: { tournamentId: string; code?
|
||||||
tournamentId={tournamentId}
|
tournamentId={tournamentId}
|
||||||
codeParam={codeParam}
|
codeParam={codeParam}
|
||||||
teamsById={teamsById}
|
teamsById={teamsById}
|
||||||
|
refreshKey={refreshKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -258,21 +290,20 @@ function SessionRow({
|
||||||
tournamentId,
|
tournamentId,
|
||||||
codeParam,
|
codeParam,
|
||||||
teamsById,
|
teamsById,
|
||||||
|
refreshKey,
|
||||||
}: {
|
}: {
|
||||||
session: ApiSession
|
session: ApiSession
|
||||||
tournamentId: string
|
tournamentId: string
|
||||||
codeParam: string
|
codeParam: string
|
||||||
teamsById: Map<string, TeamStanding>
|
teamsById: Map<string, TeamStanding>
|
||||||
|
refreshKey: number
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [matches, setMatches] = useState<ApiMatch[] | null>(null)
|
const [matches, setMatches] = useState<ApiMatch[] | null>(null)
|
||||||
const [loadingMatches, setLoadingMatches] = useState(false)
|
const [loadingMatches, setLoadingMatches] = useState(false)
|
||||||
const title = session.name?.trim() || `Økt ${session.sequence}`
|
const title = session.name?.trim() || `Økt ${session.sequence}`
|
||||||
|
|
||||||
async function toggle() {
|
async function fetchMatches() {
|
||||||
const next = !open
|
|
||||||
setOpen(next)
|
|
||||||
if (next && matches === null) {
|
|
||||||
setLoadingMatches(true)
|
setLoadingMatches(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|
@ -284,8 +315,25 @@ function SessionRow({
|
||||||
setLoadingMatches(false)
|
setLoadingMatches(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const next = !open
|
||||||
|
setOpen(next)
|
||||||
|
if (next) void fetchMatches()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanntid: hent matchene på nytt når WS sier noe endret seg -- men KUN
|
||||||
|
// hvis økten faktisk er åpen (ingen vits i å hente noe brukeren ikke ser).
|
||||||
|
const isFirstRefresh = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRefresh.current) {
|
||||||
|
isFirstRefresh.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (open) void fetchMatches()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [refreshKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
<button
|
<button
|
||||||
|
|
@ -325,6 +373,7 @@ function SessionRow({
|
||||||
codeParam={codeParam}
|
codeParam={codeParam}
|
||||||
teamA={teamsById.get(m.team_a_id)}
|
teamA={teamsById.get(m.team_a_id)}
|
||||||
teamB={teamsById.get(m.team_b_id)}
|
teamB={teamsById.get(m.team_b_id)}
|
||||||
|
refreshKey={refreshKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -339,22 +388,21 @@ function MatchRow({
|
||||||
codeParam,
|
codeParam,
|
||||||
teamA,
|
teamA,
|
||||||
teamB,
|
teamB,
|
||||||
|
refreshKey,
|
||||||
}: {
|
}: {
|
||||||
match: ApiMatch
|
match: ApiMatch
|
||||||
tournamentId: string
|
tournamentId: string
|
||||||
codeParam: string
|
codeParam: string
|
||||||
teamA?: TeamStanding
|
teamA?: TeamStanding
|
||||||
teamB?: TeamStanding
|
teamB?: TeamStanding
|
||||||
|
refreshKey: number
|
||||||
}) {
|
}) {
|
||||||
const [showScorecard, setShowScorecard] = useState(false)
|
const [showScorecard, setShowScorecard] = useState(false)
|
||||||
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
|
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
|
||||||
const [loadingScorecard, setLoadingScorecard] = useState(false)
|
const [loadingScorecard, setLoadingScorecard] = useState(false)
|
||||||
const leadingColor = match.leading_side === "a" ? teamA?.color : match.leading_side === "b" ? teamB?.color : undefined
|
const leadingColor = match.leading_side === "a" ? teamA?.color : match.leading_side === "b" ? teamB?.color : undefined
|
||||||
|
|
||||||
async function toggleScorecard() {
|
async function fetchScorecard() {
|
||||||
const next = !showScorecard
|
|
||||||
setShowScorecard(next)
|
|
||||||
if (next && scorecard === null) {
|
|
||||||
setLoadingScorecard(true)
|
setLoadingScorecard(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|
@ -366,8 +414,23 @@ function MatchRow({
|
||||||
setLoadingScorecard(false)
|
setLoadingScorecard(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleScorecard() {
|
||||||
|
const next = !showScorecard
|
||||||
|
setShowScorecard(next)
|
||||||
|
if (next) void fetchScorecard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFirstRefresh = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstRefresh.current) {
|
||||||
|
isFirstRefresh.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (showScorecard) void fetchScorecard()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [refreshKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="overflow-hidden rounded-xl border border-border"
|
className="overflow-hidden rounded-xl border border-border"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue