diff --git a/075_tournament_hole_stats_parity.sql b/075_tournament_hole_stats_parity.sql new file mode 100644 index 0000000..6c1a918 --- /dev/null +++ b/075_tournament_hole_stats_parity.sql @@ -0,0 +1,52 @@ +-- ===================================================================== +-- TeeCup — Full statistikkdybde i org-turneringers hull-scoring (ADR-071, +-- 2026-08-15). Steg 1 av "spillerens per-hull-historikk"-funksjonen. +-- +-- `tournament_round_hole` har siden opprettelse (migrasjon 040) KUN hatt +-- `gross_strokes` -- ingen putts/utslag-/innspillretning/bunker-/ +-- straffeslag-detalj slik `round_hole` (frittstående runder) allerede +-- har. Denne migrasjonen gir turneringssiden samme feltsett, SAMME navn +-- og SAMME CHECK-betingelser som `round_hole` (022_round_stats_and_bag.sql/ +-- 062_round_hole_version.sql) -- en ren utvidelse, ingen eksisterende +-- format (Københavner/BBB/Flag/Eclectic) berøres, de leser fortsatt kun +-- `gross_strokes`. +-- +-- `version` (optimistisk låsing, ADR-057-mønster) er NY for denne +-- tabellen -- `tournament_round_hole.update_hole` har til nå vært rent +-- siste-skriver-vinner. DEFAULT 1, samme begrunnelse som migrasjon 062: +-- en fersk rad skal kunne treffes med expected_version=1 uten at +-- klienten må vite at "ubrukt" betyr noe annet. +-- +-- `tournament_participant.stat_level` (samme tre verdier, samme trygge +-- default 'strokes_only') styrer hvor mye UI/backend faktisk ber om -- +-- lagt på `tournament_participant` (tournament-bredt), ikke +-- `tournament_round_participant` (per runde), fordi en deltaker normalt +-- spiller flere runder i samme turnering og ikke skal måtte velge +-- statistikknivå på nytt hver gang. +-- ===================================================================== +\set ON_ERROR_STOP on + +ALTER TABLE tournament_round_hole + ADD COLUMN putts smallint + CHECK (putts IS NULL OR putts BETWEEN 0 AND 10), + ADD COLUMN club_off_tee text, + ADD COLUMN tee_shot_result text + CHECK (tee_shot_result IS NULL OR tee_shot_result IN ('fairway', 'left', 'right')), + ADD COLUMN approach_result text + CHECK (approach_result IS NULL OR approach_result IN ('hit', 'long', 'short', 'left', 'right')), + ADD COLUMN chip_count smallint + CHECK (chip_count IS NULL OR chip_count >= 0), + ADD COLUMN bunker_shot_count smallint + CHECK (bunker_shot_count IS NULL OR bunker_shot_count >= 0), + ADD COLUMN penalty_strokes smallint + CHECK (penalty_strokes IS NULL OR penalty_strokes >= 0), + ADD COLUMN anyway_strokes smallint + CHECK (anyway_strokes IS NULL OR anyway_strokes >= 0), + ADD COLUMN first_putt_distance_bucket text + CHECK (first_putt_distance_bucket IS NULL OR first_putt_distance_bucket IN ( + '<1m', '<2m', '<3m', '<5m', '<8m', '8m+' + )), + ADD COLUMN version integer NOT NULL DEFAULT 1; + +ALTER TABLE tournament_participant ADD COLUMN stat_level text NOT NULL DEFAULT 'strokes_only' + CHECK (stat_level IN ('strokes_only', 'strokes_and_putts', 'full')); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 453eeb0..d619375 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -7014,6 +7014,103 @@ teecup_frontend && up -d` -- begge containere startet rent. --- +## ADR-071: Full statistikkdybde i org-turneringers hull-scoring, paritet med frittstående runder ("Steg 1" av spillerens per-hull-historikk) (2026-08-15) + +Foranledning: bruker ba om å kunne se full statistikk basert på ALLE +tidligere ganger en spiller har spilt et gitt hull. Undersøkelse avdekket +at `tournament_round_hole` (org-turneringer) aldri har hatt noe utover +`gross_strokes` siden den ble opprettet (migrasjon 040) -- ingen putts/ +utslag-/innspillretning/bunker-/straffeslag-detalj, og ingen UI for det i +`individual-tournament-detail.tsx` sin `HoleGrid`. Historikk-funksjonen +kan derfor kun bli "full" for turneringssiden hvis den datadybden bygges +FØRST. Bekreftet med bruker (AskUserQuestion): begge datakilder skal +telle med, med full dybde i begge -- valgte det tyngste alternativet +fremfor "kun frittstående" eller "turnering kun med slagtall". + +**Migrasjon 075**: `tournament_round_hole` fikk nøyaktig samme nye felt +som `round_hole` allerede har (`putts, club_off_tee, tee_shot_result, +approach_result, chip_count, bunker_shot_count, penalty_strokes, +anyway_strokes, first_putt_distance_bucket`), pluss `version` (ny +optimistisk-lås-kolonne for denne tabellen -- endepunktet var til nå rent +siste-skriver-vinner, ulikt `round_hole` sitt ADR-057/migrasjon-062- +mønster). `tournament_participant` fikk `stat_level` (samme tre verdier +som `round_participant.stat_level`) -- lagt PÅ TOURNAMENT-NIVÅ (ikke per +runde), fordi en deltaker normalt spiller flere runder i samme turnering +og ikke skal måtte velge nivå på nytt hver gang. Trygg default +(`strokes_only`) -- ingen eksisterende turnering endrer oppførsel før +noen eksplisitt hever nivået. + +**Backend** (`individual_tournaments.py`): `HoleUpdate`/`RoundHoleOut` +utvidet feltnavn-for-feltnavn etter `rounds.py` sin ekvivalent. +`update_hole` sin `INSERT ... ON CONFLICT DO UPDATE` fikk en +`WHERE (expected_version IS NULL OR tournament_round_hole.version = +expected_version)`-betingelse på DO UPDATE-grenen -- MERK at dette er en +reell forskjell fra `round_hole` sitt mønster: der finnes raden alltid +fra rundestart (ren `UPDATE`), her opprettes raden først ved FØRSTE +score, så INSERT-grenen må være ubetinget (en fersk innsending skal +aldri kunne 409-blokkeres av en `expected_version` som ikke gir mening +ennå). `TournamentParticipantCreate/Update/Out` + `_TOURNAMENT_ +PARTICIPANT_COLUMNS` fikk `stat_level` som ren whitelist-tilføyelse +(samme mønster som migrasjon 074 sin `paid`/`comment`, ingen +handler-logikk-endring). `RoundParticipantOut` fikk `stat_level` speilet +inn (join via `tournament_participant`) slik at frontend vet hvilken +UI-vei å ta per deltaker. + +**Frontend**: `NumberPicker`/`ChoiceRow`/`DirectionCross`/`Stepper`/ +`WizardSection` (+ `golfTermForScore`-hjelperen) trukket UT av +`round-detail.tsx` til en ny delt fil `hole-stat-inputs.tsx` -- ren +mekanisk utrekking (ingen atferdsendring for `ScoringWizard`, bekreftet +med full `tsc`/vitest etterpå), nødvendig fordi begge scoringsflytene nå +trenger identiske trykkbaserte inputs (ALDRI dropdowns i selve +hull-registreringen -- egen, bevisst UI-regel for denne typen felt, +IKKE en generell "ingen select-elementer i appen"-regel: `class_id`/nye +`stat_level`-velgerne i organisatorens deltakerliste er vanlige +`` ved siden av den +eksisterende klasse-velgeren). + +**Bevisst avgrenset:** `ownBagClubs` sendes tom (`[]`) til +`HoleStatsSheet` -- "din egen kølle-bag" krever å vite om scoreren ER +spilleren selv (`player.user_id` mot innlogget bruker), som +`individual-tournament-detail.tsx` ikke allerede henter noe sted. +`ClubPicker` fungerer fint uten (faller tilbake til fritekst) -- en +convenience-nicety utelatt, ikke en mangel. + +**Verifisert:** `python3 -m py_compile` + full +`./scripts/run_backend_tests.sh` (64/64, 6 nye tester i +`test_tournament_hole_stats.py`: versjonsøkning, 409 ved stale version, +`expected_version=None` omgår sjekken, FØRSTE skriving lykkes uansett +feil `expected_version` (INSERT-grenen ubetinget), full feltrundtur, +`stat_level`-default+eksplisitt-verdi ved opprettelse). `tsc --noEmit` +rent + 45/45 vitest. Egen scratch-database + scratch `teecup_api` +(port 18005, live-montert kode) + lokal `next dev` (port 13005): to +deltakere (`strokes_only` og `full`) i samme runde -- bekreftet +`HoleStatsSheet` åpner og lagrer alle felt korrekt for `full`-deltakeren +(inkl. gjenåpning med forhåndsutfylte verdier), OG at `strokes_only`- +deltakerens opprinnelige inline-tallfelt fungerer helt uendret (ingen +regresjon). Lys+mørk bekreftet, inkl. retning/detaljer-seksjonene. +Scratch-stacken fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/ +`teecup_frontend` urørt. + +**Neste steg (Steg 2, egen ADR):** selve historikk-aggregeringen på +tvers av alle spilte runder/turneringer -- denne ADR-en leverer kun +datadybden Steg 2 er avhengig av. + +**Rullet ut 2026-08-15** -- migrasjon 075 kjørt mot ekte `teecup_db` som +`teeoff_admin`, etter eksplisitt bekreftelse fra bruker. `docker compose +build teecup_api teecup_frontend && up -d` -- begge containere startet +rent. + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0135e..3026669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11355,3 +11355,67 @@ Neste steg: migrasjon å rulle ut. **Rullet ut 2026-08-14** -- `docker compose build teecup_frontend && up -d`, etter eksplisitt bekreftelse fra bruker. Containeren startet rent. + +87. **Full statistikkdybde i org-turneringers hull-scoring, "Steg 1" av + spillerens per-hull-historikk (ADR-071) — 2026-08-15.** Bruker ba om + å se full statistikk basert på ALLE tidligere ganger en spiller har + spilt et gitt hull. `tournament_round_hole` har aldri hatt noe utover + `gross_strokes` (uendret siden migrasjon 040) -- historikk-funksjonen + kan derfor kun bli "full" for turneringssiden hvis denne datadybden + bygges først. Bekreftet med bruker: begge datakilder (frittstående + + turnering) skal telle med, med full dybde i begge. + + Migrasjon 075: `tournament_round_hole` fikk samme nye felt som + `round_hole` (putts, klubb, utslag-/innspillretning, chip/bunker/ + straffe-/anywayslag, puttavstand-bøtte) pluss en NY `version`-kolonne + (optimistisk lås -- endepunktet var til nå rent siste-skriver-vinner). + `tournament_participant` fikk `stat_level` (samme tre nivåer som + `round_participant`), lagt PÅ TURNERING-NIVÅ (ikke per runde) siden en + deltaker normalt spiller flere runder i samme turnering. Trygg + default (strokes_only) -- ingen eksisterende turnering endrer + oppførsel. + + Backend (`individual_tournaments.py`): `HoleUpdate`/`RoundHoleOut` + utvidet feltnavn-for-feltnavn etter `rounds.py`. `update_hole` sin + upsert fikk en versjonssjekk på DO UPDATE-grenen -- MERK reell + forskjell fra `round_hole`: raden opprettes først ved FØRSTE score + (ikke ved rundestart som `round_hole`), så INSERT-grenen er + ubetinget -- en fersk innsending 409-blokkeres aldri av en + `expected_version` som ennå ikke gir mening. `TournamentParticipant*` + + `RoundParticipantOut` fikk `stat_level` som ren whitelist- + tilføyelse, samme mønster som migrasjon 074. + + Frontend: `NumberPicker`/`ChoiceRow`/`DirectionCross`/`Stepper`/ + `WizardSection` trukket ut av `round-detail.tsx` til ny delt fil + `hole-stat-inputs.tsx` (ren mekanisk utrekking, ingen atferdsendring + for `ScoringWizard`) -- begge scoringsflytene trenger nå identiske + trykkbaserte inputs. Nytt `HoleStatsSheet` i + `individual-tournament-detail.tsx`: ETT skjermbilde (ikke en + flerstegs-veiviser -- HoleGrid har allerede valgt ett hull for én + deltaker). `HoleGrid` sin opprinnelige inline-tallfelt-redigering er + BEVISST UENDRET for `strokes_only`-deltakere. `stat_level` redigeres + via ny ` )} + {/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye + HoleGrid/HoleStatsSheet ber om ved scoring for DENNE + deltakeren, tournament-bredt (ikke per runde). */} + +
+ + Hull {hole.hole_number} · Par {hole.par} + + {playerName} +
+ + ) +} + function HoleGrid({ holes, holeConfig, + statLevel, + playerName, + strokesReceived, + ownBagClubs, onUpdate, + onUpdateFull, }: { holes: ApiHole[] holeConfig: ApiRound["hole_config"] + statLevel: "strokes_only" | "strokes_and_putts" | "full" + playerName: string + strokesReceived: (holeNumber: number) => number | null + ownBagClubs: string[] onUpdate: (holeNumber: number, grossStrokes: number) => Promise + onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise }) { const [editingHole, setEditingHole] = useState(null) const [draft, setDraft] = useState("") + // Full statistikkdybde (migrasjon 075, ADR-071): en annen deltaker- + // celle enn den enkle inline-tekstboksen under -- kun for statLevel != + // "strokes_only". HoleGrid sin egen strokes_only-vei (editingHole/ + // draft/openEditor/commit over) er BEVISST uendret, ingen regresjon. + const [sheetHole, setSheetHole] = useState(null) const numbers = holeNumbersFor(holeConfig) const byNumber = new Map(holes.map((h) => [h.hole_number, h])) @@ -3118,6 +3446,10 @@ function HoleGrid({ const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null function openEditor(holeNumber: number, current: number | null) { + if (statLevel !== "strokes_only") { + setSheetHole(holeNumber) + return + } setEditingHole(holeNumber) setDraft(current !== null ? String(current) : "") } @@ -3211,6 +3543,18 @@ function HoleGrid({ )} + + {sheetHole !== null && statLevel !== "strokes_only" && byNumber.get(sheetHole) && ( + onUpdateFull(sheetHole, patch)} + onClose={() => setSheetHole(null)} + /> + )} ) } diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 4491b23..98bdd40 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -17,10 +17,7 @@ import { RoundPageShell } from "@/components/round-page-shell" import { RoundMessages } from "@/components/round-messages" import type { FlagMapEntry } from "@/components/flag-map-overview" import { - ArrowDown, - ArrowLeft, ArrowRight, - ArrowUp, Check, ChevronDown, ChevronsUpDown, @@ -31,13 +28,11 @@ import { Crosshair, Flag, MapPin, - Minus, Plus, RefreshCw, Ruler, Search, Settings2, - Target, Trophy, Users, WifiOff, @@ -51,6 +46,14 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { cn } from "@/lib/utils" import { ClubPicker } from "@/components/teecup/club-picker" +import { + ChoiceRow, + DirectionCross, + golfTermForScore, + NumberPicker, + Stepper, + WizardSection, +} from "@/components/hole-stat-inputs" import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet" import { HoleTargetDistance } from "@/components/hole-target-distance" import { bearingDegrees } from "@/lib/geo" @@ -7283,17 +7286,6 @@ function MatchScorecardCell({ // konkurrentapps taltastatur der HVER knapp er merket kontekstuelt -- // fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller // dobbel bogey?" i hodet). Kun brukt for Slag-velgeren. -function golfTermForScore(n: number, par: number): string | null { - const diff = n - par - if (diff <= -3) return "Albatross" - if (diff === -2) return "Eagle" - if (diff === -1) return "Birdie" - if (diff === 0) return "Par" - if (diff === 1) return "Bogey" - if (diff === 2) return "Dobbel bogey" - return null -} - // --- Delt-ball-scorekort (ADR-039 Beslutning C) ----------------------------- // Samme grid-idé som ScorecardGrid, men rader = SIDER (ikke spillere) -- kun // ett tall per hull (ingen putt/netto-linje, en delt ball har ingen entydig @@ -7764,15 +7756,6 @@ function SideScoreWizard({ // Liten seksjonsoverskrift + hårfin divider, brukt til å dele detalj-steget // i tydelige grupper ("Retning" vs. "Detaljer per hull"). Rent presentasjon // -- ingen egen state, ingen ny farge (gjenbruker border/muted-tokens). -function WizardSection({ title, children }: { title: string; children: React.ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - // --- "Mer innhold under"-hint (2026-08-11) --------------------------------- // Legger en myk bunn-fade + nedoverpil over et scrollbart område SÅ LENGE det // finnes mer innhold under fold-linjen -- og skjuler seg av seg selv idet man @@ -7836,330 +7819,3 @@ function ScrollFade({ children, className }: { children: React.ReactNode; classN ) } -function NumberPicker({ - label, - value, - directValues, - expandValues, - expandLabel, - parValue, - showGolfTerms, - maxValue, - strokesReceived, - accent, - onChange, - readOnly, -}: { - label: string - value: number | null - directValues: number[] - expandValues: number[] - expandLabel: string - // Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt. - parValue?: number - // Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til - // parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt - // for Slag-velgeren, ikke Putter/Anywayslag. - showGolfTerms?: boolean - // Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige - // antall slag registrert på hullet) -- ingen vits i å tilby et tall som - // uansett ville vært selvmotsigende. - maxValue?: number - // Spillerens tildelte handicap-slag PÅ DETTE hullet (2026-08-11). Når satt - // (og showGolfTerms er på, dvs. Slag-velgeren), markeres knappen som - // tilsvarer spillerens PERSONLIGE netto par (rå par + mottatte slag) med en - // liten, diskret prikk -- tydelig forskjellig fra "valgt"-tilstanden, og de - // to kan opptre samtidig på samme knapp. - strokesReceived?: number | null - // Valgfri egen aksent for seksjonen (2026-08-11, brukes til å gi Putter- - // velgeren en distinkt visuell identitet så den ikke forveksles med Slag- - // velgeren). Rører IKKE selve tallknappene -- kun tittelrad/ikon. Gjenbruker - // eksisterende tokens. - accent?: "primary" | "orange" - onChange: (value: number) => void - readOnly: boolean -}) { - const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue)) - const cappedDirect = cap(directValues) - const cappedExpand = cap(expandValues) - - const valueInExpand = value !== null && cappedExpand.includes(value) - const [expanded, setExpanded] = useState(false) - const showExpanded = expanded || valueInExpand - - const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect - - // Spillerens netto par for hullet = rå par + tildelte slag. Kun beregnet - // når vi faktisk har begge (Slag-velgeren med kjent strokesReceived). - const netPar = - parValue !== undefined && strokesReceived !== null && strokesReceived !== undefined - ? parValue + strokesReceived - : null - - // Putter-velgeren får en egen aksent (oransje) og et eget ikon på - // tittelraden, så den ikke er til å forveksle med Slag-velgeren -- som - // ellers er nøyaktig samme rutenett. Kun tittelraden/venstre aksentbånd - // endres; selve tallknappene beholder den etablerte grønne "valgt"-stilen. - const isOrange = accent === "orange" - - return ( -
- - {isOrange && ( - - )} - {label} - - {/* Netto par-forklaring: gjør prikken selvforklarende uten å stole på - fargesyn alene (tilgjengelighet -- sol/utendørs). Vises kun når det - faktisk finnes en netto par-markør å forklare. */} - {netPar !== null && ( - - - )} - {/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater - for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */} -
- {visibleValues.map((n) => { - const selected = value === n - const isNetPar = netPar !== null && n === netPar - const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null - return ( - - ) - })} - {!showExpanded && !readOnly && cappedExpand.length > 0 && ( - - )} -
-
- ) -} - -// --- Retningskors (D-pad for Utslag / Innspill) ----------------------------- - -function DirButton({ - icon: Icon, - label, - selected, - onClick, - readOnly, -}: { - icon: typeof ArrowUp - label: string - selected: boolean - onClick: () => void - readOnly: boolean -}) { - return ( - - ) -} - -function DirectionCross({ - label, - value, - onChange, - readOnly, - variant, - centerLabel, -}: { - label: string - value: string | null - onChange: (value: string) => void - readOnly: boolean - // "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill) - variant: "horizontal" | "full" - centerLabel: string -}) { - const spacer =