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.
669 lines
21 KiB
TypeScript
669 lines
21 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||
import { Flag, Pause, Play, Trophy } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
// --- Types -----------------------------------------------------------------
|
||
|
||
export type RoundCell = {
|
||
roundNumber: number // 1, 2, 3, or 4
|
||
label: string // "70", "38 p" (Stableford) — or "" if not yet played
|
||
// "even" is its own state (not folded into "over") -- matches this app's
|
||
// established Golfscore-språket (DESIGN_SYSTEM.md): E gets plain neutral
|
||
// text, no circle/square frame, never the over-par treatment.
|
||
tone: "under" | "even" | "over" | null // null = not yet played, render as an empty/dashed cell
|
||
}
|
||
|
||
export type LeaderboardRow = {
|
||
position: string // "1", "T2", "T5" — already formatted, ties included
|
||
playerName: string
|
||
todayLabel: string | null // "-2", "+4", "E", "38 p" — null = today's round not started yet
|
||
todayIsUnderPar: boolean | null
|
||
thruLabel: string // "F" (finished), "14" (holes played), "-" (not started)
|
||
totalLabel: string // "-13", "+7", "146 p"
|
||
totalIsUnderPar: boolean
|
||
isLeader: boolean // true for every row sharing the current lead position (ties possible)
|
||
rounds: RoundCell[] // one entry per round played SO FAR (1-4 entries, not always 4)
|
||
}
|
||
|
||
// --- Mock seed data --------------------------------------------------------
|
||
// A par-72, 4-round event that is currently 2 rounds in (round 2 live), so the
|
||
// table only shows R1 + R2 columns. Mix of finished / mid-round / not-started,
|
||
// with a tie for the lead (two T1 rows).
|
||
|
||
const t = (n: string): RoundCell["label"] => n
|
||
function completed(roundNumber: number, label: string, tone: "under" | "even" | "over"): RoundCell {
|
||
return { roundNumber, label, tone }
|
||
}
|
||
|
||
export const MOCK_ROWS: LeaderboardRow[] = [
|
||
{
|
||
position: "T1",
|
||
playerName: "Ingrid Berg",
|
||
todayLabel: "-5",
|
||
todayIsUnderPar: true,
|
||
thruLabel: "F",
|
||
totalLabel: "-8",
|
||
totalIsUnderPar: true,
|
||
isLeader: true,
|
||
rounds: [completed(1, t("69"), "under"), completed(2, t("67"), "under")],
|
||
},
|
||
{
|
||
position: "T1",
|
||
playerName: "Sofie Dahl",
|
||
todayLabel: "-2",
|
||
todayIsUnderPar: true,
|
||
thruLabel: "F",
|
||
totalLabel: "-8",
|
||
totalIsUnderPar: true,
|
||
isLeader: true,
|
||
rounds: [completed(1, t("66"), "under"), completed(2, t("70"), "under")],
|
||
},
|
||
{
|
||
position: "3",
|
||
playerName: "Anna Ruud",
|
||
todayLabel: "-5",
|
||
todayIsUnderPar: true,
|
||
thruLabel: "14",
|
||
totalLabel: "-7",
|
||
totalIsUnderPar: true,
|
||
isLeader: false,
|
||
// Round 2 in progress -> only R1 completed.
|
||
rounds: [completed(1, t("70"), "under")],
|
||
},
|
||
{
|
||
position: "4",
|
||
playerName: "Kari Holt",
|
||
todayLabel: "-4",
|
||
todayIsUnderPar: true,
|
||
thruLabel: "12",
|
||
totalLabel: "-5",
|
||
totalIsUnderPar: true,
|
||
isLeader: false,
|
||
rounds: [completed(1, t("71"), "under")],
|
||
},
|
||
{
|
||
position: "5",
|
||
playerName: "Mette Lie",
|
||
todayLabel: "+1",
|
||
todayIsUnderPar: false,
|
||
thruLabel: "16",
|
||
totalLabel: "-3",
|
||
totalIsUnderPar: true,
|
||
isLeader: false,
|
||
rounds: [completed(1, t("68"), "under")],
|
||
},
|
||
{
|
||
position: "6",
|
||
playerName: "Bjørg Sund",
|
||
todayLabel: "-1",
|
||
todayIsUnderPar: true,
|
||
thruLabel: "F",
|
||
totalLabel: "-2",
|
||
totalIsUnderPar: true,
|
||
isLeader: false,
|
||
rounds: [completed(1, t("71"), "under"), completed(2, t("72"), "even")],
|
||
},
|
||
{
|
||
position: "7",
|
||
playerName: "Live Aas",
|
||
todayLabel: "E",
|
||
todayIsUnderPar: null,
|
||
thruLabel: "9",
|
||
totalLabel: "+1",
|
||
totalIsUnderPar: false,
|
||
isLeader: false,
|
||
rounds: [completed(1, t("73"), "over")],
|
||
},
|
||
{
|
||
position: "8",
|
||
playerName: "Randi Vik",
|
||
todayLabel: null, // round 2 not started yet
|
||
todayIsUnderPar: null,
|
||
thruLabel: "-",
|
||
totalLabel: "+2",
|
||
totalIsUnderPar: false,
|
||
isLeader: false,
|
||
rounds: [completed(1, t("74"), "over")],
|
||
},
|
||
{
|
||
position: "9",
|
||
playerName: "Tuva Moen",
|
||
todayLabel: "+3",
|
||
todayIsUnderPar: false,
|
||
thruLabel: "6",
|
||
totalLabel: "+6",
|
||
totalIsUnderPar: false,
|
||
isLeader: false,
|
||
rounds: [completed(1, t("75"), "over")],
|
||
},
|
||
]
|
||
|
||
// --- Helpers ---------------------------------------------------------------
|
||
|
||
type Tone = "under" | "over" | "even" | "none"
|
||
|
||
// Determine tone from a signed label so meaning never relies on color alone.
|
||
function toneFromLabel(label: string | null): Tone {
|
||
if (label == null) return "none"
|
||
const v = label.trim()
|
||
if (v === "") return "none"
|
||
if (v.startsWith("-")) return "under"
|
||
if (v.startsWith("+")) return "over"
|
||
return "even" // "E", "38 p", "146 p"
|
||
}
|
||
|
||
const toneText: Record<Tone, string> = {
|
||
under: "text-primary",
|
||
over: "text-brand-orange",
|
||
even: "text-foreground",
|
||
none: "text-muted-foreground",
|
||
}
|
||
|
||
function usePrefersReducedMotion() {
|
||
const [reduced, setReduced] = useState(false)
|
||
useEffect(() => {
|
||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
|
||
setReduced(mq.matches)
|
||
const onChange = () => setReduced(mq.matches)
|
||
mq.addEventListener("change", onChange)
|
||
return () => mq.removeEventListener("change", onChange)
|
||
}, [])
|
||
return reduced
|
||
}
|
||
|
||
// --- Column geometry (shared widths so header/leaders/body align) ----------
|
||
|
||
const COL = {
|
||
pos: "w-12 lg:w-24",
|
||
player: "min-w-[8.5rem] lg:min-w-[16rem]",
|
||
today: "w-16 lg:w-28",
|
||
thru: "w-14 lg:w-24",
|
||
total: "w-20 lg:w-36",
|
||
round: "w-14 lg:w-24",
|
||
}
|
||
// Sticky-left offset for the PLAYER column must equal the POS column width.
|
||
const PLAYER_LEFT = "left-12 lg:left-24"
|
||
|
||
const cellPad = "px-2 py-3 lg:px-5 lg:py-5"
|
||
|
||
// --- Component -------------------------------------------------------------
|
||
|
||
export function StrokePlayLeaderboard({
|
||
rows = MOCK_ROWS,
|
||
caption = "Resultattavle",
|
||
}: {
|
||
rows?: LeaderboardRow[]
|
||
caption?: string
|
||
}) {
|
||
const reducedMotion = usePrefersReducedMotion()
|
||
const [playing, setPlaying] = useState(false)
|
||
|
||
const leaders = useMemo(() => rows.filter((r) => r.isLeader), [rows])
|
||
const field = useMemo(() => rows.filter((r) => !r.isLeader), [rows])
|
||
const roundCount = useMemo(
|
||
() => rows.reduce((max, r) => Math.max(max, ...r.rounds.map((c) => c.roundNumber), 0), 0),
|
||
[rows],
|
||
)
|
||
const roundNumbers = useMemo(
|
||
() => Array.from({ length: roundCount }, (_, i) => i + 1),
|
||
[roundCount],
|
||
)
|
||
const totalCols = 5 + roundCount
|
||
|
||
// Measure the header height so pinned leader rows stick right beneath it.
|
||
const theadRef = useRef<HTMLTableSectionElement>(null)
|
||
const [headTop, setHeadTop] = useState(0)
|
||
useLayoutEffect(() => {
|
||
const el = theadRef.current
|
||
if (!el) return
|
||
const update = () => setHeadTop(el.getBoundingClientRect().height)
|
||
update()
|
||
const ro = new ResizeObserver(update)
|
||
ro.observe(el)
|
||
return () => ro.disconnect()
|
||
}, [])
|
||
|
||
// Auto-scroll ticker over the non-leader field.
|
||
const scrollRef = useRef<HTMLDivElement>(null)
|
||
useEffect(() => {
|
||
if (!playing || reducedMotion) return
|
||
const el = scrollRef.current
|
||
if (!el) return
|
||
let raf = 0
|
||
let last = performance.now()
|
||
const speed = 26 // px per second
|
||
const tick = (now: number) => {
|
||
const dt = Math.min((now - last) / 1000, 0.05)
|
||
last = now
|
||
const max = el.scrollHeight - el.clientHeight
|
||
if (max > 1) {
|
||
let next = el.scrollTop + speed * dt
|
||
if (next >= max) next = 0 // loop back to top of field
|
||
el.scrollTop = next
|
||
}
|
||
raf = requestAnimationFrame(tick)
|
||
}
|
||
raf = requestAnimationFrame(tick)
|
||
return () => cancelAnimationFrame(raf)
|
||
}, [playing, reducedMotion])
|
||
|
||
// Any manual gesture on the list pauses the ticker (does not react to the
|
||
// programmatic scrollTop writes above).
|
||
useEffect(() => {
|
||
const el = scrollRef.current
|
||
if (!el) return
|
||
const pause = () => setPlaying(false)
|
||
const opts: AddEventListenerOptions = { passive: true }
|
||
el.addEventListener("wheel", pause, opts)
|
||
el.addEventListener("touchstart", pause, opts)
|
||
el.addEventListener("pointerdown", pause)
|
||
el.addEventListener("keydown", pause)
|
||
return () => {
|
||
el.removeEventListener("wheel", pause, opts)
|
||
el.removeEventListener("touchstart", pause, opts)
|
||
el.removeEventListener("pointerdown", pause)
|
||
el.removeEventListener("keydown", pause)
|
||
}
|
||
}, [])
|
||
|
||
return (
|
||
<section
|
||
aria-label={caption}
|
||
className="flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm lg:rounded-3xl"
|
||
>
|
||
{/* Control bar (part of the component, not page chrome) */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-background px-3 py-3 lg:px-5 lg:py-4">
|
||
<Legend />
|
||
{!reducedMotion && (
|
||
<Button
|
||
type="button"
|
||
variant={playing ? "secondary" : "outline"}
|
||
size="lg"
|
||
onClick={() => setPlaying((p) => !p)}
|
||
aria-pressed={playing}
|
||
className="h-11 gap-2 px-4 text-sm lg:h-14 lg:px-6 lg:text-base"
|
||
>
|
||
{playing ? (
|
||
<Pause aria-hidden="true" className="size-4 lg:size-5" />
|
||
) : (
|
||
<Play aria-hidden="true" className="size-4 lg:size-5" />
|
||
)}
|
||
{playing ? "Stopp rulling" : "Rull automatisk"}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* One scroll container drives both frozen-column horizontal scroll (narrow
|
||
screens) and the vertical ticker. On large "storskjerm" viewports the
|
||
cap is removed so the whole board fits with no scrolling. */}
|
||
<div
|
||
ref={scrollRef}
|
||
tabIndex={0}
|
||
aria-label="Resultatliste"
|
||
className="relative max-h-[24rem] overflow-auto outline-none md:max-h-[32rem] lg:max-h-none lg:overflow-visible"
|
||
>
|
||
<table className="w-full border-separate border-spacing-0 text-left tabular-nums">
|
||
<colgroup>
|
||
<col className={COL.pos} />
|
||
<col className={COL.player} />
|
||
<col className={COL.today} />
|
||
<col className={COL.thru} />
|
||
<col className={COL.total} />
|
||
{roundNumbers.map((n) => (
|
||
<col key={n} className={COL.round} />
|
||
))}
|
||
</colgroup>
|
||
|
||
<thead ref={theadRef}>
|
||
<tr className="bg-foreground text-background">
|
||
<Th sticky="pos" className="z-50 text-center">
|
||
POS<span className="sr-only"> (posisjon)</span>
|
||
</Th>
|
||
<Th sticky="player" className="z-50 text-left">
|
||
PLAYER<span className="sr-only"> (spiller)</span>
|
||
</Th>
|
||
<Th className="text-right">
|
||
TODAY<span className="sr-only"> (i dag)</span>
|
||
</Th>
|
||
<Th className="text-right">
|
||
THRU<span className="sr-only"> (hull spilt)</span>
|
||
</Th>
|
||
<Th className="text-right">
|
||
TOTAL<span className="sr-only"> (totalt mot par)</span>
|
||
</Th>
|
||
{roundNumbers.map((n) => (
|
||
<Th key={n} className="text-right">
|
||
R{n}
|
||
<span className="sr-only"> (runde {n})</span>
|
||
</Th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
|
||
<tbody>
|
||
{/* Pinned leader row(s) — stick beneath the header, never in the ticker. */}
|
||
{leaders.map((row, i) => (
|
||
<LeaderRow
|
||
key={`leader-${row.playerName}`}
|
||
row={row}
|
||
roundNumbers={roundNumbers}
|
||
top={headTop}
|
||
isLast={i === leaders.length - 1}
|
||
/>
|
||
))}
|
||
|
||
{field.length === 0 && (
|
||
<tr>
|
||
<td
|
||
colSpan={totalCols}
|
||
className="px-4 py-8 text-center text-sm text-muted-foreground"
|
||
>
|
||
Ingen spillere i feltet ennå.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
|
||
{field.map((row, i) => (
|
||
<FieldRow
|
||
key={`field-${row.playerName}`}
|
||
row={row}
|
||
roundNumbers={roundNumbers}
|
||
striped={i % 2 === 1}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// --- Legend ----------------------------------------------------------------
|
||
|
||
function Legend() {
|
||
return (
|
||
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs font-medium text-muted-foreground lg:text-sm">
|
||
<li className="flex items-center gap-1.5">
|
||
<span
|
||
aria-hidden="true"
|
||
className="flex size-5 items-center justify-center rounded-full bg-primary/15 text-[10px] font-bold text-primary lg:size-6"
|
||
>
|
||
−
|
||
</span>
|
||
Under par
|
||
</li>
|
||
<li className="flex items-center gap-1.5">
|
||
<span
|
||
aria-hidden="true"
|
||
className="flex size-5 items-center justify-center rounded-md bg-brand-orange/15 text-[10px] font-bold text-brand-orange lg:size-6"
|
||
>
|
||
+
|
||
</span>
|
||
Over par
|
||
</li>
|
||
<li className="flex items-center gap-1.5">
|
||
<span
|
||
aria-hidden="true"
|
||
className="flex size-5 items-center justify-center rounded-md text-[10px] font-bold text-foreground lg:size-6"
|
||
>
|
||
E
|
||
</span>
|
||
Par
|
||
</li>
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
// --- Header cell -----------------------------------------------------------
|
||
|
||
function Th({
|
||
children,
|
||
className,
|
||
sticky,
|
||
}: {
|
||
children: React.ReactNode
|
||
className?: string
|
||
sticky?: "pos" | "player"
|
||
}) {
|
||
return (
|
||
<th
|
||
scope="col"
|
||
className={cn(
|
||
cellPad,
|
||
"sticky top-0 z-40 bg-foreground text-[0.7rem] font-extrabold uppercase tracking-wider text-background lg:text-base",
|
||
sticky === "pos" && "left-0",
|
||
sticky === "player" && PLAYER_LEFT,
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</th>
|
||
)
|
||
}
|
||
|
||
// --- Leader row (pinned) ---------------------------------------------------
|
||
|
||
function LeaderRow({
|
||
row,
|
||
roundNumbers,
|
||
top,
|
||
isLast,
|
||
}: {
|
||
row: LeaderboardRow
|
||
roundNumbers: number[]
|
||
top: number
|
||
isLast: boolean
|
||
}) {
|
||
const stickyStyle = { top }
|
||
const rowBg = "bg-gold text-gold-foreground"
|
||
const border = isLast ? "border-b-2 border-gold-foreground/30" : "border-b border-gold-foreground/15"
|
||
|
||
return (
|
||
<tr className={rowBg}>
|
||
{/* POS + trophy (sticky both top and left, above field + own row) */}
|
||
<td
|
||
style={stickyStyle}
|
||
className={cn(cellPad, rowBg, border, "sticky left-0 z-30 text-center align-middle")}
|
||
>
|
||
<div className="flex items-center justify-center gap-1.5">
|
||
<Trophy aria-hidden="true" className="size-4 shrink-0 lg:size-6" />
|
||
<span className="text-sm font-extrabold lg:text-3xl">{row.position}</span>
|
||
<span className="sr-only">, leder</span>
|
||
</div>
|
||
</td>
|
||
|
||
{/* PLAYER */}
|
||
<td
|
||
style={stickyStyle}
|
||
className={cn(cellPad, rowBg, border, "sticky z-30 text-left", PLAYER_LEFT)}
|
||
>
|
||
<span className="flex items-center gap-2 text-sm font-extrabold lg:text-2xl">
|
||
<Flag aria-hidden="true" className="size-3.5 shrink-0 lg:size-5" />
|
||
<span className="truncate">{row.playerName}</span>
|
||
</span>
|
||
</td>
|
||
|
||
<ValueTd style={stickyStyle} rowBg={rowBg} border={border} label={row.todayLabel} emphasis="today" leader />
|
||
<ThruTd style={stickyStyle} rowBg={rowBg} border={border} thru={row.thruLabel} leader />
|
||
<ValueTd style={stickyStyle} rowBg={rowBg} border={border} label={row.totalLabel} emphasis="total" leader />
|
||
|
||
{roundNumbers.map((n) => {
|
||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||
return <RoundTd key={n} style={stickyStyle} rowBg={rowBg} border={border} cell={cell} leader />
|
||
})}
|
||
</tr>
|
||
)
|
||
}
|
||
|
||
// --- Field row -------------------------------------------------------------
|
||
|
||
function FieldRow({
|
||
row,
|
||
roundNumbers,
|
||
striped,
|
||
}: {
|
||
row: LeaderboardRow
|
||
roundNumbers: number[]
|
||
striped: boolean
|
||
}) {
|
||
const rowBg = striped ? "bg-muted/40" : "bg-card"
|
||
const border = "border-b border-border"
|
||
|
||
return (
|
||
<tr className={rowBg}>
|
||
<td className={cn(cellPad, rowBg, border, "sticky left-0 z-10 text-center")}>
|
||
<span className="text-sm font-bold text-foreground lg:text-2xl">{row.position}</span>
|
||
</td>
|
||
<td className={cn(cellPad, rowBg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
|
||
<span className="block truncate text-sm font-semibold text-foreground lg:text-xl">
|
||
{row.playerName}
|
||
</span>
|
||
</td>
|
||
|
||
<ValueTd rowBg={rowBg} border={border} label={row.todayLabel} emphasis="today" />
|
||
<ThruTd rowBg={rowBg} border={border} thru={row.thruLabel} />
|
||
<ValueTd rowBg={rowBg} border={border} label={row.totalLabel} emphasis="total" />
|
||
|
||
{roundNumbers.map((n) => {
|
||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||
return <RoundTd key={n} rowBg={rowBg} border={border} cell={cell} />
|
||
})}
|
||
</tr>
|
||
)
|
||
}
|
||
|
||
// --- Value cell (TODAY / TOTAL) --------------------------------------------
|
||
|
||
function ValueTd({
|
||
label,
|
||
emphasis,
|
||
rowBg,
|
||
border,
|
||
style,
|
||
leader,
|
||
}: {
|
||
label: string | null
|
||
emphasis: "today" | "total"
|
||
rowBg: string
|
||
border: string
|
||
style?: React.CSSProperties
|
||
leader?: boolean
|
||
}) {
|
||
const tone = toneFromLabel(label)
|
||
const isTotal = emphasis === "total"
|
||
|
||
// On the gold leader band, keep values readable on gold rather than tinting
|
||
// them green/orange (which fails contrast on gold); the sign still encodes tone.
|
||
const color = leader ? "text-gold-foreground" : toneText[tone]
|
||
|
||
const size = isTotal
|
||
? "text-base font-extrabold lg:text-4xl"
|
||
: "text-sm font-bold lg:text-2xl"
|
||
|
||
return (
|
||
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||
{label == null ? (
|
||
<span className="text-sm text-muted-foreground lg:text-xl" aria-label="ikke startet">
|
||
–
|
||
</span>
|
||
) : (
|
||
<span className={cn(size, color)}>{label}</span>
|
||
)}
|
||
</td>
|
||
)
|
||
}
|
||
|
||
// --- THRU cell -------------------------------------------------------------
|
||
|
||
function ThruTd({
|
||
thru,
|
||
rowBg,
|
||
border,
|
||
style,
|
||
leader,
|
||
}: {
|
||
thru: string
|
||
rowBg: string
|
||
border: string
|
||
style?: React.CSSProperties
|
||
leader?: boolean
|
||
}) {
|
||
const finished = thru === "F"
|
||
const notStarted = thru === "-"
|
||
const color = leader ? "text-gold-foreground" : "text-foreground"
|
||
|
||
return (
|
||
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||
{notStarted ? (
|
||
<span className="text-sm text-muted-foreground lg:text-xl">–</span>
|
||
) : finished ? (
|
||
<span className={cn("text-sm font-bold lg:text-xl", color)}>
|
||
F<span className="sr-only"> (ferdig)</span>
|
||
</span>
|
||
) : (
|
||
<span className={cn("text-sm font-semibold lg:text-xl", leader ? "text-gold-foreground" : "text-muted-foreground")}>
|
||
{thru}
|
||
<span className="sr-only"> hull spilt</span>
|
||
</span>
|
||
)}
|
||
</td>
|
||
)
|
||
}
|
||
|
||
// --- Round cell (Rn) -------------------------------------------------------
|
||
// Shape encodes tone in addition to color: under-par = round pill, over-par =
|
||
// square pill — so meaning survives without color perception.
|
||
|
||
function RoundTd({
|
||
cell,
|
||
rowBg,
|
||
border,
|
||
style,
|
||
leader,
|
||
}: {
|
||
cell?: RoundCell
|
||
rowBg: string
|
||
border: string
|
||
style?: React.CSSProperties
|
||
leader?: boolean
|
||
}) {
|
||
const played = cell && cell.label !== "" && cell.tone !== null
|
||
|
||
return (
|
||
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||
{!played ? (
|
||
<span className="text-sm text-muted-foreground lg:text-xl" aria-label="ikke spilt">
|
||
–
|
||
</span>
|
||
) : cell!.tone === "even" ? (
|
||
// "E" er alltid ren tekst, ingen ramme -- samme regel som resten av
|
||
// appens Golfscore-språk (DESIGN_SYSTEM.md), aldri over-par-behandling.
|
||
<span className={cn("text-sm font-bold lg:text-lg", leader ? "text-gold-foreground" : "text-foreground")}>
|
||
{cell!.label}
|
||
<span className="sr-only"> par</span>
|
||
</span>
|
||
) : (
|
||
<span
|
||
className={cn(
|
||
"inline-flex min-w-8 items-center justify-center px-1.5 py-0.5 text-sm font-bold lg:min-w-12 lg:px-2 lg:py-1 lg:text-lg",
|
||
// Shape encodes tone (circle=under, square=over) so meaning survives
|
||
// without color. On the gold leader band, use a solid opaque chip so
|
||
// the low-opacity tint doesn't wash out against gold.
|
||
cell!.tone === "under" ? "rounded-full" : "rounded-md",
|
||
leader
|
||
? cn("bg-card", cell!.tone === "under" ? "text-primary" : "text-brand-orange")
|
||
: cell!.tone === "under"
|
||
? "bg-primary/15 text-primary"
|
||
: "bg-brand-orange/15 text-brand-orange",
|
||
)}
|
||
>
|
||
{cell!.label}
|
||
<span className="sr-only">{cell!.tone === "under" ? " under par" : " over par"}</span>
|
||
</span>
|
||
)}
|
||
</td>
|
||
)
|
||
}
|