Compare commits

..

No commits in common. "ad1cbf71134cc461a6366ceb1a60007d7f1445ae" and "a7896122cb1800d042358f245046375a4d6d075f" have entirely different histories.

3 changed files with 60 additions and 422 deletions

View file

@ -10590,42 +10590,3 @@ Neste steg:
`docker compose build teecup_api teecup_frontend && up -d`. Ren
omstart, ingen feil i containerloggene, ingen konsollfeil på
`https://teecup.golf/logg-inn` etter omstart.
75. **Statistikk: lineær regresjon i stedet for glidende snitt, trend på
alle variabler — 2026-08-12.** Oppfølging til punkt 74, samme dag.
Bruker: "Trendlinjen din viser jo egentlig bare det samme som
stolpediagrammet" — korrekt, et 3-runders glidende snitt over så få
punkter følger bare stolpene med én runde forsinkelse, smoother
ingenting reelt.
Presenterte tre alternativer via AskUserQuestion (lineær regresjon /
eksponentielt glidende snitt / kumulativt løpende snitt) FØR noe ble
bygget, jf. brukerens eksplisitte "Gi meg forslag før vi
implementerer". Bruker valgte lineær regresjon (minste kvadraters
metode) — en rett linje, stigningstall = "endring per runde", vist i
bildeteksten (f.eks. "+7 pp per runde"). Visuelt en helt annen form
enn stolpene, løser selve klagen.
Bruker ba samtidig om trend på ALLE variabler. Lagt til stolpe+trend
for Fairwaytreff, Greentreff (GIR), Putt/18, Én-putt, Chip/runde,
Bunkerslag/runde, Straffeslag/runde og Anywayslag/runde, hver i sitt
eksisterende kort. Scrambling/sand save unntatt (brukervalg via
samme AskUserQuestion): for få kvalifiserte hull per runde til et
per-runde-tall, vises i stedet som én kumulativ linje.
Backend: `round_series` flyttet fra `RoundStatsSummary` til
`RoundStatsWindowSummary`-nivå, utvidet med per-runde-tall for alle
variabler pluss kumulativ scrambling/sand save. Fanget og rettet en
enhets-bug under egen scratch-verifisering (før commit): scrambling/
sand save ble først bygget på 01-skala i stedet for samme 0100-
skala som resten av API-et.
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest, 31/31 backend-
tester. Full scratch-stack med åtte syntetiske runder (bevisst
støyende fremgang) browserverifisert lys+mørk: regresjonslinjen
bekreftet rett og visuelt distinkt fra stolpene på alle variabler,
riktig fortegn/farge på stigningstall, ingen dobbel enhet i
bildetekstene, kumulative linjer konvergerer som forventet. Ingen
konsollfeil. `teecup_db` urørt.
**Ikke rullet ut ennå.**

View file

@ -1660,30 +1660,18 @@ async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[Rou
class RoundStatsSeriesPoint(BaseModel):
played_at: date
score_to_par: float
# Per-runde-tall for stolpe+trendlinje-grafene (bruker-instruks
# 2026-08-12: "trender på alle variablene"). Samme filtrering/formler
# som de tilsvarende pooled-tallene i RoundStatsSummary, bare regnet
# PER runde i stedet for poolet over alle runder i vinduet -- None der
# runden ikke har noe kvalifisert hull for akkurat den metrikken.
putts_per_18: float | None
fairway_hit_pct: float | None
gir_pct: float | None
one_putt_pct: float | None
chip_per_round: float | None
bunker_per_round: float | None
penalty_per_round: float | None
anyway_per_round: float | None
# Scrambling/sand save er KUMULATIVE (poolet over alle runder til og
# med denne), ikke per-runde -- en enkelt runde har ofte 0-2
# kvalifiserte hull (bom green / i bunker), for lite til et eget
# per-runde-tall (brukervalg 2026-08-12, se AskUserQuestion-svar).
scrambling_cumulative_pct: float | None
sand_save_cumulative_pct: float | None
class RoundStatsSummary(BaseModel):
rounds_completed: int
avg_score_to_par: float | None
# Kronologisk (eldste først) snitt-til-par per fullført runde i
# perioden -- til stolpegraf-med-trendlinje på frontend (bruker-
# instruks 2026-08-12). Kun for `current`-vinduet (se
# get_rounds_stats_summary); `previous` trenger ikke egen serie siden
# grafen bare viser inneværende periodes utvikling, ikke en
# sammenligning mot forrige periode slik de andre tallene på siden gjør.
round_series: list[RoundStatsSeriesPoint]
avg_putts_per_18: float | None
putts_tracked_rounds: int
fairway_hit_pct: float | None
@ -1726,11 +1714,6 @@ class RoundStatsWindowSummary(BaseModel):
window: str
current: RoundStatsSummary
previous: RoundStatsSummary
# Kronologisk (eldste først) per-runde-serie for stolpe+trendlinje-
# grafene (brukerinstruks 2026-08-12). Kun for `current`-vinduet --
# grafene viser inneværende periodes utvikling, ikke en sammenligning
# mot forrige periode slik de andre tallene på siden gjør.
round_series: list[RoundStatsSeriesPoint]
StatsWindow = Literal["last_round", "last_5", "last_10", "month", "year", "last_year", "all"]
@ -1775,77 +1758,6 @@ def _resolve_stats_window(window: StatsWindow, ordered_rounds: list[tuple[str, d
return current, previous
def _build_round_series(
round_ids: set[str],
by_round: dict[str, list],
stat_level_by_round: dict[str, str],
played_at_by_round: dict[str, date],
) -> list[RoundStatsSeriesPoint]:
"""Kronologisk (eldste først) per-runde-serie for stolpe+trendlinje-
grafene. Samme filtre/formler som de pooled tallene i
`_summarize_rounds`, bare regnet PER runde -- unntatt scrambling/sand
save, som er bevisst KUMULATIVE (se `RoundStatsSeriesPoint`)."""
def avg(values: list[float]) -> float | None:
return sum(values) / len(values) if values else None
def is_gir(h) -> bool:
return h["score"] - h["putts"] <= h["par"] - 2
ordered = sorted(round_ids, key=lambda rid: played_at_by_round[rid])
points: list[RoundStatsSeriesPoint] = []
cum_scrambling: list[float] = []
cum_sand_save: list[float] = []
for rid in ordered:
holes = by_round.get(rid, [])
played = [h for h in holes if h["played"] and h["score"] is not None]
if not played:
continue
putts_per_18 = None
if stat_level_by_round.get(rid) != "strokes_only":
putts_per_18 = float(sum(h["putts"] if h["putts"] is not None else 2 for h in holes))
fairway_tracked = [h for h in played if h["par"] != 3 and h["tee_shot_result"] is not None]
fairway_hit_pct = avg([100.0 if h["tee_shot_result"] == "fairway" else 0.0 for h in fairway_tracked])
gir_eligible = [h for h in played if h["putts"] is not None]
gir_pct = avg([100.0 if is_gir(h) else 0.0 for h in gir_eligible])
one_putt_pct = avg([100.0 if h["putts"] <= 1 else 0.0 for h in gir_eligible])
def summed(field: str) -> float | None:
eligible = [h[field] for h in played if h[field] is not None]
return float(sum(eligible)) if eligible else None
# Utvider den kumulative poolen med DENNE rundens hull FØR prosenten
# regnes ut, slik at tallet ved runde N er "raten din til og med
# runde N" -- se RoundStatsSeriesPoint for hvorfor disse to alene
# er kumulative og ikke per-runde.
gir_missed_this_round = [h for h in gir_eligible if not is_gir(h)]
cum_scrambling.extend([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in gir_missed_this_round])
sand_this_round = [h for h in gir_missed_this_round if h["bunker_shot_count"] is not None and h["bunker_shot_count"] >= 1]
cum_sand_save.extend([100.0 if (h["score"] - h["par"]) <= 0 else 0.0 for h in sand_this_round])
points.append(
RoundStatsSeriesPoint(
played_at=played_at_by_round[rid],
score_to_par=float(sum(h["score"] - h["par"] for h in played)),
putts_per_18=putts_per_18,
fairway_hit_pct=fairway_hit_pct,
gir_pct=gir_pct,
one_putt_pct=one_putt_pct,
chip_per_round=summed("chip_count"),
bunker_per_round=summed("bunker_shot_count"),
penalty_per_round=summed("penalty_strokes"),
anyway_per_round=summed("anyway_strokes"),
scrambling_cumulative_pct=avg(cum_scrambling) if cum_scrambling else None,
sand_save_cumulative_pct=avg(cum_sand_save) if cum_sand_save else None,
)
)
return points
def _summarize_rounds(
round_ids: set[str],
by_round: dict[str, list],
@ -1861,13 +1773,18 @@ def _summarize_rounds(
to_par_values: list[float] = []
putt_totals: list[float] = []
round_series: list[RoundStatsSeriesPoint] = []
for round_id in round_ids:
holes = by_round.get(round_id, [])
played = [h for h in holes if h["played"] and h["score"] is not None]
if played:
to_par_values.append(float(sum(h["score"] - h["par"] for h in played)))
round_to_par = float(sum(h["score"] - h["par"] for h in played))
to_par_values.append(round_to_par)
round_series.append(RoundStatsSeriesPoint(played_at=played_at_by_round[round_id], score_to_par=round_to_par))
if stat_level_by_round.get(round_id) != "strokes_only":
putt_totals.append(sum(h["putts"] if h["putts"] is not None else 2 for h in holes))
# Eldste først -- stolpegrafen på frontend viser utvikling venstre->høyre.
round_series.sort(key=lambda pt: pt.played_at)
all_played = [h for rid in round_ids for h in by_round.get(rid, []) if h["played"] and h["score"] is not None]
@ -1939,6 +1856,7 @@ def _summarize_rounds(
return RoundStatsSummary(
rounds_completed=len(round_ids),
avg_score_to_par=avg(to_par_values),
round_series=round_series,
avg_putts_per_18=avg(putt_totals),
putts_tracked_rounds=len(putt_totals),
fairway_hit_pct=fairway_hit_pct,
@ -2031,7 +1949,6 @@ async def get_rounds_stats_summary(
window=window,
current=_summarize_rounds(current_ids, by_round, stat_level_by_round, played_at_by_round),
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round, played_at_by_round),
round_series=_build_round_series(current_ids, by_round, stat_level_by_round, played_at_by_round),
)

View file

@ -23,24 +23,12 @@ import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BarChart3, Flag, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react"
type ApiRoundSeriesPoint = {
played_at: string
score_to_par: number
putts_per_18: number | null
fairway_hit_pct: number | null
gir_pct: number | null
one_putt_pct: number | null
chip_per_round: number | null
bunker_per_round: number | null
penalty_per_round: number | null
anyway_per_round: number | null
scrambling_cumulative_pct: number | null
sand_save_cumulative_pct: number | null
}
type ApiRoundSeriesPoint = { played_at: string; score_to_par: number }
type ApiStatsSummary = {
rounds_completed: number
avg_score_to_par: number | null
round_series: ApiRoundSeriesPoint[]
avg_putts_per_18: number | null
putts_tracked_rounds: number
fairway_hit_pct: number | null
@ -78,7 +66,6 @@ type ApiWindowSummary = {
window: string
current: ApiStatsSummary
previous: ApiStatsSummary
round_series: ApiRoundSeriesPoint[]
}
type WindowKey = "last_round" | "last_5" | "last_10" | "month" | "year" | "last_year" | "all"
@ -102,17 +89,6 @@ function pct(n: number): string {
return `${Math.round(n)} %`
}
// Enhetsløs versjon av `pct` -- til bruk som `formatSlope` i BarTrendChart
// der `slopeSuffix` allerede bærer enheten ("pp per runde"); `pct` sin
// egen "%" ville da kollidert med det.
function roundedNoUnit(n: number): string {
return String(Math.round(n))
}
function oneDecimal(n: number): string {
return n.toFixed(1).replace(".", ",")
}
// Prosentpoeng-differanse mellom to andeler (ikke "N % endring", men "N pp").
function signedPoints(n: number): string {
const rounded = Math.round(n)
@ -406,49 +382,30 @@ function FairwayBar({
)
}
// --- Utvikling over tid: stolpegraf + trendlinje, per variabel ---------
// --- Utvikling over tid: stolpegraf + trendlinje -----------------------
// Brukerinstruks 2026-08-12: stolpe per runde (eldste til venstre) +
// trendlinje, for ALLE variabler -- ikke bare snitt til par. Tidligere
// bevisst UTSATT (se CHANGELOG 2026-07-28: "for lite rundehistorikk til
// å vise noe meningsfullt"), bygget da brukeren fikk nok runder.
// trendlinje over. Tidligere bevisst UTSATT (se CHANGELOG 2026-07-28: "for
// lite rundehistorikk til å vise noe meningsfullt") -- bygges nå som
// brukeren har nok fullførte runder til at en trend faktisk sier noe.
//
// Trendlinjen er LINEÆR REGRESJON (minste kvadraters metode), ikke et
// glidende snitt -- brukeren påpekte (med rette) at et glidende snitt
// over så få runder bare gjentar stolpene med én runde forsinkelse. En
// rett regresjonslinje er visuelt og matematisk en helt annen form (en
// retning, ikke en kopi av dataene), og stigningstallet kan leses rett
// ut som "endring per runde" (se AskUserQuestion-svar 2026-08-12).
//
// Fargevalg: KUN snitt til par har en meningsfull 0-linje (par) å være
// diverging rundt (primary = på/under par, brand-orange = over par,
// samme par som `Delta` allerede bruker). De andre variablene er ren
// magnitude (prosent eller antall, alltid >= 0) og får én gjennomgående
// farge -- ingen kunstig "godt/dårlig"-deling der en slik deling ikke
// finnes i selve tallet.
// Fargevalg følger samme diverging-par som `Delta` allerede bruker på
// resten av siden (primary = under/på par, brand-orange = over par) --
// ikke en ny palett, samme etablerte semantikk. Trendlinjen er et
// glidende snitt (ikke rå lineær regresjon), tegnet i en nøytral,
// dempet farge og stiplet, slik at den tydelig leses som UTLEDET, ikke
// som en tredje rådataserie.
const TREND_BAR_WIDTH = 20
const TREND_BAR_GAP = 10
const TREND_CHART_HEIGHT = 160
const TREND_HALF_HEIGHT = TREND_CHART_HEIGHT / 2 - 14 // plass til dato-etiketter under
const TREND_MAGNITUDE_HEIGHT = TREND_CHART_HEIGHT - 34 // baseline nederst, samme topp-marg
// Lineær regresjon (minste kvadraters metode) -- returnerer den rette
// linjens y-verdi ved hver indeks. Rendret som polyline blir dette
// bokstavelig talt en rett strek, ikke en linje som følger dataene.
function linearRegression(values: number[]): number[] {
const n = values.length
if (n === 0) return []
const xMean = (n - 1) / 2
const yMean = values.reduce((a, b) => a + b, 0) / n
let num = 0
let den = 0
values.forEach((y, x) => {
num += (x - xMean) * (y - yMean)
den += (x - xMean) ** 2
function movingAverage(values: number[], window: number): number[] {
return values.map((_, i) => {
const start = Math.max(0, i - window + 1)
const slice = values.slice(start, i + 1)
return slice.reduce((sum, v) => sum + v, 0) / slice.length
})
const slope = den === 0 ? 0 : num / den
const intercept = yMean - slope * xMean
return values.map((_, x) => intercept + slope * x)
}
function formatShortDate(iso: string): string {
@ -474,97 +431,63 @@ function barPath(x: number, valueY: number, baselineY: number, width: number, ra
return `M ${x} ${top} H ${x + width} V ${bottom - radius} A ${radius} ${radius} 0 0 1 ${x + width - radius} ${bottom} H ${x + radius} A ${radius} ${radius} 0 0 1 ${x} ${bottom - radius} Z`
}
type TrendPoint = { played_at: string; value: number }
function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
if (series.length < 2) return null
function BarTrendChart({
points,
mode,
formatValue,
formatSlope = formatValue,
goodDirection,
slopeSuffix,
}: {
points: TrendPoint[]
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
// (kun snitt til par). "magnitude": baseline nederst, alltid >= 0, én farge.
mode: "diverging" | "magnitude"
// Brukt på hver stolpes verdi-etikett/tooltip -- kan trygt ha egen enhet
// (f.eks. "%") siden den står alene der.
formatValue: (v: number) => string
// Brukt KUN på stigningstallet i bildeteksten -- egen formatterer der
// formatValue sin enhet ville kollidert med slopeSuffix (f.eks. "52 %"
// + "pp per runde" ville blitt "52 % pp per runde"). Faller tilbake til
// formatValue når det ikke kolliderer (f.eks. `signed`, som ikke har
// noen enhet innebygd).
formatSlope?: (v: number) => string
goodDirection: "up" | "down"
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
slopeSuffix: string
}) {
if (points.length < 2) return null
const values = points.map((p) => p.value)
const trend = linearRegression(values)
const slopePerRound = points.length > 1 ? trend[trend.length - 1] - trend[0] : 0
const avgSlopePerRound = slopePerRound / (points.length - 1)
const improving = goodDirection === "up" ? avgSlopePerRound > 0 : avgSlopePerRound < 0
const baselineY = TREND_CHART_HEIGHT - 20
let valueToY: (v: number) => number
if (mode === "diverging") {
const values = series.map((p) => p.score_to_par)
const trend = movingAverage(values, 3)
const maxAbs = Math.max(...values.map((v) => Math.abs(v)), 1)
const scale = TREND_HALF_HEIGHT / maxAbs
valueToY = (v) => baselineY - v * scale
} else {
const maxVal = Math.max(...values, 1)
const scale = TREND_MAGNITUDE_HEIGHT / maxVal
valueToY = (v) => baselineY - v * scale
}
const baselineY = TREND_CHART_HEIGHT - 20
const width = series.length * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_GAP
const width = points.length * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_GAP
const barCenterX = (i: number) => TREND_BAR_GAP + i * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_WIDTH / 2
const trendLinePoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
const valueToY = (v: number) => baselineY - v * scale
const trendPoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
return (
<div className="flex flex-col gap-2">
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={`${points.length} runder, fra ${formatShortDate(points[0].played_at)} til ${formatShortDate(points[points.length - 1].played_at)}. Trend: ${avgSlopePerRound >= 0 ? "+" : ""}${formatSlope(Math.abs(avgSlopePerRound))} ${slopeSuffix}.`}
aria-label={`Snitt til par for ${series.length} runder, fra ${formatShortDate(series[0].played_at)} til ${formatShortDate(series[series.length - 1].played_at)}.`}
viewBox={`0 0 ${width} ${TREND_CHART_HEIGHT}`}
width={width}
height={TREND_CHART_HEIGHT}
className="block"
>
{/* 0-linjen (par) -- hårfin, ubrutt, tilbaketrukket, som resten av rutenettet skal være. */}
<line x1={0} y1={baselineY} x2={width} y2={baselineY} stroke="var(--border)" strokeWidth={1} />
{points.map((point, i) => {
{series.map((point, i) => {
const cx = barCenterX(i)
const overPar = mode === "diverging" && point.value > 0
const isLast = i === points.length - 1
const overPar = point.score_to_par > 0
const isLast = i === series.length - 1
return (
<g key={point.played_at + i}>
<path
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.value), baselineY, TREND_BAR_WIDTH, 4)}
fill={mode === "diverging" ? (overPar ? "var(--brand-orange)" : "var(--primary)") : "var(--primary)"}
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.score_to_par), baselineY, TREND_BAR_WIDTH, 4)}
fill={overPar ? "var(--brand-orange)" : "var(--primary)"}
>
<title>
{formatShortDate(point.played_at)}: {formatValue(point.value)}
{formatShortDate(point.played_at)}: {signed(point.score_to_par)}
</title>
</path>
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
"merk endepunktet, ikke hvert punkt". */}
"merk endepunktet, ikke hvert punkt". Resten leses via 0-linjen,
stolpenes relative høyde, og trendlinjens retning. */}
{isLast && (
<text
x={cx}
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
y={valueToY(point.score_to_par) + (overPar ? -6 : 14)}
textAnchor="middle"
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{formatValue(point.value)}
{signed(point.score_to_par)}
</text>
)}
{(i === 0 || i === points.length - 1 || i === Math.floor(points.length / 2)) && (
{(i === 0 || i === series.length - 1 || i === Math.floor(series.length / 2)) && (
<text x={cx} y={TREND_CHART_HEIGHT - 4} textAnchor="middle" className="fill-muted-foreground text-[10px] font-semibold">
{formatShortDate(point.played_at)}
</text>
@ -573,95 +496,19 @@ function BarTrendChart({
)
})}
<polyline points={trendLinePoints} fill="none" stroke="var(--muted-foreground)" strokeWidth={2} strokeDasharray="5 4" strokeLinecap="round" />
<polyline points={trendPoints} fill="none" stroke="var(--muted-foreground)" strokeWidth={2} strokeDasharray="5 4" strokeLinecap="round" />
</svg>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-semibold">
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span aria-hidden="true" className="h-0.5 w-4 rounded-full" style={{ backgroundColor: "var(--muted-foreground)", opacity: 0.7 }} />
Trend (lineær regresjon)
</span>
<span className={improving ? "text-primary" : "text-brand-orange"}>
{avgSlopePerRound >= 0 ? "+" : ""}
{formatSlope(Math.abs(avgSlopePerRound))} {slopeSuffix}
Trendlinje (glidende snitt, siste 3 runder)
</span>
</div>
</div>
)
}
function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
const points = series.map((p) => ({ played_at: p.played_at, value: p.score_to_par }))
if (points.length < 2) return null
return (
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" />
</Card>
)
}
// Generisk per-variabel-trend -- filtrerer bort runder uten data for
// akkurat DENNE metrikken (f.eks. en runde uten puttsporing), i stedet
// for å late som verdien var 0.
function seriesFor(series: ApiRoundSeriesPoint[], field: keyof ApiRoundSeriesPoint): TrendPoint[] {
return series
.filter((p) => p[field] !== null)
.map((p) => ({ played_at: p.played_at, value: p[field] as number }))
}
// Kumulativ (ikke per-runde) linje for Scrambling/Sand save -- se
// AskUserQuestion-svar 2026-08-12: for få kvalifiserte hull per runde
// til at et per-runde-tall er meningsfullt, så her vises kun raten
// "til og med denne runden" som en enkel linje, ingen stolper.
function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: string }) {
if (points.length < 2) return null
const values = points.map((p) => p.value)
const maxVal = Math.max(...values, 1)
const baselineY = TREND_CHART_HEIGHT - 20
const scale = TREND_MAGNITUDE_HEIGHT / maxVal
const valueToY = (v: number) => baselineY - v * scale
const width = Math.max(points.length * 26, 160)
const stepX = (i: number) => (points.length > 1 ? (i / (points.length - 1)) * (width - 24) + 12 : width / 2)
const linePoints = points.map((p, i) => `${stepX(i)},${valueToY(p.value)}`).join(" ")
return (
<div className="flex flex-col gap-2">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={`${label}, kumulativt: ${Math.round(points[0].value)} % ved starten, ${Math.round(points[points.length - 1].value)} % nå.`}
viewBox={`0 0 ${width} ${TREND_CHART_HEIGHT}`}
width={width}
height={TREND_CHART_HEIGHT}
className="block"
>
<line x1={0} y1={baselineY} x2={width} y2={baselineY} stroke="var(--border)" strokeWidth={1} />
<polyline points={linePoints} fill="none" stroke="var(--primary)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
{points.map((p, i) => (
<circle key={p.played_at + i} cx={stepX(i)} cy={valueToY(p.value)} r={i === points.length - 1 ? 4 : 3} fill="var(--primary)">
<title>
{formatShortDate(p.played_at)}: {Math.round(p.value)} %
</title>
</circle>
))}
<text x={stepX(0)} y={valueToY(points[0].value) - 10} textAnchor="start" className="fill-muted-foreground text-[10px] font-semibold">
{Math.round(points[0].value)} %
</text>
<text
x={stepX(points.length - 1)}
y={valueToY(points[points.length - 1].value) - 10}
textAnchor="end"
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{Math.round(points[points.length - 1].value)} %
</text>
</svg>
</div>
<p className="text-xs font-semibold text-muted-foreground">Kumulativt -- raten din til og med hver runde, ikke per-runde-tall.</p>
</div>
)
}
// --- Til par: med vs. uten en gitt hendelse på hullet -----------------------
// Brukerens eksplisitte instruks 2026-07-28: se snitt til par NÅR noe
// inntraff mot NÅR det ikke gjorde det (greentreff, fairwaytreff, bunker,
@ -798,17 +645,6 @@ export function RoundsStatsSummary() {
(() => {
const s = data.current
const p = data.previous
const series = data.round_series
const fairwayTrend = seriesFor(series, "fairway_hit_pct")
const girTrend = seriesFor(series, "gir_pct")
const puttsTrend = seriesFor(series, "putts_per_18")
const onePuttTrend = seriesFor(series, "one_putt_pct")
const chipTrend = seriesFor(series, "chip_per_round")
const bunkerTrend = seriesFor(series, "bunker_per_round")
const penaltyTrend = seriesFor(series, "penalty_per_round")
const anywayTrend = seriesFor(series, "anyway_per_round")
const scramblingCumulative = seriesFor(series, "scrambling_cumulative_pct")
const sandSaveCumulative = seriesFor(series, "sand_save_cumulative_pct")
return (
<>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
@ -841,7 +677,7 @@ export function RoundsStatsSummary() {
/>
</Section>
<ScoreTrendChart series={series} />
<ScoreTrendChart series={s.round_series} />
{/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter
Greentreff (en beregnet andel), Innspill (en retnings-
@ -855,19 +691,6 @@ export function RoundsStatsSummary() {
<span className="text-sm font-semibold text-muted-foreground">Fairwaytreff</span>
<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />
</div>
{fairwayTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Fairwaytreff over tid</p>
<BarTrendChart
points={fairwayTrend}
mode="magnitude"
formatValue={pct}
formatSlope={roundedNoUnit}
goodDirection="up"
slopeSuffix="pp per runde"
/>
</div>
)}
</Card>
<Card title="Greentreff (GIR)" subtitle="Andel innspill som traff green i regulert antall slag.">
@ -876,12 +699,6 @@ export function RoundsStatsSummary() {
<span className="text-sm font-semibold text-muted-foreground">Greentreff</span>
<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />
</div>
{girTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Greentreff over tid</p>
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
</div>
)}
</Card>
<Card title="Innspill" subtitle="Retningen du bommer i, når du bommer greenen.">
@ -913,18 +730,6 @@ export function RoundsStatsSummary() {
<span className="text-sm font-semibold text-muted-foreground">Én-putt</span>
<Delta current={s.one_putt_pct} previous={p.one_putt_pct} format={signedPoints} goodDirection="up" />
</div>
{puttsTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Putt / 18 hull over tid</p>
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" />
</div>
)}
{onePuttTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Én-putt over tid</p>
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
</div>
)}
</Card>
<Section title="Chip, bunker og straffeslag" icon={Waves}>
@ -945,29 +750,6 @@ export function RoundsStatsSummary() {
/>
</Section>
{(chipTrend.length >= 2 || bunkerTrend.length >= 2 || penaltyTrend.length >= 2) && (
<Card title="Chip, bunker og straffeslag over tid">
{chipTrend.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Chip / runde</p>
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
{bunkerTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Bunkerslag / runde</p>
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
{penaltyTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Straffeslag / runde</p>
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
</Card>
)}
{s.avg_anyway_per_round !== null && (
<Section title="Annet" icon={Wind}>
<StatTile
@ -978,12 +760,6 @@ export function RoundsStatsSummary() {
</Section>
)}
{anywayTrend.length >= 2 && (
<Card title="Anywayslag over tid">
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</Card>
)}
<Card title="Redning" subtitle="Par eller bedre etter en bom.">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col items-center gap-2">
@ -1011,22 +787,6 @@ export function RoundsStatsSummary() {
<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />
</div>
</div>
{(scramblingCumulative.length >= 2 || sandSaveCumulative.length >= 2) && (
<div className="grid grid-cols-1 gap-4 border-t border-border pt-3 sm:grid-cols-2">
{scramblingCumulative.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" />
</div>
)}
{sandSaveCumulative.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" />
</div>
)}
</div>
)}
</Card>
<Card title="Til par: med vs. uten" subtitle="Snitt til par på enkelthull, splittet på om noe faktisk inntraff.">