teecup/frontend/components/stroke-play-leaderboard.tsx
Erol Haagenrud eeb7e9fb29 Leaderboard: fyller full bredde + autoscroll fungerer på storskjerm
StrokePlayLeaderboard var bygget for å skalere opp på lg:-brytningspunkt,
men satt inni en delt max-w-4xl-wrapper som aldri lot den. Samme
brytningspunkt fjernet skrolle-taket helt, så autoscroll-tickeren fikk
aldri noe å bevege for et stort felt. Begge rettet -- ren visuell polish
kommer i en oppfølgende V0-redesign av samme komponent.

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

677 lines
22 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 { 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<Tone, string> = {
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<HTMLTableSectionElement>(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<HTMLDivElement>(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 (
<section
aria-label={caption}
className="flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm lg:rounded-3xl"
>
{/* Control bar (part of the component, not page chrome) */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-background px-3 py-3 lg:px-5 lg:py-4">
<Legend />
{!reducedMotion && (
<Button
type="button"
variant={playing ? "secondary" : "outline"}
size="lg"
onClick={() => setPlaying((p) => !p)}
aria-pressed={playing}
className="h-11 gap-2 px-4 text-sm lg:h-14 lg:px-6 lg:text-base"
>
{playing ? (
<Pause aria-hidden="true" className="size-4 lg:size-5" />
) : (
<Play aria-hidden="true" className="size-4 lg:size-5" />
)}
{playing ? "Stopp rulling" : "Rull automatisk"}
</Button>
)}
</div>
{/* 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. */}
<div
ref={scrollRef}
tabIndex={0}
aria-label="Resultatliste"
className="relative max-h-[24rem] overflow-auto outline-none md:max-h-[32rem] lg:max-h-[calc(100vh-18rem)]"
>
<table className="w-full border-separate border-spacing-0 text-left tabular-nums">
<colgroup>
<col className={COL.pos} />
<col className={COL.player} />
<col className={COL.today} />
<col className={COL.thru} />
<col className={COL.total} />
{roundNumbers.map((n) => (
<col key={n} className={COL.round} />
))}
</colgroup>
<thead ref={theadRef}>
<tr className="bg-foreground text-background">
<Th sticky="pos" className="z-50 text-center">
POS<span className="sr-only"> (posisjon)</span>
</Th>
<Th sticky="player" className="z-50 text-left">
PLAYER<span className="sr-only"> (spiller)</span>
</Th>
<Th className="text-right">
TODAY<span className="sr-only"> (i dag)</span>
</Th>
<Th className="text-right">
THRU<span className="sr-only"> (hull spilt)</span>
</Th>
<Th className="text-right">
TOTAL<span className="sr-only"> (totalt mot par)</span>
</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) — stick beneath the header, never in the ticker. */}
{leaders.map((row, i) => (
<LeaderRow
key={`leader-${row.playerName}`}
row={row}
roundNumbers={roundNumbers}
top={headTop}
isLast={i === leaders.length - 1}
/>
))}
{field.length === 0 && (
<tr>
<td
colSpan={totalCols}
className="px-4 py-8 text-center text-sm text-muted-foreground"
>
Ingen spillere i feltet ennå.
</td>
</tr>
)}
{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="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs font-medium text-muted-foreground lg:text-sm">
<li className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="flex size-5 items-center justify-center rounded-full bg-primary/15 text-[10px] font-bold text-primary lg:size-6"
>
</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-brand-orange/15 text-[10px] font-bold text-brand-orange lg:size-6"
>
+
</span>
Over par
</li>
<li className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="flex size-5 items-center justify-center rounded-md text-[10px] font-bold text-foreground lg:size-6"
>
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-foreground text-[0.7rem] font-extrabold uppercase tracking-wider text-background lg:text-base",
sticky === "pos" && "left-0",
sticky === "player" && PLAYER_LEFT,
className,
)}
>
{children}
</th>
)
}
// --- 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 (
<tr className={rowBg}>
{/* POS + trophy (sticky both top and left, above field + own row) */}
<td
style={stickyStyle}
className={cn(cellPad, rowBg, 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 lg:size-6" />
<span className="text-sm font-extrabold lg:text-3xl">{row.position}</span>
<span className="sr-only">, leder</span>
</div>
</td>
{/* PLAYER */}
<td
style={stickyStyle}
className={cn(cellPad, rowBg, border, "sticky z-30 text-left", PLAYER_LEFT)}
>
<span className="flex items-center gap-2 text-sm font-extrabold lg:text-2xl">
<Flag aria-hidden="true" className="size-3.5 shrink-0 lg:size-5" />
<span className="truncate">{row.playerName}</span>
</span>
</td>
<ValueTd style={stickyStyle} rowBg={rowBg} border={border} label={row.todayLabel} emphasis="today" leader />
<ThruTd style={stickyStyle} rowBg={rowBg} border={border} thru={row.thruLabel} leader />
<ValueTd style={stickyStyle} rowBg={rowBg} border={border} label={row.totalLabel} emphasis="total" leader />
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} style={stickyStyle} rowBg={rowBg} border={border} cell={cell} leader />
})}
</tr>
)
}
// --- 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 (
<tr className={rowBg}>
<td className={cn(cellPad, rowBg, border, "sticky left-0 z-10 text-center")}>
<span className="text-sm font-bold text-foreground lg:text-2xl">{row.position}</span>
</td>
<td className={cn(cellPad, rowBg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
<span className="block truncate text-sm font-semibold text-foreground lg:text-xl">
{row.playerName}
</span>
</td>
<ValueTd rowBg={rowBg} border={border} label={row.todayLabel} emphasis="today" />
<ThruTd rowBg={rowBg} border={border} thru={row.thruLabel} />
<ValueTd rowBg={rowBg} border={border} label={row.totalLabel} emphasis="total" />
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} rowBg={rowBg} border={border} cell={cell} />
})}
</tr>
)
}
// --- 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 (
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
{label == null ? (
<span className="text-sm text-muted-foreground lg:text-xl" aria-label="ikke startet">
</span>
) : (
<span className={cn(size, color)}>{label}</span>
)}
</td>
)
}
// --- 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 (
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
{notStarted ? (
<span className="text-sm text-muted-foreground lg:text-xl"></span>
) : finished ? (
<span className={cn("text-sm font-bold lg:text-xl", color)}>
F<span className="sr-only"> (ferdig)</span>
</span>
) : (
<span className={cn("text-sm font-semibold lg:text-xl", leader ? "text-gold-foreground" : "text-muted-foreground")}>
{thru}
<span className="sr-only"> hull spilt</span>
</span>
)}
</td>
)
}
// --- 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 (
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
{!played ? (
<span className="text-sm text-muted-foreground lg:text-xl" aria-label="ikke spilt">
</span>
) : 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.
<span className={cn("text-sm font-bold lg:text-lg", leader ? "text-gold-foreground" : "text-foreground")}>
{cell!.label}
<span className="sr-only"> par</span>
</span>
) : (
<span
className={cn(
"inline-flex min-w-8 items-center justify-center px-1.5 py-0.5 text-sm font-bold lg:min-w-12 lg:px-2 lg:py-1 lg:text-lg",
// Shape encodes tone (circle=under, square=over) so meaning survives
// without color. On the gold leader band, use a solid opaque chip so
// the low-opacity tint doesn't wash out against gold.
cell!.tone === "under" ? "rounded-full" : "rounded-md",
leader
? cn("bg-card", cell!.tone === "under" ? "text-primary" : "text-brand-orange")
: cell!.tone === "under"
? "bg-primary/15 text-primary"
: "bg-brand-orange/15 text-brand-orange",
)}
>
{cell!.label}
<span className="sr-only">{cell!.tone === "under" ? " under par" : " over par"}</span>
</span>
)}
</td>
)
}