"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 { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { RoundLeaderboardMini } from "@/components/round-leaderboard" 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 // Alltid den faktiske display_name (2026-07-27: fjernet "Deg"-erstatning // her -- se playerLabel() -- brøt "roster-kontekst → fullt navn" og ga en // "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av). name: string userId: string | null guestEmail: string | null isOwner: boolean gender: Gender hcp: number | null // Tildelte slag for RUNDEN (course handicap, ikke selve HCP-indeksen) -- // null når HCP ikke spores for spilleren (2026-07-26). courseHandicap: 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}` } // Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som // round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for // akkumulert score-så-langt per spiller i den nye scoringsflyten). function formatToPar(value: number): string { if (value === 0) return "E" return value > 0 ? `+${value}` : `−${Math.abs(value)}` } function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | null { if (!holes) return null const played = holes.filter((h) => h.played && h.score !== null) if (played.length === 0) return null const score = played.reduce((sum, h) => sum + (h.score as number), 0) const par = played.reduce((sum, h) => sum + h.par, 0) return { toPar: score - par, holesPlayed: played.length } } 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 // Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26). guest_email: 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, } } // Alltid det faktiske visningsnavnet -- ALDRI "Deg" (2026-07-27, compliance- // pass mot teecup-scorekort-og-entry-spec.md). "Deg" var tidligere en // viewer-relativ erstatning for selve navnet, men det produserte en ekte // "Deg Deg"-duplikat der navnet OGSÅ sto ved siden av en egen "Deg"-badge // (spillerkort, scoringslisten) -- og brøt spesifikasjonens eksplisitte // "roster-kontekst → fullt navn"-regel i skjermer uten noen badge i det // hele tatt (veiviserens header/kontekst-rad, "Neste: {navn}", fullført- // banneret). `isSelf` (se players-konstruksjonen under) er fortsatt // viewer-relativ og brukes alene til å vise "Deg"-badgen der det trengs. function playerLabel(p: ApiParticipant): string { return 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) // Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/ // spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26, // etterspurt av bruker etter å ha sammenlignet direkte med en // konkurrentapp -- forrige runde flyttet kun ScoreSoFar, men lot alt det // ANDRE administrative innholdet stå igjen øverst på samme side som // scoringen, som fortsatt gjorde siden "bråkete" før man i det hele tatt // når frem til oppgaven). "Score" er default -- den ENESTE fanen de // fleste trenger under selve spillingen. const [pageTab, setPageTab] = useState<"score" | "manage">("score") const [completing, setCompleting] = useState(false) const [deleting, setDeleting] = useState(false) const [showEditRound, setShowEditRound] = useState(false) // Hvilken spillers "Rediger"-panel som er åpent, om noen (2026-07-26 -- // panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan // derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere). const [editingPlayerId, setEditingPlayerId] = useState(null) // Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT // HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for // aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt). const [wizardPlayerId, setWizardPlayerId] = useState(null) // 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], ) // Laster hull for ALLE deltakere (2026-07-26, ikke bare aktiv spiller) -- // den nye scoringslisten viser akkumulert score-så-langt for HVER spiller // samtidig, ikke kun den ene som tilfeldigvis er "aktiv". useEffect(() => { if (!round) return for (const p of round.participants) { if (!holesByParticipant[p.id]) { void loadHoles(p.id) } } }, [round, holesByParticipant, loadHoles]) // Alltid gjeldende deltaker-id-er tilgjengelig INNI WebSocket-handleren // under, uten å måtte koble socket-en til/fra ved hver ny runde-lasting // (en vanlig closure over `round` i effektens dependency-array ville // krevd det). const participantIdsRef = useRef([]) useEffect(() => { participantIdsRef.current = round?.participants.map((p) => p.id) ?? [] }, [round]) // 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. Henter nå ALLE // deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten // viser alles akkumulerte score-så-langt samtidig. 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() for (const id of participantIdsRef.current) { void loadHoles(id) } } 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), userId: p.user_id, guestEmail: p.guest_email, isOwner: p.is_owner, gender: apiGenderToUi(p.gender), hcp: p.handicap_index_snapshot, courseHandicap: p.course_handicap_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) : [] // Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat // nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin // "Så langt i runden"-oversikt lenger ned som vises). const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat() async function updateWizardStat(patch: Partial) { if (readOnly || !wizardPlayerId) return const merged: HoleStat = { ...wizardStat, ...patch } const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/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, [wizardPlayerId]: (prev[wizardPlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), })) } 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() } // Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for // siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme // veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste -- // lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen. function advanceWizardPlayer() { const idx = players.findIndex((p) => p.id === wizardPlayerId) const isLast = idx === -1 || idx === players.length - 1 if (!isLast) { const next = players[idx + 1] setActivePlayerId(next.id) setWizardPlayerId(next.id) } else { setWizardPlayerId(null) setActivePlayerId(players[0]?.id ?? null) goNext() } } 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() } // Utslag+HCP+statistikknivå (og navn/kjønn/e-post for en gjest) 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}

)} {/* To faner: "Score" (default, KUN hull-navigasjon + registrering -- det eneste de fleste trenger midt i en runde) og "Spillere og runde" (alt administrativt, samlet ett sted i stedet for å ligge i veien øverst på scoringssiden). */}
{pageTab === "manage" && ( <> {/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for runder med flere enn én deltaker (komponenten returnerer selv null ellers), lenke til hele leaderboardet. */}
{isOwnerViewer && ( )} {!completed && ( )} {isOwnerViewer && ( )}
{showEditRound && isOwnerViewer && ( setShowEditRound(false)} /> )}
{/* Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) -- vertikal, ett kort per spiller. Utslag/HCP/ statistikknivå + "Rediger" ligger nå INNI hvert kort (samme kort er også selve velg-aktiv-spiller-trykkflaten), i stedet for en egen rad under listen som kun gjaldt aktiv spiller. */} setShowAddGuest((v) => !v)} addOpen={showAddGuest} readOnly={readOnly} canManage={isOwnerViewer} editingPlayerId={editingPlayerId} onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))} onPatchParticipant={patchParticipant} onAddGuest={addGuest} onAddSearched={addSearchedParticipant} /> {/* Flyttet HIT (2026-07-26, samme redesign-runde) -- lå tidligere på selve scoringssiden (først øverst, deretter lenger ned), men er detaljert stats-innsyn (fairway/innspill/kølle- fordeling for VALGT spiller), ikke selve registrerings- oppgaven. Hver spillers rad i scoringslisten viser allerede løpende til-par, så dette er nå rendyrket "se mer detaljer" -- hører hjemme i administrasjons-fanen, ikke i veien for score-registreringen. */} {!completed && } )} {pageTab === "score" && ( <> {/* Completed summary banner */} {completed && ( )} {/* Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, bygget 2026-07-27 -- erstatter den forrige rundens ett-hull-om-gangen- liste, bruker eksplisitt bekreftet "JA" på at grid-redesignet er en egen, større runde). Spillere som rader (sticky venstre navnekolonne), hull som horisontalt scrollbare kolonner -- HELE runden synlig samtidig, ikke bare det aktive hullet. */} {holes.length === 0 ? (
) : (
{ setCurrentHole(n) scrollToHolePanel() }} onOpenEntry={(playerId, holeNumber) => { setCurrentHole(holeNumber) setActivePlayerId(playerId) setWizardPlayerId(playerId) }} /> {/* Rask sekvensiell hull-navigasjon -- gridet viser allerede alle hull som kolonner (tapp en kolonneoverskrift for å velge), men disse to gir en rask vei videre uten å måtte scrolle bredt for hvert eneste hull. */}
)} )}
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT UTENFOR
, øverst i DOM-treet, slik at den dekker hele skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */} {wizardPlayer && hole && ( setWizardPlayerId(null)} onAdvance={advanceWizardPlayer} readOnly={readOnly} ownBagClubs={ownBagClubs} /> )}
) } // --- Scoringsveiviser (2026-07-26) ------------------------------------------ // Skjermovertagende, ett-spiller-om-gangen registreringsflyt -- inspirert av // en konkurrentapps samlebåndsflyt (video studert bilde for bilde), men // BEVISST forenklet til TeeCups eget stat_level-drevne steg-antall i stedet // for å kopiere konkurrentens nøyaktige skjermrekkefølge/design: // - "strokes_only": ett steg (Slag) // - "strokes_and_putts": to steg (Slag, Putter+avstand første putt) // - "full": tre steg (Slag, Putter+avstand første putt, resten samlet) // "Neste"/"Forrige" beveger seg gjennom stegene for ÉN spiller; på siste // steg blir knappen "Neste: {navn}" (bytter til neste spiller, nullstiller // til steg 1) eller "Ferdig" (er spilleren den siste, lukker veiviseren og // varsler forelderen om å gå videre til neste hull). type WizardStep = "strokes" | "putts" | "details" function wizardStepsFor(statLevel: StatLevel): WizardStep[] { if (statLevel === "strokes_only") return ["strokes"] if (statLevel === "strokes_and_putts") return ["strokes", "putts"] return ["strokes", "putts", "details"] } function ScoringWizard({ players, player, hole, stat, onUpdate, onClose, onAdvance, readOnly, ownBagClubs, }: { players: Player[] player: Player hole: Hole stat: HoleStat onUpdate: (patch: Partial) => void onClose: () => void onAdvance: () => void readOnly: boolean ownBagClubs: string[] }) { const playerIndex = players.findIndex((p) => p.id === player.id) const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1 const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null const steps = wizardStepsFor(player.statLevel) const [stepIndex, setStepIndex] = useState(0) // Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller -- // uten dette ville "Neste: {navn}" hoppet rett til der FORRIGE spiller // slapp, i stedet for å starte på Slag igjen for den nye spilleren. useEffect(() => { setStepIndex(0) }, [player.id]) const step = steps[Math.min(stepIndex, steps.length - 1)] const isFirstStep = stepIndex === 0 const isLastStep = stepIndex === steps.length - 1 const canProceed = step === "strokes" ? stat.strokes !== null : step === "putts" ? stat.putts !== null : true function back() { if (isFirstStep) { onClose() return } setStepIndex((i) => i - 1) } function forward() { if (!isLastStep) { setStepIndex((i) => i + 1) return } onAdvance() } return (
Hull {hole.holeNumber} · Par {hole.par} {player.name}
{/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som konkurrentappen: man mister aldri oversikten over hvem som allerede er ferdig på hullet. Ren informasjon, ikke en snarvei -- bytte spiller midt i et steg ville latt uferdige felt henge løst. */}
{players.map((p) => ( {p.name} ))}
{step === "strokes" && ( onUpdate({ strokes: v, played: true })} readOnly={readOnly} /> )} {step === "putts" && ( <> onUpdate({ putts: v })} readOnly={readOnly} /> onUpdate({ firstPuttBucket: v as PuttBucket })} readOnly={readOnly} /> )} {step === "details" && ( <>
{player.isSelf && ownBagClubs.length > 0 ? (
{ownBagClubs.map((club) => { const selected = stat.club === club return ( ) })}
) : ( onUpdate({ club: e.target.value })} placeholder="F.eks. Driver, 3-jern" disabled={readOnly} className="h-12 rounded-2xl text-base" /> )}
{hole.par !== 3 && ( onUpdate({ teeShot: v as TeeShot })} readOnly={readOnly} /> )} onUpdate({ approach: v as Approach })} readOnly={readOnly} />
onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} /> onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
onUpdate({ 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 ? ( Hcp spilt til {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 ----------------------------------------------------------- // --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) --- // Vertikal liste av spillerkort -- hvert kort er BÅDE "velg meg som aktiv // spiller" (hovedtrykkflaten) og bærer et eget "Rediger"-panel (utslag/HCP/ // statistikk, pluss navn/kjønn/e-post for en gjest) som en utvidbar // underseksjon. Skalerer til mange spillere uten fane-scroll. function PlayerList({ roundId, players, activePlayerId, onSelect, onRemove, onAdd, addOpen, readOnly, canManage, editingPlayerId, onToggleEdit, onPatchParticipant, onAddGuest, onAddSearched, }: { roundId: string 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/redigere medspillere (ADR-036 // fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt // VELGE kort for å registrere score for hele flighten. canManage: boolean editingPlayerId: string | null onToggleEdit: (id: string) => void onPatchParticipant: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void onAddSearched: (userId: string, statLevel: StatLevel) => Promise }) { return (
{players.map((player) => { const active = player.id === activePlayerId const editing = editingPlayerId === player.id const canRemove = !player.isSelf && !readOnly && canManage const canEdit = canManage && !readOnly return (
{canEdit && ( )} {canRemove && ( )}
{editing && canEdit && ( onToggleEdit(player.id)} /> )}
) })} {!readOnly && canManage && ( addOpen ? ( ) : ( ) )}
) } // --- 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 }) { // En "midlertidig spiller" (gjest uten TeeCup-konto) har ikke egen profil // -- navn/kjønn/e-post kan derfor redigeres her. En lenket bruker har sin // egen konto, og disse feltene gir ingen mening (backend avviser dem også // eksplisitt, 2026-07-26). const isGuest = player.userId === null const [teeOptions, setTeeOptions] = useState(null) const [teeName, setTeeName] = useState(player.teeName) const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "") const [statLevel, setStatLevel] = useState(player.statLevel) const [guestName, setGuestName] = useState(player.name) const [gender, setGender] = useState(player.gender) const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "") 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]) // Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da // umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket // bruker er `gender` uendret gjennom hele panelets levetid. const apiGender = uiGenderToApi(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 (statLevel !== player.statLevel) body.stat_level = statLevel if (isGuest) { const trimmedName = guestName.trim() if (trimmedName && trimmedName !== player.name) body.guest_name = trimmedName if (gender !== player.gender) body.gender = apiGender const trimmedEmail = guestEmail.trim() const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail } 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 (
{error &&

{error}

} {isGuest && ( <>
setGuestName(e.target.value)} className="h-11 rounded-xl text-base" />
setGender(v as Gender)} readOnly={false} />
setGuestEmail(e.target.value)} placeholder="navn@eksempel.no" className="h-11 rounded-xl text-base" />
)}

Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.

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

Fant ingen 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" />
setStatLevel(v as StatLevel)} readOnly={false} />
) } 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) => ( ))}
)}
) } // --- Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, 2026-07-27) --- // Spillere = rader (sticky venstre navnekolonne), hull = horisontalt // scrollbare kolonner (sticky Ut/Inn/Sum til høyre) -- erstatter den // forrige ett-hull-om-gangen-listen. Tapp en kolonneoverskrift for å velge // "gjeldende" hull (kun navigasjon), tapp en spillers navnecelle eller en // score-celle for å åpne `ScoringWizard` for AKKURAT den (spiller, hull)- // kombinasjonen -- samme veiviser som før, bare adressert direkte fra // gridet i stedet for via ett enkelt "aktivt hull"-panel. // Par/stroke-indeks er samme fysiske hull for alle spillere -- hentes fra // HVILKEN SOM HELST allerede lastet spillers hull-liste (ikke bare aktiv // spiller), robust mot i hvilken rekkefølge deltakernes hull lastes inn. function ScorecardGrid({ players, holesByParticipant, holeOrder, holesPlanned, currentHole, readOnly, onSelectHole, onOpenEntry, }: { players: Player[] holesByParticipant: Record holeOrder: number[] holesPlanned: number currentHole: number readOnly: boolean onSelectHole: (holeNumber: number) => void onOpenEntry: (playerId: string, holeNumber: number) => void }) { const holeMeta: Record = {} for (const list of Object.values(holesByParticipant)) { for (const h of list) { if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index } } } // Ut(1-9)/Inn(10-18) er alltid gruppert på FYSISK hullnummer (tradisjonell // scorekort-konvensjon), ikke på spillerekkefølgen -- riktig uansett // hvilket starthull runden faktisk begynte på. Kun meningsfullt å splitte // for en 18-hulls runde; en 9-hulls runde får kun én samlet Sum-kolonne. const showSplit = holesPlanned === 18 const front = holeOrder.filter((n) => n <= 9) const back = holeOrder.filter((n) => n >= 10) function sumFor(playerId: string, numbers: number[]): { strokes: number; par: number } | null { const list = holesByParticipant[playerId] if (!list || numbers.length === 0) return null const played = numbers .map((n) => list.find((h) => h.hole_number === n)) .filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null) if (played.length === 0) return null return { strokes: played.reduce((sum, h) => sum + (h.score as number), 0), par: played.reduce((sum, h) => sum + h.par, 0), } } const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) return (
{holeOrder.map((n) => ( ))} {showSplit && } {showSplit && } {holeOrder.map((n) => { const active = n === currentHole return ( ) })} {showSplit && ( )} {showSplit && ( )} {/* Hcp- og Par-referanserader (samme mønster som den allerede shippede round-scorecard.tsx) -- rene informasjonsrader, ikke trykkbare. */} {holeOrder.map((n) => ( ))} {showSplit && {holeOrder.map((n) => ( ))} {showSplit && ( )} {showSplit && ( )} {players.map((player) => { const list = holesByParticipant[player.id] const frontSum = showSplit ? sumFor(player.id, front) : null const backSum = showSplit ? sumFor(player.id, back) : null const totalSum = sumFor(player.id, holeOrder) return ( {holeOrder.map((n) => { const apiHole = list?.find((h) => h.hole_number === n) ?? null const meta = holeMeta[n] const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null const net = hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null return ( ) })} {showSplit && ( )} {showSplit && ( )} ) })}
Spiller Ut Inn Sum
Hcp {holeMeta[n]?.index ?? "–"} } {showSplit && }
Par {holeMeta[n]?.par ?? "–"} {front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"} {back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"} {parRowTotal || "–"}
{frontSum ? frontSum.strokes : "–"} {backSum ? backSum.strokes : "–"} {totalSum ? ( {totalSum.strokes} {formatToPar(totalSum.strokes - totalSum.par)} ) : ( "–" )}
) } // Golfscore-språket for en LITEN tabellcelle -- samme eksakte klassifisering // og Tailwind-klasser som `ScoreMark`/`classify` i round-scorecard.tsx // (bekreftet ved å lese kildekoden der direkte, egen kopi her per // prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se // DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over // par, ren tekst på par, fylt ved 2+ slag fra par. function ScorecardCell({ value, diff }: { value: number | null; diff: number | null }) { const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums" if (value === null) return if (diff === null || diff === 0) return {value} if (diff <= -2) return {value} if (diff === -1) return {value} if (diff === 1) { return {value} } return {value} } // --- 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. // --- Number picker --------------------------------------------------------- // Golf-term per slagtall relativt til par (2026-07-26, inspirert av en // konkurrentapps taltastatur der HVER knapp er merket kontekstuelt -- // fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller // dobbel bogey?" i hodet). Kun brukt for Slag-velgeren. function golfTermForScore(n: number, par: number): string | null { const diff = n - par if (diff <= -3) return "Albatross" if (diff === -2) return "Eagle" if (diff === -1) return "Birdie" if (diff === 0) return "Par" if (diff === 1) return "Bogey" if (diff === 2) return "Dobbel bogey" return null } function NumberPicker({ label, value, directValues, expandValues, expandLabel, parValue, showGolfTerms, maxValue, onChange, readOnly, }: { label: string value: number | null directValues: number[] expandValues: number[] expandLabel: string // Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt. parValue?: number // Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til // parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt // for Slag-velgeren, ikke Putter/Anywayslag. showGolfTerms?: boolean // Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige // antall slag registrert på hullet) -- ingen vits i å tilby et tall som // uansett ville vært selvmotsigende. maxValue?: number 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 term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null 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 =