Chapman: format-valg i new-round.tsx + tournament-program.tsx (session) Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx Bingo Bango Bongo: format-valg + per-hull picker + poengtabell Flaggturnering: format-valg + nedtelling/resultatvisning Shamble: format-valg + best_n + lagvisning (frittstående + org-lag) Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag) High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater) Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter) Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute) Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
1224 lines
50 KiB
TypeScript
1224 lines
50 KiB
TypeScript
"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-md shadow-black/8">
|
||
<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
|
||
// ADR-039 -- hvilken side deltakeren er på, null for slagspill/skins.
|
||
round_side_id: string | null
|
||
}
|
||
|
||
type ApiSide = {
|
||
id: string
|
||
label: string | null
|
||
}
|
||
|
||
type ApiRound = {
|
||
id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
tee_name_snapshot: string
|
||
played_at: string
|
||
start_hole: number
|
||
holes_planned: number
|
||
play_format: string
|
||
participants: ApiParticipant[]
|
||
sides: ApiSide[]
|
||
}
|
||
|
||
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
|
||
// (migrasjon 036, FEATURE_BACKLOG.md "Scramble: statistikk over utslag
|
||
// brukt per spiller"). Samme rad-form som round-detail.tsx sin ApiSideHole.
|
||
type ApiSideHole = {
|
||
hole_number: number
|
||
played: boolean
|
||
score: number | null
|
||
selected_participant_id: string | null
|
||
}
|
||
|
||
// Delt-ball-formater (foursome/greensome/scramble, ADR-039 Beslutning C)
|
||
// lagrer ÉN score PER SIDE per hull -- ingen individuell round_hole-rad
|
||
// eksisterer i det hele tatt for noen deltaker her, så all statistikken på
|
||
// denne siden (fairway/GIR/putt-fordeling osv., som forutsetter individuell
|
||
// slag-for-slag-registrering) er strukturelt umulig å vise for disse
|
||
// formatene. Vist som en forklarende melding i stedet for en stille tom/
|
||
// misvisende side (2026-07-28, funnet ved simulering av alle spillformater).
|
||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
||
const FORMAT_LABELS: Record<string, string> = {
|
||
foursome: "Foursome",
|
||
greensome: "Greensome",
|
||
scramble_2: "Scramble (2)",
|
||
scramble_4: "Scramble (4)",
|
||
chapman: "Chapman",
|
||
}
|
||
|
||
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 ----------------------------------------------------------------
|
||
|
||
// Scramble/greensome: "utslag brukt" per spiller (migrasjon 036,
|
||
// FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per spiller").
|
||
// Ren klientside-telling fra allerede hentet round_hole-data (samme
|
||
// "regn ut ved lesing, ikke lagre et eget tall"-prinsipp som resten av
|
||
// denne siden) -- vises kun de hullene som faktisk har et registrert valg,
|
||
// ingen antakelse om at ALLE hull må ha ett (helt valgfritt å registrere).
|
||
function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHoles: Record<string, ApiSideHole[]> }) {
|
||
const rows = round.sides.map((side) => {
|
||
const holes = sideHoles[side.id] ?? []
|
||
const holesWithSelection = holes.filter((h) => h.selected_participant_id !== null)
|
||
const counts = new Map<string, number>()
|
||
for (const h of holesWithSelection) {
|
||
const pid = h.selected_participant_id as string
|
||
counts.set(pid, (counts.get(pid) ?? 0) + 1)
|
||
}
|
||
const sideParticipants = round.participants.filter((p) => p.round_side_id === side.id)
|
||
const playedCount = holes.filter((h) => h.played && h.score !== null).length
|
||
return {
|
||
side,
|
||
playedCount,
|
||
registeredCount: holesWithSelection.length,
|
||
entries: sideParticipants
|
||
.map((p) => ({ name: p.display_name, count: counts.get(p.id) ?? 0 }))
|
||
.sort((a, b) => b.count - a.count),
|
||
}
|
||
})
|
||
|
||
if (rows.every((r) => r.registeredCount === 0)) {
|
||
return (
|
||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||
Ingen har registrert hvem sitt utslag som ble brukt på noe hull ennå -- valgfritt felt i
|
||
scoreregistreringen for {FORMAT_LABELS[round.play_format] ?? round.play_format}.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<h2 className="text-base font-bold text-foreground">Utslag brukt</h2>
|
||
{rows.map(({ side, playedCount, registeredCount, entries }, idx) => (
|
||
<div key={side.id} className="flex flex-col gap-2">
|
||
{rows.length > 1 && (
|
||
<span className="text-sm font-semibold text-muted-foreground">
|
||
{side.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`}
|
||
</span>
|
||
)}
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{entries.map((e) => (
|
||
<li key={e.name} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{e.name}</span>
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">{e.count} utslag</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<span className="text-xs text-muted-foreground">
|
||
Registrert på {registeredCount} av {playedCount} spilte hull.
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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])
|
||
|
||
// Scramble/greensome: hent hver sides hull for "Utslag brukt"-sammendraget
|
||
// under (migrasjon 036) -- samme endepunkt round-detail.tsx allerede bruker
|
||
// for selve registreringen.
|
||
const [sideHoles, setSideHoles] = useState<Record<string, ApiSideHole[]>>({})
|
||
useEffect(() => {
|
||
if (!round || !SHARED_BALL_FORMATS.includes(round.play_format)) return
|
||
let cancelled = false
|
||
async function loadSideHoles() {
|
||
const entries = await Promise.all(
|
||
round!.sides.map(async (s) => {
|
||
const res = await fetch(`/rounds/${roundId}/sides/${s.id}/holes`, { credentials: "include" })
|
||
const data: ApiSideHole[] = res.ok ? await res.json() : []
|
||
return [s.id, data] as const
|
||
}),
|
||
)
|
||
if (!cancelled) setSideHoles(Object.fromEntries(entries))
|
||
}
|
||
void loadSideHoles()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [round, roundId, refreshKey])
|
||
|
||
useEffect(() => {
|
||
// Delt-ball-formater har ingen individuell round_hole-rad å hente --
|
||
// se guarden under, som viser en forklarende melding i stedet.
|
||
if (!participantId || (round && SHARED_BALL_FORMATS.includes(round.play_format))) 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, round, 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) {
|
||
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>
|
||
)
|
||
}
|
||
|
||
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
|
||
return (
|
||
<div className="min-h-dvh bg-background">
|
||
<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" />
|
||
Statistikk
|
||
</Link>
|
||
</div>
|
||
</header>
|
||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
|
||
|
||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||
Detaljert statistikk (fairwaytreff, GIR, putt-fordeling m.m.) er ikke tilgjengelig for{" "}
|
||
{FORMAT_LABELS[round.play_format] ?? round.play_format} -- disse formatene registrerer kun ETT felles
|
||
slagtall per side, ikke individuelle slag-for-slag-detaljer.{" "}
|
||
<Link
|
||
href={`/my-rounds/${roundId}/scorecard`}
|
||
className="font-semibold text-primary underline underline-offset-2"
|
||
>
|
||
Se scorekortet
|
||
</Link>{" "}
|
||
for hull-for-hull-forløpet i stedet.
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!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-md shadow-black/8">
|
||
<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, 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 på 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>
|
||
)
|
||
}
|