teecup/frontend/components/scramble-solo-result.tsx

351 lines
11 KiB
TypeScript
Raw Normal View History

"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>
)
}