diff --git a/app/routers/rounds.py b/app/routers/rounds.py
index 63c4c93..a20c8fc 100644
--- a/app/routers/rounds.py
+++ b/app/routers/rounds.py
@@ -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),
)
diff --git a/frontend/components/rounds-stats-summary.tsx b/frontend/components/rounds-stats-summary.tsx
index ea00eb1..0ba527b 100644
--- a/frontend/components/rounds-stats-summary.tsx
+++ b/frontend/components/rounds-stats-summary.tsx
@@ -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 }
+
+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 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
const baselineY = TREND_CHART_HEIGHT - 20
- const width = series.length * (TREND_BAR_WIDTH + TREND_BAR_GAP) + TREND_BAR_GAP
+ 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
+ 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 (
-
+