"use client" import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { Flag, Pause, Play, Trophy } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" // --- Types ----------------------------------------------------------------- export type RoundCell = { roundNumber: number // 1, 2, 3, or 4 label: string // "70", "38 p" (Stableford) — or "" if not yet played // "even" is its own state (not folded into "over") -- matches this app's // established Golfscore-språket (DESIGN_SYSTEM.md): E gets plain neutral // text, no circle/square frame, never the over-par treatment. tone: "under" | "even" | "over" | null // null = not yet played, render as an empty/dashed cell } 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 // "-13", "+7", "146 p" totalIsUnderPar: boolean isLeader: boolean // true for every row sharing the current lead position (ties possible) rounds: RoundCell[] // one entry per round played SO FAR (1-4 entries, not always 4) } // --- Mock seed data -------------------------------------------------------- // A par-72, 4-round event that is currently 2 rounds in (round 2 live), so the // table only shows R1 + R2 columns. Mix of finished / mid-round / not-started, // with a tie for the lead (two T1 rows). const t = (n: string): RoundCell["label"] => n function completed(roundNumber: number, label: string, tone: "under" | "even" | "over"): RoundCell { return { roundNumber, label, tone } } export const MOCK_ROWS: LeaderboardRow[] = [ { position: "T1", playerName: "Ingrid Berg", todayLabel: "-5", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-8", totalIsUnderPar: true, isLeader: true, rounds: [completed(1, t("69"), "under"), completed(2, t("67"), "under")], }, { position: "T1", playerName: "Sofie Dahl", todayLabel: "-2", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-8", totalIsUnderPar: true, isLeader: true, rounds: [completed(1, t("66"), "under"), completed(2, t("70"), "under")], }, { position: "3", playerName: "Anna Ruud", todayLabel: "-5", todayIsUnderPar: true, thruLabel: "14", totalLabel: "-7", totalIsUnderPar: true, isLeader: false, // Round 2 in progress -> only R1 completed. rounds: [completed(1, t("70"), "under")], }, { position: "4", playerName: "Kari Holt", todayLabel: "-4", todayIsUnderPar: true, thruLabel: "12", totalLabel: "-5", totalIsUnderPar: true, isLeader: false, rounds: [completed(1, t("71"), "under")], }, { position: "5", playerName: "Mette Lie", todayLabel: "+1", todayIsUnderPar: false, thruLabel: "16", totalLabel: "-3", totalIsUnderPar: true, isLeader: false, rounds: [completed(1, t("68"), "under")], }, { position: "6", playerName: "Bjørg Sund", todayLabel: "-1", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-2", totalIsUnderPar: true, isLeader: false, rounds: [completed(1, t("71"), "under"), completed(2, t("72"), "even")], }, { position: "7", playerName: "Live Aas", todayLabel: "E", todayIsUnderPar: null, thruLabel: "9", totalLabel: "+1", totalIsUnderPar: false, isLeader: false, rounds: [completed(1, t("73"), "over")], }, { position: "8", playerName: "Randi Vik", todayLabel: null, // round 2 not started yet todayIsUnderPar: null, thruLabel: "-", totalLabel: "+2", totalIsUnderPar: false, isLeader: false, rounds: [completed(1, t("74"), "over")], }, { position: "9", playerName: "Tuva Moen", todayLabel: "+3", todayIsUnderPar: false, thruLabel: "6", totalLabel: "+6", totalIsUnderPar: false, isLeader: false, rounds: [completed(1, t("75"), "over")], }, ] // --- Helpers --------------------------------------------------------------- type Tone = "under" | "over" | "even" | "none" // Determine tone from a signed label so meaning never relies on color alone. function toneFromLabel(label: string | null): Tone { if (label == null) return "none" const v = label.trim() if (v === "") return "none" if (v.startsWith("-")) return "under" if (v.startsWith("+")) return "over" return "even" // "E", "38 p", "146 p" } const toneText: Record = { under: "text-primary", over: "text-brand-orange", even: "text-foreground", none: "text-muted-foreground", } function usePrefersReducedMotion() { const [reduced, setReduced] = useState(false) useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)") setReduced(mq.matches) const onChange = () => setReduced(mq.matches) mq.addEventListener("change", onChange) return () => mq.removeEventListener("change", onChange) }, []) return reduced } // --- Column geometry (shared widths so header/leaders/body align) ---------- const COL = { pos: "w-12 lg:w-24", player: "min-w-[8.5rem] lg:min-w-[16rem]", today: "w-16 lg:w-28", thru: "w-14 lg:w-24", total: "w-20 lg:w-36", round: "w-14 lg:w-24", } // Sticky-left offset for the PLAYER column must equal the POS column width. const PLAYER_LEFT = "left-12 lg:left-24" const cellPad = "px-2 py-3 lg:px-5 lg:py-5" // --- Component ------------------------------------------------------------- export function StrokePlayLeaderboard({ rows = MOCK_ROWS, caption = "Resultattavle", }: { rows?: LeaderboardRow[] caption?: string }) { const reducedMotion = usePrefersReducedMotion() const [playing, setPlaying] = 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((max, r) => Math.max(max, ...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 the header height so pinned leader rows stick right beneath it. const theadRef = useRef(null) const [headTop, setHeadTop] = useState(0) useLayoutEffect(() => { const el = theadRef.current if (!el) return const update = () => setHeadTop(el.getBoundingClientRect().height) update() const ro = new ResizeObserver(update) ro.observe(el) return () => ro.disconnect() }, []) // Auto-scroll ticker over the non-leader field. const scrollRef = useRef(null) useEffect(() => { if (!playing || reducedMotion) return const el = scrollRef.current if (!el) return let raf = 0 let last = performance.now() const speed = 26 // px per second const tick = (now: number) => { const dt = Math.min((now - last) / 1000, 0.05) last = now const max = el.scrollHeight - el.clientHeight if (max > 1) { let next = el.scrollTop + speed * dt if (next >= max) next = 0 // loop back to top of field el.scrollTop = next } raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) }, [playing, reducedMotion]) // Any manual gesture on the list pauses the ticker (does not react to the // programmatic scrollTop writes above). 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) } }, []) return (
{/* Control bar (part of the component, not page chrome) */}
{!reducedMotion && ( )}
{/* One scroll container drives both frozen-column horizontal scroll (narrow screens) and the vertical ticker. RETTET 2026-08-18 (brukerrapport: "autoscroller ikke") -- `lg:max-h-none lg:overflow-visible` fjernet taket HELT på store skjermer, ut fra en antakelse om at hele feltet alltid ville få plass der. For et stort felt (mange spillere) stemte ikke det -- containeren fikk aldri noe å skrolle PÅ, så "Rull automatisk" vekslet kun knappe-tilstanden uten synlig bevegelse. Nå: et vindu-relativt tak på ALLE bredder (aldri `overflow-visible`) -- et lite felt får uansett plass innenfor taket (ingen skrolling da heller, samme sluttresultat som før), et stort felt får en ekte skrollbar flate autoscroll faktisk kan bevege. */}
{roundNumbers.map((n) => ( ))} {roundNumbers.map((n) => ( ))} {/* Pinned leader row(s) — stick beneath the header, never in the ticker. */} {leaders.map((row, i) => ( ))} {field.length === 0 && ( )} {field.map((row, i) => ( ))}
POS (posisjon) PLAYER (spiller) TODAY (i dag) THRU (hull spilt) TOTAL (totalt mot par) 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) --------------------------------------------------- function LeaderRow({ row, roundNumbers, top, isLast, }: { row: LeaderboardRow roundNumbers: number[] top: number isLast: boolean }) { const stickyStyle = { top } const rowBg = "bg-gold text-gold-foreground" const border = isLast ? "border-b-2 border-gold-foreground/30" : "border-b border-gold-foreground/15" return ( {/* POS + trophy (sticky both top and left, above field + own row) */}
{/* PLAYER */} {roundNumbers.map((n) => { const cell = row.rounds.find((c) => c.roundNumber === n) return })} ) } // --- Field row ------------------------------------------------------------- function FieldRow({ row, roundNumbers, striped, }: { row: LeaderboardRow roundNumbers: number[] striped: boolean }) { const rowBg = striped ? "bg-muted/40" : "bg-card" const border = "border-b border-border" return ( {row.position} {row.playerName} {roundNumbers.map((n) => { const cell = row.rounds.find((c) => c.roundNumber === n) return })} ) } // --- Value cell (TODAY / TOTAL) -------------------------------------------- function ValueTd({ label, emphasis, rowBg, border, style, leader, }: { label: string | null emphasis: "today" | "total" rowBg: string border: string style?: React.CSSProperties leader?: boolean }) { const tone = toneFromLabel(label) const isTotal = emphasis === "total" // On the gold leader band, keep values readable on gold rather than tinting // them green/orange (which fails contrast on gold); the sign still encodes tone. const color = leader ? "text-gold-foreground" : toneText[tone] const size = isTotal ? "text-base font-extrabold lg:text-4xl" : "text-sm font-bold lg:text-2xl" return ( {label == null ? ( ) : ( {label} )} ) } // --- THRU cell ------------------------------------------------------------- function ThruTd({ thru, rowBg, border, style, leader, }: { thru: string rowBg: string border: string style?: React.CSSProperties leader?: boolean }) { const finished = thru === "F" const notStarted = thru === "-" const color = leader ? "text-gold-foreground" : "text-foreground" return ( {notStarted ? ( ) : finished ? ( F (ferdig) ) : ( {thru} hull spilt )} ) } // --- Round cell (Rn) ------------------------------------------------------- // Shape encodes tone in addition to color: under-par = round pill, over-par = // square pill — so meaning survives without color perception. function RoundTd({ cell, rowBg, border, style, leader, }: { cell?: RoundCell rowBg: string border: string style?: React.CSSProperties leader?: boolean }) { const played = cell && cell.label !== "" && cell.tone !== null return ( {!played ? ( ) : cell!.tone === "even" ? ( // "E" er alltid ren tekst, ingen ramme -- samme regel som resten av // appens Golfscore-språk (DESIGN_SYSTEM.md), aldri over-par-behandling. {cell!.label} par ) : ( {cell!.label} {cell!.tone === "under" ? " under par" : " over par"} )} ) }