Full statistikkdybde i org-turneringers hull-scoring (ADR-071)

tournament_round_hole hadde siden opprettelse (migrasjon 040) kun
gross_strokes -- ingen putts/utslag/innspill/bunker/straffeslag-detalj
slik frittstående runder (round_hole) allerede har. Migrasjon 075 gir
turneringssiden samme feltsett + en ny optimistisk-lås-kolonne (endepunktet
var til nå rent siste-skriver-vinner). tournament_participant.stat_level
styrer om HoleGrid åpner det uendrede inline-tallfeltet eller det nye
HoleStatsSheet. NumberPicker/ChoiceRow/DirectionCross/Stepper trukket ut
til en delt hole-stat-inputs.tsx for gjenbruk mellom begge scoringsflytene.

Dette er Steg 1 av to mot spillerens per-hull-historikk på tvers av alle
runder/turneringer -- se ADR-071/CHANGELOG for full detalj og verifisering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-15 06:32:17 +02:00
parent 41ae0afa15
commit 60ee6a7d16
8 changed files with 1195 additions and 375 deletions

View file

@ -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'));

View file

@ -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
`<select>`, samme presedens som eksisterende klasse-velger der).
Nytt `HoleStatsSheet` i `individual-tournament-detail.tsx` -- ETT
skjermbilde (IKKE en flerstegs-veiviser som `ScoringWizard`, siden
`HoleGrid` allerede har valgt ETT hull for ÉN allerede valgt deltaker,
ikke en hel spillerekkefølge å bla gjennom). `HoleGrid` sin egen
`editingHole`/`draft`-tilstand (enkelt inline-tallfelt) er BEVISST
UENDRET for `stat_level="strokes_only"`-deltakere -- kun `stat_level !=
"strokes_only"` åpner det nye sheet-et. `stat_level` redigeres i
"Deltakere"-listen under "Oppsett" (ny `<select>` 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) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -11355,3 +11355,67 @@ Neste steg:
migrasjon å rulle ut. **Rullet ut 2026-08-14** -- `docker compose migrasjon å rulle ut. **Rullet ut 2026-08-14** -- `docker compose
build teecup_frontend && up -d`, etter eksplisitt bekreftelse fra build teecup_frontend && up -d`, etter eksplisitt bekreftelse fra
bruker. Containeren startet rent. 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 `<select>` i deltakerlisten under Oppsett.
**Bevisst avgrenset:** `ownBagClubs` sendes tom -- "din egen
kølle-bag" krever å vite om scoreren ER spilleren selv, som denne
filen ikke allerede sporer. `ClubPicker` faller tilbake til fritekst
uten den, en utelatt nicety, 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`). `tsc --noEmit` rent + 45/45 vitest.
Scratch-database + scratch `teecup_api` (port 18005, live-montert
kode) + lokal `next dev` (port 13005): to deltakere (strokes_only og
full) i samme runde -- HoleStatsSheet åpner/lagrer/gjenåpner med
forhåndsutfylte verdier korrekt for full-deltakeren, strokes_only-
deltakerens opprinnelige inline-felt uendret (ingen regresjon).
Lys+mørk bekreftet. 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.
**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.

View file

@ -41,6 +41,8 @@ turneringer).
from __future__ import annotations from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -219,17 +221,26 @@ async def delete_round(
# Turnering-deltakere (tournament_participant -- flatt felt, intet lag) # Turnering-deltakere (tournament_participant -- flatt felt, intet lag)
# ===================================================================== # =====================================================================
# Statistikknivå (migrasjon 075, ADR-071) -- samme tre verdier og samme
# semantikk som round_participant.stat_level (rounds.py sin StatLevel):
# hvor mye detalj UI-et ber om utover selve bruttoslaget. Tournament-bredt
# (ikke per runde) -- en deltaker spiller normalt flere runder i samme
# turnering og skal ikke måtte velge på nytt hver gang.
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
class TournamentParticipantCreate(BaseModel): class TournamentParticipantCreate(BaseModel):
player_id: str player_id: str
# Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql. # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
class_id: str | None = None class_id: str | None = None
stat_level: StatLevel = "strokes_only"
class TournamentParticipantUpdate(BaseModel): class TournamentParticipantUpdate(BaseModel):
"""Eneste redigerbare felt i dag er klassetilhørighet -- exclude_unset, """exclude_unset, samme PATCH-semantikk som resten av appen."""
samme PATCH-semantikk som resten av appen."""
class_id: str | None = None class_id: str | None = None
stat_level: StatLevel | None = None
class TournamentParticipantOut(BaseModel): class TournamentParticipantOut(BaseModel):
@ -239,13 +250,14 @@ class TournamentParticipantOut(BaseModel):
handicap_index_snapshot: float | None handicap_index_snapshot: float | None
class_id: str | None class_id: str | None
class_name: str | None class_name: str | None
stat_level: str
_TOURNAMENT_PARTICIPANT_COLUMNS = """ _TOURNAMENT_PARTICIPANT_COLUMNS = """
tp.id::text AS id, tp.player_id::text AS player_id, tp.id::text AS id, tp.player_id::text AS player_id,
p.display_name AS player_name, p.display_name AS player_name,
tp.handicap_index_snapshot::float AS handicap_index_snapshot, tp.handicap_index_snapshot::float AS handicap_index_snapshot,
tp.class_id::text AS class_id, tc.name AS class_name tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level
""" """
@ -269,9 +281,9 @@ async def add_tournament_participant(
f""" f"""
WITH inserted AS ( WITH inserted AS (
INSERT INTO tournament_participant INSERT INTO tournament_participant
(organization_id, tournament_id, player_id, handicap_index_snapshot, class_id) (organization_id, tournament_id, player_id, handicap_index_snapshot, class_id, stat_level)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, player_id, handicap_index_snapshot, class_id RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
) )
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
FROM inserted tp FROM inserted tp
@ -283,6 +295,7 @@ async def add_tournament_participant(
body.player_id, body.player_id,
player["handicap_index"], player["handicap_index"],
body.class_id, body.class_id,
body.stat_level,
) )
return TournamentParticipantOut(**dict(row)) return TournamentParticipantOut(**dict(row))
@ -338,7 +351,7 @@ async def update_tournament_participant(
WITH updated AS ( WITH updated AS (
UPDATE tournament_participant SET {', '.join(set_clauses)} UPDATE tournament_participant SET {', '.join(set_clauses)}
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
RETURNING id, player_id, handicap_index_snapshot, class_id RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
) )
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
FROM updated tp FROM updated tp
@ -388,6 +401,10 @@ class RoundParticipantOut(BaseModel):
tee_name: str tee_name: str
course_handicap: int | None course_handicap: int | None
playing_handicap: int | None playing_handicap: int | None
# Statistikknivå (migrasjon 075, ADR-071) -- tournament-bredt (tp.stat_
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
stat_level: str
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None: async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
@ -520,7 +537,7 @@ async def add_round_participant(
""" """
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
trp.course_handicap, trp.playing_handicap trp.course_handicap, trp.playing_handicap, tp.stat_level
FROM tournament_round_participant trp FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id JOIN player p ON p.id = tp.player_id
@ -546,7 +563,7 @@ async def list_round_participants(
""" """
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id, SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name, p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
trp.course_handicap, trp.playing_handicap trp.course_handicap, trp.playing_handicap, tp.stat_level
FROM tournament_round_participant trp FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id JOIN player p ON p.id = tp.player_id
@ -585,6 +602,20 @@ async def remove_round_participant(
class HoleUpdate(BaseModel): class HoleUpdate(BaseModel):
gross_strokes: int = Field(ge=1, le=20) gross_strokes: int = Field(ge=1, le=20)
# Full statistikkdybde (migrasjon 075, ADR-071) -- speiler rounds.py sin
# HoleUpdate feltnavn-for-feltnavn (samme CHECK-betingelser i skjemaet).
putts: int | None = Field(default=None, ge=0, le=10)
club_off_tee: str | None = Field(default=None, max_length=50)
tee_shot_result: Literal["fairway", "left", "right"] | None = None
approach_result: Literal["hit", "long", "short", "left", "right"] | None = None
chip_count: int | None = Field(default=None, ge=0)
bunker_shot_count: int | None = Field(default=None, ge=0)
penalty_strokes: int | None = Field(default=None, ge=0)
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
anyway_strokes: int | None = Field(default=None, ge=0)
# Optimistisk versjonssjekk (ADR-057-mønster, ny for denne tabellen).
# None = hopp over sjekken (samme bakoverkompatible mønster som rounds.py).
expected_version: int | None = None
class RoundHoleOut(BaseModel): class RoundHoleOut(BaseModel):
@ -592,6 +623,16 @@ class RoundHoleOut(BaseModel):
par: int par: int
stroke_index: int stroke_index: int
gross_strokes: int | None gross_strokes: int | None
putts: int | None
club_off_tee: str | None
tee_shot_result: str | None
approach_result: str | None
chip_count: int | None
bunker_shot_count: int | None
penalty_strokes: int | None
first_putt_distance_bucket: str | None
anyway_strokes: int | None
version: int
strokes_received: int | None strokes_received: int | None
@ -935,18 +976,50 @@ async def update_hole(
400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret." 400, "VALIDATION_FAILED", "Banen mangler hull-data for dette hullnummeret."
) )
await conn.execute( # Optimistisk versjonssjekk (ADR-057-mønster): WHERE-betingelsen på
# DO UPDATE-grenen gjelder KUN når raden allerede finnes (konflikt)
# -- første innsending av et hull treffer alltid INSERT-grenen og
# skrives uansett expected_version, samme som round_hole sitt
# migrasjon-062-mønster forutsetter (raden "finnes" fra scratch der
# fordi alle 18 opprettes ved rundestart -- her opprettes den først
# ved første score, så INSERT-grenen MÅ være ubetinget).
updated_row = await conn.fetchrow(
""" """
INSERT INTO tournament_round_hole INSERT INTO tournament_round_hole
(organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at) (organization_id, tournament_round_participant_id, hole_number, gross_strokes,
VALUES ($1, $2, $3, $4, now()) putts, club_off_tee, tee_shot_result, approach_result, chip_count,
bunker_shot_count, penalty_strokes, first_putt_distance_bucket, anyway_strokes,
version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 1, now())
ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET ON CONFLICT (tournament_round_participant_id, hole_number) DO UPDATE SET
gross_strokes = $4, updated_at = now() gross_strokes = $4, putts = $5, club_off_tee = $6, tee_shot_result = $7,
approach_result = $8, chip_count = $9, bunker_shot_count = $10,
penalty_strokes = $11, first_putt_distance_bucket = $12, anyway_strokes = $13,
version = tournament_round_hole.version + 1, updated_at = now()
WHERE ($14::int IS NULL OR tournament_round_hole.version = $14::int)
RETURNING gross_strokes, putts, club_off_tee, tee_shot_result, approach_result,
chip_count, bunker_shot_count, penalty_strokes, first_putt_distance_bucket,
anyway_strokes, version
""", """,
organization_id, organization_id,
round_participant_id, round_participant_id,
hole_number, hole_number,
body.gross_strokes, body.gross_strokes,
body.putts,
body.club_off_tee,
body.tee_shot_result,
body.approach_result,
body.chip_count,
body.bunker_shot_count,
body.penalty_strokes,
body.first_putt_distance_bucket,
body.anyway_strokes,
body.expected_version,
)
if updated_row is None:
raise app_error(
409, "STALE_VERSION",
"Noen andre har endret dette hullet i mellomtiden. Laster inn siste versjon.",
) )
await _recompute_round_score(conn, round_id, round_participant_id, organization_id) await _recompute_round_score(conn, round_id, round_participant_id, organization_id)
if round_row["scoring_method"] == "copenhagen": if round_row["scoring_method"] == "copenhagen":
@ -981,8 +1054,8 @@ async def update_hole(
hole_number=hole_number, hole_number=hole_number,
par=hole["par"], par=hole["par"],
stroke_index=hole["stroke_index"], stroke_index=hole["stroke_index"],
gross_strokes=body.gross_strokes,
strokes_received=strokes_received, strokes_received=strokes_received,
**dict(updated_row),
) )
@ -1010,7 +1083,11 @@ async def list_round_participant_holes(
) )
holes = await conn.fetch( holes = await conn.fetch(
""" """
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes,
trh.putts, trh.club_off_tee, trh.tee_shot_result, trh.approach_result,
trh.chip_count, trh.bunker_shot_count, trh.penalty_strokes,
trh.first_putt_distance_bucket, trh.anyway_strokes,
COALESCE(trh.version, 1) AS version
FROM hole h FROM hole h
LEFT JOIN tournament_round_hole trh LEFT JOIN tournament_round_hole trh
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
@ -1028,13 +1105,7 @@ async def list_round_participant_holes(
else [None] * len(holes) else [None] * len(holes)
) )
return [ return [
RoundHoleOut( RoundHoleOut(strokes_received=a, **dict(h))
hole_number=h["hole_number"],
par=h["par"],
stroke_index=h["stroke_index"],
gross_strokes=h["gross_strokes"],
strokes_received=a,
)
for h, a in zip(holes, allocation) for h, a in zip(holes, allocation)
] ]

View file

@ -0,0 +1,350 @@
"use client"
// Delte, utelukkende trykkbaserte input-primitiver for hull-for-hull-
// statistikkregistrering (ALDRI dropdowns) -- trukket ut av round-detail.tsx
// sin ScoringWizard (frittstående runder) 2026-08-15 (ADR-071) for gjenbruk
// i org-turneringers nye HoleStatsSheet, som nå har samme statistikkdybde
// (migrasjon 075). Ren UI, ingen datahenting/state utover det som sendes inn.
import { useState, type ReactNode } from "react"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Flag, Minus, Plus, Target } from "lucide-react"
import { cn } from "@/lib/utils"
export 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
}
export 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. 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 (brukes til å gi Putter-velgeren en
// distinkt visuell identitet så den ikke forveksles med Slag-velgeren).
// Rører IKKE selve tallknappene -- kun tittelrad/ikon.
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 (
<div className={cn("flex flex-col gap-2", isOrange && "rounded-2xl border-l-4 border-brand-orange bg-brand-orange/5 py-3 pl-3 pr-1")}>
<span className="flex items-center gap-2 text-base font-semibold text-foreground">
{isOrange && (
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-lg bg-brand-orange text-brand-orange-foreground">
<Flag className="size-4" strokeWidth={2.5} />
</span>
)}
{label}
</span>
{/* 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 && (
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span aria-hidden="true" className="size-2 rounded-full bg-brand-orange ring-2 ring-brand-orange/25" />
Ditt netto par ({netPar})
</span>
)}
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
for bruk utendørs, ofte med sol skjermen. */}
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{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 (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={term ? `${label} ${n}, ${term}${isNetPar ? ", ditt netto par" : ""}` : `${label} ${n}${isNetPar ? ", ditt netto par" : ""}`}
aria-pressed={selected}
className={cn(
"relative flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
// Netto par-knapp som IKKE er valgt: en subtil oransje ring +
// hint-bakgrunn, tydelig annerledes enn den fylte grønne
// "valgt"-tilstanden. Prikken øverst til høyre er signalet som
// holder også når knappen ER valgt (grønn fyll under).
isNetPar && !selected && "border-brand-orange/60 bg-brand-orange/5",
readOnly && !selected && "opacity-40",
)}
>
{isNetPar && (
<span
aria-hidden="true"
className={cn(
"absolute right-1.5 top-1.5 size-2.5 rounded-full bg-brand-orange",
// Kontrast-ring så prikken leses uansett om knappen er
// grønn (valgt) eller lys (uvalgt).
selected ? "ring-2 ring-primary" : "ring-2 ring-card",
)}
/>
)}
<span>{n}</span>
{term && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
{term}
</span>
)}
</button>
)
})}
{!showExpanded && !readOnly && cappedExpand.length > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
>
{expandLabel}
</button>
)}
</div>
</div>
)
}
// --- 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 (
<button
type="button"
disabled={readOnly}
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<Icon aria-hidden="true" className="size-6" />
<span className="text-sm font-bold">{label}</span>
</button>
)
}
export 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 = <div aria-hidden="true" />
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
{spacer}
</>
)}
<DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
<DirButton
icon={Target}
label={centerLabel}
selected={value === (variant === "full" ? "hit" : "fairway")}
onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
readOnly={readOnly}
/>
<DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
{spacer}
</>
)}
</div>
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
export function ChoiceRow({
label,
options,
value,
onChange,
readOnly,
}: {
label: string
options: { value: string; label: string }[]
value: string | null
onChange: (value: string) => void
readOnly: boolean
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid grid-cols-3 gap-2">
{options.map((opt) => {
const selected = value === opt.value
return (
<button
key={opt.value}
type="button"
disabled={readOnly}
onClick={() => onChange(opt.value)}
aria-pressed={selected}
className={cn(
"flex min-h-12 items-center justify-center rounded-2xl border px-2 text-base font-bold transition-colors disabled:opacity-100",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
// --- Stepper (+/-) ---------------------------------------------------------
export function Stepper({
label,
value,
onChange,
readOnly,
max,
}: {
label: string
value: number
onChange: (value: number) => void
readOnly: boolean
// Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
// umulig å ha chippet flere ganger enn totalt antall slag.
max?: number
}) {
const atMax = max !== undefined && value >= max
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card p-1.5">
<button
type="button"
disabled={readOnly || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
aria-label={`Færre ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-5" />
</button>
<span className="min-w-8 text-center text-xl font-extrabold tabular-nums text-foreground">{value}</span>
<button
type="button"
disabled={readOnly || atMax}
onClick={() => onChange(value + 1)}
aria-label={`Flere ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-5" />
</button>
</div>
</div>
)
}
export function WizardSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-4 border-t border-border pt-5 first:border-t-0 first:pt-0">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}

View file

@ -43,6 +43,14 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import {
ChoiceRow,
DirectionCross,
NumberPicker,
Stepper,
WizardSection,
} from "@/components/hole-stat-inputs"
import { ClubPicker } from "@/components/teecup/club-picker"
import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { TournamentPresentationPanel } from "@/components/tournament-presentation"
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
@ -104,6 +112,9 @@ type ApiParticipant = {
handicap_index_snapshot: number | null handicap_index_snapshot: number | null
class_id: string | null class_id: string | null
class_name: string | null class_name: string | null
// Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/
// HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde).
stat_level: "strokes_only" | "strokes_and_putts" | "full"
} }
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt // Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
@ -140,6 +151,10 @@ type ApiRoundParticipant = {
tee_name: string tee_name: string
course_handicap: number | null course_handicap: number | null
playing_handicap: number | null playing_handicap: number | null
// Statistikknivå (migrasjon 075, ADR-071) -- "strokes_only" /
// "strokes_and_putts" / "full", styrer om HoleGrid sin celle åpner det
// enkle inline-tallfeltet (uendret oppførsel) eller HoleStatsSheet.
stat_level: "strokes_only" | "strokes_and_putts" | "full"
} }
type ApiHole = { type ApiHole = {
@ -147,6 +162,20 @@ type ApiHole = {
par: number par: number
stroke_index: number stroke_index: number
gross_strokes: number | null gross_strokes: number | null
// Full statistikkdybde (migrasjon 075, ADR-071) -- speiler round_hole
// (frittstående runder), styrt av deltakerens stat_level. Alltid til
// stede i responsen (null når ikke registrert), uansett stat_level --
// frontend avgjør selv hva som faktisk VISES/redigeres.
putts: number | null
club_off_tee: string | null
tee_shot_result: string | null
approach_result: string | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: string | null
anyway_strokes: number | null
version: number
strokes_received: number | null strokes_received: number | null
} }
@ -500,6 +529,25 @@ export function IndividualTournamentDetail({
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
} }
async function setParticipantStatLevel(
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stat_level: statLevel }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å endre statistikknivå."))
return
}
const updated: ApiParticipant = await res.json()
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
}
async function createClass(name: string, defaultTeeId: string | null) { async function createClass(name: string, defaultTeeId: string | null) {
setError(null) setError(null)
const res = await fetch(`${base}/classes`, { const res = await fetch(`${base}/classes`, {
@ -692,6 +740,7 @@ export function IndividualTournamentDetail({
onCreateClass={createClass} onCreateClass={createClass}
onDeleteClass={deleteClass} onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass} onSetParticipantClass={setParticipantClass}
onSetParticipantStatLevel={setParticipantStatLevel}
onError={setError} onError={setError}
/> />
)} )}
@ -763,6 +812,7 @@ function SetupTab({
onCreateClass, onCreateClass,
onDeleteClass, onDeleteClass,
onSetParticipantClass, onSetParticipantClass,
onSetParticipantStatLevel,
onError, onError,
}: { }: {
base: string base: string
@ -785,6 +835,10 @@ function SetupTab({
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void> onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
onDeleteClass: (id: string) => Promise<void> onDeleteClass: (id: string) => Promise<void>
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void> onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
onSetParticipantStatLevel: (
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void>
onError: (message: string) => void onError: (message: string) => void
}) { }) {
const [pool, setPool] = useState<ApiPlayer[]>([]) const [pool, setPool] = useState<ApiPlayer[]>([])
@ -889,6 +943,7 @@ function SetupTab({
onAddNewPlayer={onAddNewPlayer} onAddNewPlayer={onAddNewPlayer}
onRemoveParticipant={onRemoveParticipant} onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass} onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
/> />
</div> </div>
) )
@ -1049,6 +1104,12 @@ function ClassesCard({
) )
} }
const STAT_LEVEL_LABELS: Record<string, string> = {
strokes_only: "Kun slag",
strokes_and_putts: "Slag og putter",
full: "All statistikk",
}
function ParticipantsCard({ function ParticipantsCard({
participants, participants,
pool, pool,
@ -1057,6 +1118,7 @@ function ParticipantsCard({
onAddNewPlayer, onAddNewPlayer,
onRemoveParticipant, onRemoveParticipant,
onSetClass, onSetClass,
onSetStatLevel,
}: { }: {
participants: ApiParticipant[] participants: ApiParticipant[]
pool: ApiPlayer[] pool: ApiPlayer[]
@ -1065,6 +1127,10 @@ function ParticipantsCard({
onAddNewPlayer: (input: NewPlayerInput) => Promise<void> onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onRemoveParticipant: (id: string) => Promise<void> onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => Promise<void> onSetClass: (participantId: string, classId: string | null) => Promise<void>
onSetStatLevel: (
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void>
}) { }) {
return ( return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"> <section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
@ -1105,6 +1171,23 @@ function ParticipantsCard({
))} ))}
</select> </select>
)} )}
{/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye
HoleGrid/HoleStatsSheet ber om ved scoring for DENNE
deltakeren, tournament-bredt (ikke per runde). */}
<select
value={p.stat_level}
onChange={(e) =>
onSetStatLevel(p.id, e.target.value as "strokes_only" | "strokes_and_putts" | "full")
}
aria-label={`Statistikknivå for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
{Object.entries(STAT_LEVEL_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -2118,6 +2201,26 @@ function ScoreTab({
setRefreshTick((n) => n + 1) setRefreshTick((n) => n + 1)
} }
// Full statistikkdybde (migrasjon 075, ADR-071) -- samme endepunkt som
// updateHole over, men med hele HolePatch-kroppen fra HoleStatsSheet i
// stedet for kun gross_strokes. Kaster videre ved feil (409 STALE_VERSION
// inkludert) -- HoleStatsSheet fanger og viser en generisk feilmelding,
// beholder brukerens utfylte skjema i stedet for å lukke sheet-et blindt.
async function updateHoleFull(holeNumber: number, patch: HolePatch) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) {
throw new Error("Klarte ikke å lagre.")
}
const updated: ApiHole = await res.json()
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
setRefreshTick((n) => n + 1)
}
if (rounds.length === 0) { if (rounds.length === 0) {
return ( return (
<div className="rounded-2xl border border-dashed border-border p-6 text-center"> <div className="rounded-2xl border border-dashed border-border p-6 text-center">
@ -2176,7 +2279,16 @@ function ScoreTab({
</div> </div>
{round && holes && ( {round && holes && (
<HoleGrid holes={holes} holeConfig={round.hole_config} onUpdate={updateHole} /> <HoleGrid
holes={holes}
holeConfig={round.hole_config}
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"}
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
ownBagClubs={[]}
onUpdate={updateHole}
onUpdateFull={updateHoleFull}
/>
)} )}
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon, {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
@ -3099,17 +3211,233 @@ function scoreMarkClasses(kind: ScoreClass): string {
} }
} }
// --- Full statistikkregistrering per hull (migrasjon 075, ADR-071) --------
// Enkelt-skjerm (IKKE en flerstegs-veiviser som ScoringWizard i round-
// detail.tsx -- HoleGrid åpner ETT hull om gangen for ÉN allerede valgt
// deltaker, ikke en hel spillerekkefølge, så et enkelt skjermbilde med
// alle aktuelle felt gruppert er tilstrekkelig her). Viser kun feltene
// deltakerens stat_level faktisk ber om.
type HolePatch = {
gross_strokes: number
putts?: number | null
club_off_tee?: string | null
tee_shot_result?: string | null
approach_result?: string | null
chip_count?: number | null
bunker_shot_count?: number | null
penalty_strokes?: number | null
first_putt_distance_bucket?: string | null
anyway_strokes?: number | null
expected_version?: number | null
}
function HoleStatsSheet({
hole,
playerName,
statLevel,
strokesReceived,
ownBagClubs,
onSave,
onClose,
}: {
hole: ApiHole
playerName: string
statLevel: "strokes_and_putts" | "full"
strokesReceived: number | null
ownBagClubs: string[]
onSave: (patch: HolePatch) => Promise<void>
onClose: () => void
}) {
const [strokes, setStrokes] = useState<number | null>(hole.gross_strokes)
const [putts, setPutts] = useState<number | null>(hole.putts)
const [puttBucket, setPuttBucket] = useState<string | null>(hole.first_putt_distance_bucket)
const [club, setClub] = useState<string | null>(hole.club_off_tee)
const [teeShot, setTeeShot] = useState<string | null>(hole.tee_shot_result)
const [approach, setApproach] = useState<string | null>(hole.approach_result)
const [chip, setChip] = useState(hole.chip_count ?? 0)
const [bunker, setBunker] = useState(hole.bunker_shot_count ?? 0)
const [penalty, setPenalty] = useState(hole.penalty_strokes ?? 0)
const [anywayStrokes, setAnywayStrokes] = useState(hole.anyway_strokes ?? 0)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function save() {
if (strokes === null) {
setError("Registrer antall slag først.")
return
}
setSaving(true)
setError(null)
try {
await onSave({
gross_strokes: strokes,
putts,
first_putt_distance_bucket: puttBucket,
club_off_tee: club,
tee_shot_result: teeShot,
approach_result: approach,
chip_count: chip,
bunker_shot_count: bunker,
penalty_strokes: penalty,
anyway_strokes: anywayStrokes,
expected_version: hole.version,
})
onClose()
} catch {
setError("Klarte ikke å lagre. Prøv igjen.")
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
>
<X aria-hidden="true" className="size-5" />
</button>
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
<span className="truncate text-base font-extrabold text-foreground">
Hull {hole.hole_number} · Par {hole.par}
</span>
<span className="truncate text-sm font-semibold text-muted-foreground">{playerName}</span>
</div>
<div className="size-11 shrink-0" aria-hidden="true" />
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
<NumberPicker
label="Slag"
value={strokes}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+"
parValue={hole.par}
showGolfTerms
strokesReceived={strokesReceived}
onChange={setStrokes}
readOnly={false}
/>
<NumberPicker
label="Putter"
value={putts}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={strokes ?? undefined}
accent="orange"
onChange={setPutts}
readOnly={false}
/>
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "0-1m" },
{ value: "<2m", label: "1-2m" },
{ value: "<3m", label: "2-3m" },
{ value: "<5m", label: "3-5m" },
{ value: "<8m", label: "5-8m" },
{ value: "8m+", label: "8m+" },
]}
value={puttBucket}
onChange={setPuttBucket}
readOnly={false}
/>
{statLevel === "full" && (
<>
<ClubPicker
label="Kølle brukt ved utslaget"
value={club ?? ""}
onChange={setClub}
ownBagClubs={ownBagClubs}
readOnly={false}
/>
<WizardSection title="Retning">
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={teeShot}
onChange={setTeeShot}
readOnly={false}
/>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
value={approach}
onChange={setApproach}
readOnly={false}
/>
</WizardSection>
<WizardSection title="Detaljer per hull">
<div className="grid grid-cols-1 gap-3">
<Stepper label="Chip" value={chip} onChange={setChip} readOnly={false} max={strokes ?? undefined} />
<Stepper label="Bunker" value={bunker} onChange={setBunker} readOnly={false} max={strokes ?? undefined} />
<Stepper label="Straffeslag" value={penalty} onChange={setPenalty} readOnly={false} max={strokes ?? undefined} />
<Stepper label="Anywayslag" value={anywayStrokes} onChange={setAnywayStrokes} readOnly={false} max={strokes ?? undefined} />
</div>
</WizardSection>
</>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
</main>
<footer className="shrink-0 border-t border-border p-4">
<Button
type="button"
disabled={saving || strokes === null}
onClick={save}
className="h-14 w-full rounded-2xl text-base font-bold disabled:opacity-40"
>
{saving ? "Lagrer…" : "Lagre"}
</Button>
</footer>
</div>
)
}
function HoleGrid({ function HoleGrid({
holes, holes,
holeConfig, holeConfig,
statLevel,
playerName,
strokesReceived,
ownBagClubs,
onUpdate, onUpdate,
onUpdateFull,
}: { }: {
holes: ApiHole[] holes: ApiHole[]
holeConfig: ApiRound["hole_config"] 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<void> onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
}) { }) {
const [editingHole, setEditingHole] = useState<number | null>(null) const [editingHole, setEditingHole] = useState<number | null>(null)
const [draft, setDraft] = useState("") 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<number | null>(null)
const numbers = holeNumbersFor(holeConfig) const numbers = holeNumbersFor(holeConfig)
const byNumber = new Map(holes.map((h) => [h.hole_number, h])) 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 const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null
function openEditor(holeNumber: number, current: number | null) { function openEditor(holeNumber: number, current: number | null) {
if (statLevel !== "strokes_only") {
setSheetHole(holeNumber)
return
}
setEditingHole(holeNumber) setEditingHole(holeNumber)
setDraft(current !== null ? String(current) : "") setDraft(current !== null ? String(current) : "")
} }
@ -3211,6 +3543,18 @@ function HoleGrid({
</span> </span>
</div> </div>
)} )}
{sheetHole !== null && statLevel !== "strokes_only" && byNumber.get(sheetHole) && (
<HoleStatsSheet
hole={byNumber.get(sheetHole)!}
playerName={playerName}
statLevel={statLevel}
strokesReceived={strokesReceived(sheetHole)}
ownBagClubs={ownBagClubs}
onSave={(patch) => onUpdateFull(sheetHole, patch)}
onClose={() => setSheetHole(null)}
/>
)}
</div> </div>
) )
} }

View file

@ -17,10 +17,7 @@ import { RoundPageShell } from "@/components/round-page-shell"
import { RoundMessages } from "@/components/round-messages" import { RoundMessages } from "@/components/round-messages"
import type { FlagMapEntry } from "@/components/flag-map-overview" import type { FlagMapEntry } from "@/components/flag-map-overview"
import { import {
ArrowDown,
ArrowLeft,
ArrowRight, ArrowRight,
ArrowUp,
Check, Check,
ChevronDown, ChevronDown,
ChevronsUpDown, ChevronsUpDown,
@ -31,13 +28,11 @@ import {
Crosshair, Crosshair,
Flag, Flag,
MapPin, MapPin,
Minus,
Plus, Plus,
RefreshCw, RefreshCw,
Ruler, Ruler,
Search, Search,
Settings2, Settings2,
Target,
Trophy, Trophy,
Users, Users,
WifiOff, WifiOff,
@ -51,6 +46,14 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ClubPicker } from "@/components/teecup/club-picker" 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 { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { HoleTargetDistance } from "@/components/hole-target-distance" import { HoleTargetDistance } from "@/components/hole-target-distance"
import { bearingDegrees } from "@/lib/geo" import { bearingDegrees } from "@/lib/geo"
@ -7283,17 +7286,6 @@ function MatchScorecardCell({
// konkurrentapps taltastatur der HVER knapp er merket kontekstuelt -- // konkurrentapps taltastatur der HVER knapp er merket kontekstuelt --
// fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller // fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller
// dobbel bogey?" i hodet). Kun brukt for Slag-velgeren. // 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) ----------------------------- // --- Delt-ball-scorekort (ADR-039 Beslutning C) -----------------------------
// Samme grid-idé som ScorecardGrid, men rader = SIDER (ikke spillere) -- kun // 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 // 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 // Liten seksjonsoverskrift + hårfin divider, brukt til å dele detalj-steget
// i tydelige grupper ("Retning" vs. "Detaljer per hull"). Rent presentasjon // i tydelige grupper ("Retning" vs. "Detaljer per hull"). Rent presentasjon
// -- ingen egen state, ingen ny farge (gjenbruker border/muted-tokens). // -- ingen egen state, ingen ny farge (gjenbruker border/muted-tokens).
function WizardSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-4 border-t border-border pt-5 first:border-t-0 first:pt-0">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
// --- "Mer innhold under"-hint (2026-08-11) --------------------------------- // --- "Mer innhold under"-hint (2026-08-11) ---------------------------------
// Legger en myk bunn-fade + nedoverpil over et scrollbart område SÅ LENGE det // 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 // 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 (
<div className={cn("flex flex-col gap-2", isOrange && "rounded-2xl border-l-4 border-brand-orange bg-brand-orange/5 py-3 pl-3 pr-1")}>
<span className="flex items-center gap-2 text-base font-semibold text-foreground">
{isOrange && (
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-lg bg-brand-orange text-brand-orange-foreground">
<Flag className="size-4" strokeWidth={2.5} />
</span>
)}
{label}
</span>
{/* 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 && (
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span aria-hidden="true" className="size-2 rounded-full bg-brand-orange ring-2 ring-brand-orange/25" />
Ditt netto par ({netPar})
</span>
)}
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
for bruk utendørs, ofte med sol skjermen (V0-runde 2026-07-24). */}
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{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 (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={term ? `${label} ${n}, ${term}${isNetPar ? ", ditt netto par" : ""}` : `${label} ${n}${isNetPar ? ", ditt netto par" : ""}`}
aria-pressed={selected}
className={cn(
"relative flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
// Netto par-knapp som IKKE er valgt: en subtil oransje ring +
// hint-bakgrunn, tydelig annerledes enn den fylte grønne
// "valgt"-tilstanden. Prikken øverst til høyre er signalet som
// holder også når knappen ER valgt (grønn fyll under).
isNetPar && !selected && "border-brand-orange/60 bg-brand-orange/5",
readOnly && !selected && "opacity-40",
)}
>
{isNetPar && (
<span
aria-hidden="true"
className={cn(
"absolute right-1.5 top-1.5 size-2.5 rounded-full bg-brand-orange",
// Kontrast-ring så prikken leses uansett om knappen er
// grønn (valgt) eller lys (uvalgt).
selected ? "ring-2 ring-primary" : "ring-2 ring-card",
)}
/>
)}
<span>{n}</span>
{term && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
{term}
</span>
)}
</button>
)
})}
{!showExpanded && !readOnly && cappedExpand.length > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
>
{expandLabel}
</button>
)}
</div>
</div>
)
}
// --- 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 (
<button
type="button"
disabled={readOnly}
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<Icon aria-hidden="true" className="size-6" />
<span className="text-sm font-bold">{label}</span>
</button>
)
}
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 = <div aria-hidden="true" />
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
{spacer}
</>
)}
<DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
<DirButton
icon={Target}
label={centerLabel}
selected={value === (variant === "full" ? "hit" : "fairway")}
onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
readOnly={readOnly}
/>
<DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
{spacer}
</>
)}
</div>
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
function ChoiceRow({
label,
options,
value,
onChange,
readOnly,
}: {
label: string
options: { value: string; label: string }[]
value: string | null
onChange: (value: string) => void
readOnly: boolean
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
{/* Fast 3-kolonners rutenett (2026-08-11, brukertilbakemelding) --
IKKE flex-wrap lenger. flex-wrap+flex-1 pakket knappene tett
content-bredden tillot (5 knapper rad 1, 1 alene rad 2 for
"Avstand første putt" sine seks alternativer) i stedet for en
forutsigbar, jevn oppstilling. Alle fire andre ChoiceRow-bruken
i denne filen har nøyaktig 3 valg hver, dette er visuelt
identisk for dem (én rad à tre) -- kun 6-alternativs-tilfellet
endrer seg (2 rader à tre, i stedet for 5+1). */}
<div className="grid grid-cols-3 gap-2">
{options.map((opt) => {
const selected = value === opt.value
return (
<button
key={opt.value}
type="button"
disabled={readOnly}
onClick={() => onChange(opt.value)}
aria-pressed={selected}
className={cn(
"flex min-h-12 items-center justify-center rounded-2xl border px-2 text-base font-bold transition-colors disabled:opacity-100",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
// --- Stepper (+/-) ---------------------------------------------------------
function Stepper({
label,
value,
onChange,
readOnly,
max,
}: {
label: string
value: number
onChange: (value: number) => void
readOnly: boolean
// Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
// umulig å ha chippet flere ganger enn totalt antall slag.
max?: number
}) {
const atMax = max !== undefined && value >= max
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card p-1.5">
<button
type="button"
disabled={readOnly || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
aria-label={`Færre ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-5" />
</button>
<span className="min-w-8 text-center text-xl font-extrabold tabular-nums text-foreground">{value}</span>
<button
type="button"
disabled={readOnly || atMax}
onClick={() => onChange(value + 1)}
aria-label={`Flere ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-5" />
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,186 @@
"""
Full statistikkdybde + optimistisk versjonssjekk tournament_round_hole
(migrasjon 075, ADR-071) -- speiler test_concurrency_version_check.py sitt
mønster for round_hole, men gjennom individual_tournaments.py sin egen
update_hole (self-only autorisasjon, upsert i stedet for ren UPDATE siden
raden ikke finnes før første score).
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
HoleUpdate,
TournamentParticipantCreate,
add_tournament_participant,
update_hole,
)
from tests.conftest import (
add_membership,
create_org,
create_org_hole,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
async def _setup_tournament(par=4):
org_id = await create_org()
user_id = await create_user()
await add_membership(org_id, user_id, role="owner")
tournament_id = await create_tournament(org_id)
course_id = await create_course(org_id)
for n in range(1, 19):
await create_org_hole(
org_id, course_id, hole_number=n,
par=par if n == 1 else 4,
stroke_index=n,
)
tee_id = await create_tee(org_id, course_id)
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
player_id = await create_player(org_id, user_id=user_id)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
return org_id, user_id, tournament_id, tournament_round_id, rp_id
async def test_update_succeeds_and_increments_version(pool):
org_id, user_id, tournament_id, round_id, rp_id = await _setup_tournament()
user = CurrentUser(user_id=user_id)
out = await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=5, putts=2, expected_version=None),
organization_id=org_id, user=user,
)
assert out.gross_strokes == 5
assert out.putts == 2
assert out.version == 1
async def test_stale_version_is_rejected_with_409(pool):
org_id, user_id, tournament_id, round_id, rp_id = await _setup_tournament()
user = CurrentUser(user_id=user_id)
# Første innsending -- raden finnes ikke fra før, INSERT-grenen treffes
# ubetinget uansett expected_version. Hullet er nå versjon 1.
await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=4, expected_version=None),
organization_id=org_id, user=user,
)
# "Enhet 1" leser versjon 1, skriver -- lykkes, hullet er nå versjon 2.
await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=4, expected_version=1),
organization_id=org_id, user=user,
)
# "Enhet 2" leste OGSÅ versjon 1 (før enhet 1 sin skriving nådde den).
with pytest.raises(HTTPException) as exc_info:
await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=6, expected_version=1),
organization_id=org_id, user=user,
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail["code"] == "STALE_VERSION"
async def test_expected_version_none_bypasses_check(pool):
org_id, user_id, tournament_id, round_id, rp_id = await _setup_tournament()
user = CurrentUser(user_id=user_id)
await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=4, expected_version=None),
organization_id=org_id, user=user,
)
await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=4, expected_version=1),
organization_id=org_id, user=user,
)
# Hullet er nå versjon 2 -- men klienten sender ingen forventet versjon.
out = await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=7, expected_version=None),
organization_id=org_id, user=user,
)
assert out.gross_strokes == 7
assert out.version == 3
async def test_first_write_succeeds_regardless_of_expected_version(pool):
"""Raden finnes ikke før første score -- INSERT-grenen er ubetinget, så
selv en helt feil expected_version IKKE blokkere en fersk innsending
(ulikt round_hole, der raden alltid finnes fra rundestart)."""
org_id, user_id, tournament_id, round_id, rp_id = await _setup_tournament()
user = CurrentUser(user_id=user_id)
out = await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(gross_strokes=5, expected_version=99),
organization_id=org_id, user=user,
)
assert out.gross_strokes == 5
assert out.version == 1
async def test_full_stat_fields_round_trip(pool):
org_id, user_id, tournament_id, round_id, rp_id = await _setup_tournament(par=4)
user = CurrentUser(user_id=user_id)
out = await update_hole(
tournament_id, round_id, rp_id, 1,
HoleUpdate(
gross_strokes=4,
putts=1,
club_off_tee="Driver",
tee_shot_result="fairway",
approach_result="hit",
chip_count=0,
bunker_shot_count=1,
penalty_strokes=0,
first_putt_distance_bucket="<3m",
anyway_strokes=0,
expected_version=None,
),
organization_id=org_id, user=user,
)
assert out.putts == 1
assert out.club_off_tee == "Driver"
assert out.tee_shot_result == "fairway"
assert out.approach_result == "hit"
assert out.bunker_shot_count == 1
assert out.first_putt_distance_bucket == "<3m"
async def test_stat_level_defaults_to_strokes_only_and_is_settable_on_create(pool):
org_id = await create_org()
user_id = await create_user()
await add_membership(org_id, user_id, role="owner")
tournament_id = await create_tournament(org_id)
player_id = await create_player(org_id, user_id=user_id)
default_out = await add_tournament_participant(
tournament_id, TournamentParticipantCreate(player_id=player_id),
organization_id=org_id,
)
assert default_out.stat_level == "strokes_only"
player_id_2 = await create_player(org_id)
full_out = await add_tournament_participant(
tournament_id,
TournamentParticipantCreate(player_id=player_id_2, stat_level="full"),
organization_id=org_id,
)
assert full_out.stat_level == "full"