teecup/frontend/components/stroke-play-leaderboard.tsx
Erol Haagenrud 50653303f4 Alt er scratch-verifisert grundig — API og ekte nettleser, både på 390px mobil og en 1920px "storskjerm". Før jeg deployer:
Hva som skjer:

Ingen migrasjon (kun nye felt i et eksisterende API-svar, ingen skjemaendring).
docker compose up -d --build teecup_api teecup_frontend mot ekte teecup_db.
Hva som er verifisert:

Ny Augusta-stil resultattavle (POS/PLAYER/TODAY/THRU/TOTAL/R1-Rn) for brutto-, netto- og stableford-turneringer — København/BBB/Flagg beholder dagens visning uendret.
Lederen(e) fastspent øverst i gull, uavgjort håndteres riktig (bekreftet: to spillere delte ledelsen med -5 hver, viste begge som "T1").
Rull-automatisk-knapp fungerer (av/på bekreftet).
Storskjerm skalerer opp fint uten skrolling; mobil får riktig frossen POS/PLAYER-kolonne.
Rettet appens eget "E → ren tekst, ingen ramme"-regel inn i den nye komponenten (V0 hadde ikke helt fått det med seg for selve runde-cellene) — bekreftet visuelt.
Fant og rettet en reell rangeringsbug under verifiseringen: i en flerrunde-turnering der spillere har spilt ulikt antall hull, kan man IKKE rangere på rått slagtall (færre hull spilt ga misvisende lavt tall) — måtte rangere på til-par i stedet. Uten denne fiksen ville en spiller som knapt hadde startet kunnet vises som leder.
2026-08-04 04:47:27 +02:00

669 lines
21 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. On large "storskjerm" viewports the
cap is removed so the whole board fits with no scrolling. */}
<div
ref={scrollRef}
tabIndex={0}
aria-label="Resultatliste"
className="relative max-h-[24rem] overflow-auto outline-none md:max-h-[32rem] lg:max-h-none lg:overflow-visible"
>
<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>
)
}