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):
|
class RoundStatsSeriesPoint(BaseModel):
|
||||||
played_at: date
|
played_at: date
|
||||||
score_to_par: float
|
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):
|
class RoundStatsSummary(BaseModel):
|
||||||
rounds_completed: int
|
rounds_completed: int
|
||||||
avg_score_to_par: float | None
|
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
|
avg_putts_per_18: float | None
|
||||||
putts_tracked_rounds: int
|
putts_tracked_rounds: int
|
||||||
fairway_hit_pct: float | None
|
fairway_hit_pct: float | None
|
||||||
|
|
@ -1714,6 +1726,11 @@ class RoundStatsWindowSummary(BaseModel):
|
||||||
window: str
|
window: str
|
||||||
current: RoundStatsSummary
|
current: RoundStatsSummary
|
||||||
previous: 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"]
|
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
|
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(
|
def _summarize_rounds(
|
||||||
round_ids: set[str],
|
round_ids: set[str],
|
||||||
by_round: dict[str, list],
|
by_round: dict[str, list],
|
||||||
|
|
@ -1773,18 +1861,13 @@ def _summarize_rounds(
|
||||||
|
|
||||||
to_par_values: list[float] = []
|
to_par_values: list[float] = []
|
||||||
putt_totals: list[float] = []
|
putt_totals: list[float] = []
|
||||||
round_series: list[RoundStatsSeriesPoint] = []
|
|
||||||
for round_id in round_ids:
|
for round_id in round_ids:
|
||||||
holes = by_round.get(round_id, [])
|
holes = by_round.get(round_id, [])
|
||||||
played = [h for h in holes if h["played"] and h["score"] is not None]
|
played = [h for h in holes if h["played"] and h["score"] is not None]
|
||||||
if played:
|
if played:
|
||||||
round_to_par = float(sum(h["score"] - h["par"] for h in played))
|
to_par_values.append(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))
|
|
||||||
if stat_level_by_round.get(round_id) != "strokes_only":
|
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))
|
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]
|
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(
|
return RoundStatsSummary(
|
||||||
rounds_completed=len(round_ids),
|
rounds_completed=len(round_ids),
|
||||||
avg_score_to_par=avg(to_par_values),
|
avg_score_to_par=avg(to_par_values),
|
||||||
round_series=round_series,
|
|
||||||
avg_putts_per_18=avg(putt_totals),
|
avg_putts_per_18=avg(putt_totals),
|
||||||
putts_tracked_rounds=len(putt_totals),
|
putts_tracked_rounds=len(putt_totals),
|
||||||
fairway_hit_pct=fairway_hit_pct,
|
fairway_hit_pct=fairway_hit_pct,
|
||||||
|
|
@ -1949,6 +2031,7 @@ async def get_rounds_stats_summary(
|
||||||
window=window,
|
window=window,
|
||||||
current=_summarize_rounds(current_ids, by_round, stat_level_by_round, played_at_by_round),
|
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),
|
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 Link from "next/link"
|
||||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BarChart3, Flag, TrendingDown, TrendingUp, Waves, Wind } from "lucide-react"
|
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 = {
|
type ApiStatsSummary = {
|
||||||
rounds_completed: number
|
rounds_completed: number
|
||||||
avg_score_to_par: number | null
|
avg_score_to_par: number | null
|
||||||
round_series: ApiRoundSeriesPoint[]
|
|
||||||
avg_putts_per_18: number | null
|
avg_putts_per_18: number | null
|
||||||
putts_tracked_rounds: number
|
putts_tracked_rounds: number
|
||||||
fairway_hit_pct: number | null
|
fairway_hit_pct: number | null
|
||||||
|
|
@ -66,6 +78,7 @@ type ApiWindowSummary = {
|
||||||
window: string
|
window: string
|
||||||
current: ApiStatsSummary
|
current: ApiStatsSummary
|
||||||
previous: ApiStatsSummary
|
previous: ApiStatsSummary
|
||||||
|
round_series: ApiRoundSeriesPoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type WindowKey = "last_round" | "last_5" | "last_10" | "month" | "year" | "last_year" | "all"
|
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)} %`
|
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").
|
// Prosentpoeng-differanse mellom to andeler (ikke "N % endring", men "N pp").
|
||||||
function signedPoints(n: number): string {
|
function signedPoints(n: number): string {
|
||||||
const rounded = Math.round(n)
|
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) +
|
// Brukerinstruks 2026-08-12: stolpe per runde (eldste til venstre) +
|
||||||
// trendlinje over. Tidligere bevisst UTSATT (se CHANGELOG 2026-07-28: "for
|
// trendlinje, for ALLE variabler -- ikke bare snitt til par. Tidligere
|
||||||
// lite rundehistorikk til å vise noe meningsfullt") -- bygges nå som
|
// bevisst UTSATT (se CHANGELOG 2026-07-28: "for lite rundehistorikk til
|
||||||
// brukeren har nok fullførte runder til at en trend faktisk sier noe.
|
// å vise noe meningsfullt"), bygget da brukeren fikk nok runder.
|
||||||
//
|
//
|
||||||
// Fargevalg følger samme diverging-par som `Delta` allerede bruker på
|
// Trendlinjen er LINEÆR REGRESJON (minste kvadraters metode), ikke et
|
||||||
// resten av siden (primary = under/på par, brand-orange = over par) --
|
// glidende snitt -- brukeren påpekte (med rette) at et glidende snitt
|
||||||
// ikke en ny palett, samme etablerte semantikk. Trendlinjen er et
|
// over så få runder bare gjentar stolpene med én runde forsinkelse. En
|
||||||
// glidende snitt (ikke rå lineær regresjon), tegnet i en nøytral,
|
// rett regresjonslinje er visuelt og matematisk en helt annen form (en
|
||||||
// dempet farge og stiplet, slik at den tydelig leses som UTLEDET, ikke
|
// retning, ikke en kopi av dataene), og stigningstallet kan leses rett
|
||||||
// som en tredje rådataserie.
|
// 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_WIDTH = 20
|
||||||
const TREND_BAR_GAP = 10
|
const TREND_BAR_GAP = 10
|
||||||
const TREND_CHART_HEIGHT = 160
|
const TREND_CHART_HEIGHT = 160
|
||||||
const TREND_HALF_HEIGHT = TREND_CHART_HEIGHT / 2 - 14 // plass til dato-etiketter under
|
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[] {
|
// Lineær regresjon (minste kvadraters metode) -- returnerer den rette
|
||||||
return values.map((_, i) => {
|
// linjens y-verdi ved hver indeks. Rendret som polyline blir dette
|
||||||
const start = Math.max(0, i - window + 1)
|
// bokstavelig talt en rett strek, ikke en linje som følger dataene.
|
||||||
const slice = values.slice(start, i + 1)
|
function linearRegression(values: number[]): number[] {
|
||||||
return slice.reduce((sum, v) => sum + v, 0) / slice.length
|
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 {
|
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`
|
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[] }) {
|
type TrendPoint = { played_at: string; value: number }
|
||||||
if (series.length < 2) return null
|
|
||||||
|
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 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 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 trendLinePoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
|
||||||
|
|
||||||
const trendPoints = trend.map((v, i) => `${barCenterX(i)},${valueToY(v)}`).join(" ")
|
|
||||||
|
|
||||||
return (
|
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">
|
<div className="overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
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}`}
|
viewBox={`0 0 ${width} ${TREND_CHART_HEIGHT}`}
|
||||||
width={width}
|
width={width}
|
||||||
height={TREND_CHART_HEIGHT}
|
height={TREND_CHART_HEIGHT}
|
||||||
className="block"
|
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} />
|
<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 cx = barCenterX(i)
|
||||||
const overPar = point.score_to_par > 0
|
const overPar = mode === "diverging" && point.value > 0
|
||||||
const isLast = i === series.length - 1
|
const isLast = i === points.length - 1
|
||||||
return (
|
return (
|
||||||
<g key={point.played_at + i}>
|
<g key={point.played_at + i}>
|
||||||
<path
|
<path
|
||||||
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.score_to_par), baselineY, TREND_BAR_WIDTH, 4)}
|
d={barPath(cx - TREND_BAR_WIDTH / 2, valueToY(point.value), baselineY, TREND_BAR_WIDTH, 4)}
|
||||||
fill={overPar ? "var(--brand-orange)" : "var(--primary)"}
|
fill={mode === "diverging" ? (overPar ? "var(--brand-orange)" : "var(--primary)") : "var(--primary)"}
|
||||||
>
|
>
|
||||||
<title>
|
<title>
|
||||||
{formatShortDate(point.played_at)}: {signed(point.score_to_par)}
|
{formatShortDate(point.played_at)}: {formatValue(point.value)}
|
||||||
</title>
|
</title>
|
||||||
</path>
|
</path>
|
||||||
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
|
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
|
||||||
"merk endepunktet, ikke hvert punkt". Resten leses via 0-linjen,
|
"merk endepunktet, ikke hvert punkt". */}
|
||||||
stolpenes relative høyde, og trendlinjens retning. */}
|
|
||||||
{isLast && (
|
{isLast && (
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={cx}
|
||||||
y={valueToY(point.score_to_par) + (overPar ? -6 : 14)}
|
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-foreground text-[11px] font-bold tabular-nums"
|
className="fill-foreground text-[11px] font-bold tabular-nums"
|
||||||
>
|
>
|
||||||
{signed(point.score_to_par)}
|
{formatValue(point.value)}
|
||||||
</text>
|
</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">
|
<text x={cx} y={TREND_CHART_HEIGHT - 4} textAnchor="middle" className="fill-muted-foreground text-[10px] font-semibold">
|
||||||
{formatShortDate(point.played_at)}
|
{formatShortDate(point.played_at)}
|
||||||
</text>
|
</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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
|
<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">
|
<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 }} />
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
</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 -----------------------
|
// --- Til par: med vs. uten en gitt hendelse på hullet -----------------------
|
||||||
// Brukerens eksplisitte instruks 2026-07-28: se snitt til par NÅR noe
|
// Brukerens eksplisitte instruks 2026-07-28: se snitt til par NÅR noe
|
||||||
// inntraff mot NÅR det ikke gjorde det (greentreff, fairwaytreff, bunker,
|
// inntraff mot NÅR det ikke gjorde det (greentreff, fairwaytreff, bunker,
|
||||||
|
|
@ -645,6 +798,17 @@ export function RoundsStatsSummary() {
|
||||||
(() => {
|
(() => {
|
||||||
const s = data.current
|
const s = data.current
|
||||||
const p = data.previous
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
|
@ -677,7 +841,7 @@ export function RoundsStatsSummary() {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<ScoreTrendChart series={s.round_series} />
|
<ScoreTrendChart series={series} />
|
||||||
|
|
||||||
{/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter
|
{/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter
|
||||||
Greentreff (en beregnet andel), så Innspill (en retnings-
|
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>
|
<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" />
|
<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Card title="Greentreff (GIR)" subtitle="Andel innspill som traff green i regulert antall slag.">
|
<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>
|
<span className="text-sm font-semibold text-muted-foreground">Greentreff</span>
|
||||||
<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />
|
<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Card title="Innspill" subtitle="Retningen du bommer i, når du bommer greenen.">
|
<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>
|
<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" />
|
<Delta current={s.one_putt_pct} previous={p.one_putt_pct} format={signedPoints} goodDirection="up" />
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
|
|
||||||
<Section title="Chip, bunker og straffeslag" icon={Waves}>
|
<Section title="Chip, bunker og straffeslag" icon={Waves}>
|
||||||
|
|
@ -750,6 +945,29 @@ export function RoundsStatsSummary() {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</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 && (
|
{s.avg_anyway_per_round !== null && (
|
||||||
<Section title="Annet" icon={Wind}>
|
<Section title="Annet" icon={Wind}>
|
||||||
<StatTile
|
<StatTile
|
||||||
|
|
@ -760,6 +978,12 @@ export function RoundsStatsSummary() {
|
||||||
</Section>
|
</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.">
|
<Card title="Redning" subtitle="Par eller bedre etter en bom.">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<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" />
|
<Delta current={s.sand_save_pct} previous={p.sand_save_pct} format={signedPoints} goodDirection="up" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<Card title="Til par: med vs. uten" subtitle="Snitt til par på enkelthull, splittet på om noe faktisk inntraff.">
|
<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