From 50653303f4c6c866ff802072ed90a89979a9ec5f Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 4 Aug 2026 04:47:27 +0200 Subject: [PATCH] =?UTF-8?q?Alt=20er=20scratch-verifisert=20grundig=20?= =?UTF-8?q?=E2=80=94=20API=20og=20ekte=20nettleser,=20b=C3=A5de=20p=C3=A5?= =?UTF-8?q?=20390px=20mobil=20og=20en=201920px=20"storskjerm".=20F=C3=B8r?= =?UTF-8?q?=20jeg=20deployer:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/routers/individual_tournaments.py | 221 +++++- .../individual-tournament-detail.tsx | 48 +- .../components/stroke-play-leaderboard.tsx | 669 ++++++++++++++++++ 3 files changed, 923 insertions(+), 15 deletions(-) create mode 100644 frontend/components/stroke-play-leaderboard.tsx diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index b7a250a..c798ec4 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -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 diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index aef0647..56fe4e7 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -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} )} - + {isStrokePlay ? ( + + ) : ( + + )} ))} diff --git a/frontend/components/stroke-play-leaderboard.tsx b/frontend/components/stroke-play-leaderboard.tsx new file mode 100644 index 0000000..33dd2a6 --- /dev/null +++ b/frontend/components/stroke-play-leaderboard.tsx @@ -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 = { + 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(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(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 ( +
+ {/* Control bar (part of the component, not page chrome) */} +
+ + {!reducedMotion && ( + + )} +
+ + {/* 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. */} +
+ + + + + + + + {roundNumbers.map((n) => ( + + ))} + + + + + + + + + + {roundNumbers.map((n) => ( + + ))} + + + + + {/* Pinned leader row(s) — stick beneath the header, never in the ticker. */} + {leaders.map((row, i) => ( + + ))} + + {field.length === 0 && ( + + + + )} + + {field.map((row, i) => ( + + ))} + +
+ POS (posisjon) + + PLAYER (spiller) + + TODAY (i dag) + + THRU (hull spilt) + + TOTAL (totalt mot par) + + R{n} + (runde {n}) +
+ Ingen spillere i feltet ennå. +
+
+
+ ) +} + +// --- Legend ---------------------------------------------------------------- + +function Legend() { + return ( +
    +
  • + + Under par +
  • +
  • + + Over par +
  • +
  • + + Par +
  • +
+ ) +} + +// --- Header cell ----------------------------------------------------------- + +function Th({ + children, + className, + sticky, +}: { + children: React.ReactNode + className?: string + sticky?: "pos" | "player" +}) { + return ( + + {children} + + ) +} + +// --- 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 ( + + {/* POS + trophy (sticky both top and left, above field + own row) */} + +
+
+ + + {/* PLAYER */} + + + + + + + + + + {roundNumbers.map((n) => { + const cell = row.rounds.find((c) => c.roundNumber === n) + return + })} + + ) +} + +// --- 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 ( + + + {row.position} + + + + {row.playerName} + + + + + + + + {roundNumbers.map((n) => { + const cell = row.rounds.find((c) => c.roundNumber === n) + return + })} + + ) +} + +// --- 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 ( + + {label == null ? ( + + – + + ) : ( + {label} + )} + + ) +} + +// --- 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 ( + + {notStarted ? ( + + ) : finished ? ( + + F (ferdig) + + ) : ( + + {thru} + hull spilt + + )} + + ) +} + +// --- 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 ( + + {!played ? ( + + – + + ) : 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. + + {cell!.label} + par + + ) : ( + + {cell!.label} + {cell!.tone === "under" ? " under par" : " over par"} + + )} + + ) +}