teecup/frontend/components/scramble-solo-result.tsx
Erol Haagenrud 038516d236 All five tasks from the original list are complete, plus one critical production bug found and fixed along the way. Summary:
Fullført:

TeeCup-beskrivelse for logo — levert tidligere.
Migrasjon 056 (scramble_solo) — kjørt og live.
Matchspill-varianten (scramble_solo_match, migrasjon 057) — bygget, verifisert, live.
Kritisk bug funnet og rettet: lagspillere kunne ikke registrere score i det hele tatt for scramble_solo-formatene (evig spinner, ingen feilmelding) — var allerede live og ødelagt for ekte lagspillere. Rettet for begge varianter, rullet ut.
"Bruk som mal for egen bane" — vises nå kun i "Opprett ny bane"-flyten (ny lenke "Basér på en eksisterende bane i stedet"), ikke i det vanlige søket.
Alt er rullet ut live på teecup.golf, verifisert i nettleser mot isolerte scratch-miljøer (ryddet opp etterpå), og dokumentert i CHANGELOG.md (punkt 22–24) og FEATURE_BACKLOG.md.
2026-08-05 21:37:32 +02:00

350 lines
11 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"
// Resultatvisning for "Scramble mot enkeltspiller" (migrasjon 056,
// 2026-08-05) -- ren V0-eksport (zip 31), uendret fra levert komponent.
// Adapter fra det generiske API-svaret (ApiFormatResult) til denne
// komponentens ScrambleSoloResult-props ligger i round-leaderboard.tsx
// (buildScrambleSoloResult) og round-detail.tsx (lokal kopi, samme navn).
import { Check, Flag, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------
// Data shape (matches the API response this component is built around)
// ---------------------------------------------------------------------------
export interface ScrambleSoloHole {
holeNumber: number
par: number
strokeIndex: number
result: "a" | "b" | "halved" // "a" = team won hole, "b" = individual won, "halved" = tied
team: { gross: number; net: number; strokesReceived: number }
individual: { gross: number; net: number; strokesReceived: number }
}
export interface ScrambleSoloResult {
ready: boolean
teamLabel: string
individualLabel: string
teamNet: number | null
individualNet: number | null
margin: number | null // individualNet - teamNet; positive = team leads, negative = individual leads, 0 = tied
holes: ScrambleSoloHole[]
}
type Leader = "team" | "individual" | "tie"
function leaderFromMargin(margin: number | null): Leader {
if (margin === null) return "tie"
if (margin > 0) return "team"
if (margin < 0) return "individual"
return "tie"
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ScrambleSoloResultView({ result }: { result: ScrambleSoloResult }) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4">
{result.ready ? (
<>
<HeadlineCard result={result} />
<HoleLedger result={result} />
</>
) : (
<WaitingState />
)}
</div>
)
}
// --- Waiting state ---------------------------------------------------------
function WaitingState() {
return (
<section
className="flex flex-col items-center gap-3 rounded-3xl border border-border bg-card p-8 text-center shadow-md shadow-black/8"
aria-live="polite"
>
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Flag aria-hidden="true" className="size-7" />
</span>
<h2 className="text-xl font-bold text-foreground text-balance">
Venter at oppsettet blir fullført
</h2>
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
Resultatet vises snart handicap og lag er ferdig satt opp for denne runden.
</p>
</section>
)
}
// --- Headline card ---------------------------------------------------------
function HeadlineCard({ result }: { result: ScrambleSoloResult }) {
const leader = leaderFromMargin(result.margin)
const strokes = result.margin === null ? 0 : Math.abs(result.margin)
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<MarginBanner leader={leader} strokes={strokes} result={result} />
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2">
<SideTotal
label={result.teamLabel}
kind="team"
net={result.teamNet}
leading={leader === "team"}
/>
<div className="flex items-center justify-center">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">mot</span>
</div>
<SideTotal
label={result.individualLabel}
kind="individual"
net={result.individualNet}
leading={leader === "individual"}
/>
</div>
</section>
)
}
function MarginBanner({
leader,
strokes,
result,
}: {
leader: Leader
strokes: number
result: ScrambleSoloResult
}) {
let text: string
let tone: "team" | "individual" | "tie"
if (leader === "tie") {
text = "Uavgjort"
tone = "tie"
} else if (leader === "team") {
text = `${result.teamLabel} leder med ${strokes} ${strokes === 1 ? "slag" : "slag"}`
tone = "team"
} else {
text = `${result.individualLabel} leder med ${strokes} slag`
tone = "individual"
}
return (
<div
className={cn(
"flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-center",
tone === "team" && "border-primary/40 bg-primary/10",
tone === "individual" && "border-brand-orange/40 bg-brand-orange/10",
tone === "tie" && "border-border bg-muted/60",
)}
aria-live="polite"
>
{tone === "tie" ? (
<Minus aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
) : (
<Check
aria-hidden="true"
className={cn(
"size-5 shrink-0",
tone === "team" ? "text-primary" : "text-brand-orange",
)}
/>
)}
<span
className={cn(
"text-lg font-extrabold text-balance",
tone === "team" && "text-primary",
tone === "individual" && "text-brand-orange",
tone === "tie" && "text-foreground",
)}
>
{text}
</span>
</div>
)
}
function SideTotal({
label,
kind,
net,
leading,
}: {
label: string
kind: "team" | "individual"
net: number | null
leading: boolean
}) {
return (
<div
className={cn(
"flex flex-col items-center gap-1 rounded-2xl border p-4 text-center transition-colors",
leading && kind === "team" && "border-primary/40 bg-primary/10",
leading && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
!leading && "border-border bg-card",
)}
>
<span
className={cn(
"line-clamp-2 text-base font-bold leading-tight text-pretty",
leading && kind === "team" && "text-primary",
leading && kind === "individual" && "text-brand-orange",
!leading && "text-foreground",
)}
>
{label}
</span>
<span
className={cn(
"text-5xl font-extrabold tabular-nums",
leading && kind === "team" && "text-primary",
leading && kind === "individual" && "text-brand-orange",
!leading && "text-foreground",
)}
>
{net ?? ""}
</span>
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
netto
</span>
{leading && (
<span
className={cn(
"mt-0.5 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
kind === "team" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
)}
>
Leder
</span>
)}
</div>
)
}
// --- Hole-by-hole ledger ---------------------------------------------------
function HoleLedger({ result }: { result: ScrambleSoloResult }) {
return (
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3 px-1">
<h2 className="text-lg font-bold text-foreground">Hull for hull</h2>
<span className="text-sm text-muted-foreground">
{result.holes.length} {result.holes.length === 1 ? "hull spilt" : "hull spilt"}
</span>
</div>
{/* Column legend so the two net columns are unambiguous */}
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 px-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-14">Hull</span>
<span className="truncate text-right">{result.teamLabel}</span>
<span className="truncate text-right">{result.individualLabel}</span>
</div>
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{result.holes.map((hole) => (
<HoleRow key={hole.holeNumber} hole={hole} />
))}
</ol>
</section>
)
}
function HoleRow({ hole }: { hole: ScrambleSoloHole }) {
return (
<li className="grid grid-cols-[auto_1fr_1fr] items-stretch gap-2 p-3">
{/* Hole meta */}
<div className="flex w-14 flex-col justify-center">
<span className="text-lg font-extrabold tabular-nums text-foreground">{hole.holeNumber}</span>
<span className="text-xs text-muted-foreground tabular-nums">
Par {hole.par}
<span className="sr-only">, </span> · idx {hole.strokeIndex}
</span>
</div>
<SideCell
gross={hole.team.gross}
net={hole.team.net}
received={hole.team.strokesReceived}
won={hole.result === "a"}
halved={hole.result === "halved"}
kind="team"
/>
<SideCell
gross={hole.individual.gross}
net={hole.individual.net}
received={hole.individual.strokesReceived}
won={hole.result === "b"}
halved={hole.result === "halved"}
kind="individual"
/>
</li>
)
}
function SideCell({
gross,
net,
received,
won,
halved,
kind,
}: {
gross: number
net: number
received: number
won: boolean
halved: boolean
kind: "team" | "individual"
}) {
const wonLabel = won
? kind === "team"
? "Laget vant hullet"
: "Utfordreren vant hullet"
: halved
? "Delt hull"
: undefined
return (
<div
className={cn(
"flex flex-col items-end justify-center gap-0.5 rounded-xl border px-3 py-2",
won && kind === "team" && "border-primary/40 bg-primary/10",
won && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
!won && halved && "border-dashed border-border bg-muted/40",
!won && !halved && "border-transparent bg-transparent",
)}
>
{/* Net (the number that decides the hole) — largest, bold */}
<div className="flex items-center gap-1.5">
{won && (
<Check
aria-hidden="true"
className={cn("size-4 shrink-0", kind === "team" ? "text-primary" : "text-brand-orange")}
/>
)}
<span
className={cn(
"text-2xl font-extrabold tabular-nums leading-none",
won && kind === "team" && "text-primary",
won && kind === "individual" && "text-brand-orange",
!won && "text-foreground",
)}
>
{net}
</span>
</div>
{/* Gross + strokes received context */}
<span className="text-xs text-muted-foreground tabular-nums">
Brutto {gross}
{received > 0 && <> · {received} slag</>}
</span>
{wonLabel && <span className="sr-only">{wonLabel}</span>}
</div>
)
}