teecup/frontend/components/round-detail.tsx
Erol Haagenrud f5d1491ceb Update Todos
Bygge backend for starthull + tidspunkt-korrigering (start_hole/started_at/completed_at i PATCH /rounds/{id})

Bygge frontend: starthull-endring + tidspunkt-korrigering i EditRoundPanel

Bygge backend nearby-endepunkt for offisielle baner (Haversine)

Bygge frontend: geolokasjon + nærmeste baner-liste i OfficialSearchStep

Scratch-verifisere + typesjekket build

Rulle ut etter bekreftelse
Alt live og dokumentert. Oppsummert:

Starthull kan nå endres midt i runden (uavhengig av bane-bytte), og utslags-/fullført-tidspunkt kan korrigeres i etterkant — fullført-tidspunktet kun etter at runden faktisk er fullført.
Nærmeste baner vises nå automatisk (med avstand, nærmeste uthevet) rett over søkefeltet når du velger «Offisiell bane» ved ny runde — spør om posisjon, feiler stille hvis avslått.
Verifisert med 14 scratch-sjekker (inkludert et ekte nearby-oppslag mot teeoff fra Tjømes egne koordinater), ren build, rullet ut uten migrasjon. teeoff.no upåvirket.
2026-07-24 11:21:39 +02:00

1773 lines
65 KiB
TypeScript

"use client"
// Hull-for-hull-registrering for en frittstående runde (ADR-033).
// Presentasjon fra V0, datalag skrevet om fra mock til ekte fetch/PATCH.
//
// VIKTIG kontraktsdetalj (bekreftet i scratch): hull-PATCH-endepunktet er
// IKKE et ekte delvis-PATCH -- det skriver ALLE felt ved hvert kall. Derfor
// slår updateStat() alltid sammen med gjeldende hull-data FØR den sender,
// aldri kun det ene feltet som ble endret.
import type React from "react"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CalendarDays,
Check,
Clock,
Trash2,
ChevronLeft,
ChevronRight,
Flag,
MapPin,
Minus,
Plus,
Search,
Settings2,
Target,
Trophy,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
// --- Types -----------------------------------------------------------------
type Gender = "male" | "female" | "other"
type ApiGender = "m" | "f" | "x"
type TeeShot = "left" | "fairway" | "right"
type Approach = "left" | "short" | "hit" | "long" | "right"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"
type Player = {
id: string
name: string
gender: Gender
hcp: number | null
isSelf: boolean
countsForHandicap: boolean
scoreDifferential: number | null
statLevel: StatLevel
}
type HoleStat = {
played: boolean
strokes: number | null
putts: number | null
club: string
teeShot: TeeShot | null
approach: Approach | null
chip: number
bunker: number
penalty: number
firstPuttBucket: PuttBucket | null
anywayStrokes: number | null
}
type Hole = {
holeNumber: number
par: number
index: number
}
function emptyStat(): HoleStat {
return {
played: false,
strokes: null,
putts: null,
club: "",
teeShot: null,
approach: null,
chip: 0,
bunker: 0,
penalty: 0,
firstPuttBucket: null,
anywayStrokes: null,
}
}
function statKey(playerId: string, holeNumber: number) {
return `${playerId}:${holeNumber}`
}
function apiGenderToUi(g: ApiGender): Gender {
return g === "m" ? "male" : g === "f" ? "female" : "other"
}
function uiGenderToApi(g: Gender): ApiGender {
return g === "male" ? "m" : g === "female" ? "f" : "x"
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function formatDate(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return dateFormatter.format(parsed)
}
// --- API-typer ---------------------------------------------------------------
type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
is_owner: boolean
gender: ApiGender
handicap_index_snapshot: number | null
course_handicap_snapshot: number | null
counts_for_handicap: boolean
score_differential: number | null
stat_level: StatLevel
}
type ApiRound = {
id: string
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
started_at: string | null
completed_at: string | null
participants: ApiParticipant[]
}
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
// lagret varighet, kun utledet ved visning.
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
function formatTime(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return timeFormatter.format(parsed)
}
function formatDuration(startedAt: string, completedAt: string): string | null {
const start = new Date(startedAt).getTime()
const end = new Date(completedAt).getTime()
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return null
const totalMinutes = Math.round((end - start) / 60000)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) return `${minutes} min`
return `${hours}t ${minutes}min`
}
type ApiHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
putts: number | null
club_off_tee: string | null
tee_shot_result: TeeShot | null
approach_result: Approach | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: PuttBucket | null
anyway_strokes: number | null
}
function apiHoleToStat(h: ApiHole): HoleStat {
return {
played: h.played,
strokes: h.score,
putts: h.putts,
club: h.club_off_tee ?? "",
teeShot: h.tee_shot_result,
approach: h.approach_result,
chip: h.chip_count ?? 0,
bunker: h.bunker_shot_count ?? 0,
penalty: h.penalty_strokes ?? 0,
firstPuttBucket: h.first_putt_distance_bucket,
anywayStrokes: h.anyway_strokes,
}
}
function statToPatchBody(s: HoleStat) {
return {
played: s.played,
score: s.strokes,
putts: s.putts,
club_off_tee: s.club.trim() === "" ? null : s.club,
tee_shot_result: s.teeShot,
approach_result: s.approach,
chip_count: s.chip,
bunker_shot_count: s.bunker,
penalty_strokes: s.penalty,
first_putt_distance_bucket: s.firstPuttBucket,
anyway_strokes: s.anywayStrokes,
}
}
function playerLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : p.guest_name ?? "Gjest"
}
// --- Component -------------------------------------------------------------
export function RoundDetail({ roundId }: { roundId: string }) {
const router = useRouter()
const [round, setRound] = useState<ApiRound | null>(null)
const [error, setError] = useState<string | null>(null)
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
// Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et
// knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
// for eieren selv (gjester har ingen profil å hente dette fra).
const [ownBagClubs, setOwnBagClubs] = useState<string[]>([])
// `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1
// er en gyldig, truthy hullverdi og ville gjort `prev || start_hole`
// lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
// hull 1 uansett faktisk starthull).
const [currentHole, setCurrentHole] = useState<number | null>(null)
// "score" (slag/putter) eller "stats" (kølle/retning/chip/bunker/
// straffeslag/putt-avstand/anywayslag) -- kun relevant når
// statLevel==="full", ellers vises "score"-innholdet direkte uten
// faneraden (se PanelTabs-bruken lenger ned).
const [panelTab, setPanelTab] = useState<"score" | "stats">("score")
const [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false)
const loadRound = useCallback(async () => {
try {
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) {
setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!res.ok) throw new Error(`round: ${res.status}`)
const data: ApiRound = await res.json()
setRound(data)
setActivePlayerId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
setCurrentHole((prev) => prev ?? data.start_hole)
} catch {
setError("Klarte ikke å hente runden. Prøv igjen om litt.")
}
}, [roundId])
useEffect(() => {
void loadRound()
}, [loadRound])
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { bag_clubs: string[] } | null) => {
if (!cancelled && data) setOwnBagClubs(data.bag_clubs)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const loadHoles = useCallback(
async (participantId: string) => {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
if (!res.ok) return
const data: ApiHole[] = await res.json()
setHolesByParticipant((prev) => ({ ...prev, [participantId]: data }))
},
[roundId],
)
useEffect(() => {
if (activePlayerId && !holesByParticipant[activePlayerId]) {
void loadHoles(activePlayerId)
}
}, [activePlayerId, holesByParticipant, loadHoles])
const completed = round?.completed_at != null
const readOnly = completed
const players: Player[] =
round?.participants.map((p) => ({
id: p.id,
name: playerLabel(p),
gender: apiGenderToUi(p.gender),
hcp: p.handicap_index_snapshot,
isSelf: p.is_owner,
countsForHandicap: p.counts_for_handicap,
scoreDifferential: p.score_differential,
statLevel: p.stat_level,
})) ?? []
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
// loadRound() har satt currentHole fra round.start_hole.
const activeHole = currentHole ?? round?.start_hole ?? 1
const hole = holes.find((h) => h.holeNumber === activeHole) ?? null
// Navigasjonsrekkefølge starter på øktens starthull og går rundt
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
// planlegger (holes_planned) -- en 9-hulls runde 10-18 skal aldri vise
// hull 1-9 i det hele tatt, og "Forrige" fra hull 10 skal gå til hull
// 18 (siste i settet), ikke hull 9 (funnet 2026-07-24: brukte tidligere
// alltid 18, uansett holes_planned).
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
async function updateStat(patch: Partial<HoleStat>) {
if (readOnly || !activePlayerId || !currentApiHole) return
const merged: HoleStat = { ...currentStat, ...patch }
const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(statToPatchBody(merged)),
})
if (!res.ok) return
const updated: ApiHole = await res.json()
setHolesByParticipant((prev) => ({
...prev,
[activePlayerId]: (prev[activePlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
}))
}
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])
setPanelTab("score")
}
function goNext() {
const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % holeOrder.length])
setPanelTab("score")
}
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
guest_name: name,
gender: uiGenderToApi(gender),
handicap_index: hcp,
stat_level: statLevel,
}),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
return
}
setShowAddGuest(false)
const created: ApiParticipant = await res.json()
await loadRound()
setActivePlayerId(created.id)
}
async function removeGuest(id: string) {
if (!confirm("Fjerne denne spilleren fra runden?")) return
const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" })
if (!res.ok) return
if (activePlayerId === id) setActivePlayerId(round?.participants.find((p) => p.is_owner)?.id ?? null)
await loadRound()
}
async function updateStatLevel(participantId: string, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stat_level: statLevel }),
})
if (!res.ok) return
const updated: ApiParticipant = await res.json()
setRound((prev) =>
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
)
}
async function finishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
setCompleting(true)
try {
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
if (!res.ok) throw new Error()
setRound(await res.json())
} catch {
setError("Klarte ikke å fullføre runden. Prøv igjen.")
} finally {
setCompleting(false)
}
}
async function deleteRound() {
if (!confirm("Slette denne runden permanent? Dette kan ikke angres, og gjelder for alle spillerne i runden.")) return
setDeleting(true)
try {
const res = await fetch(`/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
if (!res.ok) throw new Error()
router.replace("/my-rounds")
} catch {
setError("Klarte ikke å slette runden. Prøv igjen.")
setDeleting(false)
}
}
// Retter opp feil bane/utslag eller feil antall hull underveis i runden
// (2026-07-24) -- rører ALDRI allerede registrerte slag/putter/etc.,
// kun rating-grunnlaget. Backend avviser tydelig (400) hvis en deltaker
// ville mistet HCP-sporing, uten å skrive noe.
async function patchRound(body: Record<string, unknown>): Promise<{ ok: true } | { ok: false; message: string }> {
const res = await fetch(`/rounds/${roundId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) {
const errBody = await res.json().catch(() => null)
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere runden. Prøv igjen." }
}
const updated: ApiRound = await res.json()
setRound(updated)
// Par/stroke-indeks kan ha endret seg for ALLE deltakere -- hent hull-
// dataen på nytt for alle vi allerede har lastet, ikke bare aktiv spiller.
const participantIds = Object.keys(holesByParticipant)
for (const pid of participantIds) {
await loadHoles(pid)
}
return { ok: true }
}
if (error) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
</Link>
</div>
)
}
if (!round || !activePlayer) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
{/* Sticky header */}
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
<Link
href="/my-rounds"
aria-label="Tilbake til egne runder"
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{round.course_name_snapshot}</span>
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
{round.tee_name_snapshot}
</span>
<span className="flex items-center gap-1.5 tabular-nums">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
{formatDate(round.played_at)}
</span>
{round.started_at && (
<span className="flex items-center gap-1.5 tabular-nums">
<Clock aria-hidden="true" className="size-4 shrink-0" />
{formatTime(round.started_at)}
</span>
)}
</div>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
{error && (
<p role="alert" className="mb-4 text-base font-medium text-destructive">
{error}
</p>
)}
<div className="mb-5">
{showEditRound ? (
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
) : (
<button
type="button"
onClick={() => setShowEditRound(true)}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Settings2 aria-hidden="true" className="size-4" />
Rediger runde
</button>
)}
</div>
{/* Completed summary banner */}
{completed && (
<CompletedBanner
players={players}
duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
/>
)}
{/* Participant tabs */}
<PlayerTabs
players={players}
activePlayerId={activePlayer.id}
onSelect={setActivePlayerId}
onRemove={removeGuest}
onAdd={() => setShowAddGuest((v) => !v)}
addOpen={showAddGuest}
readOnly={readOnly}
/>
{!readOnly && (
<StatLevelPicker
value={activePlayer.statLevel}
onChange={(v) => updateStatLevel(activePlayer.id, v)}
/>
)}
{showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />}
{/* Hole navigation */}
{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}
currentHole={activeHole}
isPlayed={holeIsPlayed}
onSelect={(n) => {
setCurrentHole(n)
setPanelTab("score")
}}
/>
{/* Current hole panel */}
{hole && (
<section className="mt-5 flex flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<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}
</h2>
{showGir && (
<span className="shrink-0 rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
GIR
</span>
)}
</div>
{activePlayer.statLevel === "full" && <PanelTabs value={panelTab} onChange={setPanelTab} />}
{(activePlayer.statLevel !== "full" || panelTab === "score") ? (
<div className="flex flex-col gap-6">
<NumberPicker
label="Slag"
value={currentStat.strokes}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+"
parValue={hole.par}
onChange={(v) => updateStat({ strokes: v, played: true })}
readOnly={readOnly}
/>
{activePlayer.statLevel !== "strokes_only" && (
<NumberPicker
label="Putter"
value={currentStat.putts}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ putts: v })}
readOnly={readOnly}
/>
)}
</div>
) : (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = currentStat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => updateStat({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm 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",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
id="club"
value={currentStat.club}
onChange={(e) => updateStat({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={currentStat.teeShot}
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
value={currentStat.approach}
onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
</div>
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={currentStat.firstPuttBucket}
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
<NumberPicker
label="Anywayslag"
value={currentStat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ anywayStrokes: v })}
readOnly={readOnly}
/>
</div>
)}
<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
</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>
)}
</>
)}
{!completed && (
<Button type="button" disabled={completing} onClick={finishRound} className="mt-6 h-14 w-full rounded-2xl text-base font-bold shadow-sm">
<Check aria-hidden="true" className="size-5" />
{completing ? "Fullfører…" : "Fullfør runde"}
</Button>
)}
<Button
type="button"
variant="ghost"
disabled={deleting}
onClick={deleteRound}
className="mt-3 h-12 w-full rounded-2xl text-base font-semibold text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-5" />
{deleting ? "Sletter…" : "Slett runde"}
</Button>
</main>
</div>
)
}
// --- Completed banner ------------------------------------------------------
function CompletedBanner({ players, duration }: { players: Player[]; duration: string | null }) {
return (
<div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6">
<div className="flex items-center gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
<Trophy aria-hidden="true" className="size-6" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Ferdigspilt</span>
<h2 className="text-xl font-extrabold tracking-tight text-foreground">Runde fullført</h2>
</div>
{duration && (
<div className="ml-auto flex flex-col items-end">
<span className="text-xs font-medium text-muted-foreground">Tid brukt</span>
<span className="flex items-center gap-1.5 text-lg font-extrabold tabular-nums text-foreground">
<Clock aria-hidden="true" className="size-4 text-primary" />
{duration}
</span>
</div>
)}
</div>
<ul className="flex flex-col divide-y divide-primary/20 overflow-hidden rounded-2xl border border-primary/20 bg-card">
{players.map((player) => {
const counts = player.countsForHandicap && player.scoreDifferential !== null
return (
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-3">
<span className="text-base font-bold text-foreground">{player.name}</span>
{counts ? (
<span className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Differensial</span>
<span className="text-lg font-extrabold tabular-nums text-foreground">
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
</span>
</span>
) : (
<span className="text-sm font-semibold text-muted-foreground">Telte ikke mot HCP</span>
)}
</li>
)
})}
</ul>
</div>
)
}
// --- Player tabs -----------------------------------------------------------
function PlayerTabs({
players,
activePlayerId,
onSelect,
onRemove,
onAdd,
addOpen,
readOnly,
}: {
players: Player[]
activePlayerId: string
onSelect: (id: string) => void
onRemove: (id: string) => void
onAdd: () => void
addOpen: boolean
readOnly: boolean
}) {
return (
<div className="-mx-5 overflow-x-auto px-5">
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
{players.map((player) => {
const active = player.id === activePlayerId
const canRemove = !player.isSelf && !readOnly
return (
<div
key={player.id}
className={cn(
"flex min-h-12 shrink-0 items-center gap-1 rounded-2xl border pl-4 transition-colors",
canRemove ? "pr-1.5" : "pr-4",
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
<button type="button" role="tab" aria-selected={active} onClick={() => onSelect(player.id)} className="text-base font-bold">
{player.name}
</button>
{canRemove && (
<button
type="button"
onClick={() => onRemove(player.id)}
aria-label={`Fjern ${player.name}`}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-xl transition-colors",
active ? "text-primary-foreground/80 hover:bg-primary-foreground/20" : "text-muted-foreground hover:bg-accent",
)}
>
<X aria-hidden="true" className="size-4" />
</button>
)}
</div>
)
})}
{!readOnly && (
<button
type="button"
onClick={onAdd}
aria-label="Legg til gjestespiller"
aria-expanded={addOpen}
className="flex min-h-12 shrink-0 items-center gap-1.5 rounded-2xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<Plus aria-hidden="true" className="size-5" />
Gjest
</button>
)}
</div>
</div>
)
}
// --- Add guest form --------------------------------------------------------
function AddGuestForm({
onAdd,
onCancel,
}: {
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
const [gender, setGender] = useState<Gender>("male")
const [hcp, setHcp] = useState("")
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null, statLevel)
}
return (
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-5 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
<div className="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-base font-semibold">
Navn
</Label>
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
</div>
<ChoiceRow
label="Kjønn"
options={[
{ value: "male", label: "Mann" },
{ value: "female", label: "Kvinne" },
{ value: "other", label: "Annet" },
]}
value={gender}
onChange={(v) => setGender(v as Gender)}
readOnly={false}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-hcp" className="text-base font-semibold">
HCP <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="guest-hcp"
inputMode="decimal"
type="number"
step="0.1"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
placeholder="F.eks. 18"
className="h-12 rounded-2xl text-base"
/>
</div>
<ChoiceRow
label="Statistikk for denne spilleren"
options={[
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]}
value={statLevel}
onChange={(v) => setStatLevel(v as StatLevel)}
readOnly={false}
/>
<div className="flex gap-3">
<Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
Legg til
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-12 rounded-2xl px-6 text-base font-semibold">
Avbryt
</Button>
</div>
</form>
)
}
// --- Rediger runde (bane/utslag/antall hull) -------------------------------
// Retter opp feil bane eller feil antall hull underveis -- rører ALDRI
// allerede registrerte slag/putter/etc. (backend garanterer dette, se
// PATCH /rounds/{id}). Kun tilgjengelig før runden er fullført.
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
type ApiPersonalCourseSummary = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
// Konverterer til/fra <input type="datetime-local">-verdiformatet
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
// tidsstempler (UTC), kun visningen er lokal.
function toDatetimeLocalValue(iso: string | null): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
return local.toISOString().slice(0, 16)
}
function fromDatetimeLocalValue(value: string): string | null {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
function EditRoundPanel({
round,
onPatch,
onClose,
}: {
round: ApiRound
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onClose: () => void
}) {
const isCompleted = round.completed_at !== null
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18)
const [startHole, setStartHole] = useState(String(round.start_hole))
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
const [saving, setSaving] = useState(false)
const [showChangeCourse, setShowChangeCourse] = useState(false)
const [error, setError] = useState<string | null>(null)
const hasMetadataChanges =
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
toDatetimeLocalValue(round.started_at) !== startedAt ||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt)
async function saveMetadata() {
setSaving(true)
setError(null)
const body: Record<string, unknown> = {}
if (!isCompleted) {
if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole)
}
if (toDatetimeLocalValue(round.started_at) !== startedAt) body.started_at = fromDatetimeLocalValue(startedAt)
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
body.completed_at = fromDatetimeLocalValue(completedAt)
}
const result = await onPatch(body)
if (!result.ok) setError(result.message)
setSaving(false)
}
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Rediger runde</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{!isCompleted && (
<>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Antall hull</span>
<div className="flex gap-2">
{([9, 18] as const).map((n) => (
<button
key={n}
type="button"
onClick={() => setHolesPlanned(n)}
aria-pressed={holesPlanned === n}
className={cn(
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
holesPlanned === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{n} hull
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-start-hole" className="text-sm font-semibold">
Starthull
</Label>
<select
id="edit-start-hole"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
<option key={n} value={String(n)}>
Hull {n}
</option>
))}
</select>
</div>
</>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="edit-started-at" className="text-sm font-semibold">
Utslagstid
</Label>
<Input
id="edit-started-at"
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
{isCompleted && (
<div className="flex flex-col gap-2">
<Label htmlFor="edit-completed-at" className="text-sm font-semibold">
Fullført-tidspunkt
</Label>
<Input
id="edit-completed-at"
type="datetime-local"
value={completedAt}
onChange={(e) => setCompletedAt(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
)}
{hasMetadataChanges && (
<Button type="button" disabled={saving} onClick={saveMetadata} className="h-11 rounded-xl text-sm font-bold">
{saving ? "Lagrer…" : "Lagre endringer"}
</Button>
)}
{!isCompleted && (
<div className="border-t border-border pt-4">
{showChangeCourse ? (
<ChangeCourseForm onPatch={onPatch} onCancel={() => setShowChangeCourse(false)} />
) : (
<button
type="button"
onClick={() => setShowChangeCourse(true)}
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-primary"
>
<Search aria-hidden="true" className="size-4" />
Bytt bane
</button>
)}
</div>
)}
</div>
)
}
function ChangeCourseForm({
onPatch,
onCancel,
}: {
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onCancel: () => void
}) {
const [source, setSource] = useState<"teeoff" | "custom" | null>(null)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// Teeoff-søk
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
const [officialCourses, setOfficialCourses] = useState<ApiOfficialCourseOption[] | null>(null)
const [selectedOfficialCourse, setSelectedOfficialCourse] = useState<ApiOfficialCourseOption | null>(null)
// Egen bane-søk
const [customQuery, setCustomQuery] = useState("")
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)
const [teeName, setTeeName] = useState("")
useEffect(() => {
if (source !== "custom") return
let cancelled = false
const timer = setTimeout(async () => {
try {
const res = await fetch(`/personal-courses?q=${encodeURIComponent(customQuery.trim())}`, { credentials: "include" })
if (res.ok && !cancelled) setCustomResults(await res.json())
} catch {
// Stille -- listen blir bare uendret, ingen kritisk feil å vise her.
}
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [source, customQuery])
async function searchFacilities() {
setError(null)
try {
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
if (!res.ok) throw new Error()
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
}
}
async function pickFacility(facility: ApiFacility) {
setSelectedFacility(facility)
setError(null)
try {
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
if (!res.ok) throw new Error()
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setOfficialCourses(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
}
}
async function pickCustomCourse(course: ApiPersonalCourseSummary) {
setError(null)
try {
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
if (!res.ok) throw new Error()
setSelectedCustomCourse(await res.json())
} catch {
setError("Klarte ikke å hente banedetaljer.")
}
}
const availableTees = selectedOfficialCourse?.tees ?? selectedCustomCourse?.tees ?? []
async function handleSubmit() {
if (!teeName) return
setSubmitting(true)
setError(null)
const body =
source === "teeoff" && selectedFacility && selectedOfficialCourse
? {
course_source: "teeoff",
teeoff_facility_slug: selectedFacility.slug,
teeoff_course_id: selectedOfficialCourse.teeoff_course_id,
tee_name: teeName,
}
: selectedCustomCourse
? { course_source: "custom", personal_course_id: selectedCustomCourse.id, tee_name: teeName }
: null
if (!body) {
setSubmitting(false)
return
}
const result = await onPatch(body)
setSubmitting(false)
if (!result.ok) {
setError(result.message)
return
}
onCancel()
}
return (
<div className="flex flex-col gap-3">
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke.
</p>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{source === null && (
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" onClick={() => setSource("teeoff")} className="h-11 rounded-xl text-sm font-bold">
Offisiell bane
</Button>
<Button type="button" variant="outline" onClick={() => setSource("custom")} className="h-11 rounded-xl text-sm font-bold">
Egen bane
</Button>
</div>
)}
{source === "teeoff" && !selectedFacility && (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
searchFacilities()
}
}}
placeholder="Søk klubbnavn…"
className="h-11 flex-1 rounded-xl text-base"
/>
<Button type="button" onClick={searchFacilities} className="h-11 shrink-0 rounded-xl text-sm font-bold">
Søk
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickFacility(f)}
className="flex w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>
)}
</button>
</li>
))}
{facilities.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
</ul>
)}
</div>
)}
{source === "teeoff" && selectedFacility && !selectedOfficialCourse && (
<div className="flex flex-col gap-2">
<button type="button" onClick={() => setSelectedFacility(null)} className="self-start text-sm font-semibold text-muted-foreground">
{selectedFacility.name}
</button>
{officialCourses === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{officialCourses.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => setSelectedOfficialCourse(c)}
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
>
{c.name}
</button>
</li>
))}
</ul>
)}
</div>
)}
{source === "custom" && !selectedCustomCourse && (
<div className="flex flex-col gap-2">
<Input
autoFocus
value={customQuery}
onChange={(e) => setCustomQuery(e.target.value)}
placeholder="Søk egendefinert bane…"
className="h-11 rounded-xl text-base"
/>
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{customResults.map((c) => (
<li key={c.id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickCustomCourse(c)}
className="flex w-full px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
>
{c.name}
</button>
</li>
))}
{customResults.length === 0 && (
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
</div>
)}
{(selectedOfficialCourse || selectedCustomCourse) && (
<div className="flex flex-col gap-3">
<span className="text-sm font-bold text-foreground">
{selectedOfficialCourse?.name ?? selectedCustomCourse?.name}
</span>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Utslag</span>
<div className="flex flex-wrap gap-2">
{availableTees.map((t) => (
<button
key={t.name}
type="button"
onClick={() => setTeeName(t.name)}
aria-pressed={teeName === t.name}
className={cn(
"h-10 rounded-lg border px-3 text-sm font-semibold transition-colors",
teeName === t.name
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{t.name}
</button>
))}
</div>
</div>
<Button type="button" disabled={submitting || !teeName} onClick={handleSubmit} className="h-11 rounded-xl text-sm font-bold">
{submitting ? "Bytter bane…" : "Bytt bane"}
</Button>
</div>
)}
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 self-start rounded-xl text-sm font-semibold text-muted-foreground">
Avbryt
</Button>
</div>
)
}
// --- Hole navigation -------------------------------------------------------
function HoleNav({
holes,
currentHole,
isPlayed,
onSelect,
}: {
holes: Hole[]
currentHole: number
isPlayed: (holeNumber: number) => boolean
onSelect: (holeNumber: number) => void
}) {
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",
)}
>
{h.holeNumber}
</button>
)
})}
</div>
</nav>
)
}
// --- Statistikknivå-velger ---------------------------------------------------
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
// overflødig -- score settes allerede automatisk til "spilt" idet et
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
// 2026-07-24 på brukerens eksplisitte bekreftelse.
function StatLevelPicker({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
const options: { value: StatLevel; label: string }[] = [
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Statistikk</span>
<div className="flex gap-1.5">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
// --- Number picker ---------------------------------------------------------
function NumberPicker({
label,
value,
directValues,
expandValues,
expandLabel,
parValue,
maxValue,
onChange,
readOnly,
}: {
label: string
value: number | null
directValues: number[]
expandValues: number[]
expandLabel: string
// Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst
// (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten
// å måtte huske det fra hull-overskriften.
parValue?: number
// 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
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
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */}
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{visibleValues.map((n) => {
const selected = value === n
const isPar = n === parValue
return (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={isPar ? `${label} ${n}, par` : `${label} ${n}`}
aria-pressed={selected}
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",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<span>{n}</span>
{isPar && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
par
</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>
)
}
// --- Panel tabs (Score / Statistikk) ----------------------------------------
// Erstatter den tidligere "Flere detaljer"-utvidbare seksjonen. Bevisst
// trykk-baserte faner, IKKE et sveip-panel -- skjermen har allerede to
// horisontalt scrollende rader (spillerfaner, hull-navigasjon), og enda en
// sveiperetning på samme skjerm ville vært forvirrende (V0s egen vurdering,
// 2026-07-24-runden, se FEATURE_BACKLOG.md for hele prompten).
function PanelTabs({ value, onChange }: { value: "score" | "stats"; onChange: (value: "score" | "stats") => void }) {
const tabs: Array<{ value: "score" | "stats"; label: string }> = [
{ value: "score", label: "Score" },
{ value: "stats", label: "Statistikk" },
]
return (
<div
role="tablist"
aria-label="Velg visning"
className="grid grid-cols-2 gap-1.5 rounded-2xl border border-border bg-muted/50 p-1.5"
>
{tabs.map((tab) => {
const active = value === tab.value
return (
<button
key={tab.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(tab.value)}
className={cn(
"min-h-12 rounded-xl text-base font-bold transition-colors",
active ? "bg-primary text-primary-foreground shadow-sm" : "text-foreground hover:bg-accent/60",
)}
>
{tab.label}
</button>
)
})}
</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>
)
}
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) ----------------------------------------
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="flex flex-wrap 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 flex-1 items-center justify-center rounded-2xl border px-4 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 (+/-) ---------------------------------------------------------
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>
)
}