teecup/frontend/components/public-individual-live.tsx
Erol Haagenrud 9fad4b3f38 Offentlig "Følg live" for individuelle turneringer: V0-UI + full kobling
Fullfører ADR-098. Ny components/public-individual-live.tsx (V0-bygget,
presentasjonslaget uendret fra eksporten) koblet til de ekte endepunktene
fra forrige commit. Rettet en reell modellmismatch underveis: V0-mock-en
modellerte hver runde som sitt eget separate leaderboard, den ekte
backend-beregningen er ett samlet, kumulativt turnering-leaderboard --
rundevelgeren styrer nå kun hvilken rundes hull-for-hull som lastes.

Ny offentlig GET .../rounds/{round_id}/participants/{tournament_participant_id}
/holes for hull-for-hull-utvidelsen (egen rute, ikke gjenbruk av
frittstående-runders tilsvarende -- ulik datamodell).

app/t/[id]/live/page.tsx avgjør nå format SERVER-side før noe rendres,
unngår klient-side-feilklassen fra forrige commit helt for denne siden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 19:50:35 +02:00

653 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Offentlig "Følg live" for INDIVIDUELLE turneringer (2026-08-21) --
// motstykket til public-live.tsx (Cup-format). Se CHANGELOG.md samme dag
// for hele bakgrunnen (bevisst utsatt siden 2026-08-14, FEATURE_BACKLOG.md
// "Flaggturnering Del B", nå bygget etter grundig todelt analyse).
//
// Visuelt: Claude-skrevet V0-eksport (v0-prompt-public-individual-live.md),
// presentasjonskomponentene under er UENDRET fra eksporten. Denne filen
// bytter ut mock-datalaget med ekte fetching + sanntid.
//
// VIKTIG forenkling fra mock til ekte data: mock-en modellerte hver runde
// som sitt eget, helt separate leaderboard. Den ekte backend-modellen
// (individual_tournaments.py _compute_individual_standings) beregner ETT
// SAMLET, kumulativt turnering-leaderboard -- "i dag"/"thru" gjelder
// alltid gjeldende/siste påbegynte runde, "total" er summen på tvers av
// ALLE spilte runder. Rundevelgeren endrer derfor IKKE selve
// leaderboard-radene (position/thru/i dag/total er alltid de samme,
// uansett hvilken rundefane som er valgt) -- den styrer KUN hvilken
// rundes hull-for-hull-rutenett som lastes når en rad utvides.
import { useEffect, useMemo, useRef, useState } from "react"
import { ArrowLeft, Radio, Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Wordmark } from "@/components/wordmark"
/* ------------------------------------------------------------------ *
* API-typer (matcher app/routers/registration.py sine nye endepunkt) *
* ------------------------------------------------------------------ */
type RoundStatus = "not_started" | "in_progress" | "completed"
type ApiRound = {
id: string
sequence: number
name: string
course_name: string
scheduled_at: string | null
status: RoundStatus
}
type Tone = "under" | "even" | "over" | null
type ApiRoundCell = { round_number: number; label: string; tone: Tone }
type PlayerStatus = "active" | "dsq" | "rtd" | "dnf" | "dns"
type ApiLeaderboardEntry = {
tournament_participant_id: string
player_name: string
class_id: string | null
class_name: string | null
position: string | null
is_leader: boolean
today_label: string | null
thru_label: string | null
total_label: string | null
rank: number | null
cut: boolean
status: PlayerStatus
handicap_index: number | null
rounds: ApiRoundCell[]
}
type ApiHole = { hole_number: number; par: number; gross_strokes: number | null }
type ApiTournamentInfo = { name: string; format_type: string; status: string }
/* ------------------------------------------------------------------ *
* Visningstyper for hull-rutenettet (per runde, hentes lat ved utvidelse)
* ------------------------------------------------------------------ */
type HoleResult = { holeNumber: number; par: number; toParTone: Tone; label: string }
function toneOf(strokesOverPar: number): Tone {
return strokesOverPar < 0 ? "under" : strokesOverPar > 0 ? "over" : "even"
}
function holesFromApi(holes: ApiHole[]): HoleResult[] {
return holes.map((h) => {
if (h.gross_strokes == null) return { holeNumber: h.hole_number, par: h.par, toParTone: null, label: "" }
return {
holeNumber: h.hole_number,
par: h.par,
toParTone: toneOf(h.gross_strokes - h.par),
label: String(h.gross_strokes),
}
})
}
/* ------------------------------------------------------------------ *
* Delt golfscore-visuelt språk -- UENDRET fra V0-eksporten. *
* under = sirkel · over = avrundet firkant · even = ren tekst. *
* emphasis = fylt (leder); ellers tint + kant. *
* ------------------------------------------------------------------ */
function ScoreShape({
label,
tone,
emphasis = false,
size = "md",
}: {
label: string | null
tone: Tone
emphasis?: boolean
size?: "sm" | "md"
}) {
const dims = size === "sm" ? "min-h-7 min-w-7 px-1 text-xs" : "min-h-10 min-w-10 px-2 text-sm"
const base = cn("inline-flex items-center justify-center font-bold tabular-nums", dims)
if (label == null) {
return (
<span className={cn(base, "text-muted-foreground")} aria-hidden="true">
</span>
)
}
if (tone === "even") {
return (
<span className={cn(base, "text-foreground")}>
{label}
<span className="sr-only"> likt med par</span>
</span>
)
}
if (tone == null) {
return <span className={cn(base, "text-muted-foreground")}>{label}</span>
}
const under = tone === "under"
const shape = under ? "rounded-full" : "rounded-[5px]"
const skin = emphasis
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary/35 bg-primary/10 text-primary"
: "border-2 border-brand-orange/35 bg-brand-orange/10 text-brand-orange"
return (
<span className={cn(base, shape, skin)}>
{label}
<span className="sr-only">{under ? " under par" : " over par"}</span>
</span>
)
}
function RankBadge({ position, isLeader }: { position: string | null; isLeader: boolean }) {
if (!position) {
return (
<span className="inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg bg-muted px-1.5 text-sm font-bold text-muted-foreground">
</span>
)
}
return (
<span
className={cn(
"inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg px-1.5 text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
)}
>
{position}
</span>
)
}
function StatusPill({ label }: { label: string }) {
return (
<span className="inline-flex min-h-8 items-center justify-center rounded-md border border-dashed border-border bg-muted/40 px-2.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
)
}
function RoundStatusBadge({ status }: { status: RoundStatus }) {
if (status === "in_progress") {
return (
<span className="inline-flex items-center gap-1.5 self-start rounded-full bg-primary px-2.5 py-1 text-xs font-bold text-primary-foreground">
<Radio aria-hidden="true" className="size-3.5 animate-pulse" />
Pågår
</span>
)
}
if (status === "completed") {
return (
<span className="inline-flex items-center gap-1.5 self-start text-xs font-semibold text-muted-foreground">
<Check aria-hidden="true" className="size-3.5" />
Fullført
</span>
)
}
return <span className="self-start text-xs font-semibold text-muted-foreground">Ikke startet</span>
}
const STATUS_LABEL: Record<Exclude<PlayerStatus, "active">, string> = {
dsq: "DSQ",
rtd: "RTD",
dnf: "DNF",
dns: "DNS",
}
/* ------------------------------------------------------------------ *
* Utvidbart hull-for-hull-rutenett *
* ------------------------------------------------------------------ */
function HoleNine({ holes, label }: { holes: HoleResult[]; label: string }) {
const parSum = holes.reduce((s, h) => s + h.par, 0)
const strokeSum = holes.reduce((s, h) => s + (h.label ? Number(h.label) : 0), 0)
return (
<div className="overflow-hidden rounded-xl border border-border">
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center bg-muted/60 text-center text-[11px] font-semibold text-muted-foreground">
<div className="py-1">{label}</div>
{holes.map((h) => (
<div key={h.holeNumber} className="py-1">
{h.holeNumber}
</div>
))}
<div className="py-1">Sum</div>
</div>
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center border-t border-border text-center text-[11px] text-muted-foreground">
<div className="py-1 font-semibold">Par</div>
{holes.map((h) => (
<div key={h.holeNumber} className="py-1 tabular-nums">
{h.par}
</div>
))}
<div className="py-1 font-semibold tabular-nums">{parSum}</div>
</div>
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center justify-items-center border-t border-border py-1 text-center">
<div className="text-[11px] font-semibold text-foreground">Deg</div>
{holes.map((h) =>
h.label ? (
<ScoreShape key={h.holeNumber} label={h.label} tone={h.toParTone} size="sm" />
) : (
<span key={h.holeNumber} className="text-xs text-muted-foreground" aria-hidden="true">
</span>
),
)}
<div className="text-xs font-bold tabular-nums text-foreground">{strokeSum || ""}</div>
</div>
</div>
)
}
function HoleGrid({ holes }: { holes: HoleResult[] }) {
if (holes.length === 0) {
return <p className="px-3 pb-3 text-sm text-muted-foreground">Ingen hull registrert for denne runden ennå.</p>
}
return (
<div className="flex flex-col gap-2 px-3 pb-3">
<HoleNine holes={holes.slice(0, 9)} label="Ut" />
{holes.length > 9 && <HoleNine holes={holes.slice(9)} label="Inn" />}
</div>
)
}
/* ------------------------------------------------------------------ *
* Leaderboard-rad -- henter hull-for-hull LAT, for VALGT runde, *
* kun når raden faktisk utvides. *
* ------------------------------------------------------------------ */
function LeaderboardRow({
entry,
organizationlessBase,
roundId,
}: {
entry: ApiLeaderboardEntry
organizationlessBase: string
roundId: string | null
}) {
const [open, setOpen] = useState(false)
const [holes, setHoles] = useState<HoleResult[] | null>(null)
const [loadingHoles, setLoadingHoles] = useState(false)
const panelId = `holes-${entry.tournament_participant_id}`
const isStatus = entry.status !== "active"
// Rundevalg endret seg mens raden var åpen -- kast cachet data, hent på nytt.
useEffect(() => {
setHoles(null)
}, [roundId])
async function toggle() {
const next = !open
setOpen(next)
if (next && roundId && holes === null) {
setLoadingHoles(true)
try {
const res = await fetch(
`${organizationlessBase}/rounds/${roundId}/participants/${entry.tournament_participant_id}/holes`,
{ credentials: "include" },
)
if (res.ok) {
const data: ApiHole[] = await res.json()
setHoles(holesFromApi(data))
}
} finally {
setLoadingHoles(false)
}
}
}
const rowInner = (
<>
<RankBadge position={entry.position} isLeader={entry.is_leader} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[15px] font-bold text-foreground">{entry.player_name}</span>
<span className="truncate text-xs text-muted-foreground">
{entry.handicap_index != null && `HCP ${entry.handicap_index.toFixed(1).replace(".", ",")}`}
{entry.handicap_index != null && entry.class_name ? " · " : ""}
{entry.class_name}
</span>
</span>
<span className="flex flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Thru</span>
<span className="text-sm font-bold tabular-nums text-foreground">{entry.thru_label ?? "-"}</span>
</span>
<span className="flex flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">I dag</span>
{isStatus ? (
<span className="text-sm text-muted-foreground" aria-hidden="true">
</span>
) : (
<ScoreShape
label={entry.today_label}
tone={entry.today_label?.startsWith("-") || entry.today_label?.startsWith("") ? "under" : entry.today_label?.startsWith("+") ? "over" : entry.today_label === "E" ? "even" : null}
/>
)}
</span>
<span className="flex flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Total</span>
{isStatus || entry.cut ? (
<StatusPill label={entry.cut ? "Kuttet" : STATUS_LABEL[entry.status as Exclude<PlayerStatus, "active">]} />
) : (
<ScoreShape
label={entry.total_label}
tone={entry.total_label?.startsWith("-") || entry.total_label?.startsWith("") ? "under" : entry.total_label?.startsWith("+") ? "over" : entry.total_label === "E" ? "even" : null}
emphasis={entry.is_leader}
/>
)}
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-5 shrink-0 text-muted-foreground transition-transform duration-200",
roundId ? (open ? "rotate-180" : "") : "invisible",
)}
/>
</>
)
if (!roundId) {
return <li className="flex items-center gap-2.5 px-3 py-3 sm:px-4">{rowInner}</li>
}
return (
<li>
<button
type="button"
onClick={() => void toggle()}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-center gap-2.5 px-3 py-3 text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-4"
>
{rowInner}
<span className="sr-only">{open ? "Skjul hull for hull" : "Vis hull for hull"}</span>
</button>
<div
id={panelId}
className={cn(
"grid transition-all duration-300 ease-out",
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">
<div className={cn(!open && "invisible")} aria-hidden={!open}>
{loadingHoles ? (
<p className="px-3 pb-3 text-sm text-muted-foreground">Laster hull </p>
) : (
<HoleGrid holes={holes ?? []} />
)}
</div>
</div>
</div>
</li>
)
}
/* ------------------------------------------------------------------ *
* Side *
* ------------------------------------------------------------------ */
function pickDefaultRound(rounds: ApiRound[]): string | null {
const live = rounds.find((r) => r.status === "in_progress")
if (live) return live.id
const completed = [...rounds].reverse().find((r) => r.status === "completed")
if (completed) return completed.id
return rounds[0]?.id ?? null
}
function rankKey(e: ApiLeaderboardEntry): number {
if (e.position) return Number.parseInt(e.position.replace(/\D/g, ""), 10) || 999
return 1000
}
export function PublicIndividualLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
const base = `/public/tournaments/${tournamentId}`
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
const [rounds, setRounds] = useState<ApiRound[]>([])
const [entries, setEntries] = useState<ApiLeaderboardEntry[]>([])
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const [classFilter, setClassFilter] = useState<string>("all")
const [loading, setLoading] = useState(true)
async function loadAll(isFirst: boolean) {
const [tRes, rRes, lRes] = await Promise.all([
isFirst ? fetch(`${base}${codeParam}`, { credentials: "include" }) : Promise.resolve(null),
fetch(`${base}/rounds${codeParam}`, { credentials: "include" }),
fetch(`${base}/individual-leaderboard${codeParam}`, { credentials: "include" }),
])
if (tRes?.ok) {
const data: ApiTournamentInfo = await tRes.json()
setTournament(data)
}
if (rRes.ok) {
const data: ApiRound[] = await rRes.json()
setRounds(data)
if (isFirst) setSelectedRoundId(pickDefaultRound(data))
}
if (lRes.ok) {
const data: ApiLeaderboardEntry[] = await lRes.json()
setEntries(data)
}
}
useEffect(() => {
let cancelled = false
loadAll(true).finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournamentId])
// Sanntid -- SAMME WebSocket-kanal og "noe endret, hent på nytt"-mønster
// som public-live.tsx (Cup-format) allerede bruker, ADR-027.
const isFirstRefresh = useRef(true)
useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(
`${proto}://${window.location.host}/ws/public/tournaments/${tournamentId}/live${codeParam}`,
)
ws.onmessage = () => {
if (isFirstRefresh.current) {
isFirstRefresh.current = false
return
}
void loadAll(false)
}
return () => ws.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournamentId])
const classes = useMemo(
() => Array.from(new Set(entries.map((e) => e.class_name).filter((c): c is string => !!c))),
[entries],
)
const visible = useMemo(() => {
const filtered = classFilter === "all" ? entries : entries.filter((e) => e.class_name === classFilter)
return [...filtered].sort((a, b) => rankKey(a) - rankKey(b))
}, [entries, classFilter])
const advancing = visible.filter((e) => e.status === "active" && !e.cut)
const belowCut = visible.filter((e) => e.cut || e.status !== "active")
const hasCut = belowCut.length > 0
const isFinished = tournament?.status === "completed" || tournament?.status === "archived"
if (loading) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
return (
<div className="min-h-dvh bg-background text-foreground">
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3">
<a
href={`/t/${tournamentId}${codeParam}`}
aria-label="Tilbake til turneringen"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</a>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={cn("size-2 rounded-full", isFinished ? "bg-muted-foreground" : "bg-primary animate-pulse")}
/>
<span className="text-[11px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
{isFinished ? "Avsluttet" : "Følg live"}
</span>
</span>
<h1 className="truncate text-lg font-extrabold leading-tight text-foreground text-balance">
{tournament?.name ?? "Turnering"}
</h1>
</div>
<Wordmark compact />
</div>
</header>
<main className="mx-auto flex max-w-3xl flex-col gap-5 px-4 py-5">
{rounds.length > 0 && (
<nav aria-label="Velg runde" className="-mx-4 overflow-x-auto px-4">
<ul className="flex gap-2.5">
{rounds.map((r) => {
const selected = r.id === selectedRoundId
const date = r.scheduled_at
? new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }).format(
new Date(r.scheduled_at),
)
: null
return (
<li key={r.id}>
<button
type="button"
onClick={() => setSelectedRoundId(r.id)}
aria-pressed={selected}
className={cn(
"flex min-w-[10.5rem] flex-col gap-1.5 rounded-2xl border p-3 text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border bg-card hover:bg-muted/50",
)}
>
<span className="text-sm font-extrabold text-foreground">{r.name}</span>
<span className="text-xs text-muted-foreground">
{r.course_name}
{date ? ` · ${date}` : ""}
</span>
<RoundStatusBadge status={r.status} />
</button>
</li>
)
})}
</ul>
</nav>
)}
<section aria-label="Resultatliste" className="flex flex-col gap-3">
{classes.length > 0 && entries.length > 0 && (
<div
role="group"
aria-label="Filtrer på klasse"
className="flex flex-wrap gap-1.5 rounded-xl border border-border bg-card p-1"
>
{[{ key: "all", label: "Alle" }, ...classes.map((c) => ({ key: c, label: c }))].map((opt) => {
const active = classFilter === opt.key
return (
<button
key={opt.key}
type="button"
onClick={() => setClassFilter(opt.key)}
aria-pressed={active}
className={cn(
"min-h-9 flex-1 rounded-lg px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
>
{opt.label}
</button>
)
})}
</div>
)}
{entries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
<p className="text-sm font-semibold text-foreground">Ingen resultater ennå</p>
<p className="mt-1 text-sm text-muted-foreground text-pretty">
Ingen resultater registrert ennå for denne turneringen.
</p>
</div>
) : visible.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
<p className="text-sm text-muted-foreground">Ingen spillere i denne klassen.</p>
</div>
) : (
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/[0.06]">
{advancing.map((e) => (
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} />
))}
{hasCut && (
<li aria-hidden="true" className="flex items-center gap-3 bg-muted/50 px-4 py-2">
<span className="h-px flex-1 bg-border" />
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Cut · {advancing.length} videre
</span>
<span className="h-px flex-1 bg-border" />
</li>
)}
{belowCut.map((e) => (
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} />
))}
</ol>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="flex size-5 items-center justify-center rounded-full border-2 border-primary/35 bg-primary/10 text-[10px] font-bold text-primary">
</span>
Under par
</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-foreground">E</span>
Par
</span>
<span className="flex items-center gap-1.5">
<span className="flex size-5 items-center justify-center rounded-[4px] border-2 border-brand-orange/35 bg-brand-orange/10 text-[10px] font-bold text-brand-orange">
+
</span>
Over par
</span>
</div>
</section>
</main>
</div>
)
}