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>
242 lines
9.5 KiB
TypeScript
242 lines
9.5 KiB
TypeScript
"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 på 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>
|
||
)
|
||
}
|