From 985dada178d217dfdb3ebf1fb2350edca9c3f1ac Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 18 Aug 2026 13:49:12 +0200 Subject: [PATCH] V0-redesign av StrokePlayLeaderboard integrert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rent innbytte -- identisk props-kontrakt. Løser bredde/autoscroll-bugene fra forrige runde mer robust (ekte ResizeObserver-måling i stedet for en brytningspunkt-antakelse), autostarter rulling for ubetjent kiosk-visning, og legger til dempet visning for WD/DQ/DNS/NR/CUT-rader. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 49 ++ .../components/stroke-play-leaderboard.tsx | 689 +++++++++--------- 2 files changed, 390 insertions(+), 348 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7499ef0..ce33e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12439,4 +12439,53 @@ Neste steg: bevisst utsatt til en kommende V0-redesign av samme komponent (samme brukerforespørsel, se eget notat/prompt levert samme dag). + **Overtatt av punkt 112 samme dag** -- V0-redesignet under løser + bredde/autoscroll på egen, bedre måte (reell måling i stedet for + brytningspunkt-antakelse), denne håndfiksen er ren mellomstasjon. + +112. **V0-redesign av StrokePlayLeaderboard mottatt og integrert — + 2026-08-18.** V0-prompten fra punkt 111 kjørt av bruker, full + app-eksport lastet opp til Temp-uploads. Kun `components/stroke- + play-leaderboard.tsx` hentet ut (samme mønster som tidligere V0- + rundetter denne økten) -- eksporten traff props-kontrakten + (`LeaderboardRow`/`RoundCell`/`StrokePlayLeaderboard`) helt + eksakt, identisk eksportert overflate som før (kun `rows`/ + `caption` gjort valgfrie med defaults, ikke et brudd siden + kallestedet uansett alltid sender begge eksplisitt) -- rent + innbytte, ingen endring nødvendig i `individual-tournament- + detail.tsx`. + + **Løste bredde-/autoscroll-bugsene fra punkt 111 på en mer + robust måte** enn hånd-fiksen: `canScroll` måles nå med en ekte + `ResizeObserver` (`scrollHeight - clientHeight > 4`) i stedet for + en antatt brytningspunkt-grense, og skrolleregionen bruker ett + `max-h-[62vh]` på ALLE bredder (ingen `lg:`-spesialtilfelle i det + hele tatt) -- autoscroll fungerer nå garantert når feltet faktisk + er for stort, uansett skjermbredde. Andre kvalitetstillegg utover + det som ble bedt om: autoscroll STARTER automatisk (`playing: + true` som default, riktig for et ubetjent kiosk-oppsett), en + myk paus ved bunnen før den hopper tilbake til toppen (900ms + hold), og gjenkjenning av status-etiketter (WD/DQ/DNS/NR/CUT) som + gir hele raden en dempet, "–"-fylt visning -- går lenger enn + "cut-linje senere"-hintet i prompten. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Full + scratch-runde (database+API+next dev) med et rikt, syntetisk felt + (22 deltakere, 2 runder, ulik fremdrift) på en 1920×1080 + "storskjerm"-visning -- bekreftet full breddeutfylling, ekte + golfscore-språk (sirkel/firkant/ren E-tekst), lederrad fastholdt + øverst i gull med troféikon mens feltet under faktisk beveget seg + (to skjermbilder tatt med mellomrom, ulike rader synlige -- ekte + bevegelse, ikke bare en veksling av knappe-tilstand). Lys+mørk + bekreftet, smal mobilbredde bekreftet (sticky POS/SPILLER, + horisontal skrolling for resten). Scratch-stacken revet ned. + + **Lærdom fra egen scratch-seeding (ikke en app-bug):** rå + `INSERT`-er i `tournament_round_hole` alene holder IKKE -- + leaderboard-spørringen leser fra `tournament_round_score` (et + per-runde-aggregat), som normalt fylles som en bieffekt av den + ekte score-lagrings-API-en. Måtte etterfylle aggregat-tabellen + manuelt for at scratch-dataen skulle vise seg riktig -- verdt å + huske for fremtidig scratch-seeding av individuelle turneringer. + **Rullet ut:** venter på bekreftelse. diff --git a/frontend/components/stroke-play-leaderboard.tsx b/frontend/components/stroke-play-leaderboard.tsx index f160927..5075b2a 100644 --- a/frontend/components/stroke-play-leaderboard.tsx +++ b/frontend/components/stroke-play-leaderboard.tsx @@ -1,19 +1,15 @@ "use client" -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { useCallback, 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 ----------------------------------------------------------------- +// --- Types (exact contract — do not change) -------------------------------- export type RoundCell = { roundNumber: number // 1, 2, 3, or 4 label: string // "70", "38 p" (Stableford) — or "" if not yet played - // "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 + tone: "under" | "even" | "over" | null // null = not yet played, render empty/dashed } export type LeaderboardRow = { @@ -25,141 +21,65 @@ export type LeaderboardRow = { 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) + 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). +// --- Mock data ------------------------------------------------------------- +// Par-72, 4-round event, currently 2 rounds in (round 2 live) so only R1 + R2 +// columns show. Mix of finished / in-progress / not-started, a genuine tie for +// the lead (two T1), and a withdrawal (WD). -const t = (n: string): RoundCell["label"] => n -function completed(roundNumber: number, label: string, tone: "under" | "even" | "over"): RoundCell { - return { roundNumber, label, tone } +function rc(roundNumber: number, score: number): RoundCell { + return { + roundNumber, + label: String(score), + tone: score < 72 ? "under" : score > 72 ? "over" : "even", + } } export const MOCK_ROWS: LeaderboardRow[] = [ - { - position: "T1", - playerName: "Ingrid Berg", - todayLabel: "-5", - todayIsUnderPar: true, - thruLabel: "F", - totalLabel: "-8", - totalIsUnderPar: true, - isLeader: true, - rounds: [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")], - }, + { position: "T1", playerName: "Ingrid Berg", todayLabel: "-5", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-8", totalIsUnderPar: true, isLeader: true, rounds: [rc(1, 69), rc(2, 67)] }, + { position: "T1", playerName: "Sofie Dahl", todayLabel: "-2", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-8", totalIsUnderPar: true, isLeader: true, rounds: [rc(1, 66), rc(2, 70)] }, + { position: "3", playerName: "Anna Ruud", todayLabel: "-5", todayIsUnderPar: true, thruLabel: "14", totalLabel: "-7", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 70)] }, + { position: "4", playerName: "Kari Holt", todayLabel: "-4", todayIsUnderPar: true, thruLabel: "12", totalLabel: "-5", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 71)] }, + { position: "T5", playerName: "Live Solberg", todayLabel: "-3", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-3", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 74), rc(2, 67)] }, + { position: "T5", playerName: "Mette Lie", todayLabel: "+1", todayIsUnderPar: false, thruLabel: "16", totalLabel: "-3", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 68)] }, + { position: "7", playerName: "Bjørg Sund", todayLabel: "-1", todayIsUnderPar: true, thruLabel: "F", totalLabel: "-2", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 71), rc(2, 71)] }, + { position: "8", playerName: "Nora Eide", todayLabel: "E", todayIsUnderPar: false, thruLabel: "9", totalLabel: "-1", totalIsUnderPar: true, isLeader: false, rounds: [rc(1, 72)] }, + { position: "9", playerName: "Hanna Vik", todayLabel: "+2", todayIsUnderPar: false, thruLabel: "11", totalLabel: "E", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 72)] }, + { position: "10", playerName: "Randi Aas", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "+2", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 74)] }, + { position: "T11", playerName: "Tuva Moen", todayLabel: "+3", todayIsUnderPar: false, thruLabel: "6", totalLabel: "+4", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 73)] }, + { position: "T11", playerName: "Elin Haug", todayLabel: "+1", todayIsUnderPar: false, thruLabel: "F", totalLabel: "+4", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 75), rc(2, 73)] }, + { position: "13", playerName: "Silje Rø", todayLabel: "+5", todayIsUnderPar: false, thruLabel: "8", totalLabel: "+7", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 74)] }, + { position: "14", playerName: "Maja Lund", todayLabel: "+2", todayIsUnderPar: false, thruLabel: "F", totalLabel: "+9", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 79), rc(2, 74)] }, + { position: "15", playerName: "Frida Ness", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "+11", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 83)] }, + { position: "–", playerName: "Kaia Strand", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "WD", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 76)] }, ] -// --- Helpers --------------------------------------------------------------- +// --- Tone helpers ---------------------------------------------------------- -type Tone = "under" | "over" | "even" | "none" +type Tone = "under" | "even" | "over" | "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 STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT"]) +function isStatus(label: string) { + return STATUS.has(label.trim().toUpperCase()) } -const toneText: Record = { - under: "text-primary", - over: "text-brand-orange", - even: "text-foreground", - none: "text-muted-foreground", +// TODAY / TOTAL only carry an isUnderPar boolean, so derive even-vs-over from +// the label sign — meaning must never rest on color alone. +function toneFrom(isUnder: boolean | null, label: string | null): Tone { + if (isUnder == null || label == null) return "none" + const v = label.trim() + if (v === "") return "none" + if (isUnder) return "under" + if (v.toUpperCase() === "E") return "even" + if (v.startsWith("+")) return "over" + return "even" // stableford-style non-negative that isn't flagged under +} + +function roundTone(cell: RoundCell | undefined): Tone { + if (!cell || !cell.label || cell.tone == null) return "none" + return cell.tone } function usePrefersReducedMotion() { @@ -167,27 +87,20 @@ function usePrefersReducedMotion() { 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) + const on = () => setReduced(mq.matches) + mq.addEventListener("change", on) + return () => mq.removeEventListener("change", on) }, []) return reduced } -// --- Column geometry (shared widths so header/leaders/body align) ---------- +// --- Column geometry (shared so header / leaders / body align) ------------- +// POS + PLAYER stay frozen on the left while round columns scroll sideways on +// narrow screens; the sticky-left offset of PLAYER must equal the POS width. -const COL = { - pos: "w-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" +const COL_POS = "w-14 sm:w-16 xl:w-28" +const PLAYER_LEFT = "left-14 sm:left-16 xl:left-28" +const cellPad = "px-3 py-4 sm:px-4 xl:px-6 xl:py-6" // --- Component ------------------------------------------------------------- @@ -199,59 +112,97 @@ export function StrokePlayLeaderboard({ caption?: string }) { const reducedMotion = usePrefersReducedMotion() - const [playing, setPlaying] = useState(false) + const [playing, setPlaying] = useState(true) + const [canScroll, setCanScroll] = useState(false) const leaders = useMemo(() => rows.filter((r) => r.isLeader), [rows]) const field = useMemo(() => rows.filter((r) => !r.isLeader), [rows]) const roundCount = useMemo( - () => rows.reduce((max, r) => Math.max(max, ...r.rounds.map((c) => c.roundNumber), 0), 0), + () => rows.reduce((m, r) => Math.max(m, ...r.rounds.map((c) => c.roundNumber), 0), 0), [rows], ) - const roundNumbers = useMemo( - () => Array.from({ length: roundCount }, (_, i) => i + 1), - [roundCount], - ) + const 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. + // Measure header height + each leader row height so tied leaders stack + // sequentially beneath the header instead of piling on the same offset. const theadRef = useRef(null) - const [headTop, setHeadTop] = useState(0) + const leaderRefs = useRef<(HTMLTableRowElement | null)[]>([]) + const [leaderTops, setLeaderTops] = useState([]) useLayoutEffect(() => { - const el = theadRef.current + const thead = theadRef.current + const recompute = () => { + const headH = thead ? thead.getBoundingClientRect().height : 0 + const tops: number[] = [] + let acc = headH + for (let i = 0; i < leaders.length; i++) { + tops[i] = acc + acc += leaderRefs.current[i]?.getBoundingClientRect().height ?? 0 + } + setLeaderTops(tops) + } + recompute() + const ro = new ResizeObserver(recompute) + if (thead) ro.observe(thead) + leaderRefs.current.forEach((el) => el && ro.observe(el)) + return () => ro.disconnect() + }, [leaders.length]) + + // Track whether the field actually overflows the viewport region so the + // control reflects reality (and so we never silently "disable" the ticker + // just because it happens to fit at one size). + const scrollRef = useRef(null) + const posRef = useRef(0) + useLayoutEffect(() => { + const el = scrollRef.current if (!el) return - const update = () => setHeadTop(el.getBoundingClientRect().height) - update() - const ro = new ResizeObserver(update) + const measure = () => setCanScroll(el.scrollHeight - el.clientHeight > 4) + measure() + const ro = new ResizeObserver(measure) ro.observe(el) return () => ro.disconnect() - }, []) + }, [rows]) - // Auto-scroll ticker over the non-leader field. - const scrollRef = useRef(null) + // Continuous, smooth vertical autoscroll using a float accumulator (avoids + // the integer-rounding stutter of reading scrollTop back each frame). useEffect(() => { - if (!playing || reducedMotion) return + if (!playing || reducedMotion || !canScroll) return const el = scrollRef.current if (!el) return let raf = 0 let last = performance.now() - const speed = 26 // px per second + posRef.current = el.scrollTop + const SPEED = 34 // px / second + const PAUSE_AT_ENDS = 900 // ms hold at top before looping + let holdUntil = 0 const tick = (now: number) => { const dt = Math.min((now - last) / 1000, 0.05) last = now const max = el.scrollHeight - el.clientHeight - if (max > 1) { - let next = el.scrollTop + speed * dt - if (next >= max) next = 0 // loop back to top of field - el.scrollTop = next + if (max > 1 && now >= holdUntil) { + let next = posRef.current + SPEED * dt + if (next >= max) { + next = max + el.scrollTop = next + posRef.current = 0 + holdUntil = now + PAUSE_AT_ENDS + // brief hold at the bottom, then jump back to the top to loop + window.setTimeout(() => { + if (scrollRef.current) scrollRef.current.scrollTop = 0 + }, PAUSE_AT_ENDS / 2) + } else { + posRef.current = next + el.scrollTop = next + } } raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => cancelAnimationFrame(raf) - }, [playing, reducedMotion]) + }, [playing, reducedMotion, canScroll]) - // Any manual gesture on the list pauses the ticker (does not react to the - // programmatic scrollTop writes above). + // Any manual gesture pauses instantly (programmatic scrollTop writes above + // don't fire these events, so they won't self-pause). useEffect(() => { const el = scrollRef.current if (!el) return @@ -269,79 +220,89 @@ export function StrokePlayLeaderboard({ } }, []) + const toggle = useCallback(() => setPlaying((p) => !p), []) + const scrolling = playing && !reducedMotion && canScroll + return (
- {/* Control bar (part of the component, not page chrome) */} -
- - {!reducedMotion && ( - - )} -
+ {/* Masthead — the public "face" of the tournament */} +
+
+ + +
+
+
+

+ {caption} +

+
+
- {/* 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. */} +
+ + +
+
+ + {/* Single scroll region drives both the horizontal frozen-column scroll + (narrow screens) and the vertical kiosk ticker. `relative` gives the + sr-only spans a clipped containing block so they can't widen the page. */}
- - - - - + + + + + {roundNumbers.map((n) => ( - + ))} - + - + {roundNumbers.map((n) => ( - {/* Pinned leader row(s) — stick beneath the header, never in the ticker. */} + {/* Pinned leader row(s) — always visible beneath the header, never + inside the ticker, so the winner reads from across the room. */} {leaders.map((row, i) => ( { + leaderRefs.current[i] = el + }} /> ))} {field.length === 0 && ( - )} + {/* A future "cut line" divider row can be inserted between any two + FieldRows here without restructuring. */} {field.map((row, i) => ( - + ))}
POS (posisjon) - PLAYER (spiller) + SPILLER - TODAY (i dag) + I DAG (dagens runde) - THRU (hull spilt) - - TOTAL (totalt mot par) + HULL (hull spilt) TOTALT R{n} @@ -352,35 +313,33 @@ export function StrokePlayLeaderboard({
+ Ingen spillere i feltet ennå.
@@ -393,30 +352,21 @@ export function StrokePlayLeaderboard({ function Legend() { return ( -
    +
    • -
    • -
    • -