Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
"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,
|
2026-07-25 05:32:23 +02:00
|
|
|
|
MoreHorizontal,
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
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,
|
2026-07-25 05:32:23 +02:00
|
|
|
|
// 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,
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
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
|
2026-07-25 06:29:09 +02:00
|
|
|
|
name: string | null
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
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
|
2026-07-25 05:32:23 +02:00
|
|
|
|
const pctHolesWithAnyway =
|
|
|
|
|
|
anywayEligible.length > 0
|
|
|
|
|
|
? (anywayEligible.filter((h) => (h.anyway_strokes as number) >= 1).length / anywayEligible.length) * 100
|
|
|
|
|
|
: null
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-07-25 05:32:23 +02:00
|
|
|
|
pctHolesWithAnyway,
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-07-25 06:29:09 +02:00
|
|
|
|
{round.name?.trim() || round.course_name_snapshot}
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-sm font-semibold text-muted-foreground">
|
2026-07-25 06:29:09 +02:00
|
|
|
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
{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 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-25 05:32:23 +02:00
|
|
|
|
{/* 7. Chip, bunker og straffeslag */}
|
|
|
|
|
|
<StatCard title="Chip, bunker og straffeslag" icon={Waves}>
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
{(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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-25 05:32:23 +02:00
|
|
|
|
<StatTilePair
|
|
|
|
|
|
a={{ label: "Bunkerslag per runde", value: String(s.bunkerPerRound) }}
|
|
|
|
|
|
b={{ label: "Straffeslag per runde", value: String(s.penaltyPerRound) }}
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
/>
|
|
|
|
|
|
{(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>
|
2026-07-25 05:32:23 +02:00
|
|
|
|
|
|
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
Nytt horisontalt scorekort (/my-rounds/[id]/scorecard) — ekte HTML-tabell med faste kolonnebredder, ingen scroll i det hele tatt. Score-cellene bruker form (sirkel = under par, firkant = over par) + fylt/ufylt i stedet for farge alene, med egen symbolforklaring. Ut/Inn-blokker for 18-hulls runder, én blokk for 9-hulls. Egen dedikert side (ikke sammenslått med statistikk-siden), lenket fra både "Runde fullført"-banneret og fra statistikk-siden. Byttet ut V0s egen forenklede HCP-formel med backends allerede beregnede strokes_received, så det ikke finnes to ulike slagfordelings-implementasjoner i appen. Den gamle vertikale scorekort-tabellen i statistikk-siden er fjernet — nå bor det rå scorekortet kun ett sted.
Anywayslag-fiksen din: lagt til som en tredje flis ved siden av Bunkerslag/Straffeslag, og seksjonen omdøpt fra "Chip, bunker og straffeslag" til "Annet" — notert i .md-filene at et notatfelt trolig kommer dit senere.
Ekte typesjekket build, ingen backend-endring nødvendig, teeoff.no upåvirket.
2026-07-24 23:33:41 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|