teecup/frontend/components/round-detail.tsx
Erol Haagenrud c15c68efca Update Todos
Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py

Build /rounds list page + component

Build /rounds/new creation flow (course search/create, tee, date, holes)

Build /rounds/[id] detail page (participants, hole-by-hole stats, complete)

Link new feature from dashboard

Scratch-verify backend additions + typecheck production build

Deploy live after user confirmation

Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status
Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]).

What's new, in summary:

Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched.
Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card.
One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00

774 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Hull-for-hull-registrering for en frittstående runde (ADR-033). Én
// deltaker om gangen velges via fanene øverst -- hver har sitt eget sett
// med 18 round_hole-rader (opprettet ved runde-/deltaker-opprettelse).
// PATCH-endepunktet erstatter ALLE felt på hullet ved hver kall (ikke et
// ekte delvis-PATCH) -- derfor sendes alltid hele det gjeldende hullet,
// kun ETT felt endret, aldri bare det isolerte feltet som ble trykket på.
import type React from "react"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Check, ChevronLeft, ChevronRight, Plus, Trophy, UserPlus, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
type Gender = "m" | "f" | "x"
type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
is_owner: boolean
gender: Gender
handicap_index_snapshot: number | null
course_handicap_snapshot: number | null
counts_for_handicap: boolean
score_differential: number | null
}
type ApiRound = {
id: string
course_source: string
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
completed_at: string | null
participants: ApiParticipant[]
}
type TeeShotResult = "fairway" | "left" | "right"
type ApproachResult = "hit" | "long" | "short" | "left" | "right"
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: TeeShotResult | null
approach_result: ApproachResult | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_m: number | null
}
function holeOrder(startHole: number): number[] {
return Array.from({ length: 18 }, (_, i) => ((startHole - 1 + i) % 18) + 1)
}
function participantLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : p.guest_name ?? "Gjest"
}
export function RoundDetail({ roundId }: { roundId: string }) {
const router = useRouter()
const [round, setRound] = useState<ApiRound | null>(null)
const [error, setError] = useState<string | null>(null)
const [activeParticipantId, setActiveParticipantId] = useState<string | null>(null)
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
const [currentHole, setCurrentHole] = useState<number>(1)
const [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false)
const loadRound = useCallback(async () => {
try {
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
if (res.status === 401) {
router.replace("/")
return
}
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)
setActiveParticipantId((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, router])
useEffect(() => {
void loadRound()
}, [loadRound])
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 (activeParticipantId && !holesByParticipant[activeParticipantId]) {
void loadHoles(activeParticipantId)
}
}, [activeParticipantId, holesByParticipant, loadHoles])
async function handleUpdateHole(patch: Partial<ApiHole>) {
if (!activeParticipantId || !round) return
const holes = holesByParticipant[activeParticipantId]
const existing = holes?.find((h) => h.hole_number === currentHole)
if (!existing) return
const merged: ApiHole = { ...existing, ...patch }
const res = await fetch(`/rounds/${roundId}/participants/${activeParticipantId}/holes/${currentHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
played: merged.played,
score: merged.score,
putts: merged.putts,
club_off_tee: merged.club_off_tee,
tee_shot_result: merged.tee_shot_result,
approach_result: merged.approach_result,
chip_count: merged.chip_count,
bunker_shot_count: merged.bunker_shot_count,
penalty_strokes: merged.penalty_strokes,
first_putt_distance_m: merged.first_putt_distance_m,
}),
})
if (!res.ok) return
const updated: ApiHole = await res.json()
setHolesByParticipant((prev) => ({
...prev,
[activeParticipantId]: (prev[activeParticipantId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
}))
}
async function handleAddGuest(name: string, gender: Gender, hcp: number | null) {
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ guest_name: name, gender, handicap_index: hcp }),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
return
}
setShowAddGuest(false)
await loadRound()
}
async function handleRemoveGuest(participantId: string) {
if (!confirm("Fjerne denne spilleren fra runden?")) return
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { method: "DELETE", credentials: "include" })
if (!res.ok) return
if (activeParticipantId === participantId) setActiveParticipantId(round?.participants.find((p) => p.is_owner)?.id ?? null)
await loadRound()
}
async function handleComplete() {
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)
}
}
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-sm font-medium text-destructive">{error}</p>
<Link href="/rounds" className="text-sm font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
</Link>
</div>
)
}
if (!round) {
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>
)
}
const activeParticipant = round.participants.find((p) => p.id === activeParticipantId) ?? null
const holes = activeParticipantId ? holesByParticipant[activeParticipantId] : undefined
const currentHoleData = holes?.find((h) => h.hole_number === currentHole) ?? null
const isCompleted = round.completed_at !== null
const order = holeOrder(round.start_hole)
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
<Link
href="/rounds"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Egne runder
</Link>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-1">
<h1 className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.course_name_snapshot}
</h1>
<p className="text-sm text-muted-foreground">
{round.tee_name_snapshot} · {round.holes_planned} hull · {formatDate(round.played_at)}
</p>
</div>
{isCompleted && <CompletedSummary round={round} />}
<div className="mb-4 flex flex-wrap items-center gap-2">
{round.participants.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setActiveParticipantId(p.id)}
aria-pressed={activeParticipantId === p.id}
className={cn(
"flex h-11 items-center gap-2 rounded-xl border px-3 text-sm font-bold transition-colors",
activeParticipantId === p.id
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{participantLabel(p)}
{!p.is_owner && !isCompleted && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation()
void handleRemoveGuest(p.id)
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.stopPropagation()
void handleRemoveGuest(p.id)
}
}}
aria-label={`Fjern ${p.guest_name}`}
className="-mr-1 flex size-6 items-center justify-center rounded-full hover:bg-black/10"
>
<X aria-hidden="true" className="size-3.5" />
</span>
)}
</button>
))}
{!isCompleted && !showAddGuest && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowAddGuest(true)}
className="size-11 shrink-0 rounded-xl"
aria-label="Legg til spiller"
>
<UserPlus aria-hidden="true" className="size-4" />
</Button>
)}
</div>
{showAddGuest && <AddGuestForm onCancel={() => setShowAddGuest(false)} onAdd={handleAddGuest} />}
{activeParticipant && (
<>
<HoleStrip order={order} holes={holes} currentHole={currentHole} onSelect={setCurrentHole} />
{currentHoleData ? (
<HolePanel
hole={currentHoleData}
readOnly={isCompleted}
onChange={handleUpdateHole}
/>
) : (
<div className="flex justify-center py-10">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)}
<div className="mt-4 flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setCurrentHole(order[(order.indexOf(currentHole) - 1 + 18) % 18])}
className="h-12 flex-1 rounded-xl text-sm font-bold"
>
<ChevronLeft aria-hidden="true" className="size-4" />
Forrige
</Button>
<Button
type="button"
onClick={() => setCurrentHole(order[(order.indexOf(currentHole) + 1) % 18])}
className="h-12 flex-1 rounded-xl text-sm font-bold"
>
Neste
<ChevronRight aria-hidden="true" className="size-4" />
</Button>
</div>
</>
)}
{!isCompleted && (
<Button
type="button"
disabled={completing}
onClick={handleComplete}
className="mt-8 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>
)}
</main>
</div>
)
}
// --- Fullført-sammendrag -----------------------------------------------------
function CompletedSummary({ round }: { round: ApiRound }) {
return (
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-primary/30 bg-primary/5 p-4">
<div className="flex items-center gap-2">
<Trophy aria-hidden="true" className="size-5 text-primary" />
<span className="text-base font-bold text-foreground">Runde fullført</span>
</div>
<div className="flex flex-col gap-2">
{round.participants.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-sm">
<span className="font-semibold text-foreground">{participantLabel(p)}</span>
<span className="text-muted-foreground">
{p.counts_for_handicap && p.score_differential !== null
? `Differensial: ${p.score_differential.toFixed(1)}`
: "Telte ikke mot HCP"}
</span>
</div>
))}
</div>
</div>
)
}
// --- Legg til gjest -----------------------------------------------------------
function AddGuestForm({
onCancel,
onAdd,
}: {
onCancel: () => void
onAdd: (name: string, gender: Gender, hcp: number | null) => void
}) {
const [name, setName] = useState("")
const [gender, setGender] = useState<Gender>("m")
const [hcp, setHcp] = useState("")
const valid = name.trim().length > 0
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid) return
onAdd(name.trim(), gender, hcp.trim() ? Number(hcp) : null)
setName("")
setHcp("")
}
return (
<form onSubmit={handleSubmit} className="mb-4 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-foreground">Legg til spiller</span>
<Button type="button" variant="ghost" size="icon" onClick={onCancel} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-sm font-semibold">
Navn
</Label>
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} className="h-11 rounded-xl text-base" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Label htmlFor="guest-gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="guest-gender"
value={gender}
onChange={(e) => setGender(e.target.value as Gender)}
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-medium text-foreground"
>
<option value="f">Dame</option>
<option value="m">Herre</option>
<option value="x">Annet</option>
</select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-hcp" className="text-sm font-semibold">
HCP (valgfritt)
</Label>
<Input
id="guest-hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
</div>
<Button type="submit" disabled={!valid} className="h-11 rounded-xl text-sm font-bold">
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
)
}
// --- Hull-navigasjon ----------------------------------------------------------
function HoleStrip({
order,
holes,
currentHole,
onSelect,
}: {
order: number[]
holes: ApiHole[] | undefined
currentHole: number
onSelect: (n: number) => void
}) {
return (
<div className="mb-4 flex gap-1.5 overflow-x-auto pb-1">
{order.map((n) => {
const h = holes?.find((x) => x.hole_number === n)
return (
<button
key={n}
type="button"
onClick={() => onSelect(n)}
aria-pressed={currentHole === n}
className={cn(
"flex size-11 shrink-0 flex-col items-center justify-center rounded-xl border text-sm font-bold tabular-nums transition-colors",
currentHole === n
? "border-primary bg-primary text-primary-foreground"
: h?.played
? "border-primary/40 bg-primary/10 text-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
)
})}
</div>
)
}
// --- Hull-panel -----------------------------------------------------------
function HolePanel({
hole,
readOnly,
onChange,
}: {
hole: ApiHole
readOnly: boolean
onChange: (patch: Partial<ApiHole>) => void
}) {
const [showDetails, setShowDetails] = useState(false)
const gir =
hole.approach_result === "hit" && hole.score !== null && hole.putts !== null
? hole.score - hole.putts <= hole.par - 2
: null
return (
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
<div className="flex items-baseline justify-between gap-2">
<div className="flex items-baseline gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {hole.hole_number}</h2>
<span className="text-lg font-bold text-muted-foreground">
· Par {hole.par} · Idx {hole.stroke_index}
</span>
</div>
{gir !== null && (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold",
gir ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
)}
>
{gir ? "GIR ✓" : "Ikke GIR"}
</span>
)}
</div>
<label className="flex items-center gap-2.5 text-sm font-semibold text-foreground">
<input
type="checkbox"
checked={hole.played}
disabled={readOnly}
onChange={(e) => onChange({ played: e.target.checked })}
className="size-5 rounded border-border"
/>
Hullet er spilt
</label>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Slag</span>
<NumberPicker value={hole.score} min={1} max={20} disabled={readOnly} onSelect={(n) => onChange({ score: n, played: true })} />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Putter</span>
<NumberPicker value={hole.putts} min={0} max={10} disabled={readOnly} onSelect={(n) => onChange({ putts: n })} />
</div>
<button
type="button"
onClick={() => setShowDetails((v) => !v)}
className="self-start text-sm font-semibold text-primary"
>
{showDetails ? "Skjul detaljer" : "Flere detaljer (kølle, retning, chip, bunker …)"}
</button>
{showDetails && (
<div className="flex flex-col gap-4 border-t border-border pt-4">
<div className="flex flex-col gap-2">
<Label htmlFor="club-off-tee" className="text-sm font-semibold">
Kølle brukt ved utslaget
</Label>
<Input
id="club-off-tee"
disabled={readOnly}
value={hole.club_off_tee ?? ""}
onChange={(e) => onChange({ club_off_tee: e.target.value || null })}
placeholder="F.eks. Driver"
className="h-11 rounded-xl text-base"
/>
</div>
{hole.par >= 4 && (
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Utslag</span>
<div className="grid grid-cols-3 gap-2">
{(["left", "fairway", "right"] as const).map((v) => (
<ChoiceButton
key={v}
active={hole.tee_shot_result === v}
disabled={readOnly}
onClick={() => onChange({ tee_shot_result: v })}
>
{v === "fairway" ? "Fairway" : v === "left" ? "Venstre" : "Høyre"}
</ChoiceButton>
))}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Innspill</span>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
{(["left", "short", "hit", "long", "right"] as const).map((v) => (
<ChoiceButton
key={v}
active={hole.approach_result === v}
disabled={readOnly}
onClick={() => onChange({ approach_result: v })}
>
{v === "hit" ? "Traff" : v === "long" ? "Langt" : v === "short" ? "Kort" : v === "left" ? "Venstre" : "Høyre"}
</ChoiceButton>
))}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<SmallStepper label="Chip" value={hole.chip_count} disabled={readOnly} onChange={(n) => onChange({ chip_count: n })} />
<SmallStepper label="Bunker" value={hole.bunker_shot_count} disabled={readOnly} onChange={(n) => onChange({ bunker_shot_count: n })} />
<SmallStepper label="Straffeslag" value={hole.penalty_strokes} disabled={readOnly} onChange={(n) => onChange({ penalty_strokes: n })} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="first-putt" className="text-sm font-semibold">
Avstand første putt (m)
</Label>
<Input
id="first-putt"
inputMode="decimal"
disabled={readOnly}
value={hole.first_putt_distance_m ?? ""}
onChange={(e) => onChange({ first_putt_distance_m: e.target.value === "" ? null : Number(e.target.value) })}
className="h-11 rounded-xl text-base"
/>
</div>
</div>
)}
</div>
)
}
function ChoiceButton({
active,
disabled,
onClick,
children,
}: {
active: boolean
disabled: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
aria-pressed={active}
className={cn(
"flex h-12 items-center justify-center rounded-xl border text-sm font-bold transition-colors disabled:opacity-60",
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{children}
</button>
)
}
function SmallStepper({
label,
value,
disabled,
onChange,
}: {
label: string
value: number | null
disabled: boolean
onChange: (n: number) => void
}) {
const current = value ?? 0
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled || current <= 0}
onClick={() => onChange(Math.max(0, current - 1))}
className="size-9 shrink-0 rounded-lg"
aria-label={`Reduser ${label}`}
>
</Button>
<span className="w-6 text-center text-base font-bold tabular-nums text-foreground">{current}</span>
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled}
onClick={() => onChange(current + 1)}
className="size-9 shrink-0 rounded-lg"
aria-label={`Øk ${label}`}
>
+
</Button>
</div>
</div>
)
}
// Tallvelger, samme mønster som StrokePicker i session-scorecard.tsx: rask
// direkte-trykk for de vanligste verdiene, med en "flere"-utvidelse for
// resten -- ikke en +/- stepper (for mange klikk for typiske slagtall).
function NumberPicker({
value,
min,
max,
disabled,
onSelect,
}: {
value: number | null
min: number
max: number
disabled: boolean
onSelect: (n: number) => void
}) {
const splitAt = Math.min(min + 8, max)
const [showHigh, setShowHigh] = useState(value !== null && value > splitAt)
useEffect(() => {
setShowHigh(value !== null && value > splitAt)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
const low = Array.from({ length: splitAt - min + 1 }, (_, i) => min + i)
const high = Array.from({ length: max - splitAt }, (_, i) => splitAt + 1 + i)
const numbers = showHigh ? high : low
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-5 gap-2">
{numbers.map((n) => (
<button
key={n}
type="button"
disabled={disabled}
onClick={() => onSelect(n)}
aria-pressed={value === n}
className={cn(
"flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95 disabled:opacity-60",
value === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
))}
{!showHigh && high.length > 0 && (
<button
type="button"
onClick={() => setShowHigh(true)}
className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-background text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
>
{splitAt + 1}+
</button>
)}
</div>
{showHigh && (
<button type="button" onClick={() => setShowHigh(false)} className="self-start text-xs font-semibold text-primary">
Tilbake
</button>
)}
</div>
)
}
function formatDate(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date)
}