teecup/frontend/components/round-stats.tsx

1060 lines
43 KiB
TypeScript
Raw Normal View History

"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
user_id: string | null
guest_name: string | null
display_name: string
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,
}
}
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
// begrunnelse i round-detail.tsx sin playerLabel().
function participantLabel(p: ApiParticipant, viewerId: string | null): string {
return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name
}
// --- 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)
const [viewerId, setViewerId] = useState<string | null>(null)
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
// signal over WebSocket -- bumper denne, som er med i begge fetch-
// effektene under, slik at de henter på nytt akkurat som ved
// førstegangslasting (samme mønster som public-live.tsx).
const [refreshKey, setRefreshKey] = useState(0)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
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(
(prev) =>
prev ??
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
data.participants.find((p) => p.is_owner)?.id ??
data.participants[0]?.id ??
null,
)
}
void load()
return () => {
cancelled = true
}
}, [roundId, viewerId, refreshKey])
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, refreshKey])
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 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, viewerId)}
</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>
)
}