Åtte brukerrapporterte UX-funn: ny-runde og score-registrering

Fire rettet direkte (reelle logikkfeil/tekstvalg): round-card.tsx sin
"Hull"-celle viste alltid planlagt antall, aldri faktisk spilt for
tidlig avsluttede runder; putt-avstand-etiketter gjort konsekvente
("X-Ym" i stedet for blandet "<Xm ... 8m+"); "Se feed" presisert til
"Se venneaktivitet"; @-tagging av medspillere vurdert og bevisst utsatt.

Fire sendt via V0 (reelt interaksjonsdesign), diffet mot live-treet før
integrering: ny-runde steg 2 sin rekkefølge (Antall hull/Avanserte HCP
flyttet før formatrutenettet), "detaljer"-steget delt i seksjoner med
et nytt scroll-hint, Anywayslag unifisert til samme +/--mønster som
Chip/Bunker/Straffeslag, netto par-markør + egen Putter-aksent.

Se ADR-062/CHANGELOG punkt 71 for full detalj. tsc rent, full
scratch-stack browserverifisert (lys+mørk, mobil viewport) før
utrulling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-11 08:01:42 +02:00
parent 7c8b352eca
commit 3db00e72db
6 changed files with 342 additions and 71 deletions

View file

@ -5842,6 +5842,84 @@ og en tilsvarende fullstendighetssjekk PÅ TVERS AV `round-detail.tsx`/
komponentfiler som ville krevd en helt annen testtilnærming enn ren komponentfiler som ville krevd en helt annen testtilnærming enn ren
lib-import — egen, fremtidig vurdering). lib-import — egen, fremtidig vurdering).
---
## ADR-062: Åtte brukerrapporterte UX-funn — "Ny runde" og score-registrering — 2026-08-11
Bruker sendte åtte konkrete, skjermbilde-dokumenterte problemer fra
faktisk bruk (dashbord, ny-runde-veiviseren, score-registrering, feed).
Delt i to spor etter samme prinsipp som tidligere runder: reelle
logikkfeil rettet direkte, reelt interaksjonsdesign sendt via V0.
**Direkte rettet, ingen V0 (4 av 8):**
1. **`round-card.tsx`: "Hull"-cellen viste alltid PLANLAGT antall hull
(`holes_planned`), aldri antall FAKTISK spilt** — en runde markert
"Fullført" etter f.eks. 9 av 18 hull viste fortsatt "18". Data fantes
allerede (`my_holes_played` sendes for alle runder, uansett status);
selve "X/Y hull spilt"-indikatoren var bare feilaktig gatet til
`status === "active"`. Rettet til `played < round.holes` (viser
avviket uansett status, skjuler seg selv når spilt == planlagt).
2. **Putt-avstand-knappenes etiketter** (`round-detail.tsx`) —
fem av seks brukte "&lt;Xm"-mønster, den sjette ("8m+") brøt mønsteret
med et "+"-suffiks og ble dermed lett oversett. Etikettene endret til
konsekvent "X-Ym"-format (kun `label`, ikke `value`/`PuttBucket`-
kontrakten — ingen backend-endring). Bekreftet via git-historikk at
dette var Claude-forfattet, ikke V0 (brukeren spurte eksplisitt).
3. **"Se feed" på dashbordet** — for anonymt/uspesifikt. Endret til "Se
venneaktivitet", knyttet direkte til seksjonens egen kontekst
("Venner på banen").
4. **@-tagging av medspillere i feeden** — vurdert, IKKE bygget denne
runden (egen, betydelig funksjon: datamodell + søk + rendering).
Anbefalt regel hvis/når den bygges: treff begrenset til faktiske
relasjoner (medspillere/venner, ikke hele brukerbasen — personvern),
og en ekte søkbar nedtrekksliste ved "@" fremfor fritekst-tolkning
(upålitelig med flere like navn).
**Sendt via V0, mottatt som "tee-cup (9).zip", diffet mot live-treet FØR
noe ble tatt inn (samme rutine som alltid — kun `delivery/`-mappen var
den faktiske leveransen, resten var V0s egen sandbox-scaffolding, ikke
tatt inn):**
5. **Ny-runde steg 2: "Antall hull" og "Avanserte handicap-
innstillinger" flyttet FØR det store 18-knappers formatrutenettet**
(var sist, lett oversett bak noe man må skrolle forbi). Ren
rekkefølge-endring, ingen ny funksjonalitet.
6. **Score-registreringens "detaljer"-steg delt i to synlige seksjoner**
("Retning" / "Detaljer per hull") med overskrift+divider, og en ny
`ScrollFade`-hjelpekomponent (bunn-fade + nedoverpil, ResizeObserver-
drevet, skjuler seg selv ved bunn) som løser at brukere ikke visste
de måtte skrolle for å finne Chip/Bunker/Straffeslag/Anywayslag.
7. **Anywayslag endret fra tall-rutenett til +/-stepper** — samme
inndatamønster som Chip/Bunker/Straffeslag nå (alle fire er
konseptuelt samme type data, brøt tidligere mønster uten grunn).
8. **Netto par-markør på Slag-knappene**: en liten oransje prikk (med
kontrastring, synlig i BÅDE valgt og uvalgt tilstand) på knappen som
tilsvarer spillerens personlige netto par (rå par + `strokesReceived`,
allerede tilgjengelig i komponenten), pluss en tekstlig
"Ditt netto par (N)"-forklaring over selve rutenettet (tilgjengelighet
— ikke avhengig av fargesyn alene). `aria-label` utvidet tilsvarende.
9. **Putter-velgeren fikk egen visuell identitet** (oransje venstre-
aksentkant + flagg-ikon på tittelraden) for å skille den fra Slag-
velgeren, som ellers er nøyaktig samme rutenett-komponent. Selve
tallknappenes etablerte grønne "valgt"-stil er UENDRET.
**Verifisering:** `tsc --noEmit` rent på alle endringer. Full scratch-
stack bygget (scratch-DB med alle 63 migrasjoner, egen scratch-MinIO,
scratch-API- og scratch-frontend-container, ekte produksjonsbuild av
frontend — ikke bare `tsc`). Ekte bruker opprettet og logget inn via
magic-link-bypass (`DEV_LOG_MAGIC_LINKS`), egen bane opprettet via ekte
API-kall (`POST /personal-courses`) med HCP 24 satt på spilleren for å
garantere `strokes_received > 0` på hull 1 — nødvendig for faktisk å se
netto-par-markøren i bruk, ikke bare anta at koden er riktig.
Browserverifisert (Chrome DevTools MCP, mobil viewport 390×844) i BÅDE
lys og mørk modus: ny-runde steg 2 sin nye rekkefølge, seksjonsdeling og
scroll-hint (bekreftet `opacity-100`→`opacity-0` ved faktisk scroll til
bunn, ikke bare visuell antagelse), netto-par-prikk (både alene og
samtidig med "valgt"-tilstand), Putter-aksent, de nye "X-Ym"-putt-
avstand-etikettene. Ingen konsollfeil. Scratch-miljøet ryddet opp
fullstendig (containere, images, database, rolle), `teecup_db`s
rolleoppsett bekreftet uendret.
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på

View file

@ -10386,3 +10386,45 @@ Neste steg:
`corepack enable` + `pnpm install --frozen-lockfile` + `pnpm test`. `corepack enable` + `pnpm install --frozen-lockfile` + `pnpm test`.
**Verifisert:** 30/30 grønt lokalt. Ingen produksjonskode endret. **Verifisert:** 30/30 grønt lokalt. Ingen produksjonskode endret.
71. **Åtte brukerrapporterte UX-funn — "Ny runde" og score-registrering
— 2026-08-11, se ADR-062.** Bruker sendte åtte konkrete, skjermbilde-
dokumenterte problemer. Delt i to spor: fire rettet direkte (reelle
logikkfeil/tekstvalg), fire sendt via V0 (reelt interaksjonsdesign).
**Direkte:** `round-card.tsx` sin "Hull"-celle viste alltid planlagt
antall hull, aldri faktisk spilt, selv for tidlig avsluttede
"Fullført"-runder — rettet (`played < round.holes` avgjør om
avviket vises, uansett status). Putt-avstand-knappenes etiketter
endret fra et blandet "&lt;Xm ... 8m+"-mønster til konsekvent
"X-Ym" (kun visningstekst, ikke backend-kontrakten) — bekreftet via
git-historikk at dette var Claude-forfattet, ikke V0. "Se feed" på
dashbordet endret til "Se venneaktivitet". @-tagging av medspillere
i feeden vurdert og UTSATT (egen fremtidig funksjon) — anbefalt
personvern-begrensning (kun faktiske relasjoner) og ekte søkbar
nedtrekksliste (ikke fritekst-tolkning) notert til den runden.
**Via V0** (`tee-cup (9).zip`, kun `delivery/`-mappen tatt inn, diffet
mot live-treet først): ny-runde steg 2 sin "Antall hull"/"Avanserte
handicap-innstillinger" flyttet FØR formatrutenettet (var sist, lett
oversett). Score-registreringens "detaljer"-steg delt i "Retning"/
"Detaljer per hull"-seksjoner + en ny `ScrollFade`-hjelpekomponent
(bunn-fade + nedoverpil, ResizeObserver, auto-skjuler ved bunn) --
løser at brukere ikke visste de måtte skrolle. Anywayslag endret fra
tall-rutenett til +/-stepper (samme mønster som Chip/Bunker/
Straffeslag nå). Netto par-markør (oransje prikk + kontrastring +
tekstlig forklaring, tilgjengelig uten fargesyn) på Slag-knappene,
kan opptre samtidig med "valgt"-tilstand. Putter-velgeren fikk egen
oransje aksent + flagg-ikon for å skille den fra Slag-velgeren.
**Verifisert:** `tsc --noEmit` rent. Full scratch-stack (DB, MinIO,
API, frontend — ekte produksjonsbuild). Ekte testbruker, egen bane
opprettet via API med HCP 24 for å garantere synlig netto-par-
markør. Browserverifisert (mobil viewport, lys+mørk modus): ny
rekkefølge, seksjonsdeling, scroll-hint (bekreftet både synlig og
auto-skjult), netto-par-prikk alene og kombinert med valgt-tilstand,
Putter-aksent, nye putt-avstand-etiketter. Ingen konsollfeil.
Scratch-miljøet ryddet opp fullstendig, `teecup_db` bekreftet uendret.
**Ikke rullet ut ennå** — venter på eksplisitt bekreftelse per
CLAUDE.md.

View file

@ -929,7 +929,10 @@ function friendOnCourseInitials(name: string): string {
function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) { function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
return ( return (
<section aria-label="Venner på banen"> <section aria-label="Venner på banen">
<SectionHeader title="Venner på banen" action={<SeeAllLink href="/my-feed" label="Se feed" />} /> {/* "Se feed" var for anonymt/uspesifikt til å si hva som faktisk
venter bak lenken (2026-08-11, brukertilbakemelding) -- knyttet
teksten til seksjonens egen kontekst i stedet. */}
<SectionHeader title="Venner på banen" action={<SeeAllLink href="/my-feed" label="Se venneaktivitet" />} />
{friends.length > 0 ? ( {friends.length > 0 ? (
<div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0"> <div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
{friends.map((friend, i) => { {friends.map((friend, i) => {

View file

@ -43,6 +43,30 @@ export function Step2Format() {
</div> </div>
</section> </section>
{/* Antall hull -- et grunnleggende oppsettsvalg, derfor FØR det store
formatrutenettet, ikke etter (2026-08-11, brukertilbakemelding: ble
tidligere lett oversett fordi den sist, bak et 18-knappers
rutenett man skrolle forbi først). */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Antall hull</h3>
<div className="max-w-xs">
<Segmented
label="Antall hull"
value={String(state.numHoles) as "9" | "18"}
onChange={(v) => patch({ numHoles: v === "9" ? 9 : 18 })}
options={[
{ value: "9", label: "9 hull" },
{ value: "18", label: "18 hull" },
]}
/>
</div>
</section>
{/* Avanserte HCP-innstillinger (skjult for scramble mot solo) -- fortsatt
kollapset som standard, KUN rekkefølgen flyttet opp, samme grunn som
Antall hull over. */}
{!svs ? <AdvancedHcp /> : null}
{/* Format grid */} {/* Format grid */}
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Spilleform</h3> <h3 className="text-sm font-semibold text-[var(--nr-ink)]">Spilleform</h3>
@ -86,25 +110,6 @@ export function Step2Format() {
</div> </div>
</section> </section>
) : null} ) : null}
{/* Advanced HCP (hidden for scramble vs solo) */}
{!svs ? <AdvancedHcp /> : null}
{/* Number of holes */}
<section className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Antall hull</h3>
<div className="max-w-xs">
<Segmented
label="Antall hull"
value={String(state.numHoles) as "9" | "18"}
onChange={(v) => patch({ numHoles: v === "9" ? 9 : 18 })}
options={[
{ value: "9", label: "9 hull" },
{ value: "18", label: "18 hull" },
]}
/>
</div>
</section>
</div> </div>
) )
} }

View file

@ -214,7 +214,13 @@ export function RoundCard({ round }: { round: Round }) {
</span> </span>
)} )}
</span> </span>
{round.status === "active" && ( {/* Vis "spilt av planlagt" når det faktisk AVVIKER fra planen --
ikke bare mens runden pågår. En runde markert Fullført etter
f.eks. 9 av 18 planlagte hull skal fortsatt vise det avviket
her, ikke bare stille bytte til status-teksten (2026-08-11,
reelt funn: "Hull"-cellen under viser uansett kun PLANLAGT
antall, aldri hvor mange som faktisk ble spilt). */}
{played < round.holes && (
<span className="text-xs font-semibold tabular-nums text-muted-foreground"> <span className="text-xs font-semibold tabular-nums text-muted-foreground">
{played}/{round.holes} hull spilt {played}/{round.holes} hull spilt
</span> </span>

View file

@ -1929,7 +1929,8 @@ function ScoringWizard({
</p> </p>
)} )}
<main className="flex-1 overflow-y-auto p-5 sm:p-6"> <main className="flex min-h-0 flex-1 flex-col">
<ScrollFade className="flex-1 overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex max-w-sm flex-col gap-6"> <div className="mx-auto flex max-w-sm flex-col gap-6">
{step === "strokes" && ( {step === "strokes" && (
<> <>
@ -1941,6 +1942,7 @@ function ScoringWizard({
expandLabel="10+" expandLabel="10+"
parValue={hole.par} parValue={hole.par}
showGolfTerms showGolfTerms
strokesReceived={strokesReceived}
onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })} onChange={(v) => onUpdate({ strokes: v, played: true, pickedUp: false })}
readOnly={readOnly} readOnly={readOnly}
/> />
@ -1981,6 +1983,7 @@ function ScoringWizard({
expandValues={[7, 8, 9, 10]} expandValues={[7, 8, 9, 10]}
expandLabel="7+" expandLabel="7+"
maxValue={stat.strokes ?? undefined} maxValue={stat.strokes ?? undefined}
accent="orange"
onChange={(v) => onUpdate({ putts: v })} onChange={(v) => onUpdate({ putts: v })}
readOnly={readOnly} readOnly={readOnly}
/> />
@ -1989,12 +1992,18 @@ function ScoringWizard({
{step === "puttDistance" && ( {step === "puttDistance" && (
<ChoiceRow <ChoiceRow
label="Avstand første putt" label="Avstand første putt"
// Konsekvent "X-Ym"-mønster på ALLE seks, kun den siste
// (uunngåelig ubegrenset) bruker "+" -- 2026-08-11, reelt
// UX-funn: den forrige "<Xm ... 8m+"-blandingen brøt
// mønsteret på akkurat siste knapp, som da ble lett å
// overse. `value` uendret (PuttBucket/backend-kontrakt),
// kun visningsteksten er ny.
options={[ options={[
{ value: "<1m", label: "<1m" }, { value: "<1m", label: "0-1m" },
{ value: "<2m", label: "<2m" }, { value: "<2m", label: "1-2m" },
{ value: "<3m", label: "<3m" }, { value: "<3m", label: "2-3m" },
{ value: "<5m", label: "<5m" }, { value: "<5m", label: "3-5m" },
{ value: "<8m", label: "<8m" }, { value: "<8m", label: "5-8m" },
{ value: "8m+", label: "8m+" }, { value: "8m+", label: "8m+" },
]} ]}
value={stat.firstPuttBucket} value={stat.firstPuttBucket}
@ -2022,53 +2031,65 @@ function ScoringWizard({
variant="inline" variant="inline"
/> />
{hole.par !== 3 && ( {/* Seksjon 1 -- retning (2026-08-11): retningskorsene utgjør nå
en tydelig egen gruppe med overskrift, adskilt fra tellerne
under, det er visuelt klart at skjermen består av to
ulike typer inndata (brukertilbakemelding: umulig å vite at
man måtte skrolle for å finne resten). */}
<WizardSection title="Retning">
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={stat.teeShot}
onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
<DirectionCross <DirectionCross
label="Utslag" label="Innspill"
variant="horizontal" variant="full"
centerLabel="Fairway" centerLabel="Traff"
value={stat.teeShot} value={stat.approach}
onChange={(v) => onUpdate({ teeShot: v as TeeShot })} onChange={(v) => onUpdate({ approach: v as Approach })}
readOnly={readOnly} readOnly={readOnly}
/> />
)} </WizardSection>
<DirectionCross {/* Seksjon 2 -- detaljer per hull (2026-08-11): egen overskrift
label="Innspill" + divider fra "Retning" over. Alle FIRE tellerne (Chip/
variant="full" Bunker/Straffeslag/Anywayslag) er konseptuelt samme type
centerLabel="Traff" data (antall småhendelser per hull, typisk 0-3) og bruker
value={stat.approach} ETT konsistent +/--stepper-mønster -- Anywayslag var
onChange={(v) => onUpdate({ approach: v as Approach })} tidligere det eneste tall-rutenettet, som brøt mønsteret
readOnly={readOnly} uten grunn. Alltid stablet, ALDRI et sm:grid-cols-3-sprang:
/> denne wizard-seksjonen er fast begrenset til max-w-sm
(384px, se containeren over) uansett hvor bred selve
{/* Alltid stablet, ALDRI et sm:grid-cols-3-sprang: denne wizard- VIEWPORTET er -- et viewport-basert grid-cols-3-brudd ved
seksjonen er fast begrenset til max-w-sm (384px, se 640px trigges derfor skjermer godt over 640px bredde mens
containeren over) uansett hvor bred selve VIEWPORTET er -- innholdskolonnen fortsatt kun er 384px, som får stepperne
et viewport-basert grid-cols-3-brudd ved 640px trigges (hver ~190px minimum pga. faste 44px-knapper) til å
derfor skjermer godt over 640px bredde mens overlappe hverandre i stedet for å faktisk ha plass. */}
innholdskolonnen fortsatt kun er 384px, som får de tre <WizardSection title="Detaljer per hull">
stepperne (hver ~190px minimum pga. faste 44px-knapper) til <div className="grid grid-cols-1 gap-3">
å overlappe hverandre i stedet for å faktisk ha plass. */} <Stepper label="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
<div className="grid grid-cols-1 gap-3"> <Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
<Stepper label="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> <Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
<Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> <Stepper
<Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> label="Anywayslag"
</div> value={stat.anywayStrokes ?? 0}
onChange={(v) => onUpdate({ anywayStrokes: v })}
<NumberPicker readOnly={readOnly}
label="Anywayslag" max={stat.strokes ?? undefined}
value={stat.anywayStrokes} />
directValues={[0, 1, 2, 3, 4, 5, 6]} </div>
expandValues={[7, 8, 9, 10]} </WizardSection>
expandLabel="7+"
maxValue={stat.strokes ?? undefined}
onChange={(v) => onUpdate({ anywayStrokes: v })}
readOnly={readOnly}
/>
</> </>
)} )}
</div> </div>
</ScrollFade>
</main> </main>
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4"> <footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
@ -6538,6 +6559,63 @@ function SideScoreWizard({
) )
} }
// --- Wizard-seksjon (2026-08-11) -------------------------------------------
// Liten seksjonsoverskrift + hårfin divider, brukt til å dele detalj-steget
// i tydelige grupper ("Retning" vs. "Detaljer per hull"). Rent presentasjon
// -- ingen egen state, ingen ny farge (gjenbruker border/muted-tokens).
function WizardSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 border-t border-border pt-5 first:border-t-0 first:pt-0">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
// --- "Mer innhold under"-hint (2026-08-11) ---------------------------------
// Legger en myk bunn-fade + nedoverpil over et scrollbart område SÅ LENGE det
// finnes mer innhold under fold-linjen -- og skjuler seg av seg selv idet man
// har scrollet helt ned. Fanger både resize og innholdsendringer (ulikt
// stat_level/par gir ulik høyde) via en ResizeObserver på selve scrollboksen.
function ScrollFade({ children, className }: { children: React.ReactNode; className?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [hasMore, setHasMore] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const update = () => {
// 4px slingring: unngår at en sub-piksel-rest holder hintet i live
// når man i praksis ER nederst.
setHasMore(el.scrollTop + el.clientHeight < el.scrollHeight - 4)
}
update()
el.addEventListener("scroll", update, { passive: true })
const ro = new ResizeObserver(update)
ro.observe(el)
return () => {
el.removeEventListener("scroll", update)
ro.disconnect()
}
}, [])
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div ref={ref} className={className}>
{children}
</div>
{/* Rent dekorativt -- selve scrollingen er fullt mulig uten dette. */}
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 flex h-16 items-end justify-center bg-gradient-to-t from-background to-transparent pb-1.5 transition-opacity duration-200",
hasMore ? "opacity-100" : "opacity-0",
)}
>
<ChevronDown className="size-5 animate-bounce text-muted-foreground" />
</div>
</div>
)
}
function NumberPicker({ function NumberPicker({
label, label,
value, value,
@ -6547,6 +6625,8 @@ function NumberPicker({
parValue, parValue,
showGolfTerms, showGolfTerms,
maxValue, maxValue,
strokesReceived,
accent,
onChange, onChange,
readOnly, readOnly,
}: { }: {
@ -6565,6 +6645,17 @@ function NumberPicker({
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som // antall slag registrert på hullet) -- ingen vits i å tilby et tall som
// uansett ville vært selvmotsigende. // uansett ville vært selvmotsigende.
maxValue?: number maxValue?: number
// Spillerens tildelte handicap-slag PÅ DETTE hullet (2026-08-11). Når satt
// (og showGolfTerms er på, dvs. Slag-velgeren), markeres knappen som
// tilsvarer spillerens PERSONLIGE netto par (rå par + mottatte slag) med en
// liten, diskret prikk -- tydelig forskjellig fra "valgt"-tilstanden, og de
// to kan opptre samtidig på samme knapp.
strokesReceived?: number | null
// Valgfri egen aksent for seksjonen (2026-08-11, brukes til å gi Putter-
// velgeren en distinkt visuell identitet så den ikke forveksles med Slag-
// velgeren). Rører IKKE selve tallknappene -- kun tittelrad/ikon. Gjenbruker
// eksisterende tokens.
accent?: "primary" | "orange"
onChange: (value: number) => void onChange: (value: number) => void
readOnly: boolean readOnly: boolean
}) { }) {
@ -6578,14 +6669,44 @@ function NumberPicker({
const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect
// Spillerens netto par for hullet = rå par + tildelte slag. Kun beregnet
// når vi faktisk har begge (Slag-velgeren med kjent strokesReceived).
const netPar =
parValue !== undefined && strokesReceived !== null && strokesReceived !== undefined
? parValue + strokesReceived
: null
// Putter-velgeren får en egen aksent (oransje) og et eget ikon på
// tittelraden, så den ikke er til å forveksle med Slag-velgeren -- som
// ellers er nøyaktig samme rutenett. Kun tittelraden/venstre aksentbånd
// endres; selve tallknappene beholder den etablerte grønne "valgt"-stilen.
const isOrange = accent === "orange"
return ( return (
<div className="flex flex-col gap-2"> <div className={cn("flex flex-col gap-2", isOrange && "rounded-2xl border-l-4 border-brand-orange bg-brand-orange/5 py-3 pl-3 pr-1")}>
<span className="text-base font-semibold text-foreground">{label}</span> <span className="flex items-center gap-2 text-base font-semibold text-foreground">
{isOrange && (
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-lg bg-brand-orange text-brand-orange-foreground">
<Flag className="size-4" strokeWidth={2.5} />
</span>
)}
{label}
</span>
{/* Netto par-forklaring: gjør prikken selvforklarende uten å stole på
fargesyn alene (tilgjengelighet -- sol/utendørs). Vises kun når det
faktisk finnes en netto par-markør å forklare. */}
{netPar !== null && (
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span aria-hidden="true" className="size-2 rounded-full bg-brand-orange ring-2 ring-brand-orange/25" />
Ditt netto par ({netPar})
</span>
)}
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater {/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
for bruk utendørs, ofte med sol skjermen (V0-runde 2026-07-24). */} for bruk utendørs, ofte med sol skjermen (V0-runde 2026-07-24). */}
<div className="grid max-w-sm grid-cols-3 gap-2.5"> <div className="grid max-w-sm grid-cols-3 gap-2.5">
{visibleValues.map((n) => { {visibleValues.map((n) => {
const selected = value === n const selected = value === n
const isNetPar = netPar !== null && n === netPar
const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
return ( return (
<button <button
@ -6593,16 +6714,32 @@ function NumberPicker({
type="button" type="button"
disabled={readOnly} disabled={readOnly}
onClick={() => onChange(n)} onClick={() => onChange(n)}
aria-label={term ? `${label} ${n}, ${term}` : `${label} ${n}`} aria-label={term ? `${label} ${n}, ${term}${isNetPar ? ", ditt netto par" : ""}` : `${label} ${n}${isNetPar ? ", ditt netto par" : ""}`}
aria-pressed={selected} aria-pressed={selected}
className={cn( className={cn(
"flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100", "relative flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
selected selected
? "border-primary bg-primary text-primary-foreground shadow-sm" ? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50", : "border-border bg-card text-foreground hover:bg-accent/50",
// Netto par-knapp som IKKE er valgt: en subtil oransje ring +
// hint-bakgrunn, tydelig annerledes enn den fylte grønne
// "valgt"-tilstanden. Prikken øverst til høyre er signalet som
// holder også når knappen ER valgt (grønn fyll under).
isNetPar && !selected && "border-brand-orange/60 bg-brand-orange/5",
readOnly && !selected && "opacity-40", readOnly && !selected && "opacity-40",
)} )}
> >
{isNetPar && (
<span
aria-hidden="true"
className={cn(
"absolute right-1.5 top-1.5 size-2.5 rounded-full bg-brand-orange",
// Kontrast-ring så prikken leses uansett om knappen er
// grønn (valgt) eller lys (uvalgt).
selected ? "ring-2 ring-primary" : "ring-2 ring-card",
)}
/>
)}
<span>{n}</span> <span>{n}</span>
{term && ( {term && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}> <span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>