Compare commits
2 commits
e48cfb8dda
...
859dded655
| Author | SHA1 | Date | |
|---|---|---|---|
| 859dded655 | |||
| c967ed11da |
5 changed files with 321 additions and 76 deletions
48
CHANGELOG.md
48
CHANGELOG.md
|
|
@ -10538,3 +10538,51 @@ Neste steg:
|
||||||
`teecup_frontend` bygget og restartet (ren frontend-endring, ingen
|
`teecup_frontend` bygget og restartet (ren frontend-endring, ingen
|
||||||
migrasjon). Ingen feil i containerloggene, ingen konsollfeil på
|
migrasjon). Ingen feil i containerloggene, ingen konsollfeil på
|
||||||
`https://teecup.golf/logg-inn` etter omstart.
|
`https://teecup.golf/logg-inn` etter omstart.
|
||||||
|
|
||||||
|
74. **Statistikk: ekspandert trekkspill, spilt-til-HCP, omstrukturert
|
||||||
|
/my-rounds/stats — 2026-08-12.** Tre separate tilbakemeldinger fra
|
||||||
|
bruker etter å ha sett gjennom statistikksidene med skjermbilder.
|
||||||
|
|
||||||
|
**round-stats.tsx:** trekkspillseksjonene starter nå ekspandert.
|
||||||
|
Reverserer en eksplisitt tidligere beslutning (2026-07-25, "trekk-
|
||||||
|
spillet skal vises sammenslått") — kommentaren i koden oppdatert til
|
||||||
|
å forklare hvorfor, ikke bare strøket.
|
||||||
|
|
||||||
|
**Dashbordets "HCP nå"** viste tidligere det manuelt satte tallet i
|
||||||
|
profilen (`handicap_index`) — brukeren ville ha "spilt til"-HCP, dvs.
|
||||||
|
`computed_handicap_index` (allerede beregnet og eksponert på
|
||||||
|
`/auth/me`, brukt av Konto-sidens "Faktisk HCP (beregnet)"-kort, bare
|
||||||
|
ikke koblet inn på dashbordet). Historikk-sparklinen filtreres nå
|
||||||
|
også til kun `source="computed"` — den blandet tidligere manuelle og
|
||||||
|
beregnede punkter i samme linje.
|
||||||
|
|
||||||
|
**`/my-rounds/stats` fikk tre endringer:**
|
||||||
|
- Alle "vs. forrige periode"-deltaer (pil+tall+pp) er nå synlig
|
||||||
|
selvforklarende. Teksten fantes tidligere kun som skjermleser-tekst
|
||||||
|
— seende brukere så bare et ikon og et tall, ingen kontekst for hva
|
||||||
|
det ble sammenlignet mot. Bruker: "Selv ikke jeg... forstår helt
|
||||||
|
hva som menes."
|
||||||
|
- Greentreff (GIR) og Innspill splittet i to separate kort (GIR er en
|
||||||
|
beregnet andel, Innspill er en retningsbeskrivelse — samme prinsipp
|
||||||
|
som round-stats.tsx sin eksisterende splitt mellom "Greentreff
|
||||||
|
(GIR)" og "Bom-retning på green"). Utslag flyttet til å vises
|
||||||
|
først. Redning og "Til par: med vs. uten" flyttet ned under Annet.
|
||||||
|
- Ny stolpegraf med glidende snitt-trendlinje (snitt til par per
|
||||||
|
runde, eldste runde til venstre) — tidligere bevisst utsatt ("for
|
||||||
|
lite rundehistorikk til å vise noe meningsfullt", se punkt 2026-
|
||||||
|
07-28 tidligere i denne loggen), bygget nå som brukeren har nok
|
||||||
|
fullførte runder. Backend: `RoundStatsSummary` fikk et nytt
|
||||||
|
`round_series`-felt (kronologisk snitt-til-par per fullført runde
|
||||||
|
i valgt tidsvindu, sortert eldste først).
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, 45/45 vitest, 31/31 backend-
|
||||||
|
tester (inkl. `round_series`-feltet, kjørt mot en scratch-database).
|
||||||
|
Full scratch-stack med seks syntetiske runder (varierende dato og
|
||||||
|
resultat, for å faktisk se en trend) browserverifisert lys+mørk:
|
||||||
|
HCP-bytte bekreftet (computed 5,3 vist i stedet for manuelt satt
|
||||||
|
15,0), ny kortrekkefølge, splittet GIR/Innspill, synlige delta-
|
||||||
|
bildetekster på et vindu med reell forrige-periode-sammenligning,
|
||||||
|
graf med trendlinje, trekkspill ekspandert på rundesiden. Ingen
|
||||||
|
konsollfeil. `teecup_db` urørt gjennom hele verifiseringen.
|
||||||
|
|
||||||
|
**Ikke rullet ut ennå.**
|
||||||
|
|
|
||||||
|
|
@ -1657,9 +1657,21 @@ async def list_rounds(user: CurrentUser = Depends(get_current_user)) -> list[Rou
|
||||||
return [await _load_round_out(conn, r["id"], user.user_id) for r in ids]
|
return [await _load_round_out(conn, r["id"], user.user_id) for r in ids]
|
||||||
|
|
||||||
|
|
||||||
|
class RoundStatsSeriesPoint(BaseModel):
|
||||||
|
played_at: date
|
||||||
|
score_to_par: float
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -1746,7 +1758,12 @@ def _resolve_stats_window(window: StatsWindow, ordered_rounds: list[tuple[str, d
|
||||||
return current, previous
|
return current, previous
|
||||||
|
|
||||||
|
|
||||||
def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level_by_round: dict[str, str]) -> RoundStatsSummary:
|
def _summarize_rounds(
|
||||||
|
round_ids: set[str],
|
||||||
|
by_round: dict[str, list],
|
||||||
|
stat_level_by_round: dict[str, str],
|
||||||
|
played_at_by_round: dict[str, date],
|
||||||
|
) -> RoundStatsSummary:
|
||||||
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
|
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
|
||||||
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
|
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
|
||||||
gjeldende og forrige periode (se `_resolve_stats_window`)."""
|
gjeldende og forrige periode (se `_resolve_stats_window`)."""
|
||||||
|
|
@ -1756,13 +1773,18 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level
|
||||||
|
|
||||||
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:
|
||||||
to_par_values.append(sum(h["score"] - h["par"] for h in 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))
|
||||||
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]
|
||||||
|
|
||||||
|
|
@ -1834,6 +1856,7 @@ def _summarize_rounds(round_ids: set[str], by_round: dict[str, list], stat_level
|
||||||
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,
|
||||||
|
|
@ -1924,8 +1947,8 @@ async def get_rounds_stats_summary(
|
||||||
|
|
||||||
return RoundStatsWindowSummary(
|
return RoundStatsWindowSummary(
|
||||||
window=window,
|
window=window,
|
||||||
current=_summarize_rounds(current_ids, by_round, stat_level_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),
|
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round, played_at_by_round),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ type Me = {
|
||||||
preferred_locale: string
|
preferred_locale: string
|
||||||
profile_complete: boolean
|
profile_complete: boolean
|
||||||
handicap_index: number | null
|
handicap_index: number | null
|
||||||
|
computed_handicap_index: number | null
|
||||||
organizations: MyOrg[]
|
organizations: MyOrg[]
|
||||||
my_tournaments: MyTournament[]
|
my_tournaments: MyTournament[]
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +161,7 @@ type ApiRound = {
|
||||||
my_match_lead: number | null
|
my_match_lead: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiHandicapPoint = { handicap_index: number; recorded_at: string }
|
type ApiHandicapPoint = { handicap_index: number; recorded_at: string; source: "manual" | "computed" }
|
||||||
|
|
||||||
// ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele
|
// ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele
|
||||||
// GET /friends-responsen, som også bærer utgående forespørsler/kategorier).
|
// GET /friends-responsen, som også bærer utgående forespørsler/kategorier).
|
||||||
|
|
@ -453,9 +454,12 @@ export function Dashboard() {
|
||||||
.map((r) => r.my_score_to_par as number)
|
.map((r) => r.my_score_to_par as number)
|
||||||
const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—"
|
const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—"
|
||||||
|
|
||||||
const hcpValues = hcpHistory.map((h) => h.handicap_index)
|
// Dashbordet skal vise "spilt til"-HCP (beregnet fra faktiske runder),
|
||||||
|
// ikke det manuelt satte tallet i profilen -- historikk-grafen filtreres
|
||||||
|
// til samme kilde, ellers ville sparklinen blande to ulike signaler.
|
||||||
|
const hcpValues = hcpHistory.filter((h) => h.source === "computed").map((h) => h.handicap_index)
|
||||||
const hcpTrend = hcpValues.length >= 2 ? hcpValues[hcpValues.length - 1] - hcpValues[hcpValues.length - 2] : null
|
const hcpTrend = hcpValues.length >= 2 ? hcpValues[hcpValues.length - 1] - hcpValues[hcpValues.length - 2] : null
|
||||||
const hcpNow = me.handicap_index !== null ? me.handicap_index.toFixed(1).replace(".", ",") : "—"
|
const hcpNow = me.computed_handicap_index !== null ? me.computed_handicap_index.toFixed(1).replace(".", ",") : "—"
|
||||||
|
|
||||||
const coursesMap = new Map<string, { name: string; visits: number; lastPlayed: string }>()
|
const coursesMap = new Map<string, { name: string; visits: number; lastPlayed: string }>()
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
|
|
|
||||||
|
|
@ -57,10 +57,12 @@ const C = {
|
||||||
function StatCard({
|
function StatCard({
|
||||||
title,
|
title,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
// Alle seksjonene starter kollapset (etterspurt av bruker 2026-07-25:
|
// Alle seksjonene starter ekspandert (2026-08-12, snur en tidligere
|
||||||
// "trekkspillet skal vises sammenslått") -- må trykkes på hver enkelt
|
// beslutning fra 2026-07-25 om at trekkspillet skulle vises sammenslått)
|
||||||
// for å se innholdet, i stedet for at hele siden lastes åpen.
|
// -- brukeren skal se alt med det samme, i stedet for å måtte åpne hver
|
||||||
defaultOpen = false,
|
// seksjon manuelt. Trekkspillet finnes fortsatt, for å kunne lukke ned
|
||||||
|
// seksjoner man ikke bryr seg om akkurat nå.
|
||||||
|
defaultOpen = true,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,14 @@ import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, BarChart3, Target, 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 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
|
||||||
|
|
@ -107,17 +110,24 @@ function Delta({
|
||||||
format: (diff: number) => string
|
format: (diff: number) => string
|
||||||
goodDirection?: "up" | "down"
|
goodDirection?: "up" | "down"
|
||||||
}) {
|
}) {
|
||||||
|
// Teksten "vs. forrige periode" var tidligere KUN skjermleser-tekst --
|
||||||
|
// seende brukere så bare et ikon + et tall + "pp", uten noen synlig
|
||||||
|
// forklaring på hva tallet ble sammenlignet MOT. Nå alltid synlig, slik
|
||||||
|
// at delta-en er selvforklarende alene, uten å måtte lese en fjern
|
||||||
|
// overskrift lenger oppe på siden (2026-08-12, brukertilbakemelding).
|
||||||
if (current === null || previous === null) return null
|
if (current === null || previous === null) return null
|
||||||
const diff = current - previous
|
const diff = current - previous
|
||||||
if (diff === 0) return <span className="text-xs font-semibold text-muted-foreground">Uendret</span>
|
if (diff === 0) {
|
||||||
|
return <span className="text-xs font-semibold text-muted-foreground">Uendret vs. forrige periode</span>
|
||||||
|
}
|
||||||
const improved = goodDirection === "up" ? diff > 0 : goodDirection === "down" ? diff < 0 : null
|
const improved = goodDirection === "up" ? diff > 0 : goodDirection === "down" ? diff < 0 : null
|
||||||
const Icon = diff > 0 ? TrendingUp : TrendingDown
|
const Icon = diff > 0 ? TrendingUp : TrendingDown
|
||||||
const colorClass = improved === true ? "text-primary" : improved === false ? "text-brand-orange" : "text-muted-foreground"
|
const colorClass = improved === true ? "text-primary" : improved === false ? "text-brand-orange" : "text-muted-foreground"
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1 text-xs font-bold ${colorClass}`}>
|
<span className={`inline-flex flex-wrap items-center gap-1 text-xs font-bold ${colorClass}`}>
|
||||||
<Icon aria-hidden="true" className="size-3.5" />
|
<Icon aria-hidden="true" className="size-3.5" />
|
||||||
<span className="tabular-nums">{format(diff)}</span>
|
<span className="tabular-nums">{format(diff)}</span>
|
||||||
<span className="sr-only">vs. forrige periode</span>
|
<span className="font-semibold text-muted-foreground">vs. forrige periode</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -265,25 +275,47 @@ function CompassCell({ icon: Icon, label, value }: { icon: typeof ArrowUp; label
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function GirCompass({
|
// Greentreff (GIR) og Innspill var tidligere ÉN kombinert komponent
|
||||||
hitPct,
|
// (GirCompass). Splittet i to 2026-08-12 på brukerens eksplisitte
|
||||||
|
// instruks: GIR er en beregnet andel (traff/bommet), Innspill er en
|
||||||
|
// retningsbeskrivelse -- to ulike typer informasjon, samme prinsipp som
|
||||||
|
// round-stats.tsx sin split mellom "Greentreff (GIR)" og "Bom-retning på
|
||||||
|
// green" (se den filen).
|
||||||
|
|
||||||
|
function GirGauge({ hitPct, trackedHoles }: { hitPct: number | null; trackedHoles: number }) {
|
||||||
|
if (hitPct === null) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Ingen greentreff-data registrert ennå.</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Donut
|
||||||
|
segments={[
|
||||||
|
{ label: "Traff green", value: hitPct, color: "var(--primary)" },
|
||||||
|
{ label: "Bommet green", value: 100 - hitPct, color: "var(--brand-orange)" },
|
||||||
|
]}
|
||||||
|
centerValue={`${Math.round(hitPct)} %`}
|
||||||
|
centerLabel="GIR"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{trackedHoles} hull registrert</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApproachDirectionCompass({
|
||||||
trackedHoles,
|
trackedHoles,
|
||||||
missTrackedHoles,
|
|
||||||
long,
|
long,
|
||||||
short,
|
short,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
||||||
}: {
|
}: {
|
||||||
hitPct: number | null
|
|
||||||
trackedHoles: number
|
trackedHoles: number
|
||||||
missTrackedHoles: number
|
|
||||||
long: number | null
|
long: number | null
|
||||||
short: number | null
|
short: number | null
|
||||||
left: number | null
|
left: number | null
|
||||||
right: number | null
|
right: number | null
|
||||||
}) {
|
}) {
|
||||||
if (hitPct === null) {
|
if (trackedHoles === 0) {
|
||||||
return <p className="text-sm text-muted-foreground">Ingen greentreff-data registrert ennå.</p>
|
return <p className="text-sm text-muted-foreground">Ingen innspill med retning registrert ennå.</p>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
|
@ -292,20 +324,19 @@ function GirCompass({
|
||||||
<CompassCell icon={ArrowUp} label="Langt" value={long} />
|
<CompassCell icon={ArrowUp} label="Langt" value={long} />
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
<CompassCell icon={ArrowLeft} label="Venstre" value={left} />
|
<CompassCell icon={ArrowLeft} label="Venstre" value={left} />
|
||||||
<div className="flex flex-col items-center justify-center gap-0.5 rounded-2xl border-2 border-primary bg-primary/15 p-2">
|
<div className="flex items-center justify-center">
|
||||||
<Target aria-hidden="true" className="size-5 text-primary" />
|
<div className="relative flex size-14 items-center justify-center rounded-full bg-primary/15">
|
||||||
<span className="text-2xl font-extrabold tabular-nums text-foreground">{Math.round(hitPct)} %</span>
|
<div className="flex size-10 items-center justify-center rounded-full bg-primary/30">
|
||||||
<span className="text-[11px] font-semibold text-muted-foreground">Greentreff</span>
|
<Flag aria-hidden="true" className="size-5 text-primary" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CompassCell icon={ArrowRight} label="Høyre" value={right} />
|
<CompassCell icon={ArrowRight} label="Høyre" value={right} />
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
<CompassCell icon={ArrowDown} label="Kort" value={short} />
|
<CompassCell icon={ArrowDown} label="Kort" value={short} />
|
||||||
<div aria-hidden="true" />
|
<div aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">{trackedHoles} innspill registrert med retning.</p>
|
||||||
{trackedHoles} hull registrert
|
|
||||||
{missTrackedHoles > 0 && `, ${missTrackedHoles} bom med retning`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +382,133 @@ function FairwayBar({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Utvikling over tid: stolpegraf + trendlinje -----------------------
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${d.getDate()}.${d.getMonth() + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stolpe med avrundet ende BORTE fra 0-linjen, skarp kant VED 0-linjen --
|
||||||
|
// samme prinsipp uansett om verdien er over eller under par (dataviz-
|
||||||
|
// skillets "4px avrundet dataende, skarp ved grunnlinjen").
|
||||||
|
function barPath(x: number, valueY: number, baselineY: number, width: number, radius: number): string {
|
||||||
|
const top = Math.min(valueY, baselineY)
|
||||||
|
const bottom = Math.max(valueY, baselineY)
|
||||||
|
const h = bottom - top
|
||||||
|
if (h < radius) {
|
||||||
|
// For kort til å avrunde pent -- vanlig rektangel.
|
||||||
|
return `M ${x} ${top} H ${x + width} V ${bottom} H ${x} Z`
|
||||||
|
}
|
||||||
|
const roundedAtTop = valueY < baselineY
|
||||||
|
if (roundedAtTop) {
|
||||||
|
return `M ${x} ${top + radius} A ${radius} ${radius} 0 0 1 ${x + radius} ${top} H ${x + width - radius} A ${radius} ${radius} 0 0 1 ${x + width} ${top + radius} V ${bottom} H ${x} 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[] }) {
|
||||||
|
if (series.length < 2) return null
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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(" ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
|
||||||
|
<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)}.`}
|
||||||
|
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) => {
|
||||||
|
const cx = barCenterX(i)
|
||||||
|
const overPar = point.score_to_par > 0
|
||||||
|
const isLast = i === series.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)"}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{formatShortDate(point.played_at)}: {signed(point.score_to_par)}
|
||||||
|
</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. */}
|
||||||
|
{isLast && (
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={valueToY(point.score_to_par) + (overPar ? -6 : 14)}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-foreground text-[11px] font-bold tabular-nums"
|
||||||
|
>
|
||||||
|
{signed(point.score_to_par)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
{(i === 0 || i === series.length - 1 || i === Math.floor(series.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>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<polyline points={trendPoints} 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">
|
||||||
|
<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)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- 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,
|
||||||
|
|
@ -519,28 +677,38 @@ export function RoundsStatsSummary() {
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Card title="Greentreff (GIR)" subtitle="Hvor treffer og bommer du innspillet, sett fra green?">
|
<ScoreTrendChart series={s.round_series} />
|
||||||
<GirCompass
|
|
||||||
hitPct={s.gir_pct}
|
{/* Rekkefølge 2026-08-12 (brukerinstruks): Utslag først, deretter
|
||||||
trackedHoles={s.gir_tracked_holes}
|
Greentreff (en beregnet andel), så Innspill (en retnings-
|
||||||
missTrackedHoles={s.green_miss_tracked_holes}
|
beskrivelse -- egen kortype av samme grunn som round-stats.tsx
|
||||||
|
skiller "Greentreff (GIR)" fra "Bom-retning på green"). Redning
|
||||||
|
og "Til par: med vs. uten" flyttet ned under Annet, se der. */}
|
||||||
|
|
||||||
|
<Card title="Utslag" subtitle="Kun par 4/5, kun registrerte utslag.">
|
||||||
|
<FairwayBar tracked={s.fairway_tracked_holes} left={s.fairway_left_pct} hit={s.fairway_hit_pct} right={s.fairway_right_pct} />
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Greentreff (GIR)" subtitle="Andel innspill som traff green i regulert antall slag.">
|
||||||
|
<GirGauge hitPct={s.gir_pct} trackedHoles={s.gir_tracked_holes} />
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Innspill" subtitle="Retningen du bommer i, når du bommer greenen.">
|
||||||
|
<ApproachDirectionCompass
|
||||||
|
trackedHoles={s.green_miss_tracked_holes}
|
||||||
long={s.green_miss_long_pct}
|
long={s.green_miss_long_pct}
|
||||||
short={s.green_miss_short_pct}
|
short={s.green_miss_short_pct}
|
||||||
left={s.green_miss_left_pct}
|
left={s.green_miss_left_pct}
|
||||||
right={s.green_miss_right_pct}
|
right={s.green_miss_right_pct}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground">vs. forrige periode</span>
|
|
||||||
<Delta current={s.gir_pct} previous={p.gir_pct} format={signedPoints} goodDirection="up" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="Fra utslaget" subtitle="Kun par 4/5, kun registrerte utslag.">
|
|
||||||
<FairwayBar tracked={s.fairway_tracked_holes} left={s.fairway_left_pct} hit={s.fairway_hit_pct} right={s.fairway_right_pct} />
|
|
||||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
|
||||||
<span className="text-sm font-semibold text-muted-foreground">Fairwaytreff vs. forrige periode</span>
|
|
||||||
<Delta current={s.fairway_hit_pct} previous={p.fairway_hit_pct} format={signedPoints} goodDirection="up" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Putting">
|
<Card title="Putting">
|
||||||
|
|
@ -559,11 +727,39 @@ export function RoundsStatsSummary() {
|
||||||
/>
|
/>
|
||||||
{s.putt_dist_one_pct === null && <p className="text-sm text-muted-foreground">Ingen puttfordeling registrert ennå.</p>}
|
{s.putt_dist_one_pct === null && <p className="text-sm text-muted-foreground">Ingen puttfordeling registrert ennå.</p>}
|
||||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||||
<span className="text-sm font-semibold text-muted-foreground">Én-putt vs. forrige periode</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>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Section title="Chip, bunker og straffeslag" icon={Waves}>
|
||||||
|
<StatTile
|
||||||
|
label="Chip / runde"
|
||||||
|
value={s.avg_chip_per_round !== null ? s.avg_chip_per_round.toFixed(1).replace(".", ",") : "–"}
|
||||||
|
delta={<Delta current={s.avg_chip_per_round} previous={p.avg_chip_per_round} format={signed} goodDirection="down" />}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Bunkerslag / runde"
|
||||||
|
value={s.avg_bunker_per_round !== null ? s.avg_bunker_per_round.toFixed(1).replace(".", ",") : "–"}
|
||||||
|
delta={<Delta current={s.avg_bunker_per_round} previous={p.avg_bunker_per_round} format={signed} goodDirection="down" />}
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Straffeslag / runde"
|
||||||
|
value={s.avg_penalty_per_round !== null ? s.avg_penalty_per_round.toFixed(1).replace(".", ",") : "–"}
|
||||||
|
delta={<Delta current={s.avg_penalty_per_round} previous={p.avg_penalty_per_round} format={signed} goodDirection="down" />}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{s.avg_anyway_per_round !== null && (
|
||||||
|
<Section title="Annet" icon={Wind}>
|
||||||
|
<StatTile
|
||||||
|
label="Anywayslag / runde"
|
||||||
|
value={s.avg_anyway_per_round.toFixed(1).replace(".", ",")}
|
||||||
|
delta={<Delta current={s.avg_anyway_per_round} previous={p.avg_anyway_per_round} format={signed} goodDirection="down" />}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
<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">
|
||||||
|
|
@ -623,34 +819,6 @@ export function RoundsStatsSummary() {
|
||||||
bValue={s.avg_to_par_with_anyway}
|
bValue={s.avg_to_par_with_anyway}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Section title="Chip, bunker og straffeslag" icon={Waves}>
|
|
||||||
<StatTile
|
|
||||||
label="Chip / runde"
|
|
||||||
value={s.avg_chip_per_round !== null ? s.avg_chip_per_round.toFixed(1).replace(".", ",") : "–"}
|
|
||||||
delta={<Delta current={s.avg_chip_per_round} previous={p.avg_chip_per_round} format={signed} goodDirection="down" />}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="Bunkerslag / runde"
|
|
||||||
value={s.avg_bunker_per_round !== null ? s.avg_bunker_per_round.toFixed(1).replace(".", ",") : "–"}
|
|
||||||
delta={<Delta current={s.avg_bunker_per_round} previous={p.avg_bunker_per_round} format={signed} goodDirection="down" />}
|
|
||||||
/>
|
|
||||||
<StatTile
|
|
||||||
label="Straffeslag / runde"
|
|
||||||
value={s.avg_penalty_per_round !== null ? s.avg_penalty_per_round.toFixed(1).replace(".", ",") : "–"}
|
|
||||||
delta={<Delta current={s.avg_penalty_per_round} previous={p.avg_penalty_per_round} format={signed} goodDirection="down" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{s.avg_anyway_per_round !== null && (
|
|
||||||
<Section title="Annet" icon={Wind}>
|
|
||||||
<StatTile
|
|
||||||
label="Anywayslag / runde"
|
|
||||||
value={s.avg_anyway_per_round.toFixed(1).replace(".", ",")}
|
|
||||||
delta={<Delta current={s.avg_anyway_per_round} previous={p.avg_anyway_per_round} format={signed} goodDirection="down" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})()
|
})()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue