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.
1324 lines
52 KiB
TypeScript
1324 lines
52 KiB
TypeScript
"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
|
||
}
|
||
|
||
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) {
|
||
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,
|
||
}
|
||
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
|
||
|
||
async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) {
|
||
setError(null)
|
||
if (!navigator.onLine) {
|
||
await queueStroke(holeNumber, unit, grossStrokes)
|
||
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,
|
||
}),
|
||
})
|
||
} catch {
|
||
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
|
||
// for å vise en feil, se ADR-028.
|
||
await queueStroke(holeNumber, unit, grossStrokes)
|
||
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 på 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"} på 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 nå
|
||
</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}
|
||
/>
|
||
</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 på 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)}
|
||
/>
|
||
</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 på 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 1–9
|
||
</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>
|
||
)
|
||
}
|
||
|
||
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(".", ",")
|
||
}
|