From fea701091c41f898668b5b02499c9e5ebc8f6328 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 4 Aug 2026 14:19:30 +0200 Subject: [PATCH] =?UTF-8?q?Bygg=20Order=20of=20Merit:=20sesong-sammenlagt?= =?UTF-8?q?=20spillerrangering=20p=C3=A5=20tvers=20av=20turneringer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 055_order_of_merit.sql | 117 +++ ARCHITECTURE_DECISIONS.md | 91 +++ CHANGELOG.md | 109 +++ FEATURE_BACKLOG.md | 36 + app/main.py | 2 + app/routers/individual_tournaments.py | 121 +-- app/routers/order_of_merit.py | 511 ++++++++++++ .../[id]/order-of-merit/[oomId]/page.tsx | 13 + .../[id]/order-of-merit/page.tsx | 13 + frontend/components/order-of-merit-detail.tsx | 739 ++++++++++++++++++ frontend/components/order-of-merit-list.tsx | 306 ++++++++ frontend/components/org-members.tsx | 15 +- handicap_engine.py | 56 ++ test_handicap_engine.py | 59 ++ 14 files changed, 2137 insertions(+), 51 deletions(-) create mode 100644 055_order_of_merit.sql create mode 100644 app/routers/order_of_merit.py create mode 100644 frontend/app/organizations/[id]/order-of-merit/[oomId]/page.tsx create mode 100644 frontend/app/organizations/[id]/order-of-merit/page.tsx create mode 100644 frontend/components/order-of-merit-detail.tsx create mode 100644 frontend/components/order-of-merit-list.tsx diff --git a/055_order_of_merit.sql b/055_order_of_merit.sql new file mode 100644 index 0000000..afcb20f --- /dev/null +++ b/055_order_of_merit.sql @@ -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 $$; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index f88ac71..9d2abe7 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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å) Disse må avklares før eller under de relevante fasene: diff --git a/CHANGELOG.md b/CHANGELOG.md index ee93ee4..5e5c79f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7388,3 +7388,112 @@ Neste steg: databrudd), `docker compose up -d --build teecup_api teecup_frontend`, begge containere boot-et rent, `/health` og `/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. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 6ccb238..e24be13 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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 - 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte diff --git a/app/main.py b/app/main.py index f57a428..617779c 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,7 @@ from .routers import ( matches, messaging, notifications, + order_of_merit, organizations, players, registration, @@ -51,6 +52,7 @@ app.include_router(players.router) app.include_router(courses.router) app.include_router(tournaments.router) app.include_router(individual_tournaments.router) +app.include_router(order_of_merit.router) app.include_router(matches.router) app.include_router(scoring.router) app.include_router(registration.router) diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index c798ec4..947190c 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -1032,6 +1032,10 @@ class RoundCellOut(BaseModel): class LeaderboardEntry(BaseModel): 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 rounds_played: int gross_total: int | None @@ -1056,6 +1060,72 @@ class LeaderboardEntry(BaseModel): rounds: list[RoundCellOut] = [] +async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]: + """Delt mellom `individual_leaderboard` under (denne ENE turneringens + egen visning) og Order of Merit sitt leaderboard-endepunkt + (`order_of_merit.py`, som summerer resultater over FLERE lenkede + turneringer) -- faktorisert ut 2026-08-04 for å unngå å duplisere + posisjon-/til-par-beregningen (inkl. den til-par-normaliserte + rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`). + + Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng + trenger `tournament_row["scoring_method"]` for å vite hvilket felt på + hver entry (`gross_total`/`net_total`/`stableford_total`/`position`) + som faktisk er meningsfullt å lese videre.""" + tournament = await conn.fetchrow( + "SELECT format_type, scoring_method FROM tournament WHERE id = $1", 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 har dette leaderboardet.") + + rows = await conn.fetch( + """ + 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, + SUM(trs.gross_total)::int AS gross_total, + SUM(trs.net_total)::int AS net_total, + SUM(trs.stableford_points)::int AS stableford_total, + SUM(trs.copenhagen_points)::int AS copenhagen_total, + SUM(trs.bbb_points)::int AS bbb_total, + tc.id::text AS class_id, tc.name AS class_name + FROM tournament_participant tp + JOIN player p ON p.id = tp.player_id + LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id + LEFT JOIN tournament_class tc ON tc.id = tp.class_id + WHERE tp.tournament_id = $1 + GROUP BY tp.id, p.display_name, tc.id, tc.name + """, + tournament_id, + ) + entries = [LeaderboardEntry(**dict(r)) for r in rows] + + method = tournament["scoring_method"] + if method in ("stroke_gross", "stroke_net", "stableford"): + # VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her + # (slik de andre metodene gjør) -- i en flerrunde-turnering har + # deltakere spilt ULIKT antall hull til enhver tid (noen midt i + # runde 2, andre ikke startet ennå), så rå sum er ikke + # sammenlignbar. _attach_stroke_play_columns sorterer selv, på + # til-par (normalisert for antall hull spilt), FØR posisjon + # tildeles -- reell bug funnet under scratch-verifisering: en + # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran + # deltakere med -5 til par over 27-36 hull, kun fordi rått + # slagtall er mindre jo færre hull man har spilt. + await _attach_stroke_play_columns(conn, tournament_id, method, entries) + elif method == "copenhagen": + # Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4). + entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0))) + elif method == "bingo_bango_bongo": + entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0))) + else: + 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 @@ -1069,56 +1139,7 @@ async def individual_leaderboard( organization_id: str = Depends(get_authorized_org), ) -> list[LeaderboardEntry]: async with org_connection(organization_id) as conn: - tournament = await conn.fetchrow( - "SELECT format_type, scoring_method FROM tournament WHERE id = $1", 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 har dette leaderboardet.") - - rows = await conn.fetch( - """ - SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name, - count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played, - SUM(trs.gross_total)::int AS gross_total, - SUM(trs.net_total)::int AS net_total, - SUM(trs.stableford_points)::int AS stableford_total, - SUM(trs.copenhagen_points)::int AS copenhagen_total, - SUM(trs.bbb_points)::int AS bbb_total, - tc.id::text AS class_id, tc.name AS class_name - FROM tournament_participant tp - JOIN player p ON p.id = tp.player_id - LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id - LEFT JOIN tournament_class tc ON tc.id = tp.class_id - WHERE tp.tournament_id = $1 - GROUP BY tp.id, p.display_name, tc.id, tc.name - """, - tournament_id, - ) - entries = [LeaderboardEntry(**dict(r)) for r in rows] - - method = tournament["scoring_method"] - if method in ("stroke_gross", "stroke_net", "stableford"): - # VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her - # (slik de andre metodene gjør) -- i en flerrunde-turnering har - # deltakere spilt ULIKT antall hull til enhver tid (noen midt i - # runde 2, andre ikke startet ennå), så rå sum er ikke - # sammenlignbar. _attach_stroke_play_columns sorterer selv, på - # til-par (normalisert for antall hull spilt), FØR posisjon - # tildeles -- reell bug funnet under scratch-verifisering: en - # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran - # deltakere med -5 til par over 27-36 hull, kun fordi rått - # slagtall er mindre jo færre hull man har spilt. - await _attach_stroke_play_columns(conn, tournament_id, method, entries) - elif method == "copenhagen": - # Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4). - entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0))) - elif method == "bingo_bango_bongo": - entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0))) - else: - entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0)) - + _, entries = await _compute_individual_standings(conn, tournament_id) return entries diff --git a/app/routers/order_of_merit.py b/app/routers/order_of_merit.py new file mode 100644 index 0000000..324bcbd --- /dev/null +++ b/app/routers/order_of_merit.py @@ -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 diff --git a/frontend/app/organizations/[id]/order-of-merit/[oomId]/page.tsx b/frontend/app/organizations/[id]/order-of-merit/[oomId]/page.tsx new file mode 100644 index 0000000..d62ae1f --- /dev/null +++ b/frontend/app/organizations/[id]/order-of-merit/[oomId]/page.tsx @@ -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 +} diff --git a/frontend/app/organizations/[id]/order-of-merit/page.tsx b/frontend/app/organizations/[id]/order-of-merit/page.tsx new file mode 100644 index 0000000..453a8f3 --- /dev/null +++ b/frontend/app/organizations/[id]/order-of-merit/page.tsx @@ -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 +} diff --git a/frontend/components/order-of-merit-detail.tsx b/frontend/components/order-of-merit-detail.tsx new file mode 100644 index 0000000..cb38788 --- /dev/null +++ b/frontend/components/order-of-merit-detail.tsx @@ -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 = { player: "Spiller", team: "Lag" } +const RESULT_TYPE_LABELS: Record = { + 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(null) + const [links, setLinks] = useState(null) + const [leaderboard, setLeaderboard] = useState(null) + const [allTournaments, setAllTournaments] = useState([]) + const [error, setError] = useState(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) { + 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 ( +
+

Fant ikke denne Order of Merit-en.

+ + Tilbake til listen + +
+ ) + } + + if (!oom) { + return ( +
+ + ) + } + + const linkedTournamentIds = new Set((links ?? []).map((l) => l.tournament_id)) + const linkableTournaments = allTournaments.filter( + (t) => t.format_type === "individual" && !linkedTournamentIds.has(t.id), + ) + + return ( +
+
+
+ +
+
+ +
+ {error && ( +

+ {error} +

+ )} + + + + + + +
+
+ ) +} + +// --- 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 + onUnlink: (linkId: string) => void +}) { + const [adding, setAdding] = useState(false) + + return ( +
+
+
+
+

Lenkede turneringer

+
+ + {links === null ? ( +

Laster…

+ ) : links.length === 0 ? ( +

+ Ingen turneringer lenket ennå. +

+ ) : ( +
    + {links.map((l) => ( +
  • +
    + {l.tournament_name} + {resultType === "points" && l.points_table && ( + Poeng: {l.points_table.join("-")} + )} + {resultType === "money" && l.money_pool_total !== null && ( + + Premiepott: {Math.round(l.money_pool_total).toLocaleString("nb-NO")} kr + + )} +
    + +
  • + ))} +
+ )} + + {!adding ? ( + + ) : ( + { + await onLink(input) + setAdding(false) + }} + onCancel={() => setAdding(false)} + /> + )} + {linkableTournaments.length === 0 && !adding && ( +

+ Ingen flere individuelle turneringer å lenke -- alle er allerede lenket, eller organisasjonen har ingen ennå. +

+ )} +
+ ) +} + +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 + onCancel: () => void +}) { + const [tournamentId, setTournamentId] = useState(candidates[0]?.id ?? "") + const [pointsRows, setPointsRows] = useState(["10", "6", "3"]) + const [moneyPool, setMoneyPool] = useState("") + const [moneyRows, setMoneyRows] = useState(["", "", ""]) + const [submitting, setSubmitting] = useState(false) + + function updateRow(setter: React.Dispatch>, 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 ( +
+
+ + +
+ + {resultType === "points" && ( +
+ Poeng per plassering + {pointsRows.map((v, i) => ( +
+ {i + 1}. plass + updateRow(setPointsRows, i, e.target.value)} + className="h-10 flex-1 rounded-lg text-sm" + /> +
+ ))} + +
+ )} + + {resultType === "money" && ( +
+
+ + setMoneyPool(e.target.value)} + className="h-10 rounded-lg text-sm" + /> +
+
+ Utbetaling per plassering (kr) + {moneyRows.map((v, i) => ( +
+ {i + 1}. plass + updateRow(setMoneyRows, i, e.target.value)} + className="h-10 flex-1 rounded-lg text-sm" + /> +
+ ))} + +
+
+ )} + +
+ + +
+
+ ) +} + +// --- C. Innstillinger --------------------------------------------------------- + +function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch: Record) => Promise }) { + const [open, setOpen] = useState(false) + const [name, setName] = useState(oom.name) + const [aggregationMode, setAggregationMode] = useState(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 ( +
+ + + {open && ( +
+

+ Type ({KIND_LABELS[oom.kind]}) og resultattype ({RESULT_TYPE_LABELS[oom.result_type]}) kan ikke endres etter opprettelse. +

+ +
+ + setName(e.target.value)} className="h-11 rounded-xl" /> +
+ +
+ Aggregering +
+ setAggregationMode("sum")}> + Sum + + setAggregationMode("average")}> + Snitt + +
+
+ +
+ + setBestN(e.target.value)} + placeholder="Tom = tell alle resultater" + className="h-11 rounded-xl" + /> +
+ +
+ + setMinResults(e.target.value)} + className="h-11 rounded-xl" + /> +
+ +
+ Aldersgrense +
+
+ + setBirthYearFrom(e.target.value)} className="h-11 rounded-xl" /> +
+
+ + setBirthYearTo(e.target.value)} className="h-11 rounded-xl" /> +
+
+
+ + + + +
+ )} +
+ ) +} + +function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ) +} + +// --- D. Resultatliste ---------------------------------------------------------- + +function LeaderboardSection({ + kind, + resultType, + entries, +}: { + kind: OomKind + resultType: OomResultType + entries: ApiLeaderboardEntry[] | null +}) { + return ( +
+
+
+
+

Resultatliste

+
+ + {kind === "team" ? ( +
+
+ ) : entries === null ? ( +

Laster…

+ ) : ( + + )} +
+ ) +} + +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

Ingen resultater ennå -- lenk minst én turnering over.

+ } + + return ( +
+
    + {eligible.map((e) => { + const isLeader = e.position === "1" + return ( +
  • + + {e.position} + +
    + {e.player_name} + {e.results_counted !== e.results_available && ( + + {e.results_counted} av {e.results_available} resultater talt + + )} +
    + + {formatValue(e.value, resultType)} + +
  • + ) + })} +
+ + {ineligible.length > 0 && ( +
+

Ikke kvalifisert

+
    + {ineligible.map((e) => ( +
  • +
    + {e.player_name} + {e.ineligible_reason && {e.ineligible_reason}} +
    +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/components/order-of-merit-list.tsx b/frontend/components/order-of-merit-list.tsx new file mode 100644 index 0000000..77c01ae --- /dev/null +++ b/frontend/components/order-of-merit-list.tsx @@ -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 = { player: "Spiller", team: "Lag" } +const RESULT_TYPE_LABELS: Record = { + points: "Poeng etter plassering", + stableford: "Stableford-sum", + gross: "Bruttoscore-sum", + net: "Nettoscore-sum", + money: "Pengeliste", +} +const AGGREGATION_LABELS: Record = { sum: "Sum", average: "Snitt" } + +export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) { + const router = useRouter() + const [items, setItems] = useState(null) + const [creating, setCreating] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+ +
+
+ +
+ {error && ( +

+ {error} +

+ )} + +
+

+ Sesong-sammenlagt rangering av spillere på tvers av flere turneringer. +

+ {!creating && ( + + )} +
+ + {creating && ( +
+ setCreating(false)} /> +
+ )} + + {items === null ? ( +
+ + ) : items.length === 0 && !creating ? ( +
+
+
+

Ingen Order of Merit-er ennå

+

+ Sett opp en sesong-sammenlagt rangering på tvers av flere turneringer. +

+ +
+ ) : ( +
    + {items.map((oom) => ( +
  • + +
    + {oom.kind === "team" ? ( +
    +
    + {oom.name} +
    + {KIND_LABELS[oom.kind]} + {RESULT_TYPE_LABELS[oom.result_type]} + {AGGREGATION_LABELS[oom.aggregation_mode]} +
    +
    +
  • + ))} +
+ )} +
+
+ ) +} + +function Badge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function SegmentButton({ + selected, + onClick, + children, +}: { + selected: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +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("player") + const [resultType, setResultType] = useState("stableford") + const [aggregationMode, setAggregationMode] = useState("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 ( +
+

Ny Order of Merit

+ +
+ + setName(e.target.value)} + placeholder="F.eks. Klubbmesterskap-serien 2026" + className="h-11 rounded-xl" + /> +
+ +
+ Type +
+ setKind("player")}> + Spiller + + setKind("team")}> + Lag + +
+
+ +
+ Resultattype +
+ {(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => ( + setResultType(rt)}> + {RESULT_TYPE_LABELS[rt]} + + ))} +
+
+ +
+ Aggregering +
+ setAggregationMode("sum")}> + Sum + + setAggregationMode("average")}> + Snitt + +
+
+ +
+ + +
+
+ ) +} diff --git a/frontend/components/org-members.tsx b/frontend/components/org-members.tsx index e699295..701f598 100644 --- a/frontend/components/org-members.tsx +++ b/frontend/components/org-members.tsx @@ -8,7 +8,7 @@ import type React from "react" import { useEffect, useState } from "react" 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 { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -181,6 +181,19 @@ export function OrgMembers({ organizationId, orgName }: { organizationId: string {canInvite && } + +
+
+
+ Order of Merit + Sesong-sammenlagt rangering på tvers av turneringer +
+ +