2026-08-04 04:47:27 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
2026-08-18 15:07:21 +02:00
|
|
|
|
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
2026-08-18 14:23:03 +02:00
|
|
|
|
import { Clock, Flag, Pause, Play, Trophy } from "lucide-react"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- Types (exact contract — do not change) --------------------------------
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
export type RoundCell = {
|
|
|
|
|
|
roundNumber: number // 1, 2, 3, or 4
|
|
|
|
|
|
label: string // "70", "38 p" (Stableford) — or "" if not yet played
|
2026-08-18 13:49:12 +02:00
|
|
|
|
tone: "under" | "even" | "over" | null // null = not yet played, render empty/dashed
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-18 14:23:03 +02:00
|
|
|
|
totalLabel: string | null // "-13", "+7", "146 p" — null = hasn't played a single hole yet (neutral, not "E")
|
2026-08-04 04:47:27 +02:00
|
|
|
|
totalIsUnderPar: boolean
|
|
|
|
|
|
isLeader: boolean // true for every row sharing the current lead position (ties possible)
|
2026-08-18 14:23:03 +02:00
|
|
|
|
// 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
|
2026-08-18 13:49:12 +02:00
|
|
|
|
rounds: RoundCell[] // one entry per round played so far (1-4 entries, not always 4)
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- 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",
|
|
|
|
|
|
}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const MOCK_ROWS: LeaderboardRow[] = [
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{ 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)] },
|
2026-08-18 14:23:03 +02:00
|
|
|
|
{ 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: [] },
|
2026-08-18 15:07:21 +02:00
|
|
|
|
{ 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)] },
|
2026-08-04 04:47:27 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- Tone helpers ----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
type Tone = "under" | "even" | "over" | "none"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT"])
|
|
|
|
|
|
function isStatus(label: string) {
|
|
|
|
|
|
return STATUS.has(label.trim().toUpperCase())
|
|
|
|
|
|
}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// 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"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
const v = label.trim()
|
|
|
|
|
|
if (v === "") return "none"
|
2026-08-18 13:49:12 +02:00
|
|
|
|
if (isUnder) return "under"
|
|
|
|
|
|
if (v.toUpperCase() === "E") return "even"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
if (v.startsWith("+")) return "over"
|
2026-08-18 13:49:12 +02:00
|
|
|
|
return "even" // stableford-style non-negative that isn't flagged under
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
function roundTone(cell: RoundCell | undefined): Tone {
|
|
|
|
|
|
if (!cell || !cell.label || cell.tone == null) return "none"
|
|
|
|
|
|
return cell.tone
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function usePrefersReducedMotion() {
|
|
|
|
|
|
const [reduced, setReduced] = useState(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
|
|
|
|
|
|
setReduced(mq.matches)
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const on = () => setReduced(mq.matches)
|
|
|
|
|
|
mq.addEventListener("change", on)
|
|
|
|
|
|
return () => mq.removeEventListener("change", on)
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}, [])
|
|
|
|
|
|
return reduced
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- 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.
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
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"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
// --- Component -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export function StrokePlayLeaderboard({
|
|
|
|
|
|
rows = MOCK_ROWS,
|
|
|
|
|
|
caption = "Resultattavle",
|
|
|
|
|
|
}: {
|
|
|
|
|
|
rows?: LeaderboardRow[]
|
|
|
|
|
|
caption?: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const reducedMotion = usePrefersReducedMotion()
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const [playing, setPlaying] = useState(true)
|
|
|
|
|
|
const [canScroll, setCanScroll] = useState(false)
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
const leaders = useMemo(() => rows.filter((r) => r.isLeader), [rows])
|
|
|
|
|
|
const field = useMemo(() => rows.filter((r) => !r.isLeader), [rows])
|
|
|
|
|
|
const roundCount = useMemo(
|
2026-08-18 13:49:12 +02:00
|
|
|
|
() => rows.reduce((m, r) => Math.max(m, ...r.rounds.map((c) => c.roundNumber), 0), 0),
|
2026-08-04 04:47:27 +02:00
|
|
|
|
[rows],
|
|
|
|
|
|
)
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const roundNumbers = useMemo(() => Array.from({ length: roundCount }, (_, i) => i + 1), [roundCount])
|
2026-08-04 04:47:27 +02:00
|
|
|
|
const totalCols = 5 + roundCount
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// Measure header height + each leader row height so tied leaders stack
|
|
|
|
|
|
// sequentially beneath the header instead of piling on the same offset.
|
2026-08-04 04:47:27 +02:00
|
|
|
|
const theadRef = useRef<HTMLTableSectionElement>(null)
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const leaderRefs = useRef<(HTMLTableRowElement | null)[]>([])
|
|
|
|
|
|
const [leaderTops, setLeaderTops] = useState<number[]>([])
|
|
|
|
|
|
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<HTMLDivElement>(null)
|
|
|
|
|
|
const posRef = useRef(0)
|
2026-08-04 04:47:27 +02:00
|
|
|
|
useLayoutEffect(() => {
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const el = scrollRef.current
|
2026-08-04 04:47:27 +02:00
|
|
|
|
if (!el) return
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const measure = () => setCanScroll(el.scrollHeight - el.clientHeight > 4)
|
|
|
|
|
|
measure()
|
|
|
|
|
|
const ro = new ResizeObserver(measure)
|
2026-08-04 04:47:27 +02:00
|
|
|
|
ro.observe(el)
|
|
|
|
|
|
return () => ro.disconnect()
|
2026-08-18 13:49:12 +02:00
|
|
|
|
}, [rows])
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// Continuous, smooth vertical autoscroll using a float accumulator (avoids
|
|
|
|
|
|
// the integer-rounding stutter of reading scrollTop back each frame).
|
2026-08-04 04:47:27 +02:00
|
|
|
|
useEffect(() => {
|
2026-08-18 13:49:12 +02:00
|
|
|
|
if (!playing || reducedMotion || !canScroll) return
|
2026-08-04 04:47:27 +02:00
|
|
|
|
const el = scrollRef.current
|
|
|
|
|
|
if (!el) return
|
|
|
|
|
|
let raf = 0
|
|
|
|
|
|
let last = performance.now()
|
2026-08-18 13:49:12 +02:00
|
|
|
|
posRef.current = el.scrollTop
|
|
|
|
|
|
const SPEED = 34 // px / second
|
|
|
|
|
|
const PAUSE_AT_ENDS = 900 // ms hold at top before looping
|
|
|
|
|
|
let holdUntil = 0
|
2026-08-04 04:47:27 +02:00
|
|
|
|
const tick = (now: number) => {
|
|
|
|
|
|
const dt = Math.min((now - last) / 1000, 0.05)
|
|
|
|
|
|
last = now
|
|
|
|
|
|
const max = el.scrollHeight - el.clientHeight
|
2026-08-18 13:49:12 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
raf = requestAnimationFrame(tick)
|
|
|
|
|
|
}
|
|
|
|
|
|
raf = requestAnimationFrame(tick)
|
|
|
|
|
|
return () => cancelAnimationFrame(raf)
|
2026-08-18 13:49:12 +02:00
|
|
|
|
}, [playing, reducedMotion, canScroll])
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// Any manual gesture pauses instantly (programmatic scrollTop writes above
|
|
|
|
|
|
// don't fire these events, so they won't self-pause).
|
2026-08-04 04:47:27 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const toggle = useCallback(() => setPlaying((p) => !p), [])
|
|
|
|
|
|
const scrolling = playing && !reducedMotion && canScroll
|
|
|
|
|
|
|
2026-08-04 04:47:27 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<section
|
|
|
|
|
|
aria-label={caption}
|
2026-08-18 13:49:12 +02:00
|
|
|
|
className="flex w-full flex-col overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-card shadow-xl lg:rounded-[1.75rem]"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{/* Masthead — the public "face" of the tournament */}
|
|
|
|
|
|
<header className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4 bg-tee-strong px-4 py-4 text-clubhouse-bg sm:px-6 xl:px-10 xl:py-6">
|
|
|
|
|
|
<div className="flex items-center gap-3 xl:gap-4">
|
|
|
|
|
|
<span className="flex size-9 items-center justify-center rounded-xl bg-clubhouse-bg/15 xl:size-14">
|
|
|
|
|
|
<Flag aria-hidden="true" className="size-5 xl:size-8" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={cn("size-2.5 rounded-full bg-cup xl:size-3", scrolling && "animate-pulse")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-xs font-bold uppercase tracking-[0.2em] xl:text-base">Live</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h2 className="text-pretty text-xl font-black leading-tight tracking-tight xl:text-4xl">
|
|
|
|
|
|
{caption}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-4 xl:gap-6">
|
|
|
|
|
|
<Legend />
|
|
|
|
|
|
<button
|
2026-08-04 04:47:27 +02:00
|
|
|
|
type="button"
|
2026-08-18 13:49:12 +02:00
|
|
|
|
onClick={toggle}
|
|
|
|
|
|
aria-pressed={scrolling}
|
|
|
|
|
|
disabled={reducedMotion || !canScroll}
|
|
|
|
|
|
className="inline-flex min-h-12 items-center gap-2 rounded-xl bg-clubhouse-bg px-4 text-sm font-bold text-tee-strong shadow-sm transition-transform hover:opacity-95 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-clubhouse-bg/60 disabled:cursor-not-allowed disabled:opacity-50 xl:min-h-16 xl:gap-3 xl:px-7 xl:text-lg"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{scrolling ? (
|
|
|
|
|
|
<Pause aria-hidden="true" className="size-5 xl:size-7" />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
) : (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<Play aria-hidden="true" className="size-5 xl:size-7" />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
)}
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{scrolling ? "Stopp rulling" : "Rull automatisk"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{/* 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. */}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
<div
|
|
|
|
|
|
ref={scrollRef}
|
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
|
aria-label="Resultatliste"
|
2026-08-18 13:49:12 +02:00
|
|
|
|
className="relative max-h-[62vh] overflow-auto outline-none"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
>
|
|
|
|
|
|
<table className="w-full border-separate border-spacing-0 text-left tabular-nums">
|
|
|
|
|
|
<colgroup>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<col className={COL_POS} />
|
|
|
|
|
|
<col className="min-w-[9rem] xl:min-w-[20rem]" />
|
|
|
|
|
|
<col className="w-16 xl:w-32" />
|
|
|
|
|
|
<col className="w-14 xl:w-28" />
|
|
|
|
|
|
<col className="w-20 xl:w-40" />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
{roundNumbers.map((n) => (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<col key={n} className="w-14 xl:w-24" />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
))}
|
|
|
|
|
|
</colgroup>
|
|
|
|
|
|
|
|
|
|
|
|
<thead ref={theadRef}>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<tr className="bg-clubhouse-ink text-clubhouse-bg">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
<Th sticky="pos" className="z-50 text-center">
|
|
|
|
|
|
POS<span className="sr-only"> (posisjon)</span>
|
|
|
|
|
|
</Th>
|
|
|
|
|
|
<Th sticky="player" className="z-50 text-left">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
SPILLER
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</Th>
|
|
|
|
|
|
<Th className="text-right">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
I DAG<span className="sr-only"> (dagens runde)</span>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</Th>
|
|
|
|
|
|
<Th className="text-right">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
HULL<span className="sr-only"> (hull spilt)</span>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</Th>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<Th className="text-right">TOTALT</Th>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
{roundNumbers.map((n) => (
|
|
|
|
|
|
<Th key={n} className="text-right">
|
|
|
|
|
|
R{n}
|
|
|
|
|
|
<span className="sr-only"> (runde {n})</span>
|
|
|
|
|
|
</Th>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
|
|
|
|
|
|
<tbody>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{/* Pinned leader row(s) — always visible beneath the header, never
|
|
|
|
|
|
inside the ticker, so the winner reads from across the room. */}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
{leaders.map((row, i) => (
|
|
|
|
|
|
<LeaderRow
|
|
|
|
|
|
key={`leader-${row.playerName}`}
|
|
|
|
|
|
row={row}
|
|
|
|
|
|
roundNumbers={roundNumbers}
|
2026-08-18 13:49:12 +02:00
|
|
|
|
top={leaderTops[i] ?? 0}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
isLast={i === leaders.length - 1}
|
2026-08-18 13:49:12 +02:00
|
|
|
|
rowRef={(el) => {
|
|
|
|
|
|
leaderRefs.current[i] = el
|
|
|
|
|
|
}}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{field.length === 0 && (
|
|
|
|
|
|
<tr>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td colSpan={totalCols} className="px-4 py-10 text-center text-clubhouse-muted">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
Ingen spillere i feltet ennå.
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-18 15:07:21 +02:00
|
|
|
|
{/* 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) => (
|
|
|
|
|
|
<Fragment key={`field-${row.playerName}`}>
|
|
|
|
|
|
{i === firstCutIndex && <CutLineRow totalCols={totalCols} />}
|
|
|
|
|
|
<FieldRow row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
|
|
|
|
|
|
</Fragment>
|
|
|
|
|
|
))
|
|
|
|
|
|
})()}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Legend ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function Legend() {
|
|
|
|
|
|
return (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<ul className="hidden flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold sm:flex xl:text-base">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
<li className="flex items-center gap-1.5">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<span aria-hidden="true" className="flex size-5 items-center justify-center rounded-full bg-clubhouse-bg/20 text-[11px] font-black xl:size-7 xl:text-sm">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
−
|
|
|
|
|
|
</span>
|
|
|
|
|
|
Under par
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-center gap-1.5">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<span aria-hidden="true" className="flex size-5 items-center justify-center rounded-md bg-clubhouse-bg/20 text-[11px] font-black xl:size-7 xl:text-sm">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
+
|
|
|
|
|
|
</span>
|
|
|
|
|
|
Over par
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-center gap-1.5">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<span aria-hidden="true" className="flex size-5 items-center justify-center text-[11px] font-black xl:size-7 xl:text-sm">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
E
|
|
|
|
|
|
</span>
|
|
|
|
|
|
Par
|
|
|
|
|
|
</li>
|
2026-08-18 14:23:03 +02:00
|
|
|
|
<li className="flex items-center gap-1.5">
|
|
|
|
|
|
<Clock aria-hidden="true" className="size-4 xl:size-6" />
|
|
|
|
|
|
Ikke startet
|
|
|
|
|
|
</li>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</ul>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Header cell -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function Th({
|
|
|
|
|
|
children,
|
|
|
|
|
|
className,
|
|
|
|
|
|
sticky,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
children: React.ReactNode
|
|
|
|
|
|
className?: string
|
|
|
|
|
|
sticky?: "pos" | "player"
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<th
|
|
|
|
|
|
scope="col"
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
cellPad,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
"sticky top-0 z-40 bg-clubhouse-ink py-3 text-[0.7rem] font-black uppercase tracking-wider text-clubhouse-bg xl:text-lg",
|
2026-08-04 04:47:27 +02:00
|
|
|
|
sticky === "pos" && "left-0",
|
|
|
|
|
|
sticky === "player" && PLAYER_LEFT,
|
|
|
|
|
|
className,
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</th>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- Leader row (pinned, gold) ---------------------------------------------
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
function LeaderRow({
|
|
|
|
|
|
row,
|
|
|
|
|
|
roundNumbers,
|
|
|
|
|
|
top,
|
|
|
|
|
|
isLast,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
rowRef,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
row: LeaderboardRow
|
|
|
|
|
|
roundNumbers: number[]
|
|
|
|
|
|
top: number
|
|
|
|
|
|
isLast: boolean
|
2026-08-18 13:49:12 +02:00
|
|
|
|
rowRef?: (el: HTMLTableRowElement | null) => void
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}) {
|
2026-08-18 13:49:12 +02:00
|
|
|
|
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"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<tr ref={rowRef} className={bg}>
|
|
|
|
|
|
<td style={style} className={cn(cellPad, bg, border, "sticky left-0 z-30 text-center align-middle")}>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
<div className="flex items-center justify-center gap-1.5">
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<Trophy aria-hidden="true" className="size-4 shrink-0 xl:size-7" />
|
|
|
|
|
|
<span className="text-lg font-black xl:text-4xl">{row.position}</span>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
<span className="sr-only">, leder</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td style={style} className={cn(cellPad, bg, border, "sticky z-30 text-left", PLAYER_LEFT)}>
|
|
|
|
|
|
<span className="block truncate text-lg font-black xl:text-4xl">{row.playerName}</span>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</td>
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{/* TODAY + rounds keep the shape but wear a neutral gold-foreground so
|
|
|
|
|
|
they stay legible on gold in both themes. */}
|
|
|
|
|
|
<ValueTd style={style} bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} onGold size="today" />
|
|
|
|
|
|
<ThruTd style={style} bg={bg} border={border} thru={row.thruLabel} onGold />
|
|
|
|
|
|
{/* TOTAL is the marquee: a solid, colored chip that pops even on gold. */}
|
|
|
|
|
|
<ValueTd style={style} bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" solid />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
{roundNumbers.map((n) => {
|
|
|
|
|
|
const cell = row.rounds.find((c) => c.roundNumber === n)
|
2026-08-18 13:49:12 +02:00
|
|
|
|
return <RoundTd key={n} style={style} bg={bg} border={border} cell={cell} onGold />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
})}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Field row -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function FieldRow({
|
|
|
|
|
|
row,
|
|
|
|
|
|
roundNumbers,
|
|
|
|
|
|
striped,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
row: LeaderboardRow
|
|
|
|
|
|
roundNumbers: number[]
|
|
|
|
|
|
striped: boolean
|
|
|
|
|
|
}) {
|
2026-08-18 14:23:03 +02:00
|
|
|
|
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
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
|
|
|
|
|
|
const border = "border-b border-clubhouse-border"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-18 14:23:03 +02:00
|
|
|
|
<tr className={cn(bg, (status || notStarted) && "opacity-70")}>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}>
|
|
|
|
|
|
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</td>
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
|
2026-08-18 14:23:03 +02:00
|
|
|
|
<span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
{row.playerName}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{status ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<DashTd bg={bg} border={border} />
|
|
|
|
|
|
<DashTd bg={bg} border={border} />
|
|
|
|
|
|
<td className={cn(cellPad, bg, border, "text-right")}>
|
|
|
|
|
|
<span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-sm font-black uppercase tracking-wide text-clubhouse-muted xl:text-xl">
|
|
|
|
|
|
{row.totalLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
{roundNumbers.map((n) => {
|
|
|
|
|
|
const cell = row.rounds.find((c) => c.roundNumber === n)
|
|
|
|
|
|
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
|
|
|
|
|
})}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
|
|
|
|
|
|
<ThruTd bg={bg} border={border} thru={row.thruLabel} />
|
2026-08-18 14:23:03 +02:00
|
|
|
|
{notStarted ? (
|
|
|
|
|
|
<TeeInfoTd bg={bg} border={border} teeTime={row.nextTeeTime ?? null} startHole={row.nextStartHole ?? null} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
|
|
|
|
|
|
)}
|
2026-08-18 13:49:12 +02:00
|
|
|
|
{roundNumbers.map((n) => {
|
|
|
|
|
|
const cell = row.rounds.find((c) => c.roundNumber === n)
|
|
|
|
|
|
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
|
|
|
|
|
})}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-08-04 04:47:27 +02:00
|
|
|
|
</tr>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- 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 (
|
|
|
|
|
|
<span className={cn("inline-flex items-center justify-center", dims, onGold ? "text-gold-foreground" : "text-clubhouse-ink")}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
|
<span className={cn("inline-flex items-center justify-center", dims, shape, skin)}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
<span className="sr-only">{tone === "under" ? " under par" : " over par"}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 04:47:27 +02:00
|
|
|
|
// --- Value cell (TODAY / TOTAL) --------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function ValueTd({
|
|
|
|
|
|
label,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
tone,
|
|
|
|
|
|
size,
|
|
|
|
|
|
bg,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
border,
|
|
|
|
|
|
style,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
solid,
|
|
|
|
|
|
onGold,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
label: string | null
|
2026-08-18 13:49:12 +02:00
|
|
|
|
tone: Tone
|
|
|
|
|
|
size: "today" | "total"
|
|
|
|
|
|
bg: string
|
2026-08-04 04:47:27 +02:00
|
|
|
|
border: string
|
|
|
|
|
|
style?: React.CSSProperties
|
2026-08-18 13:49:12 +02:00
|
|
|
|
solid?: boolean
|
|
|
|
|
|
onGold?: boolean
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td style={style} className={cn(cellPad, bg, border, "text-right", (solid || onGold) && "sticky z-20")}>
|
|
|
|
|
|
{label == null || tone === "none" ? (
|
|
|
|
|
|
<span className="text-base text-clubhouse-muted xl:text-2xl" aria-label="ikke startet">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
–
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<ScoreChip label={label} tone={tone} size={size} solid={solid} onGold={onGold} />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- THRU cell -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function ThruTd({
|
|
|
|
|
|
thru,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
bg,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
border,
|
|
|
|
|
|
style,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
onGold,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
thru: string
|
2026-08-18 13:49:12 +02:00
|
|
|
|
bg: string
|
2026-08-04 04:47:27 +02:00
|
|
|
|
border: string
|
|
|
|
|
|
style?: React.CSSProperties
|
2026-08-18 13:49:12 +02:00
|
|
|
|
onGold?: boolean
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
const finished = thru === "F"
|
|
|
|
|
|
const notStarted = thru === "-"
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const ink = onGold ? "text-gold-foreground" : "text-clubhouse-ink"
|
|
|
|
|
|
const muted = onGold ? "text-gold-foreground/70" : "text-clubhouse-muted"
|
2026-08-04 04:47:27 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td style={style} className={cn(cellPad, bg, border, "text-right", onGold && "sticky z-20")}>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
{notStarted ? (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<span className="text-base text-clubhouse-muted xl:text-2xl">–</span>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
) : finished ? (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<span className={cn("text-base font-bold xl:text-2xl", ink)}>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
F<span className="sr-only"> (ferdig)</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<span className={cn("text-base font-semibold xl:text-2xl", muted)}>
|
2026-08-04 04:47:27 +02:00
|
|
|
|
{thru}
|
|
|
|
|
|
<span className="sr-only"> hull spilt</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Round cell (Rn) -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function RoundTd({
|
|
|
|
|
|
cell,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
bg,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
border,
|
|
|
|
|
|
style,
|
2026-08-18 13:49:12 +02:00
|
|
|
|
onGold,
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
cell?: RoundCell
|
2026-08-18 13:49:12 +02:00
|
|
|
|
bg: string
|
2026-08-04 04:47:27 +02:00
|
|
|
|
border: string
|
|
|
|
|
|
style?: React.CSSProperties
|
2026-08-18 13:49:12 +02:00
|
|
|
|
onGold?: boolean
|
2026-08-04 04:47:27 +02:00
|
|
|
|
}) {
|
2026-08-18 13:49:12 +02:00
|
|
|
|
const tone = roundTone(cell)
|
2026-08-04 04:47:27 +02:00
|
|
|
|
return (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<td style={style} className={cn(cellPad, bg, border, "text-right", onGold && "sticky z-20")}>
|
|
|
|
|
|
{tone === "none" ? (
|
|
|
|
|
|
<span className="text-base text-clubhouse-muted xl:text-2xl" aria-label="ikke spilt">
|
2026-08-04 04:47:27 +02:00
|
|
|
|
–
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
2026-08-18 13:49:12 +02:00
|
|
|
|
<ScoreChip label={cell!.label} tone={tone} size="today" onGold={onGold} />
|
2026-08-04 04:47:27 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-18 13:49:12 +02:00
|
|
|
|
|
2026-08-18 15:07:21 +02:00
|
|
|
|
// --- 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 (
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td colSpan={totalCols} className="border-y-2 border-clubhouse-border bg-clubhouse-field px-4 py-2">
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<span aria-hidden="true" className="h-px flex-1 bg-clubhouse-border" />
|
|
|
|
|
|
<span className="text-xs font-black uppercase tracking-[0.2em] text-clubhouse-muted">
|
|
|
|
|
|
Cut -- spillerne under gikk ikke videre
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span aria-hidden="true" className="h-px flex-1 bg-clubhouse-border" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-18 13:49:12 +02:00
|
|
|
|
// --- Not-applicable dash cell (status rows) --------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function DashTd({ bg, border }: { bg: string; border: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<td className={cn(cellPad, bg, border, "text-right")}>
|
|
|
|
|
|
<span className="text-base text-clubhouse-muted xl:text-2xl">–</span>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-08-18 14:23:03 +02:00
|
|
|
|
|
|
|
|
|
|
// --- 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 (
|
|
|
|
|
|
<td className={cn(cellPad, bg, border, "text-right")}>
|
|
|
|
|
|
{parts.length > 0 ? (
|
|
|
|
|
|
<span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md bg-muted px-2.5 py-1 text-sm font-bold text-clubhouse-muted xl:text-lg">
|
|
|
|
|
|
<Clock aria-hidden="true" className="size-3.5 shrink-0 xl:size-5" />
|
|
|
|
|
|
{parts.join(" · ")}
|
|
|
|
|
|
<span className="sr-only"> (ikke startet)</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-base text-clubhouse-muted xl:text-2xl" aria-label="ikke startet">
|
|
|
|
|
|
–
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|