"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, RefreshCw, Search, Settings2, Target, Trophy, Users, WifiOff, 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" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" // --- 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 // ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av // countsForHandicap (den automatiske kvalifiseringen). excludeFromHandicap: boolean // ADR-039 -- hvilken side (match/fourball/foursome/greensome/scramble), // og den format-relative match-play-enheten (ikke samme som courseHandicap). roundSideId: string | null playingHandicap: number | null // Money Ball (2026-07-30) -- se ApiParticipant.lineup_order. lineupOrder: number | null } type HoleStat = { played: boolean strokes: number | null // "Plukket opp" (migrasjon 038, Stableford) -- serveren regner selv ut // og skriver Net Double Bogey som `strokes` når dette er sant; feltet // her er KUN til visning ("Plukket opp" i stedet for tallet). pickedUp: boolean 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, pickedUp: false, 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 exclude_from_handicap: boolean // ADR-039 -- hvilken side deltakeren er på (match/fourball/foursome/ // greensome/scramble), null for slagspill/skins (ingen sider). round_side_id: string | null // ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som // course_handicap_snapshot (den absolutte WHS-verdien). playing_handicap: number | null // Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3), // null inntil eieren tildeler den. KUN meningsfullt for play_format="money_ball". lineup_order: number | null } type ApiSide = { id: string label: string | null } 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 play_format: string skins_scoring: string | null skins_tie_handling: string | null started_at: string | null completed_at: string | null participants: ApiParticipant[] sides: ApiSide[] setup_complete: boolean setup_message: string | null // ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts // innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller. visibility_mode: string visible_categories: string[] // Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde. flight_group_id: string | null } // Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx // sin CATEGORY_OPTIONS -- egen, lokal kopi (samme "liten duplisering // fremfor cross-file-import"-mønster som FORMAT_LABELS ellers i appen). const VISIBILITY_CATEGORY_OPTIONS = [ { code: "spouse", label: "Make" }, { code: "close_family", label: "Nær familie" }, { code: "extended_family", label: "Storfamilie" }, { code: "close_friends", label: "Nære venner" }, { code: "golf_friends", label: "Golfvenner" }, { code: "colleagues", label: "Kollegaer" }, { code: "business", label: "Forretningsforbindelser" }, { code: "classmates", label: "Studiekamerater" }, { code: "acquaintances", label: "Perifere bekjente" }, { code: "other", label: "Ymse" }, ] as const // ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py // (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for // å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid // server-side -- disse styrer bare presentasjon. // Åtte nye formater (2026-07-30). Chapman er to-sidet+delt-ball (som // foursome). High-low-high er to-sidet+individuell-ball (som fourball). // Copenhagen/BBB/Flag/Shamble/Money Ball er ALDRI to-sidet (flatt felt, // se ADR/plan) -- faller derfor gjennom til vanlig individuell-ball // scorekort/veiviser (samme som stroke/stableford/skins), med egne // tilleggsvisninger i FormatResultPanel. const TWO_SIDED_FORMATS = new Set([ "match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high", ]) const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]) const SIDE_PLAYER_COUNT: Record = { match: 1, fourball: 2, foursome: 2, greensome: 2, scramble_2: 2, scramble_4: 4, chapman: 2, high_low_high: 2, } const FORMAT_LABELS: Record = { stroke: "Slagspill", stableford: "Stableford", match: "Match", skins: "Skins", fourball: "Fourball", foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", chapman: "Chapman", copenhagen: "Københavner", bbb: "Bingo Bango Bongo", flag: "Flaggturnering", shamble: "Shamble", money_ball: "Money Ball", high_low_high: "High-low-high", } // Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider, // bruker vanlig individuell-ball scorekort/veiviser for selve registreringen. const FLAT_FIELD_FORMATS = new Set(["copenhagen", "bbb", "flag", "shamble", "money_ball"]) // 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 picked_up: boolean 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 } // ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble) // har ÉN score PER SIDE per hull, ikke per spiller -- ingen av de andre // detalj-feltene gir entydig mening for en delt ball. type ApiSideHole = { hole_number: number par: number stroke_index: number played: boolean score: number | null // Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt // (migrasjon 036) -- helt valgfritt, null = ikke registrert. selected_participant_id: string | null // Sidens kombinerte playing_handicap sin allokering for DETTE hullet -- // fantes allerede i API-svaret (RoundSideHoleOut), bare aldri lest her // før nå (2026-07-29, "vis slag mottatt før hullet er fylt ut"). strokes_received: number | null } function apiHoleToStat(h: ApiHole): HoleStat { return { played: h.played, strokes: h.score, pickedUp: h.picked_up, 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, picked_up: s.pickedUp, 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) // ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins- // tavlen skjer (hull-registrering, websocket-signal), slik at // formatResult-hentingen under vet den skal hente på nytt. const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0) // Løftet OPP hit fra FormatResultPanel (2026-07-29) -- ScorecardGrid/ // SideScorecardGrid trenger NÅ den samme per-hull-oppløsningen (hvem vant // hvilket hull) for å farge scorecellene og vise en løpende Stilling-rad, // ikke bare selve statusbanneret. Én henting, delt av begge. const [formatResult, setFormatResult] = 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) // ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer PER // SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette. const [wizardSideId, setWizardSideId] = useState(null) const [holesBySide, setHolesBySide] = useState>({}) // --- Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) ------------ // Samme kø/synk-mønster som turnering-scorekortet (session-scorecard.tsx) // -- portert hit siden frittstående runder er der man oftest står alene // ute på banen uten dekning, og ADR-028s kø tidligere kun var koblet til // turnering-scorekortet. Enklere her: PATCH-endepunktene erstatter HELE // hull-raden per kall (ikke en append-historie), så en køet skriving kan // speiles direkte inn i holesByParticipant/holesBySide -- ingen egen // verdi-overlay nødvendig, kun et sett med nøkler for selve "lagret // lokalt"-indikatoren i veiviseren. const [isOnline, setIsOnline] = useState(true) const [pendingCount, setPendingCount] = useState(0) const [syncing, setSyncing] = useState(false) const [pendingParticipantHoles, setPendingParticipantHoles] = useState>(new Set()) const [pendingSideHoles, setPendingSideHoles] = useState>(new Set()) // 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) // ADR-039 -- sider/deltaker-tildeling kan ha endret seg (SidesPanel // kaller denne som onSidesChanged), som igjen kan gjøre FormatResultPanel // sitt tidligere "ikke klart"-svar utdatert. Bump ved HVER runde-refetch // (billig, og runde-refetch skjer uansett ikke ofte nok til at det er en // reell kostnad) i stedet for å spore hver enkelt kallsted som kan // påvirke handicap-beregningen. setFormatResultRefreshTick((n) => n + 1) } catch { setError("Klarte ikke å hente runden. Prøv igjen om litt.") } }, [roundId]) useEffect(() => { void loadRound() }, [loadRound]) // Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri // lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid // (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill/Stableford // (migrasjon 038) trenger ingen egen henting -- ingen sider/matchstatus. useEffect(() => { if (!round || round.play_format === "stroke" || round.play_format === "stableford") { setFormatResult(null) return } let cancelled = false fetch(`/rounds/${roundId}/format-result`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiFormatResult | null) => { if (!cancelled) setFormatResult(data) }) .catch(() => {}) return () => { cancelled = true } }, [roundId, round?.play_format, formatResultRefreshTick]) // Bingo Bango Bongo (2026-07-30) -- rå bingo/bango/bongo-VALG per hull // (ikke poengene, som ligger i formatResult over). Egen henting siden // dette er en separat tabell (round_bbb_hole), ikke del av // RoundFormatResultOut sin generiske form. const [bbbHoles, setBbbHoles] = useState>({}) useEffect(() => { if (!round || round.play_format !== "bbb") { setBbbHoles({}) return } let cancelled = false fetch(`/rounds/${roundId}/bbb`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiBBBHole[]) => { if (!cancelled) setBbbHoles(Object.fromEntries(data.map((h) => [h.hole_number, h]))) }) .catch(() => {}) return () => { cancelled = true } }, [roundId, round?.play_format, formatResultRefreshTick]) async function patchBbbHole(holeNumber: number, body: Record) { const res = await fetch(`/rounds/${roundId}/bbb/${holeNumber}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }) if (!res.ok) return const updated: ApiBBBHole = await res.json() setBbbHoles((prev) => ({ ...prev, [updated.hole_number]: updated })) setFormatResultRefreshTick((n) => n + 1) } 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]) // ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer per // SIDE, ikke per deltaker (se holesBySide over). const loadSideHoles = useCallback( async (sideId: string) => { const res = await fetch(`/rounds/${roundId}/sides/${sideId}/holes`, { credentials: "include" }) if (!res.ok) return const data: ApiSideHole[] = await res.json() setHolesBySide((prev) => ({ ...prev, [sideId]: data })) }, [roundId], ) useEffect(() => { if (!round) return for (const s of round.sides) { if (!holesBySide[s.id]) { void loadSideHoles(s.id) } } }, [round, holesBySide, loadSideHoles]) // --- Offline scoreregistrering, forts. ------------------------------- // Køer en PATCH i IndexedDB og speiler den samme kroppen optimistisk inn // i lokal state -- body sine felt matcher ApiHole/ApiSideHole sine // navn 1:1 (se statToPatchBody), så en enkel spread er nok, ingen egen // oversettelse. Serveren får siste ord når synk faktisk skjer (bl.a. // strokes_received kan derfor være utdatert helt til da). async function queueParticipantHoleWrite( participantId: string, holeNumber: number, body: ReturnType, ) { const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}` await enqueueWrite({ url, method: "PATCH", body, matchId: roundId }) setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`)) setPendingCount((c) => c + 1) setHolesByParticipant((prev) => ({ ...prev, [participantId]: (prev[participantId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)), })) } async function queueSideHoleWrite( sideId: string, holeNumber: number, body: { played: boolean; score: number | null; selected_participant_id: string | null }, ) { const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}` await enqueueWrite({ url, method: "PATCH", body, matchId: roundId }) setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`)) setPendingCount((c) => c + 1) setHolesBySide((prev) => ({ ...prev, [sideId]: (prev[sideId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)), })) } // Sender køede skrivinger og henter deltakerens/sidens hull på nytt fra // serveren for hver som faktisk fikk noe synkronisert -- reconciles bl.a. // strokes_received, som den optimistiske speilingen over ikke kan regne // ut selv. Matcher URL-mønsteret for å vite hvilken type/id det gjaldt // (samme kø brukes til begge endepunktstypene). async function flushPending() { if (syncing) return setSyncing(true) try { const outcomes = await flushQueue(roundId) if (outcomes.length === 0) return const syncedParticipants = new Set() const syncedSides = new Set() for (const o of outcomes) { const participantMatch = o.entry.url.match(/\/participants\/([^/]+)\/holes\/(\d+)$/) if (participantMatch) { setPendingParticipantHoles((prev) => { const next = new Set(prev) next.delete(`${participantMatch[1]}:${participantMatch[2]}`) return next }) syncedParticipants.add(participantMatch[1]) continue } const sideMatch = o.entry.url.match(/\/sides\/([^/]+)\/holes\/(\d+)$/) if (sideMatch) { setPendingSideHoles((prev) => { const next = new Set(prev) next.delete(`${sideMatch[1]}:${sideMatch[2]}`) return next }) syncedSides.add(sideMatch[1]) } } const failed = outcomes.filter((o) => !o.ok) if (failed.length > 0) { setError( `${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`, ) } setPendingCount(await queueCount(roundId)) for (const id of syncedParticipants) void loadHoles(id) for (const id of syncedSides) void loadSideHoles(id) setFormatResultRefreshTick((n) => n + 1) } finally { setSyncing(false) } } useEffect(() => { setIsOnline(navigator.onLine) function handleOnline() { setIsOnline(true) void flushPending() } function handleOffline() { setIsOnline(false) } window.addEventListener("online", handleOnline) window.addEventListener("offline", handleOffline) return () => { window.removeEventListener("online", handleOnline) window.removeEventListener("offline", handleOffline) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [roundId]) // Sjekk for allerede køede skrivinger fra en tidligere økt (f.eks. siden // ble lukket mens offline) -- samme engangs-sjekk som session-scorecard.tsx. useEffect(() => { let cancelled = false queueCount(roundId) .then((count) => { if (cancelled) return setPendingCount(count) if (count > 0 && navigator.onLine) void flushPending() }) .catch(() => {}) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [roundId]) // Alltid gjeldende deltaker-/side-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([]) const sideIdsRef = useRef([]) useEffect(() => { participantIdsRef.current = round?.participants.map((p) => p.id) ?? [] sideIdsRef.current = round?.sides.map((s) => s.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) } for (const id of sideIdsRef.current) { void loadSideHoles(id) } setFormatResultRefreshTick((n) => n + 1) } return () => socket.close() }, [roundId, loadRound, loadHoles, loadSideHoles]) 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, excludeFromHandicap: p.exclude_from_handicap, roundSideId: p.round_side_id, playingHandicap: p.playing_handicap, lineupOrder: p.lineup_order, })) ?? [] // 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 body = statToPatchBody(merged) if (!navigator.onLine) { await queueParticipantHoleWrite(wizardPlayerId, activeHole, body) return } let res: Response try { res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }) } catch { // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet // for å tape registreringen, se ADR-028. await queueParticipantHoleWrite(wizardPlayerId, activeHole, body) return } 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)), })) setFormatResultRefreshTick((n) => n + 1) } 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() } } // ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble) // scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og // -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig // mening for en delt ball). const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null // Par/index for aktivt hull -- samme for begge sider (samme bane), henter // fra hvilken som helst av de to som allerede har lastet inn hullene sine. const sharedHole: Hole | null = (() => { for (const s of round?.sides ?? []) { const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole) if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index } } return null })() // Auto-prompt "Fullfør runde" (2026-07-30): er ALLE hull ført for ALLE // deltakere (individuelle formater) eller for BEGGE sider (delt-ball) -- // vis en tydelig CTA på Score-fanen i stedet for å stole på at brukeren // selv husker den tonet-ned knappen i "Spillere og runde"-fanen. Samme // `setup_complete`-gate som selve knappen (unngår å reklamere for en // handling som uansett ville vært deaktivert). const allHolesEnteredForEveryone = !completed && round !== null && round.setup_complete && holeOrder.length > 0 && (isSharedBall ? round.sides.length >= 2 && round.sides.every((s) => { const list = holesBySide[s.id] return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true) }) : players.length > 0 && players.every((p) => { const list = holesByParticipant[p.id] return list !== undefined && holeOrder.every((n) => list.find((h) => h.hole_number === n)?.played === true) })) // Begge feltene (score OG selected_participant_id) sendes ALLTID sammen // (samme "full overwrite hvert kall"-kontrakt som played/score alltid har // hatt) -- ellers ville en score-oppdatering stille nullstilt en allerede // registrert utslags-valg (migrasjon 036), siden backend ikke gjør en // delvis PATCH her. async function patchSideHole(score: number | null, selectedParticipantId: string | null) { if (readOnly || !wizardSideId) return const body = { played: true, score, selected_participant_id: selectedParticipantId } if (!navigator.onLine) { await queueSideHoleWrite(wizardSideId, activeHole, body) return } let res: Response try { res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }) } catch { await queueSideHoleWrite(wizardSideId, activeHole, body) return } if (!res.ok) return const updated: ApiSideHole = await res.json() setHolesBySide((prev) => ({ ...prev, [wizardSideId]: (prev[wizardSideId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), })) setFormatResultRefreshTick((n) => n + 1) } function updateWizardSideScore(score: number | null) { void patchSideHole(score, wizardSideHole?.selected_participant_id ?? null) } // Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036) -- helt // valgfritt, egen handling fra selve slagregistreringen slik at man kan // sette/endre valget uten å røre et allerede registrert slagtall. function updateWizardSideSelection(participantId: string | null) { void patchSideHole(wizardSideHole?.score ?? null, participantId) } function advanceWizardSide() { if (!round) return const idx = round.sides.findIndex((s) => s.id === wizardSideId) const isLast = idx === -1 || idx === round.sides.length - 1 if (!isLast) { setWizardSideId(round.sides[idx + 1].id) } else { setWizardSideId(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). */}
{/* Offline scoreregistrering (ADR-028-utvidelse) -- synlig uansett fane, siden offline-tilstanden er global for hele siden. */} {!isOnline && (
)} {pendingCount > 0 && (
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering. {isOnline && ( )}
)} {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. */} {/* Løpende matchstatus (to-sidede formater) eller skins-tavle (ADR-039) -- avledet ved lesing, aldri lagret. */} {/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer (front9/back9/overall) av det samme to-sidede formatet, kun for 18-hulls runder. Ren tilleggsvisning, ikke et eget play_format. */} {TWO_SIDED_FORMATS.has(round.play_format) && round.holes_planned === 18 && ( )} {/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md. Eieren tar ansvar for å sette opp ALLE flightene i én handling ("+ Legg til en flight til" -> gjenbruker hele opprett-runde-flyten, forhåndsutfylt fra denne runden). */}
{isOwnerViewer && ( )} {!completed && ( )} {isOwnerViewer && ( )}
{showEditRound && isOwnerViewer && ( setShowEditRound(false)} /> )}
{/* Sider (ADR-039 Beslutning A) -- kun for formater som faktisk bruker sider (match/fourball/foursome/greensome/scramble). Opprett/slett sider + fordel deltakerne. */} {TWO_SIDED_FORMATS.has(round.play_format) && ( )} {!round.setup_complete && !completed && (

{round.setup_message ?? "Oppsettet er ikke komplett ennå."}

)} {/* 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" && ( <> {/* Matchstatus/skins-tavle øverst på Score-fanen også (2026-07-29, etterspurt av bruker -- fantes fra før KUN under "Spillere og runde", lett å overse siden scoring naturlig skjer her). Samme komponent, ikke duplisert logikk. */} {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon (ikke GPS/live-sporet), separat fra selve slag-/putt- registreringen (som fortsatt skjer via vanlig ScoringWizard, for stat_level/GIR/HCP-formål -- BBB-poengene er UAVHENGIGE av gross_strokes). */} {round.play_format === "bbb" && !readOnly && ( )} {/* Money Ball (2026-07-30) -- rekkefølgen (0-3) må være tildelt for alle fire FØR scoring/fullføring gir mening (avgjør hvem som er "money ball" på hvilket hull). Vises kun til rekkefølgen er komplett, deretter automatisk skjult. */} {round.play_format === "money_ball" && isOwnerViewer && !readOnly && ( )} {/* Auto-prompt "Fullfør runde" (2026-07-30) -- kun synlig når alle hull faktisk er ført for alle, forsvinner selv idet runden er fullført (completed flipper til true). */} {allHolesEnteredForEveryone && } {/* Completed summary banner */} {completed && ( )} {/* ADR-039 Beslutning C -- delt-ball-formater scorer PER SIDE, ikke per spiller. Vises kun når begge sider faktisk er opprettet (SidesPanel i "Spillere og runde" setter dette opp). */} {isSharedBall && round.sides.length < 2 ? (

Opprett begge sidene under "Spillere og runde" før du kan registrere score.

) : isSharedBall ? ( round.sides.some((s) => !holesBySide[s.id]) ? (
) : (
{ setCurrentHole(n) scrollToHolePanel() }} onOpenEntry={(sideId, holeNumber) => { setCurrentHole(holeNumber) setWizardSideId(sideId) }} />
) ) : ( <> {/* 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. */} {wizardSide && sharedHole && ( p.roundSideId === wizardSide.id)} hole={sharedHole} score={wizardSideHole?.played ? wizardSideHole.score : null} selectedParticipantId={wizardSideHole?.selected_participant_id ?? null} onSave={updateWizardSideScore} onSelectParticipant={updateWizardSideSelection} onClose={() => setWizardSideId(null)} onAdvance={advanceWizardSide} readOnly={readOnly} isPending={pendingSideHoles.has(`${wizardSide.id}:${activeHole}`)} /> )} {wizardPlayer && hole && ( setWizardPlayerId(null)} onAdvance={advanceWizardPlayer} readOnly={readOnly} ownBagClubs={ownBagClubs} isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)} playFormat={round.play_format} strokesReceived={wizardApiHole?.strokes_received ?? null} /> )}
) } // --- Scoringsveiviser (2026-07-26, auto-hopp lagt til 2026-07-28) ---------- // 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": fire steg (Slag, Putter, Avstand første putt, resten samlet) // Slag/Putter hopper AUTOMATISK videre i det øyeblikket en verdi velges -- // ingen "Neste"-trykk nødvendig (etterspurt av bruker 2026-07-28: "Avstand // første putt" lå tidligere PÅ SAMME steg som selve putt-tallet, som ville // gjort feltet uoppnåelig hvis auto-hopp fyrte idet putt-tallet ble valgt -- // derfor et eget, eget steg for avstanden, som ELLERS ville vært umulig å // nå). "Avstand"/"detaljer" er valgfrie/flerfelts og auto-hopper derfor // ikke -- "Neste"/"Forrige"-knappene i bunnen fungerer fortsatt som // manuell reserve for disse (og for å rette et allerede registrert steg). // 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" | "puttDistance" | "details" function wizardStepsFor(statLevel: StatLevel): WizardStep[] { if (statLevel === "strokes_only") return ["strokes"] if (statLevel === "strokes_and_putts") return ["strokes", "putts", "puttDistance"] return ["strokes", "putts", "puttDistance", "details"] } // Hvilket HoleStat-felt (om noen) som "eier" auto-hopp for et gitt steg -- // null for steg uten noe entydig enkeltfelt å trigge på (flerfelts/valgfritt). function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPuttBucket" | null { if (step === "strokes") return "strokes" if (step === "putts") return "putts" if (step === "puttDistance") return "firstPuttBucket" return null } function ScoringWizard({ players, player, hole, stat, onUpdate, onClose, onAdvance, readOnly, ownBagClubs, isPending, playFormat, strokesReceived, }: { players: Player[] player: Player hole: Hole stat: HoleStat onUpdate: (patch: Partial) => void onClose: () => void onAdvance: () => void readOnly: boolean ownBagClubs: string[] // Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- sann når // gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen, // ikke bekreftet av serveren ennå. isPending: boolean // "Plukket opp" (migrasjon 038, Stableford) -- kun meningsfullt for // slagspill/Stableford (match/skins/par-lag har egne, ubyggede // hull-avgjørelsesmekanismer -- konsedering, ikke dette). Krever en // kjent strokes_received for AKTUELT hull for at serveren skal kunne // regne ut Net Double Bogey-scoren. playFormat: string strokesReceived: number | null }) { 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 || stat.pickedUp : step === "putts" ? stat.putts !== null : true // Auto-hopp (2026-07-28): så snart steget sitt eget felt får en verdi, // hopp videre av seg selv -- ingen "Neste"-trykk nødvendig. `enteredWith // ValueRef` fanger om feltet ALLEREDE hadde en verdi idet vi ankom steget // (et allerede utfylt hull skal ikke hoppe videre bare fordi veiviseren // åpnes) -- kun en FERSK endring gjort AV BRUKEREN akkurat nå skal utløse // hoppet. `firedRef` hindrer at samme hopp trigges to ganger. const autoField = autoAdvanceFieldFor(step) const enteredWithValueRef = useRef(false) const firedRef = useRef(false) useEffect(() => { firedRef.current = false enteredWithValueRef.current = autoField !== null && stat[autoField] !== null // eslint-disable-next-line react-hooks/exhaustive-deps }, [step, player.id, hole.holeNumber]) useEffect(() => { if (!autoField || firedRef.current || enteredWithValueRef.current) return if (stat[autoField] !== null) { firedRef.current = true forward() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [stat.strokes, stat.putts, stat.firstPuttBucket]) // GIR-auto-inferens (2026-07-30): score - putts <= par - 2 GARANTERER // greentreff i regulering (samme formel som round-stats.tsx sin isGir/ // round-detail.tsx sin showGir, som allerede styrer GIR-STATISTIKKEN) -- // da er det unødvendig å i tillegg kreve et manuelt "Innspill: Traff"- // valg i DirectionCross-feltet under. Kun en FORHÅNDSUTFYLLING: rører // ALDRI et allerede satt (manuelt ELLER tidligere auto-satt) approach- // valg, og gjelder aldri motsatt vei (GIR usann sier ingenting om // retning -- en scrambling-birdie gir identisk slag/putt-mønster som en // ekte GIR). Trigges når "details"-steget nås, siden slag+putt begge må // være kjent (kommer tidligere i wizard-rekkefølgen). useEffect(() => { if (step !== "details") return if (stat.approach !== null) return if (stat.strokes === null || stat.putts === null) return if (stat.strokes - stat.putts <= hole.par - 2) { onUpdate({ approach: "hit" }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [step, stat.strokes, stat.putts, stat.approach, hole.par]) 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} ))}
{isPending && (

Lagret lokalt · venter på synk

)}
{step === "strokes" && ( <> onUpdate({ strokes: v, played: true, pickedUp: false })} readOnly={readOnly} /> {/* "Plukket opp" (migrasjon 038, Stableford, 2026-07-29) -- kun for slagspill/Stableford, og kun når serveren faktisk kan regne ut Net Double Bogey (krever kjent strokes_received). */} {(playFormat === "stroke" || playFormat === "stableford") && strokesReceived !== null && ( )} )} {step === "putts" && ( onUpdate({ putts: v })} readOnly={readOnly} /> )} {step === "puttDistance" && ( 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} /> {/* Alltid stablet, ALDRI et sm:grid-cols-3-sprang: denne wizard- seksjonen er fast begrenset til max-w-sm (384px, se containeren over) uansett hvor bred selve VIEWPORTET er -- et viewport-basert grid-cols-3-brudd ved 640px trigges derfor på skjermer godt over 640px bredde mens innholdskolonnen fortsatt kun er 384px, som får de tre stepperne (hver ~190px minimum pga. faste 44px-knapper) til å overlappe hverandre i stedet for å faktisk ha plass. */}
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 AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) { return (
Alle hull er ført

Klar til å fullføre runden?

) } 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.picked_up ? "PU" : 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) => ( ))}
) } // --- Sider (ADR-039 Beslutning A) ------------------------------------------- // Kun for match/fourball/foursome/greensome/scramble -- nøyaktig to sider, // hver med et forventet spillerantall avhengig av format. Sidene opprettes // tomme (POST .../sides), deltakere tildeles/flyttes deretter via PATCH // .../participants/{id} med round_side_id. function SidesPanel({ roundId, round, players, canManage, onSidesChanged, onPatchParticipant, }: { roundId: string round: ApiRound players: Player[] canManage: boolean onSidesChanged: () => Promise onPatchParticipant: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> }) { const [labelDrafts, setLabelDrafts] = useState>({}) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const expected = SIDE_PLAYER_COUNT[round.play_format] ?? 1 const slots: (ApiSide | null)[] = [round.sides[0] ?? null, round.sides[1] ?? null] const unassigned = players.filter((p) => p.roundSideId === null) async function createSide(slotIndex: number) { setBusy(true) setError(null) const res = await fetch(`/rounds/${roundId}/sides`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ label: labelDrafts[slotIndex]?.trim() || null }), }) if (!res.ok) { const body = await res.json().catch(() => null) setError(body?.detail?.message ?? "Klarte ikke å opprette siden.") setBusy(false) return } await onSidesChanged() setBusy(false) } async function deleteSide(sideId: string) { if (!confirm("Slette denne siden? Deltakerne blir liggende i runden, uten side-tilhørighet.")) return setBusy(true) setError(null) const res = await fetch(`/rounds/${roundId}/sides/${sideId}`, { method: "DELETE", credentials: "include" }) if (!res.ok) { const body = await res.json().catch(() => null) setError(body?.detail?.message ?? "Klarte ikke å slette siden.") } await onSidesChanged() setBusy(false) } async function assign(participantId: string, sideId: string | null) { setBusy(true) setError(null) const result = await onPatchParticipant(participantId, { round_side_id: sideId }) if (!result.ok) { setError(result.message) } else { // patchParticipant oppdaterer kun deltaker-listen lokalt -- setup_complete/ // setup_message/playing_handicap er beregnet SERVER-side og krever en // full runde-refetch for å ikke bli stående utdatert (2026-07-28, funnet // under browserverifisering: meldingen "ikke tildelt en side" ble // stående etter en vellykket tildeling). await onSidesChanged() } setBusy(false) } if (!canManage && round.sides.length === 0) return null return (
Sider · {expected} spiller{expected !== 1 ? "e" : ""} per side
{error &&

{error}

}
{slots.map((side, slotIndex) => { const assignedPlayers = side ? players.filter((p) => p.roundSideId === side.id) : [] return (
{side ? ( <>
{side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`} {canManage && ( )}
{assignedPlayers.length}/{expected} spillere {assignedPlayers.some((p) => p.playingHandicap !== null) && ( <> {" · "} Match-hcp{" "} {assignedPlayers .filter((p) => p.playingHandicap !== null) .map((p) => p.playingHandicap) .join("/")} )} {assignedPlayers.length === 0 ? (

Ingen spillere ennå.

) : (
    {assignedPlayers.map((p) => (
  • {p.name} {canManage && ( )}
  • ))}
)} ) : canManage ? (
setLabelDrafts((prev) => ({ ...prev, [slotIndex]: e.target.value }))} placeholder={`Navn på side (valgfritt)`} className="h-10 rounded-xl text-sm" />
) : (

Ikke opprettet ennå.

)}
) })}
{canManage && unassigned.length > 0 && (
Ikke tildelt en side
    {unassigned.map((p) => (
  • {p.name} {slots.map((side, slotIndex) => { if (!side) return null const count = players.filter((x) => x.roundSideId === side.id).length const full = count >= expected return ( ) })}
  • ))}
)}
) } // --- Løpende matchstatus / skins-tavle (ADR-039) ---------------------------- // Avledet ved LESING (aldri lagret) fra GET .../format-result -- samme // filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger // ingen egen visning her (eksisterende leaderboard dekker det). // Per-hull-oppløsning (2026-07-29, samme kontrakt som round-scorecard.tsx sin // ApiFormatHoleEntry/ApiFormatHoleOut) -- gjør det mulig å farge hver // scorecelle etter hvem som VANT hullet, og å regne en løpende Stilling-rad, // i stedet for kun et sluttresultat. type ApiFormatHoleEntry = { id: string label: string side: "a" | "b" | null gross: number | null net: number | null strokes_received: number | null counted: boolean | null // Københavner (2026-07-30) -- poeng denne deltakeren fikk PÅ DETTE hullet. points: number | null } type ApiFormatHoleOut = { hole_number: number par: number stroke_index: number entries: ApiFormatHoleEntry[] result: string | null winner_ids: string[] | null carried: boolean | null } type ApiFlagResult = { holes_completed: number ran_out: boolean strokes_remaining: number label: string } type ApiBBBHole = { hole_number: number bingo_participant_id: string | null bango_participant_id: string | null bongo_participant_id: string | null } type ApiFormatResult = { play_format: string ready: boolean match_lead: number | null match_holes_played: number | null match_holes_remaining: number | null match_is_closed: boolean | null match_is_dormie: boolean | null match_status_text: string | null hole_results: string[] | null skins_won: Record | null // Åtte nye formater (2026-07-30) -- se app/routers/rounds.py sin // RoundFormatResultOut for full kontrakt. copenhagen_points: Record | null bbb_points: Record | null flag_results: Record | null shamble_team_score: number | null money_ball_team_score: number | null hlh_points_a: number | null hlh_points_b: number | null holes: ApiFormatHoleOut[] | null } // Kort statustekst UTEN backend sin bokstavelige "(A)"/"(B)"-suffiks -- // identitetsbanneret viser navnet separat (se IdentitySide), så suffikset // ville vært en duplikat. Speiler handicap_engine.py sin // MatchState.describe() presist. function formatMatchStatus(lead: number, holesRemaining: number, isClosed: boolean, isDormie: boolean): string { const margin = Math.abs(lead) if (isClosed) return holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}` if (lead === 0) return "AS" return isDormie ? `Dormie ${margin}` : `${margin} UP` } // Speiler samme funksjon i handicap_engine.py/round-scorecard.tsx, men // regnet ETT PREFIKS om gangen langs `holeOrder` (spillerekkefølgen, ikke // nødvendigvis fysisk hullnummer) -- gir en løpende stilling PER HULL i // stedet for kun sluttresultatet. Stopper (viser ingen verdi videre) ved // første hull uten et registrert resultat, siden hull avgjøres sekvensielt. function matchStillingByHole( holeOrder: number[], formatResult: ApiFormatResult | null, totalHoles: number, ): Map { const out = new Map() if (!formatResult?.holes) return out const byHole = new Map(formatResult.holes.map((h) => [h.hole_number, h])) let lead = 0 let holesPlayed = 0 for (const n of holeOrder) { const h = byHole.get(n) if (!h || !h.result) break holesPlayed += 1 lead += h.result === "a" ? 1 : h.result === "b" ? -1 : 0 const holesRemaining = totalHoles - holesPlayed const isClosed = Math.abs(lead) > holesRemaining const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining out.set(n, { label: formatMatchStatus(lead, holesRemaining, isClosed, isDormie), tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", }) } return out } // Identitet (navn+HCP) for en side i banneret/gridet -- viser SPILLERENS // navn når nøyaktig én er tildelt siden (match, eller en komplett foursome- // side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers // sidens eget (evt. generiske) navn uten noen entydig HCP å vise. function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } { const idx = side === "a" ? 0 : 1 const sideId = round.sides[idx]?.id const sidePlayers = players.filter((p) => p.roundSideId === sideId) if (sidePlayers.length === 1) return { name: sidePlayers[0].name, hcp: sidePlayers[0].playingHandicap } return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null } } function IdentitySide({ identity, side }: { identity: { name: string; hcp: number | null }; side: "a" | "b" }) { return (
{identity.hcp !== null && ( HCP {identity.hcp} )}
) } // Territorium-bar (2026-07-29, erstatter det tidligere flate, likt-delte // banneret) -- den ledende sidens sone strekker seg PROPORSJONALT forbi // midtlinjen inn på motstanderens halvdel (brukerens eget forslag: "hele // den halve delen som tilhører den som er en opp er markert over på // motstanderens halvdel"), i stedet for en statisk 50/50-boks med tekst i // midten. Ved AS (eller ingen hull spilt ennå) er begge soner nøyaktig like // store OG likt (nøytralt) farget -- ingen side skal se ut til å lede. // Bruker samme proporsjonale to-fargede-bar-idé som SegmentedBar // (tournament-leaderboard.tsx), tilpasset lead-i-hull i stedet for poeng. function leadZoneFraction(lead: number, totalHoles: number): number { if (totalHoles <= 0) return 0.5 const fraction = 0.5 + (lead / totalHoles) * 0.5 return Math.min(0.82, Math.max(0.18, fraction)) } function LeadZone({ identity, side, dominant, }: { identity: { name: string; hcp: number | null } side: "a" | "b" dominant: boolean }) { const textTone = dominant ? (side === "a" ? "text-primary-foreground" : "text-brand-orange-foreground") : "text-foreground" const subTone = dominant ? side === "a" ? "text-primary-foreground/75" : "text-brand-orange-foreground/75" : "text-muted-foreground" // Merk: den ytre boksen STREKKER SEG (ingen items-start/items-end her -- // det ville sizet boksen etter innholdets egen bredde, ikke etter sonens // faktiske tildelte bredde, og kunne visuelt lekke inn i midt-kolonnen på // smale skjermer). Venstre/høyre-justering skjer i stedet med // justify-start/justify-end og text-align INNI en boks som alltid har // sonens fulle, korrekte bredde -- truncate virker da presist. return (
{!dominant && (
{identity.hcp !== null && ( HCP {identity.hcp} )}
) } type ApiFlightSummary = { round_id: string name: string | null course_name_snapshot: string played_at: string started_at: string | null completed_at: string | null participant_count: number is_anchor: boolean } // Flere flighter i én "vanlig" runde (migrasjon 035, retning 1 -- løs // gruppering av separate `round`-rader, se FEATURE_BACKLOG.md). "+ Legg // til en flight til" gjenbruker HELE eksisterende opprett-runde-flyten // (/my-rounds/new) i stedet for et eget skjema -- forhåndsutfylt med denne // flightens dato/starthull/hullantall/spilleform via URL-parametre, men // banen velges på nytt (samme lave friksjon som normal bane-søk ellers). function FlightGroupPanel({ roundId, round, isOwnerViewer, }: { roundId: string round: ApiRound isOwnerViewer: boolean }) { const router = useRouter() const [flights, setFlights] = useState(null) const [adding, setAdding] = useState(false) useEffect(() => { let cancelled = false fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null) => { if (!cancelled) setFlights(data?.flights ?? null) }) .catch(() => { if (!cancelled) setFlights(null) }) return () => { cancelled = true } }, [roundId, round.flight_group_id]) async function addFlight() { if (!isOwnerViewer) return setAdding(true) try { let groupId = round.flight_group_id if (!groupId) { groupId = crypto.randomUUID() await fetch(`/rounds/${roundId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ flight_group_id: groupId }), }) } const params = new URLSearchParams({ flightGroupId: groupId, playedAt: round.played_at, startHole: String(round.start_hole), holes: String(round.holes_planned), playFormat: round.play_format, }) router.push(`/my-rounds/new?${params.toString()}`) } finally { setAdding(false) } } const others = (flights ?? []).filter((f) => f.round_id !== roundId) return (
{isOwnerViewer && ( )}
{others.length === 0 ? (

Kun denne flighten er satt opp. Legg til en flight til hvis flere spiller sammen med dere samme dag -- de deler da leaderboard med denne flighten.

) : ( <>
    {(flights ?? []).map((f) => (
  • {f.round_id === roundId ? ( {f.name || f.course_name_snapshot}{" "} (denne) ) : ( {f.name || f.course_name_snapshot} )} {f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""} {f.completed_at ? " · Ferdig" : ""}
  • ))}
Se samlet leaderboard for flightene
) } function FormatResultPanel({ round, players, result, }: { round: ApiRound players: Player[] result: ApiFormatResult | null }) { if (round.play_format === "stroke" || round.play_format === "stableford" || !result) return null if (round.play_format === "skins") { const won = result.skins_won ?? {} const rows = players .map((p) => ({ player: p, skins: won[p.id] ?? 0 })) .sort((a, b) => b.skins - a.skins) const totalAwarded = Object.values(won).reduce((sum, v) => sum + v, 0) return (
Skins-tavle {totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
    {rows.map(({ player, skins }) => (
  • {player.name} {skins}
  • ))}
) } // Københavner (2026-07-30) -- flatt felt, poengtabell (6 poeng deles // per hull mellom nøyaktig 3 spillere -- flest poeng totalt vinner). if (round.play_format === "copenhagen") { const points = result.copenhagen_points ?? {} const rows = players .map((p) => ({ player: p, points: points[p.id] ?? 0 })) .sort((a, b) => b.points - a.points) return (
Københavner -- poengtavle
    {rows.map(({ player, points: pts }) => (
  • {player.name} {pts}
  • ))}
) } // Bingo Bango Bongo (2026-07-30) -- flatt felt, poengtabell (registreres // per hull via egen BBBHolePicker, se lenger ned i Score-fanen). if (round.play_format === "bbb") { const points = result.bbb_points ?? {} const rows = players .map((p) => ({ player: p, points: points[p.id] ?? 0 })) .sort((a, b) => b.points - a.points) return (
Bingo Bango Bongo -- poengtavle
    {rows.map(({ player, points: pts }) => (
  • {player.name} {pts}
  • ))}
) } // Flaggturnering (2026-07-30) -- flatt felt, individuelt slagbudsjett. // Flest fullførte hull vinner (ran_out=true, gikk tom for slag). if (round.play_format === "flag") { const results = result.flag_results ?? {} const rows = Object.entries(results) .map(([id, r]) => ({ id, ...r })) .sort((a, b) => b.holes_completed - a.holes_completed) return (
Flaggturnering {rows.length === 0 ? (

Venter på at handicap beregnes for minst én spiller.

) : (
    {rows.map((r) => (
  • {r.label} {r.holes_completed} hull {r.ran_out ? "gikk tom for slag" : `${r.strokes_remaining} slag igjen`}
  • ))}
)}
) } // Shamble/Money Ball (2026-07-30) -- flatt felt, ETT lag = hele rundens // deltakersett (ingen motstander innad -- flere lag kobles via // flight_group_id-leaderboardet). Viser kun lagets samlede sum. if (round.play_format === "shamble" || round.play_format === "money_ball") { const teamScore = round.play_format === "shamble" ? result.shamble_team_score : result.money_ball_team_score return (
{FORMAT_LABELS[round.play_format]} -- lagets samlede score {teamScore ?? "–"} {teamScore === null && (

Venter på at alle spillerne registrerer på minst ett hull.

)}
) } // High-low-high (2026-07-30) -- literal poengsum (IKKE match-play // "X UP" -- poeng fordeles 0-2 per hull via to uavhengige duelløer, // se handicap_engine.py sin high_low_high_points_for_hole). Samme // territorium-bar-visuelle språk som resten av to-sidede formater, // men banneret viser "{poeng A}-{poeng B}" i stedet for en "X UP"-tekst. if (round.play_format === "high_low_high") { if (!result.ready) { return (
Venter på at begge sider er komplette og har beregnet handicap før High-low-high-status kan vises.
) } const pa = result.hlh_points_a ?? 0 const pb = result.hlh_points_b ?? 0 const holesPlayed = result.match_holes_played ?? 0 const identityA = sideIdentity(round, players, "a") const identityB = sideIdentity(round, players, "b") const tone: "a" | "b" | "neutral" = pa > pb ? "a" : pb > pa ? "b" : "neutral" const widthA = pa + pb > 0 ? (pa / (pa + pb)) * 100 : 50 const widthB = 100 - widthA const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted" const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted" return (
High-low-high
{holesPlayed > 0 ? ( <> {pa}-{pb} {holesPlayed} hull spilt ) : ( Ingen hull spilt ennå )}
) } // To-sidede formater (match/fourball/foursome/greensome/scramble/chapman). if (!result.ready) { return (
Venter på at begge sider er komplette og har beregnet handicap før {FORMAT_LABELS[round.play_format] ?? round.play_format}-status kan vises.
) } // Identitetsbanner (2026-07-29, territorium-bar-versjon -- se leadZoneFraction // over). Navn+HCP i hver sone, stor sentrert løpende status flytende oppå // begge soner, "Ferdig"-merke når avgjort -- samme visuelle språk (nå) som // MatchScorecardGrid (round-scorecard.tsx) og TournamentMatchGrid // (session-scorecard.tsx), egen lokal kopi per prosjektets konvensjon. const identityA = sideIdentity(round, players, "a") const identityB = sideIdentity(round, players, "b") const lead = result.match_lead ?? 0 const holesPlayed = result.match_holes_played ?? 0 const holesRemaining = result.match_holes_remaining ?? 0 const isClosed = result.match_is_closed ?? false const isDormie = result.match_is_dormie ?? false const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral" const statusLabel = holesPlayed > 0 ? formatMatchStatus(lead, holesRemaining, isClosed, isDormie) : null const totalHoles = holesPlayed + holesRemaining const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50 const widthB = 100 - widthA const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted" const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted" return (
{FORMAT_LABELS[round.play_format] ?? round.play_format} {/* CSS Grid, ikke absolutt-posisjonert overlegg (2026-07-29-fiks) -- en tidligere versjon la statusen som en absolutt sentrert boks OPPÅ navnetekst, som kunne overlappe et langt navn på smale skjermer. Med grid-kolonner `minmax(0,X fr) auto minmax(0,Y fr)` reserverer nettleseren ALLTID nøyaktig midt-kolonnens egen bredde til status- boksen -- soneandelen forblir proporsjonal (X:Y), men kan aldri overlappe, og lange navn trunkeres i stedet (samme `truncate`- mekanisme som ellers). */}
{statusLabel ? ( <> {statusLabel} {isClosed ? ( Ferdig ) : ( {holesPlayed} hull spilt {holesRemaining > 0 && isDormie && " · dormie"} )} ) : ( Ingen hull spilt ennå )}
) } // --- Nassau Match Play (2026-07-30) -- selvstendig, henter sin egen data -- // tre parallelle vinduer av et EKSISTERENDE to-sidet format, ingen egen // lagring (se app/routers/rounds.py sin get_round_nassau_result). type ApiNassauWindow = { lead: number holes_played: number holes_remaining: number is_closed: boolean is_dormie: boolean status_text: string } type ApiNassauResult = { ready: boolean front9: ApiNassauWindow | null back9: ApiNassauWindow | null overall: ApiNassauWindow | null } function NassauWindowCard({ label, window }: { label: string; window: ApiNassauWindow | null }) { return (
{label} {window && window.holes_played > 0 ? ( <> {window.status_text} {window.holes_played} hull spilt ) : ( )}
) } function NassauPanel({ roundId, formatResultRefreshTick }: { roundId: string; formatResultRefreshTick: number }) { const [result, setResult] = useState(null) useEffect(() => { let cancelled = false fetch(`/rounds/${roundId}/format-result/nassau`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiNassauResult | null) => { if (!cancelled) setResult(data) }) .catch(() => {}) return () => { cancelled = true } }, [roundId, formatResultRefreshTick]) if (!result?.ready) return null return (
Nassau
) } // --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) ----------------- // Tre uavhengige kategorier (først på green/nærmest hull/først i hull), // manuelt registrert av den som fører score -- ikke GPS/live-sporet. Én // spiller kan vinne flere/alle kategorier på samme hull (sistnevnte utløser // evt. sweep-bonusen, se compute_bbb). Klikk på en allerede valgt spiller // fjerner valget (samme "toggle av/på"-mønster som resten av appen). function BBBCategoryRow({ label, players, selectedId, onSelect, }: { label: string players: Player[] selectedId: string | null onSelect: (id: string | null) => void }) { return (
{label}
{players.map((p) => { const active = selectedId === p.id return ( ) })}
) } function BBBHolePicker({ players, holeNumber, selection, onSet, }: { players: Player[] holeNumber: number selection: ApiBBBHole | null onSet: (holeNumber: number, body: Record) => Promise }) { return (
Bingo Bango Bongo -- hull {holeNumber} void onSet(holeNumber, { bingo_participant_id: id })} /> void onSet(holeNumber, { bango_participant_id: id })} /> void onSet(holeNumber, { bongo_participant_id: id })} />
) } // --- Money Ball -- lineup_order-tildeling (2026-07-30) ---------------------- // Fast rotasjonsrekkefølge (0-3), avgjør hvem som er "money ball" på hvilket // hull. Gjenbruker eksisterende patchParticipant (samme generiske PATCH som // resten av spiller-redigeringen) -- ingen egen fetch-logikk her. function MoneyBallLineupPanel({ players, onPatchParticipant, }: { players: Player[] onPatchParticipant: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> }) { const [error, setError] = useState(null) const allAssigned = players.length === 4 && players.every((p) => p.lineupOrder !== null) async function assign(playerId: string, order: number) { setError(null) const res = await onPatchParticipant(playerId, { lineup_order: order }) if (!res.ok) setError(res.message) } if (allAssigned) return null return (
Money Ball -- rekkefølge

Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" på hvilket hull.

{error &&

{error}

}
    {players.map((p) => (
  • {p.name}
    {[0, 1, 2, 3].map((n) => ( ))}
  • ))}
) } // --- 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, sides, activePlayerId, onSelect, onRemove, onAdd, addOpen, readOnly, canManage, editingPlayerId, onToggleEdit, onPatchParticipant, onAddGuest, onAddSearched, }: { roundId: string players: Player[] // ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve // tildelingen skjer i SidesPanel, ikke her. sides: ApiSide[] 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 // ADR-038 -- full redigering (utslag/HCP/navn/statistikk) er // fortsatt eier-only OG sperret etter fullføring. Men enhver // lenket deltaker (eier ELLER medspiller, kun sin EGEN rad) skal // kunne endre "ekskluder fra faktisk HCP" UANSETT fullført-status // -- dekket av en RESTRIKTERT variant av samme panel, kun // meningsfullt for en spiller med egen konto (userId). const canEditFull = canManage && !readOnly const canEditRestricted = (canManage || player.isSelf) && player.userId !== null const canEdit = canEditFull || canEditRestricted 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)) // ADR-038, utvidet med Stableford (migrasjon 038) -- ren metadata, aldri // sperret av fullført-status (rører aldri AGS/differensial-matematikken // selv). const currentEditablePlayFormat: "stroke" | "match" | "stableford" = round.play_format === "match" ? "match" : round.play_format === "stableford" ? "stableford" : "stroke" const [playFormat, setPlayFormat] = useState<"stroke" | "match" | "stableford">(currentEditablePlayFormat) // ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret // av fullført-status (samme begrunnelse som playFormat over). const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">( round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private", ) const [visibleCategories, setVisibleCategories] = useState(round.visible_categories) const [saving, setSaving] = useState(false) const [showChangeCourse, setShowChangeCourse] = useState(false) const [error, setError] = useState(null) const sameCategories = visibleCategories.length === round.visible_categories.length && visibleCategories.every((c) => round.visible_categories.includes(c)) 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) || playFormat !== currentEditablePlayFormat || visibilityMode !== round.visibility_mode || (visibilityMode === "friends" && !sameCategories) 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) } if (playFormat !== currentEditablePlayFormat) body.play_format = playFormat if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories 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" />
Spilleform
{( [ { value: "stroke" as const, label: "Slagspill" }, { value: "stableford" as const, label: "Stableford" }, { value: "match" as const, label: "Matchspill" }, ] ).map((opt) => ( ))}
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller (som alltid ser runden uansett). */}
Synlighet
{( [ { value: "private" as const, label: "Privat" }, { value: "friends" as const, label: "Venner" }, { value: "public" as const, label: "Offentlig" }, ] ).map((opt) => ( ))}
{visibilityMode === "friends" && (

Hvilke av dine venne-kategorier skal kunne se runden?

{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => { const active = visibleCategories.includes(code) return ( ) })}
)}
{!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, restricted, onPatch, onClose, }: { roundId: string player: Player // ADR-038 -- true når viewer IKKE har full redigeringsrett her (en // ikke-eier som redigerer sin EGEN rad, ELLER eieren etter at runden er // fullført) -- viser da KUN eksklusjons-toggelen, ingen av de andre // feltene under. restricted: boolean 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 [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (restricted) return 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, restricted]) // 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 (!restricted) { 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 (!isGuest && excludeFromHandicap !== player.excludeFromHandicap) { body.exclude_from_handicap = excludeFromHandicap } 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}

} {restricted && (

Runden er fullført, eller du har ikke rett til å endre de andre feltene her -- du kan likevel endre om {player.isSelf ? "din egen deltakelse" : "spillerens deltakelse"} skal telle mot {player.isSelf ? "ditt" : "spillerens"} faktiske HCP.

)} {!restricted && ( <> {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} /> )} {!isGuest && ( )}
) } 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, sides, isTwoSided, formatResult, onSelectHole, onOpenEntry, }: { players: Player[] holesByParticipant: Record holeOrder: number[] holesPlanned: number currentHole: number readOnly: boolean // Match-aware fargelegging + Stilling-rad (2026-07-29) -- kun for // to-sidede individuell-ball-formater (match/fourball), aldri slagspill/ // skins (ingen sider/lead-begrep der). sides: ApiSide[] isTwoSided: boolean formatResult: ApiFormatResult | null 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) // Match-aware fargelegging (2026-07-29): entries[].id == round_participant.id // for individuell-ball-formater (match/fourball) -- se _build_format_result // i app/routers/rounds.py. Kun relevant når formatResult faktisk er klart. const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes) const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h])) const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map() function sideOf(playerId: string): "a" | "b" | null { const p = players.find((x) => x.id === playerId) if (!p?.roundSideId) return null return p.roundSideId === sides[0]?.id ? "a" : "b" } 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) const playerSide = showMatchColors ? sideOf(player.id) : null 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 const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined const entry = formatHole?.entries.find((e) => e.id === player.id) const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false) const dimmed = Boolean(entry && entry.counted === false) return ( ) })} {showSplit && ( )} {showSplit && ( )} ) })} {/* High-low-high (2026-07-30) har INGEN "X UP"-lead-begrep -- poeng fordeles 0-2 per hull (to uavhengige duelløer), ikke ett lead-poeng per vunnet hull som resten av to-sidede formater (denne raden ville da vist en misvisende match-play-tekst basert på hull VUNNET, ikke faktiske poeng). Literal poengsum vises i stedet i FormatResultPanel over selve gridet -- per-hull-fargeleggingen (won/dimmed) er fortsatt riktig og vises uendret. Formatet er uansett aldri delt-ball, så denne sjekken er kun reelt aktiv i ScorecardGrid, ikke her. */} {showMatchColors && formatResult?.play_format !== "high_low_high" && ( {holeOrder.map((n) => { const s = stillingByHole.get(n) return ( ) })} {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)} ) : ( "–" )}
Stilling {s?.label ?? "–"} } {showSplit && }
) } // 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. // Mottatte slag FØR hullet er fylt ut, vist som prikker i stedet for tekst // (2026-07-30, etterspurt av bruker som en kompakt erstatning for "−N") -- // ren visuell hint, den faktiske informasjonen ligger i knappens eget // aria-label (§tilgjengelighet: aldri kun et visuelt symbol uten tekst for // noe som faktisk betyr noe). function StrokeDots({ count }: { count: number }) { return (