teecup/frontend/components/hole-history-detail.tsx

243 lines
9.5 KiB
TypeScript
Raw Normal View History

"use client"
// Full historikk på ett hull, inkl. grafer (ADR-079, 2026-08-16) -- åpnes
// ved klikk fra HoleHistoryPanel sin kompakte oppsummering (hole-stat-
// inputs.tsx), som selv sitter i "skjermen før" slagvinduet/-arket (se
// round-detail.tsx sin PlayerHoleCards og individual-tournament-detail.tsx
// sin HoleStatsSheet-"Oversikt"-steg). INGEN backend-endring -- samme
// GET .../holes/{n}/history-endepunkt som panelet selv allerede brukte
// (app/hole_history.py), som allerede returnerer alt som trengs her.
//
// Grafene gjenbruker EKSAKT samme mønster/fargelogikk som round-stats.tsx
// sin CategoryBar/DeviationBar (bekreftet med bruker: "grafer" betyr dette
// etablerte stolpe-mønsteret, ikke et nytt graf-bibliotek) -- egne lokale
// kopier her, samme "ikke delt kode på tvers av filer"-mønster som resten
// av dette området (se DESIGN_SYSTEM.md).
import { useEffect, useState } from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
type HoleHistoryInstance = {
source: "personal" | "tournament"
played_at: string | null
label: string
score: number
par: number
putts: number | null
tee_shot_result: string | null
approach_result: string | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: string | null
}
type HoleHistory = {
times_played: number
times_personal: number
times_tournament: number
average_score: number
average_score_vs_par: number
gir_percent: number | null
fairway_hit_percent: number | null
average_putts: number | null
best_score: number
worst_score: number
instances: HoleHistoryInstance[]
}
// Samme fargeskala/kategori-inndeling som round-stats.tsx (chart-1..6,
// grønn->rød golf-resultat-skala).
const C = {
eagle: "var(--chart-1)",
birdie: "var(--chart-2)",
par: "var(--chart-3)",
bogey: "var(--chart-4)",
dobbel: "var(--chart-5)",
verre: "var(--chart-6)",
}
function signed(n: number): string {
const sign = n > 0 ? "+" : n < 0 ? "" : "±"
return `${sign}${Math.abs(n).toFixed(2).replace(".", ",")}`
}
function pct(n: number): string {
return `${Math.round(n)} %`
}
export function HoleHistoryDetail({
url,
holeNumber,
onClose,
}: {
url: string
holeNumber: number
onClose: () => void
}) {
const [history, setHistory] = useState<HoleHistory | null | undefined>(undefined)
useEffect(() => {
let cancelled = false
fetch(url, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled) setHistory(data)
})
.catch(() => {
if (!cancelled) setHistory(null)
})
return () => {
cancelled = true
}
}, [url])
const par = history?.instances[0]?.par ?? null
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
>
<X aria-hidden="true" className="size-5" />
</button>
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
<span className="truncate text-base font-extrabold text-foreground">
Hull {holeNumber}
{par !== null ? ` · Par ${par}` : ""}
</span>
<span className="truncate text-sm font-semibold text-muted-foreground">Din historikk</span>
</div>
<div className="size-11 shrink-0" aria-hidden="true" />
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
{history === undefined ? (
<div className="flex flex-1 items-center justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : history === null || history.times_played === 0 ? (
<p className="rounded-3xl border border-border bg-card p-6 text-center text-base font-semibold text-muted-foreground">
Ingen historikk å vise dette hullet ennå.
</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<StatTile label="Ganger spilt" value={String(history.times_played)} />
<StatTile label="Snitt slag" value={history.average_score.toFixed(1)} />
<StatTile label="Beste" value={String(history.best_score)} />
<StatTile label="Dårligste" value={String(history.worst_score)} />
</div>
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">
Snitt til par
</span>
<span className="text-5xl font-extrabold tabular-nums text-brand-orange">
{signed(history.average_score_vs_par)}
</span>
</div>
{(history.gir_percent !== null || history.fairway_hit_percent !== null) && (
<div className="grid grid-cols-2 gap-3">
{history.gir_percent !== null && <StatTile label="GIR" value={pct(history.gir_percent)} />}
{history.fairway_hit_percent !== null && (
<StatTile label="Fairway truffet" value={pct(history.fairway_hit_percent)} />
)}
</div>
)}
<ScoreDistribution instances={history.instances} />
<div className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Alle gangene</h3>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{history.instances.map((inst, i) => (
<li key={i} className="flex items-center justify-between gap-3 bg-card px-4 py-3">
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{inst.label}</span>
<span className="text-xs text-muted-foreground">
{inst.played_at ?? "Ukjent dato"} · {inst.source === "personal" ? "Frittstående" : "Turnering"}
</span>
</span>
<span className="shrink-0 text-sm font-extrabold tabular-nums text-foreground">
{inst.score} slag
{inst.putts !== null ? `, ${inst.putts} putt${inst.putts === 1 ? "" : "er"}` : ""}
</span>
</li>
))}
</ul>
</div>
</>
)}
</div>
</main>
</div>
)
}
function StatTile({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-4">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground">{value}</span>
</div>
)
}
// Samme stolpe-mønster som round-stats.tsx sin CategoryBar -- fargede,
// prosent-bredde rektangler med store tall og etiketter.
function ScoreDistribution({ instances }: { instances: HoleHistoryInstance[] }) {
const diff = (inst: HoleHistoryInstance) => inst.score - inst.par
const categories = [
{ label: "Eagle+", test: (d: number) => d <= -2, color: C.eagle },
{ label: "Birdie", test: (d: number) => d === -1, color: C.birdie },
{ label: "Par", test: (d: number) => d === 0, color: C.par },
{ label: "Bogey", test: (d: number) => d === 1, color: C.bogey },
{ label: "Dobbel bogey", test: (d: number) => d === 2, color: C.dobbel },
{ label: "Verre", test: (d: number) => d >= 3, color: C.verre },
]
.map((c) => {
const count = instances.filter((inst) => c.test(diff(inst))).length
const percentage = instances.length > 0 ? (count / instances.length) * 100 : 0
return { ...c, count, percentage }
})
.filter((c) => c.count > 0)
const maxPct = Math.max(...categories.map((c) => c.percentage), 1)
return (
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Score-fordeling</h3>
{categories.map((c) => {
const width = maxPct > 0 ? (c.percentage / maxPct) * 100 : 0
return (
<div key={c.label} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-base font-semibold text-foreground">{c.label}</span>
<span className="text-sm font-bold tabular-nums text-muted-foreground">
{pct(c.percentage)} <span className="text-foreground">· {c.count}</span>
</span>
</div>
<div className="h-6 overflow-hidden rounded-lg bg-muted">
<div
className={cn("h-full rounded-lg")}
style={{ width: `${Math.max(width, 6)}%`, backgroundColor: c.color }}
/>
</div>
</div>
)
})}
</div>
)
}