"use client" import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { ChevronDown, Clock, Flag, Pause, Play, Trophy } from "lucide-react" import { cn } from "@/lib/utils" // --- Types (exact contract — do not change) -------------------------------- export type RoundCell = { roundNumber: number // 1, 2, 3, or 4 label: string // "70", "38 p" (Stableford) — or "" if not yet played tone: "under" | "even" | "over" | null // null = not yet played, render empty/dashed } export type LeaderboardRow = { position: string // "1", "T2", "T5" — already formatted, ties included playerName: string todayLabel: string | null // "-2", "+4", "E", "38 p" — null = today's round not started yet todayIsUnderPar: boolean | null thruLabel: string // "F" (finished), "14" (holes played), "-" (not started) totalLabel: string | null // "-13", "+7", "146 p" — null = hasn't played a single hole yet (neutral, not "E") totalIsUnderPar: boolean isLeader: boolean // true for every row sharing the current lead position (ties possible) // When totalLabel is null, the row is rendered neutrally and shows this // instead of a score: the tee time / start hole of the round they're // about to play, if the organizer set one. Either can be null/undefined. nextTeeTime?: string | null // ISO datetime, or null if no tee time was set nextStartHole?: number | null rounds: RoundCell[] // one entry per round played so far (1-4 entries, not always 4) } // --- Mock data ------------------------------------------------------------- // Par-72, 4-round event, currently 2 rounds in (round 2 live) so only R1 + R2 // columns show. Mix of finished / in-progress / not-started, a genuine tie for // the lead (two T1), and a withdrawal (WD). function rc(roundNumber: number, score: number): RoundCell { return { roundNumber, label: String(score), tone: score < 72 ? "under" : score > 72 ? "over" : "even", } } export const MOCK_ROWS: LeaderboardRow[] = [ { position: "T1", playerName: "Ingrid Berg", todayLabel: "-5", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-8", totalIsUnderPar: true, isLeader: true, rounds: [rc(1, 69), rc(2, 67)] }, { position: "T1", playerName: "Sofie Dahl", todayLabel: "-2", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-8", totalIsUnderPar: true, isLeader: true, rounds: [rc(1, 66), rc(2, 70)] }, { position: "3", playerName: "Anna Ruud", todayLabel: "-5", todayIsUnderPar: true, thruLabel: "14", totalLabel: "-7", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 70)] }, { position: "4", playerName: "Kari Holt", todayLabel: "-4", todayIsUnderPar: true, thruLabel: "12", totalLabel: "-5", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 71)] }, { position: "T5", playerName: "Live Solberg", todayLabel: "-3", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-3", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 74), rc(2, 67)] }, { position: "T5", playerName: "Mette Lie", todayLabel: "+1", todayIsUnderPar: false, thruLabel: "16", totalLabel: "-3", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 68)] }, { position: "7", playerName: "Bjørg Sund", todayLabel: "-1", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-2", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 71), rc(2, 71)] }, { position: "8", playerName: "Nora Eide", todayLabel: "E", todayIsUnderPar: false, thruLabel: "9", totalLabel: "-1", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 72)] }, { position: "9", playerName: "Hanna Vik", todayLabel: "+2", todayIsUnderPar: false, thruLabel: "11", totalLabel: "E", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 72)] }, { position: "10", playerName: "Randi Aas", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "+2", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 74)] }, { position: "T11", playerName: "Tuva Moen", todayLabel: "+3", todayIsUnderPar: false, thruLabel: "6", totalLabel: "+4", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 73)] }, { position: "T11", playerName: "Elin Haug", todayLabel: "+1", todayIsUnderPar: false, thruLabel: "F", totalLabel: "+4", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 75), rc(2, 73)] }, { position: "13", playerName: "Silje Rø", todayLabel: "+5", todayIsUnderPar: false, thruLabel: "8", totalLabel: "+7", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 74)] }, { position: "14", playerName: "Maja Lund", todayLabel: "+2", todayIsUnderPar: false, thruLabel: "F", totalLabel: "+9", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 79), rc(2, 74)] }, { position: "15", playerName: "Frida Ness", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "+11", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 83)] }, { position: "–", playerName: "Kaia Strand", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "WD", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 76)] }, { position: "-", playerName: "Julie Berget", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: "2026-08-19T11:10:00", nextStartHole: 1, rounds: [] }, { position: "-", playerName: "Selma Vang", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: null, nextStartHole: 10, rounds: [] }, { position: "16", playerName: "Thea Foss", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "CUT", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 81)] }, { position: "17", playerName: "Vilde Skog", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "CUT", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 84)] }, ] // --- Tone helpers ---------------------------------------------------------- type Tone = "under" | "even" | "over" | "none" // DSQ/RTD/DNF lagt til 2026-08-18 -- manuell deltakerstatus (migrasjon 087, // individual_tournaments.py), samme mekanisme som WD/DNS/DQ/NR/CUT allerede // hadde (dempet rad, egen badge i stedet for et score-tall). const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT", "DSQ", "RTD", "DNF"]) function isStatus(label: string) { return STATUS.has(label.trim().toUpperCase()) } // TODAY / TOTAL only carry an isUnderPar boolean, so derive even-vs-over from // the label sign — meaning must never rest on color alone. function toneFrom(isUnder: boolean | null, label: string | null): Tone { if (isUnder == null || label == null) return "none" const v = label.trim() if (v === "") return "none" if (isUnder) return "under" if (v.toUpperCase() === "E") return "even" if (v.startsWith("+")) return "over" return "even" // stableford-style non-negative that isn't flagged under } function roundTone(cell: RoundCell | undefined): Tone { if (!cell || !cell.label || cell.tone == null) return "none" return cell.tone } function usePrefersReducedMotion() { const [reduced, setReduced] = useState(false) useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)") setReduced(mq.matches) const on = () => setReduced(mq.matches) mq.addEventListener("change", on) return () => mq.removeEventListener("change", on) }, []) return reduced } // --- Column geometry (shared so header / leaders / body align) ------------- // POS + PLAYER stay frozen on the left while round columns scroll sideways on // narrow screens; the sticky-left offset of PLAYER must equal the POS width. const COL_POS = "w-14 sm:w-16 xl:w-28" const PLAYER_LEFT = "left-14 sm:left-16 xl:left-28" const cellPad = "px-3 py-4 sm:px-4 xl:px-6 xl:py-6" // --- Component ------------------------------------------------------------- export function StrokePlayLeaderboard({ rows = MOCK_ROWS, caption = "Resultattavle", expandedId = null, onToggleExpand, renderExpanded, }: { rows?: LeaderboardRow[] caption?: string // Hull-for-hull-utvidelse (2026-08-22, ADR-101) -- IKKE en del av // LeaderboardRow-kontrakten over ("eksakt kontrakt -- ikke endre"), et // helt separat, valgfritt prop-sett på selve komponenten. Forelderen // (LeaderboardTab) eier state + henting, akkurat som // TournamentScoringWizard/ScoreTab -- denne komponenten er ren // presentasjon. `playerName` er nøkkelen (samme identitet komponenten // allerede bruker som React-key), siden LeaderboardRow ikke bærer noen // egen id. Kun FELT-rader (ikke ledere) er utvidbare -- en utvidet // ledertrad ville krevd at leaderTops-beregningen over tok høyde for // utvidelsens ekstra høyde i den STICKY posisjoneringen, en unødvendig // risiko når lederen(e) uansett allerede er fremhevet og synlig øverst. expandedId?: string | null onToggleExpand?: (row: LeaderboardRow) => void renderExpanded?: (row: LeaderboardRow) => React.ReactNode }) { const reducedMotion = usePrefersReducedMotion() const [playing, setPlaying] = useState(true) const [canScroll, setCanScroll] = useState(false) const leaders = useMemo(() => rows.filter((r) => r.isLeader), [rows]) const field = useMemo(() => rows.filter((r) => !r.isLeader), [rows]) const roundCount = useMemo( () => rows.reduce((m, r) => Math.max(m, ...r.rounds.map((c) => c.roundNumber), 0), 0), [rows], ) const roundNumbers = useMemo(() => Array.from({ length: roundCount }, (_, i) => i + 1), [roundCount]) const totalCols = 5 + roundCount // Measure header height + each leader row height so tied leaders stack // sequentially beneath the header instead of piling on the same offset. const theadRef = useRef(null) const leaderRefs = useRef<(HTMLTableRowElement | null)[]>([]) const [leaderTops, setLeaderTops] = useState([]) useLayoutEffect(() => { const thead = theadRef.current const recompute = () => { const headH = thead ? thead.getBoundingClientRect().height : 0 const tops: number[] = [] let acc = headH for (let i = 0; i < leaders.length; i++) { tops[i] = acc acc += leaderRefs.current[i]?.getBoundingClientRect().height ?? 0 } setLeaderTops(tops) } recompute() const ro = new ResizeObserver(recompute) if (thead) ro.observe(thead) leaderRefs.current.forEach((el) => el && ro.observe(el)) return () => ro.disconnect() }, [leaders.length]) // Track whether the field actually overflows the viewport region so the // control reflects reality (and so we never silently "disable" the ticker // just because it happens to fit at one size). const scrollRef = useRef(null) const posRef = useRef(0) useLayoutEffect(() => { const el = scrollRef.current if (!el) return const measure = () => setCanScroll(el.scrollHeight - el.clientHeight > 4) measure() const ro = new ResizeObserver(measure) ro.observe(el) return () => ro.disconnect() }, [rows]) // Continuous, smooth vertical autoscroll using a float accumulator (avoids // the integer-rounding stutter of reading scrollTop back each frame). useEffect(() => { if (!playing || reducedMotion || !canScroll) return const el = scrollRef.current if (!el) return let raf = 0 let last = performance.now() posRef.current = el.scrollTop const SPEED = 34 // px / second const PAUSE_AT_ENDS = 900 // ms hold at top before looping let holdUntil = 0 const tick = (now: number) => { const dt = Math.min((now - last) / 1000, 0.05) last = now const max = el.scrollHeight - el.clientHeight if (max > 1 && now >= holdUntil) { let next = posRef.current + SPEED * dt if (next >= max) { next = max el.scrollTop = next posRef.current = 0 holdUntil = now + PAUSE_AT_ENDS // brief hold at the bottom, then jump back to the top to loop window.setTimeout(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0 }, PAUSE_AT_ENDS / 2) } else { posRef.current = next el.scrollTop = next } } raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [playing, reducedMotion, canScroll]) // Any manual gesture pauses instantly (programmatic scrollTop writes above // don't fire these events, so they won't self-pause). useEffect(() => { const el = scrollRef.current if (!el) return const pause = () => setPlaying(false) const opts: AddEventListenerOptions = { passive: true } el.addEventListener("wheel", pause, opts) el.addEventListener("touchstart", pause, opts) el.addEventListener("pointerdown", pause) el.addEventListener("keydown", pause) return () => { el.removeEventListener("wheel", pause, opts) el.removeEventListener("touchstart", pause, opts) el.removeEventListener("pointerdown", pause) el.removeEventListener("keydown", pause) } }, []) const toggle = useCallback(() => setPlaying((p) => !p), []) const scrolling = playing && !reducedMotion && canScroll return (
{/* Masthead — the public "face" of the tournament */}

{caption}

{/* Single scroll region drives both the horizontal frozen-column scroll (narrow screens) and the vertical kiosk ticker. `relative` gives the sr-only spans a clipped containing block so they can't widen the page. */}
{roundNumbers.map((n) => ( ))} {roundNumbers.map((n) => ( ))} {/* Pinned leader row(s) — always visible beneath the header, never inside the ticker, so the winner reads from across the room. */} {leaders.map((row, i) => ( { leaderRefs.current[i] = el }} /> ))} {field.length === 0 && ( )} {/* Cut-linje (2026-08-18): "CUT" er en egen, eksplisitt status (skilt fra WD/DQ/DNS/NR, som fortsatt bare vises inline uten divider) -- backend sorterer ALLTID kuttede spillere etter alle fortsatt-med, så "første CUT-rad" er en entydig, stabil grense å sette streken foran. */} {(() => { const firstCutIndex = field.findIndex((row) => row.totalLabel === "CUT") return field.map((row, i) => ( {i === firstCutIndex && } onToggleExpand(row) : undefined} renderExpanded={renderExpanded} totalCols={totalCols} /> )) })()}
POS (posisjon) SPILLER I DAG (dagens runde) HULL (hull spilt) TOTALT R{n} (runde {n})
Ingen spillere i feltet ennå.
) } // --- Legend ---------------------------------------------------------------- function Legend() { return (
  • Under par
  • Over par
  • Par
) } // --- Header cell ----------------------------------------------------------- function Th({ children, className, sticky, }: { children: React.ReactNode className?: string sticky?: "pos" | "player" }) { return ( {children} ) } // --- Leader row (pinned, gold) --------------------------------------------- function LeaderRow({ row, roundNumbers, top, isLast, rowRef, }: { row: LeaderboardRow roundNumbers: number[] top: number isLast: boolean rowRef?: (el: HTMLTableRowElement | null) => void }) { const style = { top } const bg = "bg-gold text-gold-foreground" const border = isLast ? "border-b-4 border-tee-strong/40" : "border-b border-gold-foreground/20" return (
{row.playerName} {/* TODAY + rounds keep the shape but wear a neutral gold-foreground so they stay legible on gold in both themes. */} {/* TOTAL is the marquee: a solid, colored chip that pops even on gold. */} {roundNumbers.map((n) => { const cell = row.rounds.find((c) => c.roundNumber === n) return })} ) } // --- Field row ------------------------------------------------------------- function FieldRow({ row, roundNumbers, striped, expanded, onToggleExpand, renderExpanded, totalCols, }: { row: LeaderboardRow roundNumbers: number[] striped: boolean expanded: boolean onToggleExpand?: () => void renderExpanded?: (row: LeaderboardRow) => React.ReactNode totalCols: number }) { const status = row.totalLabel != null && isStatus(row.totalLabel) // Hasn't played a single hole yet anywhere in the tournament (backend // sends totalLabel: null for this, never "E" — "E" is a real even-par // score). Rendered neutrally, same muted treatment as a WD/DQ row, with // the upcoming tee time / start hole shown in place of a score if the // organizer set one. const notStarted = !status && row.totalLabel == null const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card" const border = "border-b border-clubhouse-border" const canExpand = Boolean(onToggleExpand && renderExpanded) const panelId = `stroke-play-holes-${row.playerName}` return ( <> { if (e.key === "Enter" || e.key === " ") { e.preventDefault() onToggleExpand?.() } }, tabIndex: 0, role: "button", "aria-expanded": expanded, "aria-controls": panelId, } : {})} > {row.position} {row.playerName} {canExpand && ( {status ? ( <> {row.totalLabel} {roundNumbers.map((n) => { const cell = row.rounds.find((c) => c.roundNumber === n) return })} ) : ( <> {notStarted ? ( ) : ( )} {roundNumbers.map((n) => { const cell = row.rounds.find((c) => c.roundNumber === n) return })} )} {canExpand && expanded && ( {renderExpanded?.(row)} )} ) } // --- Score chip (the shared golf visual language) -------------------------- // under = circle (rounded-full), over = rounded-square, even = plain text (no // frame ever), none = dash. `solid` = filled emphasis; `onGold` = neutral // outline treatment for the gold leader band. Shape carries meaning without // color, so it survives for color-blind viewers and at a distance. function ScoreChip({ label, tone, size, solid, onGold, }: { label: string tone: Tone size: "today" | "total" solid?: boolean onGold?: boolean }) { const dims = size === "total" ? "min-w-[3.25rem] px-2.5 py-1 text-lg font-black xl:min-w-[5.5rem] xl:px-4 xl:py-2 xl:text-4xl" : "min-w-[2.75rem] px-2 py-1 text-sm font-bold xl:min-w-[4rem] xl:px-3 xl:text-2xl" if (tone === "even") { return ( {label} ) } const shape = tone === "over" ? "rounded-md" : "rounded-full" let skin: string if (onGold) { skin = "border border-gold-foreground/45 bg-gold-foreground/5 text-gold-foreground" } else if (solid) { skin = tone === "over" ? "bg-cup-strong text-clubhouse-bg" : "bg-tee-strong text-clubhouse-bg" } else { skin = tone === "over" ? "bg-cup-strong/12 text-cup-strong ring-1 ring-inset ring-cup-strong/35" : "bg-tee-strong/12 text-tee-strong ring-1 ring-inset ring-tee-strong/35" } return ( {label} {tone === "under" ? " under par" : " over par"} ) } // --- Value cell (TODAY / TOTAL) -------------------------------------------- function ValueTd({ label, tone, size, bg, border, style, solid, onGold, }: { label: string | null tone: Tone size: "today" | "total" bg: string border: string style?: React.CSSProperties solid?: boolean onGold?: boolean }) { return ( {label == null || tone === "none" ? ( ) : ( )} ) } // --- THRU cell ------------------------------------------------------------- function ThruTd({ thru, bg, border, style, onGold, }: { thru: string bg: string border: string style?: React.CSSProperties onGold?: boolean }) { const finished = thru === "F" const notStarted = thru === "-" const ink = onGold ? "text-gold-foreground" : "text-clubhouse-ink" const muted = onGold ? "text-gold-foreground/70" : "text-clubhouse-muted" return ( {notStarted ? ( ) : finished ? ( F (ferdig) ) : ( {thru} hull spilt )} ) } // --- Round cell (Rn) ------------------------------------------------------- function RoundTd({ cell, bg, border, style, onGold, }: { cell?: RoundCell bg: string border: string style?: React.CSSProperties onGold?: boolean }) { const tone = roundTone(cell) return ( {tone === "none" ? ( ) : ( )} ) } // --- Cut-linje --------------------------------------------------------- // Egen seksjon-skillelinje mellom fortsatt-med-spillere og kuttede // spillere (2026-08-18) -- bruker bekreftet eksplisitt at kuttede // spillere skal "vises i egen 'Cut'-seksjon", ikke bare en dempet rad // blant resten. Samme cellePadding/border-språk som resten av tabellen, // ingen ny farge oppfunnet. function CutLineRow({ totalCols }: { totalCols: number }) { return (
) } // --- Not-applicable dash cell (status rows) -------------------------------- function DashTd({ bg, border }: { bg: string; border: string }) { return ( ) } // --- Tee-info cell (not-started-yet rows) ----------------------------------- // Replaces the TOTAL cell for a player who hasn't played a single hole yet: // their upcoming tee time / start hole instead of a score, so the row reads // as "not started" rather than as a real (if unlucky) result. function formatTeeTime(iso: string) { const date = new Date(iso) if (Number.isNaN(date.getTime())) return null return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) } function TeeInfoTd({ teeTime, startHole, bg, border, }: { teeTime: string | null startHole: number | null bg: string border: string }) { const time = teeTime ? formatTeeTime(teeTime) : null const parts = [time, startHole != null ? `Hull ${startHole}` : null].filter((v): v is string => v != null) return ( {parts.length > 0 ? ( ) : ( )} ) }