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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
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
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 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