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:
Erol Haagenrud 2026-07-19 23:14:17 +02:00
parent 254cb92c7a
commit 09aaa38bde
8 changed files with 248 additions and 44 deletions

View file

@ -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",

View file

@ -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:

View file

@ -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
View file

@ -0,0 +1,47 @@
"""
Delt in-memory WebSocket-kringkasting for sanntidsoppdateringer 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 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 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)

View file

@ -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 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)

View file

@ -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 selv ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller 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

View file

@ -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))

View file

@ -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,34 +290,50 @@ 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 setLoadingMatches(true)
setOpen(next) try {
if (next && matches === null) { const res = await fetch(
setLoadingMatches(true) `/public/tournaments/${tournamentId}/sessions/${session.id}/matches${codeParam}`,
try { { credentials: "include" },
const res = await fetch( )
`/public/tournaments/${tournamentId}/sessions/${session.id}/matches${codeParam}`, if (res.ok) setMatches(await res.json())
{ credentials: "include" }, } finally {
) setLoadingMatches(false)
if (res.ok) setMatches(await res.json())
} finally {
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,35 +388,49 @@ 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 setLoadingScorecard(true)
setShowScorecard(next) try {
if (next && scorecard === null) { const res = await fetch(
setLoadingScorecard(true) `/public/tournaments/${tournamentId}/matches/${match.id}/scorecard${codeParam}`,
try { { credentials: "include" },
const res = await fetch( )
`/public/tournaments/${tournamentId}/matches/${match.id}/scorecard${codeParam}`, if (res.ok) setScorecard(await res.json())
{ credentials: "include" }, } finally {
) setLoadingScorecard(false)
if (res.ok) setScorecard(await res.json())
} finally {
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"