teecup/frontend/components/session-scorecard.tsx
Erol Haagenrud 64bcc7ad2e "Utslag brukt"-statistikk for scramble/greensome finnes nå også for org-scopede turneringer — samme funksjon som frittstående runder fikk 2026-07-25, bygget som en rask, avgrenset oppfølger.
Ny kolonne hole_score.selected_participant_id (migrasjon 039), validert til å tilhøre riktig side og kun gyldig for delt-ball-hull (ikke individuell ball).
Ny "Hvem sitt utslag ble brukt?"-knapperad i scorekortet for foursome/greensome/scramble-matcher.
Ny "Utslag brukt"-oppsummering i "Vis full oversikt", med opptelling per spiller og "registrert på N av M spilte hull".
Verifisert med en full org-turnering bygget fra bunnen (org/bane/lag/spillere/foursome-match), 51/51 API-sjekker, og en ekte nettleser-gjennomgang der jeg klikket et faktisk valg og bekreftet det landet riktig i databasen. Migrasjon kjørt, begge containere redeployet, teeoff.no upåvirket.

Dermed er "Scramble-grensesnitt" (ARCHITECTURE_DECISIONS.md sitt åpne spørsmål 4) helt avsluttet — begge domener dekket.
2026-07-29 22:20:50 +02:00

1467 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
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)
type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
type ScoringMode = "stroke" | "hole_result"
type HoleConfig = "full_18" | "front_9" | "back_9"
const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball"]
const FORMAT_LABELS: Record<Format, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
fourball: "Fourball",
singles: "Singel",
}
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
}
type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null }
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.
function strokeKey(holeNumber: number, unit: Unit): string {
return `${holeNumber}:${unit.side}:${unit.matchParticipantId ?? ""}`
}
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<ApiSession | null>(null)
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
const [match, setMatch] = useState<ApiMatch | null>(null)
const [holes, setHoles] = useState<ApiHole[]>([])
const [scorecard, setScorecard] = useState<ApiScorecard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentHole, setCurrentHole] = useState<number | null>(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<Map<string, number>>(new Map())
const [pendingResults, setPendingResults] = useState<Map<number, "a" | "b" | null>>(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,
}
await enqueueWrite({ url, method: "POST", body, matchId })
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 }
await enqueueWrite({ url, method: "POST", body, matchId })
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
}
// `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,
}),
})
} 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) {
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 }),
})
} catch {
await queueHoleResult(holeNumber, winningSide)
return
}
if (!res.ok) {
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 (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-3xl flex-1 items-center justify-center px-5 py-6">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : (
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
)}
</main>
</div>
)
}
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 (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
<span className="truncate text-sm text-muted-foreground">
{session.name?.trim() || "Økt"} · {FORMAT_LABELS[session.format]} · Match {match.sequence}
</span>
</div>
</div>
<div className="border-t border-border bg-card/40">
<div className="mx-auto grid w-full max-w-3xl grid-cols-[1fr_auto_1fr] items-center gap-2 px-5 py-3 sm:gap-4">
<TeamTag name={teams[0].name} color={teams[0].color ?? "#64748b"} align="left" />
{decided ? (
<div className="flex flex-col items-center px-1">
<span className="text-lg font-extrabold tracking-tight text-primary">Avgjort</span>
</div>
) : (
<div className="flex items-center gap-1.5 sm:gap-3">
<button
type="button"
onClick={goPrev}
disabled={played.indexOf(currentHole) <= 0}
aria-label="Forrige hull"
className="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<ChevronLeft aria-hidden="true" className="size-5" />
</button>
<div className="flex min-w-[84px] flex-col items-center">
<span className="text-2xl font-extrabold leading-none tracking-tight text-foreground tabular-nums">
{statusLabel}
</span>
</div>
<button
type="button"
onClick={goNext}
disabled={isLast}
aria-label="Neste hull"
className="flex size-11 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<ChevronRight aria-hidden="true" className="size-5" />
</button>
</div>
)}
<TeamTag name={teams[1].name} color={teams[1].color ?? "#64748b"} align="right" />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
{!isOnline && (
<div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
<span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake nett.</span>
</div>
)}
{pendingCount > 0 && (
<div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
<span>
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} synkronisering.
</span>
{isOnline && (
<button
type="button"
onClick={() => void flushPending()}
disabled={syncing}
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
Synkroniser
</button>
)}
</div>
)}
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{decided ? (
<DecidedView
teams={teams}
scorecard={scorecard}
holes={holes}
units={units}
scoringMode={session.scoring_mode}
holeConfig={session.hole_config}
/>
) : (
<>
<nav aria-label="Velg hull" className="-mx-5 mb-4 overflow-x-auto px-5">
<div className="flex gap-2 pb-1">
{played.map((h) => {
const active = h === currentHole
const registered = registeredHoleNumbers.has(h)
return (
<button
key={h}
type="button"
onClick={() => setCurrentHole(h)}
aria-current={active ? "true" : undefined}
className={cn(
"relative flex size-11 shrink-0 flex-col items-center justify-center rounded-2xl border text-sm font-bold tabular-nums transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{h}
{registered && (
<span
aria-hidden="true"
className={cn(
"absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full border-2 border-background",
active ? "bg-primary-foreground text-primary" : "bg-primary text-primary-foreground",
)}
>
<Check className="size-2.5" strokeWidth={3} />
</span>
)}
</button>
)
})}
</div>
</nav>
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setShowSummary((v) => !v)}
aria-expanded={showSummary}
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
>
<span className="text-sm font-semibold text-foreground">Vis full oversikt</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
</button>
{showSummary && (
<div className="border-t border-border">
<TournamentMatchGrid
teams={teams}
units={units}
holes={holes}
scorecard={scorecard}
scoringMode={session.scoring_mode}
holeConfig={session.hole_config}
/>
{!isIndividual && session.scoring_mode === "stroke" && match && (
<SelectedDriverSummary
teams={teams}
participants={match.participants}
strokeEntries={scorecard.stroke_entries ?? []}
/>
)}
</div>
)}
</div>
<div className="mb-6 overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => {
setShowConcede((v) => !v)
setConcedeTeamIndex(null)
}}
aria-expanded={showConcede}
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
>
<span className="text-sm font-semibold text-foreground">Gi opp matchen (walkover)</span>
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
</button>
{showConcede && (
<div className="flex flex-col gap-3 border-t border-border p-4">
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
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.
</p>
{teams.map((team, i) =>
concedeTeamIndex === i ? (
<div
key={team.id}
className="flex flex-col gap-3 rounded-xl bg-destructive/5 p-3 sm:flex-row sm:items-center sm:justify-between"
>
<span className="text-sm text-foreground text-pretty">
Er du sikker at <span className="font-semibold">{team.name}</span> gir seg?
</span>
<div className="flex shrink-0 gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="rounded-xl"
onClick={() => concede(team.id)}
>
Gi opp
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="rounded-xl"
onClick={() => setConcedeTeamIndex(null)}
>
Avbryt
</Button>
</div>
</div>
) : (
<Button
key={team.id}
type="button"
variant="outline"
className="h-11 justify-start rounded-xl text-sm font-semibold"
onClick={() => setConcedeTeamIndex(i as 0 | 1)}
>
<span aria-hidden="true" className="mr-2 size-3 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
{team.name} gir seg
</Button>
),
)}
</div>
)}
</div>
{session.scoring_mode === "stroke" ? (
<section aria-label={`Hull ${currentHole}`}>
<div className="mb-4 flex items-baseline gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
</div>
<div className="flex flex-col gap-3">
{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 (
<div
key={unit.id}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5"
style={{ borderLeftWidth: 6, borderLeftColor: teamColor }}
>
<div className="flex items-center gap-2.5">
<span aria-hidden="true" className="size-3.5 shrink-0 rounded-full" style={{ backgroundColor: teamColor }} />
<span className="text-base font-bold tracking-tight text-foreground text-pretty">
{unit.label}
</span>
{!isIndividual && (
<span className="text-xs font-medium text-muted-foreground">(delt ball)</span>
)}
</div>
<StrokePicker
value={value}
par={par}
isPending={isPending}
onSelect={(n) => 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 && (
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Hvem sitt utslag ble brukt?{" "}
<span className="font-normal normal-case">(valgfritt)</span>
</legend>
<div className="flex flex-wrap gap-2">
{match.participants
.filter((p) => p.team_side === unit.side)
.map((p) => {
const active = selectedDriverFor(currentHole, unit) === p.id
return (
<button
key={p.id}
type="button"
onClick={() => void submitStroke(currentHole, unit, value, active ? null : p.id)}
aria-pressed={active}
className={cn(
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{p.player_name}
</button>
)
})}
</div>
</fieldset>
)}
</div>
)
})}
</div>
</section>
) : (
<section aria-label={`Hull ${currentHole}`}>
<div className="mb-4 flex items-baseline gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{(
[
{ 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 (
<button
key={opt.key}
type="button"
onClick={() => submitHoleResult(currentHole, opt.side)}
aria-pressed={active}
className={cn(
"flex h-16 items-center justify-center gap-2 rounded-2xl border text-base font-bold tracking-tight transition-colors",
active
? opt.color
? "border-transparent text-primary-foreground"
: "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
style={active && opt.color ? { backgroundColor: opt.color } : undefined}
>
{opt.color && (
<span
aria-hidden="true"
className="size-3.5 rounded-full"
style={{ backgroundColor: active ? undefined : opt.color }}
/>
)}
{opt.label}
</button>
)
})}
</div>
{pendingResults.has(currentHole) && (
<p className="mt-2 text-xs font-medium text-muted-foreground">
Lagret lokalt · venter synk
</p>
)}
</section>
)}
<div className="mt-6">
{!isLast ? (
<Button type="button" onClick={goNext} className="h-14 w-full rounded-2xl text-base font-bold shadow-sm">
Neste hull
<ChevronRight aria-hidden="true" className="size-5" />
</Button>
) : (
<Button
type="button"
onClick={() => setShowSummary(true)}
className="h-14 w-full rounded-2xl text-base font-bold shadow-sm"
>
<Check aria-hidden="true" className="size-5" />
Ferdig
</Button>
)}
</div>
</>
)}
</main>
</div>
)
}
// --- Small components --------------------------------------------------------
function TeamTag({ name, color, align }: { name: string; color: string; align: "left" | "right" }) {
const right = align === "right"
return (
<div className={cn("flex min-w-0 items-center gap-2", right ? "flex-row-reverse text-right" : "text-left")}>
<span aria-hidden="true" className="size-3.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="truncate text-sm font-bold tracking-tight text-foreground">{name}</span>
</div>
)
}
// 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 (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-5 gap-2">
{numbers.map((n) => (
<button
key={n}
type="button"
onClick={() => onSelect(n)}
aria-pressed={value === n}
className={cn(
"flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95",
value === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{n}
</button>
))}
{!showHigh && (
<button
type="button"
onClick={() => setShowHigh(true)}
className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-card text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
>
10+
</button>
)}
</div>
<div className="flex items-center justify-between gap-2">
{showHigh ? (
<button
type="button"
onClick={() => setShowHigh(false)}
className="text-xs font-semibold text-primary"
>
Tilbake til 19
</button>
) : (
<span />
)}
<span className="text-xs font-medium text-muted-foreground">
{value === null
? "Ikke registrert"
: isPending
? "Lagret lokalt · venter på synk"
: scoreToParLabel(value, par)}
</span>
</div>
</div>
)
}
// --- 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 (
<div className="flex h-full min-w-0 w-full flex-col justify-center gap-0.5 px-3">
<div
className={cn(
"flex min-w-0 items-center gap-1.5",
side === "a" ? "justify-start" : "justify-end",
side === "b" && "flex-row-reverse",
)}
>
{!dominant && <span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: color }} />}
<span
className="min-w-0 truncate text-sm font-extrabold"
style={{ color: dominant ? "#fff" : undefined }}
>
{name}
</span>
</div>
{hcp !== null && (
<span
className={cn("truncate text-xs font-bold tabular-nums text-muted-foreground", side === "a" ? "text-left" : "text-right")}
style={dominant ? { color: "rgba(255,255,255,0.75)" } : undefined}
>
HCP {hcp}
</span>
)}
</div>
)
}
// 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<string, number>()
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 (
<div className="border-t border-border p-4 text-sm text-muted-foreground">
Ingen har registrert hvem sitt utslag som ble brukt noe hull ennå -- valgfritt felt i
scoreregistreringen.
</div>
)
}
return (
<div className="flex flex-col gap-4 border-t border-border p-4">
<span className="text-sm font-bold text-foreground">Utslag brukt</span>
{rows.map(({ team, playedCount, registeredCount, entries }) => (
<div key={team.id} className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
{team.name}
</span>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{entries.map((e) => (
<li key={e.name} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{e.name}</span>
<span className="text-base font-extrabold tabular-nums text-foreground">{e.count} utslag</span>
</li>
))}
</ul>
<span className="text-xs text-muted-foreground">
Registrert {registeredCount} av {playedCount} spilte hull.
</span>
</div>
))}
</div>
)
}
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 <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p>
}
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 (
<div className="flex flex-col gap-3 p-3 sm:p-4">
{/* 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. */}
<div
className="grid h-[76px] w-full overflow-hidden rounded-2xl sm:h-20"
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
>
<div
className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", tone === "neutral" && "bg-muted")}
style={zoneAStyle}
>
<IdentityBlock name={identityA.name} hcp={identityA.hcp} color={identityA.color} side="a" dominant={tone === "a"} />
</div>
<div className="flex h-full items-center justify-center px-2">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{lastState ? (
<>
<span
className="text-xl font-extrabold leading-none tabular-nums sm:text-2xl"
style={{
color:
lastState.tone === "a" ? identityA.color : lastState.tone === "b" ? identityB.color : undefined,
}}
>
{lastState.label}
</span>
{lastState.isClosed ? (
<span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
Ferdig
</span>
) : (
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{decidedHoles.length} hull spilt
</span>
)}
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div
className={cn("h-full min-w-0 transition-[grid-template-columns] duration-300", tone === "neutral" && "bg-muted")}
style={zoneBStyle}
>
<IdentityBlock name={identityB.name} hcp={identityB.hcp} color={identityB.color} side="b" dominant={tone === "b"} />
</div>
</div>
<div className="-mx-3 overflow-x-auto px-3 sm:mx-0 sm:px-0">
<table
className="w-full border-collapse text-sm"
style={{ minWidth: `${64 + decidedHoles.length * 40}px` }}
>
<colgroup>
<col className="w-16" />
{decidedHoles.map((h) => (
<col key={h.hole_number} className="w-10" />
))}
</colgroup>
<thead>
<tr>
<th
scope="col"
className="border border-border bg-muted px-2 py-1.5 text-left text-xs font-extrabold text-muted-foreground"
>
Hull
</th>
{decidedHoles.map((h) => (
<th
key={h.hole_number}
scope="col"
className="border border-border bg-foreground py-1.5 text-center text-xs font-extrabold tabular-nums text-background"
>
{h.hole_number}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<th
scope="row"
className="border border-border px-2 py-1.5 text-left text-xs font-bold text-muted-foreground"
>
Par
</th>
{decidedHoles.map((h) => (
<td
key={h.hole_number}
className="border border-border py-1.5 text-center text-xs font-semibold text-muted-foreground"
>
{holes.find((x) => x.hole_number === h.hole_number)?.par ?? ""}
</td>
))}
</tr>
{units.map((unit) => {
const color = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
return (
<tr key={unit.id}>
<th scope="row" className="border border-border px-2 py-1.5 text-left">
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 truncate text-xs font-bold text-foreground">{unit.label}</span>
</span>
</th>
{decidedHoles.map((h) => {
const won = h.result === unit.side
const gross = scoringMode === "stroke" ? unitGross(scorecard, unit, h.hole_number) : null
return (
<td key={h.hole_number} className="border border-border p-0.5 text-center">
<span
aria-label={won ? `${unit.label} vant hullet` : undefined}
className={cn(
"mx-auto flex size-7 items-center justify-center rounded-full text-xs font-extrabold tabular-nums",
won ? "text-primary-foreground" : "text-foreground",
)}
style={won ? { backgroundColor: color } : undefined}
>
{scoringMode === "stroke" ? (gross ?? "") : won ? "✓" : ""}
</span>
</td>
)
})}
</tr>
)
})}
<tr className="bg-muted/30">
<th
scope="row"
className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground"
>
Stilling
</th>
{running.map((s, i) => (
<td
key={decidedHoles[i].hole_number}
className="border border-border py-1.5 text-center text-xs font-extrabold tabular-nums"
style={{ color: s.tone === "a" ? identityA.color : s.tone === "b" ? identityB.color : undefined }}
>
{s.label}
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
)
}
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,
}: {
teams: [ApiTeam, ApiTeam]
scorecard: ApiScorecard
holes: ApiHole[]
units: Unit[]
scoringMode: ScoringMode
holeConfig: HoleConfig
}) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4 rounded-3xl bg-primary px-5 py-5 text-primary-foreground shadow-sm">
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary-foreground/15">
<Trophy aria-hidden="true" className="size-7" />
</div>
<div className="flex flex-col">
<span className="text-lg font-extrabold tracking-tight">Matchen er avgjort</span>
<span className="text-sm text-primary-foreground/85 text-pretty">{scorecard.status_text ?? ""}</span>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-2 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex flex-col items-start gap-1">
<div className="flex items-center gap-2">
<span aria-hidden="true" className="size-3.5 rounded-full" style={{ backgroundColor: teams[0].color ?? "#64748b" }} />
<span className="text-sm font-bold text-foreground text-pretty">{teams[0].name}</span>
</div>
<span className="text-3xl font-extrabold tabular-nums text-foreground">
{formatPoints(scorecard.points_side_a ?? 0)}
</span>
</div>
<div className="text-center text-xs font-semibold uppercase tracking-wide text-muted-foreground">Poeng</div>
<div className="flex flex-col items-end gap-1">
<div className="flex flex-row-reverse items-center gap-2">
<span aria-hidden="true" className="size-3.5 rounded-full" style={{ backgroundColor: teams[1].color ?? "#64748b" }} />
<span className="text-sm font-bold text-foreground text-pretty">{teams[1].name}</span>
</div>
<span className="text-3xl font-extrabold tabular-nums text-foreground">
{formatPoints(scorecard.points_side_b ?? 0)}
</span>
</div>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">Hull for hull</h2>
<div className="overflow-hidden rounded-3xl border border-border bg-card shadow-md shadow-black/8">
<TournamentMatchGrid
teams={teams}
units={units}
holes={holes}
scorecard={scorecard}
scoringMode={scoringMode}
holeConfig={holeConfig}
/>
</div>
<p className="mt-3 text-center text-xs text-muted-foreground text-pretty">
Resultatet kan ikke endres etter at matchen er avgjort.
</p>
</div>
</div>
)
}
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}