teecup/frontend/components/public-individual-live.tsx

654 lines
25 KiB
TypeScript
Raw Normal View History

"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>
)
}