The match/skins identity banner (the "Match" card showing both players/sides + a status like "1 UP" or "AS") now renders as a territory bar in all three places it appears — round-detail.tsx's live Score tab, round-scorecard.tsx's post-round summary, and session-scorecard.tsx's tournament match view. The leading side's colored zone extends proportionally past the midpoint into the trailing side's half (capped so both names stay legible at extreme leads), and both sides are equal/neutral at AS or before any holes are scored — matching your sketch's idea directly. Along the way I found and fixed a real overlap bug during browser testing: a long name could visually collide with the centered status pill on narrow (mobile) screens. Root cause was the identity box sizing itself to its own content instead of stretching to fill its zone — fixed so it always stretches, with alignment now handled via justify-start/end instead. Verified in both light/dark mode, normal and extreme leads, on a real scratch round matching your HCP scenario. Deployed live, teeoff.no unaffected.
1101 lines
43 KiB
TypeScript
1101 lines
43 KiB
TypeScript
"use client"
|
||
|
||
// Horisontalt scorekort for en frittstående runde (ADR-033) -- egen,
|
||
// dedikert side (ikke en del av round-stats.tsx sin aggregerte statistikk).
|
||
// Presentasjon fra V0 (zip 17), datalag skrevet om fra mock til ekte fetch
|
||
// mot samme `/rounds/{id}/participants/{id}/holes`-endepunkt som
|
||
// round-detail.tsx og round-stats.tsx allerede bruker.
|
||
//
|
||
// Bevisst forskjell fra V0s eksport: V0s egen `strokesReceived()` var en
|
||
// generisk modulo-formel -- byttet ut med backend sin ALLEREDE beregnede
|
||
// `strokes_received` per hull (samme `allocate_strokes_by_index()`-
|
||
// algoritme som resten av appen), for å unngå to ulike implementasjoner av
|
||
// samme HCP-slagfordeling.
|
||
//
|
||
// Format-bevissthet lagt til 2026-07-28: brukeren rapporterte at siden kun
|
||
// noensinne viste brutto/netto/stableford, uansett spilleform -- bekreftet
|
||
// ved simulering at (a) foursome/greensome/scramble var HELT BLANKE her
|
||
// (delt-ball-formater lagrer score PER SIDE, ikke per deltaker -- se
|
||
// ADR-039 -- og denne siden spurte kun mot deltaker-endepunktet), og (b)
|
||
// match/fourball/skins viste riktige rå tall, men aldri HVORFOR et hull
|
||
// ble avgjort som det ble (brutto vs. netto, hvem sitt netto talte for
|
||
// siden i fourball, hvem som vant/hvilket hull som rullet videre i skins).
|
||
// Fikset ved å: (1) la delt-ball-formater bruke SIDER som "enhet" i stedet
|
||
// for deltakere (samme visuelle ScoreBlock-tabell, bare på
|
||
// `/rounds/{id}/sides/{id}/holes`, som fikk et nytt `strokes_received`-felt
|
||
// samtidig), og (2) legge til en egen "Matchforløp"/"Skins hull for hull"-
|
||
// seksjon drevet av `/rounds/{id}/format-result` sitt nye `holes`-felt
|
||
// (brutto/netto/slag mottatt PER enhet PER hull, pluss hvem som vant og
|
||
// hvorfor).
|
||
|
||
import { useEffect, useState } from "react"
|
||
import Link from "next/link"
|
||
import { ArrowLeft } from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
// --- API-typer ---------------------------------------------------------------
|
||
|
||
type ApiParticipant = {
|
||
id: string
|
||
user_id: string | null
|
||
guest_name: string | null
|
||
display_name: string
|
||
is_owner: boolean
|
||
round_side_id: string | null
|
||
playing_handicap: number | null
|
||
}
|
||
|
||
type ApiSide = {
|
||
id: string
|
||
label: string | null
|
||
}
|
||
|
||
type ApiRound = {
|
||
name: string | null
|
||
id: string
|
||
course_name_snapshot: string
|
||
tee_name_snapshot: string
|
||
played_at: string
|
||
start_hole: number
|
||
holes_planned: number
|
||
play_format: string
|
||
participants: ApiParticipant[]
|
||
sides: ApiSide[]
|
||
}
|
||
|
||
type ApiHole = {
|
||
hole_number: number
|
||
par: number
|
||
stroke_index: number
|
||
played: boolean
|
||
score: number | null
|
||
strokes_received: number | null
|
||
}
|
||
|
||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"]
|
||
const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS]
|
||
const FORMAT_LABELS: Record<string, string> = {
|
||
stroke: "Slagspill",
|
||
match: "Match",
|
||
skins: "Skins",
|
||
fourball: "Fourball",
|
||
foursome: "Foursome",
|
||
greensome: "Greensome",
|
||
scramble_2: "Scramble (2)",
|
||
scramble_4: "Scramble (4)",
|
||
}
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||
|
||
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
|
||
// begrunnelse i round-detail.tsx sin playerLabel().
|
||
function participantLabel(p: ApiParticipant, viewerId: string | null): string {
|
||
return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name
|
||
}
|
||
|
||
// --- Scoring helpers -------------------------------------------------------
|
||
|
||
function netScore(h: ApiHole): number | null {
|
||
return h.played && h.score !== null && h.strokes_received !== null ? h.score - h.strokes_received : null
|
||
}
|
||
|
||
// Stableford: 2 pts for net par, +/- 1 per stroke, floored at 0.
|
||
function stablefordPoints(h: ApiHole): number | null {
|
||
const net = netScore(h)
|
||
if (net === null) return null
|
||
return Math.max(0, h.par - net + 2)
|
||
}
|
||
|
||
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
|
||
|
||
function classify(gross: number, par: number): ScoreClass {
|
||
const diff = gross - par
|
||
if (diff <= -2) return "eagle"
|
||
if (diff === -1) return "birdie"
|
||
if (diff === 0) return "par"
|
||
if (diff === 1) return "bogey"
|
||
return "double"
|
||
}
|
||
|
||
function sum(values: number[]): number {
|
||
return values.reduce((a, b) => a + b, 0)
|
||
}
|
||
|
||
function signedToPar(score: number, par: number): string {
|
||
const diff = score - par
|
||
if (diff === 0) return "E"
|
||
return diff > 0 ? `+${diff}` : `−${Math.abs(diff)}`
|
||
}
|
||
|
||
// --- Score mark (the visual language for the Score row) --------------------
|
||
// Shape encodes direction (circle = under par, square = over par) so meaning
|
||
// survives without color; fill encodes magnitude (filled = 2+ strokes off).
|
||
|
||
function ScoreMark({ gross, par }: { gross: number | null; par: number }) {
|
||
const shared =
|
||
"flex size-6 items-center justify-center text-sm font-extrabold tabular-nums sm:size-7"
|
||
if (gross === null) {
|
||
return <span className={cn(shared, "text-muted-foreground")}>{"–"}</span>
|
||
}
|
||
const kind = classify(gross, par)
|
||
if (kind === "eagle") {
|
||
return (
|
||
<span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{gross}</span>
|
||
)
|
||
}
|
||
if (kind === "birdie") {
|
||
return (
|
||
<span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>
|
||
{gross}
|
||
</span>
|
||
)
|
||
}
|
||
if (kind === "bogey") {
|
||
return (
|
||
<span
|
||
className={cn(
|
||
shared,
|
||
"rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
|
||
)}
|
||
>
|
||
{gross}
|
||
</span>
|
||
)
|
||
}
|
||
if (kind === "double") {
|
||
return (
|
||
<span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>
|
||
{gross}
|
||
</span>
|
||
)
|
||
}
|
||
// par
|
||
return <span className={cn(shared, "font-bold text-foreground")}>{gross}</span>
|
||
}
|
||
|
||
// --- One nine-hole block (a real table for row/column semantics) -----------
|
||
|
||
function ScoreBlock({
|
||
holes,
|
||
summaryLabel,
|
||
showStableford,
|
||
}: {
|
||
holes: ApiHole[]
|
||
summaryLabel: string
|
||
showStableford: boolean
|
||
}) {
|
||
const parSum = sum(holes.map((h) => h.par))
|
||
const playedHoles = holes.filter((h) => h.played && h.score !== null)
|
||
const grossSum = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null
|
||
const nettoValues = holes.map(netScore).filter((n): n is number => n !== null)
|
||
const nettoSum = nettoValues.length > 0 ? sum(nettoValues) : null
|
||
const pointsValues = holes.map(stablefordPoints).filter((n): n is number => n !== null)
|
||
const pointsSum = pointsValues.length > 0 ? sum(pointsValues) : null
|
||
|
||
const cell = "border border-border text-center tabular-nums"
|
||
const sumCell = "border border-border bg-muted text-center font-extrabold tabular-nums"
|
||
const rowLabel =
|
||
"border border-border px-1 text-left text-xs font-bold text-muted-foreground"
|
||
|
||
return (
|
||
<table className="w-full table-fixed border-collapse">
|
||
<caption className="sr-only">
|
||
Scorekort hull {holes[0].hole_number} til {holes[holes.length - 1].hole_number}
|
||
</caption>
|
||
<colgroup>
|
||
<col className="w-9" />
|
||
{holes.map((h) => (
|
||
<col key={h.hole_number} />
|
||
))}
|
||
<col className="w-[11%]" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" className="border border-border bg-muted px-1 py-1 text-left">
|
||
<span className="sr-only">Rad</span>
|
||
<span aria-hidden="true" className="text-xs font-extrabold text-muted-foreground">
|
||
Hull
|
||
</span>
|
||
</th>
|
||
{holes.map((h) => (
|
||
<th
|
||
key={h.hole_number}
|
||
scope="col"
|
||
className="border border-border bg-foreground py-1 text-center text-xs font-extrabold tabular-nums text-background"
|
||
>
|
||
{h.hole_number}
|
||
</th>
|
||
))}
|
||
<th
|
||
scope="col"
|
||
className="border border-border bg-primary py-1 text-center text-xs font-extrabold uppercase text-primary-foreground"
|
||
>
|
||
{summaryLabel}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* Hcp (stroke index) */}
|
||
<tr>
|
||
<th scope="row" className={rowLabel}>
|
||
Hcp
|
||
</th>
|
||
{holes.map((h) => (
|
||
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||
{h.stroke_index}
|
||
</td>
|
||
))}
|
||
<td className={cn(sumCell, "py-1 text-xs text-muted-foreground")} aria-hidden="true">
|
||
{"–"}
|
||
</td>
|
||
</tr>
|
||
{/* Par */}
|
||
<tr>
|
||
<th scope="row" className={rowLabel}>
|
||
Par
|
||
</th>
|
||
{holes.map((h) => (
|
||
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||
{h.par}
|
||
</td>
|
||
))}
|
||
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{parSum}</td>
|
||
</tr>
|
||
{/* Score — the hero row: highlighted band + shape marks */}
|
||
<tr className="bg-primary/5">
|
||
<th scope="row" className={cn(rowLabel, "text-sm text-foreground")}>
|
||
Score
|
||
</th>
|
||
{holes.map((h) => (
|
||
<td key={h.hole_number} className={cn(cell, "px-0.5 py-1")}>
|
||
<span className="flex items-center justify-center">
|
||
<ScoreMark gross={h.played ? h.score : null} par={h.par} />
|
||
</span>
|
||
</td>
|
||
))}
|
||
<td className={cn(sumCell, "py-1 text-base text-foreground")}>{grossSum ?? "–"}</td>
|
||
</tr>
|
||
{/* Netto */}
|
||
<tr>
|
||
<th scope="row" className={cn(rowLabel, "text-foreground")}>
|
||
Netto
|
||
</th>
|
||
{holes.map((h) => (
|
||
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-bold text-foreground")}>
|
||
{netScore(h) ?? "–"}
|
||
</td>
|
||
))}
|
||
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{nettoSum ?? "–"}</td>
|
||
</tr>
|
||
{/* Stableford (only when relevant) */}
|
||
{showStableford && (
|
||
<tr>
|
||
<th scope="row" className={rowLabel} title="Stableford-poeng">
|
||
Poeng
|
||
</th>
|
||
{holes.map((h) => (
|
||
<td key={h.hole_number} className={cn(cell, "py-1 text-xs font-semibold text-muted-foreground")}>
|
||
{stablefordPoints(h) ?? "–"}
|
||
</td>
|
||
))}
|
||
<td className={cn(sumCell, "py-1 text-xs text-foreground")}>{pointsSum ?? "–"}</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
)
|
||
}
|
||
|
||
// --- Legend ----------------------------------------------------------------
|
||
|
||
function Legend() {
|
||
return (
|
||
<div
|
||
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-2xl border border-border bg-card px-4 py-3"
|
||
aria-label="Symbolforklaring"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<span className="flex size-6 items-center justify-center rounded-full border-2 border-primary bg-primary/10 text-xs font-extrabold text-primary">
|
||
3
|
||
</span>
|
||
<span className="text-sm font-semibold text-foreground">Under par (sirkel)</span>
|
||
</span>
|
||
<span className="flex items-center gap-2">
|
||
<span className="flex size-6 items-center justify-center rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-xs font-extrabold text-brand-orange">
|
||
5
|
||
</span>
|
||
<span className="text-sm font-semibold text-foreground">Over par (firkant)</span>
|
||
</span>
|
||
<span className="text-sm font-medium text-muted-foreground">Fylt symbol = 2 slag eller mer</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Combined total --------------------------------------------------------
|
||
|
||
function TotalTile({ label, value, tone }: { label: string; value: string; tone?: "orange" | "green" }) {
|
||
return (
|
||
<div className="flex flex-col items-center gap-0.5 rounded-2xl border border-border bg-card px-2 py-3">
|
||
<span
|
||
className={cn(
|
||
"text-2xl font-extrabold tabular-nums",
|
||
tone === "orange" ? "text-brand-orange" : tone === "green" ? "text-primary" : "text-foreground",
|
||
)}
|
||
>
|
||
{value}
|
||
</span>
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Matchforløp / skins-forløp (2026-07-28) ---------------------------------
|
||
// Avledet ved LESING fra GET .../format-result -- viser HVORFOR et hull ble
|
||
// avgjort som det ble (brutto vs. netto, slag mottatt, hvem sitt netto
|
||
// talte for siden i fourball, hvem som vant/hvilket hull som rullet videre
|
||
// i skins) -- ikke bare et vinn/tap-merke.
|
||
|
||
type ApiFormatHoleEntry = {
|
||
id: string
|
||
label: string
|
||
side: "a" | "b" | null
|
||
gross: number | null
|
||
net: number | null
|
||
strokes_received: number | null
|
||
counted: boolean
|
||
}
|
||
|
||
type ApiFormatHoleOut = {
|
||
hole_number: number
|
||
par: number
|
||
stroke_index: number
|
||
entries: ApiFormatHoleEntry[]
|
||
result: string | null
|
||
winner_ids: string[] | null
|
||
carried: boolean | null
|
||
}
|
||
|
||
type ApiFormatResult = {
|
||
play_format: string
|
||
ready: boolean
|
||
match_status_text: string | null
|
||
skins_won: Record<string, number> | null
|
||
holes: ApiFormatHoleOut[] | null
|
||
}
|
||
|
||
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
||
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: ApiFormatResult | null) => {
|
||
if (!cancelled) setResult(data)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, refreshKey])
|
||
return result
|
||
}
|
||
|
||
// --- Matchforløp: horisontalt scorekort med løpende stilling ---------------
|
||
// Egen visuell utforming (ikke en kopi av noe referansebilde) -- gjenbruker
|
||
// appens etablerte vinn/tap-språk (grønn=side A, oransje=side B, nøytral=delt,
|
||
// se ScoreMark/ResultChip andre steder) fremfor referansens rød/blå, og
|
||
// erstatter den tidligere vertikale hull-for-hull-listen med et horisontalt
|
||
// rutenett (Hull/Par/enhet-rader/Stilling) + en identitets-/resultatbanner --
|
||
// samme informasjon (hvem vant hvilket hull, AS/X UP/X&Y), annen presentasjon.
|
||
|
||
type RunningState = {
|
||
lead: number
|
||
label: string
|
||
tone: "a" | "b" | "neutral"
|
||
isClosed: boolean
|
||
}
|
||
|
||
// Speiler handicap_engine.py sin compute_match_state()/describe() presist,
|
||
// men regner ett PREFIKS om gangen slik at hvert hull får sin egen løpende
|
||
// stilling (backend cacher i dag kun SLUTT-tilstanden -- se ADR-039-notatet).
|
||
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
|
||
}
|
||
|
||
type HeaderIdentity = { name: string; hcp: number | null }
|
||
|
||
// Territorium-bar (2026-07-29, brukerens eget forslag: den ledende sidens
|
||
// sone strekker seg proporsjonalt forbi midtlinjen inn på motstanderens
|
||
// halvdel, i stedet for en statisk 50/50-boks med tekst i midten). Ved AS
|
||
// eller ingen hull avgjort ennå er begge soner like store og nøytralt
|
||
// farget. Egen lokal kopi av samme idé som round-detail.tsx sin
|
||
// FormatResultPanel, per prosjektets "én fil, én kopi"-konvensjon.
|
||
function leadZoneFraction(lead: number, totalHoles: number): number {
|
||
if (totalHoles <= 0) return 0.5
|
||
const fraction = 0.5 + (lead / totalHoles) * 0.5
|
||
return Math.min(0.82, Math.max(0.18, fraction))
|
||
}
|
||
|
||
function LeadZone({ identity, side, dominant }: { identity: HeaderIdentity; side: "a" | "b"; dominant: boolean }) {
|
||
const textTone = dominant ? (side === "a" ? "text-primary-foreground" : "text-brand-orange-foreground") : "text-foreground"
|
||
const subTone = dominant
|
||
? side === "a"
|
||
? "text-primary-foreground/75"
|
||
: "text-brand-orange-foreground/75"
|
||
: "text-muted-foreground"
|
||
// 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={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
|
||
/>
|
||
)}
|
||
<span className={cn("min-w-0 truncate text-sm font-extrabold", textTone)}>{identity.name}</span>
|
||
</div>
|
||
{identity.hcp !== null && (
|
||
<span
|
||
className={cn(
|
||
"truncate text-xs font-bold tabular-nums",
|
||
subTone,
|
||
side === "a" ? "text-left" : "text-right",
|
||
)}
|
||
>
|
||
HCP {identity.hcp}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LeadBar({
|
||
identityA,
|
||
identityB,
|
||
state,
|
||
holesPlayed,
|
||
totalHoles,
|
||
}: {
|
||
identityA: HeaderIdentity
|
||
identityB: HeaderIdentity
|
||
state: RunningState | null
|
||
holesPlayed: number
|
||
totalHoles: number
|
||
}) {
|
||
const lead = state?.lead ?? 0
|
||
const tone = state?.tone ?? "neutral"
|
||
const widthA = state ? leadZoneFraction(lead, totalHoles) * 100 : 50
|
||
const widthB = 100 - widthA
|
||
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
|
||
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
|
||
return (
|
||
// 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", zoneAClass)}>
|
||
<LeadZone identity={identityA} 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">
|
||
{state ? (
|
||
<>
|
||
<span
|
||
className={cn(
|
||
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
|
||
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
|
||
)}
|
||
>
|
||
{state.label}
|
||
</span>
|
||
{state.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">{holesPlayed} 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", zoneBClass)}>
|
||
<LeadZone identity={identityB} side="b" dominant={tone === "b"} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type MatchRow = { id: string; side: "a" | "b"; label: string; hcp: number | null }
|
||
|
||
function MatchScorecardGrid({
|
||
round,
|
||
roundId,
|
||
viewerId,
|
||
refreshKey,
|
||
}: {
|
||
round: ApiRound
|
||
roundId: string
|
||
viewerId: string | null
|
||
refreshKey: number
|
||
}) {
|
||
const result = useFormatResult(roundId, refreshKey)
|
||
if (!result) return null
|
||
|
||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||
|
||
const rows: MatchRow[] = isSharedBall
|
||
? round.sides.map((s, idx) => ({
|
||
id: s.id,
|
||
side: idx === 0 ? "a" : ("b" as const),
|
||
label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B"),
|
||
hcp: round.participants.find((p) => p.round_side_id === s.id)?.playing_handicap ?? null,
|
||
}))
|
||
: round.participants
|
||
.filter((p) => p.round_side_id)
|
||
.map((p) => ({
|
||
id: p.id,
|
||
side: (p.round_side_id === round.sides[0]?.id ? "a" : "b") as "a" | "b",
|
||
label: participantLabel(p, viewerId),
|
||
hcp: p.playing_handicap,
|
||
}))
|
||
|
||
function headerIdentity(side: "a" | "b"): HeaderIdentity {
|
||
const sideRows = rows.filter((r) => r.side === side)
|
||
if (sideRows.length === 1) return { name: sideRows[0].label, hcp: sideRows[0].hcp }
|
||
const idx = side === "a" ? 0 : 1
|
||
return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null }
|
||
}
|
||
|
||
if (!result.ready || !result.holes) {
|
||
return (
|
||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||
Venter på at begge sider er komplette og har beregnet handicap før matchforløpet kan vises.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const decidedHoles = result.holes
|
||
const running = computeRunning(decidedHoles.map((h) => h.result as "a" | "b" | "halved"), round.holes_planned)
|
||
const lastState = running.length > 0 ? running[running.length - 1] : null
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
|
||
<LeadBar
|
||
identityA={headerIdentity("a")}
|
||
identityB={headerIdentity("b")}
|
||
state={lastState}
|
||
holesPlayed={decidedHoles.length}
|
||
totalHoles={round.holes_planned}
|
||
/>
|
||
|
||
{decidedHoles.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har
|
||
registrert.
|
||
</p>
|
||
) : (
|
||
<div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4">
|
||
<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"
|
||
>
|
||
{h.par}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
{rows.map((row) => (
|
||
<tr key={row.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={cn(
|
||
"size-2 shrink-0 rounded-full",
|
||
row.side === "a" ? "bg-primary" : "bg-brand-orange",
|
||
)}
|
||
/>
|
||
<span className="min-w-0 truncate text-xs font-bold text-foreground">{row.label}</span>
|
||
</span>
|
||
</th>
|
||
{decidedHoles.map((h) => {
|
||
const entry = h.entries.find((e) => e.id === row.id)
|
||
const won = !!entry && h.result === row.side && entry.counted !== false
|
||
const shown = entry ? (entry.net ?? entry.gross) : null
|
||
return (
|
||
<td key={h.hole_number} className="border border-border p-0.5 text-center">
|
||
<span
|
||
aria-label={
|
||
entry
|
||
? `${row.label}: ${shown} netto${entry.gross !== null && entry.gross !== shown ? `, ${entry.gross} brutto` : ""}${won ? " -- vant hullet" : ""}`
|
||
: undefined
|
||
}
|
||
className={cn(
|
||
"mx-auto flex size-7 items-center justify-center rounded-full text-xs font-extrabold tabular-nums",
|
||
won
|
||
? row.side === "a"
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-brand-orange text-brand-orange-foreground"
|
||
: entry && entry.counted === false
|
||
? "text-muted-foreground/50"
|
||
: "text-foreground",
|
||
)}
|
||
>
|
||
{shown ?? "–"}
|
||
</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={cn(
|
||
"border border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
|
||
s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-muted-foreground",
|
||
)}
|
||
>
|
||
{s.label}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
Fylt sirkel = vunnet hull (grønn = {headerIdentity("a").name}, oransje = {headerIdentity("b").name}). Nedtonet
|
||
tall talte ikke for siden det hullet (laveste netto teller, gjelder fourball).
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
|
||
const result = useFormatResult(roundId, refreshKey)
|
||
if (!result || !result.holes || result.holes.length === 0) return null
|
||
|
||
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
|
||
<h2 className="text-base font-bold text-foreground">Skins, hull for hull</h2>
|
||
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||
{result.holes.map((h) => (
|
||
<div key={h.hole_number} className="flex flex-col gap-1.5 px-3 py-2.5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="text-xs font-bold text-muted-foreground">
|
||
Hull {h.hole_number} · Par {h.par}
|
||
</span>
|
||
{h.carried ? (
|
||
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground">
|
||
Uavgjort — rullet videre
|
||
</span>
|
||
) : h.winner_ids && h.winner_ids.length > 0 ? (
|
||
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground">
|
||
{h.winner_ids.length === 1
|
||
? `${h.entries.find((e) => e.id === h.winner_ids?.[0])?.label ?? "?"} vant skinnet`
|
||
: `Delt: ${h.winner_ids.map((id) => h.entries.find((e) => e.id === id)?.label ?? "?").join(", ")}`}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||
{h.entries.map((e) => {
|
||
const isWinner = h.winner_ids?.includes(e.id) ?? false
|
||
const shown = hasNet && e.net !== null ? e.net : e.gross
|
||
return (
|
||
<span
|
||
key={e.id}
|
||
className={cn(
|
||
"flex items-baseline gap-1 text-sm",
|
||
isWinner ? "font-extrabold text-primary" : "text-foreground",
|
||
)}
|
||
>
|
||
{e.label}: <span className="tabular-nums">{shown ?? "–"}</span>
|
||
{hasNet && e.net !== null && e.gross !== null && e.net !== e.gross && (
|
||
<span className="text-xs font-normal text-muted-foreground">({e.gross} brutto)</span>
|
||
)}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">
|
||
{hasNet ? "Skins avgjøres på netto (brutto vist i parentes)." : "Skins avgjøres på brutto."} Et uavgjort hull
|
||
ruller potten videre til neste hull.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Screen ----------------------------------------------------------------
|
||
|
||
export function RoundScorecard({ roundId }: { roundId: string }) {
|
||
const [round, setRound] = useState<ApiRound | null>(null)
|
||
const [unitId, setUnitId] = useState<string | null>(null)
|
||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [viewerId, setViewerId] = useState<string | null>(null)
|
||
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
|
||
// signal over WebSocket -- bumper denne, som er med i begge fetch-
|
||
// effektene under, slik at de henter på nytt akkurat som ved
|
||
// førstegangslasting (samme mønster som public-live.tsx).
|
||
const [refreshKey, setRefreshKey] = useState(0)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch("/auth/me", { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: { id: string } | null) => {
|
||
if (!cancelled && data) setViewerId(data.id)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
||
socket.onmessage = () => setRefreshKey((k) => k + 1)
|
||
return () => socket.close()
|
||
}, [roundId])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
async function load() {
|
||
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
|
||
if (res.status === 403 || res.status === 404) {
|
||
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
|
||
return
|
||
}
|
||
if (!res.ok) {
|
||
if (!cancelled) setError("Klarte ikke å hente runden. Prøv igjen om litt.")
|
||
return
|
||
}
|
||
const data: ApiRound = await res.json()
|
||
if (cancelled) return
|
||
setRound(data)
|
||
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
|
||
setUnitId((prev) => {
|
||
if (prev) return prev
|
||
if (isSharedBall) {
|
||
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
|
||
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
|
||
}
|
||
// Standardvalg: SIN EGEN rad hvis man er en lenket medspiller,
|
||
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen).
|
||
return (
|
||
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
|
||
data.participants.find((p) => p.is_owner)?.id ??
|
||
data.participants[0]?.id ??
|
||
null
|
||
)
|
||
})
|
||
}
|
||
void load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, viewerId, refreshKey])
|
||
|
||
useEffect(() => {
|
||
if (!unitId || !round) return
|
||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||
const endpoint = isSharedBall
|
||
? `/rounds/${roundId}/sides/${unitId}/holes`
|
||
: `/rounds/${roundId}/participants/${unitId}/holes`
|
||
let cancelled = false
|
||
async function loadHoles() {
|
||
const res = await fetch(endpoint, { credentials: "include" })
|
||
if (!res.ok) return
|
||
const data: ApiHole[] = await res.json()
|
||
if (!cancelled) setHoles(data)
|
||
}
|
||
void loadHoles()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, unitId, round?.play_format, refreshKey])
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||
<p className="text-base font-medium text-destructive">{error}</p>
|
||
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
|
||
Tilbake til egne runder
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!round) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||
const isSkins = round.play_format === "skins"
|
||
const formatLabel = FORMAT_LABELS[round.play_format] ?? round.play_format
|
||
|
||
// Delt-ball-formater trenger sidene opprettet (via runde-siden sin
|
||
// "Spillere og runde"-fane) FØR noe scorekort gir mening her -- vis en
|
||
// forklarende melding i stedet for en evig lastespinner.
|
||
if (isSharedBall && round.sides.length < 2) {
|
||
return (
|
||
<div className="min-h-dvh bg-background">
|
||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||
<Link
|
||
href={`/my-rounds/${roundId}`}
|
||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||
>
|
||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||
Scorekort
|
||
</Link>
|
||
</div>
|
||
</header>
|
||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve
|
||
runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const units: { id: string; label: string }[] = isSharedBall
|
||
? round.sides.map((s, idx) => ({ id: s.id, label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B") }))
|
||
: round.participants.map((p) => ({ id: p.id, label: participantLabel(p, viewerId) }))
|
||
|
||
if (!holes) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Samme sirkulære start_hole-rekkefølge som round-detail.tsx/round-stats.tsx
|
||
// -- "Ut"/"Inn" er dermed første/andre halvdel AV SPILLEREKKEFØLGEN, ikke
|
||
// nødvendigvis fysisk hull 1-9/10-18 (relevant kun for en 18-hulls runde
|
||
// med et annet starthull enn 1, uvanlig men strukturelt tillatt).
|
||
const holeOrder = Array.from(
|
||
{ length: round.holes_planned },
|
||
(_, i) => ((round.start_hole - 1 + i) % 18) + 1,
|
||
)
|
||
const orderedHoles = holeOrder
|
||
.map((n) => holes.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiHole => h !== undefined)
|
||
const front = orderedHoles.slice(0, 9)
|
||
const back = orderedHoles.slice(9, 18)
|
||
|
||
const showStableford = holes.some((h) => h.strokes_received !== null)
|
||
const playedHoles = orderedHoles.filter((h) => h.played && h.score !== null)
|
||
// Full rundens par (statisk referanse, f.eks. "Par 72") -- ALDRI brukt til
|
||
// en til-par-utregning, kun til "Par"-feltet nederst som viser hele
|
||
// runden uansett hvor langt man har kommet.
|
||
const totalPar = sum(orderedHoles.map((h) => h.par))
|
||
// Til-par MÅ regnes mot paret for kun de SPILTE hullene -- ellers blir
|
||
// "til par" meningsløst midt i runden (f.eks. 19 slag på 4 hull ville vist
|
||
// "−53 til par" mot en 72-par bane i stedet for korrekt "+2", funnet ved
|
||
// faktisk nettleser-testing 2026-07-27).
|
||
const playedPar = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.par)) : 0
|
||
const totalGross = playedHoles.length > 0 ? sum(playedHoles.map((h) => h.score as number)) : null
|
||
const totalPointsValues = orderedHoles.map(stablefordPoints).filter((n): n is number => n !== null)
|
||
const totalPoints = totalPointsValues.length > 0 ? sum(totalPointsValues) : null
|
||
|
||
return (
|
||
<div className="min-h-dvh bg-background">
|
||
{/* Header */}
|
||
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||
<Link
|
||
href={`/my-rounds/${roundId}`}
|
||
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||
>
|
||
<ArrowLeft aria-hidden="true" className="size-6" />
|
||
Scorekort
|
||
</Link>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||
{/* Round summary strip */}
|
||
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<div className="flex flex-col">
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||
{round.name?.trim() || round.course_name_snapshot}
|
||
</span>
|
||
{round.play_format !== "stroke" && (
|
||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
|
||
{formatLabel}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<span className="text-sm font-semibold text-muted-foreground">
|
||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||
{round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "}
|
||
{dateFormatter.format(new Date(round.played_at))}
|
||
</span>
|
||
</div>
|
||
{totalGross !== null && (
|
||
<div className="flex flex-col items-end">
|
||
<span className="text-3xl font-extrabold tabular-nums text-foreground">{totalGross}</span>
|
||
<span className="text-sm font-bold text-brand-orange">{signedToPar(totalGross, playedPar)} til par</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */}
|
||
{units.length > 1 && (
|
||
<div role="tablist" aria-label={isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
|
||
<div className="flex gap-2">
|
||
{units.map((u) => {
|
||
const active = u.id === unitId
|
||
return (
|
||
<button
|
||
key={u.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => {
|
||
setHoles(null)
|
||
setUnitId(u.id)
|
||
}}
|
||
className={cn(
|
||
"flex min-h-11 shrink-0 items-center rounded-2xl border px-4 text-base font-bold transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{u.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Legend */}
|
||
<Legend />
|
||
|
||
{/* Scorecard blocks */}
|
||
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-md shadow-black/8 sm:p-4">
|
||
{front.length > 0 && (
|
||
<ScoreBlock holes={front} summaryLabel={back.length === 0 ? "Tot" : "Ut"} showStableford={showStableford} />
|
||
)}
|
||
{back.length > 0 && <ScoreBlock holes={back} summaryLabel="Inn" showStableford={showStableford} />}
|
||
</div>
|
||
|
||
{/* Combined total */}
|
||
<div className={cn("grid gap-2", showStableford ? "grid-cols-4" : "grid-cols-3")}>
|
||
<TotalTile label="Par" value={String(totalPar)} />
|
||
<TotalTile label="Score" value={totalGross !== null ? String(totalGross) : "–"} />
|
||
<TotalTile
|
||
label="Til par"
|
||
value={totalGross !== null ? signedToPar(totalGross, playedPar) : "–"}
|
||
tone="orange"
|
||
/>
|
||
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : "–"} tone="green" />}
|
||
</div>
|
||
|
||
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
|
||
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
|
||
{isTwoSided && round.sides.length === 2 && (
|
||
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
|
||
)}
|
||
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|