Eclectic-format for org-individuelle turneringer (Del C) + tre UI-rettelser
Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per hull på tvers av en turnerings egne runder, krever samme bane (avvist tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget endepunkt -- se ADR-068 for begrunnelsen. Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet. "Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv). Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9b47e5da05
commit
9b7ff8e2ef
13 changed files with 729 additions and 76 deletions
24
072_eclectic_individual_tournament.sql
Normal file
24
072_eclectic_individual_tournament.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — Eclectic (org individuell turnering), migrasjon 072, "Del C"
|
||||||
|
-- av den tredelte Flaggturnering-utvidelsen (se ARCHITECTURE_DECISIONS.md
|
||||||
|
-- ADR-066/067 for Del A/B -- Del C er ikke Flaggturnering-relatert, men
|
||||||
|
-- ble bedt om i samme runde). Samme drop+recreate-mønster som
|
||||||
|
-- 042/044/046 -- se 046 sin header for full begrunnelse.
|
||||||
|
--
|
||||||
|
-- Ingen nye tabeller/kolonner -- Eclectic er en NY scoring_method-verdi
|
||||||
|
-- som gjenbruker eksisterende tournament_round_hole (per-hull
|
||||||
|
-- bruttoslag, allerede lagret av alle formater) og tournament_round_
|
||||||
|
-- participant.course_handicap (allerede beregnet for alle scoring_
|
||||||
|
-- method-verdier). "Beste resultat per hull på tvers av rundene" regnes
|
||||||
|
-- ut ved LESING (samme "regn ut ved lesing"-filosofi som resten av
|
||||||
|
-- individuelle turneringers leaderboard), se
|
||||||
|
-- app/routers/individual_tournaments.py::_attach_eclectic_totals.
|
||||||
|
-- =====================================================================
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
|
||||||
|
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
|
||||||
|
CHECK (scoring_method IS NULL OR scoring_method IN (
|
||||||
|
'stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo', 'flag',
|
||||||
|
'eclectic_gross', 'eclectic_net', 'eclectic_stableford'
|
||||||
|
));
|
||||||
|
|
@ -6718,6 +6718,103 @@ teecup_frontend && up -d` -- begge containere startet rent. Del C
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-068: Eclectic-format for org-individuelle turneringer ("Del C") — 2026-08-14
|
||||||
|
|
||||||
|
Tredje og siste del av den tredelte utvidelsen (se ADR-066/067 for Del
|
||||||
|
A/B -- Del C er ikke Flaggturnering-relatert i seg selv, men bedt om i
|
||||||
|
samme runde). Eclectic er en "drømmerunde": spillerens BESTE resultat
|
||||||
|
per hullnummer på tvers av ALLE turneringens runder, summert til én
|
||||||
|
total. Tre varianter (brutto/netto/Stableford), samme mønster som
|
||||||
|
`stroke_gross`/`stroke_net`/`stableford` sin eksisterende oppsplitting.
|
||||||
|
|
||||||
|
**Fail-loudly samme-bane-krav:** Eclectic krever at ALLE turneringens
|
||||||
|
runder spilles på samme bane -- ellers betyr ikke "hull 5" det samme på
|
||||||
|
tvers av rundene (ulik par/stroke index/fysisk hull), og "beste resultat
|
||||||
|
per hull" blir meningsløst å sammenligne. Håndhevet ved rundeopprettelse
|
||||||
|
(`create_round`, individual_tournaments.py): avviser med
|
||||||
|
`VALIDATION_FAILED` hvis turneringens `scoring_method` starter med
|
||||||
|
`eclectic_` og den nye rundens `course_id` avviker fra en allerede
|
||||||
|
eksisterende runde. Samme ADR-019-filosofi som resten av appen (avvis
|
||||||
|
tydelig, ikke stille feil).
|
||||||
|
|
||||||
|
**Datamodell (migrasjon 072):** INGEN nye tabeller/kolonner -- kun tre
|
||||||
|
nye verdier i `tournament_scoring_method_check` (samme drop+recreate-
|
||||||
|
mønster som 042/044/046). Eclectic gjenbruker eksisterende
|
||||||
|
`tournament_round_hole` (per-hull bruttoslag, allerede lagret av alle
|
||||||
|
formater) og `tournament_round_participant.course_handicap` (allerede
|
||||||
|
beregnet for alle scoring_method-verdier) -- alt regnes ut VED LESING,
|
||||||
|
samme "regn ut ved lesing"-filosofi som resten av individuelle
|
||||||
|
turneringers leaderboard (Beslutning C).
|
||||||
|
|
||||||
|
**Motor:** ny, ren `eclectic_best_per_hole(values_by_hole, result_type)`
|
||||||
|
i `handicap_engine.py` -- for hvert hullnummer, plukker BESTE verdi
|
||||||
|
(lavest for brutto/netto, høyest for Stableford) blant alle registrerte
|
||||||
|
runde-verdier for det hullet, uavhengig av hvilken runde den kom fra.
|
||||||
|
Sporer også HVILKEN runde (via en kaller-tildelt `round_index`) hvert
|
||||||
|
plukk kom fra -- brukt til frontend sin "beste-kilde"-visning. Hull uten
|
||||||
|
noen registrering utelates (ikke tellet som 0). 5 nye tester, inkl. ett
|
||||||
|
scenario der beste resultat for ulike hull kommer fra ULIKE runder
|
||||||
|
(beviser at plukkingen faktisk skjer per hull, ikke "velg beste hele
|
||||||
|
runde") -- full motor-suite 127/127.
|
||||||
|
|
||||||
|
**API-integrasjon -- BEVISST avvik fra opprinnelig plan.** Planen
|
||||||
|
foreslo opprinnelig et helt nytt leseendepunkt for Eclectic. Etter å ha
|
||||||
|
studert `_compute_individual_standings` (den delte funksjonen bak
|
||||||
|
`individual_leaderboard`, allerede brukt av `stroke_gross`/`stroke_net`/
|
||||||
|
`stableford`/`copenhagen`/`bingo_bango_bongo`) viste det seg klart
|
||||||
|
renere å legge Eclectic til som EN NY GREN i den samme funksjonen --
|
||||||
|
`LeaderboardEntry` fikk `eclectic_total`/`eclectic_holes`-felt (alltid
|
||||||
|
`None`/tom for andre scoring_method-verdier), og en ny
|
||||||
|
`_attach_eclectic_totals()`-hjelper populerer dem. INGEN nytt
|
||||||
|
endepunkt -- frontend bruker det samme `GET .../individual-leaderboard`
|
||||||
|
den allerede kaller for alle andre formater. 5 nye pytest-tester
|
||||||
|
(gross/net/stableford-utregning, samme-bane-avvisning, bekreftet at
|
||||||
|
IKKE-eclectic-turneringer fortsatt tillater ulike baner) -- full
|
||||||
|
backend-suite 55/55.
|
||||||
|
|
||||||
|
**Frontend (individual-tournament-detail.tsx, håndkodet -- filens egen
|
||||||
|
uttalte konvensjon, se filens header-kommentar).** `SCORING_METHOD_
|
||||||
|
LABELS` fikk de tre nye verdiene. `LeaderboardTab` sin flate liste
|
||||||
|
(samme gren som Københavner/BBB bruker, ikke Augusta-resultattavlen som
|
||||||
|
er forbeholdt de tre opprinnelige slagspill-variantene) fikk en
|
||||||
|
utvidbar "vis hull-for-hull"-rad per deltaker -- ny `EclecticHoleTable`-
|
||||||
|
komponent viser hull/par/verdi/"Fra runde N" for nøyaktig de hullene som
|
||||||
|
faktisk teller, beviser visuelt for spilleren at totalen er satt sammen
|
||||||
|
på tvers av runder.
|
||||||
|
|
||||||
|
**Verifisert:** `python3 -m py_compile` + full
|
||||||
|
`./scripts/run_backend_tests.sh` (55/55). `.venv/bin/python
|
||||||
|
test_handicap_engine.py` (127/127, standalone-selvsjekk uten pytest).
|
||||||
|
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
|
||||||
|
`teecup_api`-container (port 18001, live-mountet kode) + lokal `next
|
||||||
|
dev` (port 13001): sådde én turnering med to runder på SAMME bane, én
|
||||||
|
deltaker med hull 1 = 4/7 slag og hull 2 = 6/3 slag på tvers av de to
|
||||||
|
rundene -- forhåndsberegnet forventet total (4+3=7) stemte nøyaktig med
|
||||||
|
det viste leaderboardet, og "vis hull-for-hull" viste riktig kilde-runde
|
||||||
|
per hull. Bekreftet i nettleser: samme-bane-avvisningen ga en synlig,
|
||||||
|
tydelig feilmelding ved forsøk på å opprette en runde på en annen bane.
|
||||||
|
Lys+mørk bekreftet. Scratch-stacken fullstendig revet ned etterpå --
|
||||||
|
ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
|
||||||
|
|
||||||
|
**Samme økt, tre små ikke-relaterte UI-rettelser** (brukerrapportert via
|
||||||
|
skjermbilder, rettet opportunistisk før Del C): avstandsindikatoren
|
||||||
|
(`target-distance.tsx`) brukte "grønn" og "Midt" i stedet for de riktige
|
||||||
|
golf-uttrykkene "green"/"senter" -- rettet, samt fjernet "Oppdateres
|
||||||
|
live"-badgen (og det nå ubrukte `isLive`-sporet i
|
||||||
|
`hole-target-distance.tsx`) helt, etter brukerønske. "Ny runde"-
|
||||||
|
veiviserens "Antall hull"-bryter (`Segmented`-primitiven i
|
||||||
|
`components/ny-runde/primitives.tsx`) hadde en avvikende valgt-stil
|
||||||
|
(hvit/skygge) sammenlignet med resten av samme skjerm sine knappegrupper
|
||||||
|
(`ChoiceCard`/`ToggleButton`, grønn aksent-stil) -- rettet til samme
|
||||||
|
`border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]`-mønster,
|
||||||
|
gjelder alle `Segmented`-instanser appen-vidt (delt primitiv).
|
||||||
|
|
||||||
|
**IKKE rullet ut ennå** -- migrasjon 072 venter på eksplisitt
|
||||||
|
bekreftelse fra bruker. Dette var siste del av den tredelte
|
||||||
|
Flaggturnering-utvidelsen (Del A/B/C alle bygget).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -11177,3 +11177,58 @@ Neste steg:
|
||||||
nye kolonner verifisert direkte i skjemaet. `docker compose build
|
nye kolonner verifisert direkte i skjemaet. `docker compose build
|
||||||
teecup_api teecup_frontend && up -d` -- begge containere startet
|
teecup_api teecup_frontend && up -d` -- begge containere startet
|
||||||
rent. Del C (Eclectic) gjenstår.
|
rent. Del C (Eclectic) gjenstår.
|
||||||
|
|
||||||
|
84. **Eclectic-format for org-individuelle turneringer "Del C" (ADR-068)
|
||||||
|
+ tre ikke-relaterte UI-rettelser — 2026-08-14.** Siste del av den
|
||||||
|
tredelte utvidelsen (se punkt 82/83, ADR-066/067 for Del A/B).
|
||||||
|
Eclectic: spillerens beste resultat PER HULL på tvers av ALLE
|
||||||
|
turneringens runder, summert -- brutto/netto/Stableford-varianter.
|
||||||
|
Krever samme bane for alle runder (avvises tydelig ved
|
||||||
|
rundeopprettelse ellers, ADR-019-filosofi).
|
||||||
|
|
||||||
|
Migrasjon 072: kun tre nye verdier i
|
||||||
|
`tournament_scoring_method_check` -- ingen nye tabeller/kolonner,
|
||||||
|
Eclectic regnes ut ved lesing fra eksisterende `tournament_round_
|
||||||
|
hole`/`course_handicap`. Motor: ny `eclectic_best_per_hole()` i
|
||||||
|
handicap_engine.py, 5 nye tester (inkl. ett scenario som beviser
|
||||||
|
at beste-per-hull faktisk plukkes hullvis, ikke "beste hele
|
||||||
|
runde") -- full motor-suite 127/127.
|
||||||
|
|
||||||
|
**Bevisst avvik fra opprinnelig plan:** planla først et helt nytt
|
||||||
|
leseendepunkt, men fant at Eclectic passer renere inn som en ny
|
||||||
|
gren i den ALLEREDE eksisterende `_compute_individual_standings`
|
||||||
|
(samme funksjon bak `individual_leaderboard`, brukt av alle andre
|
||||||
|
scoring_method-verdier) -- INGEN nytt endepunkt bygget, frontend
|
||||||
|
gjenbruker det samme kallet. 5 nye pytest-tester -- full
|
||||||
|
backend-suite 55/55.
|
||||||
|
|
||||||
|
Frontend (individual-tournament-detail.tsx, håndkodet -- filens
|
||||||
|
egen konvensjon): ny utvidbar "vis hull-for-hull"-rad per deltaker
|
||||||
|
i leaderboardet (`EclecticHoleTable`), viser hull/par/verdi/
|
||||||
|
kilde-runde -- beviser visuelt at totalen er satt sammen på tvers
|
||||||
|
av runder.
|
||||||
|
|
||||||
|
**Verifisert:** `python3 -m py_compile` + full
|
||||||
|
`./scripts/run_backend_tests.sh` (55/55) + standalone
|
||||||
|
`test_handicap_engine.py` (127/127). `tsc --noEmit` rent + 45/45
|
||||||
|
vitest. Egen scratch-database + scratch `teecup_api`-container
|
||||||
|
(port 18001, live-mountet kode) + lokal `next dev` (port 13001):
|
||||||
|
to runder samme bane, hull-for-hull-forhåndsberegnet total (7)
|
||||||
|
stemte nøyaktig med visningen, samme-bane-avvisningen ga tydelig
|
||||||
|
feilmelding ved forsøk på annen bane. Lys+mørk bekreftet.
|
||||||
|
Scratch-stacken revet ned -- ekte `teecup_db`/`teecup_api`/
|
||||||
|
`teecup_frontend` urørt.
|
||||||
|
|
||||||
|
**Samme runde, tre små UI-rettelser** (brukerrapportert via
|
||||||
|
skjermbilder, ikke Eclectic-relatert): avstandsindikatoren
|
||||||
|
(`target-distance.tsx`) rettet fra "grønn"/"Midt" til de riktige
|
||||||
|
golf-uttrykkene "green"/"senter", "Oppdateres live"-badgen (og det
|
||||||
|
nå ubrukte `isLive`-sporet) fjernet helt. "Antall hull"-bryteren i
|
||||||
|
Ny runde-veiviseren (`Segmented`-primitiven) hadde en avvikende
|
||||||
|
hvit valgt-stil sammenlignet med resten av samme skjerm -- rettet
|
||||||
|
til samme grønne aksent-stil som `ChoiceCard`/`ToggleButton`,
|
||||||
|
gjelder alle `Segmented`-instanser appen-vidt.
|
||||||
|
|
||||||
|
**IKKE rullet ut ennå** -- migrasjon 072 venter på eksplisitt
|
||||||
|
bekreftelse fra bruker. Dette var siste del av den tredelte
|
||||||
|
Flaggturnering-utvidelsen.
|
||||||
|
|
|
||||||
|
|
@ -576,8 +576,9 @@
|
||||||
ADR-064 rangefinder til grønn).
|
ADR-064 rangefinder til grønn).
|
||||||
|
|
||||||
**✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14),
|
**✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14),
|
||||||
"Del A" av en tredelt utvidelse** (kartoversikt "Del B" og nytt format
|
"Del A" av en tredelt utvidelse** (kartoversikt "Del B"/ADR-067 og
|
||||||
Eclectic "Del C" gjenstår, se egne punkter): "Plant flagget"-knapp +
|
nytt format Eclectic "Del C"/ADR-068 er OGSÅ bygget nå, se egne
|
||||||
|
punkter -- alle tre deler ferdige): "Plant flagget"-knapp +
|
||||||
guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt.
|
guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt.
|
||||||
"Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot
|
"Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot
|
||||||
andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG
|
andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG
|
||||||
|
|
@ -590,11 +591,21 @@
|
||||||
|
|
||||||
**✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på-
|
**✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på-
|
||||||
bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg
|
bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg
|
||||||
på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge. Kun
|
på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge.
|
||||||
Eclectic "Del C" gjenstår nå. Org-individuelle turneringers offentlige
|
Org-individuelle turneringers offentlige tilskuervisning er BEVISST
|
||||||
tilskuervisning er BEVISST utenfor omfang her (finnes ikke i det hele
|
utenfor omfang her (finnes ikke i det hele tatt ennå — `/t/[id]/live`
|
||||||
tatt ennå — `/t/[id]/live` er lagturnerings-only — egen, større
|
er lagturnerings-only — egen, større oppgave hvis/når etterspurt).
|
||||||
oppgave hvis/når etterspurt). Migrasjon 071 — se ADR-067.
|
Migrasjon 071 — se ADR-067.
|
||||||
|
|
||||||
|
**✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del,
|
||||||
|
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
|
||||||
|
org-individuelle turneringer (`scoring_method = eclectic_gross/
|
||||||
|
eclectic_net/eclectic_stableford`) -- beste resultat PER HULL på
|
||||||
|
tvers av turneringens EGNE runder (krever samme bane, avvist tydelig
|
||||||
|
ellers). IKKE det samme som OOM sin ennå-ubygde "Eclectic på tvers av
|
||||||
|
lenkede turneringer" (se Order of Merit-seksjonen lenger ned) -- den
|
||||||
|
motoren kan trolig gjenbrukes derfra senere, men selve OOM-
|
||||||
|
integrasjonen er fortsatt ugjort. Migrasjon 072 — se ADR-068.
|
||||||
|
|
||||||
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
||||||
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
||||||
|
|
@ -4353,12 +4364,18 @@ full bygge-/verifiseringsdetalj (63/63 håndregnede API-sjekker + RLS-
|
||||||
isolasjon + ekte nettleser-gjennomgang).
|
isolasjon + ekte nettleser-gjennomgang).
|
||||||
|
|
||||||
**Gjenstår fra opprinnelig plan (bevisst utsatt, ikke glemt):**
|
**Gjenstår fra opprinnelig plan (bevisst utsatt, ikke glemt):**
|
||||||
- **Eclectic-aggregering** ("drømmerunde" -- beste resultat PER HULL på
|
- **Eclectic-aggregering PÅ TVERS AV LENKEDE TURNERINGER (OOM-nivå)**
|
||||||
tvers av alle lenkede turneringer en spiller deltok i, kun for
|
("drømmerunde" -- beste resultat PER HULL på tvers av ALLE lenkede
|
||||||
resultattype Stableford/brutto/netto). Krever egen per-hull-data-
|
turneringer en spiller deltok i gjennom sesongen, kun for
|
||||||
innsamlingslogikk i leaderboard-endepunktet (ikke bare en ny gren i
|
resultattype Stableford/brutto/netto). **IKKE det samme som** det
|
||||||
`order_of_merit_aggregate`, som kun håndterer sum/snitt) -- egen
|
Eclectic-formatet som ble bygget 2026-08-14 (ADR-068, migrasjon
|
||||||
design-/verifiseringsrunde.
|
072) -- det er scoping til RUNDENE INNENFOR ÉN ENKELT turnering
|
||||||
|
(`tournament.scoring_method = eclectic_*`), ikke på tvers av flere
|
||||||
|
turneringer i en OOM-sesong. Motoren (`eclectic_best_per_hole()` i
|
||||||
|
handicap_engine.py) er generell nok til trolig å kunne GJENBRUKES
|
||||||
|
direkte for OOM-varianten også -- selve per-hull-datainnsamlingen på
|
||||||
|
tvers av turneringer (ikke bare runder) er fortsatt ubygget og
|
||||||
|
krever egen design-/verifiseringsrunde.
|
||||||
- **Lag-OOM sin faktiske leaderboard-beregning.** Skjema
|
- **Lag-OOM sin faktiske leaderboard-beregning.** Skjema
|
||||||
(`order_of_merit_team`/`_team_member`) og prinsippet (sesong-par satt
|
(`order_of_merit_team`/`_team_member`) og prinsippet (sesong-par satt
|
||||||
opp DIREKTE i OOM-en, summerer/velger-beste-N-av medlemmenes allerede
|
opp DIREKTE i OOM-en, summerer/velger-beste-N-av medlemmenes allerede
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,16 @@ from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
allocate_over_played_holes,
|
allocate_over_played_holes,
|
||||||
|
allocate_strokes_by_index,
|
||||||
bbb_points_for_hole,
|
bbb_points_for_hole,
|
||||||
compute_bbb,
|
compute_bbb,
|
||||||
compute_copenhagen_detail,
|
compute_copenhagen_detail,
|
||||||
course_handicap_raw,
|
course_handicap_raw,
|
||||||
|
EclecticHoleValue,
|
||||||
|
eclectic_best_per_hole,
|
||||||
flag_result,
|
flag_result,
|
||||||
round_half_up,
|
round_half_up,
|
||||||
|
stableford_points_for_hole,
|
||||||
stableford_total,
|
stableford_total,
|
||||||
stroke_play_gross_total,
|
stroke_play_gross_total,
|
||||||
stroke_play_net_total,
|
stroke_play_net_total,
|
||||||
|
|
@ -114,7 +118,7 @@ async def create_round(
|
||||||
) -> TournamentRound:
|
) -> TournamentRound:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
tournament = await conn.fetchrow(
|
tournament = await conn.fetchrow(
|
||||||
"SELECT format_type FROM tournament WHERE id = $1", tournament_id
|
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
||||||
)
|
)
|
||||||
if tournament is None:
|
if tournament is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
|
@ -122,6 +126,21 @@ async def create_round(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer."
|
400, "VALIDATION_FAILED", "Runder kan kun opprettes for individuelle turneringer."
|
||||||
)
|
)
|
||||||
|
# Eclectic (ADR-067-tillegget "Del C"): "beste resultat per hull på
|
||||||
|
# tvers av rundene" krever at hullnumrene faktisk betyr det samme
|
||||||
|
# (samme par/stroke index/fysiske hull) i alle runder -- fail
|
||||||
|
# loudly fremfor å stille sammenligne epler og pærer, samme
|
||||||
|
# ADR-019-filosofi som resten av appen.
|
||||||
|
if tournament["scoring_method"] is not None and tournament["scoring_method"].startswith("eclectic_"):
|
||||||
|
existing_course_id = await conn.fetchval(
|
||||||
|
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if existing_course_id is not None and existing_course_id != body.course_id:
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Eclectic krever samme bane for alle turneringens runder.",
|
||||||
|
)
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
WITH inserted AS (
|
WITH inserted AS (
|
||||||
|
|
@ -1030,6 +1049,13 @@ class RoundCellOut(BaseModel):
|
||||||
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
|
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
|
||||||
|
|
||||||
|
|
||||||
|
class EclecticHoleCellOut(BaseModel):
|
||||||
|
hole_number: int
|
||||||
|
par: int
|
||||||
|
value: int # brutto/nettoslag eller Stableford-poeng, avhengig av scoring_method
|
||||||
|
round_number: int # HVILKEN runde (sequence) det beste resultatet kom fra ("beste-kilde")
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntry(BaseModel):
|
class LeaderboardEntry(BaseModel):
|
||||||
tournament_participant_id: str
|
tournament_participant_id: str
|
||||||
# Order of Merit (2026-08-04, order_of_merit.py) trenger den EKTE
|
# Order of Merit (2026-08-04, order_of_merit.py) trenger den EKTE
|
||||||
|
|
@ -1058,6 +1084,12 @@ class LeaderboardEntry(BaseModel):
|
||||||
thru_label: str | None = None
|
thru_label: str | None = None
|
||||||
total_label: str | None = None
|
total_label: str | None = None
|
||||||
rounds: list[RoundCellOut] = []
|
rounds: list[RoundCellOut] = []
|
||||||
|
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
|
||||||
|
# per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
|
||||||
|
# ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
|
||||||
|
# method-verdier.
|
||||||
|
eclectic_total: int | None = None
|
||||||
|
eclectic_holes: list[EclecticHoleCellOut] = []
|
||||||
|
|
||||||
|
|
||||||
async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]:
|
async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]:
|
||||||
|
|
@ -1120,6 +1152,12 @@ async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict,
|
||||||
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
||||||
elif method == "bingo_bango_bongo":
|
elif method == "bingo_bango_bongo":
|
||||||
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
||||||
|
elif method in ("eclectic_gross", "eclectic_net", "eclectic_stableford"):
|
||||||
|
await _attach_eclectic_totals(conn, tournament_id, method, entries)
|
||||||
|
if method == "eclectic_stableford":
|
||||||
|
entries.sort(key=lambda e: (e.eclectic_total is None, -(e.eclectic_total or 0)))
|
||||||
|
else:
|
||||||
|
entries.sort(key=lambda e: (e.eclectic_total is None, e.eclectic_total or 0))
|
||||||
else:
|
else:
|
||||||
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
||||||
|
|
||||||
|
|
@ -1315,6 +1353,104 @@ async def _attach_stroke_play_columns(
|
||||||
i = j
|
i = j
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- "drømmerunde" satt
|
||||||
|
# sammen av BESTE resultat per hullnummer på tvers av ALLE turneringens
|
||||||
|
# runder. Krever samme bane for alle runder (håndhevet ved rundeopprettelse,
|
||||||
|
# se create_round over) -- hullnummer 5 i runde 1 og hullnummer 5 i runde 2
|
||||||
|
# er da GARANTERT samme fysiske hull/par/stroke index, så sammenligningen er
|
||||||
|
# meningsfull. Motoren (eclectic_best_per_hole) vet ingenting om db/HCP --
|
||||||
|
# denne funksjonen forbereder per-hull-per-runde-verdiene (brutto/netto/
|
||||||
|
# Stableford-poeng, avhengig av `method`) og mater dem inn.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _attach_eclectic_totals(conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]) -> None:
|
||||||
|
course_id = await conn.fetchval(
|
||||||
|
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
|
||||||
|
)
|
||||||
|
if course_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
hole_rows = await conn.fetch(
|
||||||
|
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
||||||
|
course_id,
|
||||||
|
)
|
||||||
|
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
|
||||||
|
si_by_hole = [r["stroke_index"] for r in hole_rows] # hull 1..18, i rekkefølge
|
||||||
|
|
||||||
|
round_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT tr.sequence,
|
||||||
|
trp.tournament_participant_id::text AS tournament_participant_id,
|
||||||
|
trp.course_handicap, trh.hole_number, trh.gross_strokes
|
||||||
|
FROM tournament_round tr
|
||||||
|
JOIN tournament_round_participant trp ON trp.tournament_round_id = tr.id
|
||||||
|
JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
|
||||||
|
WHERE tr.tournament_id = $1 AND trh.gross_strokes IS NOT NULL
|
||||||
|
ORDER BY tr.sequence
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# round_index i motorens forstand = rekkefølgen sequence-verdiene faktisk
|
||||||
|
# dukker opp i (stabil, sortert) -- brukt kun til å spore "beste-kilde"
|
||||||
|
# tilbake til et ekte rundenummer (sequence) for visning.
|
||||||
|
sequence_by_index: dict[int, int] = {}
|
||||||
|
for r in round_rows:
|
||||||
|
if r["sequence"] not in sequence_by_index.values():
|
||||||
|
sequence_by_index[len(sequence_by_index)] = r["sequence"]
|
||||||
|
index_by_sequence = {seq: i for i, seq in sequence_by_index.items()}
|
||||||
|
|
||||||
|
# Cache slagfordelingen per (course_handicap) -- samme fordeling for
|
||||||
|
# enhver rad med samme handicap, uansett hvilken runde/hull den gjelder.
|
||||||
|
allocation_cache: dict[int, list[int]] = {}
|
||||||
|
|
||||||
|
values_by_participant: dict[str, list[list[EclecticHoleValue]]] = {}
|
||||||
|
for r in round_rows:
|
||||||
|
if r["course_handicap"] is None:
|
||||||
|
continue
|
||||||
|
pid = r["tournament_participant_id"]
|
||||||
|
if pid not in values_by_participant:
|
||||||
|
values_by_participant[pid] = [[] for _ in range(18)]
|
||||||
|
|
||||||
|
strokes_received = 0
|
||||||
|
if method != "eclectic_gross":
|
||||||
|
hcp = r["course_handicap"]
|
||||||
|
if hcp not in allocation_cache:
|
||||||
|
allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole)
|
||||||
|
strokes_received = allocation_cache[hcp][r["hole_number"] - 1]
|
||||||
|
|
||||||
|
if method == "eclectic_gross":
|
||||||
|
value = r["gross_strokes"]
|
||||||
|
elif method == "eclectic_net":
|
||||||
|
value = r["gross_strokes"] - strokes_received
|
||||||
|
else:
|
||||||
|
value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)
|
||||||
|
|
||||||
|
values_by_participant[pid][r["hole_number"] - 1].append(
|
||||||
|
EclecticHoleValue(round_index=index_by_sequence[r["sequence"]], value=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
result_type = "gross" if method == "eclectic_gross" else "net" if method == "eclectic_net" else "stableford"
|
||||||
|
for e in entries:
|
||||||
|
values_by_hole = values_by_participant.get(e.tournament_participant_id)
|
||||||
|
if values_by_hole is None:
|
||||||
|
continue
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, result_type)
|
||||||
|
if not result.holes:
|
||||||
|
continue
|
||||||
|
e.eclectic_total = result.total
|
||||||
|
e.eclectic_holes = [
|
||||||
|
EclecticHoleCellOut(
|
||||||
|
hole_number=h.hole_number,
|
||||||
|
par=par_by_hole[h.hole_number],
|
||||||
|
value=h.value,
|
||||||
|
round_number=sequence_by_index[h.round_index],
|
||||||
|
)
|
||||||
|
for h in result.holes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
|
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
|
||||||
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ class TournamentCreate(BaseModel):
|
||||||
# for individual, håndhevet i app-laget når runder/score bygges på den.
|
# for individual, håndhevet i app-laget når runder/score bygges på den.
|
||||||
format_type: str = Field(default="team", pattern="^(team|individual)$")
|
format_type: str = Field(default="team", pattern="^(team|individual)$")
|
||||||
scoring_method: str | None = Field(
|
scoring_method: str | None = Field(
|
||||||
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
|
default=None,
|
||||||
|
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
|
||||||
|
"eclectic_gross|eclectic_net|eclectic_stableford)$",
|
||||||
)
|
)
|
||||||
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
|
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
|
||||||
# organisator-innstilling (samme som frittstående runders round.
|
# organisator-innstilling (samme som frittstående runders round.
|
||||||
|
|
@ -184,7 +186,9 @@ class TournamentUpdate(BaseModel):
|
||||||
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
||||||
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
|
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
|
||||||
scoring_method: str | None = Field(
|
scoring_method: str | None = Field(
|
||||||
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
|
default=None,
|
||||||
|
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
|
||||||
|
"eclectic_gross|eclectic_net|eclectic_stableford)$",
|
||||||
)
|
)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
registration_deadline: datetime | None = None
|
registration_deadline: datetime | None = None
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import { useEffect, useRef, useState } from "react"
|
||||||
import { TargetDistance } from "@/components/target-distance"
|
import { TargetDistance } from "@/components/target-distance"
|
||||||
import { haversineMeters, type LatLng } from "@/lib/geo"
|
import { haversineMeters, type LatLng } from "@/lib/geo"
|
||||||
|
|
||||||
// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (grønn
|
// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (green
|
||||||
// front/midt/bak, hindringer) og kombinerer dem klient-side med spillerens
|
// front/senter/bak, hindringer) og kombinerer dem klient-side med spillerens
|
||||||
// LIVE GPS-posisjon via samme watchPosition-mønster som map-point-picker.tsx
|
// LIVE GPS-posisjon via samme watchPosition-mønster som map-point-picker.tsx
|
||||||
// allerede bruker for slagmåling (ADR-048 tillegg 2026-08-08 del 2) --
|
// allerede bruker for slagmåling (ADR-048 tillegg 2026-08-08 del 2) --
|
||||||
// `enableHighAccuracy`, grasiøs degradering ved avslått tillatelse,
|
// `enableHighAccuracy`, grasiøs degradering ved avslått tillatelse,
|
||||||
|
|
@ -56,7 +56,6 @@ export function HoleTargetDistance({
|
||||||
}) {
|
}) {
|
||||||
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
|
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
|
||||||
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
|
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
|
||||||
const [isLive, setIsLive] = useState(false)
|
|
||||||
const watchIdRef = useRef<number | null>(null)
|
const watchIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -86,11 +85,9 @@ export function HoleTargetDistance({
|
||||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
|
||||||
setIsLive(true)
|
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
|
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
|
||||||
setIsLive(false)
|
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, maximumAge: 4000 },
|
{ enableHighAccuracy: true, maximumAge: 4000 },
|
||||||
)
|
)
|
||||||
|
|
@ -105,12 +102,10 @@ export function HoleTargetDistance({
|
||||||
|
|
||||||
// Pauser GPS-sporingen helt når fanen/appen ikke er synlig (skjerm
|
// Pauser GPS-sporingen helt når fanen/appen ikke er synlig (skjerm
|
||||||
// låst, annen app i forgrunnen) -- gjenopptas automatisk når brukeren
|
// låst, annen app i forgrunnen) -- gjenopptas automatisk når brukeren
|
||||||
// kommer tilbake. `isLive` settes til false mens pauset, så visningen
|
// kommer tilbake.
|
||||||
// tydelig viser "ikke live" fremfor å late som tallet fortsatt oppdateres.
|
|
||||||
function handleVisibilityChange() {
|
function handleVisibilityChange() {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
stopWatch()
|
stopWatch()
|
||||||
setIsLive(false)
|
|
||||||
} else {
|
} else {
|
||||||
startWatch()
|
startWatch()
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +128,7 @@ export function HoleTargetDistance({
|
||||||
// verdt å vise noe.
|
// verdt å vise noe.
|
||||||
if (points === null || points.length === 0) return null
|
if (points === null || points.length === 0) return null
|
||||||
if (!livePosition) {
|
if (!livePosition) {
|
||||||
return <TargetDistance size={size} distances={null} isLive={false} className={className} />
|
return <TargetDistance size={size} distances={null} className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const greenPoints = points.filter((p) => p.poi_type === "green" && p.location)
|
const greenPoints = points.filter((p) => p.poi_type === "green" && p.location)
|
||||||
|
|
@ -141,7 +136,7 @@ export function HoleTargetDistance({
|
||||||
const middle = greenPoints.find((p) => p.location === "middle")
|
const middle = greenPoints.find((p) => p.location === "middle")
|
||||||
const back = greenPoints.find((p) => p.location === "back")
|
const back = greenPoints.find((p) => p.location === "back")
|
||||||
if (!front || !middle || !back) {
|
if (!front || !middle || !back) {
|
||||||
return <TargetDistance size={size} distances={null} isLive={false} className={className} />
|
return <TargetDistance size={size} distances={null} className={className} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const distances = {
|
const distances = {
|
||||||
|
|
@ -162,6 +157,6 @@ export function HoleTargetDistance({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TargetDistance size={size} distances={distances} nearestHazard={nearestHazard} isLive={isLive} className={className} />
|
<TargetDistance size={size} distances={distances} nearestHazard={nearestHazard} className={className} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,13 @@ type ApiRoundCell = {
|
||||||
tone: "under" | "even" | "over" | null
|
tone: "under" | "even" | "over" | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiEclecticHoleCell = {
|
||||||
|
hole_number: number
|
||||||
|
par: number
|
||||||
|
value: number
|
||||||
|
round_number: number
|
||||||
|
}
|
||||||
|
|
||||||
type ApiLeaderboardEntry = {
|
type ApiLeaderboardEntry = {
|
||||||
tournament_participant_id: string
|
tournament_participant_id: string
|
||||||
player_name: string
|
player_name: string
|
||||||
|
|
@ -148,6 +155,10 @@ type ApiLeaderboardEntry = {
|
||||||
thru_label: string | null
|
thru_label: string | null
|
||||||
total_label: string | null
|
total_label: string | null
|
||||||
rounds: ApiRoundCell[]
|
rounds: ApiRoundCell[]
|
||||||
|
// Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for
|
||||||
|
// scoring_method eclectic_gross/eclectic_net/eclectic_stableford.
|
||||||
|
eclectic_total: number | null
|
||||||
|
eclectic_holes: ApiEclecticHoleCell[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiFlagResult = {
|
type ApiFlagResult = {
|
||||||
|
|
@ -231,8 +242,13 @@ const SCORING_METHOD_LABELS: Record<string, string> = {
|
||||||
copenhagen: "Københavner",
|
copenhagen: "Københavner",
|
||||||
bingo_bango_bongo: "Bingo Bango Bongo",
|
bingo_bango_bongo: "Bingo Bango Bongo",
|
||||||
flag: "Flaggturnering",
|
flag: "Flaggturnering",
|
||||||
|
eclectic_gross: "Eclectic (brutto)",
|
||||||
|
eclectic_net: "Eclectic (netto)",
|
||||||
|
eclectic_stableford: "Eclectic (Stableford)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ECLECTIC_SCORING_METHODS = new Set(["eclectic_gross", "eclectic_net", "eclectic_stableford"])
|
||||||
|
|
||||||
function holeNumbersFor(config: ApiRound["hole_config"]): number[] {
|
function holeNumbersFor(config: ApiRound["hole_config"]): number[] {
|
||||||
if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
|
||||||
if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
|
||||||
|
|
@ -2963,6 +2979,10 @@ function HoleGrid({
|
||||||
function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) {
|
function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) {
|
||||||
const [entries, setEntries] = useState<ApiLeaderboardEntry[] | null>(null)
|
const [entries, setEntries] = useState<ApiLeaderboardEntry[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning,
|
||||||
|
// én deltaker utvidet om gangen (samme "trykk for detaljer"-mønster som
|
||||||
|
// resten av appen, ikke alle utvidet samtidig).
|
||||||
|
const [expandedEclecticId, setExpandedEclecticId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -2988,6 +3008,9 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
if (method === "stroke_net") return { label: "netto", value: entry.net_total }
|
if (method === "stroke_net") return { label: "netto", value: entry.net_total }
|
||||||
if (method === "copenhagen") return { label: "poeng", value: entry.copenhagen_total }
|
if (method === "copenhagen") return { label: "poeng", value: entry.copenhagen_total }
|
||||||
if (method === "bingo_bango_bongo") return { label: "poeng", value: entry.bbb_total }
|
if (method === "bingo_bango_bongo") return { label: "poeng", value: entry.bbb_total }
|
||||||
|
if (method === "eclectic_gross") return { label: "slag", value: entry.eclectic_total }
|
||||||
|
if (method === "eclectic_net") return { label: "netto", value: entry.eclectic_total }
|
||||||
|
if (method === "eclectic_stableford") return { label: "poeng", value: entry.eclectic_total }
|
||||||
return { label: "slag", value: entry.gross_total }
|
return { label: "slag", value: entry.gross_total }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3075,14 +3098,15 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEclectic = method !== null && ECLECTIC_SCORING_METHODS.has(method)
|
||||||
|
|
||||||
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
|
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
|
||||||
const { label, value } = valueFor(entry)
|
const { label, value } = valueFor(entry)
|
||||||
const isLeader = rank === 1 && value !== null
|
const isLeader = rank === 1 && value !== null
|
||||||
return (
|
const canExpand = isEclectic && entry.eclectic_holes.length > 0
|
||||||
<li
|
const expanded = expandedEclecticId === entry.tournament_participant_id
|
||||||
key={entry.tournament_participant_id}
|
const row = (
|
||||||
className={cn("flex items-center gap-3 px-4 py-3", isLeader && "bg-gold/10")}
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
>
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
||||||
|
|
@ -3095,6 +3119,7 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
<span className="truncate text-sm font-bold text-foreground">{entry.player_name}</span>
|
<span className="truncate text-sm font-bold text-foreground">{entry.player_name}</span>
|
||||||
<span className="text-xs font-semibold text-muted-foreground">
|
<span className="text-xs font-semibold text-muted-foreground">
|
||||||
{entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt
|
{entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt
|
||||||
|
{canExpand ? (expanded ? " · skjul hull-for-hull" : " · vis hull-for-hull") : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-right text-lg font-extrabold tabular-nums text-foreground">
|
<span className="shrink-0 text-right text-lg font-extrabold tabular-nums text-foreground">
|
||||||
|
|
@ -3107,6 +3132,23 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
<span className="text-sm font-medium text-muted-foreground">–</span>
|
<span className="text-sm font-medium text-muted-foreground">–</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<li key={entry.tournament_participant_id} className={cn(isLeader && "bg-gold/10")}>
|
||||||
|
{canExpand ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedEclecticId(expanded ? null : entry.tournament_participant_id)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="min-h-11 w-full text-left transition-colors hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
row
|
||||||
|
)}
|
||||||
|
{expanded ? <EclecticHoleTable holes={entry.eclectic_holes} valueLabel={label} /> : null}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -3136,6 +3178,46 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning:
|
||||||
|
// hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra.
|
||||||
|
// Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det
|
||||||
|
// beste fra flere runder, ikke bare den beste hele runden.
|
||||||
|
function EclecticHoleTable({ holes, valueLabel }: { holes: ApiEclecticHoleCell[]; valueLabel: string }) {
|
||||||
|
const sorted = [...holes].sort((a, b) => a.hole_number - b.hole_number)
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto border-t border-border bg-muted/30 px-4 py-3">
|
||||||
|
<table className="w-full min-w-[420px] border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th scope="col" className="px-2 py-1 text-left">
|
||||||
|
Hull
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-1 text-right">
|
||||||
|
Par
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-1 text-right">
|
||||||
|
{valueLabel === "poeng" ? "Poeng" : valueLabel === "netto" ? "Netto" : "Slag"}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-1 text-right">
|
||||||
|
Fra runde
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((h) => (
|
||||||
|
<tr key={h.hole_number} className="border-t border-border/60">
|
||||||
|
<td className="px-2 py-1 font-semibold text-foreground">{h.hole_number}</td>
|
||||||
|
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">{h.par}</td>
|
||||||
|
<td className="px-2 py-1 text-right font-bold tabular-nums text-foreground">{h.value}</td>
|
||||||
|
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">Runde {h.round_number}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
|
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
|
||||||
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
|
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
|
||||||
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
|
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
|
||||||
|
|
|
||||||
|
|
@ -216,10 +216,10 @@ export function Segmented<T extends string>({
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-9 flex-1 rounded-md px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
|
"min-h-9 flex-1 rounded-md border px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
|
||||||
active
|
active
|
||||||
? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm"
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
|
||||||
: "text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
|
: "border-transparent text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { TriangleAlert, Satellite } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TargetDistance — ren visningskomponent for gjenstående avstand til grønn.
|
* TargetDistance — ren visningskomponent for gjenstående avstand til green.
|
||||||
*
|
*
|
||||||
* "Dum" med vilje: den mottar ferdigregnede tall som props. Ingen GPS,
|
* "Dum" med vilje: den mottar ferdigregnede tall som props. Ingen GPS,
|
||||||
* ingen fetch, ingen kart. Følger TeeCups clubhouse-palett (grønn/oransje
|
* ingen fetch, ingen kart. Følger TeeCups clubhouse-palett (grønn/oransje
|
||||||
|
|
@ -20,39 +20,11 @@ export type TargetDistanceProps = {
|
||||||
label: string // f.eks. "Bunker (front)"
|
label: string // f.eks. "Bunker (front)"
|
||||||
distanceMeters: number
|
distanceMeters: number
|
||||||
} | null
|
} | null
|
||||||
isLive: boolean // true = tallene oppdateres kontinuerlig fra GPS akkurat nå
|
|
||||||
/** "compact" = badge ved siden av GIR-merket, "full" = eget panel. */
|
/** "compact" = badge ved siden av GIR-merket, "full" = eget panel. */
|
||||||
size?: "compact" | "full"
|
size?: "compact" | "full"
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liten "oppdateres live"-indikator: pulserende prikk + tekst (aldri kun farge). */
|
|
||||||
function LiveStatus({ isLive, compact }: { isLive: boolean; compact?: boolean }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 font-medium text-clubhouse-muted",
|
|
||||||
compact ? "text-[11px]" : "text-xs",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLive ? (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true" className="relative flex size-2">
|
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-tee-strong opacity-70" />
|
|
||||||
<span className="relative inline-flex size-2 rounded-full bg-tee-strong" />
|
|
||||||
</span>
|
|
||||||
{compact ? "Live" : "Oppdateres live"}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Satellite aria-hidden="true" className="size-3.5" />
|
|
||||||
{compact ? "Sist målt" : "Ikke live — sist målte posisjon"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hindring-linje: kun synlig når det finnes en relevant hindring. Oransje varsel, ikke rødt. */
|
/** Hindring-linje: kun synlig når det finnes en relevant hindring. Oransje varsel, ikke rødt. */
|
||||||
function HazardLine({
|
function HazardLine({
|
||||||
hazard,
|
hazard,
|
||||||
|
|
@ -146,7 +118,6 @@ function NoData({ compact, className }: { compact?: boolean; className?: string
|
||||||
export function TargetDistance({
|
export function TargetDistance({
|
||||||
distances,
|
distances,
|
||||||
nearestHazard,
|
nearestHazard,
|
||||||
isLive,
|
|
||||||
size = "full",
|
size = "full",
|
||||||
className,
|
className,
|
||||||
}: TargetDistanceProps) {
|
}: TargetDistanceProps) {
|
||||||
|
|
@ -167,33 +138,31 @@ export function TargetDistance({
|
||||||
>
|
>
|
||||||
<div className="flex items-end gap-3">
|
<div className="flex items-end gap-3">
|
||||||
<DistanceColumn label="Front" value={distances.front} compact />
|
<DistanceColumn label="Front" value={distances.front} compact />
|
||||||
<DistanceColumn label="Midt" value={distances.middle} emphasized compact />
|
<DistanceColumn label="Senter" value={distances.middle} emphasized compact />
|
||||||
<DistanceColumn label="Bak" value={distances.back} compact />
|
<DistanceColumn label="Bak" value={distances.back} compact />
|
||||||
</div>
|
</div>
|
||||||
|
{nearestHazard ? (
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<LiveStatus isLive={isLive} compact />
|
<HazardLine hazard={nearestHazard} compact />
|
||||||
{nearestHazard ? <HazardLine hazard={nearestHazard} compact /> : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Avstand til grønn"
|
aria-label="Avstand til green"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 shadow-sm",
|
"flex w-full flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 shadow-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-clubhouse-muted">Til green</h3>
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-clubhouse-muted">Til grønn</h3>
|
|
||||||
<LiveStatus isLive={isLive} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end justify-between gap-2">
|
<div className="flex items-end justify-between gap-2">
|
||||||
<DistanceColumn label="Front" value={distances.front} />
|
<DistanceColumn label="Front" value={distances.front} />
|
||||||
<DistanceColumn label="Midt" value={distances.middle} emphasized />
|
<DistanceColumn label="Senter" value={distances.middle} emphasized />
|
||||||
<DistanceColumn label="Bak" value={distances.back} />
|
<DistanceColumn label="Bak" value={distances.back} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1060,3 +1060,61 @@ def order_of_merit_aggregate(
|
||||||
if mode == "sum":
|
if mode == "sum":
|
||||||
return sum(kept)
|
return sum(kept)
|
||||||
return sum(kept) / len(kept)
|
return sum(kept) / len(kept)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eclectic (org individuell turnering, ADR-067-tillegget "Del C") -- en
|
||||||
|
# "drømmerunde" satt sammen av spillerens BESTE resultat per hullnummer, på
|
||||||
|
# tvers av ALLE turneringens runder (som må være spilt på samme bane --
|
||||||
|
# håndheves i app-laget ved rundeopprettelse, ikke her). Tre varianter
|
||||||
|
# (brutto/netto/Stableford) deler samme plukke-logikk, kun hva som telles
|
||||||
|
# som "best" (lavest for brutto/netto, høyest for Stableford) og selve
|
||||||
|
# verdien (rå slag/nettoslag/poeng) skiller dem -- kalleren regner ut disse
|
||||||
|
# per-hull-per-runde-verdiene på forhånd (samme "motoren kjenner ingen
|
||||||
|
# db/API"-prinsipp som resten av filen).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EclecticHoleValue:
|
||||||
|
"""Én runde sitt resultat for ETT hull -- `round_index` er kallerens egen
|
||||||
|
indeksering av rundene (f.eks. inn i en liste den selv holder styr på),
|
||||||
|
brukt kun til å spore HVILKEN runde det beste resultatet kom fra."""
|
||||||
|
|
||||||
|
round_index: int
|
||||||
|
value: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EclecticHolePick:
|
||||||
|
hole_number: int
|
||||||
|
value: int
|
||||||
|
round_index: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EclecticResult:
|
||||||
|
total: int
|
||||||
|
holes: list[EclecticHolePick] # kun hull med minst én registrering, stigende hullnummer
|
||||||
|
|
||||||
|
|
||||||
|
def eclectic_best_per_hole(
|
||||||
|
values_by_hole: Sequence[Sequence[EclecticHoleValue]],
|
||||||
|
result_type: Literal["gross", "net", "stableford"],
|
||||||
|
) -> EclecticResult:
|
||||||
|
"""`values_by_hole[i]` = ALLE registrerte verdier for hull i+1 (én per
|
||||||
|
runde der spilleren faktisk har ført score på det hullet) -- tom liste
|
||||||
|
betyr "ikke spilt i noen runde ennå". Plukker BESTE verdi (lavest for
|
||||||
|
gross/net, høyest for stableford) per hull, UAVHENGIG av hvilken runde
|
||||||
|
den kom fra -- summerer de plukkede verdiene til én total.
|
||||||
|
|
||||||
|
Ved uavgjort mellom flere runder for samme hull vinner den FØRSTE
|
||||||
|
(laveste `round_index`) -- vilkårlig men deterministisk, ingen
|
||||||
|
golfregel avgjør dette (identisk resultat uansett hvilken velges)."""
|
||||||
|
pick = max if result_type == "stableford" else min
|
||||||
|
holes: list[EclecticHolePick] = []
|
||||||
|
for i, vals in enumerate(values_by_hole):
|
||||||
|
if not vals:
|
||||||
|
continue
|
||||||
|
best = pick(vals, key=lambda v: v.value)
|
||||||
|
holes.append(EclecticHolePick(hole_number=i + 1, value=best.value, round_index=best.round_index))
|
||||||
|
return EclecticResult(total=sum(h.value for h in holes), holes=holes)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ from handicap_engine import (
|
||||||
FlagResult,
|
FlagResult,
|
||||||
flag_result,
|
flag_result,
|
||||||
flag_lap_and_hole,
|
flag_lap_and_hole,
|
||||||
|
EclecticHoleValue,
|
||||||
|
EclecticHolePick,
|
||||||
|
eclectic_best_per_hole,
|
||||||
shamble_hole_score,
|
shamble_hole_score,
|
||||||
money_ball_hole_score,
|
money_ball_hole_score,
|
||||||
high_low_high_points_for_hole,
|
high_low_high_points_for_hole,
|
||||||
|
|
@ -1151,6 +1154,68 @@ def test_oom_aggregate_higher_is_better_gross_uses_negated_values():
|
||||||
assert order_of_merit_aggregate(negated, "sum", 2) == -138
|
assert order_of_merit_aggregate(negated, "sum", 2) == -138
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eclectic (org individuell turnering, "Del C") -- beste resultat per hull
|
||||||
|
# på tvers av rundene.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_eclectic_picks_lowest_gross_per_hole_across_rounds():
|
||||||
|
# Hull 1: runde 0 ga 5, runde 1 ga 4 -- 4 skal plukkes.
|
||||||
|
# Hull 2: runde 0 ga 3, runde 1 ga 6 -- 3 skal plukkes.
|
||||||
|
values_by_hole = [
|
||||||
|
[EclecticHoleValue(round_index=0, value=5), EclecticHoleValue(round_index=1, value=4)],
|
||||||
|
[EclecticHoleValue(round_index=0, value=3), EclecticHoleValue(round_index=1, value=6)],
|
||||||
|
]
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, "gross")
|
||||||
|
assert result.total == 4 + 3
|
||||||
|
assert result.holes == [
|
||||||
|
EclecticHolePick(hole_number=1, value=4, round_index=1),
|
||||||
|
EclecticHolePick(hole_number=2, value=3, round_index=0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_eclectic_best_result_comes_from_different_rounds_for_different_holes():
|
||||||
|
# Beviser at plukkingen faktisk skjer PER HULL, ikke "velg beste hele
|
||||||
|
# runde": runde 0 er best på hull 1, runde 1 er best på hull 2 -- ingen
|
||||||
|
# av rundene alene ville gitt denne totalen (5 hadde vært verre enn 4
|
||||||
|
# på hull 1, og 6 hadde vært verre enn 3 på hull 2, uansett hvilken
|
||||||
|
# HELE runde man valgte).
|
||||||
|
values_by_hole = [
|
||||||
|
[EclecticHoleValue(round_index=0, value=4), EclecticHoleValue(round_index=1, value=6)],
|
||||||
|
[EclecticHoleValue(round_index=0, value=7), EclecticHoleValue(round_index=1, value=3)],
|
||||||
|
]
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, "gross")
|
||||||
|
assert [h.round_index for h in result.holes] == [0, 1]
|
||||||
|
assert result.total == 4 + 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_eclectic_net_also_picks_lowest():
|
||||||
|
values_by_hole = [[EclecticHoleValue(round_index=0, value=2), EclecticHoleValue(round_index=1, value=1)]]
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, "net")
|
||||||
|
assert result.total == 1
|
||||||
|
assert result.holes[0].round_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_eclectic_stableford_picks_highest():
|
||||||
|
values_by_hole = [[EclecticHoleValue(round_index=0, value=2), EclecticHoleValue(round_index=1, value=4)]]
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, "stableford")
|
||||||
|
assert result.total == 4
|
||||||
|
assert result.holes[0].round_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_eclectic_hole_with_no_recorded_value_is_excluded_not_zero():
|
||||||
|
# Hull 2 er ikke spilt i NOEN runde ennå -- skal utelates fra
|
||||||
|
# `holes` (og dermed fra totalen), ikke telle som 0.
|
||||||
|
values_by_hole = [
|
||||||
|
[EclecticHoleValue(round_index=0, value=5)],
|
||||||
|
[],
|
||||||
|
[EclecticHoleValue(round_index=0, value=4)],
|
||||||
|
]
|
||||||
|
result = eclectic_best_per_hole(values_by_hole, "gross")
|
||||||
|
assert result.total == 9
|
||||||
|
assert [h.hole_number for h in result.holes] == [1, 3]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enkel selvsjekk uten pytest
|
# Enkel selvsjekk uten pytest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
151
tests/test_eclectic.py
Normal file
151
tests/test_eclectic.py
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"""
|
||||||
|
Eclectic (org individuell turnering, ADR-067-tillegget "Del C", migrasjon
|
||||||
|
072) -- "drømmerunde" satt sammen av beste resultat per hull på tvers av
|
||||||
|
turneringens runder. Samme kall-direkte-mønster som
|
||||||
|
test_flag_plant_individual_tournament.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.auth import CurrentUser
|
||||||
|
from app.routers.individual_tournaments import (
|
||||||
|
HoleUpdate,
|
||||||
|
TournamentRoundCreate,
|
||||||
|
create_round,
|
||||||
|
individual_leaderboard,
|
||||||
|
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_eclectic_tournament(scoring_method: str = "eclectic_gross"):
|
||||||
|
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=4, stroke_index=n)
|
||||||
|
tee_id = await create_tee(org_id, course_id)
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
|
||||||
|
tournament_id, scoring_method,
|
||||||
|
)
|
||||||
|
|
||||||
|
round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
|
||||||
|
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
|
||||||
|
player_id = await create_player(org_id, user_id=user_id)
|
||||||
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||||
|
# course_handicap=0 -- gjør netto/Stableford-tallene enkle å regne for hånd.
|
||||||
|
rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id, course_handicap=0)
|
||||||
|
rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id, course_handicap=0)
|
||||||
|
return org_id, user_id, tournament_id, round1_id, round2_id, rp1_id, rp2_id, tp_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _play_hole(tournament_id, round_id, rp_id, hole_number, score, org_id, user):
|
||||||
|
await update_hole(
|
||||||
|
tournament_id, round_id, rp_id, hole_number,
|
||||||
|
HoleUpdate(gross_strokes=score),
|
||||||
|
organization_id=org_id, user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_gross_picks_best_per_hole_across_rounds(pool):
|
||||||
|
org_id, user_id, tournament_id, round1_id, round2_id, rp1_id, rp2_id, tp_id = (
|
||||||
|
await _setup_eclectic_tournament("eclectic_gross")
|
||||||
|
)
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
# Hull 1: runde 1 -> 5, runde 2 -> 6 (runde 1 vinner).
|
||||||
|
# Hull 2: runde 1 -> 6, runde 2 -> 4 (runde 2 vinner).
|
||||||
|
await _play_hole(tournament_id, round1_id, rp1_id, 1, 5, org_id, user)
|
||||||
|
await _play_hole(tournament_id, round1_id, rp1_id, 2, 6, org_id, user)
|
||||||
|
await _play_hole(tournament_id, round2_id, rp2_id, 1, 6, org_id, user)
|
||||||
|
await _play_hole(tournament_id, round2_id, rp2_id, 2, 4, org_id, user)
|
||||||
|
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
|
||||||
|
assert entry.eclectic_total == 5 + 4
|
||||||
|
holes_by_number = {h.hole_number: h for h in entry.eclectic_holes}
|
||||||
|
assert holes_by_number[1].value == 5
|
||||||
|
assert holes_by_number[1].round_number == 1
|
||||||
|
assert holes_by_number[2].value == 4
|
||||||
|
assert holes_by_number[2].round_number == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_net_subtracts_strokes_received(pool):
|
||||||
|
org_id, user_id, tournament_id, round1_id, round2_id, rp1_id, rp2_id, tp_id = (
|
||||||
|
await _setup_eclectic_tournament("eclectic_net")
|
||||||
|
)
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
await _play_hole(tournament_id, round1_id, rp1_id, 1, 5, org_id, user)
|
||||||
|
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
|
||||||
|
# course_handicap=0 -- ingen mottatte slag, netto == brutto.
|
||||||
|
assert entry.eclectic_total == 5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_stableford_picks_highest_points(pool):
|
||||||
|
org_id, user_id, tournament_id, round1_id, round2_id, rp1_id, rp2_id, tp_id = (
|
||||||
|
await _setup_eclectic_tournament("eclectic_stableford")
|
||||||
|
)
|
||||||
|
user = CurrentUser(user_id=user_id)
|
||||||
|
# Par 4, HCP 0: gross 4 -> netto par -> 2 poeng. gross 3 -> netto birdie -> 3 poeng.
|
||||||
|
await _play_hole(tournament_id, round1_id, rp1_id, 1, 4, org_id, user)
|
||||||
|
await _play_hole(tournament_id, round2_id, rp2_id, 1, 3, org_id, user)
|
||||||
|
|
||||||
|
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
|
||||||
|
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
|
||||||
|
assert entry.eclectic_total == 3
|
||||||
|
assert entry.eclectic_holes[0].round_number == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eclectic_rejects_round_on_different_course(pool):
|
||||||
|
org_id, user_id, tournament_id, round1_id, round2_id, rp1_id, rp2_id, tp_id = (
|
||||||
|
await _setup_eclectic_tournament("eclectic_gross")
|
||||||
|
)
|
||||||
|
other_course_id = await create_course(org_id)
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, other_course_id, hole_number=n, par=4, stroke_index=n)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await create_round(
|
||||||
|
tournament_id,
|
||||||
|
TournamentRoundCreate(sequence=3, course_id=other_course_id),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.detail["code"] == "VALIDATION_FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_eclectic_tournament_allows_different_courses(pool):
|
||||||
|
org_id, user_id, tournament_id, round1_id, round2_id, rp1_id, rp2_id, tp_id = (
|
||||||
|
await _setup_eclectic_tournament("stroke_gross")
|
||||||
|
)
|
||||||
|
other_course_id = await create_course(org_id)
|
||||||
|
for n in range(1, 19):
|
||||||
|
await create_org_hole(org_id, other_course_id, hole_number=n, par=4, stroke_index=n)
|
||||||
|
|
||||||
|
result = await create_round(
|
||||||
|
tournament_id,
|
||||||
|
TournamentRoundCreate(sequence=3, course_id=other_course_id),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert result.course_id == other_course_id
|
||||||
Loading…
Reference in a new issue