teecup/frontend/components/hole-stat-inputs.tsx
Erol Haagenrud 60ee6a7d16 Full statistikkdybde i org-turneringers hull-scoring (ADR-071)
tournament_round_hole hadde siden opprettelse (migrasjon 040) kun
gross_strokes -- ingen putts/utslag/innspill/bunker/straffeslag-detalj
slik frittstående runder (round_hole) allerede har. Migrasjon 075 gir
turneringssiden samme feltsett + en ny optimistisk-lås-kolonne (endepunktet
var til nå rent siste-skriver-vinner). tournament_participant.stat_level
styrer om HoleGrid åpner det uendrede inline-tallfeltet eller det nye
HoleStatsSheet. NumberPicker/ChoiceRow/DirectionCross/Stepper trukket ut
til en delt hole-stat-inputs.tsx for gjenbruk mellom begge scoringsflytene.

Dette er Steg 1 av to mot spillerens per-hull-historikk på tvers av alle
runder/turneringer -- se ADR-071/CHANGELOG for full detalj og verifisering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 06:32:17 +02:00

350 lines
13 KiB
TypeScript

"use client"
// Delte, utelukkende trykkbaserte input-primitiver for hull-for-hull-
// statistikkregistrering (ALDRI dropdowns) -- trukket ut av round-detail.tsx
// sin ScoringWizard (frittstående runder) 2026-08-15 (ADR-071) for gjenbruk
// i org-turneringers nye HoleStatsSheet, som nå har samme statistikkdybde
// (migrasjon 075). Ren UI, ingen datahenting/state utover det som sendes inn.
import { useState, type ReactNode } from "react"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Flag, Minus, Plus, Target } from "lucide-react"
import { cn } from "@/lib/utils"
export function golfTermForScore(n: number, par: number): string | null {
const diff = n - par
if (diff <= -3) return "Albatross"
if (diff === -2) return "Eagle"
if (diff === -1) return "Birdie"
if (diff === 0) return "Par"
if (diff === 1) return "Bogey"
if (diff === 2) return "Dobbel bogey"
return null
}
export function NumberPicker({
label,
value,
directValues,
expandValues,
expandLabel,
parValue,
showGolfTerms,
maxValue,
strokesReceived,
accent,
onChange,
readOnly,
}: {
label: string
value: number | null
directValues: number[]
expandValues: number[]
expandLabel: string
// Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt.
parValue?: number
// Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til
// parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt
// for Slag-velgeren, ikke Putter/Anywayslag.
showGolfTerms?: boolean
// Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som
// uansett ville vært selvmotsigende.
maxValue?: number
// Spillerens tildelte handicap-slag PÅ DETTE hullet. 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 (brukes til å gi Putter-velgeren en
// distinkt visuell identitet så den ikke forveksles med Slag-velgeren).
// Rører IKKE selve tallknappene -- kun tittelrad/ikon.
accent?: "primary" | "orange"
onChange: (value: number) => void
readOnly: boolean
}) {
const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue))
const cappedDirect = cap(directValues)
const cappedExpand = cap(expandValues)
const valueInExpand = value !== null && cappedExpand.includes(value)
const [expanded, setExpanded] = useState(false)
const showExpanded = expanded || valueInExpand
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 (
<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="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
for bruk utendørs, ofte med sol på skjermen. */}
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{visibleValues.map((n) => {
const selected = value === n
const isNetPar = netPar !== null && n === netPar
const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
return (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={term ? `${label} ${n}, ${term}${isNetPar ? ", ditt netto par" : ""}` : `${label} ${n}${isNetPar ? ", ditt netto par" : ""}`}
aria-pressed={selected}
className={cn(
"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
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "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",
)}
>
{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>
{term && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
{term}
</span>
)}
</button>
)
})}
{!showExpanded && !readOnly && cappedExpand.length > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
>
{expandLabel}
</button>
)}
</div>
</div>
)
}
// --- Retningskors (D-pad for Utslag / Innspill) -----------------------------
function DirButton({
icon: Icon,
label,
selected,
onClick,
readOnly,
}: {
icon: typeof ArrowUp
label: string
selected: boolean
onClick: () => void
readOnly: boolean
}) {
return (
<button
type="button"
disabled={readOnly}
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<Icon aria-hidden="true" className="size-6" />
<span className="text-sm font-bold">{label}</span>
</button>
)
}
export function DirectionCross({
label,
value,
onChange,
readOnly,
variant,
centerLabel,
}: {
label: string
value: string | null
onChange: (value: string) => void
readOnly: boolean
// "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill)
variant: "horizontal" | "full"
centerLabel: string
}) {
const spacer = <div aria-hidden="true" />
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
{spacer}
</>
)}
<DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
<DirButton
icon={Target}
label={centerLabel}
selected={value === (variant === "full" ? "hit" : "fairway")}
onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
readOnly={readOnly}
/>
<DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
{spacer}
</>
)}
</div>
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
export function ChoiceRow({
label,
options,
value,
onChange,
readOnly,
}: {
label: string
options: { value: string; label: string }[]
value: string | null
onChange: (value: string) => void
readOnly: boolean
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid grid-cols-3 gap-2">
{options.map((opt) => {
const selected = value === opt.value
return (
<button
key={opt.value}
type="button"
disabled={readOnly}
onClick={() => onChange(opt.value)}
aria-pressed={selected}
className={cn(
"flex min-h-12 items-center justify-center rounded-2xl border px-2 text-base font-bold transition-colors disabled:opacity-100",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
// --- Stepper (+/-) ---------------------------------------------------------
export function Stepper({
label,
value,
onChange,
readOnly,
max,
}: {
label: string
value: number
onChange: (value: number) => void
readOnly: boolean
// Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
// umulig å ha chippet flere ganger enn totalt antall slag.
max?: number
}) {
const atMax = max !== undefined && value >= max
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card p-1.5">
<button
type="button"
disabled={readOnly || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
aria-label={`Færre ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-5" />
</button>
<span className="min-w-8 text-center text-xl font-extrabold tabular-nums text-foreground">{value}</span>
<button
type="button"
disabled={readOnly || atMax}
onClick={() => onChange(value + 1)}
aria-label={`Flere ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-5" />
</button>
</div>
</div>
)
}
export function WizardSection({ title, children }: { title: string; children: 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>
)
}