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:
parent
a11c638fda
commit
074e17388b
4 changed files with 408 additions and 189 deletions
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"chrome-devtools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "chrome-devtools-mcp@latest", "--headless", "--viewport", "390x844"]
|
||||
}
|
||||
}
|
||||
}
|
||||
97
CLAUDE.md
97
CLAUDE.md
|
|
@ -3576,6 +3576,103 @@ Ferdig og verifisert:
|
|||
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt). Begge
|
||||
containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||
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`
|
||||
på `<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:
|
||||
0a. **Spillerliste-redesign — LIVE, men ikke nettleser-testet
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`§Golfscore-språket` (gjenbruker `ScoreMark`s fargespråk fra
|
||||
`round-scorecard.tsx`), pluss et systematisk `tabular-nums`/44px-
|
||||
trykkgulv-avvik-audit fikset (13 steder). Spec-dokumentets §1 (scorekort
|
||||
som fullt grid, egen skjerm) bevisst IKKE bygget — større strukturell
|
||||
endring, egen fremtidig runde. Full detalj i CLAUDE.md sin statuslogg
|
||||
(2026-07-27) — ikke duplisert her.
|
||||
trykkgulv-avvik-audit fikset (13 steder).
|
||||
|
||||
Spec-dokumentets §1 (scorekort som fullt grid) krevde et bevisst "JA" fra
|
||||
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
|
||||
på `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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -454,17 +454,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
const holeOrder = round
|
||||
? 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
|
||||
// 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() {
|
||||
const i = holeOrder.indexOf(activeHole)
|
||||
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" på 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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<HoleNav
|
||||
holes={orderedHoles}
|
||||
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
|
||||
<ScorecardGrid
|
||||
players={players}
|
||||
holesByParticipant={holesByParticipant}
|
||||
holeOrder={holeOrder}
|
||||
holesPlanned={round.holes_planned}
|
||||
currentHole={activeHole}
|
||||
isPlayed={holeIsPlayed}
|
||||
onSelect={(n) => {
|
||||
readOnly={readOnly}
|
||||
onSelectHole={(n) => {
|
||||
setCurrentHole(n)
|
||||
scrollToHolePanel()
|
||||
}}
|
||||
onOpenEntry={(playerId, holeNumber) => {
|
||||
setCurrentHole(holeNumber)
|
||||
setActivePlayerId(playerId)
|
||||
setWizardPlayerId(playerId)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Current hole panel */}
|
||||
{hole && (
|
||||
<section
|
||||
ref={holePanelRef}
|
||||
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"
|
||||
>
|
||||
{/* Høyre kolonne reserverer plass ved siden av hull-headeren
|
||||
til en fremtidig avstandsmåling-indikator (ikke bygget
|
||||
ennå, men avklart 2026-07-25 at det kommer) -- GIR-merket
|
||||
ligger under den reserverte plassen slik at et senere
|
||||
avstand-chip her ikke krever noen layout-endring. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}
|
||||
{/* Mottatte slag for AKTIV spiller på dette hullet (2026-07-26)
|
||||
-- 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-så-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)} så 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>
|
||||
)}
|
||||
</>
|
||||
{/* Rask sekvensiell hull-navigasjon -- gridet viser allerede
|
||||
alle hull som kolonner (tapp en kolonneoverskrift for å
|
||||
velge), men disse to gir en rask vei videre uten å måtte
|
||||
scrolle bredt for hvert eneste hull. */}
|
||||
<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({
|
||||
holes,
|
||||
function ScorecardGrid({
|
||||
players,
|
||||
holesByParticipant,
|
||||
holeOrder,
|
||||
holesPlanned,
|
||||
currentHole,
|
||||
isPlayed,
|
||||
onSelect,
|
||||
readOnly,
|
||||
onSelectHole,
|
||||
onOpenEntry,
|
||||
}: {
|
||||
holes: Hole[]
|
||||
players: Player[]
|
||||
holesByParticipant: Record<string, ApiHole[]>
|
||||
holeOrder: number[]
|
||||
holesPlanned: number
|
||||
currentHole: number
|
||||
isPlayed: (holeNumber: number) => boolean
|
||||
onSelect: (holeNumber: number) => void
|
||||
readOnly: boolean
|
||||
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 (
|
||||
<nav aria-label="Velg hull" className="-mx-5 mt-5 overflow-x-auto px-5">
|
||||
<div className="flex gap-2 pb-1">
|
||||
{holes.map((h) => {
|
||||
const active = h.holeNumber === currentHole
|
||||
const played = isPlayed(h.holeNumber)
|
||||
return (
|
||||
<button
|
||||
key={h.holeNumber}
|
||||
type="button"
|
||||
onClick={() => onSelect(h.holeNumber)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
aria-label={`Hull ${h.holeNumber}${played ? ", spilt" : ""}`}
|
||||
className={cn(
|
||||
"flex size-12 shrink-0 items-center justify-center rounded-2xl border text-base font-bold tabular-nums transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: played
|
||||
? "border-primary/40 bg-primary/10 text-foreground hover:bg-primary/20"
|
||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="w-full border-separate border-spacing-0 text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[130px]" />
|
||||
{holeOrder.map((n) => (
|
||||
<col key={n} className="w-[52px]" />
|
||||
))}
|
||||
{showSplit && <col className="w-[60px]" />}
|
||||
{showSplit && <col className="w-[60px]" />}
|
||||
<col className="w-[60px]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th
|
||||
scope="col"
|
||||
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"
|
||||
>
|
||||
{h.holeNumber}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
Spiller
|
||||
</th>
|
||||
{holeOrder.map((n) => {
|
||||
const active = n === currentHole
|
||||
return (
|
||||
<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 ---------------------------------------------------
|
||||
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
|
||||
// overflødig -- score settes allerede automatisk til "spilt" idet et
|
||||
|
|
@ -2697,26 +2815,6 @@ function golfTermForScore(n: number, par: number): string | 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({
|
||||
label,
|
||||
value,
|
||||
|
|
|
|||
Loading…
Reference in a new issue