teecup/frontend/components/round-scorecard.tsx

487 lines
18 KiB
TypeScript
Raw Normal View History

"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.
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
guest_name: string | null
is_owner: boolean
}
type ApiRound = {
id: string
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
participants: ApiParticipant[]
}
type ApiHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
strokes_received: number | null
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function participantLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
}
// --- 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>
)
}
// --- Screen ----------------------------------------------------------------
export function RoundScorecard({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [participantId, setParticipantId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [error, setError] = useState<string | null>(null)
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)
setParticipantId(data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
}
void load()
return () => {
cancelled = true
}
}, [roundId])
useEffect(() => {
if (!participantId) return
let cancelled = false
async function loadHoles() {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
if (!res.ok) return
const data: ApiHole[] = await res.json()
if (!cancelled) setHoles(data)
}
void loadHoles()
return () => {
cancelled = true
}
}, [roundId, participantId])
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 || !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)
const totalPar = sum(orderedHoles.map((h) => h.par))
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-sm shadow-black/5">
<div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{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, totalPar)} til par</span>
</div>
)}
</div>
{/* Spillervelger, kun når runden har flere deltakere */}
{round.participants.length > 1 && (
<div role="tablist" aria-label="Velg spiller" className="-mx-4 overflow-x-auto px-4">
<div className="flex gap-2">
{round.participants.map((p) => {
const active = p.id === participantId
return (
<button
key={p.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setHoles(null)
setParticipantId(p.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",
)}
>
{participantLabel(p)}
</button>
)
})}
</div>
</div>
)}
{/* Legend */}
<Legend />
{/* Scorecard blocks */}
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 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, totalPar) : ""}
tone="orange"
/>
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : ""} tone="green" />}
</div>
</main>
</div>
)
}