Update Todos

Log in as user in chrome-devtools browser (magic link)

Screenshot scorekort-grid page at mobile viewport

Fix critical sticky-column overlap bug found via real browser test

Verify fix by scrolling grid + re-screenshotting

Assess density and golfscore-language compliance

Update CLAUDE.md/FEATURE_BACKLOG.md with the real bug+fix

Report findings to user
Good thing you asked me to actually look — I found and fixed a real, serious bug.

What was broken: position: sticky on table cells, combined with sticky columns on both the left (player names) and right (Ut/Inn/Sum) at once, rendered completely garbled in Chrome — the sticky right columns got pulled to the visible edge immediately and overlapped/bled into the hole columns underneath (you'd have seen overlapping digits like "436"/"472"). My first fix attempt (border-collapse → border-separate) didn't touch the real cause and the second screenshot still showed the same mess.

Root cause: combining sticky-left and sticky-right in one table wider than the viewport is inherently unstable — at scroll position 0, the sticky-right cells are already pinned to the screen edge, sitting visually on top of whatever hole columns happen to be there in normal document flow, only partially hidden by their translucent background.

Fix: dropped sticky positioning from the Ut/Inn/Sum columns entirely — they now scroll with the rest of the holes, same well-supported pattern as the (correctly-working) sticky name column. Verified for real this time:

Screenshot at scroll-left=0: all numbers clean, correct circle/square/plain-text score marks (bogey/double-bogey/par all visually distinct).
Scrolled the grid all the way right via a script: holes 14-18 plus Ut/Inn/Sum all render cleanly, name column still correctly pinned — Ut 36 / Inn 36 / Sum 72 on the Par row, which is exactly right for an 18-hole par-72 course.
Accessibility tree confirms the golf-term labels ("Bogey", "Dobbel bogey", "Par") are actually present in aria-labels, so §Golfscore-språket's "never color alone" rule holds in practice, not just in code.
No dead space concerns — density looks reasonable against §0.1's tolerance, and green stays reserved for active-state/under-par per §0.2 (no stray "grønt-på-grønt" spotted).

Redeployed twice (once for the failed attempt, once for the real fix); both times /health → 200, teeoff.no unaffected. Docs updated with the full story, since this is the first time this session a real browser was available to verify hand-coded frontend work — worth remembering for anything else built blind this week that you haven't clicked through yet.
This commit is contained in:
Erol Haagenrud 2026-07-27 14:34:18 +02:00
parent a11c638fda
commit 074e17388b
4 changed files with 408 additions and 189 deletions

8
.mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--headless", "--viewport", "390x844"]
}
}
}

View file

@ -3576,6 +3576,103 @@ Ferdig og verifisert:
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt). Begge også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt). Begge
containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
upåvirket. upåvirket.
- **Spec-dokumentets §1 (scorekort som fullt grid) BYGGET OG LIVE
(2026-07-27), samme dag rett etter compliance-passet:** brukeren
bekreftet eksplisitt "JA" på at grid-redesignet er en egen, bevisst
runde, egen informasjonsarkitektur enn ett-hull-om-gangen-listen
bygget dagen før. `round-detail.tsx` sin "Score"-fane fikk hull-strip
+ ett-hull-panel ERSTATTET av en ny `ScorecardGrid`: spillere som rader
(sticky venstre navnekolonne, navn+HCP, samme tap-target åpner
veiviseren for gjeldende hull), hull som horisontalt scrollbare
kolonner, `Hcp`/`Par`-referanserader over spillerradene (samme
konvensjon som den allerede shippede `round-scorecard.tsx`), sticky
`Ut`/`Inn`/`Sum` til høyre (Ut/Inn gruppert på FYSISK hullnummer 1-9/
10-18, uavhengig av øktens starthull — riktig konvensjon uansett
spillerekkefølge; kun vist for 18-hulls runder, 9-hulls runder får
én samlet Sum). Ny `ScorecardCell` gjenbruker EKSAKT samme klassifisering
og Tailwind-klasser som `ScoreMark`/`classify` i `round-scorecard.tsx`
(lest direkte, ikke gjettet) — ulikt compliance-passets softere
`rounded-2xl`-knapp-variant (fortsatt riktig der, egen visuell kontekst),
siden dette er en LITEN tabellcelle som spec eksplisitt ber om å følge
språket "UBRYTELIG".
**Interaksjon:** tapp en score-celle ELLER spillerens navnecelle ELLER
en hull-kolonneoverskrift åpner `ScoringWizard` (uendret komponent fra
v2-runden) for akkurat den (spiller, hull)-kombinasjonen — kolonne-
overskrift alene (uten å tappe en celle) setter kun "gjeldende hull"
uten å åpne veiviseren, samme jobb som den fjernede `HoleNav` gjorde.
Beholdt en kompakt "Forrige/Neste hull"-knapperad under gridet for
rask sekvensiell registrering uten bred scrolling.
**Dødt kode fjernet i samme runde:** `HoleNav`, `holeIsPlayed`,
GIR-merket/`showGir` (ga ikke lenger mening i en multi-hull-visning),
og OGSÅ compliance-passets `scoreMarkClasses`-hjelpefunksjon (var kun
brukt av den nå fjernede ett-hull-listens store runde knapp).
**Verifisert:** ekte typesjekket produksjonsbuild (samme `Dockerfile`
som deployes) kompilerte rent, alle 24 ruter listet. I TILLEGG en
engangs full runner-image bygget og kjørt i en isolert container (ikke
bare `--target builder`) — `/my-rounds/[id]` for en ukjent runde-id
ga 200, ingen React-feilgrense/krasj-markup i responsen. **Samme
ærlige begrensning som ALT håndkodet arbeid denne uken, men STØRRE
konsekvens denne gangen siden dette er en vesentlig strukturell endring
(ny informasjonsarkitektur), ikke en liten fiks:** ingen ekte
nettleser-interaksjonstest (scrolling, tapping av celler/hull-
overskrifter/navnerad, faktisk visuelt resultat av sticky-kolonnene)
er utført — flagget eksplisitt til bruker FØR utrulling, bruker bør
selv klikke seg grundig gjennom før full tillit.
**Rullet ut live 2026-07-27**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_frontend` (gjenskapte
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt). Begge
containere boot-et rent, `/health`/`/dashboard`/`/my-rounds` → 200,
`teeoff.no` upåvirket.
- **Reell produksjonsbug funnet OG fikset SAMME DAG via ekte nettleser-
testing (2026-07-27) — FØRSTE gang denne økten en Chrome DevTools MCP
har vært tilgjengelig.** Brukeren ba meg selv åpne gridet
(`localhost:3000/my-rounds/{id}`), logget meg inn (passord feilet først
— kontoen mangler passord/miljøet pekte annerledes; løst med en ekte
magic-link brukeren limte inn), og jeg tok et ekte skjermbilde av det
nettopp bygde scorekort-gridet på en mobil viewport (390×844) mot ekte
produksjonsdata (runden `c5db2e31-...`, 2 spillere, 4/18 hull spilt).
**Fant umiddelbart en alvorlig, reell rendering-bug** som ALDRI ble
fanget av typesjekking/container-boot-verifisering: `position: sticky`
`<td>`/`<th>` inni en `<table>` med `border-collapse` rendret
fullstendig ødelagt i Chrome — de sticky `Ut`/`Inn`/`Sum`-kolonnene
overlappet/utvisket hull-kolonnene bak seg (synlige sammenblandede
siffer, f.eks. "436"/"472" der Par-radens tall lå oppå hverandre).
Nøyaktig den typen feil den gjentatte "ingen ekte nettleser-test
utført"-forbeholdet advarte mot hele uken.
**Første fiks-forsøk (kun `border-collapse` → `border-separate
border-spacing-0`) løste IKKE problemet** — re-skjermbilde etter
redeploy viste samme overlapp. **Rot-årsaken var strukturell, ikke
syntaktisk:** å kombinere en sticky VENSTRE-kolonne MED sticky HØYRE-
kolonner i en tabell som er mye bredere enn viewporten er i seg selv
et ustabilt mønster — ved scroll-posisjon 0 blir de sticky høyre-
kolonnene umiddelbart trukket til synlig høyre kant, og alt som
"egentlig" befinner seg der i normal dokument-flyt (hull 3+) blir
liggende RETT BAK dem, delvis synlig gjennom `bg-muted/60`s
delvise gjennomsiktighet. **Fikset ved å fjerne sticky-posisjonering
fra `Ut`/`Inn`/`Sum`-kolonnene helt** (de scroller nå med resten av
hullene i normal flyt, samme velprøvde, veletablerte mønster som den
gjenværende sticky VENSTRE navnekolonnen, som fungerte korrekt hele
tiden) — droppet også `/60`-gjennomsiktigheten til fordel for en helt
opak `bg-muted`.
**Verifisert presist, ekte, etter fiksen** (ikke bare "ser bedre ut"):
nytt skjermbilde ved scroll-posisjon 0 viste alle tall rene og lesbare
(Hcp/Par-radene, begge spilleres scoringsceller, korrekt sirkel/firkant-
form for bogey/dobbel bogey/par), OG et script som scrollet gridet helt
til høyre (`scrollLeft = scrollWidth`) bekreftet `Ut`/`Inn`/`Sum` også
rene der (`Ut 36/Inn 36/Sum 72` på Par-raden — stemmer eksakt med en
18-hulls par-72-bane), med navnekolonnen fortsatt korrekt pinnet til
venstre gjennom hele scrollingen. Tilgjengelighetstreet (`take_snapshot`)
bekreftet også at aria-labels med golftermer ("Bogey", "Dobbel bogey",
"Par") faktisk leses ut korrekt — `§Golfscore-språket`s "aldri farge
alene"-regel holder i praksis, ikke bare i teorien.
**Rullet ut live 2026-07-27**, samme dag: `docker compose up -d --build
teecup_frontend` kjørt to ganger (én for det mislykkede første forsøket,
én for den faktiske fiksen), `/health` 200 begge ganger, `teeoff.no`
upåvirket.
**Lærdom:** Chrome DevTools MCP-tilgangen endrer risikobildet for alt
fremtidig håndkodet frontend-arbeid denne økten — bruk den til å
FAKTISK se resultatet før noe rapporteres som ferdig, i stedet for kun
typesjekk+container-boot+`curl`-baserte proxyer for "det virker".
Neste steg: Neste steg:
0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet 0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet

View file

@ -1333,10 +1333,26 @@ DAG — ✅ LIVE:** "Deg Deg"-badge-duplikat fjernet (`playerLabel()` viser nå
alltid ekte navn), scoringslistens score-knapp gjort om til å følge alltid ekte navn), scoringslistens score-knapp gjort om til å følge
`§Golfscore-språket` (gjenbruker `ScoreMark`s fargespråk fra `§Golfscore-språket` (gjenbruker `ScoreMark`s fargespråk fra
`round-scorecard.tsx`), pluss et system­atisk `tabular-nums`/44px- `round-scorecard.tsx`), pluss et system­atisk `tabular-nums`/44px-
trykkgulv-avvik-audit fikset (13 steder). Spec-dokumentets §1 (scorekort trykkgulv-avvik-audit fikset (13 steder).
som fullt grid, egen skjerm) bevisst IKKE bygget — større strukturell
endring, egen fremtidig runde. Full detalj i CLAUDE.md sin statuslogg Spec-dokumentets §1 (scorekort som fullt grid) krevde et bevisst "JA" fra
(2026-07-27) — ikke duplisert her. brukeren først (egen informasjonsarkitektur enn ett-hull-om-gangen-listen)
— bekreftet SAMME dag, og BYGGET OG RULLET UT LIVE 2026-07-27 rett etter
compliance-passet: erstattet med en scrollbar `ScorecardGrid` (spillere
som rader, hull som kolonner, sticky navnekolonne + Ut/Inn/Sum), celler
følger `§Golfscore-språket` UBRYTELIG (gjenbrukt eksakt fra
`round-scorecard.tsx`). Tapp en celle/navnerad/hull-overskrift åpner
samme `ScoringWizard` som før, nå adressert direkte fra gridet.
**Reell alvorlig rendering-bug funnet OG fikset SAMME DAG, rett etter
utrullingen, via ekte nettleser-testing** (første gang en Chrome DevTools
MCP var tilgjengelig denne økten) — `position: sticky` på tabellceller
kombinert med sticky venstre+høyre kolonner samtidig rendret fullstendig
ødelagt/overlappende i Chrome. Fikset ved å droppe sticky-posisjonering
`Ut`/`Inn`/`Sum`-kolonnene (scroller nå med resten av hullene i normal
flyt) og beholde kun den velprøvde sticky venstre navnekolonnen. Verifisert
med ekte skjermbilder + scroll-simulering mot ekte produksjonsdata. Full
detalj i CLAUDE.md sin statuslogg (2026-07-27) — ikke duplisert her.
--- ---

View file

@ -454,17 +454,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const holeOrder = round const holeOrder = round
? Array.from({ length: round.holes_planned }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1) ? Array.from({ length: round.holes_planned }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1)
: [] : []
const orderedHoles = holeOrder.map((n) => holes.find((h) => h.holeNumber === n)).filter((h): h is Hole => h !== undefined)
const currentApiHole = apiHoles?.find((h) => h.hole_number === activeHole) ?? null
const currentStat = currentApiHole ? apiHoleToStat(currentApiHole) : emptyStat()
const showGir =
currentStat.played &&
currentStat.strokes !== null &&
currentStat.putts !== null &&
hole !== null &&
currentStat.strokes - currentStat.putts <= hole.par - 2
// Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat // Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat
// nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin // nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin
@ -490,10 +479,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
})) }))
} }
function holeIsPlayed(holeNumber: number) {
return apiHoles?.find((h) => h.hole_number === holeNumber)?.played ?? false
}
function goPrev() { function goPrev() {
const i = holeOrder.indexOf(activeHole) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length]) setCurrentHole(holeOrder[(i - 1 + holeOrder.length) % holeOrder.length])
@ -847,133 +832,51 @@ export function RoundDetail({ roundId }: { roundId: string }) {
/> />
)} )}
{/* Hole navigation */} {/* Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, bygget
2026-07-27 -- erstatter den forrige rundens ett-hull-om-gangen-
liste, bruker eksplisitt bekreftet "JA" at grid-redesignet er
en egen, større runde). Spillere som rader (sticky venstre
navnekolonne), hull som horisontalt scrollbare kolonner --
HELE runden synlig samtidig, ikke bare det aktive hullet. */}
{holes.length === 0 ? ( {holes.length === 0 ? (
<div className="mt-5 flex justify-center py-8"> <div className="mt-5 flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" /> <div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div> </div>
) : ( ) : (
<> <section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
<HoleNav <ScorecardGrid
holes={orderedHoles} players={players}
holesByParticipant={holesByParticipant}
holeOrder={holeOrder}
holesPlanned={round.holes_planned}
currentHole={activeHole} currentHole={activeHole}
isPlayed={holeIsPlayed} readOnly={readOnly}
onSelect={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setCurrentHole(n)
scrollToHolePanel() scrollToHolePanel()
}} }}
onOpenEntry={(playerId, holeNumber) => {
setCurrentHole(holeNumber)
setActivePlayerId(playerId)
setWizardPlayerId(playerId)
}}
/> />
{/* Current hole panel */} {/* Rask sekvensiell hull-navigasjon -- gridet viser allerede
{hole && ( alle hull som kolonner (tapp en kolonneoverskrift for å
<section velge), men disse to gir en rask vei videre uten å måtte
ref={holePanelRef} scrolle bredt for hvert eneste hull. */}
className="mt-5 flex scroll-mt-28 flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6" <div className="grid grid-cols-2 gap-3">
> <Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
{/* Høyre kolonne reserverer plass ved siden av hull-headeren <ChevronLeft aria-hidden="true" className="size-5" />
til en fremtidig avstandsmåling-indikator (ikke bygget Forrige hull
ennå, men avklart 2026-07-25 at det kommer) -- GIR-merket </Button>
ligger under den reserverte plassen slik at et senere <Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
avstand-chip her ikke krever noen layout-endring. */} Neste hull
<div className="flex items-start justify-between gap-3"> <ChevronRight aria-hidden="true" className="size-5" />
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance"> </Button>
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index} </div>
{/* Mottatte slag for AKTIV spiller på dette hullet (2026-07-26) </section>
-- samme allerede hentede strokes_received som ScoreSoFar/
leaderboardet bruker til netto, bare aldri vist FØR
scoring her. Golfvis fortegn (ekte minustegn), kun vist
når spilleren faktisk mottar minst ett slag her. */}
{currentApiHole?.strokes_received != null && currentApiHole.strokes_received > 0 && (
<> · {currentApiHole.strokes_received}</>
)}
</h2>
<div className="flex shrink-0 flex-col items-end gap-2">
{showGir && (
<span className="rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
GIR
</span>
)}
</div>
</div>
{/* Registrering (2026-07-26, inspirert av en konkurrentapps
samlebåndsflyt): én kompakt rad per spiller med
akkumulert score--langt for RUNDEN alltid synlig, og en
trykkflate som åpner en fokusert, skjermovertagende
veiviser for AKKURAT denne spilleren og hullet -- i
stedet for ett langt skjema man skrollet gjennom for én
spiller om gangen, med manuell fanebytting mellom
spillere (rapportert av bruker 2026-07-26 som "ingen
forbedring", "visuelt like overveldende og rotete"). */}
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border">
{players.map((player) => {
const playerHoles = holesByParticipant[player.id]
const playerApiHole = playerHoles?.find((h) => h.hole_number === activeHole) ?? null
const hasScore = Boolean(playerApiHole?.played) && playerApiHole?.score !== null
const running = computeRunningToPar(playerHoles)
// Golfscore-språket (2026-07-27, compliance-pass): sirkel
// under par / rundet firkant over par / nøytral sirkel på
// par -- ALDRI samme grønne fylte sirkel uansett resultat
// (bogey og dobbel bogey skal ikke se ut som birdie).
const diff = hasScore && hole ? (playerApiHole!.score as number) - hole.par : null
return (
<li key={player.id} className="flex items-center gap-3 p-3 sm:p-4">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
{player.isSelf && (
<Badge variant="default" className="shrink-0">
Deg
</Badge>
)}
</span>
<span className="text-sm text-muted-foreground">
HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
{running && (
<>
{" · "}
{formatToPar(running.toPar)} langt ({running.holesPlayed} hull)
</>
)}
</span>
</span>
<button
type="button"
disabled={readOnly}
onClick={() => {
setActivePlayerId(player.id)
setWizardPlayerId(player.id)
}}
aria-label={
hasScore
? `Rediger ${player.name} sin registrering på hull ${activeHole}, ${playerApiHole?.score} slag${diff !== null && hole ? `, ${golfTermForScore(playerApiHole!.score as number, hole.par) ?? ""}` : ""}`
: `Registrer score for ${player.name} på hull ${activeHole}`
}
className={cn(
"flex size-14 shrink-0 items-center justify-center border-2 text-xl font-extrabold tabular-nums transition-colors disabled:opacity-60",
scoreMarkClasses(diff),
)}
>
{hasScore ? playerApiHole?.score : ""}
</button>
</li>
)
})}
</ul>
<div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige hull
</Button>
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
Neste hull
<ChevronRight aria-hidden="true" className="size-5" />
</Button>
</div>
</section>
)}
</>
)} )}
</> </>
)} )}
@ -2630,50 +2533,265 @@ function ChangeCourseForm({
) )
} }
// --- Hole navigation ------------------------------------------------------- // --- Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, 2026-07-27) ---
// Spillere = rader (sticky venstre navnekolonne), hull = horisontalt
// scrollbare kolonner (sticky Ut/Inn/Sum til høyre) -- erstatter den
// forrige ett-hull-om-gangen-listen. Tapp en kolonneoverskrift for å velge
// "gjeldende" hull (kun navigasjon), tapp en spillers navnecelle eller en
// score-celle for å åpne `ScoringWizard` for AKKURAT den (spiller, hull)-
// kombinasjonen -- samme veiviser som før, bare adressert direkte fra
// gridet i stedet for via ett enkelt "aktivt hull"-panel.
// Par/stroke-indeks er samme fysiske hull for alle spillere -- hentes fra
// HVILKEN SOM HELST allerede lastet spillers hull-liste (ikke bare aktiv
// spiller), robust mot i hvilken rekkefølge deltakernes hull lastes inn.
function HoleNav({ function ScorecardGrid({
holes, players,
holesByParticipant,
holeOrder,
holesPlanned,
currentHole, currentHole,
isPlayed, readOnly,
onSelect, onSelectHole,
onOpenEntry,
}: { }: {
holes: Hole[] players: Player[]
holesByParticipant: Record<string, ApiHole[]>
holeOrder: number[]
holesPlanned: number
currentHole: number currentHole: number
isPlayed: (holeNumber: number) => boolean readOnly: boolean
onSelect: (holeNumber: number) => void onSelectHole: (holeNumber: number) => void
onOpenEntry: (playerId: string, holeNumber: number) => void
}) { }) {
const holeMeta: Record<number, { par: number; index: number }> = {}
for (const list of Object.values(holesByParticipant)) {
for (const h of list) {
if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
}
}
// Ut(1-9)/Inn(10-18) er alltid gruppert på FYSISK hullnummer (tradisjonell
// scorekort-konvensjon), ikke på spillerekkefølgen -- riktig uansett
// hvilket starthull runden faktisk begynte på. Kun meningsfullt å splitte
// for en 18-hulls runde; en 9-hulls runde får kun én samlet Sum-kolonne.
const showSplit = holesPlanned === 18
const front = holeOrder.filter((n) => n <= 9)
const back = holeOrder.filter((n) => n >= 10)
function sumFor(playerId: string, numbers: number[]): { strokes: number; par: number } | null {
const list = holesByParticipant[playerId]
if (!list || numbers.length === 0) return null
const played = numbers
.map((n) => list.find((h) => h.hole_number === n))
.filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null)
if (played.length === 0) return null
return {
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
par: played.reduce((sum, h) => sum + h.par, 0),
}
}
const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)
return ( return (
<nav aria-label="Velg hull" className="-mx-5 mt-5 overflow-x-auto px-5"> <div className="overflow-x-auto rounded-2xl border border-border">
<div className="flex gap-2 pb-1"> <table className="w-full border-separate border-spacing-0 text-sm">
{holes.map((h) => { <colgroup>
const active = h.holeNumber === currentHole <col className="w-[130px]" />
const played = isPlayed(h.holeNumber) {holeOrder.map((n) => (
return ( <col key={n} className="w-[52px]" />
<button ))}
key={h.holeNumber} {showSplit && <col className="w-[60px]" />}
type="button" {showSplit && <col className="w-[60px]" />}
onClick={() => onSelect(h.holeNumber)} <col className="w-[60px]" />
aria-current={active ? "true" : undefined} </colgroup>
aria-label={`Hull ${h.holeNumber}${played ? ", spilt" : ""}`} <thead>
className={cn( <tr className="border-b border-border">
"flex size-12 shrink-0 items-center justify-center rounded-2xl border text-base font-bold tabular-nums transition-colors", <th
active scope="col"
? "border-primary bg-primary text-primary-foreground" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
: played
? "border-primary/40 bg-primary/10 text-foreground hover:bg-primary/20"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
> >
{h.holeNumber} Spiller
</button> </th>
) {holeOrder.map((n) => {
})} const active = n === currentHole
</div> return (
</nav> <th key={n} scope="col" className="border-l border-border p-0">
<button
type="button"
onClick={() => onSelectHole(n)}
aria-current={active ? "true" : undefined}
aria-label={`Velg hull ${n}`}
className={cn(
"flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
</th>
)
})}
{showSplit && (
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
Ut
</th>
)}
{showSplit && (
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
Inn
</th>
)}
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
Sum
</th>
</tr>
</thead>
<tbody>
{/* Hcp- og Par-referanserader (samme mønster som den allerede
shippede round-scorecard.tsx) -- rene informasjonsrader, ikke
trykkbare. */}
<tr className="border-b border-border">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
Hcp
</th>
{holeOrder.map((n) => (
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
{holeMeta[n]?.index ?? ""}
</td>
))}
{showSplit && <td className="border-l border-border bg-muted" />}
{showSplit && <td className="border-l border-border bg-muted" />}
<td className="border-l border-border bg-muted" />
</tr>
<tr className="border-b-2 border-border">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
Par
</th>
{holeOrder.map((n) => (
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
{holeMeta[n]?.par ?? ""}
</td>
))}
{showSplit && (
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
{front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || ""}
</td>
)}
{showSplit && (
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
{back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || ""}
</td>
)}
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
{parRowTotal || ""}
</td>
</tr>
{players.map((player) => {
const list = holesByParticipant[player.id]
const frontSum = showSplit ? sumFor(player.id, front) : null
const backSum = showSplit ? sumFor(player.id, back) : null
const totalSum = sumFor(player.id, holeOrder)
return (
<tr key={player.id} className="border-b border-border last:border-b-0">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
<button
type="button"
disabled={readOnly}
onClick={() => onOpenEntry(player.id, currentHole)}
aria-label={`Registrer score for ${player.name} på hull ${currentHole}`}
className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
>
<span className="flex items-center gap-1.5">
<span className="truncate text-sm font-bold text-foreground">{player.name}</span>
{player.isSelf && (
<Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
Deg
</Badge>
)}
</span>
<span className="truncate text-xs tabular-nums text-muted-foreground">
HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : ""}
</span>
</button>
</th>
{holeOrder.map((n) => {
const apiHole = list?.find((h) => h.hole_number === n) ?? null
const meta = holeMeta[n]
const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
const net =
hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null
return (
<td key={n} className="border-l border-border p-0 text-center">
<button
type="button"
disabled={readOnly}
onClick={() => onOpenEntry(player.id, n)}
aria-label={
hasScore
? `${player.name}, hull ${n}, ${apiHole?.score} slag${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}`
: `Registrer ${player.name} sin score på hull ${n}`
}
className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
>
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
{net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>}
</button>
</td>
)
})}
{showSplit && (
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
{frontSum ? frontSum.strokes : ""}
</td>
)}
{showSplit && (
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
{backSum ? backSum.strokes : ""}
</td>
)}
<td className="border-l border-border bg-muted px-1 py-1 text-center">
{totalSum ? (
<span className="flex flex-col items-center leading-tight">
<span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
{formatToPar(totalSum.strokes - totalSum.par)}
</span>
</span>
) : (
""
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) )
} }
// Golfscore-språket for en LITEN tabellcelle -- samme eksakte klassifisering
// og Tailwind-klasser som `ScoreMark`/`classify` i round-scorecard.tsx
// (bekreftet ved å lese kildekoden der direkte, egen kopi her per
// prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se
// DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over
// par, ren tekst på par, fylt ved 2+ slag fra par.
function ScorecardCell({ value, diff }: { value: number | null; diff: number | null }) {
const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
if (value === null) return <span className={cn(shared, "text-muted-foreground")}></span>
if (diff === null || diff === 0) return <span className={cn(shared, "font-bold text-foreground")}>{value}</span>
if (diff <= -2) return <span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{value}</span>
if (diff === -1) return <span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>{value}</span>
if (diff === 1) {
return <span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange")}>{value}</span>
}
return <span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>{value}</span>
}
// --- Statistikknivå-velger --------------------------------------------------- // --- Statistikknivå-velger ---------------------------------------------------
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt // "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
// overflødig -- score settes allerede automatisk til "spilt" idet et // overflødig -- score settes allerede automatisk til "spilt" idet et
@ -2697,26 +2815,6 @@ function golfTermForScore(n: number, par: number): string | null {
return null return null
} }
// Golfscore-språket (§Golfscore-språket i DESIGN_SYSTEM.md, håndhevet
// eksplisitt av teecup-scorekort-og-entry-spec.md §0.3/§3): form encoder
// retning (sirkel = under par, "firkant" = over par) slik at betydningen
// overlever selv uten farge; fylt vs. border+10%-tint encoder omfang (fylt =
// 2 slag eller mer fra par). Samme klassifisering som `ScoreMark` i
// round-scorecard.tsx -- egen kopi her siden denne knappen (size-14,
// trykkbar) trenger andre grunnklasser enn scorekortets små tabell-merker,
// men SAMME fargespråk (2026-07-27, compliance-pass). Bevisst `rounded-2xl`
// for "over par" i stedet for scorekortets `rounded-[4px]` -- fortsatt
// tydelig IKKE en sirkel, men matcher denne skjermens øvrige store
// trykkflate-avrunding (NumberPicker/hull-panel bruker samme radius).
function scoreMarkClasses(diff: number | null): string {
if (diff === null) return "rounded-full border-dashed border-border text-muted-foreground hover:border-primary/60 hover:text-primary"
if (diff <= -2) return "rounded-full border-primary bg-primary text-primary-foreground"
if (diff === -1) return "rounded-full border-primary bg-primary/10 text-primary"
if (diff === 0) return "rounded-full border-border bg-card text-foreground"
if (diff === 1) return "rounded-2xl border-brand-orange bg-brand-orange/10 text-brand-orange"
return "rounded-2xl border-brand-orange bg-brand-orange text-brand-orange-foreground"
}
function NumberPicker({ function NumberPicker({
label, label,
value, value,