teecup/frontend/components/hole-history-detail.tsx
Erol Haagenrud 9f2711e4b3 Personlig hull-historikk ut av slagvinduet + full historikk med grafer (ADR-079)
Historikk-panelet flyttet ut av selve slagvinduet/-arket i begge flyter:
inn i PlayerHoleCards-kortet (frittstående runder), som nytt "overview"-
steg med auto-hopp når tomt (org-turneringer). Klikk åpner nå en ny delt
full-historikk-skjerm (hole-history-detail.tsx) med aggregerte tall og en
score-fordeling som fargede stolper -- samme CategoryBar-mønster som
round-stats.tsx, ingen ny graf-avhengighet. Ingen backend-endring.

tsc + 45/45 vitest, scratch-verifisert i begge flyter inkl. kombinert
historikk på tvers av runde-/turneringstyper og auto-hopp for hull uten
historikk (lys+mørk). Rullet ut.

Med dette er alle tre forbedringene i score-registreringen (ADR-077/078/079)
fullført.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:21:48 +02:00

242 lines
9.5 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"
// 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>
)
}