"use client" import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ArrowLeft, Check, ChevronLeft, ChevronRight, ChevronsUpDown, RefreshCw, Trophy, WifiOff, } from "lucide-react" import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/scoring.py/courses.py) // Fire nye org-lagturnering-formater (2026-07-30) -- se tournament-program.tsx. type Format = | "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" | "chapman" | "shamble" | "money_ball" | "high_low_high" type ScoringMode = "stroke" | "hole_result" type HoleConfig = "full_18" | "front_9" | "back_9" // Individuell-ball -- hver spiller sin egen hole_score-rad (ikke shamble/ // money_ball/high_low_high sin egen aggregering, se app/routers/scoring.py // sin _INDIVIDUAL_FORMATS). Chapman er delt-ball (som foursome), IKKE med her. const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball", "shamble", "money_ball", "high_low_high"] const FORMAT_LABELS: Record = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", fourball: "Fourball", singles: "Singel", chapman: "Chapman", shamble: "Shamble", money_ball: "Money Ball", high_low_high: "High-low-high", } // To-sidede formater (ADR-039-lignende, org-lag) -- gater Nassau-panelet // (ren tilleggsvisning av 18-hulls to-sidet data, ingen egen play_format). const TWO_SIDED_FORMATS: Format[] = [ "singles", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high", ] type ApiTeam = { id: string; name: string; color: string | null } type ApiSession = { id: string name: string | null format: Format hole_config: HoleConfig course_id: string scoring_mode: ScoringMode } type ApiParticipant = { id: string team_side: "a" | "b" team_roster_id: string player_name: string tee_id: string tee_name: string playing_handicap: number | null } type ApiMatch = { id: string sequence: number team_a_id: string team_b_id: string participants: ApiParticipant[] } type ApiHole = { hole_number: number; par: number; stroke_index: number } type ApiHoleScore = { hole_number: number team_side: "a" | "b" match_participant_id: string | null gross_strokes: number // Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme // funksjon som frittstående runder fikk 2026-07-25). Kun satt for // delt-ball-rader (match_participant_id null). selected_participant_id: string | null // Optimistisk versjonssjekk (ADR-060). Alltid satt av serveren. version: number } type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null; version: number } type ApiScorecard = { match_id: string status_text: string | null points_side_a: number | null points_side_b: number | null holes: { hole_number: number; result: "a" | "b" | "halved" }[] stroke_entries: ApiHoleScore[] | null hole_result_entries: ApiHoleResult[] | null } // Enhet som scores: én per SPILLER for individuelle formater (singles/ // fourball), én per SIDE for delt-ball-formater (foursome/greensome/ // scramble) -- se app/routers/scoring.py sin is_individual-sjekk. type Unit = { id: string side: "a" | "b" label: string matchParticipantId: string | null playingHandicap: number | null } // Nøkkel for det lokale offline-overlayet (ADR-028) -- entydig per // hull+side+enhet, samme identitet som backend bruker for upsert. Brukes // OGSÅ som offline-kø-ens resourceKey (ADR-060) -- /hole-scores har samme // URL for alle hull i matchen, så uten en egen ressursnøkkel ville // versjons-kjedingen i flushQueue blandet sammen ulike hull. function strokeKey(holeNumber: number, unit: Unit): string { return `${holeNumber}:${unit.side}:${unit.matchParticipantId ?? ""}` } // Samme begrunnelse som strokeKey, for /hole-results (som ikke har // side/enhet -- kun ett resultat per hull). function resultKey(holeNumber: number): string { return `result:${holeNumber}` } function playedHoleNumbers(holeConfig: HoleConfig): number[] { if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) return Array.from({ length: 18 }, (_, i) => i + 1) } export function SessionScorecard({ organizationId, tournamentId, sessionId, matchId, tournamentName, }: { organizationId: string tournamentId: string sessionId: string matchId: string tournamentName: string }) { const [session, setSession] = useState(null) const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null) const [match, setMatch] = useState(null) const [holes, setHoles] = useState([]) const [scorecard, setScorecard] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [currentHole, setCurrentHole] = useState(null) const [showSummary, setShowSummary] = useState(false) const [showConcede, setShowConcede] = useState(false) const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null) // --- Offline scoreregistrering (ADR-028) ----------------------------- // isOnline styrer kun banner-teksten (navigator.onLine er allerede // sjekket direkte der det faktisk betyr noe, i submitStroke/ // submitHoleResult) -- pendingStrokes/pendingResults er et lokalt // overlay som viser verdier som er lagret i IndexedDB-køen, men ikke // bekreftet mot serveren ennå. const [isOnline, setIsOnline] = useState(true) const [pendingCount, setPendingCount] = useState(0) const [syncing, setSyncing] = useState(false) const [pendingStrokes, setPendingStrokes] = useState>(new Map()) const [pendingResults, setPendingResults] = useState>(new Map()) async function flushPending() { if (syncing) return setSyncing(true) try { const outcomes = await flushQueue(matchId) if (outcomes.length === 0) return setPendingStrokes((prev) => { const next = new Map(prev) for (const o of outcomes) { if (o.entry.url.endsWith("/hole-scores")) { const b = o.entry.body as { hole_number: number team_side: "a" | "b" match_participant_id: string | null } next.delete(`${b.hole_number}:${b.team_side}:${b.match_participant_id ?? ""}`) } } return next }) setPendingResults((prev) => { const next = new Map(prev) for (const o of outcomes) { if (o.entry.url.endsWith("/hole-results")) { const b = o.entry.body as { hole_number: number } next.delete(b.hole_number) } } return next }) 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(matchId)) await refetchScorecard() } 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 }, [matchId]) async function queueStroke( holeNumber: number, unit: Unit, grossStrokes: number, selectedParticipantId: string | null, ) { const url = `/orgs/${organizationId}/matches/${matchId}/hole-scores` const body = { team_side: unit.side, match_participant_id: unit.matchParticipantId, hole_number: holeNumber, gross_strokes: grossStrokes, selected_participant_id: selectedParticipantId, expected_version: strokeVersionFor(holeNumber, unit), } await enqueueWrite({ url, method: "POST", body, matchId, resourceKey: strokeKey(holeNumber, unit) }) setPendingStrokes((prev) => new Map(prev).set(strokeKey(holeNumber, unit), grossStrokes)) setPendingCount((c) => c + 1) } async function queueHoleResult(holeNumber: number, winningSide: "a" | "b" | null) { const url = `/orgs/${organizationId}/matches/${matchId}/hole-results` const body = { hole_number: holeNumber, winning_side: winningSide, expected_version: resultVersionFor(holeNumber), } await enqueueWrite({ url, method: "POST", body, matchId, resourceKey: resultKey(holeNumber) }) setPendingResults((prev) => new Map(prev).set(holeNumber, winningSide)) setPendingCount((c) => c + 1) } async function refetchScorecard() { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include", }) if (res.ok) setScorecard(await res.json()) } useEffect(() => { let cancelled = false async function load() { try { const [sessionsRes, teamsRes, matchesRes] = await Promise.all([ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }), fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }), fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }), ]) if (!sessionsRes.ok || !teamsRes.ok || !matchesRes.ok) throw new Error("load failed") const sessionsData: ApiSession[] = await sessionsRes.json() const foundSession = sessionsData.find((s) => s.id === sessionId) if (!foundSession) throw new Error("session not found") const teamsData: ApiTeam[] = await teamsRes.json() if (teamsData.length !== 2) throw new Error("expected 2 teams") const matchesData: ApiMatch[] = await matchesRes.json() const foundMatch = matchesData.find((m) => m.id === matchId) if (!foundMatch) throw new Error("match not found") const [holesRes, scorecardRes] = await Promise.all([ fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/holes`, { credentials: "include" }), fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include" }), ]) if (!holesRes.ok || !scorecardRes.ok) throw new Error("load failed") const holesData: ApiHole[] = await holesRes.json() const scorecardData: ApiScorecard = await scorecardRes.json() if (cancelled) return setSession(foundSession) setTeams([teamsData[0], teamsData[1]]) setMatch(foundMatch) setHoles(holesData) setScorecard(scorecardData) const played = playedHoleNumbers(foundSession.hole_config) const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1] setCurrentHole(nextUnregistered) const count = await queueCount(matchId).catch(() => 0) if (!cancelled) setPendingCount(count) if (count > 0 && navigator.onLine) void flushPending() } catch { if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.") } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [organizationId, tournamentId, sessionId, matchId]) const units: Unit[] = useMemo(() => { if (!session || !match || !teams) return [] const isIndividual = INDIVIDUAL_FORMATS.includes(session.format) if (isIndividual) { return match.participants.map((p) => ({ id: p.id, side: p.team_side, label: p.player_name, matchParticipantId: p.id, playingHandicap: p.playing_handicap, })) } // Delt-ball (foursome/greensome/scramble): enheten er selve SIDEN, men // playing_handicap er den kombinerte side-verdien lagret på HVER av // sidens deltakere (ADR-039) -- plukk den fra en av dem. return [ { id: "a", side: "a" as const, label: teams[0].name, matchParticipantId: null, playingHandicap: match.participants.find((p) => p.team_side === "a")?.playing_handicap ?? null, }, { id: "b", side: "b" as const, label: teams[1].name, matchParticipantId: null, playingHandicap: match.participants.find((p) => p.team_side === "b")?.playing_handicap ?? null, }, ] }, [session, match, teams]) const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null // Scramble/greensome: hvem sitt utslag som ble valgt for et allerede // registrert hull (migrasjon 039) -- lest fra scorekortet, ikke lagret // lokalt separat. function selectedDriverFor(holeNumber: number, unit: Unit): string | null { const entry = (scorecard?.stroke_entries ?? []).find( (e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId, ) return entry?.selected_participant_id ?? null } // Sist kjente versjon for et hull (ADR-060) -- undefined/null for et // hull som ikke er registrert fra før, som er riktig: serveren bryr seg // ikke om expected_version på en helt ny rad (se scoring.py). function strokeVersionFor(holeNumber: number, unit: Unit): number | null { const entry = (scorecard?.stroke_entries ?? []).find( (e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId, ) return entry?.version ?? null } function resultVersionFor(holeNumber: number): number | null { const entry = (scorecard?.hole_result_entries ?? []).find((e) => e.hole_number === holeNumber) return entry?.version ?? null } // `selectedParticipantId` utelatt = behold gjeldende valg uendret (brukt // av selve slagregistreringen); eksplisitt sendt (inkl. `null`) = et nytt // utslags-valg (brukt av "hvem sitt utslag ble brukt"-knappene under). async function submitStroke( holeNumber: number, unit: Unit, grossStrokes: number, selectedParticipantId?: string | null, ) { const resolvedSelection = selectedParticipantId !== undefined ? selectedParticipantId : selectedDriverFor(holeNumber, unit) setError(null) if (!navigator.onLine) { await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection) return } let res: Response try { res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ team_side: unit.side, match_participant_id: unit.matchParticipantId, hole_number: holeNumber, gross_strokes: grossStrokes, selected_participant_id: resolvedSelection, expected_version: strokeVersionFor(holeNumber, unit), }), }) } catch { // Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet // for å vise en feil, se ADR-028. await queueStroke(holeNumber, unit, grossStrokes, resolvedSelection) return } if (!res.ok) { if (res.status === 409) { const body = await res.json().catch(() => null) if (body?.detail?.code === "STALE_VERSION") { setError(body.detail.message ?? "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.") await refetchScorecard() return } } setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.") return } await refetchScorecard() } // Walkover/konsesjon (ADR-024): kun kaptein for laget som GIR SEG (eller // org-admin) får lov -- backend håndhever dette, UI-et viser bare begge // knappene og lar en 403 melde seg som en vanlig feiltekst, samme mønster // som resten av appen (ingen klientside-forhåndsfiltrering på kapteinstatus). async function concede(teamId: string) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/concede`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ conceding_team_id: teamId }), }) if (!res.ok) { const body = await res.json().catch(() => null) throw new Error(body?.detail?.message ?? "Klarte ikke å registrere walkover.") } setConcedeTeamIndex(null) setShowConcede(false) await refetchScorecard() } catch (e) { setError(e instanceof Error ? e.message : "Klarte ikke å registrere walkover.") } } async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) { setError(null) if (!navigator.onLine) { await queueHoleResult(holeNumber, winningSide) return } let res: Response try { res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide, expected_version: resultVersionFor(holeNumber), }), }) } catch { await queueHoleResult(holeNumber, winningSide) return } if (!res.ok) { if (res.status === 409) { const body = await res.json().catch(() => null) if (body?.detail?.code === "STALE_VERSION") { setError(body.detail.message ?? "Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.") await refetchScorecard() return } } setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.") return } await refetchScorecard() } const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` if (loading || !session || !teams || !match || !scorecard || currentHole === null) { return (
{error ? (

{error}

) : (
) } const played = playedHoleNumbers(session.hole_config) const totalHoles = played.length const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4 const registeredHoleNumbers = new Set([ ...scorecard.holes.map((h) => h.hole_number), ...Array.from(pendingStrokes.keys(), (k) => Number(k.split(":")[0])), ...Array.from(pendingResults.keys()), ]) const statusLabel = scorecard.status_text ?? "AS" function strokeValue(holeNumber: number, unit: Unit): number | null { const key = strokeKey(holeNumber, unit) if (pendingStrokes.has(key)) return pendingStrokes.get(key)! const entry = (scorecard!.stroke_entries ?? []).find( (e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId, ) return entry ? entry.gross_strokes : null } const currentResultEntry = pendingResults.has(currentHole) ? { hole_number: currentHole, winning_side: pendingResults.get(currentHole) ?? null } : (scorecard.hole_result_entries ?? []).find((e) => e.hole_number === currentHole) function goPrev() { setCurrentHole((h) => { const idx = played.indexOf(h!) return played[Math.max(0, idx - 1)] }) } function goNext() { setCurrentHole((h) => { const idx = played.indexOf(h!) return played[Math.min(played.length - 1, idx + 1)] }) } const isIndividual = INDIVIDUAL_FORMATS.includes(session.format) const isLast = played.indexOf(currentHole) >= played.length - 1 return (
{decided ? (
Avgjort
) : (
{statusLabel}
)}
{!isOnline && (
)} {pendingCount > 0 && (
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering. {isOnline && ( )}
)} {error && (

{error}

)} {decided ? ( ) : ( <> {/* Nassau Match Play (2026-07-30) -- tre parallelle vinduer av et EKSISTERENDE to-sidet format, kun for 18-hulls økter. Ren tilleggsvisning, ikke et eget format. */} {TWO_SIDED_FORMATS.includes(session.format) && session.hole_config === "full_18" && ( )}
{showSummary && (
{session.format === "high_low_high" ? ( ) : ( )} {!isIndividual && session.scoring_mode === "stroke" && match && ( )}
)}
{showConcede && (

Kun kapteinen for laget som gir seg (eller en organisasjonsadministrator) kan gjøre dette. Motstanderen får full poengsum for matchen, uansett hvor mange hull som allerede er registrert.

{teams.map((team, i) => concedeTeamIndex === i ? (
Er du sikker på at {team.name} gir seg?
) : ( ), )}
)}
{session.scoring_mode === "stroke" ? (

Hull {currentHole}

· Par {par}
{units.map((unit) => { const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b" const value = strokeValue(currentHole, unit) const isPending = pendingStrokes.has(strokeKey(currentHole, unit)) return (
void submitStroke(currentHole, unit, n)} /> {/* Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 039, samme funksjon som frittstående runder fikk 2026-07-25). Krever et registrert slagtall å knytte valget til -- hole_score-raden finnes ikke før da (ulikt round_hole, som alltid forhåndsopprettes). Helt valgfritt, egen handling. */} {!isIndividual && value !== null && match && (
Hvem sitt utslag ble brukt?{" "} (valgfritt)
{match.participants .filter((p) => p.team_side === unit.side) .map((p) => { const active = selectedDriverFor(currentHole, unit) === p.id return ( ) })}
)}
) })}
) : (

Hull {currentHole}

· Par {par}
{( [ { key: "a", label: `${teams[0].name} vant`, color: teams[0].color, side: "a" as const }, { key: "h", label: "Delt", color: undefined, side: null }, { key: "b", label: `${teams[1].name} vant`, color: teams[1].color, side: "b" as const }, ] as const ).map((opt) => { const active = currentResultEntry ? currentResultEntry.winning_side === opt.side : false return ( ) })}
{pendingResults.has(currentHole) && (

Lagret lokalt · venter på synk

)}
)}
{!isLast ? ( ) : ( )}
)}
) } // --- Small components -------------------------------------------------------- function TeamTag({ name, color, align }: { name: string; color: string; align: "left" | "right" }) { const right = align === "right" return (
) } // Tallvelger for slagregistrering: 1-9 direkte, pluss et "10 eller flere"- // valg som åpner en ny rad med 10-19. Erstatter en tidligere pluss/minus- // stepper -- raskere å treffe riktig tall banebruk (uten å måtte klikke seg // opp/ned ett og ett slag). function StrokePicker({ value, par, isPending, onSelect, }: { value: number | null par: number isPending: boolean onSelect: (n: number) => void }) { const [showHigh, setShowHigh] = useState(value !== null && value >= 10) useEffect(() => { setShowHigh(value !== null && value >= 10) }, [value]) const numbers = showHigh ? Array.from({ length: 10 }, (_, i) => i + 10) : Array.from({ length: 9 }, (_, i) => i + 1) return (
{numbers.map((n) => ( ))} {!showHigh && ( )}
{showHigh ? ( ) : ( )} {value === null ? "Ikke registrert" : isPending ? "Lagret lokalt · venter på synk" : scoreToParLabel(value, par)}
) } // --- Match-scorekort: horisontalt rutenett med løpende stilling ------------ // Egen visuell utforming (ikke en kopi av noe referansebilde) -- samme // informasjon som den tidligere vertikale hull-for-hull-listen (hvem vant // hvilket hull, brutto der relevant), pluss en løpende "Stilling"-rad // (AS/X UP/X&Y) regnet frem hull for hull i stedet for kun sluttresultatet. // Bruker lagenes egne farger (samme konvensjon som resten av appens // turnering-UI, ikke appens generiske primær/oransje-golfscore-språk -- // laget ER identiteten her, ikke over/under par). type RunningState = { lead: number; label: string; tone: "a" | "b" | "neutral"; isClosed: boolean } // Speiler handicap_engine.py sin compute_match_state()/describe() presist, // regnet ett prefiks om gangen -- backend cacher i dag kun SLUTT-tilstanden. function computeRunning(results: ("a" | "b" | "halved")[], totalHoles: number): RunningState[] { const out: RunningState[] = [] let lead = 0 for (let i = 0; i < results.length; i++) { lead += results[i] === "a" ? 1 : results[i] === "b" ? -1 : 0 const holesPlayed = i + 1 const holesRemaining = totalHoles - holesPlayed const isClosed = Math.abs(lead) > holesRemaining const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining const margin = Math.abs(lead) let label: string if (isClosed) { label = holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}` } else if (lead === 0) { label = "AS" } else { label = isDormie ? `Dormie ${margin}` : `${margin} UP` } out.push({ lead, label, tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", isClosed }) } return out } function unitGross(scorecard: ApiScorecard, unit: Unit, holeNumber: number): number | null { const entry = (scorecard.stroke_entries ?? []).find( (e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId, ) return entry ? entry.gross_strokes : null } // Territorium-bar (2026-07-29, brukerens eget forslag: den ledende sidens // sone strekker seg proporsjonalt forbi midtlinjen inn på motstanderens // halvdel). Ulikt round-detail.tsx/round-scorecard.tsx sin primary/brand- // orange-versjon bruker denne lagets FAKTISKE `team.color` (vilkårlig hex, // samme som SegmentedBar i tournament-leaderboard.tsx) -- dominant sone får // hvit tekst (samme kontrastvalg som SegmentedBar), lys sone en svak // gjennomsiktig tone av samme farge med vanlig tema-tekst. 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 IdentityBlock({ name, hcp, color, side, dominant, }: { name: string hcp: number | null color: string side: "a" | "b" dominant: boolean }) { // Boksen STREKKER SEG (ingen items-start/items-end) -- ellers sizes den // etter innholdets egen bredde, ikke sonens faktiske tildelte bredde, og // kan visuelt lekke inn i midt-kolonnen på smale skjermer. Justering skjer // med justify-start/justify-end + text-align inni en boks som alltid har // full, korrekt bredde -- truncate virker da presist. return (
{!dominant &&
{hcp !== null && ( HCP {hcp} )}
) } // Scramble/greensome: "utslag brukt" per spiller (migrasjon 039, samme // funksjon som frittstående runder fikk 2026-07-25, se round-stats.tsx sin // tilsvarende SelectedDriverSummary). Ren klientside-telling fra allerede // hentet scorecard-data -- vises kun de hullene som faktisk har et // registrert valg, ingen antakelse om at ALLE hull må ha ett. function SelectedDriverSummary({ teams, participants, strokeEntries, }: { teams: [ApiTeam, ApiTeam] participants: ApiParticipant[] strokeEntries: ApiHoleScore[] }) { const rows = (["a", "b"] as const).map((side, idx) => { const sideEntries = strokeEntries.filter((e) => e.team_side === side && e.match_participant_id === null) const withSelection = sideEntries.filter((e) => e.selected_participant_id !== null) const counts = new Map() for (const e of withSelection) { const pid = e.selected_participant_id as string counts.set(pid, (counts.get(pid) ?? 0) + 1) } const sideParticipants = participants.filter((p) => p.team_side === side) return { team: teams[idx], playedCount: sideEntries.length, registeredCount: withSelection.length, entries: sideParticipants .map((p) => ({ name: p.player_name, count: counts.get(p.id) ?? 0 })) .sort((a, b) => b.count - a.count), } }) if (rows.every((r) => r.registeredCount === 0)) { return (
Ingen har registrert hvem sitt utslag som ble brukt på noe hull ennå -- valgfritt felt i scoreregistreringen.
) } return (
Utslag brukt {rows.map(({ team, playedCount, registeredCount, entries }) => (
    {entries.map((e) => (
  • {e.name} {e.count} utslag
  • ))}
Registrert på {registeredCount} av {playedCount} spilte hull.
))}
) } function TournamentMatchGrid({ teams, units, holes, scorecard, scoringMode, holeConfig, }: { teams: [ApiTeam, ApiTeam] units: Unit[] holes: ApiHole[] scorecard: ApiScorecard scoringMode: ScoringMode holeConfig: HoleConfig }) { if (scorecard.holes.length === 0) { return

Ingen hull er registrert ennå.

} const decidedHoles = [...scorecard.holes].sort((a, b) => a.hole_number - b.hole_number) const totalHoles = playedHoleNumbers(holeConfig).length const running = computeRunning( decidedHoles.map((h) => h.result), totalHoles, ) const lastState = running.length > 0 ? running[running.length - 1] : null function headerIdentity(side: "a" | "b"): { name: string; hcp: number | null; color: string } { const team = side === "a" ? teams[0] : teams[1] const color = team.color ?? "#64748b" const sideUnits = units.filter((u) => u.side === side) if (sideUnits.length === 1) return { name: sideUnits[0].label, hcp: sideUnits[0].playingHandicap, color } return { name: team.name, hcp: null, color } } const identityA = headerIdentity("a") const identityB = headerIdentity("b") const lead = lastState?.lead ?? 0 const tone = lastState?.tone ?? "neutral" const widthA = lastState ? leadZoneFraction(lead, totalHoles) * 100 : 50 const widthB = 100 - widthA const zoneAStyle = tone === "a" ? { backgroundColor: identityA.color } : tone === "b" ? { backgroundColor: `color-mix(in srgb, ${identityA.color} 15%, transparent)` } : undefined const zoneBStyle = tone === "b" ? { backgroundColor: identityB.color } : tone === "a" ? { backgroundColor: `color-mix(in srgb, ${identityB.color} 15%, transparent)` } : undefined return (
{/* CSS Grid, ikke absolutt overlegg (2026-07-29-fiks, se round-detail.tsx sin FormatResultPanel for full begrunnelse) -- midt-kolonnen (status) er `auto`-bredde, de to sonene `minmax(0, X fr)` -- reserverer alltid nøyaktig plass til statusboksen, kan aldri overlappe et langt navn. */}
{lastState ? ( <> {lastState.label} {lastState.isClosed ? ( Ferdig ) : ( {decidedHoles.length} hull spilt )} ) : ( Ingen hull spilt ennå )}
{decidedHoles.map((h) => ( ))} {decidedHoles.map((h) => ( ))} {decidedHoles.map((h) => ( ))} {units.map((unit) => { const color = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b" return ( {decidedHoles.map((h) => { const won = h.result === unit.side const gross = scoringMode === "stroke" ? unitGross(scorecard, unit, h.hole_number) : null return ( ) })} ) })} {running.map((s, i) => ( ))}
Hull {h.hole_number}
Par {holes.find((x) => x.hole_number === h.hole_number)?.par ?? "–"}
{scoringMode === "stroke" ? (gross ?? "–") : won ? "✓" : "–"}
Stilling {s.label}
) } // --- Nassau Match Play (2026-07-30) -- selvstendig, henter sin egen data --- // Tre parallelle vinduer (front9/back9/overall) av et eksisterende to-sidet // format, ingen egen lagring (se app/routers/scoring.py sin get_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({ organizationId, matchId, refreshKey, }: { organizationId: string matchId: string refreshKey: number }) { const [result, setResult] = useState(null) useEffect(() => { let cancelled = false fetch(`/orgs/${organizationId}/matches/${matchId}/nassau`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiNassauResult | null) => { if (!cancelled) setResult(data) }) .catch(() => {}) return () => { cancelled = true } }, [organizationId, matchId, refreshKey]) if (!result?.ready) return null return (
Nassau
) } // --- High-low-high (2026-07-30) -- egen, dedikert visning ------------------ // Passer IKKE inn i TournamentMatchGrid over: _compute_hole_results returnerer // bevisst [] for dette formatet (litterale 0-2-poeng per hull, ikke en // ternær vinn/tap/delt-modell), så scorecard.holes ville alltid vært tom her // selv om ekte slag er registrert. Egen henting mot det dedikerte // /high-low-high-endepunktet, samme mønster som round-detail.tsx sin // FormatResultPanel-gren for dette formatet. type ApiHighLowHighHole = { hole_number: number; points_a: number; points_b: number } type ApiHighLowHighResult = { ready: boolean points_a: number | null points_b: number | null holes: ApiHighLowHighHole[] | null } function HighLowHighPanel({ organizationId, matchId, teams, refreshKey, }: { organizationId: string matchId: string teams: [ApiTeam, ApiTeam] refreshKey: number }) { const [result, setResult] = useState(null) useEffect(() => { let cancelled = false fetch(`/orgs/${organizationId}/matches/${matchId}/high-low-high`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiHighLowHighResult | null) => { if (!cancelled) setResult(data) }) .catch(() => {}) return () => { cancelled = true } }, [organizationId, matchId, refreshKey]) if (!result?.ready) { return

Venter på at begge sider er komplette.

} const holes = result.holes ?? [] if (holes.length === 0) { return

Ingen hull er registrert ennå.

} return (
{teams[0].name} {result.points_a ?? 0}
Poeng
{teams[1].name} {result.points_b ?? 0}
{holes.map((h) => ( ))} {holes.map((h) => ( ))} {holes.map((h) => ( ))}
Hull {h.hole_number}
{teams[0].name} {h.points_a}
{teams[1].name} {h.points_b}
) } function scoreToParLabel(value: number, par: number): string { const diff = value - par if (diff === 0) return "Par" if (diff === -1) return "Birdie" if (diff === -2) return "Eagle" if (diff <= -3) return "Albatross" if (diff === 1) return "Bogey" if (diff === 2) return "Dobbel bogey" return `+${diff}` } function DecidedView({ teams, scorecard, holes, units, scoringMode, holeConfig, format, organizationId, matchId, }: { teams: [ApiTeam, ApiTeam] scorecard: ApiScorecard holes: ApiHole[] units: Unit[] scoringMode: ScoringMode holeConfig: HoleConfig format: Format organizationId: string matchId: string }) { return (
Matchen er avgjort {scorecard.status_text ?? ""}
{formatPoints(scorecard.points_side_a ?? 0)}
Poeng
{formatPoints(scorecard.points_side_b ?? 0)}

Hull for hull

{format === "high_low_high" ? ( ) : ( )}

Resultatet kan ikke endres etter at matchen er avgjort.

) } function formatPoints(points: number) { return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") }