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:
parent
41ae0afa15
commit
60ee6a7d16
8 changed files with 1195 additions and 375 deletions
52
075_tournament_hole_stats_parity.sql
Normal file
52
075_tournament_hole_stats_parity.sql
Normal 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'));
|
||||
|
|
@ -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)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
64
CHANGELOG.md
64
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 `<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.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ turneringer).
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -219,17 +221,26 @@ async def delete_round(
|
|||
# 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):
|
||||
player_id: str
|
||||
# Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
|
||||
class_id: str | None = None
|
||||
stat_level: StatLevel = "strokes_only"
|
||||
|
||||
|
||||
class TournamentParticipantUpdate(BaseModel):
|
||||
"""Eneste redigerbare felt i dag er klassetilhørighet -- exclude_unset,
|
||||
samme PATCH-semantikk som resten av appen."""
|
||||
"""exclude_unset, samme PATCH-semantikk som resten av appen."""
|
||||
|
||||
class_id: str | None = None
|
||||
stat_level: StatLevel | None = None
|
||||
|
||||
|
||||
class TournamentParticipantOut(BaseModel):
|
||||
|
|
@ -239,13 +250,14 @@ class TournamentParticipantOut(BaseModel):
|
|||
handicap_index_snapshot: float | None
|
||||
class_id: str | None
|
||||
class_name: str | None
|
||||
stat_level: str
|
||||
|
||||
|
||||
_TOURNAMENT_PARTICIPANT_COLUMNS = """
|
||||
tp.id::text AS id, tp.player_id::text AS player_id,
|
||||
p.display_name AS player_name,
|
||||
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"""
|
||||
WITH inserted AS (
|
||||
INSERT INTO tournament_participant
|
||||
(organization_id, tournament_id, player_id, handicap_index_snapshot, class_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING 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, $6)
|
||||
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level
|
||||
)
|
||||
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
|
||||
FROM inserted tp
|
||||
|
|
@ -283,6 +295,7 @@ async def add_tournament_participant(
|
|||
body.player_id,
|
||||
player["handicap_index"],
|
||||
body.class_id,
|
||||
body.stat_level,
|
||||
)
|
||||
return TournamentParticipantOut(**dict(row))
|
||||
|
||||
|
|
@ -338,7 +351,7 @@ async def update_tournament_participant(
|
|||
WITH updated AS (
|
||||
UPDATE tournament_participant SET {', '.join(set_clauses)}
|
||||
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}
|
||||
FROM updated tp
|
||||
|
|
@ -388,6 +401,10 @@ class RoundParticipantOut(BaseModel):
|
|||
tee_name: str
|
||||
course_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:
|
||||
|
|
@ -520,7 +537,7 @@ async def add_round_participant(
|
|||
"""
|
||||
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,
|
||||
trp.course_handicap, trp.playing_handicap
|
||||
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
||||
FROM tournament_round_participant trp
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_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,
|
||||
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
|
||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||
JOIN player p ON p.id = tp.player_id
|
||||
|
|
@ -585,6 +602,20 @@ async def remove_round_participant(
|
|||
|
||||
class HoleUpdate(BaseModel):
|
||||
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):
|
||||
|
|
@ -592,6 +623,16 @@ class RoundHoleOut(BaseModel):
|
|||
par: int
|
||||
stroke_index: int
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -935,18 +976,50 @@ async def update_hole(
|
|||
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
|
||||
(organization_id, tournament_round_participant_id, hole_number, gross_strokes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, now())
|
||||
(organization_id, tournament_round_participant_id, hole_number, 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, 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
|
||||
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,
|
||||
round_participant_id,
|
||||
hole_number,
|
||||
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)
|
||||
if round_row["scoring_method"] == "copenhagen":
|
||||
|
|
@ -981,8 +1054,8 @@ async def update_hole(
|
|||
hole_number=hole_number,
|
||||
par=hole["par"],
|
||||
stroke_index=hole["stroke_index"],
|
||||
gross_strokes=body.gross_strokes,
|
||||
strokes_received=strokes_received,
|
||||
**dict(updated_row),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1010,7 +1083,11 @@ async def list_round_participant_holes(
|
|||
)
|
||||
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
|
||||
LEFT JOIN tournament_round_hole trh
|
||||
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)
|
||||
)
|
||||
return [
|
||||
RoundHoleOut(
|
||||
hole_number=h["hole_number"],
|
||||
par=h["par"],
|
||||
stroke_index=h["stroke_index"],
|
||||
gross_strokes=h["gross_strokes"],
|
||||
strokes_received=a,
|
||||
)
|
||||
RoundHoleOut(strokes_received=a, **dict(h))
|
||||
for h, a in zip(holes, allocation)
|
||||
]
|
||||
|
||||
|
|
|
|||
350
frontend/components/hole-stat-inputs.tsx
Normal file
350
frontend/components/hole-stat-inputs.tsx
Normal 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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,6 +43,14 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} 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 { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
||||
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||
|
|
@ -104,6 +112,9 @@ type ApiParticipant = {
|
|||
handicap_index_snapshot: number | null
|
||||
class_id: 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
|
||||
|
|
@ -140,6 +151,10 @@ type ApiRoundParticipant = {
|
|||
tee_name: string
|
||||
course_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 = {
|
||||
|
|
@ -147,6 +162,20 @@ type ApiHole = {
|
|||
par: number
|
||||
stroke_index: number
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -500,6 +529,25 @@ export function IndividualTournamentDetail({
|
|||
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) {
|
||||
setError(null)
|
||||
const res = await fetch(`${base}/classes`, {
|
||||
|
|
@ -692,6 +740,7 @@ export function IndividualTournamentDetail({
|
|||
onCreateClass={createClass}
|
||||
onDeleteClass={deleteClass}
|
||||
onSetParticipantClass={setParticipantClass}
|
||||
onSetParticipantStatLevel={setParticipantStatLevel}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -763,6 +812,7 @@ function SetupTab({
|
|||
onCreateClass,
|
||||
onDeleteClass,
|
||||
onSetParticipantClass,
|
||||
onSetParticipantStatLevel,
|
||||
onError,
|
||||
}: {
|
||||
base: string
|
||||
|
|
@ -785,6 +835,10 @@ function SetupTab({
|
|||
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
|
||||
onDeleteClass: (id: string) => 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
|
||||
}) {
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
|
|
@ -889,6 +943,7 @@ function SetupTab({
|
|||
onAddNewPlayer={onAddNewPlayer}
|
||||
onRemoveParticipant={onRemoveParticipant}
|
||||
onSetClass={onSetParticipantClass}
|
||||
onSetStatLevel={onSetParticipantStatLevel}
|
||||
/>
|
||||
</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({
|
||||
participants,
|
||||
pool,
|
||||
|
|
@ -1057,6 +1118,7 @@ function ParticipantsCard({
|
|||
onAddNewPlayer,
|
||||
onRemoveParticipant,
|
||||
onSetClass,
|
||||
onSetStatLevel,
|
||||
}: {
|
||||
participants: ApiParticipant[]
|
||||
pool: ApiPlayer[]
|
||||
|
|
@ -1065,6 +1127,10 @@ function ParticipantsCard({
|
|||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||
onRemoveParticipant: (id: string) => Promise<void>
|
||||
onSetClass: (participantId: string, classId: string | null) => Promise<void>
|
||||
onSetStatLevel: (
|
||||
participantId: string,
|
||||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||
) => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{/* 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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -2118,6 +2201,26 @@ function ScoreTab({
|
|||
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) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
|
||||
|
|
@ -2176,7 +2279,16 @@ function ScoreTab({
|
|||
</div>
|
||||
|
||||
{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,
|
||||
|
|
@ -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({
|
||||
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<void>
|
||||
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
|
||||
}) {
|
||||
const [editingHole, setEditingHole] = useState<number | null>(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<number | null>(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({
|
|||
</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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) ---------------------------------
|
||||
// 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 (
|
||||
<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 på 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 så tett
|
||||
content-bredden tillot (5 knapper på rad 1, 1 alene på 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, så 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
186
tests/test_tournament_hole_stats.py
Normal file
186
tests/test_tournament_hole_stats.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""
|
||||
Full statistikkdybde + optimistisk versjonssjekk på 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 må 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"
|
||||
Loading…
Reference in a new issue