teecup/frontend/components/stroke-play-leaderboard.tsx
Erol Haagenrud 985dada178 V0-redesign av StrokePlayLeaderboard integrert
Rent innbytte -- identisk props-kontrakt. Løser bredde/autoscroll-bugene
fra forrige runde mer robust (ekte ResizeObserver-måling i stedet for en
brytningspunkt-antakelse), autostarter rulling for ubetjent
kiosk-visning, og legger til dempet visning for WD/DQ/DNS/NR/CUT-rader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 13:49:12 +02:00

670 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { 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 // "-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 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)] },
]
// --- Tone helpers ----------------------------------------------------------
type Tone = "under" | "even" | "over" | "none"
const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT"])
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",
}: {
rows?: LeaderboardRow[]
caption?: string
}) {
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<HTMLTableSectionElement>(null)
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)
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 (
<section
aria-label={caption}
className="flex w-full flex-col overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-card shadow-xl lg:rounded-[1.75rem]"
>
{/* 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
type="button"
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"
>
{scrolling ? (
<Pause aria-hidden="true" className="size-5 xl:size-7" />
) : (
<Play aria-hidden="true" className="size-5 xl:size-7" />
)}
{scrolling ? "Stopp rulling" : "Rull automatisk"}
</button>
</div>
</header>
{/* 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. */}
<div
ref={scrollRef}
tabIndex={0}
aria-label="Resultatliste"
className="relative max-h-[62vh] overflow-auto outline-none"
>
<table className="w-full border-separate border-spacing-0 text-left tabular-nums">
<colgroup>
<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" />
{roundNumbers.map((n) => (
<col key={n} className="w-14 xl:w-24" />
))}
</colgroup>
<thead ref={theadRef}>
<tr className="bg-clubhouse-ink text-clubhouse-bg">
<Th sticky="pos" className="z-50 text-center">
POS<span className="sr-only"> (posisjon)</span>
</Th>
<Th sticky="player" className="z-50 text-left">
SPILLER
</Th>
<Th className="text-right">
I DAG<span className="sr-only"> (dagens runde)</span>
</Th>
<Th className="text-right">
HULL<span className="sr-only"> (hull spilt)</span>
</Th>
<Th className="text-right">TOTALT</Th>
{roundNumbers.map((n) => (
<Th key={n} className="text-right">
R{n}
<span className="sr-only"> (runde {n})</span>
</Th>
))}
</tr>
</thead>
<tbody>
{/* 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) => (
<LeaderRow
key={`leader-${row.playerName}`}
row={row}
roundNumbers={roundNumbers}
top={leaderTops[i] ?? 0}
isLast={i === leaders.length - 1}
rowRef={(el) => {
leaderRefs.current[i] = el
}}
/>
))}
{field.length === 0 && (
<tr>
<td colSpan={totalCols} className="px-4 py-10 text-center text-clubhouse-muted">
Ingen spillere i feltet ennå.
</td>
</tr>
)}
{/* A future "cut line" divider row can be inserted between any two
FieldRows here without restructuring. */}
{field.map((row, i) => (
<FieldRow key={`field-${row.playerName}`} row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
))}
</tbody>
</table>
</div>
</section>
)
}
// --- Legend ----------------------------------------------------------------
function Legend() {
return (
<ul className="hidden flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold sm:flex xl:text-base">
<li className="flex items-center gap-1.5">
<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">
</span>
Under par
</li>
<li className="flex items-center gap-1.5">
<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">
+
</span>
Over par
</li>
<li className="flex items-center gap-1.5">
<span aria-hidden="true" className="flex size-5 items-center justify-center text-[11px] font-black xl:size-7 xl:text-sm">
E
</span>
Par
</li>
</ul>
)
}
// --- Header cell -----------------------------------------------------------
function Th({
children,
className,
sticky,
}: {
children: React.ReactNode
className?: string
sticky?: "pos" | "player"
}) {
return (
<th
scope="col"
className={cn(
cellPad,
"sticky top-0 z-40 bg-clubhouse-ink py-3 text-[0.7rem] font-black uppercase tracking-wider text-clubhouse-bg xl:text-lg",
sticky === "pos" && "left-0",
sticky === "player" && PLAYER_LEFT,
className,
)}
>
{children}
</th>
)
}
// --- 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 (
<tr ref={rowRef} className={bg}>
<td style={style} className={cn(cellPad, bg, border, "sticky left-0 z-30 text-center align-middle")}>
<div className="flex items-center justify-center gap-1.5">
<Trophy aria-hidden="true" className="size-4 shrink-0 xl:size-7" />
<span className="text-lg font-black xl:text-4xl">{row.position}</span>
<span className="sr-only">, leder</span>
</div>
</td>
<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>
</td>
{/* 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 />
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} style={style} bg={bg} border={border} cell={cell} onGold />
})}
</tr>
)
}
// --- Field row -------------------------------------------------------------
function FieldRow({
row,
roundNumbers,
striped,
}: {
row: LeaderboardRow
roundNumbers: number[]
striped: boolean
}) {
const status = isStatus(row.totalLabel)
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
const border = "border-b border-clubhouse-border"
return (
<tr className={cn(bg, status && "opacity-70")}>
<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>
</td>
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
<span className={cn("block truncate text-base font-semibold xl:text-2xl", status ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
{row.playerName}
</span>
</td>
{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} />
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
})}
</>
)}
</tr>
)
}
// --- 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>
)
}
// --- 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 (
<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">
</span>
) : (
<ScoreChip label={label} tone={tone} size={size} solid={solid} onGold={onGold} />
)}
</td>
)
}
// --- 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 (
<td style={style} className={cn(cellPad, bg, border, "text-right", onGold && "sticky z-20")}>
{notStarted ? (
<span className="text-base text-clubhouse-muted xl:text-2xl"></span>
) : finished ? (
<span className={cn("text-base font-bold xl:text-2xl", ink)}>
F<span className="sr-only"> (ferdig)</span>
</span>
) : (
<span className={cn("text-base font-semibold xl:text-2xl", muted)}>
{thru}
<span className="sr-only"> hull spilt</span>
</span>
)}
</td>
)
}
// --- 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 (
<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">
</span>
) : (
<ScoreChip label={cell!.label} tone={tone} size="today" onGold={onGold} />
)}
</td>
)
}
// --- 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>
)
}