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:
Erol Haagenrud 2026-08-14 11:12:25 +02:00
parent 9b47e5da05
commit 9b7ff8e2ef
13 changed files with 729 additions and 76 deletions

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

View file

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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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} />
) )
} }

View file

@ -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

View file

@ -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}

View file

@ -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>
<div className="flex items-center justify-between gap-2"> {nearestHazard ? (
<LiveStatus isLive={isLive} compact /> <div className="flex items-center justify-between gap-2">
{nearestHazard ? <HazardLine hazard={nearestHazard} compact /> : null} <HazardLine hazard={nearestHazard} compact />
</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>

View file

@ -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 ),
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 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)

View file

@ -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
View 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 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