"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, useRef, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, CalendarDays, Check, ChevronDown, 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 teeName: string 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 // Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått // visningsnavn for en lenket bruker (eier eller medspiller). display_name: string is_owner: boolean gender: ApiGender // Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen // deltakers, redigerbart av eieren kun for DENNE runden. tee_name_snapshot: string handicap_index_snapshot: number | null course_handicap_snapshot: number | null counts_for_handicap: boolean score_differential: number | null stat_level: StatLevel } type PersonMatch = { id: string first_name: string last_name: string avatar_url: string | null home_club: string | null handicap_index: number | null } type ApiTeeOption = { name: string genders: ApiGender[] } type ApiRound = { id: string name: string | null 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 strokes_received: 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, } } // Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26): "Deg" skal vises // for DEN SOM SER PÅ, ikke alltid runde-eieren -- en lenket medspiller som // åpner runden skal se seg selv som "Deg" og eieren under sitt eget navn. function playerLabel(p: ApiParticipant, viewerId: string | null): string { return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name } // --- Component ------------------------------------------------------------- export function RoundDetail({ roundId }: { roundId: string }) { const router = useRouter() const [round, setRound] = useState(null) const [error, setError] = useState(null) const [activePlayerId, setActivePlayerId] = useState(null) const [holesByParticipant, setHolesByParticipant] = useState>({}) // 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([]) // Den innloggede brukerens egen id (ADR-036 fase 3-utvidelsen, // 2026-07-26) -- runden kan nå åpnes av en lenket medspiller, ikke bare // eieren, så "hvem er 'Deg'" og "hvem har lov til å forvalte runden" // avhenger av DENNE, ikke av `is_owner` alene. const [viewerId, setViewerId] = useState(null) // `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(null) const [showAddGuest, setShowAddGuest] = useState(false) const [completing, setCompleting] = useState(false) const [deleting, setDeleting] = useState(false) const [showEditRound, setShowEditRound] = useState(false) const [showEditParticipant, setShowEditParticipant] = useState(false) // "Flere detaljer" er kollapset som default (etterspurt av bruker // 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye // stablet oppå hverandre for et enkelt slag-registrering) slik at en // rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt. const [detailsOpen, setDetailsOpen] = useState(false) // Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger // NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet // helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i // panelet) er utenfor skjermen (rapportert av bruker 2026-07-25). const holePanelRef = useRef(null) function scrollToHolePanel() { holePanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }) } 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: { id: string; bag_clubs: string[] } | null) => { if (!cancelled && data) { setOwnBagClubs(data.bag_clubs) setViewerId(data.id) } }) .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]) // Alltid gjeldende activePlayerId tilgjengelig INNI WebSocket-handleren // under, uten å måtte koble socket-en til/fra ved hvert fanebytte (som en // vanlig closure over `activePlayerId` i effektens dependency-array ville // krevd -- ville ødelagt WS-tilkoblingen hver gang brukeren bytter fane). const activePlayerIdRef = useRef(null) useEffect(() => { activePlayerIdRef.current = activePlayerId }, [activePlayerId]) // Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"- // signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST- // kallene den uansett gjør ved førstegangslasting. Aktiv spillers hull // hentes på nytt DIREKTE (overskriver når svaret kommer, ingen mellomsteg // med tom stat som ville blinket til for spilleren som selv nettopp // registrerte et slag) -- de ANDRE spillernes cache droppes i stedet, slik // at et senere fanebytte henter friskt fremfor en stale verdi. useEffect(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) socket.onmessage = () => { void loadRound() const active = activePlayerIdRef.current setHolesByParticipant((prev) => (active && prev[active] ? { [active]: prev[active] } : {})) if (active) void loadHoles(active) } return () => socket.close() }, [roundId, loadRound, loadHoles]) const completed = round?.completed_at != null const readOnly = completed const players: Player[] = round?.participants.map((p) => ({ id: p.id, name: playerLabel(p, viewerId), gender: apiGenderToUi(p.gender), hcp: p.handicap_index_snapshot, teeName: p.tee_name_snapshot, isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner, countsForHandicap: p.counts_for_handicap, scoreDifferential: p.score_differential, statLevel: p.stat_level, })) ?? [] // Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne // medspillere) -- en lenket medspiller kan se runden og registrere // score for hele flighten, men ikke dette (ADR-036 fase 3-utvidelsen). const isOwnerViewer = viewerId !== null && (round?.participants.some((p) => p.is_owner && p.user_id === viewerId) ?? false) 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) { 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]) scrollToHolePanel() } function goNext() { const i = holeOrder.indexOf(activeHole) setCurrentHole(holeOrder[(i + 1) % holeOrder.length]) scrollToHolePanel() } 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 addSearchedParticipant(userId: string, statLevel: StatLevel): Promise { const res = await fetch(`/rounds/${roundId}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ user_id: userId, stat_level: statLevel }), }) if (!res.ok) { const body = await res.json().catch(() => null) return body?.detail?.message ?? "Klarte ikke å legge til spilleren." } setShowAddGuest(false) const created: ApiParticipant = await res.json() await loadRound() setActivePlayerId(created.id) return null } 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, ) } // Utslag+HCP for en enkelt deltaker (2026-07-26) -- gjelder KUN denne // runden, rører aldri spillerens egen profil. Bygget for å dekke at en // medspiller kan spille fra et annet utslag enn eieren, og at en foreslått // HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen. async function patchParticipant( participantId: string, body: Record, ): Promise<{ ok: true } | { ok: false; message: string }> { const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { 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 spilleren. Prøv igjen." } } const updated: ApiParticipant = await res.json() setRound((prev) => prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev, ) return { ok: true } } 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): 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 (

{error}

Tilbake til egne runder
) } if (!round || !activePlayer) { return (
) } return (
{/* Sticky header */}
{error && (

{error}

)} {/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme nøytrale trigger-stil som hverandre) -- disse lå tidligere som store, fremtredende knapper RETT under "Neste hull"-navigasjonen nederst i hull-panelet, som gjorde det altfor lett å trykke feil ved et uhell mens man bare skulle bla mellom hull (rapportert av bruker 2026-07-25). Ved å flytte dem hit må man aktivt scrolle OPP forbi hull-registreringen for å nå dem i det hele tatt. */}
{isOwnerViewer && ( )} {!completed && ( )} {isOwnerViewer && ( )}
{showEditRound && isOwnerViewer && ( setShowEditRound(false)} /> )}
{/* Completed summary banner */} {completed && ( )} {/* Participant tabs */} setShowAddGuest((v) => !v)} addOpen={showAddGuest} readOnly={readOnly} canManage={isOwnerViewer} /> {!readOnly && ( updateStatLevel(activePlayer.id, v)} /> )} {/* Utslag+HCP for DENNE runden (2026-07-26) -- ikke alle deltakere spiller nødvendigvis fra samme utslag som eieren, og en auto-foreslått HCP (fra profilen ved søk) kan være feil for akkurat denne dagen. Endrer aldri spillerens egen profil. */} {!readOnly && (
Utslag: {activePlayer.teeName} {" · "} HCP:{" "} {activePlayer.hcp !== null ? activePlayer.hcp.toFixed(1).replace(".", ",") : "ikke satt"} {isOwnerViewer && ( )}
)} {showEditParticipant && isOwnerViewer && !readOnly && ( setShowEditParticipant(false)} /> )} {/* Skjules når runden er fullført (2026-07-25, rapportert av bruker som "veldig mye dobbel informasjon") -- CompletedBanner sin "Se full rundestatistikk"-lenke dekker nå akkurat det samme, langt grundigere. Fortsatt nyttig som live fremdriftsoversikt mens runden pågår. */} {!completed && } {showAddGuest && !readOnly && isOwnerViewer && ( setShowAddGuest(false)} /> )} {/* Hole navigation */} {holes.length === 0 ? (
) : ( <> { setCurrentHole(n) scrollToHolePanel() }} /> {/* Current hole panel */} {hole && (
{/* Høyre kolonne reserverer plass ved siden av hull-headeren til en fremtidig avstandsmåling-indikator (ikke bygget ennå, men avklart 2026-07-25 at det kommer) -- GIR-merket ligger under den reserverte plassen slik at et senere avstand-chip her ikke krever noen layout-endring. */}

Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index} {/* Mottatte slag for AKTIV spiller på dette hullet (2026-07-26) -- samme allerede hentede strokes_received som ScoreSoFar/ leaderboardet bruker til netto, bare aldri vist FØR scoring her. Golfvis fortegn (ekte minustegn), kun vist når spilleren faktisk mottar minst ett slag her. */} {currentApiHole?.strokes_received != null && currentApiHole.strokes_received > 0 && ( <> · −{currentApiHole.strokes_received} )}

{showGir && ( GIR )}
{/* Slag/Putter og (ved statLevel="full") alle detaljene vises ALLTID samlet under hverandre -- ikke bak en fane man må oppdage og trykke på (funnet 2026-07-25: brukeren huket av "All statistikk", men fikk aldri se noe utover slag/putter siden det lå bak en fane). */}
updateStat({ strokes: v, played: true })} readOnly={readOnly} /> {activePlayer.statLevel !== "strokes_only" && ( updateStat({ putts: v })} readOnly={readOnly} /> )} {activePlayer.statLevel === "full" && ( <> {/* Rett under antall putter (etterspurt av bruker 2026-07-25) -- hører naturlig sammen med Putter-feltet rett over, ikke med kølle/retning/chip-gruppen lenger ned. */} updateStat({ firstPuttBucket: v as PuttBucket })} readOnly={readOnly} /> {/* Alt annet lever bak én kollapsbar seksjon, lukket som default -- panelet forblir kort for det vanlige slag-/putt-registreringstilfellet. */}
{detailsOpen && (
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
{ownBagClubs.map((club) => { const selected = currentStat.club === club return ( ) })}
) : ( updateStat({ club: e.target.value })} placeholder="F.eks. Driver, 3-jern" disabled={readOnly} className="h-12 rounded-2xl text-base" /> )}
{hole.par !== 3 && ( updateStat({ teeShot: v as TeeShot })} readOnly={readOnly} /> )} updateStat({ approach: v as Approach })} readOnly={readOnly} />
updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} /> updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
updateStat({ anywayStrokes: v })} readOnly={readOnly} />
)}
)}
)} )}
) } // --- Completed banner ------------------------------------------------------ function CompletedBanner({ players, duration, roundId, }: { players: Player[] duration: string | null roundId: string }) { return (
Ferdigspilt

Runde fullført

{duration && (
Tid brukt
)}
    {players.map((player) => { const counts = player.countsForHandicap && player.scoreDifferential !== null return (
  • {player.name} {counts ? ( Differensial {player.scoreDifferential!.toFixed(1).replace(".", ",")} ) : ( Telte ikke mot HCP )}
  • ) })}
Se scorekort
) } // --- Score så langt (kompakt linje + utvidbar full oversikt) -------------- // Etterspurt av bruker 2026-07-25 med referanse til en annen golf-app -- // samme "Vis full oversikt"-mønster som allerede etablert i // session-scorecard.tsx (turnering-scoring), tilpasset til en enkelt // spillers frittstående runde (ingen motstander-side å vise her). // // Utvidet SAMME DAG, etterspurt av bruker: netto-/stableford-sum, putt-/ // kølle-statistikk-totaler, og grafisk fairway-/innspill-fordeling + // gjennomsnittlig score-til-par splittet på treff/bom. Appen har ingen // egen "spilleform"-innstilling for frittstående runder -- stableford // beregnes derfor alltid ut fra netto score der det er mulig (krever // registrert HCP), uavhengig av om brukeren "egentlig" spiller slagspill // eller stableford -- rent informativt, ikke en offisiell poengsum. // Standard stableford-poengtabell: netto dobbel bogey eller dårligere = 0, // netto par = 2, hvert slag bedre/dårligere enn par gir ett poeng mer/mindre. function stablefordPoints(netScore: number, par: number): number { return Math.max(0, 2 - (netScore - par)) } function avgScoreToPar(holes: ApiHole[]): number | null { const played = holes.filter((h) => h.played && h.score !== null) if (played.length === 0) return null const diffSum = played.reduce((sum, h) => sum + ((h.score as number) - h.par), 0) return diffSum / played.length } function formatSignedAvg(n: number): string { const fixed = Math.abs(n).toFixed(2) return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed } function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) { const [expanded, setExpanded] = useState(false) if (!holes || holes.length === 0) return null const orderedHoles = order .map((n) => holes.find((h) => h.hole_number === n)) .filter((h): h is ApiHole => h !== undefined) const playedSoFar = orderedHoles.filter((h) => h.played && h.score !== null) if (playedSoFar.length === 0) return null const totalStrokes = playedSoFar.reduce((sum, h) => sum + (h.score ?? 0), 0) const totalPar = playedSoFar.reduce((sum, h) => sum + h.par, 0) const toPar = totalStrokes - totalPar const toParLabel = toPar === 0 ? "Par" : toPar > 0 ? `+${toPar}` : `${toPar}` // Netto/stableford krever at deltakeren har en beregnet course handicap // (strokes_received er da satt på ALLE hull, ellers på ingen) -- uten // det vises rett og slett ikke disse to (f.eks. en gjest uten HCP). const withNetto = playedSoFar.filter((h) => h.strokes_received !== null) const hasNetto = withNetto.length > 0 const nettoTotal = hasNetto ? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0) : null const stablefordTotal = hasNetto ? withNetto.reduce( (sum, h) => sum + stablefordPoints((h.score as number) - (h.strokes_received as number), h.par), 0, ) : null const puttsPlayed = orderedHoles.filter((h) => h.putts !== null) const puttsTotal = puttsPlayed.length > 0 ? puttsPlayed.reduce((sum, h) => sum + (h.putts as number), 0) : null const chipPlayed = orderedHoles.filter((h) => h.chip_count !== null) const chipTotal = chipPlayed.length > 0 ? chipPlayed.reduce((sum, h) => sum + (h.chip_count as number), 0) : null const bunkerPlayed = orderedHoles.filter((h) => h.bunker_shot_count !== null) const bunkerTotal = bunkerPlayed.length > 0 ? bunkerPlayed.reduce((sum, h) => sum + (h.bunker_shot_count as number), 0) : null const penaltyPlayed = orderedHoles.filter((h) => h.penalty_strokes !== null) const penaltyTotal = penaltyPlayed.length > 0 ? penaltyPlayed.reduce((sum, h) => sum + (h.penalty_strokes as number), 0) : null const anywayPlayed = orderedHoles.filter((h) => h.anyway_strokes !== null) const anywayTotal = anywayPlayed.length > 0 ? anywayPlayed.reduce((sum, h) => sum + (h.anyway_strokes as number), 0) : null // Fairwaytreff spores kun på hull som ikke er par 3 (samme regel som // registrerings-skjemaet -- DirectionCross for Utslag vises ikke der). const fairwayTracked = orderedHoles.filter((h) => h.par !== 3 && h.tee_shot_result !== null) const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway") const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left") const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right") const fairwayMiss = [...fairwayLeft, ...fairwayRight] const avgToParFairwayHit = avgScoreToPar(fairwayHit) const avgToParFairwayMiss = avgScoreToPar(fairwayMiss) const approachTracked = orderedHoles.filter((h) => h.approach_result !== null) const approachHit = approachTracked.filter((h) => h.approach_result === "hit") const approachShort = approachTracked.filter((h) => h.approach_result === "short") const approachLong = approachTracked.filter((h) => h.approach_result === "long") const approachLeft = approachTracked.filter((h) => h.approach_result === "left") const approachRight = approachTracked.filter((h) => h.approach_result === "right") const approachMiss = [...approachShort, ...approachLong, ...approachLeft, ...approachRight] const avgToParApproachHit = avgScoreToPar(approachHit) const avgToParApproachMiss = avgScoreToPar(approachMiss) let running = 0 return (
{expanded && (
{hasNetto && 0 ? `+${nettoTotal}` : String(nettoTotal)} />} {hasNetto && } {puttsTotal !== null && } {chipTotal !== null && } {bunkerTotal !== null && } {penaltyTotal !== null && } {anywayTotal !== null && }
{hasNetto && } {orderedHoles.map((h) => { const played = h.played && h.score !== null if (played) running += h.score as number const netto = played && h.strokes_received !== null ? (h.score as number) - h.strokes_received : null const points = played && netto !== null ? stablefordPoints(netto, h.par) : null return ( {hasNetto && ( )} ) })}
Hull Par Score NettoStablefordSum
{h.hole_number} {h.par} {played ? h.score : "–"} {netto ?? "–"}{points ?? "–"}{played ? running : "–"}
{fairwayTracked.length > 0 && (
Fairwaytreff {(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && (
{avgToParFairwayHit !== null && ( Til par ved fairwaytreff:{" "} {formatSignedAvg(avgToParFairwayHit)} )} {avgToParFairwayMiss !== null && ( Til par ved bom på fairway:{" "} {formatSignedAvg(avgToParFairwayMiss)} )}
)}
)} {approachTracked.length > 0 && (
Innspill {(avgToParApproachHit !== null || avgToParApproachMiss !== null) && (
{avgToParApproachHit !== null && ( Til par ved treff på innspill:{" "} {formatSignedAvg(avgToParApproachHit)} )} {avgToParApproachMiss !== null && ( Til par ved bom på innspill:{" "} {formatSignedAvg(avgToParApproachMiss)} )}
)}
)}
)}
) } function StatPill({ label, value }: { label: string; value: string }) { return (
{value} {label}
) } // Ett rektangel delt i fargede segmenter proporsjonalt med antall -- // samme visuelle idé som SegmentedBar i tournament-leaderboard.tsx, men // generalisert til N kategorier (ikke bare to lag). function DistributionBar({ segments }: { segments: { label: string; count: number; colorClass: string }[] }) { const total = segments.reduce((sum, s) => sum + s.count, 0) if (total === 0) return null return (
{segments .filter((s) => s.count > 0) .map((s) => (
{segments .filter((s) => s.count > 0) .map((s) => ( ))}
) } // --- Player tabs ----------------------------------------------------------- function PlayerTabs({ players, activePlayerId, onSelect, onRemove, onAdd, addOpen, readOnly, canManage, }: { players: Player[] activePlayerId: string onSelect: (id: string) => void onRemove: (id: string) => void onAdd: () => void addOpen: boolean readOnly: boolean // Kun runde-eieren kan legge til/fjerne medspillere (ADR-036 fase // 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt BYTTE // mellom faner for å registrere score for hele flighten. canManage: boolean }) { return (
{players.map((player) => { const active = player.id === activePlayerId const canRemove = !player.isSelf && !readOnly && canManage return (
{canRemove && ( )}
) })} {!readOnly && canManage && ( )}
) } // --- Legg til medspiller: søk etter ekte bruker, eller gjest uten konto ---- // (ADR-036 fase 3-utvidelsen, 2026-07-26 -- "+ Gjest" søkte tidligere ikke // etter spillere i det hele tatt, kun et rent tekstfelt.) function AddParticipantForm({ onAddGuest, onAddSearched, onCancel, }: { onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void onAddSearched: (userId: string, statLevel: StatLevel) => Promise onCancel: () => void }) { const [mode, setMode] = useState<"search" | "guest">("search") const [query, setQuery] = useState("") const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const [adding, setAdding] = useState(false) const [searchError, setSearchError] = useState(null) useEffect(() => { const trimmed = query.trim() if (trimmed.length < 2) { setResults([]) setSearching(false) return } setSearching(true) const handle = setTimeout(() => { fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: PersonMatch[]) => setResults(data)) .catch(() => setResults([])) .finally(() => setSearching(false)) }, 250) return () => clearTimeout(handle) }, [query]) async function handlePick(person: PersonMatch) { setAdding(true) setSearchError(null) const errorMessage = await onAddSearched(person.id, "strokes_only") setAdding(false) if (errorMessage) setSearchError(errorMessage) } if (mode === "guest") { return setMode("search")} /> } return (
{query.trim().length > 0 && query.trim().length < 2 && (

Skriv minst 2 tegn for å søke.

)}
{searchError && (

{searchError}

)} {results.length > 0 && (
    {results.map((person) => (
  • ))}
)} {!searching && query.trim().length >= 2 && results.length === 0 && (

Fant ingen med det navnet.

)}
) } // --- Add guest form (fallback for spillere uten TeeCup-konto) -------------- function AddGuestForm({ onAdd, onCancel, onBack, }: { onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void onCancel: () => void onBack?: () => void }) { const [name, setName] = useState("") const [gender, setGender] = useState("male") const [hcp, setHcp] = useState("") const [statLevel, setStatLevel] = useState("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 (
{onBack && ( )}
setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
setGender(v as Gender)} readOnly={false} />
setHcp(e.target.value)} placeholder="F.eks. 18" className="h-12 rounded-2xl text-base" />
setStatLevel(v as StatLevel)} readOnly={false} />
) } // --- 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 -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) => Promise<{ ok: true } | { ok: false; message: string }> onClose: () => void }) { const isCompleted = round.completed_at !== null const [name, setName] = useState(round.name ?? "") 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(null) const hasMetadataChanges = name.trim() !== (round.name ?? "") || (!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 = {} // Tom streng betyr "fjern navnet" (backend-kontrakt) -- send den derfor // alltid med når feltet faktisk er endret, aldri utelatt for å tømme. if (name.trim() !== (round.name ?? "")) body.name = name.trim() 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 (
Rediger runde
{error &&

{error}

}
setName(e.target.value)} placeholder={round.course_name_snapshot} maxLength={200} className="h-11 rounded-xl text-base" />
{!isCompleted && ( <>
Antall hull
{([9, 18] as const).map((n) => ( ))}
)}
setStartedAt(e.target.value)} className="h-11 rounded-xl text-base" />
{isCompleted && (
setCompletedAt(e.target.value)} className="h-11 rounded-xl text-base" />
)} {hasMetadataChanges && ( )} {!isCompleted && (
{showChangeCourse ? ( setShowChangeCourse(false)} /> ) : ( )}
)}
) } // --- Rediger utslag+HCP for én deltaker, KUN for denne runden (2026-07-26) - function EditParticipantPanel({ roundId, player, onPatch, onClose, }: { roundId: string player: Player onPatch: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> onClose: () => void }) { const [teeOptions, setTeeOptions] = useState(null) const [teeName, setTeeName] = useState(player.teeName) const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "") const [saving, setSaving] = useState(false) const [error, setError] = useState(null) useEffect(() => { let cancelled = false fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiTeeOption[]) => { if (!cancelled) setTeeOptions(data) }) .catch(() => { if (!cancelled) setTeeOptions([]) }) return () => { cancelled = true } }, [roundId]) const apiGender = uiGenderToApi(player.gender) const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender)) async function save() { setSaving(true) setError(null) const body: Record = {} if (teeName !== player.teeName) body.tee_name = teeName const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp if (Object.keys(body).length === 0) { setSaving(false) onClose() return } const result = await onPatch(player.id, body) setSaving(false) if (!result.ok) { setError(result.message) return } onClose() } return (
Rediger {player.name} for denne runden

Gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.

{error &&

{error}

}
{teeOptions !== null && availableTees.length === 0 && (

Fant ingen andre utslag med rating for dette kjønnet på denne banen.

)}
setHcp(e.target.value)} placeholder="F.eks. 18" className="h-12 rounded-2xl text-base" />
) } function ChangeCourseForm({ onPatch, onCancel, }: { onPatch: (body: Record) => Promise<{ ok: true } | { ok: false; message: string }> onCancel: () => void }) { const [source, setSource] = useState<"teeoff" | "custom" | null>(null) const [error, setError] = useState(null) const [submitting, setSubmitting] = useState(false) // Teeoff-søk const [query, setQuery] = useState("") const [facilities, setFacilities] = useState(null) const [selectedFacility, setSelectedFacility] = useState(null) const [officialCourses, setOfficialCourses] = useState(null) const [selectedOfficialCourse, setSelectedOfficialCourse] = useState(null) // Egen bane-søk const [customQuery, setCustomQuery] = useState("") const [customResults, setCustomResults] = useState([]) const [selectedCustomCourse, setSelectedCustomCourse] = useState(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 (

Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke.

{error &&

{error}

} {source === null && (
)} {source === "teeoff" && !selectedFacility && (
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" />
{facilities && (
    {facilities.map((f) => (
  • ))} {facilities.length === 0 &&
  • Ingen treff.
  • }
)}
)} {source === "teeoff" && selectedFacility && !selectedOfficialCourse && (
{officialCourses === null ? (

Laster baner…

) : (
    {officialCourses.map((c) => (
  • ))}
)}
)} {source === "custom" && !selectedCustomCourse && (
setCustomQuery(e.target.value)} placeholder="Søk egendefinert bane…" className="h-11 rounded-xl text-base" />
    {customResults.map((c) => (
  • ))} {customResults.length === 0 && (
  • Ingen treff.
  • )}
)} {(selectedOfficialCourse || selectedCustomCourse) && (
{selectedOfficialCourse?.name ?? selectedCustomCourse?.name}
Utslag
{availableTees.map((t) => ( ))}
)}
) } // --- Hole navigation ------------------------------------------------------- function HoleNav({ holes, currentHole, isPlayed, onSelect, }: { holes: Hole[] currentHole: number isPlayed: (holeNumber: number) => boolean onSelect: (holeNumber: number) => void }) { return ( ) } // --- 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 (
Statistikk
{options.map((opt) => ( ))}
) } // --- 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 (
{label} {/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */}
{visibleValues.map((n) => { const selected = value === n const isPar = n === parValue return ( ) })} {!showExpanded && !readOnly && cappedExpand.length > 0 && ( )}
) } // --- 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 ( ) } 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 =