243 lines
9.5 KiB
TypeScript
243 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>
|
|||
|
|
)
|
|||
|
|
}
|