Compare commits
2 commits
a7896122cb
...
ad1cbf7113
| Author | SHA1 | Date | |
|---|---|---|---|
| ad1cbf7113 | |||
| a65858af84 |
3 changed files with 422 additions and 60 deletions
39
CHANGELOG.md
39
CHANGELOG.md
|
|
@ -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å 0–1-skala i stedet for samme 0–100-
|
||||
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å.**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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), så 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.">
|
||||
|
|
|
|||
Loading…
Reference in a new issue