Order of Merit: eclectic-aggregering på tvers av lenkede turneringer (ADR-075)

Sesong-"drømmerunde" -- beste resultat per hull på tvers av ALLE lenkede
turneringer (stableford/brutto/netto), med samme-bane-håndheving ved
lenking og modus-bytte. Gjenbruker eclectic_best_per_hole() uendret fra
handicap_engine.py. Frontend: "Eclectic" lagt til i både opprettelses-
skjema og innstillinger, gated til gyldige kind/resultattype-kombinasjoner.

80/80 backend-tester, 45/45 vitest, scratch-verifisert i nettleser
(lys+mørk). Rullet ut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-15 17:38:00 +02:00
parent 5d502f305a
commit fe13a82009
6 changed files with 571 additions and 32 deletions

View file

@ -7400,6 +7400,111 @@ bruker. Begge containere startet rent.
--- ---
## ADR-075: Order of Merit -- eclectic-aggregering på tvers av lenkede turneringer ("Steg 2" av gjenstår-listen) (2026-08-15)
Foranledning: andre av de tre gjenstående OOM-punktene fra ADR-043, se
ADR-074 sin innledning for hele bakgrunnen/bekreftelsen. Eclectic-
aggregering ("sesong-drømmerunde": beste resultat PER HULL på tvers av
ALLE lenkede turneringer, kun `result_type IN ('stableford','gross',
'net')`) var forberedt i skjemaet (`aggregation_mode='eclectic'` fantes
som verdi) men eksplisitt avvist i `order_of_merit_leaderboard` ("ikke
støttet ennå").
**Samme-bane-håndheving** (app-laget, ingen migrasjon): eclectic-
aggregering på tvers av turneringer er meningsløs hvis turneringene ikke
er spilt på samme bane (ulike hull-oppsett/par/stroke index). Håndhevet
på TO steder, samme "fail loudly"-filosofi som ADR-068/ADR-019
Beslutning C:
- `link_tournament_to_order_of_merit`: avviser lenking av en turnering
hvis OOM-en allerede er `aggregation_mode='eclectic'` OG den nye
turneringens bane avviker fra en allerede lenket turnerings bane.
- `update_order_of_merit`: avviser å endre `aggregation_mode` TIL
`'eclectic'` hvis turneringer med ulik bane allerede er lenket.
Ny `_validate_same_course_for_eclectic()` + `_course_id_for_tournament()`
(samme `LIMIT 1`-antakelse på `tournament_round.course_id` som
`_attach_eclectic_totals` allerede gjorde -- én bane per turnering,
udiskutert forutsetning i hele kodebasen).
**Datainnsamling** (ny `_compute_eclectic_oom_values`, gjenbruker
`eclectic_best_per_hole()` fra `handicap_engine.py` HELT UENDRET --
motoren viste seg, som antatt i planen, kildeuavhengig nok til å
fungere identisk for "flere runder i én turnering" (ADR-068) og "flere
turneringer i én OOM" (dette steget), kun datainnsamlingen foran den er
forskjellig): tilpasset variant av `_attach_eclectic_totals` sin
`tournament_round_hole`-spørring, filtrert på `tr.tournament_id =
ANY($linked_ids)` i stedet for én turnering. Nøkkelforskjell fra
innad-i-turnering-varianten: keyed på `player_id` (ikke
`tournament_participant_id`, som er ulik per turnering for samme
spiller), og kilde-indeksen er sammensatt av (turnering-id, runde-
sekvens) siden `sequence` kun er unik INNENFOR én turnering. Returnerer
også `tournament_count_by_player` -- antall DISTINKTE turneringer som
faktisk bidro minst ett hull til spillerens sluttsum, brukt som
`results_available` for `min_results_required`-sjekken (rå `len(values)`
ville alltid vært 1 i eclectic-modus, meningsløst som eligibility-mål).
`_player_eligibility` sin signatur endret fra å ta `raw_values: list`
til å ta `results_available: int` direkte -- frikobler "hvor mange
resultater talte" fra "de faktiske verdiene", nødvendig fordi eclectic-
modusens tall ikke lenger kommer fra `len(raw_values)`.
**Leaderboard**: `order_of_merit_leaderboard` sin tidligere eksplisitte
avvisning for `aggregation_mode == 'eclectic'` erstattet med faktisk
beregning -- kun for `kind='player'` (kind='team' + eclectic er allerede
sperret ved opprettelse, uendret fra ADR-043).
**Frontend**: "Eclectic"-valget lagt til begge steder
`aggregation_mode` settes, gated identisk til (og speilende) server-
sidens regel (`kind==='player' && result_type IN
('stableford','gross','net')`) slik at UI-et aldri tilbyr et valg
serveren uansett ville avvist:
- `order-of-merit-list.tsx` (opprettelsesskjema): ny `eclecticAllowed()`-
helper, tredje `SegmentButton` vist betinget, PLUSS auto-reset til
`'sum'` hvis brukeren endrer `kind`/`resultType` ETTER å ha valgt
Eclectic slik at den ikke lenger er gyldig (kind/resultType er fritt
redigerbare før opprettelse, i motsetning til detail-siden under).
- `order-of-merit-detail.tsx` (innstillinger, eksisterende OOM): samme
`eclecticAllowed()`-mønster, men INGEN reset-logikk trengs -- `kind`/
`result_type` er låst etter opprettelse her (vist eksplisitt i UI-en,
se ADR-074), så gatingen er ren visning av et fast faktum. Hjelpetekst
lagt til under Eclectic-valget som forklarer samme-bane-kravet.
**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (80/80 -- 3 nye tester i
`test_order_of_merit.py`: cross-tournament eclectic velger faktisk
beste-per-hull på TVERS av turneringer, ikke bare beste turnering eller
snitt (hånd-utregnet: par 1-9 fra turnering A + par 10-18 fra turnering
B = 72, `results_available == 2`), avvisning ved lenking av turnering på
annen bane, avvisning ved modus-bytte til eclectic med allerede ulike
baner lenket). `tsc --noEmit` rent + 45/45 vitest.
Egen scratch-database + scratch `teecup_api`-container (port 18102,
live-montert kode, `TEECUP_DEV_LOG_MAGIC_LINKS=true` for å logge inn
uten SMTP) + lokal `next dev` (port 13102): seedet to turneringer på
samme bane (par hull 1-9/bogey 10-18 i A, omvendt i B) + én tredje
turnering på en ANNEN bane, opprettet en eclectic-OOM, lenket A+B --
leaderboard viste 72 (bekreftet også direkte mot API før nettleser-
verifisering). Innstillinger-seksjonen viste "Eclectic" korrekt valgt
med hjelpeteksten synlig. Forsøk på å lenke turnering C (annen bane) ga
en tydelig, lesbar feilmelding i UI-et: "Eclectic-aggregering krever at
alle lenkede turneringer spilles på samme bane." Opprettelsesskjemaets
gating verifisert direkte i nettleseren: Eclectic-knappen dukker opp for
`Spiller`+`Stableford-sum`/`Bruttoscore-sum`/`Nettoscore-sum`, forsvinner
og aggregering nullstilles automatisk til `Sum` når resultattype endres
til `Poeng etter plassering` ELLER type endres til `Lag`. Lys+mørk
bekreftet (skjermbilde av Innstillinger-seksjonen i mørk modus). Scratch-
stacken fullstendig revet ned (database, rolle, container, MinIO-bøtte,
`next dev`) -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
**Ingen migrasjon** -- ren kode-endring, `aggregation_mode='eclectic'`
fantes som gyldig verdi fra migrasjon 055.
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
bruker. Begge containere startet rent.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -11572,3 +11572,50 @@ Neste steg:
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build **Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
bruker. Begge containere startet rent. bruker. Begge containere startet rent.
91. **Order of Merit: eclectic-aggregering på tvers av lenkede
turneringer (ADR-075) — 2026-08-15.** Tredje av de gjenstående OOM-
punktene fra ADR-043/074 -- sesong-"drømmerunde": beste resultat PER
HULL på tvers av ALLE lenkede turneringer (kun `result_type IN
('stableford','gross','net')`), tidligere eksplisitt avvist i
leaderboard-endepunktet.
Samme-bane-håndheving lagt til (ny, ingen migrasjon): en eclectic-
OOM kan ikke lenke en turnering på en annen bane enn de allerede
lenkede, verken ved lenking eller ved å bytte `aggregation_mode` til
eclectic med ulike baner allerede lenket. Datainnsamlingen (ny
`_compute_eclectic_oom_values`) gjenbruker `eclectic_best_per_hole()`
fra `handicap_engine.py` HELT UENDRET -- samme motor som ADR-068
(flere runder i én turnering), kun datainnsamlingen foran er
tilpasset til å samle på tvers av turneringer i stedet, keyed på
`player_id` med en sammensatt (turnering-id, runde-sekvens)-kilde-
indeks. Frontend: "Eclectic" lagt til som tredje aggregeringsvalg
både i opprettelsesskjemaet (`order-of-merit-list.tsx`, med auto-
reset til Sum hvis kind/resultattype endres til noe eclectic ikke
støtter) og innstillinger for eksisterende OOM-er (`order-of-merit-
detail.tsx`, ingen reset nødvendig der siden kind/resultattype er
låst etter opprettelse) -- begge gated identisk til (og speilende)
serverens regel.
**Verifisert:** full `./scripts/run_backend_tests.sh` (80/80 -- 3 nye
tester: cross-tournament eclectic velger faktisk beste-per-hull på
TVERS av turneringer med hånd-utregnet forventet verdi, avvisning
ved lenking på annen bane, avvisning ved modus-bytte med ulike baner
allerede lenket). `tsc --noEmit` rent + 45/45 vitest. Scratch-
database + scratch `teecup_api` (port 18102, live-montert kode,
`TEECUP_DEV_LOG_MAGIC_LINKS=true` for innlogging uten SMTP) + lokal
`next dev` (port 13102): opprettet ekte eclectic-OOM i nettleseren,
lenket to turneringer på samme bane -- leaderboard viste korrekt
kryss-turnering-tall (72). Forsøk på å lenke en turnering på en annen
bane ga en tydelig feilmelding i UI-et. Opprettelsesskjemaets gating
verifisert live (Eclectic dukker opp/forsvinner + nullstiller
korrekt ved endring av type/resultattype). Lys+mørk bekreftet.
Scratch-stacken fullstendig revet ned (database, rolle, container,
MinIO-bøtte, `next dev`) -- ekte `teecup_db`/`teecup_api`/
`teecup_frontend` urørt.
**Ingen migrasjon.**
**Rullet ut 2026-08-15** -- ingen migrasjon, `docker compose build
teecup_api teecup_frontend && up -d` etter eksplisitt bekreftelse fra
bruker. Begge containere startet rent.

View file

@ -35,7 +35,14 @@ from pydantic import BaseModel, Field
from ..auth import get_authorized_org from ..auth import get_authorized_org
from ..db import org_connection from ..db import org_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from handicap_engine import order_of_merit_aggregate, order_of_merit_points_for_position from handicap_engine import (
allocate_strokes_by_index,
EclecticHoleValue,
eclectic_best_per_hole,
order_of_merit_aggregate,
order_of_merit_points_for_position,
stableford_points_for_hole,
)
from .individual_tournaments import _compute_individual_standings from .individual_tournaments import _compute_individual_standings
@ -165,6 +172,24 @@ async def update_order_of_merit(
raise app_error( raise app_error(
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto." 400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
) )
if effective_mode == "eclectic":
# Slår PÅ eclectic mens flere turneringer med ulik bane
# allerede er lenket -- avvis her også, ikke bare ved lenking
# av en NY turnering (ADR-075).
links = await conn.fetch(
"SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
)
course_ids = set()
for link in links:
cid = await _course_id_for_tournament(conn, link["tournament_id"])
if cid is not None:
course_ids.add(cid)
if len(course_ids) > 1:
raise app_error(
400,
"VALIDATION_FAILED",
"Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
)
row = await conn.fetchrow( row = await conn.fetchrow(
f""" f"""
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now() UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
@ -248,6 +273,39 @@ def _parse_link_row(r) -> dict:
return d return d
async def _course_id_for_tournament(conn, tournament_id: str) -> str | None:
"""Samme mønster som _attach_eclectic_totals sin egen bane-oppslag
(individual_tournaments.py) -- LIMIT 1 antar (bevisst, håndhevet av
resten av appen ved rundeopprettelse) at en turnerings runder deler
samme bane."""
return await conn.fetchval(
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
)
async def _validate_same_course_for_eclectic(conn, oom_id: str, new_tournament_id: str) -> None:
"""Eclectic-aggregering (ADR-075) plukker beste resultat PER HULL på
tvers av alle lenkede turneringer -- meningsløst hvis banene ikke er
de samme (hull 7 én bane er ikke hull 7 en annen). Håndhevet
her (lenke-tidspunkt) OG i update_order_of_merit (modus-endring),
samme "fail loudly, ikke stille feil tall"-filosofi som ADR-068/
ADR-019 Beslutning C."""
new_course_id = await _course_id_for_tournament(conn, new_tournament_id)
if new_course_id is None:
return # ingen runder ennå på den nye turneringen -- ingenting å sammenligne mot
links = await conn.fetch(
"SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
)
for link in links:
existing_course_id = await _course_id_for_tournament(conn, link["tournament_id"])
if existing_course_id is not None and existing_course_id != new_course_id:
raise app_error(
400,
"VALIDATION_FAILED",
"Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
)
@router.post( @router.post(
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments", "/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
response_model=OrderOfMeritLinkOut, response_model=OrderOfMeritLinkOut,
@ -259,7 +317,7 @@ async def link_tournament_to_order_of_merit(
organization_id: str = Depends(get_authorized_org), organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritLinkOut: ) -> OrderOfMeritLinkOut:
async with org_connection(organization_id) as conn, translate_db_errors(): async with org_connection(organization_id) as conn, translate_db_errors():
oom = await conn.fetchrow("SELECT result_type FROM order_of_merit WHERE id = $1", oom_id) oom = await conn.fetchrow("SELECT result_type, aggregation_mode FROM order_of_merit WHERE id = $1", oom_id)
if oom is None: if oom is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
tournament = await conn.fetchrow( tournament = await conn.fetchrow(
@ -271,6 +329,8 @@ async def link_tournament_to_order_of_merit(
raise app_error( raise app_error(
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit." 400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
) )
if oom["aggregation_mode"] == "eclectic":
await _validate_same_course_for_eclectic(conn, oom_id, body.tournament_id)
result_type = oom["result_type"] result_type = oom["result_type"]
if result_type == "points" and not body.points_table: if result_type == "points" and not body.points_table:
@ -496,6 +556,117 @@ async def remove_order_of_merit_team_member(
raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.") raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")
# ---------------------------------------------------------------------------
# Eclectic-aggregering på tvers av lenkede turneringer (ADR-075) -- sesong-
# "drømmerunde": beste resultat PER HULL på tvers av ALLE en spillers
# lenkede turneringer (bekreftet samme bane av _validate_same_course_for_
# eclectic ved lenking/modus-endring). IKKE det samme som ADR-068 sitt
# Eclectic-format (scoping til RUNDENE innenfor ÉN turnering) -- samme
# motorfunksjon (`eclectic_best_per_hole`) gjenbrukes UENDRET, men
# datainnsamlingen her spenner over FLERE turneringers `tournament_round_
# hole`, ikke bare én. Nøkkelen er `player_id` (samme spiller har ulik
# `tournament_participant_id` per turnering), ikke `tournament_
# participant_id` slik innad-i-én-turnering-varianten (`_attach_eclectic_
# totals` i individual_tournaments.py) bruker.
# ---------------------------------------------------------------------------
async def _compute_eclectic_oom_values(
conn, tournament_ids: list[str], result_type: Literal["gross", "net", "stableford"]
) -> tuple[dict[str, float], dict[str, str], dict[str, int]]:
"""Returnerer (totaler, navn, antall-kilde-turneringer) per spiller.
Eclectic-aggregering ER selve tvers-av-turneringer-sammenslåingen --
det finnes ingen ekstra "sum/snitt av N eclectic-totaler" ovenpå
(`order_of_merit_aggregate` er derfor bevisst IKKE brukt her, se
kalleren)."""
if not tournament_ids:
return {}, {}, {}
course_id = None
for tid in tournament_ids:
course_id = await _course_id_for_tournament(conn, tid)
if course_id is not None:
break
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]
rows = await conn.fetch(
"""
SELECT tr.tournament_id::text AS tournament_id, tr.sequence,
tp.player_id::text AS player_id, p.display_name AS player_name,
trp.course_handicap, trh.hole_number, trh.gross_strokes
FROM tournament_round_hole trh
JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
WHERE tr.tournament_id = ANY($1::uuid[]) AND trh.gross_strokes IS NOT NULL
ORDER BY tr.tournament_id, tr.sequence
""",
tournament_ids,
)
# Sammensatt kilde-indeks: (turnering-id, sekvens) -> global indeks,
# siden `sequence` kun er unik INNENFOR én turnering (speiler
# _attach_eclectic_totals sin sequence_by_index, men på tvers av flere
# turneringer i stedet for kun én).
index_by_source: dict[tuple[str, int], int] = {}
source_by_index: dict[int, tuple[str, int]] = {}
for r in rows:
key = (r["tournament_id"], r["sequence"])
if key not in index_by_source:
idx = len(index_by_source)
index_by_source[key] = idx
source_by_index[idx] = key
allocation_cache: dict[int, list[int]] = {}
values_by_player: dict[str, list[list[EclecticHoleValue]]] = {}
names_by_player: dict[str, str] = {}
for r in rows:
if r["course_handicap"] is None:
continue
pid = r["player_id"]
names_by_player[pid] = r["player_name"]
if pid not in values_by_player:
values_by_player[pid] = [[] for _ in range(18)]
strokes_received = 0
if result_type != "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 result_type == "gross":
value = r["gross_strokes"]
elif result_type == "net":
value = r["gross_strokes"] - strokes_received
else:
value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)
source_index = index_by_source[(r["tournament_id"], r["sequence"])]
values_by_player[pid][r["hole_number"] - 1].append(
EclecticHoleValue(round_index=source_index, value=value)
)
totals_by_player: dict[str, float] = {}
tournament_count_by_player: dict[str, int] = {}
for pid, values_by_hole in values_by_player.items():
result = eclectic_best_per_hole(values_by_hole, result_type)
if not result.holes:
continue
totals_by_player[pid] = float(result.total)
tournament_count_by_player[pid] = len({source_by_index[h.round_index][0] for h in result.holes})
return totals_by_player, names_by_player, tournament_count_by_player
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi # Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
# som Nassau/High-low-high/individual_leaderboard selv). # som Nassau/High-low-high/individual_leaderboard selv).
@ -524,10 +695,14 @@ def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
return 0.0 return 0.0
def _player_eligibility(raw_values: list[float], oom, birth_year: int | None) -> tuple[bool, str | None]: def _player_eligibility(results_available: int, oom, birth_year: int | None) -> tuple[bool, str | None]:
"""Delt mellom spiller- og lag-leaderboardet (et lags medlem er """Delt mellom spiller- og lag-leaderboardet (et lags medlem er
eligibility-sjekket EKSAKT som en spiller ville vært alene).""" eligibility-sjekket EKSAKT som en spiller ville vært alene), OG mellom
results_available = len(raw_values) sum/snitt (`results_available = len(raw_values)`) og eclectic
(`results_available` = antall KILDE-turneringer som faktisk bidro et
hull til sesong-drømmerunden, se _compute_eclectic_oom_values) --
tallet betyr det samme ("hvor mange turneringer talte") uansett hvilken
modus som produserte det."""
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None: if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
if birth_year is None: if birth_year is None:
return False, "Ingen registrert fødselsdato." return False, "Ingen registrert fødselsdato."
@ -571,11 +746,10 @@ async def order_of_merit_leaderboard(
) )
if oom is None: if oom is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.") raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
if oom["aggregation_mode"] == "eclectic":
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
result_type = oom["result_type"] result_type = oom["result_type"]
lower_is_better = result_type in ("gross", "net") lower_is_better = result_type in ("gross", "net")
is_eclectic = oom["aggregation_mode"] == "eclectic"
links = await conn.fetch( links = await conn.fetch(
""" """
@ -587,9 +761,26 @@ async def order_of_merit_leaderboard(
) )
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i] # player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
# (eclectic-modus: nøyaktig ÉN verdi per spiller -- sesongens
# ferdig-sammenslåtte drømmerunde-total, se _compute_eclectic_oom_
# values. "results_available" betyr da antall KILDE-turneringer
# som bidro et hull, ikke antall verdier i denne listen.)
values_by_player: dict[str, list[float]] = {} values_by_player: dict[str, list[float]] = {}
names_by_player: dict[str, str] = {} names_by_player: dict[str, str] = {}
results_available_by_player: dict[str, int] = {}
if is_eclectic:
# ADR-075: krever result_type IN (stableford,gross,net) og
# kind='player', begge håndhevet ved opprettelse/PATCH --
# ingen ekstra sjekk nødvendig her.
tournament_ids = [link["tournament_id"] for link in links]
totals, names_by_player, tournament_counts = await _compute_eclectic_oom_values(
conn, tournament_ids, result_type
)
for player_id, total in totals.items():
values_by_player[player_id] = [total]
results_available_by_player[player_id] = tournament_counts[player_id]
else:
for link in links: for link in links:
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"]) tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
points_table = _decode_json(link["points_table"]) points_table = _decode_json(link["points_table"])
@ -602,6 +793,8 @@ async def order_of_merit_leaderboard(
continue continue
values_by_player.setdefault(e.player_id, []).append(contribution) values_by_player.setdefault(e.player_id, []).append(contribution)
names_by_player[e.player_id] = e.player_name names_by_player[e.player_id] = e.player_name
for player_id, raw_values in values_by_player.items():
results_available_by_player[player_id] = len(raw_values)
# Aldersgrense: filtrert FØR aggregering (spillere utenfor # Aldersgrense: filtrert FØR aggregering (spillere utenfor
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke # fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
@ -620,10 +813,21 @@ async def order_of_merit_leaderboard(
if oom["kind"] == "player": if oom["kind"] == "player":
for player_id, raw_values in values_by_player.items(): for player_id, raw_values in values_by_player.items():
eligible, ineligible_reason = _player_eligibility(raw_values, oom, birth_years.get(player_id)) results_available = results_available_by_player[player_id]
eligible, ineligible_reason = _player_eligibility(results_available, oom, birth_years.get(player_id))
# Eclectic: raw_values er allerede ÉN ferdig-sammenslått
# sesongtotal (se _compute_eclectic_oom_values) -- ingen
# order_of_merit_aggregate-kall (som uansett kun kjenner
# sum/snitt, ikke eclectic, se motoren), og count_best_n
# gir ingen mening ("beste N av ÉN verdi").
if is_eclectic:
value = raw_values[0]
counted = results_available
else:
value = _aggregate_value(raw_values, oom, lower_is_better) value = _aggregate_value(raw_values, oom, lower_is_better)
results_available = len(raw_values) counted = (
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
)
results.append( results.append(
OrderOfMeritLeaderboardEntry( OrderOfMeritLeaderboardEntry(
@ -667,7 +871,9 @@ async def order_of_merit_leaderboard(
raw_values = values_by_player.get(pid) raw_values = values_by_player.get(pid)
if not raw_values: if not raw_values:
continue continue
member_eligible, _ = _player_eligibility(raw_values, oom, birth_years.get(pid)) member_eligible, _ = _player_eligibility(
results_available_by_player[pid], oom, birth_years.get(pid)
)
if not member_eligible: if not member_eligible:
continue continue
member_value = _aggregate_value(raw_values, oom, lower_is_better) member_value = _aggregate_value(raw_values, oom, lower_is_better)

View file

@ -18,7 +18,16 @@ import { cn } from "@/lib/utils"
type OomKind = "player" | "team" type OomKind = "player" | "team"
type OomResultType = "points" | "stableford" | "gross" | "net" | "money" type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
type OomAggregationMode = "sum" | "average" type OomAggregationMode = "sum" | "average" | "eclectic"
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype med
// per-hull-granularitet (stableford/brutto/netto) -- håndhevet server-side
// (order_of_merit.py). Kind/resultattype er låst etter opprettelse her, så
// ingen reset-logikk trengs (i motsetning til order-of-merit-list.tsx sitt
// opprettelsesskjema).
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
}
type ApiOrderOfMerit = { type ApiOrderOfMerit = {
id: string id: string
@ -656,14 +665,25 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Aggregering</span> <span className="text-sm font-semibold text-foreground">Aggregering</span>
<div className="grid grid-cols-2 gap-2"> <div className={`grid grid-cols-2 gap-2 ${eclecticAllowed(oom.kind, oom.result_type) ? "sm:grid-cols-3" : ""}`}>
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}> <SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum Sum
</SettingsSegment> </SettingsSegment>
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}> <SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt Snitt
</SettingsSegment> </SettingsSegment>
{eclecticAllowed(oom.kind, oom.result_type) && (
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
Eclectic
</SettingsSegment>
)}
</div> </div>
{aggregationMode === "eclectic" && (
<p className="text-xs text-muted-foreground">
Eclectic krever at alle lenkede turneringer spilles samme bane -- lenking av en turnering en annen
bane avvises lenge denne modusen er valgt.
</p>
)}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">

View file

@ -19,7 +19,15 @@ import { cn } from "@/lib/utils"
type OomKind = "player" | "team" type OomKind = "player" | "team"
type OomResultType = "points" | "stableford" | "gross" | "net" | "money" type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
type OomAggregationMode = "sum" | "average" type OomAggregationMode = "sum" | "average" | "eclectic"
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype
// med per-hull-granularitet (stableford/brutto/netto) -- håndhevet
// server-side (order_of_merit.py), speilet her for å skjule valget når
// det uansett ville blitt avvist.
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
}
type ApiOrderOfMerit = { type ApiOrderOfMerit = {
id: string id: string
@ -42,7 +50,11 @@ const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
net: "Nettoscore-sum", net: "Nettoscore-sum",
money: "Pengeliste", money: "Pengeliste",
} }
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = { sum: "Sum", average: "Snitt" } const AGGREGATION_LABELS: Record<OomAggregationMode, string> = {
sum: "Sum",
average: "Snitt",
eclectic: "Eclectic (drømmerunde)",
}
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) { export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
const router = useRouter() const router = useRouter()
@ -229,6 +241,17 @@ function CreateForm({
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum") const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const showEclectic = eclecticAllowed(kind, resultType)
function selectKind(k: OomKind) {
setKind(k)
if (aggregationMode === "eclectic" && !eclecticAllowed(k, resultType)) setAggregationMode("sum")
}
function selectResultType(rt: OomResultType) {
setResultType(rt)
if (aggregationMode === "eclectic" && !eclecticAllowed(kind, rt)) setAggregationMode("sum")
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!name.trim() || submitting) return if (!name.trim() || submitting) return
@ -261,10 +284,10 @@ function CreateForm({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Type</span> <span className="text-sm font-semibold text-foreground">Type</span>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}> <SegmentButton selected={kind === "player"} onClick={() => selectKind("player")}>
Spiller Spiller
</SegmentButton> </SegmentButton>
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}> <SegmentButton selected={kind === "team"} onClick={() => selectKind("team")}>
Lag Lag
</SegmentButton> </SegmentButton>
</div> </div>
@ -274,7 +297,7 @@ function CreateForm({
<span className="text-sm font-semibold text-foreground">Resultattype</span> <span className="text-sm font-semibold text-foreground">Resultattype</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => ( {(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
<SegmentButton key={rt} selected={resultType === rt} onClick={() => setResultType(rt)}> <SegmentButton key={rt} selected={resultType === rt} onClick={() => selectResultType(rt)}>
{RESULT_TYPE_LABELS[rt]} {RESULT_TYPE_LABELS[rt]}
</SegmentButton> </SegmentButton>
))} ))}
@ -283,13 +306,18 @@ function CreateForm({
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Aggregering</span> <span className="text-sm font-semibold text-foreground">Aggregering</span>
<div className="grid grid-cols-2 gap-2"> <div className={`grid grid-cols-2 gap-2 ${showEclectic ? "sm:grid-cols-3" : ""}`}>
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}> <SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum Sum
</SegmentButton> </SegmentButton>
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}> <SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt Snitt
</SegmentButton> </SegmentButton>
{showEclectic && (
<SegmentButton selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
Eclectic
</SegmentButton>
)}
</div> </div>
</div> </div>

View file

@ -17,6 +17,7 @@ from app.routers.order_of_merit import (
OrderOfMeritLinkCreate, OrderOfMeritLinkCreate,
OrderOfMeritTeamCreate, OrderOfMeritTeamCreate,
OrderOfMeritTeamMemberCreate, OrderOfMeritTeamMemberCreate,
OrderOfMeritUpdate,
add_order_of_merit_team_member, add_order_of_merit_team_member,
create_order_of_merit, create_order_of_merit,
create_order_of_merit_team, create_order_of_merit_team,
@ -25,6 +26,7 @@ from app.routers.order_of_merit import (
list_order_of_merit_teams, list_order_of_merit_teams,
order_of_merit_leaderboard, order_of_merit_leaderboard,
remove_order_of_merit_team_member, remove_order_of_merit_team_member,
update_order_of_merit,
) )
from tests.conftest import ( from tests.conftest import (
@ -255,3 +257,134 @@ async def test_team_crud_and_kind_guard(pool):
await delete_order_of_merit_team(team_oom.id, team.id, organization_id=org_id) await delete_order_of_merit_team(team_oom.id, team.id, organization_id=org_id)
teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id) teams = await list_order_of_merit_teams(team_oom.id, organization_id=org_id)
assert len(teams) == 0 assert len(teams) == 0
# ---------------------------------------------------------------------------
# Steg 2 (ADR-075): eclectic-aggregering på tvers av lenkede turneringer.
# ---------------------------------------------------------------------------
async def _setup_tournament_on_course(org_id, name, course_id, tee_id, participants: list[tuple[str, str, list[int]]]):
"""participants: liste av (player_id, user_id, [score per hull 1..18])
-- DELER en allerede opprettet bane med kalleren (kreves for eclectic-
testene, der to turneringer ha SAMME course_id)."""
tournament_id = await create_tournament(org_id, name=name)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
tournament_id,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id)
for player_id, user_id, scores in participants:
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id, course_handicap=0)
user = CurrentUser(user_id=user_id)
for n, score in enumerate(scores, start=1):
await tournament_update_hole(
tournament_id, round_id, rp_id, n,
TournamentHoleUpdate(gross_strokes=score, expected_version=None),
organization_id=org_id, user=user,
)
return tournament_id
async def test_eclectic_oom_picks_best_hole_across_tournaments(pool):
"""To turneringer, SAMME bane. Spiller: par (4) på hull 1-9 i A, bogey
(5) hull 10-18 i A -- omvendt i B. Eclectic gross skal plukke par
(4) ALLE 18 hull (9 fra A, 9 fra B) = 72, IKKE 81 (snitt) eller noe
annet -- beviser faktisk kryss-turnering-utvelgelse, samme bevis-
mønster som ADR-068 sin egen innad-i-turnering-test."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
user_id = await create_user()
player_id = await create_player(org_id, user_id=user_id)
course_id = await create_course(org_id, name="Delt bane")
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)
scores_a = [4] * 9 + [5] * 9 # par hull 1-9, bogey hull 10-18
scores_b = [5] * 9 + [4] * 9 # bogey hull 1-9, par hull 10-18
tournament_a = await _setup_tournament_on_course(org_id, "A", course_id, tee_id, [(player_id, user_id, scores_a)])
tournament_b = await _setup_tournament_on_course(org_id, "B", course_id, tee_id, [(player_id, user_id, scores_b)])
oom = await create_order_of_merit(
OrderOfMeritCreate(name="Eclectic-OOM", kind="player", result_type="gross", aggregation_mode="eclectic"),
organization_id=org_id,
)
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id)
leaderboard = await order_of_merit_leaderboard(oom.id, organization_id=org_id)
assert len(leaderboard) == 1
assert leaderboard[0].value == 72.0
assert leaderboard[0].results_available == 2 # begge turneringene bidro minst ett hull
async def test_eclectic_oom_rejects_linking_tournament_on_different_course(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
user_id = await create_user()
player_id = await create_player(org_id, user_id=user_id)
course_a = await create_course(org_id, name="Bane A")
for n in range(1, 19):
await create_org_hole(org_id, course_a, hole_number=n, par=4, stroke_index=n)
tee_a = await create_tee(org_id, course_a)
tournament_a = await _setup_tournament_on_course(org_id, "A", course_a, tee_a, [(player_id, user_id, [4] * 18)])
course_b = await create_course(org_id, name="Bane B")
for n in range(1, 19):
await create_org_hole(org_id, course_b, hole_number=n, par=4, stroke_index=n)
tee_b = await create_tee(org_id, course_b)
tournament_b = await _setup_tournament_on_course(org_id, "B", course_b, tee_b, [(player_id, user_id, [5] * 18)])
oom = await create_order_of_merit(
OrderOfMeritCreate(name="Eclectic-OOM", kind="player", result_type="gross", aggregation_mode="eclectic"),
organization_id=org_id,
)
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
with pytest.raises(HTTPException) as exc_info:
await link_tournament_to_order_of_merit(
oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id
)
assert exc_info.value.status_code == 400
async def test_eclectic_oom_rejects_mode_switch_with_existing_different_course_links(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
user_id = await create_user()
player_id = await create_player(org_id, user_id=user_id)
course_a = await create_course(org_id, name="Bane A")
for n in range(1, 19):
await create_org_hole(org_id, course_a, hole_number=n, par=4, stroke_index=n)
tee_a = await create_tee(org_id, course_a)
tournament_a = await _setup_tournament_on_course(org_id, "A", course_a, tee_a, [(player_id, user_id, [4] * 18)])
course_b = await create_course(org_id, name="Bane B")
for n in range(1, 19):
await create_org_hole(org_id, course_b, hole_number=n, par=4, stroke_index=n)
tee_b = await create_tee(org_id, course_b)
tournament_b = await _setup_tournament_on_course(org_id, "B", course_b, tee_b, [(player_id, user_id, [5] * 18)])
# Opprettes med sum (ikke eclectic), slik at begge ulikbane-turneringene
# kan lenkes uten å treffe lenke-tidspunktets sjekk -- selve bug-en som
# skal testes er modus-BYTTET etterpå.
oom = await create_order_of_merit(
OrderOfMeritCreate(name="Sum-OOM", kind="player", result_type="gross", aggregation_mode="sum"),
organization_id=org_id,
)
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_a), organization_id=org_id)
await link_tournament_to_order_of_merit(oom.id, OrderOfMeritLinkCreate(tournament_id=tournament_b), organization_id=org_id)
with pytest.raises(HTTPException) as exc_info:
await update_order_of_merit(
oom.id, OrderOfMeritUpdate(aggregation_mode="eclectic"), organization_id=org_id
)
assert exc_info.value.status_code == 400