teecup/frontend/components/session-scorecard.tsx
Erol Haagenrud 21196c9cfd Update Todos
Diffe zip 7 mot live-treet

Integrere session-scorecard.tsx mot ekte API

Lenke fra blind draw/program til scorekort-siden

Ekte typesjekket frontend-build

Scratch/manuell verifisering av hele skrive-flyten

Rulle ut live

Oppdatere .md-filer + slette zip 7
Scorekort-skjermen er live. Oppsummert:

Ett hull i fokus om gangen, store steppere for slag-modus, tre-valgs vinnerknapper for hullresultat-modus, hull-navigasjon med registrert-markering, kollapsbar full oversikt, og et feiret «avgjort»-banner som låser alt read-only
Lenket inn fra blind draw sin avslørte visning
Ingen nye backend-hull denne runden — forrige rundes forarbeid dekket alt
Verifisert med en test som speiler frontend-ens egen last-sekvens nøyaktig, test_isolation.sql 12/12, ekte typesjekket build
.md-filene oppdatert, zip 7 slettet
2026-07-18 17:47:33 +02:00

736 lines
30 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,
Minus,
Plus,
Trophy,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- 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
}
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
}
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 }
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)
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)
} 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,
}))
}
return [
{ id: "a", side: "a", label: teams[0].name, matchParticipantId: null },
{ id: "b", side: "b", label: teams[1].name, matchParticipantId: null },
]
}, [session, match, teams])
const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null
async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
setError(null)
try {
const 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,
}),
})
if (!res.ok) throw new Error(`submit stroke: ${res.status}`)
await refetchScorecard()
} catch {
setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.")
}
}
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
setError(null)
try {
const 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 }),
})
if (!res.ok) throw new Error(`submit result: ${res.status}`)
await refetchScorecard()
} catch {
setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.")
}
}
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))
const statusLabel = scorecard.status_text ?? "AS"
function strokeValue(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 ? entry.gross_strokes : null
}
function stepStroke(unit: Unit, delta: number) {
const current = strokeValue(currentHole!, unit)
const base = current === null ? par : current
const next = Math.max(1, Math.min(20, base + delta))
void submitStroke(currentHole!, unit, next)
}
const currentResultEntry = (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">
{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}
/>
) : (
<>
<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">
<HoleSummaryTable teams={teams} scorecard={scorecard} holes={holes} scoringMode={session.scoring_mode} />
</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)
return (
<div
key={unit.id}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 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>
<div className="flex items-center justify-between gap-3">
<button
type="button"
onClick={() => stepStroke(unit, -1)}
aria-label={`Ett slag mindre for ${unit.label}`}
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-secondary text-secondary-foreground transition-colors hover:bg-accent active:scale-95"
>
<Minus aria-hidden="true" className="size-6" />
</button>
<div className="flex flex-1 flex-col items-center">
<span
className={cn(
"text-5xl font-extrabold leading-none tabular-nums",
value === null ? "text-muted-foreground/40" : "text-foreground",
)}
>
{value === null ? "" : value}
</span>
<span className="mt-1 text-xs font-medium text-muted-foreground">
{value === null ? "Ikke registrert" : scoreToParLabel(value, par)}
</span>
</div>
<button
type="button"
onClick={() => stepStroke(unit, 1)}
aria-label={`Ett slag mer for ${unit.label}`}
className="flex size-14 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground transition-colors hover:bg-primary/90 active:scale-95"
>
<Plus aria-hidden="true" className="size-6" />
</button>
</div>
</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>
</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>
)
}
function HoleSummaryTable({
teams,
scorecard,
holes,
scoringMode,
}: {
teams: [ApiTeam, ApiTeam]
scorecard: ApiScorecard
holes: ApiHole[]
scoringMode: ScoringMode
}) {
if (scorecard.holes.length === 0) {
return <p className="px-4 py-4 text-sm text-muted-foreground">Ingen hull er registrert ennå.</p>
}
return (
<ul className="divide-y divide-border">
{scorecard.holes.map((h) => {
const par = holes.find((x) => x.hole_number === h.hole_number)?.par
return (
<li key={h.hole_number} className="flex items-center justify-between gap-3 px-4 py-2.5">
<div className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-xl bg-secondary text-sm font-extrabold tabular-nums text-secondary-foreground">
{h.hole_number}
</span>
{par !== undefined && (
<span className="text-xs font-medium text-muted-foreground">Par {par}</span>
)}
{scoringMode === "stroke" && (
<span className="text-xs tabular-nums text-muted-foreground">
{grossPairLabel(scorecard, h.hole_number)}
</span>
)}
</div>
<OutcomeBadge teams={teams} result={h.result} />
</li>
)
})}
</ul>
)
}
function grossPairLabel(scorecard: ApiScorecard, holeNumber: number): string {
const entries = scorecard.stroke_entries ?? []
const sideGross = (side: "a" | "b"): string => {
const vals = entries.filter((e) => e.hole_number === holeNumber && e.team_side === side).map((e) => e.gross_strokes)
if (vals.length === 0) return ""
return String(Math.min(...vals))
}
return `${sideGross("a")}${sideGross("b")}`
}
function OutcomeBadge({ teams, result }: { teams: [ApiTeam, ApiTeam]; result: "a" | "b" | "halved" }) {
if (result === "halved") {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
Delt
</span>
)
}
const team = result === "a" ? teams[0] : teams[1]
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold text-primary-foreground"
style={{ backgroundColor: team.color ?? "#64748b" }}
>
{team.name}
</span>
)
}
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,
}: {
teams: [ApiTeam, ApiTeam]
scorecard: ApiScorecard
holes: ApiHole[]
units: Unit[]
scoringMode: ScoringMode
}) {
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-2xl border border-border bg-card">
<HoleSummaryTable teams={teams} scorecard={scorecard} holes={holes} scoringMode={scoringMode} />
</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(".", ",")
}