"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 ( ) } if (tone === "even") { return ( {label} likt med par ) } if (tone == null) { return {label} } 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 ( {label} {under ? " under par" : " over par"} ) } function RankBadge({ position, isLeader }: { position: string | null; isLeader: boolean }) { if (!position) { return ( ) } return ( {position} ) } function StatusPill({ label }: { label: string }) { return ( {label} ) } function RoundStatusBadge({ status }: { status: RoundStatus }) { if (status === "in_progress") { return ( ) } if (status === "completed") { return ( ) } return Ikke startet } const STATUS_LABEL: Record, 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 (
{label}
{holes.map((h) => (
{h.holeNumber}
))}
Sum
Par
{holes.map((h) => (
{h.par}
))}
{parSum}
Deg
{holes.map((h) => h.label ? ( ) : ( ), )}
{strokeSum || "–"}
) } function HoleGrid({ holes }: { holes: HoleResult[] }) { if (holes.length === 0) { return

Ingen hull registrert for denne runden ennå.

} return (
{holes.length > 9 && }
) } /* ------------------------------------------------------------------ * * 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(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 = ( <> {entry.player_name} {entry.handicap_index != null && `HCP ${entry.handicap_index.toFixed(1).replace(".", ",")}`} {entry.handicap_index != null && entry.class_name ? " · " : ""} {entry.class_name} Thru {entry.thru_label ?? "-"} I dag {isStatus ? ( ) : ( )} Total {isStatus || entry.cut ? ( ]} /> ) : ( )}