Historikk-panelet flyttet ut av selve slagvinduet/-arket i begge flyter: inn i PlayerHoleCards-kortet (frittstående runder), som nytt "overview"- steg med auto-hopp når tomt (org-turneringer). Klikk åpner nå en ny delt full-historikk-skjerm (hole-history-detail.tsx) med aggregerte tall og en score-fordeling som fargede stolper -- samme CategoryBar-mønster som round-stats.tsx, ingen ny graf-avhengighet. Ingen backend-endring. tsc + 45/45 vitest, scratch-verifisert i begge flyter inkl. kombinert historikk på tvers av runde-/turneringstyper og auto-hopp for hull uten historikk (lys+mørk). Rullet ut. Med dette er alle tre forbedringene i score-registreringen (ADR-077/078/079) fullført. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
460 lines
17 KiB
TypeScript
460 lines
17 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.
|
|
//
|
|
// HoleHistoryPanel (ADR-072) er unntaket -- den HENTER selv (GET .../history),
|
|
// siden begge scoringsflytene bruker nøyaktig samme henting/visning og det
|
|
// ville vært ren duplisering å bygge den to ganger.
|
|
|
|
import { useEffect, useState, type ReactNode } from "react"
|
|
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronRight, 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
|
|
const atMin = value <= 0
|
|
// Teller-FLIS (ADR-078, 2026-08-16, V0-designet) -- selvstendig kort med
|
|
// etikett + stort tall + -/+ på bunnen, ment for et 2x2-rutenett (se
|
|
// holeDetails-steget i round-detail.tsx/individual-tournament-detail.tsx).
|
|
// Interaksjonskontrakten er uendret: -/tall/+, minus disabled på 0, pluss
|
|
// disabled på max, alle trykkflater >=44px. Kun app-tokens.
|
|
return (
|
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card p-4">
|
|
<span className="text-center text-sm font-bold leading-tight text-foreground">{label}</span>
|
|
{/* Tallet bærer selve informasjonen (aldri farge alene); nullverdi
|
|
dempes kun visuelt via muted-foreground, som fortsatt er AA på
|
|
kort. */}
|
|
<span
|
|
className={cn(
|
|
"text-4xl font-extrabold leading-none tabular-nums",
|
|
atMin ? "text-muted-foreground" : "text-foreground",
|
|
)}
|
|
>
|
|
{value}
|
|
</span>
|
|
<div className="flex w-full items-center gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={readOnly || atMin}
|
|
onClick={() => onChange(Math.max(0, value - 1))}
|
|
aria-label={`Færre ${label}`}
|
|
className="flex h-11 flex-1 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>
|
|
<button
|
|
type="button"
|
|
disabled={readOnly || atMax}
|
|
onClick={() => onChange(value + 1)}
|
|
aria-label={`Flere ${label}`}
|
|
className="flex h-11 flex-1 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>
|
|
)
|
|
}
|
|
|
|
// --- Spillerens per-hull-historikk (ADR-072) --------------------------------
|
|
// "Hvordan spiller jeg egentlig dette hullet?" -- aggregert på tvers av ALLE
|
|
// tidligere frittstående runder OG turneringsrunder (custom/håndlagde baner
|
|
// og gjestespillere er allerede filtrert bort server-side, se
|
|
// app/hole_history.py). Panelet skjuler seg selv (viser ingenting) når
|
|
// endepunktet svarer null -- ikke en feil, bare "ingen historikk å vise
|
|
// ennå/denne banen støtter det ikke".
|
|
|
|
type HoleHistoryInstance = {
|
|
source: "personal" | "tournament"
|
|
played_at: string | null
|
|
label: string
|
|
score: number
|
|
par: number
|
|
putts: number | null
|
|
tee_shot_result: string | null
|
|
approach_result: string | null
|
|
chip_count: number | null
|
|
bunker_shot_count: number | null
|
|
penalty_strokes: number | null
|
|
first_putt_distance_bucket: string | null
|
|
}
|
|
|
|
type HoleHistory = {
|
|
times_played: number
|
|
times_personal: number
|
|
times_tournament: number
|
|
average_score: number
|
|
average_score_vs_par: number
|
|
gir_percent: number | null
|
|
fairway_hit_percent: number | null
|
|
average_putts: number | null
|
|
best_score: number
|
|
worst_score: number
|
|
instances: HoleHistoryInstance[]
|
|
}
|
|
|
|
// ADR-079 (2026-08-16): panelet åpner nå den FULLE historikk-skjermen
|
|
// (components/hole-history-detail.tsx) ved klikk i stedet for å ekspandere
|
|
// en enkel liste inline -- flyttet HIT ut av selve slagvinduet/-arket til
|
|
// "skjermen før" (PlayerHoleCards for frittstående runder, "Oversikt"-
|
|
// steget for org-turneringer), se round-detail.tsx/individual-tournament-
|
|
// detail.tsx for de to bruksstedene.
|
|
export function HoleHistoryPanel({ url, onOpenFull }: { url: string; onOpenFull: () => void }) {
|
|
// undefined = laster ennå, null = ingen historikk å vise (custom bane,
|
|
// gjest, eller genuint aldri spilt før).
|
|
const [history, setHistory] = useState<HoleHistory | null | undefined>(undefined)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
setHistory(undefined)
|
|
fetch(url, { credentials: "include" })
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data) => {
|
|
if (!cancelled) setHistory(data)
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setHistory(null)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [url])
|
|
|
|
if (!history) return null
|
|
|
|
const parSign = history.average_score_vs_par >= 0 ? "+" : ""
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onOpenFull}
|
|
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent/50"
|
|
>
|
|
<span className="flex flex-col gap-1">
|
|
<span className="text-sm font-bold text-foreground">Din historikk på dette hullet</span>
|
|
<span className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
|
Spilt {history.times_played} {history.times_played === 1 ? "gang" : "ganger"} før
|
|
{" "}({history.times_personal} frittstående, {history.times_tournament} turnering) -- snitt{" "}
|
|
{history.average_score.toFixed(1)} slag ({parSign}
|
|
{history.average_score_vs_par.toFixed(1)} til par)
|
|
{history.gir_percent !== null && `, GIR ${Math.round(history.gir_percent)}%`}
|
|
{history.average_putts !== null && `, ${history.average_putts.toFixed(1)} putter i snitt`}
|
|
</span>
|
|
</span>
|
|
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
)
|
|
}
|