2026-08-21 19:50:35 +02:00
|
|
|
|
"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 nå
|
|
|
|
|
|
</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">
|
2026-08-21 20:08:11 +02:00
|
|
|
|
{/* Navnet får bryte til ny linje i stedet for å avkortes med "..." på
|
|
|
|
|
|
smale skjermer (360px) -- funnet 2026-08-21 under scratch-
|
|
|
|
|
|
verifisering: et reelt navn ("Erol Haagenrud") kuttet ned til
|
|
|
|
|
|
"Erol Haag..." er under lesbarhetsbarren (CLAUDE.md, ufravikelig).
|
|
|
|
|
|
Undertittelen (HCP/klasse, mindre viktig) beholder truncate. */}
|
|
|
|
|
|
<span className="text-pretty text-[15px] font-bold leading-tight text-foreground">{entry.player_name}</span>
|
2026-08-21 19:50:35 +02:00
|
|
|
|
<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) {
|
2026-08-21 20:08:11 +02:00
|
|
|
|
return <li className="flex items-center gap-2 px-2.5 py-3 sm:gap-2.5 sm:px-4">{rowInner}</li>
|
2026-08-21 19:50:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void toggle()}
|
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
|
aria-controls={panelId}
|
2026-08-21 20:08:11 +02:00
|
|
|
|
className="flex w-full items-center gap-2 px-2.5 py-3 text-left sm:gap-2.5 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"
|
2026-08-21 19:50:35 +02:00
|
|
|
|
>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|