Compare commits

...

2 commits

Author SHA1 Message Date
ad1cbf7113 Dokumenter lineær regresjon + trend-på-alle-variabler i CHANGELOG.md
All checks were successful
Backend-tester / test (push) Successful in 20s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 11:18:01 +02:00
a65858af84 Statistikk: lineær regresjon i stedet for glidende snitt, trend på alle variabler
Brukeren påpekte (med rette) at det glidende 3-runde-snittet i "Utvikling"
bare gjentok stolpediagrammet med én runde forsinkelse -- for få punkter
til at det smoothet noe reelt. Foreslo tre alternativer via
AskUserQuestion (lineær regresjon / eksponentielt glidende snitt /
kumulativt løpende snitt); brukeren valgte lineær regresjon.

Lineær regresjon (minste kvadraters metode) gir en rett linje --
stigningstallet leses direkte som "endring per runde" og vises nå i
bildeteksten (f.eks. "+7 pp per runde"). Visuelt en helt annen form enn
stolpene (rett strek vs. hakkete stolper), som løser selve klagen.

Bruker ba samtidig om trend på ALLE variabler, ikke bare snitt til par.
Lagt til stolpe+trend for: Fairwaytreff, Greentreff (GIR), Putt/18,
Én-putt, Chip/runde, Bunkerslag/runde, Straffeslag/runde, Anywayslag/
runde -- hver plassert i det eksisterende kortet for akkurat den
metrikken. Scrambling/sand save er unntaket (brukervalg via
AskUserQuestion): for få kvalifiserte hull per runde (0-2) til at et
per-runde-tall er meningsfullt, vises i stedet som én kumulativ linje
("raten din til og med hver runde"), ingen stolper.

Backend: RoundStatsSummary sitt gamle round_series-felt (kun snitt til
par) erstattet av et nytt round_series på RoundStatsWindowSummary-nivå,
med per-runde-tall for alle variabler pluss kumulativ scrambling/sand
save. Fanget og rettet en enhets-bug selv under scratch-verifisering:
scrambling/sand save ble først bygget som 0-1-brøker (kopiert fra en
boolsk 1.0/0.0-sjekk) i stedet for samme 0-100-skala som resten av
API-et -- rettet før commit.

Verifisert: tsc --noEmit rent, 45/45 vitest, 31/31 backend-tester. Full
scratch-stack med åtte syntetiske runder (bevisst støyende fremgang, for
å faktisk se at regresjonslinjen skiller seg fra stolpene) browser-
verifisert lys+mørk: regresjonslinje bekreftet rett og visuelt distinkt
fra stolpene på alle variabler, riktig fortegn/farge på stigningstall,
ingen dobbel enhet i bildetekstene, kumulative linjer for scrambling/
sand save konvergerer som forventet. Ingen konsollfeil. teecup_db urørt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 11:17:00 +02:00
3 changed files with 422 additions and 60 deletions

View file

@ -10590,3 +10590,42 @@ 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,18 +1660,30 @@ 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
@ -1714,6 +1726,11 @@ 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"]
@ -1758,6 +1775,77 @@ 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],
@ -1773,18 +1861,13 @@ 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:
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))
to_par_values.append(float(sum(h["score"] - h["par"] for h in played)))
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]
@ -1856,7 +1939,6 @@ 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,
@ -1949,6 +2031,7 @@ 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,12 +23,24 @@ 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 }
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 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
@ -66,6 +78,7 @@ type ApiWindowSummary = {
window: string
current: ApiStatsSummary
previous: ApiStatsSummary
round_series: ApiRoundSeriesPoint[]
}
type WindowKey = "last_round" | "last_5" | "last_10" | "month" | "year" | "last_year" | "all"
@ -89,6 +102,17 @@ 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)
@ -382,30 +406,49 @@ function FairwayBar({
)
}
// --- Utvikling over tid: stolpegraf + trendlinje -----------------------
// --- Utvikling over tid: stolpegraf + trendlinje, per variabel ---------
// Brukerinstruks 2026-08-12: stolpe per runde (eldste til venstre) +
// 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.
// 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.
//
// 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.
// 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.
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
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
// 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
})
const slope = den === 0 ? 0 : num / den
const intercept = yMean - slope * xMean
return values.map((_, x) => intercept + slope * x)
}
function formatShortDate(iso: string): string {
@ -431,63 +474,97 @@ 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`
}
function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
if (series.length < 2) return null
type TrendPoint = { played_at: string; value: number }
const values = series.map((p) => p.score_to_par)
const trend = movingAverage(values, 3)
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 maxAbs = Math.max(...values.map((v) => Math.abs(v)), 1)
const scale = TREND_HALF_HEIGHT / maxAbs
const baselineY = TREND_CHART_HEIGHT - 20
const width = series.length * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_GAP
valueToY = (v) => baselineY - v * scale
} else {
const maxVal = Math.max(...values, 1)
const scale = TREND_MAGNITUDE_HEIGHT / maxVal
valueToY = (v) => baselineY - v * scale
}
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 valueToY = (v: number) => baselineY - v * scale
const trendPoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
const trendLinePoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
return (
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
<div className="flex flex-col gap-2">
<div className="overflow-x-auto">
<svg
role="img"
aria-label={`Snitt til par for ${series.length} runder, fra ${formatShortDate(series[0].played_at)} til ${formatShortDate(series[series.length - 1].played_at)}.`}
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}.`}
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} />
{series.map((point, i) => {
{points.map((point, i) => {
const cx = barCenterX(i)
const overPar = point.score_to_par > 0
const isLast = i === series.length - 1
const overPar = mode === "diverging" && point.value > 0
const isLast = i === points.length - 1
return (
<g key={point.played_at + i}>
<path
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.score_to_par), baselineY, TREND_BAR_WIDTH, 4)}
fill={overPar ? "var(--brand-orange)" : "var(--primary)"}
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)"}
>
<title>
{formatShortDate(point.played_at)}: {signed(point.score_to_par)}
{formatShortDate(point.played_at)}: {formatValue(point.value)}
</title>
</path>
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
"merk endepunktet, ikke hvert punkt". Resten leses via 0-linjen,
stolpenes relative høyde, og trendlinjens retning. */}
"merk endepunktet, ikke hvert punkt". */}
{isLast && (
<text
x={cx}
y={valueToY(point.score_to_par) + (overPar ? -6 : 14)}
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
textAnchor="middle"
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{signed(point.score_to_par)}
{formatValue(point.value)}
</text>
)}
{(i === 0 || i === series.length - 1 || i === Math.floor(series.length / 2)) && (
{(i === 0 || i === points.length - 1 || i === Math.floor(points.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>
@ -496,19 +573,95 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
)
})}
<polyline points={trendPoints} fill="none" stroke="var(--muted-foreground)" strokeWidth={2} strokeDasharray="5 4" strokeLinecap="round" />
<polyline points={trendLinePoints} fill="none" stroke="var(--muted-foreground)" strokeWidth={2} strokeDasharray="5 4" strokeLinecap="round" />
</svg>
</div>
<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">
<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">
<span aria-hidden="true" className="h-0.5 w-4 rounded-full" style={{ backgroundColor: "var(--muted-foreground)", opacity: 0.7 }} />
Trendlinje (glidende snitt, siste 3 runder)
Trend (lineær regresjon)
</span>
<span className={improving ? "text-primary" : "text-brand-orange"}>
{avgSlopePerRound >= 0 ? "+" : ""}
{formatSlope(Math.abs(avgSlopePerRound))} {slopeSuffix}
</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,
@ -645,6 +798,17 @@ 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">
@ -677,7 +841,7 @@ export function RoundsStatsSummary() {
/>
</Section>
<ScoreTrendChart series={s.round_series} />
<ScoreTrendChart series={series} />
{/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter
Greentreff (en beregnet andel), Innspill (en retnings-
@ -691,6 +855,19 @@ 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.">
@ -699,6 +876,12 @@ 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.">
@ -730,6 +913,18 @@ 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}>
@ -750,6 +945,29 @@ 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
@ -760,6 +978,12 @@ 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">
@ -787,6 +1011,22 @@ 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.">