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 ( - +
= 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. */} - {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 ( - {formatShortDate(point.played_at)}: {signed(point.score_to_par)} + {formatShortDate(point.played_at)}: {formatValue(point.value)} {/* 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 && ( - {signed(point.score_to_par)} + {formatValue(point.value)} )} - {(i === 0 || i === series.length - 1 || i === Math.floor(series.length / 2)) && ( + {(i === 0 || i === points.length - 1 || i === Math.floor(points.length / 2)) && ( {formatShortDate(point.played_at)} @@ -496,19 +573,95 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) { ) })} - +
-
- +
+ + + {avgSlopePerRound >= 0 ? "+" : "−"} + {formatSlope(Math.abs(avgSlopePerRound))} {slopeSuffix}
+
+ ) +} + +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 ( + + ) } +// 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 ( +
+
+ + + + {points.map((p, i) => ( + + + {formatShortDate(p.played_at)}: {Math.round(p.value)} % + + + ))} + + {Math.round(points[0].value)} % + + + {Math.round(points[points.length - 1].value)} % + + +
+

Kumulativt -- raten din til og med hver runde, ikke per-runde-tall.

+
+ ) +} + // --- 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 ( <>

@@ -677,7 +841,7 @@ export function RoundsStatsSummary() { /> - + {/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter Greentreff (en beregnet andel), så Innspill (en retnings- @@ -691,6 +855,19 @@ export function RoundsStatsSummary() { Fairwaytreff

+ {fairwayTrend.length >= 2 && ( +
+

Fairwaytreff over tid

+ +
+ )}
@@ -699,6 +876,12 @@ export function RoundsStatsSummary() { Greentreff + {girTrend.length >= 2 && ( +
+

Greentreff over tid

+ +
+ )}
@@ -730,6 +913,18 @@ export function RoundsStatsSummary() { Én-putt + {puttsTrend.length >= 2 && ( +
+

Putt / 18 hull over tid

+ +
+ )} + {onePuttTrend.length >= 2 && ( +
+

Én-putt over tid

+ +
+ )}
@@ -750,6 +945,29 @@ export function RoundsStatsSummary() { />
+ {(chipTrend.length >= 2 || bunkerTrend.length >= 2 || penaltyTrend.length >= 2) && ( + + {chipTrend.length >= 2 && ( +
+

Chip / runde

+ +
+ )} + {bunkerTrend.length >= 2 && ( +
+

Bunkerslag / runde

+ +
+ )} + {penaltyTrend.length >= 2 && ( +
+

Straffeslag / runde

+ +
+ )} +
+ )} + {s.avg_anyway_per_round !== null && (
)} + {anywayTrend.length >= 2 && ( + + + + )} +
@@ -787,6 +1011,22 @@ export function RoundsStatsSummary() {
+ {(scramblingCumulative.length >= 2 || sandSaveCumulative.length >= 2) && ( +
+ {scramblingCumulative.length >= 2 && ( +
+

Scrambling over tid

+ +
+ )} + {sandSaveCumulative.length >= 2 && ( +
+

Sand save over tid

+ +
+ )} +
+ )}