Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en Order of Merit, lenke individuelle turneringer til den, og få en sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper (poeng etter plassering, Stableford-, brutto- og nettoscore-sum, pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste, minimum antall resultater og aldersgrense. Motor i handicap_engine.py (9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye håndkodede frontend-sider under /organizations/[id]/order-of-merit. Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men leaderboardet avviser kind='team' med en tydelig feilmelding). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
aee1662de2
commit
fea701091c
14 changed files with 2137 additions and 51 deletions
117
055_order_of_merit.sql
Normal file
117
055_order_of_merit.sql
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — Order of Merit: sesong-sammenlagt rangering på tvers av
|
||||||
|
-- flere individuelle turneringer (migrasjon 055, ADR-043 [OOM])
|
||||||
|
-- =====================================================================
|
||||||
|
-- Bygges i seks stadier (se plan-loggen 2026-08-04) -- denne migrasjonen
|
||||||
|
-- dekker skjemaet for ALLE stadier på én gang (billig å legge til
|
||||||
|
-- kolonner nå, dyrere å migrere skjema seks ganger), men selve
|
||||||
|
-- BACKEND-logikken bygges stadium for stadium, scratch-verifisert
|
||||||
|
-- mellom hvert.
|
||||||
|
--
|
||||||
|
-- order_of_merit: selve OOM-en. `kind` ('player'|'team') låses etter
|
||||||
|
-- opprettelse i app-laget, som GolfBox sin egen tilsvarende låsing.
|
||||||
|
-- `result_type` (poeng/stableford/brutto/netto/pengeliste) og
|
||||||
|
-- `aggregation_mode` (sum/snitt/eclectic) er de to uavhengige aksene
|
||||||
|
-- GolfBox sitt gode mønster besto av -- HVA telles vs. HVORDAN det
|
||||||
|
-- summeres. `count_best_n`/`min_results_required`/`birth_year_from/to`
|
||||||
|
-- er alle valgfrie grenser (NULL = ingen grense), lagt til fra
|
||||||
|
-- stadium 3, men kolonnene finnes fra start.
|
||||||
|
-- order_of_merit_link: hvilke individuelle turneringer (ADR-037) som
|
||||||
|
-- teller mot en gitt OOM, pluss per-lenke-konfig for resultattyper
|
||||||
|
-- som trenger det (poeng-tabell, pengepremie-tabell) -- disse to
|
||||||
|
-- JSONB-feltene er NULL med mindre `result_type` faktisk krever dem
|
||||||
|
-- (håndhevet i app-laget, ikke en CHECK, siden reglene er
|
||||||
|
-- resultattype-avhengige).
|
||||||
|
-- order_of_merit_team/_team_member: KUN for kind='team' -- et
|
||||||
|
-- sesong-lag satt opp DIREKTE i OOM-en (ikke hentet fra noen
|
||||||
|
-- turnering), som summerer/velger-beste-N-av medlemmenes ALLEREDE
|
||||||
|
-- beregnede individuelle OOM-resultater. Bevisst IKKE koblet til
|
||||||
|
-- `team`/`team_roster` (ADR-011s lagturneringer) -- disse er
|
||||||
|
-- strukturelt urelaterte konsepter (ett er en enkelt-turnering-
|
||||||
|
-- kamp-side, det andre er en sesong-lang gruppering av spillere).
|
||||||
|
--
|
||||||
|
-- Bevisst forenklet vs. GolfBox (ikke glemt, kan utvides senere uten
|
||||||
|
-- brudd): ingen gjenbrukbart "grense-mal"-system på tvers av flere OOM
|
||||||
|
-- (hver OOM har sine egne grenser direkte på raden); ingen "verst N"
|
||||||
|
-- eller kombinert beste+verst, kun "behold de N beste".
|
||||||
|
-- =====================================================================
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
CREATE TABLE order_of_merit (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL REFERENCES organization (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('player', 'team')),
|
||||||
|
result_type text NOT NULL CHECK (result_type IN ('points', 'stableford', 'gross', 'net', 'money')),
|
||||||
|
aggregation_mode text NOT NULL DEFAULT 'sum' CHECK (aggregation_mode IN ('sum', 'average', 'eclectic')),
|
||||||
|
count_best_n smallint CHECK (count_best_n IS NULL OR count_best_n >= 1),
|
||||||
|
min_results_required smallint CHECK (min_results_required IS NULL OR min_results_required >= 1),
|
||||||
|
birth_year_from smallint,
|
||||||
|
birth_year_to smallint,
|
||||||
|
public_visible boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (organization_id, id),
|
||||||
|
-- Eclectic krever per-hull-granularitet -- poeng/pengeliste har ingen
|
||||||
|
-- "beste hull" å hente (samme begrensning GolfBox selv håndhever).
|
||||||
|
CHECK (aggregation_mode <> 'eclectic' OR result_type IN ('stableford', 'gross', 'net'))
|
||||||
|
);
|
||||||
|
CREATE INDEX ON order_of_merit (organization_id);
|
||||||
|
|
||||||
|
CREATE TABLE order_of_merit_link (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL,
|
||||||
|
order_of_merit_id uuid NOT NULL REFERENCES order_of_merit (id) ON DELETE CASCADE,
|
||||||
|
tournament_id uuid NOT NULL,
|
||||||
|
-- Kun result_type='points': liste av heltall, indeks 0 = poeng for
|
||||||
|
-- 1. plass, indeks 1 = 2. plass, osv.
|
||||||
|
points_table jsonb,
|
||||||
|
-- Kun result_type='money': premiepott + utbetaling per plassering.
|
||||||
|
money_pool_total numeric(10, 2),
|
||||||
|
money_payout_table jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (organization_id, tournament_id) REFERENCES tournament (organization_id, id) ON DELETE CASCADE,
|
||||||
|
UNIQUE (order_of_merit_id, tournament_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ON order_of_merit_link (organization_id, order_of_merit_id);
|
||||||
|
|
||||||
|
CREATE TABLE order_of_merit_team (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL,
|
||||||
|
order_of_merit_id uuid NOT NULL REFERENCES order_of_merit (id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (order_of_merit_id, name)
|
||||||
|
);
|
||||||
|
CREATE INDEX ON order_of_merit_team (organization_id, order_of_merit_id);
|
||||||
|
|
||||||
|
CREATE TABLE order_of_merit_team_member (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL,
|
||||||
|
order_of_merit_team_id uuid NOT NULL REFERENCES order_of_merit_team (id) ON DELETE CASCADE,
|
||||||
|
player_id uuid NOT NULL REFERENCES player (id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (order_of_merit_team_id, player_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX ON order_of_merit_team_member (organization_id, order_of_merit_team_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Row-Level Security (samme org_isolation-policy-mønster som 040/053)
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY[
|
||||||
|
'order_of_merit', 'order_of_merit_link',
|
||||||
|
'order_of_merit_team', 'order_of_merit_team_member'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t);
|
||||||
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t);
|
||||||
|
EXECUTE format($p$
|
||||||
|
CREATE POLICY org_isolation ON %I
|
||||||
|
USING (organization_id = current_setting('app.current_org', true)::uuid)
|
||||||
|
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
|
||||||
|
$p$, t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
@ -3978,6 +3978,97 @@ nettleser-verifisering på tvers av to brukere/to organisasjoner).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-043: Order of Merit — sesong-sammenlagt rangering [OOM]
|
||||||
|
|
||||||
|
**Kontekst:** Brukeren ba om en vurdering av GolfBox sin "Order of
|
||||||
|
Merit"-funksjon (opplastet PDF) og om noe var verdt å adoptere. Svaret
|
||||||
|
var "ja, delvis": skillet mellom HVA som telles per turnering
|
||||||
|
(`result_type`) og HVORDAN det summeres over en sesong
|
||||||
|
(`aggregation_mode`) er et genuint godt, gjenbrukbart mønster —
|
||||||
|
GolfBox sin lag-kobling (strengmatching på lagnavn på tvers av
|
||||||
|
turneringer) ble eksplisitt flagget som noe å IKKE kopiere, siden
|
||||||
|
TeeCup allerede har ekte `player_id`-identitet på tvers av hele
|
||||||
|
organisasjonen.
|
||||||
|
|
||||||
|
**Beslutning A — Kun individuelle turneringer kan lenkes.** En OOM
|
||||||
|
(`order_of_merit`, migrasjon 055) lenker KUN `tournament.format_type =
|
||||||
|
'individual'`-turneringer (ADR-037) — ikke org-lagturneringer (ADR-011),
|
||||||
|
av samme grunn som konkurranseklassenes leaderboard-splitting (ADR-041):
|
||||||
|
poeng i en lagturnering er knyttet til hele kamper, ikke enkeltspillere.
|
||||||
|
|
||||||
|
**Beslutning B — To uavhengige akser, fem resultattyper.** `result_type`
|
||||||
|
(poeng-etter-plassering/Stableford-sum/brutto/netto/pengeliste) utleder
|
||||||
|
HVA som telles fra en lenket turnerings EGEN, allerede beregnede
|
||||||
|
leaderboard (gjenbruker `_compute_individual_standings`, faktorisert ut
|
||||||
|
av `individual_tournaments.py` sin `individual_leaderboard` 2026-08-04 —
|
||||||
|
ingen duplisering av posisjon-/til-par-beregningen).
|
||||||
|
`aggregation_mode` (sum/snitt/eclectic) avgjør HVORDAN dette summeres
|
||||||
|
over flere turneringer, uavhengig av `result_type`. Poeng/pengeliste
|
||||||
|
trenger en per-lenke konfigurasjon (`order_of_merit_link.points_table`/
|
||||||
|
`money_payout_table`, JSONB) siden ulike turneringer i samme OOM kan gi
|
||||||
|
ulik poengverdi for samme plassering.
|
||||||
|
|
||||||
|
**Beslutning C — "Lag" i en OOM er et sesong-par satt opp DIREKTE i
|
||||||
|
OOM-en, ikke hentet fra noen turnering.** Avklart eksplisitt med bruker
|
||||||
|
etter at jeg flagget en strukturell floke (individuelle turneringer har
|
||||||
|
per definisjon ingen lag, ADR-037): `order_of_merit_team`/
|
||||||
|
`_team_member` (migrasjon 055) er en REN gruppering av spillere oppå
|
||||||
|
deres allerede beregnede individuelle OOM-resultater — ingen
|
||||||
|
strengmatching, ingen ny turnering-struktur. Bevisst IKKE bygget i denne
|
||||||
|
runden (se FEATURE_BACKLOG.md).
|
||||||
|
|
||||||
|
**Beslutning D — `kind` og `result_type` låses etter opprettelse.**
|
||||||
|
Matcher GolfBox sin egen tilsvarende låsing, strukket til også å dekke
|
||||||
|
`result_type` her: å endre den i etterkant ville gjort eksisterende
|
||||||
|
lenkers poeng-/pengetabell-konfig meningsløs uten en egen
|
||||||
|
migreringssti. `PATCH`-endepunktet eksponerer bevisst ikke disse to
|
||||||
|
feltene i det hele tatt (ikke bare app-lags-validering) — opprett en ny
|
||||||
|
OOM i stedet.
|
||||||
|
|
||||||
|
**Beslutning E — "Regn ut ved lesing", ingen cache-tabell.** Samme
|
||||||
|
filosofi som Nassau/High-low-high/`individual_leaderboard` selv —
|
||||||
|
OOM-leaderboardet beregnes helt på nytt ved hvert `GET`-kall (henter
|
||||||
|
alle lenkede turneringers standings, utleder bidrag per spiller, filtrerer
|
||||||
|
på aldersgrense/minimum-resultater, aggregerer med
|
||||||
|
`order_of_merit_aggregate`, rangerer). Ingen ytelsesbekymring i praksis
|
||||||
|
gitt forventet skala (en sesong har typisk noen få til noen titalls
|
||||||
|
lenkede turneringer).
|
||||||
|
|
||||||
|
**Bevisst utenfor omfang v1 (se FEATURE_BACKLOG.md for full liste):**
|
||||||
|
Eclectic-aggregering (per-hull "drømmerunde" på tvers av lenkede
|
||||||
|
turneringer — krever egen datainnsamlingslogikk, ikke bare en ny
|
||||||
|
aggregeringsmodus); Lag-OOM sin faktiske leaderboard-beregning (CRUD-
|
||||||
|
skjema finnes fra migrasjon 055, men `GET .../leaderboard` avviser
|
||||||
|
eksplisitt `kind='team'` med en tydelig feilmelding inntil videre).
|
||||||
|
|
||||||
|
**Motor** (`handicap_engine.py`): `order_of_merit_points_for_position`
|
||||||
|
(uavgjort deler SAMME poengverdi ved sin felles plassering, ikke
|
||||||
|
gjennomsnitt) og `order_of_merit_aggregate` (sum/snitt, valgfri
|
||||||
|
behold-N-beste — "best" er alltid størst-er-bedre, kalleren negerer
|
||||||
|
brutto/netto-verdier før kall). 9 nye enhetstester, alle grønne.
|
||||||
|
|
||||||
|
**Frontend:** to nye sider (`/organizations/[id]/order-of-merit` liste
|
||||||
|
+ opprett, `.../[oomId]` detalj: lenkede turneringer → innstillinger →
|
||||||
|
resultatliste, i den rekkefølgen). Håndkodet (ikke V0 — ingen
|
||||||
|
V0-credits tilgjengelig denne runden), bevisst bygget til samme
|
||||||
|
visuelle presisjon som appens V0-eksporter (samme tokens/kort-mønster
|
||||||
|
som `org-members.tsx`, samme gull-ledertrøye-mønster som
|
||||||
|
`stroke-play-leaderboard.tsx`). Ny inngangslenke fra `org-members.tsx`.
|
||||||
|
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-/frontend-container): 63/63
|
||||||
|
håndregnede API-sjekker (fem resultattyper, uavgjort-håndtering,
|
||||||
|
behold-N-beste, minimum-resultater, aldersgrense, låst `kind`/
|
||||||
|
`result_type`), RLS-isolasjon eksplisitt testet på de fire nye
|
||||||
|
tabellene (kryss-org-lesing gir 0 rader, kryss-org-skriving blokkeres av
|
||||||
|
`WITH CHECK`), `test_isolation.sql` 12/12 uendret, ekte
|
||||||
|
nettleser-gjennomgang (liste, opprettelse, lenke turnering inkl.
|
||||||
|
poeng-tabell-skjemaet, innstillinger, gull-ledertrøye, ikke-kvalifisert-
|
||||||
|
seksjon). Full detalj i CHANGELOG.md 2026-08-04.
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
109
CHANGELOG.md
109
CHANGELOG.md
|
|
@ -7388,3 +7388,112 @@ Neste steg:
|
||||||
databrudd), `docker compose up -d --build teecup_api
|
databrudd), `docker compose up -d --build teecup_api
|
||||||
teecup_frontend`, begge containere boot-et rent, `/health` og
|
teecup_frontend`, begge containere boot-et rent, `/health` og
|
||||||
`/dashboard` → 200 over https.
|
`/dashboard` → 200 over https.
|
||||||
|
|
||||||
|
20. **Order of Merit (sesong-sammenlagt rangering på tvers av
|
||||||
|
turneringer) — BYGGET, GRUNDIG SCRATCH-VERIFISERT OG LIVE 2026-08-04
|
||||||
|
(ADR-043, migrasjon 055).**
|
||||||
|
Brukeren ba om en vurdering av GolfBox sin Order of Merit-funksjon
|
||||||
|
(opplastet PDF) og tok deretter designet til en full plan-runde (to
|
||||||
|
`AskUserQuestion`-runder + et oppfølgingsspørsmål som avklarte at
|
||||||
|
"lag" i en OOM er et sesong-par satt opp DIREKTE i OOM-en, ikke
|
||||||
|
hentet fra noen turnering -- unngår GolfBox sin egen identifiserte
|
||||||
|
svakhet, strengmatching på lagnavn, siden TeeCup allerede har ekte
|
||||||
|
`player_id`-identitet på tvers av organisasjonen).
|
||||||
|
|
||||||
|
**Skjema** (migrasjon 055): `order_of_merit`/`order_of_merit_link`/
|
||||||
|
`order_of_merit_team`/`order_of_merit_team_member`, org-isolert RLS
|
||||||
|
(samme mønster som migrasjon 040/053). To uavhengige akser --
|
||||||
|
`result_type` (poeng-etter-plassering/Stableford-sum/brutto/netto/
|
||||||
|
pengeliste, HVA som telles) og `aggregation_mode` (sum/snitt/
|
||||||
|
eclectic, HVORDAN det summeres over en sesong).
|
||||||
|
|
||||||
|
**Motor** (`handicap_engine.py`): `order_of_merit_points_for_position`
|
||||||
|
(uavgjort deler SAMME poengverdi ved sin felles plassering, ikke
|
||||||
|
gjennomsnitt) og `order_of_merit_aggregate` (sum/snitt, valgfri
|
||||||
|
behold-N-beste, "best" er alltid størst-er-bedre -- kalleren negerer
|
||||||
|
brutto/netto-verdier). 9 nye enhetstester skrevet FØR resten av
|
||||||
|
bygget, alle grønne (107/107 totalt i `test_handicap_engine.py`).
|
||||||
|
|
||||||
|
**Backend** (nytt `app/routers/order_of_merit.py`): full CRUD for
|
||||||
|
selve OOM-en og dens lenkede turneringer, pluss et "regn ut ved
|
||||||
|
lesing"-leaderboard-endepunkt (samme filosofi som Nassau/
|
||||||
|
High-low-high -- ingen cache-tabell). `_compute_individual_standings`
|
||||||
|
faktorisert ut av `individual_tournaments.py` sin `individual_
|
||||||
|
leaderboard` (som nå kun er en tynn wrapper rundt den) for gjenbruk
|
||||||
|
her uten å duplisere posisjon-/til-par-beregningen -- `LeaderboardEntry`
|
||||||
|
fikk samtidig et nytt `player_id`-felt (manglet fra før, kun
|
||||||
|
`tournament_participant_id` fantes, utilstrekkelig for å summere én
|
||||||
|
ekte spiller på tvers av FLERE turneringers deltaker-rader).
|
||||||
|
`kind`/`result_type` låses etter opprettelse (PATCH-endepunktet
|
||||||
|
eksponerer dem bevisst ikke i det hele tatt). Denne runden dekker
|
||||||
|
spiller-OOM med alle fem resultattyper, sum/snitt-aggregering, og
|
||||||
|
behold-N-beste/minimum-resultater/aldersgrense-grenser (fødselsår
|
||||||
|
hentet fra `player.birth_date`, migrasjon 007). Eclectic-aggregering
|
||||||
|
og lag-OOM sin faktiske leaderboard-beregning er bevisst IKKE bygget
|
||||||
|
denne runden (schema og CRUD for lag finnes, men `GET .../leaderboard`
|
||||||
|
avviser eksplisitt `kind='team'` med en tydelig feilmelding) -- se
|
||||||
|
FEATURE_BACKLOG.md.
|
||||||
|
|
||||||
|
**Frontend** (`order-of-merit-list.tsx`/`order-of-merit-detail.tsx`,
|
||||||
|
nye ruter under `/organizations/[id]/order-of-merit`): håndkodet, IKKE
|
||||||
|
via V0 -- brukeren gikk tom for V0-credits midt i planleggingen av
|
||||||
|
frontend-tilnærmingen og ba om at det håndkodes "så lekkert som V0
|
||||||
|
får til" i stedet. Bygget til å gjenbruke etablerte visuelle mønstre
|
||||||
|
presist (samme header-/kort-stil som `org-members.tsx`, samme
|
||||||
|
gull-ledertrøye-behandling -- `bg-gold`/`text-gold-foreground` -- som
|
||||||
|
`stroke-play-leaderboard.tsx`). Detaljsiden har fire seksjoner i en
|
||||||
|
bevisst rekkefølge (lenkede turneringer → innstillinger →
|
||||||
|
resultatliste, siden resultater ikke gir mening før turneringer er
|
||||||
|
lenket), inkl. en egen "Ikke kvalifisert"-seksjon for spillere
|
||||||
|
ekskludert av aldersgrense/minimum-resultater med forklarende tekst
|
||||||
|
per rad. Ny inngangslenke lagt til i `org-members.tsx` (ingen egen
|
||||||
|
"org-hub"-side fantes fra før -- medlemssiden er i praksis allerede
|
||||||
|
det, samme mønster som appen ellers navigerer dit fra dashbordets
|
||||||
|
org-nedtrekksmeny).
|
||||||
|
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-/frontend-container, ekte
|
||||||
|
nettleser-innlogging inkl. 2FA):
|
||||||
|
- API-nivå: eget Python-testskript, 63/63 håndregnede sjekker bestått
|
||||||
|
(bevisst konstruert 3-spiller/2-turnering-scenario med kjent
|
||||||
|
Stableford/brutto/poeng/penge-utfall for hver av de fem
|
||||||
|
resultattypene, inkl. en ekte uavgjort i pengeliste-testen som
|
||||||
|
bekreftet "T1"/"T1"/"3"-skip-rank-oppførselen, behold-de-1-beste,
|
||||||
|
minimum-2-resultater-ekskludering, og et eksplisitt aldersfilter-
|
||||||
|
scenario der to av tre spillere korrekt ble ekskludert).
|
||||||
|
- RLS-isolasjon eksplisitt testet på de fire nye tabellene utover
|
||||||
|
`test_isolation.sql` (som uendret består 12/12): kryss-org-lesing
|
||||||
|
av `order_of_merit`/`order_of_merit_link` gir 0 rader under feil
|
||||||
|
`app.current_org`, kryss-org-innsetting blokkeres av `WITH CHECK`.
|
||||||
|
- Ekte nettleser: hele flyten klikket gjennom som innlogget
|
||||||
|
organisasjonseier -- opprett OOM (alle fem resultattyper/kind-valg
|
||||||
|
i skjemaet), lenk en turnering med poeng-tabell-radeditoren (inkl.
|
||||||
|
å faktisk lenke en tredje turnering og se den dukke opp i listen
|
||||||
|
med riktig "Poeng: 10-6-3"-oppsummering), utvid/kollaps
|
||||||
|
innstillinger-seksjonen og bekreft alle feltverdier viste riktig
|
||||||
|
(inkl. aldersgrense-feltene 1985/2000), og bekreftet resultatlisten
|
||||||
|
viste EKSAKT de håndregnede tallene fra API-testen (gull-fremhevet
|
||||||
|
leder, riktig ekskluderte spillere med "Utenfor aldersgrensen."-
|
||||||
|
forklaring).
|
||||||
|
- Ekte typesjekket produksjonsbuild (`docker build`, samme steg som
|
||||||
|
`Dockerfile` faktisk bruker) -- måtte kjøres på nytt med lengre
|
||||||
|
timeout enn standard 2 minutter, selve bygget var uendret vellykket.
|
||||||
|
Scratch-miljøet (database, rolle, MinIO, containere, images,
|
||||||
|
midlertidige hemmeligheter) fullstendig ryddet opp etter
|
||||||
|
verifisering, TO GANGER (én gang etter backend-verifisering, én gang
|
||||||
|
etter frontend-verifisering).
|
||||||
|
|
||||||
|
**Kjent selvrettet feil under bygging:** funksjonen ble først
|
||||||
|
kodekommentert som "ADR-042", som viste seg allerede å være tatt
|
||||||
|
(bane-mal-biblioteket fra samme dag, punkt 19 over) -- oppdaget og
|
||||||
|
rettet til ADR-043 gjennomgående (migrasjon, motor, router,
|
||||||
|
frontend) FØR dokumentasjon ble skrevet, ingen funksjonell påvirkning
|
||||||
|
(kun kommentarer/docstrings, ingen kode leser ADR-nummeret).
|
||||||
|
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt ("Ja
|
||||||
|
takk"): migrasjon 055 kjørt mot ekte `teecup_db` (fire nye tabeller,
|
||||||
|
bekreftet tomme/riktig strukturert, ingen databrudd), `docker
|
||||||
|
compose up -d --build teecup_api teecup_frontend`, begge containere
|
||||||
|
boot-et rent, `/health` og `/dashboard` → 200 over https. Gjenstår
|
||||||
|
fra opprinnelig plan (se plan-loggen 2026-08-04): Eclectic-
|
||||||
|
aggregering, lag-OOM sin faktiske leaderboard-beregning.
|
||||||
|
|
|
||||||
|
|
@ -4094,6 +4094,42 @@ denne veien er plattform-offentlig).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Order of Merit (sesong-sammenlagt rangering) — ✅ SPILLER-OOM BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04 (ADR-043, migrasjon 055). Eclectic + lag-OOM 📋 GJENSTÅR
|
||||||
|
|
||||||
|
Brukeren ba om en vurdering av GolfBox sin Order of Merit-funksjon og
|
||||||
|
tok den til en full designrunde. Bygget denne runden: skjema (fire nye
|
||||||
|
tabeller), motor (`order_of_merit_points_for_position`/`_aggregate`),
|
||||||
|
full CRUD + "regn ut ved lesing"-leaderboard for SPILLER-OOM med alle
|
||||||
|
fem resultattyper (poeng/Stableford/brutto/netto/pengeliste), sum/snitt-
|
||||||
|
aggregering, og behold-N-beste/minimum-resultater/aldersgrense-grenser.
|
||||||
|
Håndkodet frontend (liste + detalj) siden brukeren gikk tom for
|
||||||
|
V0-credits midt i planleggingen av frontend-tilnærmingen. Se ADR-043
|
||||||
|
for de fem arkitektoniske beslutningene og CHANGELOG.md 2026-08-04 for
|
||||||
|
full bygge-/verifiseringsdetalj (63/63 håndregnede API-sjekker + RLS-
|
||||||
|
isolasjon + ekte nettleser-gjennomgang).
|
||||||
|
|
||||||
|
**Gjenstår fra opprinnelig plan (bevisst utsatt, ikke glemt):**
|
||||||
|
- **Eclectic-aggregering** ("drømmerunde" -- beste resultat PER HULL på
|
||||||
|
tvers av alle lenkede turneringer en spiller deltok i, kun for
|
||||||
|
resultattype Stableford/brutto/netto). Krever egen per-hull-data-
|
||||||
|
innsamlingslogikk i leaderboard-endepunktet (ikke bare en ny gren i
|
||||||
|
`order_of_merit_aggregate`, som kun håndterer sum/snitt) -- egen
|
||||||
|
design-/verifiseringsrunde.
|
||||||
|
- **Lag-OOM sin faktiske leaderboard-beregning.** Skjema
|
||||||
|
(`order_of_merit_team`/`_team_member`) og prinsippet (sesong-par satt
|
||||||
|
opp DIREKTE i OOM-en, summerer/velger-beste-N-av medlemmenes allerede
|
||||||
|
beregnede individuelle OOM-resultater -- IKKE strengmatching på
|
||||||
|
turnering-lag, se ADR-043 Beslutning C) er avklart og bygget inn i
|
||||||
|
skjemaet, men selve `GET .../leaderboard`-grenen for `kind='team'`
|
||||||
|
finnes ikke ennå (avviser eksplisitt med en tydelig feilmelding).
|
||||||
|
Trenger CRUD-endepunkter for lag/medlemmer (ingen finnes ennå) pluss
|
||||||
|
selve aggregering-oppå-aggregering-logikken.
|
||||||
|
- **Offentlig/delt visning.** `public_visible`-feltet finnes og er
|
||||||
|
redigerbart i innstillinger, men ingen egen, ikke-innlogget-tilgjengelig
|
||||||
|
visning er bygget (kun den vanlige org-scopede detaljsiden).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Bevisst endret fra opprinnelige (Gemini-)råd
|
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||||
|
|
||||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from .routers import (
|
||||||
matches,
|
matches,
|
||||||
messaging,
|
messaging,
|
||||||
notifications,
|
notifications,
|
||||||
|
order_of_merit,
|
||||||
organizations,
|
organizations,
|
||||||
players,
|
players,
|
||||||
registration,
|
registration,
|
||||||
|
|
@ -51,6 +52,7 @@ app.include_router(players.router)
|
||||||
app.include_router(courses.router)
|
app.include_router(courses.router)
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(individual_tournaments.router)
|
app.include_router(individual_tournaments.router)
|
||||||
|
app.include_router(order_of_merit.router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
app.include_router(scoring.router)
|
app.include_router(scoring.router)
|
||||||
app.include_router(registration.router)
|
app.include_router(registration.router)
|
||||||
|
|
|
||||||
|
|
@ -1032,6 +1032,10 @@ class RoundCellOut(BaseModel):
|
||||||
|
|
||||||
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
|
||||||
|
# spilleren, ikke bare denne ENE turneringens deltaker-rad, for å
|
||||||
|
# summere resultater på tvers av flere lenkede turneringer.
|
||||||
|
player_id: str
|
||||||
player_name: str
|
player_name: str
|
||||||
rounds_played: int
|
rounds_played: int
|
||||||
gross_total: int | None
|
gross_total: int | None
|
||||||
|
|
@ -1056,19 +1060,18 @@ class LeaderboardEntry(BaseModel):
|
||||||
rounds: list[RoundCellOut] = []
|
rounds: list[RoundCellOut] = []
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]:
|
||||||
# IKKE /leaderboard -- den stien er allerede tournaments.py sitt
|
"""Delt mellom `individual_leaderboard` under (denne ENE turneringens
|
||||||
# LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
|
egen visning) og Order of Merit sitt leaderboard-endepunkt
|
||||||
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
(`order_of_merit.py`, som summerer resultater over FLERE lenkede
|
||||||
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
turneringer) -- faktorisert ut 2026-08-04 for å unngå å duplisere
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
posisjon-/til-par-beregningen (inkl. den til-par-normaliserte
|
||||||
response_model=list[LeaderboardEntry],
|
rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`).
|
||||||
)
|
|
||||||
async def individual_leaderboard(
|
Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng
|
||||||
tournament_id: str,
|
trenger `tournament_row["scoring_method"]` for å vite hvilket felt på
|
||||||
organization_id: str = Depends(get_authorized_org),
|
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
||||||
) -> list[LeaderboardEntry]:
|
som faktisk er meningsfullt å lese videre."""
|
||||||
async with org_connection(organization_id) as conn:
|
|
||||||
tournament = await conn.fetchrow(
|
tournament = await conn.fetchrow(
|
||||||
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
||||||
)
|
)
|
||||||
|
|
@ -1079,7 +1082,8 @@ async def individual_leaderboard(
|
||||||
|
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name,
|
SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
|
||||||
|
p.display_name AS player_name,
|
||||||
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
||||||
SUM(trs.gross_total)::int AS gross_total,
|
SUM(trs.gross_total)::int AS gross_total,
|
||||||
SUM(trs.net_total)::int AS net_total,
|
SUM(trs.net_total)::int AS net_total,
|
||||||
|
|
@ -1119,6 +1123,23 @@ async def individual_leaderboard(
|
||||||
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))
|
||||||
|
|
||||||
|
return dict(tournament), entries
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
# IKKE /leaderboard -- den stien er allerede tournaments.py sitt
|
||||||
|
# LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
|
||||||
|
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
||||||
|
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
||||||
|
response_model=list[LeaderboardEntry],
|
||||||
|
)
|
||||||
|
async def individual_leaderboard(
|
||||||
|
tournament_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[LeaderboardEntry]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
_, entries = await _compute_individual_standings(conn, tournament_id)
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
511
app/routers/order_of_merit.py
Normal file
511
app/routers/order_of_merit.py
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
"""
|
||||||
|
Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering på tvers av
|
||||||
|
FLERE individuelle turneringer (`tournament.format_type = 'individual'`,
|
||||||
|
ADR-037) -- IKKE org-lagturneringer (ADR-011, poeng er der knyttet til
|
||||||
|
hele kamper, ikke enkeltspillere, samme begrensning som
|
||||||
|
konkurranseklassenes leaderboard-splitting, ADR-041).
|
||||||
|
|
||||||
|
To uavhengige akser (GolfBox-inspirert, se plan-loggen 2026-08-04):
|
||||||
|
HVA telles per lenket turnering (`result_type`: poeng-etter-plassering/
|
||||||
|
Stableford-sum/brutto/netto/pengeliste) og HVORDAN det summeres over
|
||||||
|
flere turneringer (`aggregation_mode`: sum/snitt/eclectic -- eclectic
|
||||||
|
ikke bygget i denne runden, se FEATURE_BACKLOG.md).
|
||||||
|
|
||||||
|
Gjenbruker `_compute_individual_standings` fra individual_tournaments.py
|
||||||
|
for HVER lenket turnering sin egen leaderboard/plassering -- ingen
|
||||||
|
duplisering av posisjon-/til-par-beregningen. "Regn ut ved lesing" for
|
||||||
|
selve OOM-leaderboardet også (samme filosofi som Nassau/High-low-high/
|
||||||
|
individual_leaderboard selv) -- ingen cache-tabell.
|
||||||
|
|
||||||
|
Bygget i stadier (se plan-loggen): denne runden dekker spiller-OOM med
|
||||||
|
poeng/stableford/brutto/netto/pengeliste-resultattyper, sum/snitt-
|
||||||
|
aggregering, og behold-N-beste/minimum-resultater/aldersgrense-grenser.
|
||||||
|
Lag-OOM og Eclectic-aggregering er bevisst IKKE bygget ennå (egne,
|
||||||
|
senere stadier) -- se FEATURE_BACKLOG.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from ..auth import get_authorized_org
|
||||||
|
from ..db import org_connection
|
||||||
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from handicap_engine import order_of_merit_aggregate, order_of_merit_points_for_position
|
||||||
|
|
||||||
|
from .individual_tournaments import _compute_individual_standings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_RESULT_TYPES = Literal["points", "stableford", "gross", "net", "money"]
|
||||||
|
_AGGREGATION_MODES = Literal["sum", "average", "eclectic"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRUD: selve OOM-en
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OrderOfMeritCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
kind: Literal["player", "team"]
|
||||||
|
result_type: _RESULT_TYPES
|
||||||
|
aggregation_mode: _AGGREGATION_MODES = "sum"
|
||||||
|
count_best_n: int | None = Field(default=None, ge=1)
|
||||||
|
min_results_required: int | None = Field(default=None, ge=1)
|
||||||
|
birth_year_from: int | None = None
|
||||||
|
birth_year_to: int | None = None
|
||||||
|
public_visible: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class OrderOfMeritUpdate(BaseModel):
|
||||||
|
"""Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
|
||||||
|
TournamentUpdate/SponsorUpdate/TournamentClassUpdate.
|
||||||
|
|
||||||
|
`kind` og `result_type` er IKKE med -- begge låses etter opprettelse.
|
||||||
|
`kind` per plan (matcher GolfBox sin egen tilsvarende låsing).
|
||||||
|
`result_type` er strukket til samme regel her: å endre den i
|
||||||
|
etterkant ville gjort eksisterende lenkers `points_table`/
|
||||||
|
`money_payout_table`-konfig meningsløs uten en egen migreringssti --
|
||||||
|
opprett en ny OOM i stedet."""
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
aggregation_mode: _AGGREGATION_MODES | None = None
|
||||||
|
count_best_n: int | None = None
|
||||||
|
min_results_required: int | None = None
|
||||||
|
birth_year_from: int | None = None
|
||||||
|
birth_year_to: int | None = None
|
||||||
|
public_visible: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderOfMeritOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
result_type: str
|
||||||
|
aggregation_mode: str
|
||||||
|
count_best_n: int | None
|
||||||
|
min_results_required: int | None
|
||||||
|
birth_year_from: int | None
|
||||||
|
birth_year_to: int | None
|
||||||
|
public_visible: bool
|
||||||
|
|
||||||
|
|
||||||
|
_OOM_COLUMNS = """
|
||||||
|
id::text, name, kind, result_type, aggregation_mode, count_best_n,
|
||||||
|
min_results_required, birth_year_from, birth_year_to, public_visible
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/orgs/{organization_id}/order-of-merits", response_model=list[OrderOfMeritOut])
|
||||||
|
async def list_order_of_merits(
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[OrderOfMeritOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
rows = await conn.fetch(f"SELECT {_OOM_COLUMNS} FROM order_of_merit ORDER BY created_at DESC")
|
||||||
|
return [OrderOfMeritOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orgs/{organization_id}/order-of-merits", response_model=OrderOfMeritOut, status_code=201)
|
||||||
|
async def create_order_of_merit(
|
||||||
|
body: OrderOfMeritCreate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> OrderOfMeritOut:
|
||||||
|
if body.kind == "team" and body.aggregation_mode == "eclectic":
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO order_of_merit
|
||||||
|
(organization_id, name, kind, result_type, aggregation_mode,
|
||||||
|
count_best_n, min_results_required, birth_year_from, birth_year_to, public_visible)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING {_OOM_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
body.name,
|
||||||
|
body.kind,
|
||||||
|
body.result_type,
|
||||||
|
body.aggregation_mode,
|
||||||
|
body.count_best_n,
|
||||||
|
body.min_results_required,
|
||||||
|
body.birth_year_from,
|
||||||
|
body.birth_year_to,
|
||||||
|
body.public_visible,
|
||||||
|
)
|
||||||
|
return OrderOfMeritOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/orgs/{organization_id}/order-of-merits/{oom_id}", response_model=OrderOfMeritOut)
|
||||||
|
async def update_order_of_merit(
|
||||||
|
oom_id: str,
|
||||||
|
body: OrderOfMeritUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> OrderOfMeritOut:
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||||
|
values = list(updates.values())
|
||||||
|
values.append(oom_id)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
existing = await conn.fetchrow("SELECT kind, result_type FROM order_of_merit WHERE id = $1", oom_id)
|
||||||
|
if existing is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
|
if updates.get("aggregation_mode") == "eclectic" and existing["kind"] == "team":
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
|
||||||
|
result_type = existing["result_type"]
|
||||||
|
effective_mode = updates.get("aggregation_mode")
|
||||||
|
if effective_mode == "eclectic" and result_type not in ("stableford", "gross", "net"):
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
|
||||||
|
)
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
||||||
|
WHERE id = ${len(values)}
|
||||||
|
RETURNING {_OOM_COLUMNS}
|
||||||
|
""",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
return OrderOfMeritOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/orgs/{organization_id}/order-of-merits/{oom_id}", status_code=204)
|
||||||
|
async def delete_order_of_merit(
|
||||||
|
oom_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> None:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
result = await conn.execute("DELETE FROM order_of_merit WHERE id = $1", oom_id)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lenkede turneringer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OrderOfMeritLinkCreate(BaseModel):
|
||||||
|
tournament_id: str
|
||||||
|
# Kun gyldig (og påkrevd) når OOM-ens result_type='points': liste av
|
||||||
|
# heltall, indeks 0 = poeng for 1. plass.
|
||||||
|
points_table: list[int] | None = None
|
||||||
|
# Kun gyldig (og påkrevd) når result_type='money'.
|
||||||
|
money_pool_total: float | None = None
|
||||||
|
money_payout_table: list[dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderOfMeritLinkOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
tournament_id: str
|
||||||
|
tournament_name: str
|
||||||
|
points_table: list[int] | None
|
||||||
|
money_pool_total: float | None
|
||||||
|
money_payout_table: list[dict] | None
|
||||||
|
|
||||||
|
|
||||||
|
_LINK_COLUMNS = """
|
||||||
|
l.id::text, l.tournament_id::text, t.name AS tournament_name,
|
||||||
|
l.points_table::text AS points_table, l.money_pool_total,
|
||||||
|
l.money_payout_table::text AS money_payout_table
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||||
|
response_model=list[OrderOfMeritLinkOut],
|
||||||
|
)
|
||||||
|
async def list_order_of_merit_links(
|
||||||
|
oom_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[OrderOfMeritLinkOut]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
f"""
|
||||||
|
SELECT {_LINK_COLUMNS}
|
||||||
|
FROM order_of_merit_link l
|
||||||
|
JOIN tournament t ON t.id = l.tournament_id
|
||||||
|
WHERE l.order_of_merit_id = $1
|
||||||
|
ORDER BY l.created_at
|
||||||
|
""",
|
||||||
|
oom_id,
|
||||||
|
)
|
||||||
|
return [OrderOfMeritLinkOut(**_parse_link_row(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_link_row(r) -> dict:
|
||||||
|
d = dict(r)
|
||||||
|
d["points_table"] = json.loads(d["points_table"]) if d.get("points_table") else None
|
||||||
|
d["money_payout_table"] = json.loads(d["money_payout_table"]) if d.get("money_payout_table") else None
|
||||||
|
if d.get("money_pool_total") is not None:
|
||||||
|
d["money_pool_total"] = float(d["money_pool_total"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||||
|
response_model=OrderOfMeritLinkOut,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def link_tournament_to_order_of_merit(
|
||||||
|
oom_id: str,
|
||||||
|
body: OrderOfMeritLinkCreate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> OrderOfMeritLinkOut:
|
||||||
|
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)
|
||||||
|
if oom is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
|
tournament = await conn.fetchrow(
|
||||||
|
"SELECT format_type FROM tournament WHERE id = $1", body.tournament_id
|
||||||
|
)
|
||||||
|
if tournament is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||||
|
if tournament["format_type"] != "individual":
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
|
||||||
|
)
|
||||||
|
|
||||||
|
result_type = oom["result_type"]
|
||||||
|
if result_type == "points" and not body.points_table:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "points_table er påkrevd for en poeng-basert Order of Merit.")
|
||||||
|
if result_type != "points" and body.points_table:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "points_table er kun gyldig for en poeng-basert Order of Merit.")
|
||||||
|
if result_type == "money" and not (body.money_pool_total and body.money_payout_table):
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED", "money_pool_total og money_payout_table er påkrevd for en pengeliste-basert Order of Merit."
|
||||||
|
)
|
||||||
|
if result_type != "money" and (body.money_pool_total or body.money_payout_table):
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Pengefelt er kun gyldig for en pengeliste-basert Order of Merit.")
|
||||||
|
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
WITH inserted AS (
|
||||||
|
INSERT INTO order_of_merit_link
|
||||||
|
(organization_id, order_of_merit_id, tournament_id,
|
||||||
|
points_table, money_pool_total, money_payout_table)
|
||||||
|
VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb)
|
||||||
|
RETURNING id, tournament_id, points_table, money_pool_total, money_payout_table
|
||||||
|
)
|
||||||
|
SELECT inserted.id::text, inserted.tournament_id::text, t.name AS tournament_name,
|
||||||
|
inserted.points_table::text AS points_table, inserted.money_pool_total,
|
||||||
|
inserted.money_payout_table::text AS money_payout_table
|
||||||
|
FROM inserted JOIN tournament t ON t.id = inserted.tournament_id
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
oom_id,
|
||||||
|
body.tournament_id,
|
||||||
|
json.dumps(body.points_table) if body.points_table else None,
|
||||||
|
body.money_pool_total,
|
||||||
|
json.dumps(body.money_payout_table) if body.money_payout_table else None,
|
||||||
|
)
|
||||||
|
return OrderOfMeritLinkOut(**_parse_link_row(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments/{link_id}",
|
||||||
|
status_code=204,
|
||||||
|
)
|
||||||
|
async def unlink_tournament_from_order_of_merit(
|
||||||
|
oom_id: str,
|
||||||
|
link_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> None:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
"DELETE FROM order_of_merit_link WHERE id = $1 AND order_of_merit_id = $2", link_id, oom_id
|
||||||
|
)
|
||||||
|
if result == "DELETE 0":
|
||||||
|
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
||||||
|
# som Nassau/High-low-high/individual_leaderboard selv).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OrderOfMeritLeaderboardEntry(BaseModel):
|
||||||
|
player_id: str
|
||||||
|
player_name: str
|
||||||
|
results_counted: int
|
||||||
|
results_available: int
|
||||||
|
value: float | None
|
||||||
|
position: str | None
|
||||||
|
eligible: bool
|
||||||
|
ineligible_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
|
||||||
|
"""Samme delt-plassering-regel som order_of_merit_points_for_position
|
||||||
|
(uavgjorte deler samme utbetaling ved sin felles plassering) -- egen
|
||||||
|
liten oppslagsfunksjon her (ikke i handicap_engine.py) siden dette er
|
||||||
|
et rent JSON-konfig-oppslag, ikke golf-domenematematikk om uavgjort."""
|
||||||
|
position = int(position_label.lstrip("T"))
|
||||||
|
for entry in payout_table:
|
||||||
|
if entry.get("position") == position:
|
||||||
|
return float(entry.get("amount") or 0)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
|
||||||
|
response_model=list[OrderOfMeritLeaderboardEntry],
|
||||||
|
)
|
||||||
|
async def order_of_merit_leaderboard(
|
||||||
|
oom_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> list[OrderOfMeritLeaderboardEntry]:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
oom = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT result_type, aggregation_mode, count_best_n, min_results_required,
|
||||||
|
birth_year_from, birth_year_to, kind
|
||||||
|
FROM order_of_merit WHERE id = $1
|
||||||
|
""",
|
||||||
|
oom_id,
|
||||||
|
)
|
||||||
|
if oom is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
|
if oom["kind"] != "player":
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Lag-OOM-leaderboard er ikke bygget ennå.")
|
||||||
|
if oom["aggregation_mode"] == "eclectic":
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
|
||||||
|
|
||||||
|
result_type = oom["result_type"]
|
||||||
|
lower_is_better = result_type in ("gross", "net")
|
||||||
|
|
||||||
|
links = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT tournament_id::text, points_table::text AS points_table,
|
||||||
|
money_payout_table::text AS money_payout_table
|
||||||
|
FROM order_of_merit_link WHERE order_of_merit_id = $1
|
||||||
|
""",
|
||||||
|
oom_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
|
||||||
|
values_by_player: dict[str, list[float]] = {}
|
||||||
|
names_by_player: dict[str, str] = {}
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
_, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||||
|
points_table = _decode_json(link["points_table"])
|
||||||
|
money_payout_table = _decode_json(link["money_payout_table"])
|
||||||
|
for e in entries:
|
||||||
|
contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table)
|
||||||
|
if contribution is None:
|
||||||
|
continue
|
||||||
|
values_by_player.setdefault(e.player_id, []).append(contribution)
|
||||||
|
names_by_player[e.player_id] = e.player_name
|
||||||
|
|
||||||
|
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
||||||
|
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
||||||
|
# bare gitt 0 poeng -- de skal ikke telle med som "sist").
|
||||||
|
eligible_player_ids = set(values_by_player.keys())
|
||||||
|
birth_years: dict[str, int | None] = {}
|
||||||
|
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||||
|
if eligible_player_ids:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"SELECT id::text, EXTRACT(YEAR FROM birth_date)::int AS birth_year FROM player WHERE id = ANY($1::uuid[])",
|
||||||
|
list(eligible_player_ids),
|
||||||
|
)
|
||||||
|
birth_years = {r["id"]: r["birth_year"] for r in rows}
|
||||||
|
|
||||||
|
results: list[OrderOfMeritLeaderboardEntry] = []
|
||||||
|
for player_id, raw_values in values_by_player.items():
|
||||||
|
results_available = len(raw_values)
|
||||||
|
eligible = True
|
||||||
|
ineligible_reason = None
|
||||||
|
|
||||||
|
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||||
|
by = birth_years.get(player_id)
|
||||||
|
if by is None:
|
||||||
|
eligible = False
|
||||||
|
ineligible_reason = "Ingen registrert fødselsdato."
|
||||||
|
elif oom["birth_year_from"] is not None and by < oom["birth_year_from"]:
|
||||||
|
eligible = False
|
||||||
|
ineligible_reason = "Utenfor aldersgrensen."
|
||||||
|
elif oom["birth_year_to"] is not None and by > oom["birth_year_to"]:
|
||||||
|
eligible = False
|
||||||
|
ineligible_reason = "Utenfor aldersgrensen."
|
||||||
|
|
||||||
|
if eligible and oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
|
||||||
|
eligible = False
|
||||||
|
ineligible_reason = f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
|
||||||
|
|
||||||
|
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
|
||||||
|
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
|
||||||
|
value = (-aggregated if lower_is_better and aggregated is not None else aggregated)
|
||||||
|
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
OrderOfMeritLeaderboardEntry(
|
||||||
|
player_id=player_id,
|
||||||
|
player_name=names_by_player[player_id],
|
||||||
|
results_counted=counted,
|
||||||
|
results_available=results_available,
|
||||||
|
value=value,
|
||||||
|
position=None,
|
||||||
|
eligible=eligible,
|
||||||
|
ineligible_reason=ineligible_reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for
|
||||||
|
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
|
||||||
|
# `value` er allerede tilbake-negert til ekte visningsverdi over,
|
||||||
|
# så sorter på `value` med riktig retning per resultattype.
|
||||||
|
eligible_results = [r for r in results if r.eligible and r.value is not None]
|
||||||
|
ineligible_results = [r for r in results if not (r.eligible and r.value is not None)]
|
||||||
|
eligible_results.sort(key=lambda r: r.value, reverse=not lower_is_better)
|
||||||
|
|
||||||
|
i, n = 0, len(eligible_results)
|
||||||
|
while i < n:
|
||||||
|
j = i
|
||||||
|
while j < n and eligible_results[j].value == eligible_results[i].value:
|
||||||
|
j += 1
|
||||||
|
rank = i + 1
|
||||||
|
tied = (j - i) > 1
|
||||||
|
for k in range(i, j):
|
||||||
|
eligible_results[k].position = f"T{rank}" if tied else str(rank)
|
||||||
|
i = j
|
||||||
|
|
||||||
|
return eligible_results + ineligible_results
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_json(value):
|
||||||
|
return json.loads(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | None:
|
||||||
|
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
|
||||||
|
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
|
||||||
|
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
|
||||||
|
hopper over den).
|
||||||
|
|
||||||
|
`entry.position` (posisjon i DENNE turneringen) er KUN populert for
|
||||||
|
scoring_method i (stroke_gross, stroke_net, stableford) -- se
|
||||||
|
`_attach_stroke_play_columns` i individual_tournaments.py. Er OOM-en
|
||||||
|
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
|
||||||
|
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
|
||||||
|
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
|
||||||
|
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt."""
|
||||||
|
if entry.rounds_played == 0:
|
||||||
|
return None
|
||||||
|
if result_type == "points":
|
||||||
|
return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0
|
||||||
|
if result_type == "money":
|
||||||
|
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
|
||||||
|
if result_type == "stableford":
|
||||||
|
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
|
||||||
|
if result_type == "gross":
|
||||||
|
return float(entry.gross_total) if entry.gross_total is not None else 0.0
|
||||||
|
if result_type == "net":
|
||||||
|
return float(entry.net_total) if entry.net_total is not None else 0.0
|
||||||
|
return 0.0
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { OrderOfMeritDetail } from "@/components/order-of-merit-detail"
|
||||||
|
|
||||||
|
export default async function OrderOfMeritDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; oomId: string }>
|
||||||
|
searchParams: Promise<{ name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id, oomId } = await params
|
||||||
|
const { name } = await searchParams
|
||||||
|
return <OrderOfMeritDetail organizationId={id} oomId={oomId} orgName={name ?? "Organisasjon"} />
|
||||||
|
}
|
||||||
13
frontend/app/organizations/[id]/order-of-merit/page.tsx
Normal file
13
frontend/app/organizations/[id]/order-of-merit/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { OrderOfMeritList } from "@/components/order-of-merit-list"
|
||||||
|
|
||||||
|
export default async function OrderOfMeritListPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { name } = await searchParams
|
||||||
|
return <OrderOfMeritList organizationId={id} orgName={name ?? "Organisasjon"} />
|
||||||
|
}
|
||||||
739
frontend/components/order-of-merit-detail.tsx
Normal file
739
frontend/components/order-of-merit-detail.tsx
Normal file
|
|
@ -0,0 +1,739 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Order of Merit-detalj (ADR-043 [OOM]): lenkede turneringer -> innstillinger
|
||||||
|
// -> resultatliste, i den rekkefølgen -- resultater gir ikke mening før
|
||||||
|
// turneringer er lenket. Håndkodet (ingen V0-credits denne runden), men
|
||||||
|
// bevisst bygget til samme visuelle presisjon som resten av appen sine
|
||||||
|
// V0-eksporter (samme tokens/kort-mønster).
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, ChevronDown, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type OomKind = "player" | "team"
|
||||||
|
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
|
type OomAggregationMode = "sum" | "average"
|
||||||
|
|
||||||
|
type ApiOrderOfMerit = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: OomKind
|
||||||
|
result_type: OomResultType
|
||||||
|
aggregation_mode: OomAggregationMode
|
||||||
|
count_best_n: number | null
|
||||||
|
min_results_required: number | null
|
||||||
|
birth_year_from: number | null
|
||||||
|
birth_year_to: number | null
|
||||||
|
public_visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiLink = {
|
||||||
|
id: string
|
||||||
|
tournament_id: string
|
||||||
|
tournament_name: string
|
||||||
|
points_table: number[] | null
|
||||||
|
money_pool_total: number | null
|
||||||
|
money_payout_table: { position: number; amount: number }[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiLeaderboardEntry = {
|
||||||
|
player_id: string
|
||||||
|
player_name: string
|
||||||
|
results_counted: number
|
||||||
|
results_available: number
|
||||||
|
value: number | null
|
||||||
|
position: string | null
|
||||||
|
eligible: boolean
|
||||||
|
ineligible_reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiTournament = { id: string; name: string; format_type: string }
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
|
||||||
|
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||||
|
points: "Poeng etter plassering",
|
||||||
|
stableford: "Stableford-sum",
|
||||||
|
gross: "Bruttoscore-sum",
|
||||||
|
net: "Nettoscore-sum",
|
||||||
|
money: "Pengeliste",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number | null, resultType: OomResultType): string {
|
||||||
|
if (value === null) return "–"
|
||||||
|
if (resultType === "money") return `${Math.round(value).toLocaleString("nb-NO")} kr`
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderOfMeritDetail({
|
||||||
|
organizationId,
|
||||||
|
oomId,
|
||||||
|
orgName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
oomId: string
|
||||||
|
orgName: string
|
||||||
|
}) {
|
||||||
|
const [oom, setOom] = useState<ApiOrderOfMerit | null>(null)
|
||||||
|
const [links, setLinks] = useState<ApiLink[] | null>(null)
|
||||||
|
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
|
||||||
|
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
|
||||||
|
async function loadOom() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" })
|
||||||
|
if (!res.ok) return
|
||||||
|
const all: ApiOrderOfMerit[] = await res.json()
|
||||||
|
const mine = all.find((o) => o.id === oomId)
|
||||||
|
if (!mine) {
|
||||||
|
setNotFound(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setOom(mine)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLinks() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, { credentials: "include" })
|
||||||
|
if (res.ok) setLinks(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLeaderboard() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/leaderboard`, { credentials: "include" })
|
||||||
|
if (res.ok) setLeaderboard(await res.json())
|
||||||
|
else setLeaderboard([])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTournaments() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" })
|
||||||
|
if (res.ok) setAllTournaments(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOom()
|
||||||
|
void loadLinks()
|
||||||
|
void loadTournaments()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [organizationId, oomId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (oom?.kind === "player") void loadLeaderboard()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [oom?.kind, links])
|
||||||
|
|
||||||
|
async function linkTournament(input: {
|
||||||
|
tournament_id: string
|
||||||
|
points_table?: number[]
|
||||||
|
money_pool_total?: number
|
||||||
|
money_payout_table?: { position: number; amount: number }[]
|
||||||
|
}) {
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
setError(body?.detail?.message ?? "Klarte ikke å lenke turneringen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await loadLinks()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkTournament(linkId: string) {
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments/${linkId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.status !== 204) {
|
||||||
|
setError("Klarte ikke å fjerne lenken.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(patch: Record<string, unknown>) {
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Klarte ikke å lagre innstillingene.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated: ApiOrderOfMerit = await res.json()
|
||||||
|
setOom(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteOom() {
|
||||||
|
if (!confirm("Slette denne Order of Merit-en? Dette kan ikke angres.")) return
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.status === 204) {
|
||||||
|
window.location.href = `/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-3 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Fant ikke denne Order of Merit-en.</p>
|
||||||
|
<Link href={`/organizations/${organizationId}/order-of-merit`} className="text-sm font-semibold text-primary underline-offset-2 hover:underline">
|
||||||
|
Tilbake til listen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oom) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedTournamentIds = new Set((links ?? []).map((l) => l.tournament_id))
|
||||||
|
const linkableTournaments = allTournaments.filter(
|
||||||
|
(t) => t.format_type === "individual" && !linkedTournamentIds.has(t.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
|
||||||
|
aria-label="Tilbake til Order of Merit-listen"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Order of Merit
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{oom.name}</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={deleteOom}
|
||||||
|
className="h-10 shrink-0 rounded-xl px-3 text-sm font-semibold text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
Slett
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-5 py-6 sm:py-8">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkedTournamentsSection
|
||||||
|
resultType={oom.result_type}
|
||||||
|
links={links}
|
||||||
|
linkableTournaments={linkableTournaments}
|
||||||
|
onLink={linkTournament}
|
||||||
|
onUnlink={unlinkTournament}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsSection oom={oom} onSave={saveSettings} />
|
||||||
|
|
||||||
|
<LeaderboardSection kind={oom.kind} resultType={oom.result_type} entries={leaderboard} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- B. Lenkede turneringer --------------------------------------------------
|
||||||
|
|
||||||
|
function LinkedTournamentsSection({
|
||||||
|
resultType,
|
||||||
|
links,
|
||||||
|
linkableTournaments,
|
||||||
|
onLink,
|
||||||
|
onUnlink,
|
||||||
|
}: {
|
||||||
|
resultType: OomResultType
|
||||||
|
links: ApiLink[] | null
|
||||||
|
linkableTournaments: ApiTournament[]
|
||||||
|
onLink: (input: {
|
||||||
|
tournament_id: string
|
||||||
|
points_table?: number[]
|
||||||
|
money_pool_total?: number
|
||||||
|
money_payout_table?: { position: number; amount: number }[]
|
||||||
|
}) => Promise<void>
|
||||||
|
onUnlink: (linkId: string) => void
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Lenkede turneringer</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{links === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||||
|
) : links.length === 0 ? (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Ingen turneringer lenket ennå.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">{l.tournament_name}</span>
|
||||||
|
{resultType === "points" && l.points_table && (
|
||||||
|
<span className="text-xs text-muted-foreground">Poeng: {l.points_table.join("-")}</span>
|
||||||
|
)}
|
||||||
|
{resultType === "money" && l.money_pool_total !== null && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Premiepott: {Math.round(l.money_pool_total).toLocaleString("nb-NO")} kr
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onUnlink(l.id)}
|
||||||
|
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
Fjern
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!adding ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
disabled={linkableTournaments.length === 0}
|
||||||
|
className="h-11 self-start rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Lenk turnering
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<LinkTournamentForm
|
||||||
|
resultType={resultType}
|
||||||
|
candidates={linkableTournaments}
|
||||||
|
onSubmit={async (input) => {
|
||||||
|
await onLink(input)
|
||||||
|
setAdding(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{linkableTournaments.length === 0 && !adding && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Ingen flere individuelle turneringer å lenke -- alle er allerede lenket, eller organisasjonen har ingen ennå.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkTournamentForm({
|
||||||
|
resultType,
|
||||||
|
candidates,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
resultType: OomResultType
|
||||||
|
candidates: ApiTournament[]
|
||||||
|
onSubmit: (input: {
|
||||||
|
tournament_id: string
|
||||||
|
points_table?: number[]
|
||||||
|
money_pool_total?: number
|
||||||
|
money_payout_table?: { position: number; amount: number }[]
|
||||||
|
}) => Promise<void>
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [tournamentId, setTournamentId] = useState(candidates[0]?.id ?? "")
|
||||||
|
const [pointsRows, setPointsRows] = useState<string[]>(["10", "6", "3"])
|
||||||
|
const [moneyPool, setMoneyPool] = useState("")
|
||||||
|
const [moneyRows, setMoneyRows] = useState<string[]>(["", "", ""])
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
function updateRow(setter: React.Dispatch<React.SetStateAction<string[]>>, index: number, value: string) {
|
||||||
|
setter((prev) => prev.map((v, i) => (i === index ? value : v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!tournamentId || submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
if (resultType === "points") {
|
||||||
|
const points_table = pointsRows.map((v) => Number(v) || 0)
|
||||||
|
await onSubmit({ tournament_id: tournamentId, points_table })
|
||||||
|
} else if (resultType === "money") {
|
||||||
|
const money_payout_table = moneyRows.map((v, i) => ({ position: i + 1, amount: Number(v) || 0 }))
|
||||||
|
await onSubmit({ tournament_id: tournamentId, money_pool_total: Number(moneyPool) || 0, money_payout_table })
|
||||||
|
} else {
|
||||||
|
await onSubmit({ tournament_id: tournamentId })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="link-tournament" className="text-xs font-semibold">
|
||||||
|
Turnering
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="link-tournament"
|
||||||
|
value={tournamentId}
|
||||||
|
onChange={(e) => setTournamentId(e.target.value)}
|
||||||
|
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
{candidates.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resultType === "points" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold">Poeng per plassering</span>
|
||||||
|
{pointsRows.map((v, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="w-20 shrink-0 text-sm text-muted-foreground">{i + 1}. plass</span>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => updateRow(setPointsRows, i, e.target.value)}
|
||||||
|
className="h-10 flex-1 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPointsRows((prev) => [...prev, "0"])}
|
||||||
|
className="h-9 self-start rounded-lg text-xs font-bold text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til plassering
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resultType === "money" && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="money-pool" className="text-xs font-semibold">
|
||||||
|
Premiepott (kr)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="money-pool"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={moneyPool}
|
||||||
|
onChange={(e) => setMoneyPool(e.target.value)}
|
||||||
|
className="h-10 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold">Utbetaling per plassering (kr)</span>
|
||||||
|
{moneyRows.map((v, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className="w-20 shrink-0 text-sm text-muted-foreground">{i + 1}. plass</span>
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => updateRow(setMoneyRows, i, e.target.value)}
|
||||||
|
className="h-10 flex-1 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setMoneyRows((prev) => [...prev, ""])}
|
||||||
|
className="h-9 self-start rounded-lg text-xs font-bold text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til plassering
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={!tournamentId || submitting} className="h-10 flex-1 rounded-xl text-sm font-bold">
|
||||||
|
{submitting ? "Lenker…" : "Lenk"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 rounded-xl text-sm font-semibold text-muted-foreground">
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- C. Innstillinger ---------------------------------------------------------
|
||||||
|
|
||||||
|
function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch: Record<string, unknown>) => Promise<void> }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState(oom.name)
|
||||||
|
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>(oom.aggregation_mode)
|
||||||
|
const [bestN, setBestN] = useState(oom.count_best_n?.toString() ?? "")
|
||||||
|
const [minResults, setMinResults] = useState(oom.min_results_required?.toString() ?? "")
|
||||||
|
const [birthYearFrom, setBirthYearFrom] = useState(oom.birth_year_from?.toString() ?? "")
|
||||||
|
const [birthYearTo, setBirthYearTo] = useState(oom.birth_year_to?.toString() ?? "")
|
||||||
|
const [publicVisible, setPublicVisible] = useState(oom.public_visible)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave({
|
||||||
|
name: name.trim(),
|
||||||
|
aggregation_mode: aggregationMode,
|
||||||
|
count_best_n: bestN.trim() ? Number(bestN) : null,
|
||||||
|
min_results_required: minResults.trim() ? Number(minResults) : null,
|
||||||
|
birth_year_from: birthYearFrom.trim() ? Number(birthYearFrom) : null,
|
||||||
|
birth_year_to: birthYearTo.trim() ? Number(birthYearTo) : null,
|
||||||
|
public_visible: publicVisible,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex min-h-11 items-center gap-2.5 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Settings aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h2 className="flex-1 text-base font-bold text-foreground">Innstillinger</h2>
|
||||||
|
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Type ({KIND_LABELS[oom.kind]}) og resultattype ({RESULT_TYPE_LABELS[oom.result_type]}) kan ikke endres etter opprettelse.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="settings-name" className="text-sm font-semibold">
|
||||||
|
Navn
|
||||||
|
</Label>
|
||||||
|
<Input id="settings-name" value={name} onChange={(e) => setName(e.target.value)} className="h-11 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||||
|
Sum
|
||||||
|
</SettingsSegment>
|
||||||
|
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||||
|
Snitt
|
||||||
|
</SettingsSegment>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="best-n" className="text-sm font-semibold">
|
||||||
|
Behold de N beste
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="best-n"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={bestN}
|
||||||
|
onChange={(e) => setBestN(e.target.value)}
|
||||||
|
placeholder="Tom = tell alle resultater"
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="min-results" className="text-sm font-semibold">
|
||||||
|
Minimum antall resultater
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="min-results"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={minResults}
|
||||||
|
onChange={(e) => setMinResults(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Aldersgrense</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="birth-from" className="text-xs text-muted-foreground">
|
||||||
|
Fødselsår fra
|
||||||
|
</Label>
|
||||||
|
<Input id="birth-from" inputMode="numeric" value={birthYearFrom} onChange={(e) => setBirthYearFrom(e.target.value)} className="h-11 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="birth-to" className="text-xs text-muted-foreground">
|
||||||
|
Fødselsår til
|
||||||
|
</Label>
|
||||||
|
<Input id="birth-to" inputMode="numeric" value={birthYearTo} onChange={(e) => setBirthYearTo(e.target.value)} className="h-11 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex min-h-11 items-center justify-between gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Offentlig synlig</span>
|
||||||
|
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
|
||||||
|
{saving ? "Lagrer…" : "Lagre"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 items-center justify-center rounded-xl border px-3 py-2.5 text-sm font-semibold transition-colors",
|
||||||
|
selected ? "border-primary bg-primary/10 text-foreground" : "border-border bg-background text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- D. Resultatliste ----------------------------------------------------------
|
||||||
|
|
||||||
|
function LeaderboardSection({
|
||||||
|
kind,
|
||||||
|
resultType,
|
||||||
|
entries,
|
||||||
|
}: {
|
||||||
|
kind: OomKind
|
||||||
|
resultType: OomResultType
|
||||||
|
entries: ApiLeaderboardEntry[] | null
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Medal aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === "team" ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-6 text-center">
|
||||||
|
<Users aria-hidden="true" className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Lag-resultatliste er ikke bygget ennå.</p>
|
||||||
|
</div>
|
||||||
|
) : entries === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||||
|
) : (
|
||||||
|
<LeaderboardTable entries={entries} resultType={resultType} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
|
||||||
|
const eligible = entries.filter((e) => e.eligible)
|
||||||
|
const ineligible = entries.filter((e) => !e.eligible)
|
||||||
|
|
||||||
|
if (eligible.length === 0 && ineligible.length === 0) {
|
||||||
|
return <p className="text-sm leading-relaxed text-muted-foreground text-pretty">Ingen resultater ennå -- lenk minst én turnering over.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||||||
|
{eligible.map((e) => {
|
||||||
|
const isLeader = e.position === "1"
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={e.player_id}
|
||||||
|
className={cn("flex items-center gap-3 px-4 py-3", isLeader ? "bg-gold text-gold-foreground" : "bg-card")}
|
||||||
|
>
|
||||||
|
<span className={cn("w-9 shrink-0 text-center text-sm font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
|
||||||
|
{e.position}
|
||||||
|
</span>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className={cn("truncate text-sm font-bold", isLeader ? "text-gold-foreground" : "text-foreground")}>{e.player_name}</span>
|
||||||
|
{e.results_counted !== e.results_available && (
|
||||||
|
<span className={cn("text-xs", isLeader ? "text-gold-foreground/80" : "text-muted-foreground")}>
|
||||||
|
{e.results_counted} av {e.results_available} resultater talt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={cn("shrink-0 text-base font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
|
||||||
|
{formatValue(e.value, resultType)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{ineligible.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Ikke kvalifisert</h3>
|
||||||
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border opacity-70">
|
||||||
|
{ineligible.map((e) => (
|
||||||
|
<li key={e.player_id} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-semibold text-muted-foreground">{e.player_name}</span>
|
||||||
|
{e.ineligible_reason && <span className="text-xs text-muted-foreground">{e.ineligible_reason}</span>}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
306
frontend/components/order-of-merit-list.tsx
Normal file
306
frontend/components/order-of-merit-list.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering av spillere
|
||||||
|
// på tvers av flere individuelle turneringer (ADR-037) i samme
|
||||||
|
// organisasjon. Håndkodet (ikke V0 -- ingen V0-credits tilgjengelig denne
|
||||||
|
// runden), men bevisst bygget til å matche samme visuelle presisjon som
|
||||||
|
// V0-eksportene ellers i appen (samme design-tokens, samme kort-/
|
||||||
|
// header-mønster som org-members.tsx).
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowLeft, ChevronRight, Plus, Trophy, Users } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type OomKind = "player" | "team"
|
||||||
|
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
|
type OomAggregationMode = "sum" | "average"
|
||||||
|
|
||||||
|
type ApiOrderOfMerit = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: OomKind
|
||||||
|
result_type: OomResultType
|
||||||
|
aggregation_mode: OomAggregationMode
|
||||||
|
count_best_n: number | null
|
||||||
|
min_results_required: number | null
|
||||||
|
birth_year_from: number | null
|
||||||
|
birth_year_to: number | null
|
||||||
|
public_visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
|
||||||
|
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||||
|
points: "Poeng etter plassering",
|
||||||
|
stableford: "Stableford-sum",
|
||||||
|
gross: "Bruttoscore-sum",
|
||||||
|
net: "Nettoscore-sum",
|
||||||
|
money: "Pengeliste",
|
||||||
|
}
|
||||||
|
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = { sum: "Sum", average: "Snitt" }
|
||||||
|
|
||||||
|
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [items, setItems] = useState<ApiOrderOfMerit[] | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" })
|
||||||
|
if (res.ok) setItems(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [organizationId])
|
||||||
|
|
||||||
|
async function create(input: { name: string; kind: OomKind; result_type: OomResultType; aggregation_mode: OomAggregationMode }) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create: ${res.status}`)
|
||||||
|
const created: ApiOrderOfMerit = await res.json()
|
||||||
|
setCreating(false)
|
||||||
|
router.push(`/organizations/${organizationId}/order-of-merit/${created.id}?name=${encodeURIComponent(orgName)}`)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette Order of Merit. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href={`/organizations/${organizationId}/members?name=${encodeURIComponent(orgName)}`}
|
||||||
|
aria-label="Tilbake til organisasjonen"
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
</Link>
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Order of Merit
|
||||||
|
</span>
|
||||||
|
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{orgName}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Sesong-sammenlagt rangering av spillere på tvers av flere turneringer.
|
||||||
|
</p>
|
||||||
|
{!creating && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreating(true)}
|
||||||
|
className="h-11 shrink-0 rounded-2xl font-bold"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Ny Order of Merit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<CreateForm onCreate={create} onCancel={() => setCreating(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items === null ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 && !creating ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||||
|
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Ingen Order of Merit-er ennå</h2>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Sett opp en sesong-sammenlagt rangering på tvers av flere turneringer.
|
||||||
|
</p>
|
||||||
|
<Button type="button" onClick={() => setCreating(true)} className="mt-2 h-11 rounded-2xl font-bold">
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Ny Order of Merit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{items.map((oom) => (
|
||||||
|
<li key={oom.id}>
|
||||||
|
<Link
|
||||||
|
href={`/organizations/${organizationId}/order-of-merit/${oom.id}?name=${encodeURIComponent(orgName)}`}
|
||||||
|
className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
|
{oom.kind === "team" ? (
|
||||||
|
<Users aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Trophy aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<span className="truncate text-base font-bold text-foreground">{oom.name}</span>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Badge>{KIND_LABELS[oom.kind]}</Badge>
|
||||||
|
<Badge>{RESULT_TYPE_LABELS[oom.result_type]}</Badge>
|
||||||
|
<Badge>{AGGREGATION_LABELS[oom.aggregation_mode]}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentButton({
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 items-center justify-center rounded-xl border px-3 py-2.5 text-sm font-semibold transition-colors",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary/10 text-foreground"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateForm({
|
||||||
|
onCreate,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
onCreate: (input: { name: string; kind: OomKind; result_type: OomResultType; aggregation_mode: OomAggregationMode }) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [kind, setKind] = useState<OomKind>("player")
|
||||||
|
const [resultType, setResultType] = useState<OomResultType>("stableford")
|
||||||
|
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim() || submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
await onCreate({ name: name.trim(), kind, result_type: resultType, aggregation_mode: aggregationMode })
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col gap-5 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Ny Order of Merit</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="oom-name" className="text-sm font-semibold">
|
||||||
|
Navn
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="oom-name"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="F.eks. Klubbmesterskap-serien 2026"
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Type</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}>
|
||||||
|
Spiller
|
||||||
|
</SegmentButton>
|
||||||
|
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}>
|
||||||
|
Lag
|
||||||
|
</SegmentButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
||||||
|
<SegmentButton key={rt} selected={resultType === rt} onClick={() => setResultType(rt)}>
|
||||||
|
{RESULT_TYPE_LABELS[rt]}
|
||||||
|
</SegmentButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||||
|
Sum
|
||||||
|
</SegmentButton>
|
||||||
|
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||||
|
Snitt
|
||||||
|
</SegmentButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={!name.trim() || submitting} className="h-11 flex-1 rounded-xl font-bold">
|
||||||
|
{submitting ? "Oppretter…" : "Opprett"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl font-semibold text-muted-foreground">
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Mail, Trash2, UserPlus, Users } from "lucide-react"
|
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -181,6 +181,19 @@ export function OrgMembers({ organizationId, orgName }: { organizationId: string
|
||||||
|
|
||||||
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
|
||||||
|
className="mt-6 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
|
||||||
|
>
|
||||||
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-bold text-foreground">Order of Merit</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Sesong-sammenlagt rangering på tvers av turneringer</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<section className="mt-6 flex flex-col gap-3">
|
<section className="mt-6 flex flex-col gap-3">
|
||||||
<h2 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
<h2 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<Users aria-hidden="true" className="size-4" />
|
<Users aria-hidden="true" className="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -935,3 +935,59 @@ def high_low_high_running_score(hole_points: Sequence[tuple[int, int]]) -> tuple
|
||||||
"""Løpende STILLING -- literal poengsum per lag (IKKE match-play "up"/
|
"""Løpende STILLING -- literal poengsum per lag (IKKE match-play "up"/
|
||||||
"down"-terminologi), summert over alle spilte hull."""
|
"down"-terminologi), summert over alle spilte hull."""
|
||||||
return sum(p[0] for p in hole_points), sum(p[1] for p in hole_points)
|
return sum(p[0] for p in hole_points), sum(p[1] for p in hole_points)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Order of Merit (sesong-sammenlagt rangering på tvers av flere individuelle
|
||||||
|
# turneringer) -- ADR-043 [OOM]. To uavhengige akser: HVA telles per lenket
|
||||||
|
# turnering (result_type -- håndtert av kalleren, som utleder tallet fra en
|
||||||
|
# turnerings egen leaderboard/plassering) og HVORDAN det summeres over flere
|
||||||
|
# turneringer/spillere (aggregation_mode -- håndtert her).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def order_of_merit_points_for_position(position_label: str, points_table: Sequence[int]) -> int:
|
||||||
|
"""Poeng for en gitt plassering i en poeng-etter-plassering-OOM.
|
||||||
|
|
||||||
|
`position_label` er samme form som individual_leaderboard allerede
|
||||||
|
produserer ("1", "T2", "T3" osv.) -- en ledende "T" strippes før
|
||||||
|
oppslag. `points_table[0]` er poeng for 1. plass, `points_table[1]`
|
||||||
|
for 2. plass, osv. (1-indeksert plassering, 0-indeksert liste).
|
||||||
|
|
||||||
|
Uavgjorte deler SAMME poengverdi ved sin felles (delte) plassering --
|
||||||
|
IKKE gjennomsnittet av de "oppbrukte" plassene. Enkleste, mest
|
||||||
|
forutsigbare regel, og matcher hvordan GolfBox sin egen poeng-etter-
|
||||||
|
plassering-modell oppfører seg.
|
||||||
|
|
||||||
|
Plassering utenfor tabellen (feltet er større enn poeng-tabellen
|
||||||
|
dekker) gir 0 poeng, ikke en feil -- en kortere poeng-tabell enn
|
||||||
|
feltstørrelsen er en gyldig, vanlig konfigurasjon (kun topp-N får
|
||||||
|
poeng)."""
|
||||||
|
position = int(position_label.lstrip("T"))
|
||||||
|
index = position - 1
|
||||||
|
if index < 0 or index >= len(points_table):
|
||||||
|
return 0
|
||||||
|
return points_table[index]
|
||||||
|
|
||||||
|
|
||||||
|
def order_of_merit_aggregate(
|
||||||
|
values: Sequence[float], mode: Literal["sum", "average"], best_n: int | None
|
||||||
|
) -> float | None:
|
||||||
|
"""Slår sammen en spillers (eller et lags) tellende resultater til ett
|
||||||
|
OOM-sammenlagt tall.
|
||||||
|
|
||||||
|
`None` hvis `values` er tom (ingen tellende resultater ennå -- kalleren
|
||||||
|
avgjør om det betyr "sist" eller "ikke rangert i det hele tatt").
|
||||||
|
`best_n=None` betyr "tell alle" -- ellers beholdes kun de `best_n`
|
||||||
|
STØRSTE verdiene før summering/snitt (dropp-dårligst).
|
||||||
|
|
||||||
|
"Best" er alltid STØRRE=bedre for denne funksjonens formål -- for
|
||||||
|
brutto/nettoslag (der LAVERE er bedre i golf) må kalleren sende inn
|
||||||
|
NEGERTE verdier, samme triks som allerede brukt i
|
||||||
|
individual_tournaments.py sin til-par-normaliserte rangeringsnøkkel
|
||||||
|
(2026-08-04)."""
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
kept = sorted(values, reverse=True)[:best_n] if best_n is not None else list(values)
|
||||||
|
if mode == "sum":
|
||||||
|
return sum(kept)
|
||||||
|
return sum(kept) / len(kept)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ from handicap_engine import (
|
||||||
money_ball_hole_score,
|
money_ball_hole_score,
|
||||||
high_low_high_points_for_hole,
|
high_low_high_points_for_hole,
|
||||||
high_low_high_running_score,
|
high_low_high_running_score,
|
||||||
|
order_of_merit_points_for_position,
|
||||||
|
order_of_merit_aggregate,
|
||||||
max_hole_score_for_handicap,
|
max_hole_score_for_handicap,
|
||||||
net_par,
|
net_par,
|
||||||
round_counts_for_handicap,
|
round_counts_for_handicap,
|
||||||
|
|
@ -932,6 +934,63 @@ def test_stableford_total_matches_hand_computed_sum():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Order of Merit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_oom_points_for_position_basic():
|
||||||
|
table = [10, 8, 6, 4, 2]
|
||||||
|
assert order_of_merit_points_for_position("1", table) == 10
|
||||||
|
assert order_of_merit_points_for_position("3", table) == 6
|
||||||
|
assert order_of_merit_points_for_position("5", table) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_points_for_position_tie_shares_value_not_averaged():
|
||||||
|
table = [10, 8, 6, 4, 2]
|
||||||
|
# To spillere delt 2. plass -- begge får poengverdien for plass 2 (8),
|
||||||
|
# IKKE gjennomsnittet av plass 2+3 (7).
|
||||||
|
assert order_of_merit_points_for_position("T2", table) == 8
|
||||||
|
assert order_of_merit_points_for_position("T2", table) == order_of_merit_points_for_position("T2", table)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_points_for_position_beyond_table_is_zero():
|
||||||
|
table = [10, 8, 6]
|
||||||
|
assert order_of_merit_points_for_position("4", table) == 0
|
||||||
|
assert order_of_merit_points_for_position("T10", table) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_aggregate_empty_is_none():
|
||||||
|
assert order_of_merit_aggregate([], "sum", None) is None
|
||||||
|
assert order_of_merit_aggregate([], "average", 2) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_aggregate_sum_all():
|
||||||
|
assert order_of_merit_aggregate([10, 8, 6], "sum", None) == 24
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_aggregate_average_all():
|
||||||
|
assert order_of_merit_aggregate([10, 8, 6], "average", None) == 8
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_aggregate_best_n_drops_worst():
|
||||||
|
# Behold de 2 beste (10, 8) -- dropp den dårligste (6).
|
||||||
|
assert order_of_merit_aggregate([10, 8, 6], "sum", 2) == 18
|
||||||
|
assert order_of_merit_aggregate([10, 8, 6], "average", 2) == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_aggregate_best_n_larger_than_available_counts_all():
|
||||||
|
assert order_of_merit_aggregate([10, 8], "sum", 5) == 18
|
||||||
|
|
||||||
|
|
||||||
|
def test_oom_aggregate_higher_is_better_gross_uses_negated_values():
|
||||||
|
# Brutto 70/75/68 -- kalleren negerer (-70/-75/-68) slik at "størst er
|
||||||
|
# best" fortsatt betyr "lavest brutto er best". Behold de 2 beste
|
||||||
|
# (dvs. laveste to brutto-tall: 68, 70) -> negert sum -138, altså
|
||||||
|
# ekte sum 138.
|
||||||
|
negated = [-70, -75, -68]
|
||||||
|
assert order_of_merit_aggregate(negated, "sum", 2) == -138
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enkel selvsjekk uten pytest
|
# Enkel selvsjekk uten pytest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue