teecup/frontend/components/round-stats.tsx
Erol Haagenrud 926a1a5275 All three changes are implemented and scratch-verified (22/22 checks passed, test_isolation.sql still 12/12, frontend typechecks cleanly). Summary of what's ready to ship:
Enkeltbane-anlegg (Tjøme m.fl.): banenavnet droppes nå når anlegget bare har én bane — "Tjøme Golfklubb" i stedet for "Tjøme Golfklubb – Hovedbanen". Anlegg med flere baner (f.eks. Ålesund) beholder fortsatt kombinert navn. Gjelder både turnering-import og frittstående runder.
Navngi runder: nytt valgfritt name-felt på round (migrasjon 024_round_name.sql), settbart ved opprettelse og redigerbart/fjernbart senere via "Rediger runde". Vises på tvers av rundeliste, rundeside, scorekort og statistikk (faller tilbake til banenavn når ikke satt).
Land før hjemmeklubb: "Land" er nå en nedtrekksliste (kun "Norge" foreløpig, klargjort for flere), og "Hjemmeklubb" er en søkbar liste mot teeoffs ekte klubbregister (gjenbruker det eksisterende /rounds/official-search-endepunktet — ingen ny backend-kode). Gjelder både profil-fullføring og kontoinnstillinger.
2026-07-25 06:29:09 +02:00

1022 lines
41 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"
// Rundestatistikk -- dypdykk-skjerm for én frittstående runde (ADR-033),
// inspirert av (ikke kopiert fra) en skjermopptaksvideo brukeren delte av en
// konkurrent-app 2026-07-25 -- se ARCHITECTURE_DECISIONS.md for hvilke
// bevisste designvalg som skiller denne fra referansen. Presentasjon fra V0
// (zip 14), datalag skrevet om fra mock til ekte fetch mot samme
// `/rounds/{id}/participants/{id}/holes`-endepunkt som round-detail.tsx
// allerede bruker -- ingen ny backend nødvendig.
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
BarChart3,
ChevronDown,
ChevronRight,
Flag,
Info,
MoreHorizontal,
MoveHorizontal,
Target,
Wind,
Circle,
Waves,
} from "lucide-react"
import { cn } from "@/lib/utils"
// --- Helpers ---------------------------------------------------------------
// Signed value with two decimals, Norwegian comma. e.g. 0.89 -> "+0,89"
function signed(n: number): string {
const sign = n > 0 ? "+" : n < 0 ? "\u2212" : "\u00B1"
return `${sign}${Math.abs(n).toFixed(2).replace(".", ",")}`
}
function pct(n: number): string {
return `${Math.round(n)} %`
}
// Chart color variables (good -> bad scale defined in globals.css)
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)",
hit: "var(--chart-2)",
miss: "var(--chart-5)",
neutral: "var(--muted-foreground)",
} as const
// --- Collapsible card ------------------------------------------------------
function StatCard({
title,
icon: Icon,
// Alle seksjonene starter kollapset (etterspurt av bruker 2026-07-25:
// "trekkspillet skal vises sammenslått") -- må trykkes på hver enkelt
// for å se innholdet, i stedet for at hele siden lastes åpen.
defaultOpen = false,
children,
}: {
title: string
icon: typeof BarChart3
defaultOpen?: boolean
children: React.ReactNode
}) {
const [open, setOpen] = useState(defaultOpen)
const bodyId = `sect-${title.replace(/\s+/g, "-").toLowerCase()}`
return (
<section className="overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5">
<h2>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={bodyId}
className="flex min-h-14 w-full items-center gap-3 bg-primary px-4 py-3 text-left text-primary-foreground"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary-foreground/20">
<Icon aria-hidden="true" className="size-5" />
</span>
<span className="flex-1 text-lg font-extrabold tracking-tight">{title}</span>
<ChevronDown
aria-hidden="true"
className={cn("size-6 shrink-0 transition-transform", open && "rotate-180")}
/>
</button>
</h2>
{open && (
<div id={bodyId} className="flex flex-col gap-6 p-4 sm:p-5">
{children}
</div>
)}
</section>
)
}
// --- Donut (conic-gradient) ------------------------------------------------
type Segment = { label: string; value: number; color: string }
function Donut({
segments,
centerTop,
centerBottom,
size = 168,
}: {
segments: Segment[]
centerTop: string
centerBottom: string
size?: number
}) {
const total = segments.reduce((s, x) => s + x.value, 0) || 1
let acc = 0
const stops = segments
.map((seg) => {
const start = (acc / total) * 100
acc += seg.value
const end = (acc / total) * 100
return `${seg.color} ${start}% ${end}%`
})
.join(", ")
return (
<div
className="relative shrink-0"
style={{ width: size, height: size }}
role="img"
aria-label={segments.map((s) => `${s.label}: ${s.value}`).join(", ")}
>
<div
className="size-full rounded-full"
style={{ background: `conic-gradient(${stops})` }}
/>
<div className="absolute inset-[18%] flex flex-col items-center justify-center rounded-full bg-card text-center">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{centerTop}</span>
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{centerBottom}
</span>
</div>
</div>
)
}
function Legend({ items }: { items: Array<{ label: string; color: string; value: string }> }) {
return (
<ul className="flex flex-col gap-2">
{items.map((it) => (
<li key={it.label} className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="size-4 shrink-0 rounded-md border border-black/10"
style={{ backgroundColor: it.color }}
/>
<span className="flex-1 text-base font-semibold text-foreground">{it.label}</span>
<span className="text-base font-bold tabular-nums text-foreground">{it.value}</span>
</li>
))}
</ul>
)
}
// --- Category bar (percentage + raw count) ---------------------------------
function CategoryBar({
label,
count,
percentage,
color,
max,
}: {
label: string
count: number
percentage: number
color: string
max: number
}) {
const width = max > 0 ? (percentage / max) * 100 : 0
return (
<div className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<span className="text-sm font-bold tabular-nums text-muted-foreground">
{pct(percentage)} <span className="text-foreground">· {count}</span>
</span>
</div>
<div className="h-6 overflow-hidden rounded-lg bg-muted">
<div
className="h-full rounded-lg"
style={{ width: `${Math.max(width, count > 0 ? 6 : 0)}%`, backgroundColor: color }}
/>
</div>
</div>
)
}
// --- Deviation bar (signed, diverging from a center 0 line) ----------------
function DeviationBar({ label, value, max = 2 }: { label: string; value: number; max?: number }) {
const clamped = Math.max(-max, Math.min(max, value))
const halfPct = (Math.abs(clamped) / max) * 50
const positive = value > 0
// positive = over par (worse) -> orange; negative = under par (better) -> green
const color = value > 0 ? C.dobbel : value < 0 ? C.birdie : C.neutral
return (
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 text-base font-semibold text-foreground">{label}</span>
<div className="relative h-6 flex-1 rounded-lg bg-muted">
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border" aria-hidden="true" />
<div
className="absolute inset-y-0 rounded-lg"
style={{
backgroundColor: color,
width: `${halfPct}%`,
left: positive ? "50%" : undefined,
right: !positive ? "50%" : undefined,
}}
/>
</div>
<span className="w-16 shrink-0 text-right text-base font-bold tabular-nums text-foreground">
{signed(value)}
</span>
</div>
)
}
// --- Horizontal progress bar (single value %) ------------------------------
function ProgressRow({
label,
percentage,
color = "var(--primary)",
suffix,
}: {
label: string
percentage: number
color?: string
suffix?: string
}) {
return (
<div className="flex items-center gap-3">
<span className="w-16 shrink-0 text-base font-semibold text-foreground">{label}</span>
<div className="h-6 flex-1 overflow-hidden rounded-lg bg-muted">
<div
className="h-full rounded-lg"
style={{ width: `${percentage}%`, backgroundColor: color }}
/>
</div>
<span className="w-20 shrink-0 text-right text-base font-bold tabular-nums text-foreground">
{suffix ?? pct(percentage)}
</span>
</div>
)
}
// --- Paired stat tiles -----------------------------------------------------
function StatTilePair({
a,
b,
}: {
a: { label: string; value: string; hint?: string }
b: { label: string; value: string; hint?: string }
}) {
return (
<div className="grid grid-cols-2 gap-3">
{[a, b].map((t) => (
<div
key={t.label}
className="flex flex-col gap-1 rounded-2xl border border-border bg-muted/40 p-4"
>
<span className="text-3xl font-extrabold tabular-nums text-foreground">{t.value}</span>
<span className="text-sm font-semibold leading-snug text-muted-foreground text-pretty">
{t.label}
</span>
</div>
))}
</div>
)
}
// --- Big number stat with info toggle --------------------------------------
function InfoStat({
label,
value,
explanation,
}: {
label: string
value: string
explanation: string
}) {
const [showInfo, setShowInfo] = useState(false)
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-4xl font-extrabold tabular-nums text-foreground">{value}</span>
<button
type="button"
onClick={() => setShowInfo((v) => !v)}
aria-expanded={showInfo}
className="flex min-h-11 items-center gap-1.5 text-left text-base font-bold text-foreground"
>
{label}
<Info aria-hidden="true" className="size-4 text-primary" />
<span className="sr-only">Vis forklaring</span>
</button>
{showInfo && (
<p className="rounded-xl bg-card p-3 text-sm leading-relaxed text-muted-foreground text-pretty">
{explanation}
</p>
)}
</div>
)
}
// --- Mini distribution (small labelled bars) -------------------------------
function MiniDistribution({
title,
data,
color,
}: {
title: string
data: Array<{ label: string; value: number }>
color: string
}) {
const max = Math.max(...data.map((d) => d.value), 1)
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-muted/40 p-3">
<span className="text-sm font-bold text-foreground text-pretty">{title}</span>
<div className="flex flex-col gap-1.5">
{data.map((d) => (
<div key={d.label} className="flex items-center gap-2">
<span className="w-10 shrink-0 text-xs font-semibold tabular-nums text-muted-foreground">
{d.label}
</span>
<div className="h-4 flex-1 overflow-hidden rounded bg-muted">
<div
className="h-full rounded"
style={{ width: `${(d.value / max) * 100}%`, backgroundColor: color }}
/>
</div>
<span className="w-6 shrink-0 text-right text-xs font-bold tabular-nums text-foreground">
{d.value}
</span>
</div>
))}
</div>
</div>
)
}
// --- API-typer (samme form som round-detail.tsx allerede bruker) -----------
type ApiParticipant = {
id: string
guest_name: string | null
is_owner: boolean
}
type ApiRound = {
id: string
name: string | null
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
played: boolean
score: number | null
putts: number | null
tee_shot_result: "fairway" | "left" | "right" | null
approach_result: "hit" | "long" | "short" | "left" | "right" | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+" | null
anyway_strokes: number | null
strokes_received: number | null
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
const PUTT_BUCKETS = ["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] as const
function avg(values: number[]): number | null {
if (values.length === 0) return null
return values.reduce((s, v) => s + v, 0) / values.length
}
// --- Alle utledede tall for statistikk-skjermen, regnet ut ÉN gang fra
// rå hull-data. Ingenting her lagres -- alt beregnes ved lesing, samme
// prinsipp som "Så langt i runden" i round-detail.tsx.
function computeStats(holes: ApiHole[]) {
const played = holes.filter((h) => h.played && h.score !== null)
const diff = (h: ApiHole) => (h.score as number) - h.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 = played.filter((h) => c.test(diff(h))).length
const percentage = played.length > 0 ? (count / played.length) * 100 : 0
return { label: c.label, count, percentage, color: c.color }
})
const avgToPar = avg(played.map(diff))
const byPar = (parValue: number) => avg(played.filter((h) => h.par === parValue).map(diff))
const fairwayTracked = holes.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway")
const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left")
const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right")
const fwTotal = fairwayTracked.length || 1
const avgToParFairwayHit = avg(fairwayHit.filter((h) => h.played && h.score !== null).map(diff))
const avgToParFairwayMiss = avg(
[...fairwayLeft, ...fairwayRight].filter((h) => h.played && h.score !== null).map(diff),
)
// GIR: samme formel som round-detail.tsx sin showGir -- krever kjent putt-tall.
const girEligible = played.filter((h) => h.putts !== null)
const isGir = (h: ApiHole) => (h.score as number) - (h.putts as number) <= h.par - 2
const girHit = girEligible.filter(isGir)
const girPct = girEligible.length > 0 ? (girHit.length / girEligible.length) * 100 : null
const girByPar = (parValue: number) => {
const subset = girEligible.filter((h) => h.par === parValue)
return subset.length > 0 ? (subset.filter(isGir).length / subset.length) * 100 : null
}
const girWithFairway = (want: "hit" | "miss") => {
const subset = girEligible.filter((h) =>
want === "hit" ? h.tee_shot_result === "fairway" : h.tee_shot_result === "left" || h.tee_shot_result === "right",
)
return subset.length > 0 ? (subset.filter(isGir).length / subset.length) * 100 : null
}
const avgToParWithGir = avg(girEligible.filter(isGir).map(diff))
const avgToParWithoutGir = avg(girEligible.filter((h) => !isGir(h)).map(diff))
const missedGreen = holes.filter((h) => h.approach_result !== null && h.approach_result !== "hit")
const missTotal = missedGreen.length || 1
const missDir = (dir: "long" | "short" | "left" | "right") =>
(missedGreen.filter((h) => h.approach_result === dir).length / missTotal) * 100
const puttsEligible = played.filter((h) => h.putts !== null)
const totalPutts = puttsEligible.reduce((s, h) => s + (h.putts as number), 0)
const puttCategories = [
{ label: "1-putt", test: (p: number) => p === 1, color: C.birdie },
{ label: "2-putt", test: (p: number) => p === 2, color: C.par },
{ label: "3-putt+", test: (p: number) => p >= 3, color: C.dobbel },
].map((c) => {
const count = puttsEligible.filter((h) => c.test(h.putts as number)).length
const percentage = puttsEligible.length > 0 ? (count / puttsEligible.length) * 100 : 0
return { label: c.label, count, percentage, color: c.color }
})
const puttsByPar = (parValue: number) => avg(puttsEligible.filter((h) => h.par === parValue).map((h) => h.putts as number))
const avgPuttsWithGir = avg(puttsEligible.filter(isGir).map((h) => h.putts as number))
const avgPuttsWithoutGir = avg(puttsEligible.filter((h) => h.putts !== null && !isGir(h)).map((h) => h.putts as number))
const onePuttPctByBucket = PUTT_BUCKETS.map((bucket) => {
const subset = puttsEligible.filter((h) => h.first_putt_distance_bucket === bucket)
return { label: bucket, percentage: subset.length > 0 ? (subset.filter((h) => h.putts === 1).length / subset.length) * 100 : 0 }
})
const lengthByGir = (want: boolean) =>
PUTT_BUCKETS.map((bucket) => ({
label: bucket,
value: girEligible.filter((h) => h.first_putt_distance_bucket === bucket && isGir(h) === want).length,
}))
const chipEligible = played.filter((h) => h.chip_count !== null)
const avgChipsPerHole = avg(chipEligible.map((h) => h.chip_count as number))
const chipCategories = [
{ label: "0 chip", test: (c: number) => c === 0, color: C.birdie },
{ label: "1 chip", test: (c: number) => c === 1, color: C.bogey },
{ label: "2+ chip", test: (c: number) => c >= 2, color: C.verre },
].map((c) => {
const count = chipEligible.filter((h) => c.test(h.chip_count as number)).length
const percentage = chipEligible.length > 0 ? (count / chipEligible.length) * 100 : 0
return { label: c.label, count, percentage, color: c.color }
})
const girMissed = girEligible.filter((h) => !isGir(h))
const scramblingPct = girMissed.length > 0 ? (girMissed.filter((h) => diff(h) <= 0).length / girMissed.length) * 100 : null
const sandEligible = girMissed.filter((h) => h.bunker_shot_count !== null && h.bunker_shot_count >= 1)
const sandSavePct = sandEligible.length > 0 ? (sandEligible.filter((h) => diff(h) <= 0).length / sandEligible.length) * 100 : null
const bunkerEligible = played.filter((h) => h.bunker_shot_count !== null)
const bunkerPerRound = bunkerEligible.reduce((s, h) => s + (h.bunker_shot_count as number), 0)
const penaltyEligible = played.filter((h) => h.penalty_strokes !== null)
const penaltyPerRound = penaltyEligible.reduce((s, h) => s + (h.penalty_strokes as number), 0)
const avgToParWithBunker = avg(bunkerEligible.filter((h) => (h.bunker_shot_count as number) >= 1).map(diff))
const avgToParWithoutBunker = avg(bunkerEligible.filter((h) => (h.bunker_shot_count as number) === 0).map(diff))
const pctHolesWithPenalty =
penaltyEligible.length > 0
? (penaltyEligible.filter((h) => (h.penalty_strokes as number) >= 1).length / penaltyEligible.length) * 100
: null
const anywayEligible = played.filter((h) => h.anyway_strokes !== null)
const anywayPerRound =
anywayEligible.length > 0 ? anywayEligible.reduce((s, h) => s + (h.anyway_strokes as number), 0) : null
const pctHolesWithAnyway =
anywayEligible.length > 0
? (anywayEligible.filter((h) => (h.anyway_strokes as number) >= 1).length / anywayEligible.length) * 100
: null
return {
playedCount: played.length,
totalStrokes: played.reduce((s, h) => s + (h.score as number), 0),
totalToPar: played.reduce((s, h) => s + diff(h), 0),
categories,
avgToPar,
byPar,
fairwayTracked,
fairwayHit,
fairwayLeft,
fairwayRight,
fwTotal,
avgToParFairwayHit,
avgToParFairwayMiss,
girEligible,
girHit,
girPct,
girByPar,
girWithFairway,
avgToParWithGir,
avgToParWithoutGir,
missedGreen,
missDir,
totalPutts,
puttCategories,
puttsByPar,
avgPuttsWithGir,
avgPuttsWithoutGir,
onePuttPctByBucket,
lengthByGir,
avgChipsPerHole,
chipCategories,
scramblingPct,
sandSavePct,
bunkerPerRound,
penaltyPerRound,
avgToParWithBunker,
avgToParWithoutBunker,
pctHolesWithPenalty,
anywayPerRound,
pctHolesWithAnyway,
}
}
function participantLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : (p.guest_name ?? "Gjest")
}
// --- Screen ----------------------------------------------------------------
export function RoundStats({ 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>
)
}
const s = computeStats(holes)
const maxCatPct = Math.max(...s.categories.map((c) => c.percentage), 1)
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" />
Rundestatistikk
</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.name?.trim() || round.course_name_snapshot}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{s.totalStrokes || ""}</span>
{s.playedCount > 0 && (
<span className="text-sm font-bold text-brand-orange">{signed(s.totalToPar)} til par</span>
)}
</div>
</div>
{/* Selve hull-for-hull-scorekortet flyttet til en egen, dedikert
horisontal side 2026-07-25 (erstatter en tidligere vertikal
tabell her på statistikk-siden -- se ARCHITECTURE_DECISIONS.md
ADR-033) -- denne siden forblir aggregert/utledet statistikk. */}
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
{/* 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>
)}
{s.playedCount === 0 ? (
<p className="rounded-3xl border border-border bg-card p-6 text-center text-base font-semibold text-muted-foreground">
Ingen hull registrert ennå -- statistikk vises etter hvert som hull spilles.
</p>
) : (
<>
{/* 1. Scorer */}
<StatCard title="Scorer" icon={BarChart3}>
<div className="flex flex-col gap-3">
{s.categories.map((c) => (
<CategoryBar key={c.label} label={c.label} count={c.count} percentage={c.percentage} color={c.color} max={maxCatPct} />
))}
</div>
{s.avgToPar !== null && (
<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(s.avgToPar)}</span>
<span className="text-sm font-semibold text-muted-foreground">per hull</span>
</div>
)}
{(s.byPar(3) !== null || s.byPar(4) !== null || s.byPar(5) !== null) && (
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">Snitt til par per hulltype</span>
{s.byPar(3) !== null && <DeviationBar label="Par 3" value={s.byPar(3)!} />}
{s.byPar(4) !== null && <DeviationBar label="Par 4" value={s.byPar(4)!} />}
{s.byPar(5) !== null && <DeviationBar label="Par 5" value={s.byPar(5)!} />}
</div>
)}
</StatCard>
{/* 2. Fairway */}
{s.fairwayTracked.length > 0 && (
<StatCard title="Fairway" icon={MoveHorizontal}>
<div className="flex flex-col gap-2">
<div className="flex h-10 overflow-hidden rounded-2xl">
{s.fairwayLeft.length > 0 && (
<div
className="flex items-center justify-center text-sm font-bold text-white"
style={{ width: `${(s.fairwayLeft.length / s.fwTotal) * 100}%`, backgroundColor: C.miss }}
>
{pct((s.fairwayLeft.length / s.fwTotal) * 100)}
</div>
)}
{s.fairwayHit.length > 0 && (
<div
className="flex items-center justify-center text-sm font-bold text-white"
style={{ width: `${(s.fairwayHit.length / s.fwTotal) * 100}%`, backgroundColor: C.hit }}
>
{pct((s.fairwayHit.length / s.fwTotal) * 100)}
</div>
)}
{s.fairwayRight.length > 0 && (
<div
className="flex items-center justify-center text-sm font-bold text-white"
style={{ width: `${(s.fairwayRight.length / s.fwTotal) * 100}%`, backgroundColor: C.bogey }}
>
{pct((s.fairwayRight.length / s.fwTotal) * 100)}
</div>
)}
</div>
<div className="flex justify-between text-sm font-semibold text-foreground">
<span>Bom venstre</span>
<span>Truffet</span>
<span>Bom høyre</span>
</div>
</div>
{(s.avgToParFairwayHit !== null || s.avgToParFairwayMiss !== null) && (
<StatTilePair
a={{ label: "Snitt til par når fairway truffet", value: s.avgToParFairwayHit !== null ? signed(s.avgToParFairwayHit) : "" }}
b={{ label: "Snitt til par når fairway bommet", value: s.avgToParFairwayMiss !== null ? signed(s.avgToParFairwayMiss) : "" }}
/>
)}
</StatCard>
)}
{/* 3. Greentreff (GIR) */}
{s.girPct !== null && (
<StatCard title="Greentreff (GIR)" icon={Target}>
<div className="flex flex-col items-center gap-5 sm:flex-row">
<Donut
segments={[
{ label: "Truffet green", value: s.girHit.length, color: C.hit },
{ label: "Bommet green", value: s.girEligible.length - s.girHit.length, color: C.miss },
]}
centerTop={pct(s.girPct)}
centerBottom="GIR"
/>
<div className="w-full flex-1">
<Legend
items={[
{ label: "Truffet green", color: C.hit, value: `${s.girHit.length} · ${pct(s.girPct)}` },
{
label: "Bommet green",
color: C.miss,
value: `${s.girEligible.length - s.girHit.length} · ${pct(100 - s.girPct)}`,
},
]}
/>
</div>
</div>
{(s.girByPar(3) !== null || s.girByPar(4) !== null || s.girByPar(5) !== null) && (
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">GIR per hulltype</span>
{s.girByPar(3) !== null && <ProgressRow label="Par 3" percentage={s.girByPar(3)!} color={C.hit} />}
{s.girByPar(4) !== null && <ProgressRow label="Par 4" percentage={s.girByPar(4)!} color={C.hit} />}
{s.girByPar(5) !== null && <ProgressRow label="Par 5" percentage={s.girByPar(5)!} color={C.hit} />}
</div>
)}
{(s.girWithFairway("hit") !== null || s.girWithFairway("miss") !== null) && (
<StatTilePair
a={{ label: "GIR når fairway truffet", value: s.girWithFairway("hit") !== null ? pct(s.girWithFairway("hit")!) : "" }}
b={{ label: "GIR når fairway bommet", value: s.girWithFairway("miss") !== null ? pct(s.girWithFairway("miss")!) : "" }}
/>
)}
{(s.avgToParWithGir !== null || s.avgToParWithoutGir !== null) && (
<StatTilePair
a={{ label: "Snitt til par med GIR", value: s.avgToParWithGir !== null ? signed(s.avgToParWithGir) : "" }}
b={{ label: "Snitt til par uten GIR", value: s.avgToParWithoutGir !== null ? signed(s.avgToParWithoutGir) : "" }}
/>
)}
</StatCard>
)}
{/* 4. Bom-retning på green */}
{s.missedGreen.length > 0 && (
<StatCard title="Bom-retning på green" icon={Wind}>
<p className="text-sm font-semibold text-muted-foreground text-pretty">
Retning når greenen ble bommet ({s.missedGreen.length} hull).
</p>
<div className="mx-auto grid w-full max-w-xs grid-cols-3 grid-rows-3 gap-2">
<div className="col-start-2 row-start-1">
<CompassCell label="Langt" percentage={s.missDir("long")} />
</div>
<div className="col-start-1 row-start-2">
<CompassCell label="Venstre" percentage={s.missDir("left")} />
</div>
<div className="col-start-2 row-start-2 flex items-center justify-center">
<div className="relative flex size-20 items-center justify-center rounded-full bg-primary/15">
<div className="flex size-14 items-center justify-center rounded-full bg-primary/30">
<Flag aria-hidden="true" className="size-7 text-primary" fill="currentColor" />
</div>
</div>
</div>
<div className="col-start-3 row-start-2">
<CompassCell label="Høyre" percentage={s.missDir("right")} />
</div>
<div className="col-start-2 row-start-3">
<CompassCell label="Kort" percentage={s.missDir("short")} />
</div>
</div>
</StatCard>
)}
{/* 5. Putter */}
{s.puttCategories.some((c) => c.count > 0) && (
<StatCard title="Putter" icon={Circle}>
<div className="flex flex-col items-center gap-5 sm:flex-row">
<Donut
segments={s.puttCategories.map((c) => ({ label: c.label, value: c.count, color: c.color }))}
centerTop={String(s.totalPutts)}
centerBottom="putter/runde"
/>
<div className="w-full flex-1">
<Legend
items={s.puttCategories.map((c) => ({
label: c.label,
color: c.color,
value: `${c.count} · ${pct(c.percentage)}`,
}))}
/>
</div>
</div>
{(s.puttsByPar(3) !== null || s.puttsByPar(4) !== null || s.puttsByPar(5) !== null) && (
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">Snitt putter per hull</span>
{[3, 4, 5].map(
(parValue) =>
s.puttsByPar(parValue) !== null && (
<ProgressRow
key={parValue}
label={`Par ${parValue}`}
percentage={Math.min((s.puttsByPar(parValue)! / 3) * 100, 100)}
color={C.par}
suffix={s.puttsByPar(parValue)!.toFixed(2).replace(".", ",")}
/>
),
)}
</div>
)}
{(s.avgPuttsWithGir !== null || s.avgPuttsWithoutGir !== null) && (
<StatTilePair
a={{ label: "Snitt putter med GIR", value: s.avgPuttsWithGir !== null ? s.avgPuttsWithGir.toFixed(2).replace(".", ",") : "" }}
b={{ label: "Snitt putter uten GIR", value: s.avgPuttsWithoutGir !== null ? s.avgPuttsWithoutGir.toFixed(2).replace(".", ",") : "" }}
/>
)}
</StatCard>
)}
{/* 6. Putt-lengde */}
{s.onePuttPctByBucket.some((b) => b.percentage > 0) && (
<StatCard title="Putt-lengde" icon={BarChart3}>
<div className="flex flex-col gap-3">
<span className="text-base font-bold text-foreground">Andel én-putt etter lengde første putt</span>
{s.onePuttPctByBucket.map((b, i) => (
<ProgressRow
key={b.label}
label={b.label}
percentage={b.percentage}
color={[C.birdie, C.birdie, C.par, C.bogey, C.dobbel, C.verre][i]}
/>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<MiniDistribution title="Lengde når green truffet" color={C.hit} data={s.lengthByGir(true)} />
<MiniDistribution title="Lengde når green bommet" color={C.miss} data={s.lengthByGir(false)} />
</div>
</StatCard>
)}
{/* 7. Chip, bunker og straffeslag */}
<StatCard title="Chip, bunker og straffeslag" icon={Waves}>
{(s.scramblingPct !== null || s.sandSavePct !== null) && (
<div className="grid grid-cols-2 gap-3">
<InfoStat
label="Scrambling"
value={s.scramblingPct !== null ? pct(s.scramblingPct) : ""}
explanation="Andel hull der du reddet par eller bedre uten å treffe greenen i regulering (GIR)."
/>
<InfoStat
label="Sand save"
value={s.sandSavePct !== null ? pct(s.sandSavePct) : ""}
explanation="Andel bunkerhull der du kom deg opp og i hull for par eller bedre fra greenbunker."
/>
</div>
)}
{s.chipCategories.some((c) => c.count > 0) && (
<div className="flex flex-col items-center gap-5 sm:flex-row">
<Donut
segments={s.chipCategories.map((c) => ({ label: c.label, value: c.count, color: c.color }))}
centerTop={s.avgChipsPerHole !== null ? s.avgChipsPerHole.toFixed(2).replace(".", ",") : ""}
centerBottom="chip/hull"
/>
<div className="w-full flex-1">
<Legend
items={s.chipCategories.map((c) => ({
label: c.label,
color: c.color,
value: `${c.count} · ${pct(c.percentage)}`,
}))}
/>
</div>
</div>
)}
<StatTilePair
a={{ label: "Bunkerslag per runde", value: String(s.bunkerPerRound) }}
b={{ label: "Straffeslag per runde", value: String(s.penaltyPerRound) }}
/>
{(s.avgToParWithBunker !== null || s.avgToParWithoutBunker !== null) && (
<StatTilePair
a={{ label: "Snitt til par med bunkerslag", value: s.avgToParWithBunker !== null ? signed(s.avgToParWithBunker) : "" }}
b={{ label: "Snitt til par uten bunkerslag", value: s.avgToParWithoutBunker !== null ? signed(s.avgToParWithoutBunker) : "" }}
/>
)}
{s.pctHolesWithPenalty !== null && (
<div className="flex flex-col gap-1 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-3xl font-extrabold tabular-nums text-foreground">{pct(s.pctHolesWithPenalty)}</span>
<span className="text-sm font-semibold text-muted-foreground text-pretty">Andel hull med minst ett straffeslag</span>
</div>
)}
</StatCard>
{/* 8. Annet -- egen seksjon for rundens anywayslag, ETTER
chip/bunker/straffeslag (rettet 2026-07-25 -- feilaktig slått
sammen med den seksjonen i forrige runde). Notatfelt kommer
trolig hit senere. */}
{s.anywayPerRound !== null && (
<StatCard title="Annet" icon={MoreHorizontal}>
<StatTilePair
a={{ label: "Anywayslag per runde", value: String(s.anywayPerRound) }}
b={{
label: "Andel hull med anywayslag",
value: s.pctHolesWithAnyway !== null ? pct(s.pctHolesWithAnyway) : "",
}}
/>
</StatCard>
)}
</>
)}
</main>
</div>
)
}
// --- Compass cell ----------------------------------------------------------
function CompassCell({ label, percentage }: { label: string; percentage: number }) {
return (
<div className="flex min-h-16 flex-col items-center justify-center gap-0.5 rounded-2xl border border-border bg-muted/40 p-2 text-center">
<span className="text-lg font-extrabold tabular-nums text-foreground">{pct(percentage)}</span>
<span className="text-sm font-semibold text-muted-foreground">{label}</span>
</div>
)
}