V0-redesign av StrokePlayLeaderboard integrert
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 <noreply@anthropic.com>
This commit is contained in:
parent
eeb7e9fb29
commit
985dada178
2 changed files with 390 additions and 348 deletions
49
CHANGELOG.md
49
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.
|
||||
|
|
|
|||
|
|
@ -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<Tone, string> = {
|
||||
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<HTMLTableSectionElement>(null)
|
||||
const [headTop, setHeadTop] = useState(0)
|
||||
const leaderRefs = useRef<(HTMLTableRowElement | null)[]>([])
|
||||
const [leaderTops, setLeaderTops] = useState<number[]>([])
|
||||
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<HTMLDivElement>(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<HTMLDivElement>(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 (
|
||||
<section
|
||||
aria-label={caption}
|
||||
className="flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm lg:rounded-3xl"
|
||||
className="flex w-full flex-col overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-card shadow-xl lg:rounded-[1.75rem]"
|
||||
>
|
||||
{/* 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>
|
||||
{/* Masthead — the public "face" of the tournament */}
|
||||
<header className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4 bg-tee-strong px-4 py-4 text-clubhouse-bg sm:px-6 xl:px-10 xl:py-6">
|
||||
<div className="flex items-center gap-3 xl:gap-4">
|
||||
<span className="flex size-9 items-center justify-center rounded-xl bg-clubhouse-bg/15 xl:size-14">
|
||||
<Flag aria-hidden="true" className="size-5 xl:size-8" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("size-2.5 rounded-full bg-cup xl:size-3", scrolling && "animate-pulse")}
|
||||
/>
|
||||
<span className="text-xs font-bold uppercase tracking-[0.2em] xl:text-base">Live</span>
|
||||
</div>
|
||||
<h2 className="text-pretty text-xl font-black leading-tight tracking-tight xl:text-4xl">
|
||||
{caption}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* One scroll container drives both frozen-column horizontal scroll (narrow
|
||||
screens) and the vertical ticker. RETTET 2026-08-18 (brukerrapport:
|
||||
"autoscroller ikke") -- `lg:max-h-none lg:overflow-visible` fjernet
|
||||
taket HELT på store skjermer, ut fra en antakelse om at hele feltet
|
||||
alltid ville få plass der. For et stort felt (mange spillere) stemte
|
||||
ikke det -- containeren fikk aldri noe å skrolle PÅ, så
|
||||
"Rull automatisk" vekslet kun knappe-tilstanden uten synlig
|
||||
bevegelse. Nå: et vindu-relativt tak på ALLE bredder (aldri
|
||||
`overflow-visible`) -- et lite felt får uansett plass innenfor taket
|
||||
(ingen skrolling da heller, samme sluttresultat som før), et stort
|
||||
felt får en ekte skrollbar flate autoscroll faktisk kan bevege. */}
|
||||
<div className="flex items-center gap-4 xl:gap-6">
|
||||
<Legend />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={scrolling}
|
||||
disabled={reducedMotion || !canScroll}
|
||||
className="inline-flex min-h-12 items-center gap-2 rounded-xl bg-clubhouse-bg px-4 text-sm font-bold text-tee-strong shadow-sm transition-transform hover:opacity-95 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-clubhouse-bg/60 disabled:cursor-not-allowed disabled:opacity-50 xl:min-h-16 xl:gap-3 xl:px-7 xl:text-lg"
|
||||
>
|
||||
{scrolling ? (
|
||||
<Pause aria-hidden="true" className="size-5 xl:size-7" />
|
||||
) : (
|
||||
<Play aria-hidden="true" className="size-5 xl:size-7" />
|
||||
)}
|
||||
{scrolling ? "Stopp rulling" : "Rull automatisk"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
tabIndex={0}
|
||||
aria-label="Resultatliste"
|
||||
className="relative max-h-[24rem] overflow-auto outline-none md:max-h-[32rem] lg:max-h-[calc(100vh-18rem)]"
|
||||
className="relative max-h-[62vh] overflow-auto outline-none"
|
||||
>
|
||||
<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} />
|
||||
<col className={COL_POS} />
|
||||
<col className="min-w-[9rem] xl:min-w-[20rem]" />
|
||||
<col className="w-16 xl:w-32" />
|
||||
<col className="w-14 xl:w-28" />
|
||||
<col className="w-20 xl:w-40" />
|
||||
{roundNumbers.map((n) => (
|
||||
<col key={n} className={COL.round} />
|
||||
<col key={n} className="w-14 xl:w-24" />
|
||||
))}
|
||||
</colgroup>
|
||||
|
||||
<thead ref={theadRef}>
|
||||
<tr className="bg-foreground text-background">
|
||||
<tr className="bg-clubhouse-ink text-clubhouse-bg">
|
||||
<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>
|
||||
SPILLER
|
||||
</Th>
|
||||
<Th className="text-right">
|
||||
TODAY<span className="sr-only"> (i dag)</span>
|
||||
I DAG<span className="sr-only"> (dagens runde)</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>
|
||||
HULL<span className="sr-only"> (hull spilt)</span>
|
||||
</Th>
|
||||
<Th className="text-right">TOTALT</Th>
|
||||
{roundNumbers.map((n) => (
|
||||
<Th key={n} className="text-right">
|
||||
R{n}
|
||||
|
|
@ -352,35 +313,33 @@ export function StrokePlayLeaderboard({
|
|||
</thead>
|
||||
|
||||
<tbody>
|
||||
{/* 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) => (
|
||||
<LeaderRow
|
||||
key={`leader-${row.playerName}`}
|
||||
row={row}
|
||||
roundNumbers={roundNumbers}
|
||||
top={headTop}
|
||||
top={leaderTops[i] ?? 0}
|
||||
isLast={i === leaders.length - 1}
|
||||
rowRef={(el) => {
|
||||
leaderRefs.current[i] = el
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{field.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={totalCols}
|
||||
className="px-4 py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
<td colSpan={totalCols} className="px-4 py-10 text-center text-clubhouse-muted">
|
||||
Ingen spillere i feltet ennå.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* A future "cut line" divider row can be inserted between any two
|
||||
FieldRows here without restructuring. */}
|
||||
{field.map((row, i) => (
|
||||
<FieldRow
|
||||
key={`field-${row.playerName}`}
|
||||
row={row}
|
||||
roundNumbers={roundNumbers}
|
||||
striped={i % 2 === 1}
|
||||
/>
|
||||
<FieldRow key={`field-${row.playerName}`} row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -393,30 +352,21 @@ export function StrokePlayLeaderboard({
|
|||
|
||||
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">
|
||||
<ul className="hidden flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold sm:flex xl:text-base">
|
||||
<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 aria-hidden="true" className="flex size-5 items-center justify-center rounded-full bg-clubhouse-bg/20 text-[11px] font-black xl:size-7 xl:text-sm">
|
||||
−
|
||||
</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 aria-hidden="true" className="flex size-5 items-center justify-center rounded-md bg-clubhouse-bg/20 text-[11px] font-black xl:size-7 xl:text-sm">
|
||||
+
|
||||
</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"
|
||||
>
|
||||
<span aria-hidden="true" className="flex size-5 items-center justify-center text-[11px] font-black xl:size-7 xl:text-sm">
|
||||
E
|
||||
</span>
|
||||
Par
|
||||
|
|
@ -441,7 +391,7 @@ function 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 top-0 z-40 bg-clubhouse-ink py-3 text-[0.7rem] font-black uppercase tracking-wider text-clubhouse-bg xl:text-lg",
|
||||
sticky === "pos" && "left-0",
|
||||
sticky === "player" && PLAYER_LEFT,
|
||||
className,
|
||||
|
|
@ -452,55 +402,49 @@ function Th({
|
|||
)
|
||||
}
|
||||
|
||||
// --- Leader row (pinned) ---------------------------------------------------
|
||||
// --- Leader row (pinned, gold) ---------------------------------------------
|
||||
|
||||
function LeaderRow({
|
||||
row,
|
||||
roundNumbers,
|
||||
top,
|
||||
isLast,
|
||||
rowRef,
|
||||
}: {
|
||||
row: LeaderboardRow
|
||||
roundNumbers: number[]
|
||||
top: number
|
||||
isLast: boolean
|
||||
rowRef?: (el: HTMLTableRowElement | null) => void
|
||||
}) {
|
||||
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"
|
||||
const style = { top }
|
||||
const bg = "bg-gold text-gold-foreground"
|
||||
const border = isLast ? "border-b-4 border-tee-strong/40" : "border-b border-gold-foreground/20"
|
||||
|
||||
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")}
|
||||
>
|
||||
<tr ref={rowRef} className={bg}>
|
||||
<td style={style} className={cn(cellPad, bg, 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>
|
||||
<Trophy aria-hidden="true" className="size-4 shrink-0 xl:size-7" />
|
||||
<span className="text-lg font-black xl:text-4xl">{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 style={style} className={cn(cellPad, bg, border, "sticky z-30 text-left", PLAYER_LEFT)}>
|
||||
<span className="block truncate text-lg font-black xl:text-4xl">{row.playerName}</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 />
|
||||
{/* TODAY + rounds keep the shape but wear a neutral gold-foreground so
|
||||
they stay legible on gold in both themes. */}
|
||||
<ValueTd style={style} bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} onGold size="today" />
|
||||
<ThruTd style={style} bg={bg} border={border} thru={row.thruLabel} onGold />
|
||||
{/* TOTAL is the marquee: a solid, colored chip that pops even on gold. */}
|
||||
<ValueTd style={style} bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" solid />
|
||||
|
||||
{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 />
|
||||
return <RoundTd key={n} style={style} bg={bg} border={border} cell={cell} onGold />
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
|
|
@ -517,68 +461,132 @@ function FieldRow({
|
|||
roundNumbers: number[]
|
||||
striped: boolean
|
||||
}) {
|
||||
const rowBg = striped ? "bg-muted/40" : "bg-card"
|
||||
const border = "border-b border-border"
|
||||
const status = isStatus(row.totalLabel)
|
||||
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
|
||||
const border = "border-b border-clubhouse-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>
|
||||
<tr className={cn(bg, status && "opacity-70")}>
|
||||
<td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}>
|
||||
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{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">
|
||||
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
|
||||
<span className={cn("block truncate text-base font-semibold xl:text-2xl", status ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
|
||||
{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} />
|
||||
})}
|
||||
{status ? (
|
||||
<>
|
||||
<DashTd bg={bg} border={border} />
|
||||
<DashTd bg={bg} border={border} />
|
||||
<td className={cn(cellPad, bg, border, "text-right")}>
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-sm font-black uppercase tracking-wide text-clubhouse-muted xl:text-xl">
|
||||
{row.totalLabel}
|
||||
</span>
|
||||
</td>
|
||||
{roundNumbers.map((n) => {
|
||||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
|
||||
<ThruTd bg={bg} border={border} thru={row.thruLabel} />
|
||||
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
|
||||
{roundNumbers.map((n) => {
|
||||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Score chip (the shared golf visual language) --------------------------
|
||||
// under = circle (rounded-full), over = rounded-square, even = plain text (no
|
||||
// frame ever), none = dash. `solid` = filled emphasis; `onGold` = neutral
|
||||
// outline treatment for the gold leader band. Shape carries meaning without
|
||||
// color, so it survives for color-blind viewers and at a distance.
|
||||
|
||||
function ScoreChip({
|
||||
label,
|
||||
tone,
|
||||
size,
|
||||
solid,
|
||||
onGold,
|
||||
}: {
|
||||
label: string
|
||||
tone: Tone
|
||||
size: "today" | "total"
|
||||
solid?: boolean
|
||||
onGold?: boolean
|
||||
}) {
|
||||
const dims =
|
||||
size === "total"
|
||||
? "min-w-[3.25rem] px-2.5 py-1 text-lg font-black xl:min-w-[5.5rem] xl:px-4 xl:py-2 xl:text-4xl"
|
||||
: "min-w-[2.75rem] px-2 py-1 text-sm font-bold xl:min-w-[4rem] xl:px-3 xl:text-2xl"
|
||||
|
||||
if (tone === "even") {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center justify-center", dims, onGold ? "text-gold-foreground" : "text-clubhouse-ink")}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const shape = tone === "over" ? "rounded-md" : "rounded-full"
|
||||
let skin: string
|
||||
if (onGold) {
|
||||
skin = "border border-gold-foreground/45 bg-gold-foreground/5 text-gold-foreground"
|
||||
} else if (solid) {
|
||||
skin = tone === "over" ? "bg-cup-strong text-clubhouse-bg" : "bg-tee-strong text-clubhouse-bg"
|
||||
} else {
|
||||
skin =
|
||||
tone === "over"
|
||||
? "bg-cup-strong/12 text-cup-strong ring-1 ring-inset ring-cup-strong/35"
|
||||
: "bg-tee-strong/12 text-tee-strong ring-1 ring-inset ring-tee-strong/35"
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex items-center justify-center", dims, shape, skin)}>
|
||||
{label}
|
||||
<span className="sr-only">{tone === "under" ? " under par" : " over par"}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Value cell (TODAY / TOTAL) --------------------------------------------
|
||||
|
||||
function ValueTd({
|
||||
label,
|
||||
emphasis,
|
||||
rowBg,
|
||||
tone,
|
||||
size,
|
||||
bg,
|
||||
border,
|
||||
style,
|
||||
leader,
|
||||
solid,
|
||||
onGold,
|
||||
}: {
|
||||
label: string | null
|
||||
emphasis: "today" | "total"
|
||||
rowBg: string
|
||||
tone: Tone
|
||||
size: "today" | "total"
|
||||
bg: string
|
||||
border: string
|
||||
style?: React.CSSProperties
|
||||
leader?: boolean
|
||||
solid?: boolean
|
||||
onGold?: 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">
|
||||
<td style={style} className={cn(cellPad, bg, border, "text-right", (solid || onGold) && "sticky z-20")}>
|
||||
{label == null || tone === "none" ? (
|
||||
<span className="text-base text-clubhouse-muted xl:text-2xl" aria-label="ikke startet">
|
||||
–
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(size, color)}>{label}</span>
|
||||
<ScoreChip label={label} tone={tone} size={size} solid={solid} onGold={onGold} />
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
|
|
@ -588,31 +596,32 @@ function ValueTd({
|
|||
|
||||
function ThruTd({
|
||||
thru,
|
||||
rowBg,
|
||||
bg,
|
||||
border,
|
||||
style,
|
||||
leader,
|
||||
onGold,
|
||||
}: {
|
||||
thru: string
|
||||
rowBg: string
|
||||
bg: string
|
||||
border: string
|
||||
style?: React.CSSProperties
|
||||
leader?: boolean
|
||||
onGold?: boolean
|
||||
}) {
|
||||
const finished = thru === "F"
|
||||
const notStarted = thru === "-"
|
||||
const color = leader ? "text-gold-foreground" : "text-foreground"
|
||||
const ink = onGold ? "text-gold-foreground" : "text-clubhouse-ink"
|
||||
const muted = onGold ? "text-gold-foreground/70" : "text-clubhouse-muted"
|
||||
|
||||
return (
|
||||
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||||
<td style={style} className={cn(cellPad, bg, border, "text-right", onGold && "sticky z-20")}>
|
||||
{notStarted ? (
|
||||
<span className="text-sm text-muted-foreground lg:text-xl">–</span>
|
||||
<span className="text-base text-clubhouse-muted xl:text-2xl">–</span>
|
||||
) : finished ? (
|
||||
<span className={cn("text-sm font-bold lg:text-xl", color)}>
|
||||
<span className={cn("text-base font-bold xl:text-2xl", ink)}>
|
||||
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")}>
|
||||
<span className={cn("text-base font-semibold xl:text-2xl", muted)}>
|
||||
{thru}
|
||||
<span className="sr-only"> hull spilt</span>
|
||||
</span>
|
||||
|
|
@ -622,56 +631,40 @@ function ThruTd({
|
|||
}
|
||||
|
||||
// --- 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,
|
||||
bg,
|
||||
border,
|
||||
style,
|
||||
leader,
|
||||
onGold,
|
||||
}: {
|
||||
cell?: RoundCell
|
||||
rowBg: string
|
||||
bg: string
|
||||
border: string
|
||||
style?: React.CSSProperties
|
||||
leader?: boolean
|
||||
onGold?: boolean
|
||||
}) {
|
||||
const played = cell && cell.label !== "" && cell.tone !== null
|
||||
|
||||
const tone = roundTone(cell)
|
||||
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">
|
||||
<td style={style} className={cn(cellPad, bg, border, "text-right", onGold && "sticky z-20")}>
|
||||
{tone === "none" ? (
|
||||
<span className="text-base text-clubhouse-muted xl:text-2xl" 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>
|
||||
<ScoreChip label={cell!.label} tone={tone} size="today" onGold={onGold} />
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Not-applicable dash cell (status rows) --------------------------------
|
||||
|
||||
function DashTd({ bg, border }: { bg: string; border: string }) {
|
||||
return (
|
||||
<td className={cn(cellPad, bg, border, "text-right")}>
|
||||
<span className="text-base text-clubhouse-muted xl:text-2xl">–</span>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue