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.
This commit is contained in:
Erol Haagenrud 2026-08-04 04:47:27 +02:00
parent 38168b2871
commit 50653303f4
3 changed files with 923 additions and 15 deletions

View file

@ -1024,6 +1024,12 @@ async def list_round_participant_holes(
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
# =====================================================================
class RoundCellOut(BaseModel):
round_number: int
label: str
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
class LeaderboardEntry(BaseModel):
tournament_participant_id: str
player_name: str
@ -1038,6 +1044,16 @@ class LeaderboardEntry(BaseModel):
# rangering per klasse). Sorteringen under er UENDRET -- ren tillegg.
class_id: str | None
class_name: str | None
# Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx) --
# KUN populert for scoring_method i (stroke_gross, stroke_net, stableford).
# Alltid None/tom liste for copenhagen/bingo_bango_bongo/flag -- frontend
# bruker fortsatt den enkle listen for de formatene.
position: str | None = None
is_leader: bool = False
today_label: str | None = None
thru_label: str | None = None
total_label: str | None = None
rounds: list[RoundCellOut] = []
@router.get(
@ -1082,21 +1098,202 @@ async def individual_leaderboard(
)
entries = [LeaderboardEntry(**dict(r)) for r in rows]
method = tournament["scoring_method"]
if method == "stableford":
entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0)))
elif method == "stroke_net":
entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0))
elif method == "copenhagen":
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
elif method == "bingo_bango_bongo":
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
else:
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
method = tournament["scoring_method"]
if method in ("stroke_gross", "stroke_net", "stableford"):
# VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her
# (slik de andre metodene gjør) -- i en flerrunde-turnering har
# deltakere spilt ULIKT antall hull til enhver tid (noen midt i
# runde 2, andre ikke startet ennå), så rå sum er ikke
# sammenlignbar. _attach_stroke_play_columns sorterer selv, på
# til-par (normalisert for antall hull spilt), FØR posisjon
# tildeles -- reell bug funnet under scratch-verifisering: en
# deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran
# deltakere med -5 til par over 27-36 hull, kun fordi rått
# slagtall er mindre jo færre hull man har spilt.
await _attach_stroke_play_columns(conn, tournament_id, method, entries)
elif method == "copenhagen":
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
elif method == "bingo_bango_bongo":
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
else:
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
return entries
_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
def _to_par_label(v: int) -> str:
if v == 0:
return "E"
return f"+{v}" if v > 0 else str(v)
def _to_par_tone(v: int) -> str:
if v < 0:
return "under"
if v > 0:
return "over"
return "even"
async def _attach_stroke_play_columns(
conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]
) -> None:
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
kalleren) i stedet for å bygge en ny liste -- gjenbruker eksisterende
gross_total/net_total/stableford_total som ALLEREDE er summert riktig,
legger kun til det som mangler: par-for-spilte-hull (for til-par-tall),
per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
"""
rounds_meta = await conn.fetch(
"""
SELECT sequence, hole_config::text AS hole_config
FROM tournament_round
WHERE tournament_id = $1
ORDER BY sequence
""",
tournament_id,
)
hole_count_by_seq = {
r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta
}
# Én rad per (runde, deltaker) som faktisk er PÅBEGYNT (holes_played > 0).
# par_played = summen av par for KUN de hullene deltakeren faktisk har
# ført score på i den runden -- riktig grunnlag for et til-par-tall
# midt i en runde (ikke hele rundens par, som ville misvist før runden
# er ferdigspilt).
round_rows = await conn.fetch(
"""
SELECT tr.sequence,
trs.tournament_participant_id::text AS tournament_participant_id,
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
COALESCE(pp.par_played, 0)::int AS par_played
FROM tournament_round tr
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
LEFT JOIN LATERAL (
SELECT SUM(h.par) AS par_played
FROM tournament_round_participant trp
JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
WHERE trp.tournament_round_id = tr.id
AND trp.tournament_participant_id = trs.tournament_participant_id
AND trh.gross_strokes IS NOT NULL
) pp ON true
WHERE tr.tournament_id = $1 AND trs.holes_played > 0
ORDER BY tr.sequence
""",
tournament_id,
)
by_participant: dict[str, dict[int, object]] = {}
started_sequences: set[int] = set()
for r in round_rows:
by_participant.setdefault(r["tournament_participant_id"], {})[r["sequence"]] = r
started_sequences.add(r["sequence"])
# "I dag" = runden med høyest sekvensnummer som NOEN har påbegynt -- ikke
# nødvendigvis fullført (bekreftet med bruker: "det er den sist
# påbegynte"). Ingen eksplisitt status-/aktiv-markering finnes på
# tournament_round i dag, så dette utledes.
today_sequence = max(started_sequences) if started_sequences else None
value_col = "stableford_points" if method == "stableford" else (
"net_total" if method == "stroke_net" else "gross_total"
)
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
# flerrunde-turnering har deltakere spilt ulikt antall hull til enhver
# tid, så rå sum er ikke sammenlignbar (se kallstedets kommentar).
NO_SCORE = 1_000_000_000
rank_key_by_id: dict[str, int] = {}
for e in entries:
pmap = by_participant.get(e.tournament_participant_id, {})
rounds_out: list[RoundCellOut] = []
total_par_played = 0
total_holes_played = 0
for seq, row in sorted(pmap.items()):
if row["holes_played"] == 0:
continue
total_par_played += row["par_played"]
total_holes_played += row["holes_played"]
if method == "stableford":
points = row["stableford_points"] or 0
expected = 2 * row["holes_played"]
rounds_out.append(
RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected))
)
else:
val = row[value_col]
if val is None:
continue
rounds_out.append(
RoundCellOut(
round_number=seq,
label=str(val),
tone=_to_par_tone(val - row["par_played"]),
)
)
e.rounds = rounds_out
# TODAY / THRU
today_row = pmap.get(today_sequence) if today_sequence is not None else None
if today_row is not None and today_row["holes_played"] > 0:
hole_count = hole_count_by_seq.get(today_sequence, 18)
e.thru_label = "F" if today_row["holes_played"] >= hole_count else str(today_row["holes_played"])
if method == "stableford":
e.today_label = f"{today_row['stableford_points'] or 0} p"
else:
val = today_row[value_col]
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
else:
e.thru_label = "-"
e.today_label = None
# TOTAL -- gjenbruker den allerede korrekt summerte gross_total/
# net_total/stableford_total (uendret av denne funksjonen), trekker
# kun fra summert par-for-spilte-hull for til-par-formatet.
if method == "stableford":
e.total_label = f"{e.stableford_total or 0} p"
rank_key_by_id[e.tournament_participant_id] = (
NO_SCORE if e.stableford_total is None or total_holes_played == 0
else -(e.stableford_total - 2 * total_holes_played)
)
else:
total_val = e.net_total if method == "stroke_net" else e.gross_total
e.total_label = _to_par_label(total_val - total_par_played) if total_val is not None else "E"
rank_key_by_id[e.tournament_participant_id] = (
NO_SCORE if total_val is None or total_par_played == 0 else total_val - total_par_played
)
# Sorter på til-par-nøkkelen (lavere = bedre), IKKE på rå total -- se
# kallstedets kommentar for hvorfor rå total ikke er sammenlignbar på
# tvers av ulikt antall hull spilt.
entries.sort(key=lambda e: rank_key_by_id[e.tournament_participant_id])
# POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste
# rangering hopper over like mange plasser som antall uavgjorte).
i, n = 0, len(entries)
while i < n:
j = i
key_i = rank_key_by_id[entries[i].tournament_participant_id]
while j < n and rank_key_by_id[entries[j].tournament_participant_id] == key_i:
j += 1
rank = i + 1
tied = (j - i) > 1
for k in range(i, j):
entries[k].position = f"T{rank}" if tied else str(rank)
entries[k].is_leader = rank == 1
i = j
# ---------------------------------------------------------------------------
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør

View file

@ -39,6 +39,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
@ -114,6 +115,12 @@ type ApiHole = {
strokes_received: number | null
}
type ApiRoundCell = {
round_number: number
label: string
tone: "under" | "even" | "over" | null
}
type ApiLeaderboardEntry = {
tournament_participant_id: string
player_name: string
@ -125,6 +132,14 @@ type ApiLeaderboardEntry = {
bbb_total: number | null
class_id: string | null
class_name: string | null
// Augusta-stil resultattavle (2026-08-04) -- kun populert for
// scoring_method brutto/netto/stableford, se stroke-play-leaderboard.tsx.
position: string | null
is_leader: boolean
today_label: string | null
thru_label: string | null
total_label: string | null
rounds: ApiRoundCell[]
}
type ApiFlagResult = {
@ -2184,6 +2199,26 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
groups.sort((a, b) => (a.key === "__none__" ? 1 : b.key === "__none__" ? -1 : 0))
const showSectionHeaders = groups.length > 1 || groups[0]?.key !== "__none__"
// Augusta-stil resultattavle (2026-08-04) -- kun for brutto/netto/
// stableford, backend har allerede regnet ut alt (POS med uavgjort,
// TODAY/THRU for gjeldende runde, TOTAL, R1-Rn). Denne funksjonen er ren
// formvending, ingen domenelogikk.
const isStrokePlay = method === "stroke_gross" || method === "stroke_net" || method === "stableford"
function toStrokePlayRow(entry: ApiLeaderboardEntry): StrokePlayRow {
return {
position: entry.position ?? "-",
playerName: entry.player_name,
todayLabel: entry.today_label,
todayIsUnderPar: null,
thruLabel: entry.thru_label ?? "-",
totalLabel: entry.total_label ?? "E",
totalIsUnderPar: false,
isLeader: entry.is_leader,
rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
}
}
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
const { label, value } = valueFor(entry)
const isLeader = rank === 1 && value !== null
@ -2229,9 +2264,16 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
{group.label}
</h3>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
</ul>
{isStrokePlay ? (
<StrokePlayLeaderboard
rows={group.entries.map(toStrokePlayRow)}
caption={showSectionHeaders ? `Resultattavle ${group.label}` : "Resultattavle"}
/>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
</ul>
)}
</div>
))}
</div>

View file

@ -0,0 +1,669 @@
"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>
)
}