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>
This commit is contained in:
parent
a7896122cb
commit
a65858af84
2 changed files with 383 additions and 60 deletions
|
|
@ -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 }
|
||||
|
||||
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 (
|
||||
<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