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.
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
"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 på at oppsettet blir fullført
|
||
</h2>
|
||
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
||
Resultatet vises så 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>
|
||
)
|
||
}
|