Compare commits

...

7 commits

Author SHA1 Message Date
038516d236 All five tasks from the original list are complete, plus one critical production bug found and fixed along the way. Summary:
Fullført:

TeeCup-beskrivelse for logo — levert tidligere.
Migrasjon 056 (scramble_solo) — kjørt og live.
Matchspill-varianten (scramble_solo_match, migrasjon 057) — bygget, verifisert, live.
Kritisk bug funnet og rettet: lagspillere kunne ikke registrere score i det hele tatt for scramble_solo-formatene (evig spinner, ingen feilmelding) — var allerede live og ødelagt for ekte lagspillere. Rettet for begge varianter, rullet ut.
"Bruk som mal for egen bane" — vises nå kun i "Opprett ny bane"-flyten (ny lenke "Basér på en eksisterende bane i stedet"), ikke i det vanlige søket.
Alt er rullet ut live på teecup.golf, verifisert i nettleser mot isolerte scratch-miljøer (ryddet opp etterpå), og dokumentert i CHANGELOG.md (punkt 22–24) og FEATURE_BACKLOG.md.
2026-08-05 21:37:32 +02:00
c5455d0220 Fullfør domenemigrering teecup.teeoff.no -> teecup.golf på app-nivå
Caddy/DNS/SPF/DKIM/DMARC var allerede live på teecup.golf. Denne
committen kobler appen inn: docker-compose.yml sendte til nå ALDRI
TEECUP_PUBLIC_BASE_URL/TEECUP_VAPID_SUBJECT inn i teecup_api-
containeren (kjørende containere brukte derfor kode-fallbacken, gammelt
domene, for magic-link-URL-er til tross for at nettsiden allerede ble
servert fra teecup.golf). layout.tsx sin metadataBase og config.py sine
to hardkodede fallback-standarder rettet til samme domene. E-post-
fra-adresse (TEECUP_FROM_EMAIL/SMTP_USER, ny postkasse teecup@teecup.golf)
oppdatert direkte i .env (ikke committet, gitignored).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:35:19 +02:00
158699fb36 Rett utdatert CLAUDE.md-notat om fire uferdige turneringsformater
Københavner, High-low-high og Try-all (Chapman) er bygget og live;
Robbins ble droppet av bruker. Notatet pekte fortsatt til det gamle,
firedelte utvalget som grunn til å lese HCP-PDF-ene -- generalisert til
å gjelde enhver fremtidig ny turneringsform i stedet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:35:04 +02:00
ebd60da746 Fiks: "Fullfør runde" i header-modalen svelget feil stille og lukket seg uansett utfall
Modalen ("Administrer runde", tilgjengelig fra alle tre rundefanene) hadde en
egen, uavhengig "Fullfør runde"-vei som aldri sjekket setup_complete og
lukket dialogen umiddelbart uten å vente på fetch-svaret -- så en ufullstendig
formatoppsett (f.eks. Københavner med for få spillere) så ut til å fullføres,
men gjorde det aldri, uten noen feilmelding. Backend-valideringen var korrekt
fra før; kun frontend fulgte aldri opp svaret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 19:48:17 +02:00
fea701091c 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>
2026-08-04 14:19:30 +02:00
aee1662de2 Fiks turneringsoppsett-rekkefølge, koble til TeeOff-import, og bygg delt bane-mal-bibliotek
Klasser vises nå etter bane-/rundeoppsett (var meningsløst å velge
standardutslag før banen fantes). TeeOff-import koblet inn i
individuelle turneringer. Ny mal-basert baneoppretting i
turneringsmodulen (fra bunnen av / TeeOff / offentlig custom-bane),
med personal_course gjenbrukt som et delt, plattform-omfattende
bane-bibliotek på tvers av org- og single-runde-modulen (ADR-042,
migrasjon 054).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:34:28 +02:00
828b508c53 Dokumenter Augusta-stil resultattavle i CHANGELOG/FEATURE_BACKLOG
Fullfører dokumentasjonen for den nye slagspill-resultattavlen (POS/
PLAYER/TODAY/THRU/TOTAL/R1-Rn), rullet ut 2026-08-04: V0-integrering
(zip 29), Golfscore-språk-fiksen for rundekolonnenes eksakt-par-tilfelle,
og den reelle til-par-rangeringsbugen funnet og rettet under
scratch-verifisering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 04:51:09 +02:00
44 changed files with 7118 additions and 515 deletions

View file

@ -395,7 +395,13 @@
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)",
"Bash(echo \"tsc exit: $?\")",
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)"
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)",
"Bash(xargs -I{} basename {})",
"Bash(python3 test_part2_backend.py)",
"Bash(python3 test_oom_backend.py)",
"Bash(grep -n \"Dine organisasjoner\\\\|organizations/\\\\[id\\\\]\\\\|/organizations/\\\\${\" /opt/teecup/frontend/components/dashboard.tsx)",
"Bash(python3 -c \"import fastapi\")",
"Bash(openssl rand *)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -0,0 +1,23 @@
-- Bane-mal-bibliotek, del II (2026-08-04) -- brukeren oppdaget at
-- turneringsmodulen manglet TeeOff-import for individuelle turneringer OG at
-- "opprett manuell bane" der bare var et bart navnefelt (ingen hull/utslag-
-- skjema fantes). Løsningen: la BEGGE moduler (turnering + single-runde)
-- tilby "bruk en eksisterende bane som mal" -- enten fra TeeOff eller fra en
-- annen bruker/organisasjons offentlige custom-bane -- som forhåndsutfyller
-- et redigerbart hull/utslag-skjema før lagring.
--
-- personal_course (020_personal_rounds.sql) er, til tross for navnet,
-- ALLEREDE et globalt, plattform-omfattende bibliotek -- GET /personal-
-- courses søker på tvers av ALLE brukeres baner uten eier- eller org-
-- filtrering (rounds.py:142-149). Bekreftet med bruker: dette biblioteket
-- er nettopp det "offentlige" stedet begge moduler skal dele -- ingen ny
-- tabell trengs, kun denne ene kolonnen for opphav/attribusjon når en bane
-- er en redigert kopi ("fork") av en annen.
--
-- Eierskaps-håndhevelse for de nye endre-/dupliser-/slette-endepunktene
-- (rounds.py) skjer i APPLIKASJONSLAGET (created_by_user_id ==
-- innlogget bruker), IKKE via RLS -- personal_course har aldri vært
-- org-scopet eller RLS-dekket (bevisst, siden opprettelsen i migrasjon 020),
-- og det er ingen grunn til å endre det prinsippet nå.
ALTER TABLE personal_course
ADD COLUMN forked_from_id uuid REFERENCES personal_course (id) ON DELETE SET NULL;

117
055_order_of_merit.sql Normal file
View 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 $$;

View file

@ -0,0 +1,45 @@
-- =====================================================================
-- TeeCup — Scramble mot enkeltspiller, slagspill-varianten (frittstående
-- runder), migrasjon 056.
--
-- Første ASYMMETRISKE to-siders-format i prosjektet: et scramble-lag
-- (variabel størrelse, N>=2, delt ball) spiller netto slagspill mot ÉN
-- individuell spiller (egen ball) -- laveste nettosum vinner. Bevisst
-- utenfor omfang: match-play-varianten (egen runde senere), org-
-- lagturneringer (kun frittstående runder nå).
--
-- Lagets Playing Handicap = enkelt gjennomsnitt av medlemmenes course
-- handicap (handicap_engine.py sin nye TeamAverage) -- bevisst uavhengig
-- av scramble_2/scramble_4 sine faste RankedSplit-tabeller, som
-- forutsetter fast lagstørrelse. Ingen av de tre HCP-PDF-ene (se
-- CLAUDE.md) dekker lag-vs-individuell eller vilkårlig lagstørrelse --
-- dette er en bevisst ny, enkel TeeCup-regel, bekreftet med bruker
-- 2026-08-05, ikke en kildebelagt formel.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
ALTER TABLE round ADD CONSTRAINT round_play_format_check
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball', 'high_low_high', 'scramble_solo'));
-- Individualspillerens konfigurerbare allowance-%, arrangør-satt per
-- runde, standard 100. Bespoke kolonne (samme mønster som bbb_sweep_
-- bonus_enabled/shamble_best_n) -- IKKE den generiske allowance_override-
-- JSON-en (migrasjon 051), som forutsetter ÉN delt strategi for hele
-- runden. Dette formatet trenger to UAVHENGIGE strategier: lagsiden har
-- ingen konfig i det hele tatt (alltid rent snitt via TeamAverage),
-- individualsiden har nøyaktig denne ene justerbare prosenten.
ALTER TABLE round ADD COLUMN scramble_solo_individual_pct smallint NOT NULL DEFAULT 100
CHECK (scramble_solo_individual_pct BETWEEN 0 AND 100);
-- Side-rolle-disambiguering. Nullable, meningsløs/NULL for alle andre
-- formater. round_side kan slettes/gjenopprettes fritt (delete_round_
-- side) uten garantert stabil opprettelsesrekkefølge -- rekkefølge-basert
-- "side 1 = lag, side 2 = individuell" er derfor IKKE pålitelig identitet.
-- round_side_id IS NULL for individualspilleren ville dessuten brutt
-- eksisterende "er denne deltakeren med i matchen"-sjekker
-- (_two_sided_hole_results m.fl.), som bruker nettopp round_side_id IS
-- NOT NULL som signal -- individualspilleren får derfor en ekte
-- round_side-rad (rollen 'individual'), ikke NULL.
ALTER TABLE round_side ADD COLUMN side_role text CHECK (side_role IN ('team', 'individual'));
CREATE UNIQUE INDEX round_side_role_unique ON round_side (round_id, side_role) WHERE side_role IS NOT NULL;

View file

@ -0,0 +1,22 @@
-- =====================================================================
-- TeeCup — Scramble mot enkeltspiller, MATCHSPILL-varianten (frittstående
-- runder), migrasjon 057.
--
-- Samme asymmetriske to-siders-struktur som scramble_solo (migrasjon
-- 056, stroke-play-varianten) -- variabelt scramble-lag mot nøyaktig én
-- individuell spiller -- men avgjort hull-for-hull ("X UP"/dormie/
-- avgjort) i stedet for netto totalsum-sammenligning.
--
-- REN CHECK-utvidelse -- ingen nye kolonner. round_side.side_role og
-- round.scramble_solo_individual_pct (begge fra migrasjon 056) er
-- allerede generiske nok til å gjenbrukes uendret: samme betydning
-- (hvem er laget/individualspilleren, individualspillerens allowance-%)
-- gjelder uansett om sammenligningen er netto totalsum eller matchspill.
-- handicap_engine.py sin match_play_strokes()/compute_match_state()
-- brukes uendret (begge allerede domeneagnostiske) -- ingen ny motorkode.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
ALTER TABLE round ADD CONSTRAINT round_play_format_check
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball', 'high_low_high', 'scramble_solo', 'scramble_solo_match'));

View file

@ -3899,6 +3899,176 @@ for-tall-bekreftet leaderboard-rangering per klasse i ekte nettleser).
---
## ADR-042: Delt, plattform-omfattende bane-mal-bibliotek (`personal_course`)
**Kontekst:** Brukeren oppdaget at turneringsmodulens "opprett manuell
bane" i praksis var ubrukelig (kun et navnefelt, ingen vei til hull/
utslag), og ba om at BÅDE turneringsmodulen og single-runde-modulen skal
tilby "bruk en eksisterende bane (TeeOff eller andres custom-bane) som
mal" ved manuell baneoppretting. Et oppfølgingsspørsmål presiserte at
disse custom-banene "bør lagres, og de må være offentlige" — synlige og
gjenbrukbare av andre.
**Beslutning A — Gjenbruk `personal_course`, ikke en ny tabell.**
`personal_course` (020_personal_rounds.sql, opprinnelig bygget for
frittstående runder) er, til tross for navnet og til tross for at ingen
tidligere runde eksplisitt utnyttet det, ALLEREDE en global,
plattform-omfattende katalog — `GET /personal-courses` søker på tvers av
ALLE brukeres baner uten eier- eller organisasjonsfiltrering, siden
tabellen (bevisst, fra migrasjon 020) aldri har vært RLS-/org-scopet.
Fremfor å bygge en ny `community_course`-tabell (vurdert og forkastet)
gjenbrukes denne eksisterende, allerede-globale tabellen direkte som det
delte mal-biblioteket — eneste tilføyelse er én kolonne,
`forked_from_id` (migrasjon 054, selvreferende FK, `ON DELETE SET NULL`)
for proveniens/attribusjon. Dette er også den ENESTE farbare veien for
at single-runde-modulen (som ikke har noe organisasjons-begrep i det
hele tatt) kan dele samme bibliotek som org-turneringsmodulen.
**Beslutning B — "Offentlig" betyr hele plattformen, ikke bare egen
organisasjon.** Bekreftet eksplisitt med bruker (`AskUserQuestion`) etter
at jeg flagget spenningen: skal en publisert custom-bane være synlig for
ALLE organisasjoner + alle frittstående brukere, eller kun innad i én
organisasjon? Svaret var plattform-omfattende — organisasjonsgrensen
gjelder domenedata (turneringer, spillere, resultater), ikke dette
delte, lavsensitive bane-referansebiblioteket. En org-`course`-rad
(brukt til faktisk spill i en turnering) forblir like fullt org-scopet
og RLS-beskyttet som før — publisering til `personal_course` skjer som
en SEPARAT, samtidig innsetting i samme transaksjon (`courses.py`), ikke
en endring av `course`-tabellens egen skoping.
**Beslutning C — Eierskap håndheves i applikasjonslaget, ikke RLS.**
Siden `personal_course` bevisst aldri har vært org-scopet, finnes det
ingen `app.current_org`-kontekst å håndheve mot. De nye
endre-/dupliser-/slette-endepunktene (`rounds.py`) sjekker eksplisitt
`created_by_user_id == innlogget bruker` i handler-koden — samme
`plain_connection()`-uten-RLS-mønster som resten av `personal_course`/
`round`-familien allerede bruker (ADR-033 Beslutning A).
**Beslutning D — Rediger forker automatisk hvis du ikke eier raden;
Dupliser er en egen, eksplisitt handling.** `PATCH /personal-courses/{id}`:
eier den innloggede brukeren raden, oppdateres den i sted; eier
brukeren IKKE raden, opprettes automatisk en NY rad (eid av innlogget
bruker, `forked_from_id` satt til originalen) — originalen selv røres
aldri. Ett endepunkt dekker begge casene uten at frontend selv må
forgrene på eierskap. `POST .../duplicate` er en SEPARAT, eksplisitt
handling tilgjengelig uansett eierskap på kilden — dekker f.eks. å lage
en variant av DIN EGEN bane uten å miste originalen, noe fork-ved-
redigering alene ikke gjør (den trigges kun når raden IKKE er din).
Bekreftet eksplisitt med bruker at begge mekanismene skulle beholdes
side om side.
**Beslutning E — Mal-bruk er en engangs-kopi, ingen vedvarende kobling.**
Når en org-`course` opprettes med en `personal_course` (eller en
TeeOff-bane) som utgangspunkt, kopieres dataene inn i den nye raden ved
opprettelsestidspunktet — ingen fremmednøkkel eller synk-mekanisme
knytter dem sammen etterpå. Samme filosofi som offisiell TeeOff-import
allerede etablerte (ADR-019): en mal er et utgangspunkt å redigere fritt
fra, ikke en levende referanse. Konsekvens: sletting av en
`personal_course`-mal påvirker ALDRI en org-`course`-rad som tidligere
ble opprettet fra den.
**Konsekvens/bevisst utenfor omfang:** ingen moderasjon/vetting av
offentlige baner (tillitsbasert, matcher appens øvrige
lukkede-plattform-antagelser); ingen privat/kun-min-org-synlighet per
bane (alt publisert via denne veien er plattform-offentlig, ingen
per-rad-innstilling bygget). Se CHANGELOG.md 2026-08-04 for full
bygge-/verifiseringsdetalj (34/34 håndregnede API-sjekker, ekte
nettleser-verifisering på tvers av to brukere/to organisasjoner).
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
---
## 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:

View file

@ -7120,3 +7120,733 @@ Neste steg:
053 mot ekte `teecup_db`, `docker compose up -d --build teecup_api
teecup_frontend`, begge containere boot-et rent, `/health`/
`/dashboard` → 200.
18. **Augusta-stil resultattavle for slagspill-turneringer (POS/PLAYER/
TODAY/THRU/TOTAL/R1-Rn) — BYGGET, SCRATCH-VERIFISERT OG LIVE
2026-08-04, ingen migrasjon.** Brukeren viste et bilde av en fysisk
leaderboard-tavle fra Augusta National og ba om samme kolonneoppsett
for individuelle turneringer med brutto/netto/stableford-scoring —
med lederen alltid øverst, valgfri automatisk rulling, og eksplisitt
krav om at det skal se bra ut på storskjerm (ikke bare mobil). Bygget
via V0 (zip 29, `stroke-play-leaderboard.tsx`) etter etablert mønster:
ren visuell komponent med mock-data først, ekte backend-kobling
etterpå.
**V0-prompten** (skrevet FØR eksporten) spesifiserte eksplisitt at
fargevalget (grønn=under par/oransje=over par) skal følge appens EGEN
etablerte konvensjon, IKKE referansebildets amerikanske rød-for-
under-par-tradisjon — V0 traff dette presist. Eksporten matchet
datakontrakten i prompten nesten ordrett (`RoundCell`/`LeaderboardRow`
-typene er identiske), inkludert korrekt implementert lederrad
fastspent i gull, av/på-knapp for automatisk rulling som respekterer
`prefers-reduced-motion`, frossen POS/PLAYER-kolonne på mobil, og en
egen, betydelig større "storskjerm"-skalering (`lg:`-brekkpunkt) --
første skjerm i appen bygget eksplisitt for dette. **Én reell,
forventet justering gjort ved integrering** (prompten dekket ikke
dette presist nok selv): rundekolonnenes eksakt-par-tilfelle
("E") fikk feilaktig samme kvadrat+oransje-behandling som over par --
rettet til appens egen "E → ren tekst, ingen ramme"-regel
(DESIGN_SYSTEM.md) ved å utvide `RoundCell.isUnderPar: boolean|null`
til en eksplisitt `tone: "under"|"even"|"over"|null`, bekreftet
visuelt riktig i scratch (72 mot par 72 vises nå som ren tekst, ingen
ramme).
**Backend** (`individual_tournaments.py`): ingen skjemaendring --
`individual_leaderboard`-endepunktet utvidet med nye, valgfrie felt
(`position`/`is_leader`/`today_label`/`thru_label`/`total_label`/
`rounds[]`), KUN populert for scoring_method brutto/netto/stableford
(uendret `null`/tom liste for København/BBB, som fortsatt bruker den
enkle listevisningen). "I dag" (TODAY/THRU) utledes som runden med
høyest sekvensnummer som NOEN deltaker har påbegynt (bekreftet med
bruker: ingen eksplisitt "aktiv runde"-markering finnes eller trengs
-- runder spilles sekvensielt i praksis). Par-for-spilte-hull regnes
per (runde, deltaker) via en ny korrelert subquery mot
`tournament_round_hole`, gjenbruker de allerede cachede
`gross_total`/`net_total`/`stableford_points`-verdiene fra
`tournament_round_score` uendret.
**Reell rangeringsbug funnet OG rettet UNDER selve scratch-
verifiseringen** (ikke antatt riktig fra koden alene): den
EKSISTERENDE sorteringslogikken (uendret siden ADR-037) rangerte på
`gross_total`/`stableford_total` -- riktig for en enkelt-rundes
sammenligning, men i en flerrunde-turnering der deltakere har spilt
ULIKT antall hull til enhver tid, er rå sum ikke sammenlignbar (en
deltaker med kun 18 hull spilt fikk et lavere rått slagtall enn
deltakere med -5 til par over 27-36 hull, og rangerte dermed FORAN
dem som leder — funnet ved at test-scriptets egne håndregnede
assert-sjekker feilet). Rettet ved å innføre en til-par-normalisert
rangeringsnøkkel (`gross_total/net_total par_for_spilte_hull`,
negert for stableford siden flere poeng er bedre der) brukt BÅDE til
sortering og uavgjort-deteksjon, i stedet for rå totaler. Ny
`entries.sort(...)`-plassering flyttet inn i denne funksjonen
(erstatter den gamle grenen kun for disse tre metodene — København/
BBB/default-brutto beholder sin uendrede, allerede riktige rå-sum-
sortering, siden alle deltakere der alltid har likt antall
hull/runder tilgjengelig i de formatene).
**Frontend** (`individual-tournament-detail.tsx`): `LeaderboardTab`
grener nå på `scoring_method` -- `StrokePlayLeaderboard` (ny
komponent) for brutto/netto/stableford, uendret gammel flat/klasse-
gruppert liste for København/BBB/Flagg. Klasse-gruppering (2026-08-04
tidligere samme dag) virker uendret -- komponenten instansieres én
gang per klasse-seksjon, akkurat som den gamle listen ble.
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
engangs API-/frontend-container, ekte nettleser-innlogging inkl.
2FA): en 4-spiller, 2-rundes turnering med et bevisst konstruert
scenario -- ekte uavgjort i toppen (to spillere -5 til par, ulikt
antall hull spilt hver), én spiller midt i runde 2 (thru 9), én
spiller som IKKE hadde startet runde 2 i det hele tatt, én ferdig
begge runder. ALLE håndregnede tall (POS med "T1"-uavgjort, TODAY,
THRU inkl. "F"/"-"/hull-tall, TOTAL, R1/R2 med riktig form+farge per
Golfscore-språket) bekreftet eksakt riktige, både via rå API-JSON og
i ekte nettleser på BÅDE 390px mobil (frossen POS/PLAYER, resten
skrollbar) og 1920px storskjerm (alt synlig uten skrolling, betydelig
større tekst). Rull-automatisk-knappen bekreftet å veksle av/på.
Regresjonstestet: en BBB-turnering bekreftet fortsatt å returnere
tomme/`null`-verdier for de nye feltene (gammel visning uendret,
ingen ny kode-vei berørt for de formatene). 98/98 eksisterende
`handicap_engine.py`-enhetstester uendret (ingen motorendring), ekte
typesjekket produksjonsbuild.
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt: INGEN
migrasjon (rene response-felt-tillegg), `docker compose up -d --build
teecup_api teecup_frontend`, begge containere boot-et rent,
`/health`/`/dashboard` → 200. V0-zip-en slettet etter merge, per
etablert rutine.
19. **Baneoppsett i turneringer: rekkefølge, TeeOff-import og delt
bane-mal-bibliotek — BYGGET OG SCRATCH-VERIFISERT GRUNDIG 2026-08-04,
migrasjon 054, IKKE ENNÅ RULLET UT MOT ekte `teecup_db`.** Brukeren
oppdaget, mens hen brukte den live turneringsmodulen, at "Klasser"
(med sin Standardutslag-velger, ADR-041) vises FØR banen/runden i det
hele tatt er satt opp -- meningsløst å velge standardutslag for en
klasse før man vet hvilke utslag banen har. Bemerket samtidig at
TeeOff-henting av baner ikke så ut til å være tilgjengelig i
turneringsmodulen, og ba om en helt ny funksjon: la BÅDE
turneringsmodulen OG single-runde-modulen tilby "bruk en eksisterende
bane som mal" (fra TeeOff, eller fra en annens custom-bane) når man
oppretter en manuell bane.
**Del I -- rekkefølge + TeeOff-import-gap (ingen skjemaendring):**
Explore-agent bekreftet klagen presist og avdekket at det var verre
for lagturneringer enn antatt: for individuelle turneringer lå
`ClassesCard` FØR `RoundsCard` i samme Oppsett-fane
(`individual-tournament-detail.tsx`); for lagturneringer lå Klasser
på selve FØRSTE fanen ("Lag og spillere"), mens bane-/rundeoppsett
krevde en hel sidenavigering til "Program". TeeOff offisiell-import
(ADR-019, ferdig bygget) var KUN koblet til lagturnerings-økt-UI-et,
fullstendig fraværende fra den individuelle turneringsflyten.
Fikset: `ClassesCard` flyttet til å rendres ETTER `RoundsCard` i
individuelle turneringer; Klasser-kortet flyttet fysisk fra
`tournament-detail.tsx` ("Lag og spillere") til `tournament-program.tsx`
("Program"), rett etter økt-/rundeoppsettet -- `classes`-state (brukt
av `TeamPanel` til roster-klassevisning) ble værende i
`tournament-detail.tsx`, men create/delete-handlerne og selve
`ClassesCard`-komponentdefinisjonen flyttet med til `tournament-program.tsx`.
`OfficialCourseSearch`-komponentmønsteret (allerede i
`tournament-program.tsx`) kopiert inn i `individual-tournament-detail.tsx`
(samme duplisering-mellom-turneringstype-filer-konvensjon som
`ClassesCard` allerede fulgte), koblet til `NewRoundForm` sitt
eksisterende "+ Ny bane"-felt.
**Det største funnet (Explore-agent, avdekket FØR bygging):**
turneringsmodulens "opprett manuell bane" var i praksis ubrukelig --
KUN et navnefelt (`POST /orgs/{id}/courses` tok bare imot `{name}`),
ingen vei til hull/par/hcp-indeks/utslag i det hele tatt. To
sub-ressurs-endepunkter for å legge til dette i etterkant fantes
(`courses.py` sine `create_tee`/`create_holes`), men hadde ALDRI fått
noe frontend-kallsted -- funksjonen ble aldri fullført.
**Del II -- mal-basert baneoppretting + delt offentlig bane-bibliotek.**
Et nøkkelfunn forenklet løsningen betraktelig: `personal_course`
(020_personal_rounds.sql, "frittstående runder") er, til tross for
navnet, ALLEREDE et globalt, plattform-omfattende bibliotek -- `GET
/personal-courses` søker på tvers av ALLE brukeres baner uten eier-
eller org-filtrering, og `POST /personal-courses` tar allerede imot
full hull-/utslagdata i ett atomisk kall. Ingen ny tabell trengtes --
kun én kolonne (`forked_from_id`, migrasjon 054) for
proveniens/attribusjon.
Tre `AskUserQuestion`-runder avklarte omfanget presist FØR bygging
(samme disiplin som ADR-037/039/041): (1) ren reorder, ingen
blokkering; (2) et helt NYTT, kompakt hull-/utslag-editorskjema for
turneringsmodulen (speiler single-runde-modulens `OwnCreateStep` i
funksjon, egen stil), med "bruk som mal"-vei fra BÅDE TeeOff og
offentlig custom-bane; (3) alle tre moduler (individuell,
lagturnering, frittstående runde) skal ha funksjonen.
Et oppfølgingsspørsmål fra brukeren ("disse custom banene bør lagres,
og de må være offentlige... er det noe jeg ikke har tenkt på?") ble
tatt til en fjerde avklaringsrunde: (a) "offentlig" betyr HELE
TeeCup-plattformen (ikke bare egen org) -- eneste måte
single-runde-modulen faktisk kan dele samme mal-bibliotek som
turneringsmodulen, siden den ikke har noe org-begrep; (b) "Dupliser"
beholdes som egen, eksplisitt handling i tillegg til at redigering av
en ANNENS bane automatisk forker en kopi (redigering av DIN EGEN bane
endrer den i stedet, ingen fork).
**Backend:**
- `054_personal_course_provenance.sql`: `personal_course.forked_from_id`
(selvreferende FK, `ON DELETE SET NULL`). Ingen RLS-endring --
tabellen har aldri vært org-scopet (bevisst siden migrasjon 020).
- `rounds.py`: `PersonalCourseOut`/`PersonalCourseDetail` utvidet med
`is_mine`/`created_by_display_name` (attribusjon i mal-søket, FULLT
navn per navneformat-regelen -- dette er en administrasjonsvisning,
ikke direkte adressering) og (kun Detail) `holes`/`full_tees`/
`forked_from_id` (forhåndsutfylling). Nye endepunkter: `PATCH
/personal-courses/{id}` (eier: oppdaterer i sted; IKKE eier: forker
automatisk -- ett endepunkt dekker begge casene uten at frontend må
forgrene på eierskap), `POST .../duplicate` (eksplisitt, uavhengig
av eierskap), `DELETE ...` (kun eier, 403 ellers; fanger opp
`asyncpg.ForeignKeyViolationError` fra `round.personal_course_id`
sin allerede-eksisterende `NO ACTION`-FK og gir en vennlig 409
"IN_USE" -- IKKE via den delte `translate_db_errors()`, som ville
gitt feil retning/melding for akkurat denne casen). `GET
/personal-courses` fikk en `mine=true`-parameter (for
"Mine baner"-administrasjonen, unngår den vanlige 20-treffs-
begrensningen på navnesøket).
- `courses.py`: `POST /orgs/{id}/courses` utvidet til valgfritt å ta
imot samme `holes`/`tees`-form som `PersonalCourseCreate` --
populert setter den BÅDE org-`course`-raden (med hull/utslag,
samme SQL-mønster som de eksisterende men ubrukte sub-ressurs-
endepunktene) OG en tilsvarende `personal_course`-rad (eid av
innlogget bruker, "offentliggjøringen" brukeren ba om) i SAMME
transaksjon -- ingen vedvarende kobling mellom de to radene etterpå
(samme "engangs-kopi"-filosofi som offisiell TeeOff-import,
ADR-019). `official-import`-endepunktet selv er UENDRET.
- `rounds.py` sin `GET /rounds/official-search/{slug}` (personlig
rundemodul) utvidet med fulle `holes`/`full_tees` PER TeeOff-bane
(kun populert når teeoff-dataene er komplette nok -- samme
fullstendighetskrav som ADR-019-importen allerede håndhever) --
nødvendig fordi frittstående runder ALDRI persisterer teeoff-baner
(ADR-033 Beslutning C, live oppslag), så "bruk som mal" der må få
dataene tilbake i selve søkesvaret i stedet for å runde-trippe
gjennom en lagret rad slik org-siden kan.
**Frontend:**
- Ny delt fil `course-template-editor.tsx`: `CourseTemplateEditor`
(kompakt hull-/utslag-skjema, forhåndsutfyllbar via `initial`-prop,
ren UI + lokal validering -- kalleren avgjør hvor data persisteres,
matcher prosjektets etablerte adaptermønster) + `CourseTemplatePicker`
(velger: "Fra bunnen av" / "TeeOff-bane som mal" / "Offentlig bane
som mal" -- sistnevnte med BÅDE "Bruk direkte" og "Tilpass før
bruk"). Brukt fra `tournament-program.tsx` (både ny økt OG endre
eksisterende økts bane) og `individual-tournament-detail.tsx`
(`NewRoundForm`).
- `new-round.tsx`: `OwnCreateStep` utvidet med valgfrie
`initial`/`forkedFromId`-props (samme prefyll-mekanisme). Nye
"Bruk som mal for egen bane"-knapper i BÅDE `CourseList` (TeeOff-
baner, kun synlig når komplette maldata finnes) og `OwnSearch`
(offentlige custom-baner, nå med attribusjon "Opprettet av X" /
"Opprettet av deg" -- samme navneformat-regel som backend).
- `account-settings.tsx`: ny "Mine baner"-seksjon (`MyCoursesSection`)
-- liste over EGNE `personal_course`-rader (`?mine=true`), med
Rediger (gjenbruker `CourseTemplateEditor`, PATCH), Dupliser
(POST duplicate) og Slett (DELETE, med vennlig 409/i-bruk-melding)
per rad. Andres baner vises IKKE her -- kun i mal-søket i de
respektive modulene.
**Reell bug funnet og rettet UNDER scratch-verifisering** (ikke
antatt riktig fra koden alene): `CourseTemplateEditor` ble først
bygget med sitt eget `<form onSubmit>` -- men komponenten rendres
INNI et allerede eksisterende `<form>` (`NewRoundForm`/
`CreateSessionCard`), og nestede `<form>`-elementer er ugyldig HTML.
"Opprett bane"-knappen submittet i praksis det YTRE runde-/økt-
skjemaet i stedet, en ekte side-navigasjon som vasket bort
`?org=...`-parameteren fra URL-en -- funnet ved en ekte klikk-
gjennom-test i nettleser (fetch-kallet nådde aldri serveren, bekreftet
ved å sjekke databasen). Samme feilklasse som `OfficialCourseSearch`
allerede hadde en kommentar om fra en tidligere runde. Rettet: `<form>`
`<div>`, `type="submit"``type="button"` med eksplisitt
`onClick`-håndtering.
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-/frontend-container, ekte
nettleser-innlogging som TO forskjellige brukere på tvers av TO
forskjellige organisasjoner):
- API-nivå: eget Python-testskript, 34/34 håndregnede sjekker bestått
(publisering-ved-opprettelse, kryss-bruker/kryss-org synlighet,
attribusjon, fork-ved-ikke-eier-redigering med verifisert uendret
original, eier-redigering-i-sted, eksplisitt duplisering,
eierskaps-gatede 403-er, og slette-blokkert-mens-i-bruk med riktig
409/IN_USE-kode).
- Ekte nettleser: rekkefølge-fiksen bekreftet i begge turneringstyper;
"Opprett bane med hull/utslag" fra bunnen av OG fra TeeOff-mal
(ekte Bergen Golfklubb-data, redigert par på hull 1 før lagring,
bekreftet BÅDE org-`course`- og `personal_course`-raden ble
opprettet riktig i databasen); den nye offentlige banen dukket
umiddelbart opp i single-runde-modulens banesøk MED riktig
attribusjon; "bruk som mal"-broen fra der forhåndsutfylte
`OwnCreateStep` korrekt fra en ANNEN brukers bane og forket en ny,
egen-eid rad ved lagring (bekreftet i databasen: ny rad eid av
innlogget bruker, `forked_from_id` pekende på originalen, originalen
selv uendret); "Mine baner" i kontoinnstillinger bekreftet å vise
KUN egne baner, Rediger (i sted, verifisert i databasen), Dupliser
og Slett (inkl. `confirm()`-dialoghåndtering) alle fungerende.
- `test_isolation.sql` 12/12 bestått (additiv migrasjon, ingen
RLS-endring). Ekte typesjekket produksjonsbuild (`docker build`,
samme steg som `Dockerfile` faktisk bruker) kjørt flere ganger
gjennom byggerunden, null TypeScript-feil.
Scratch-miljøet (database, rolle, MinIO, containere, images,
midlertidige hemmeligheter) fullstendig ryddet opp etter
verifisering.
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt ("Ja
takk"): migrasjon 054 kjørt mot ekte `teecup_db` (kun
`forked_from_id`-kolonnen, bekreftet tom/nullable, ingen
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.
21. **Bugfiks: "Fullfør runde" i header-modalen ("Administrer runde")
svelget feil stille og lukket dialogen uansett utfall — 2026-08-04.**
Brukeren rapporterte (skjermbilder fra en ekte runde på
teecup.teeoff.no) at en Københavner-runde med kun 1 av påkrevde 3
spillere så ut til å la seg "fullføre" via "Administrer"-knappens
modal (bekreftelsesdialogen dukket opp, ble klikket OK på), men
runden forble ufullført -- ingen feilmelding vist noe sted.
**Rotårsak, `round-header.tsx`/`round-page-shell.tsx`:** rundens
sticky header (ADR-040 Beslutning C/D, brukt på alle tre
Score/Scorekort/Leaderboard-fanene) har sin EGEN, uavhengige
"Fullfør runde"-vei via "Administrer"-modalen -- parallell til, men
ikke delt med, `round-detail.tsx` sin allerede korrekte inline
"Spillere og runde"-fane (som riktig deaktiverer knappen og viser
`setup_message` når `setup_complete` er usann). Modal-veien hadde
ALDRI blitt koblet til `setup_complete`-gaten, OG knappens
`onClick` kalte `onClose()` rett etter å ha startet
`onFinishRound()` UTEN å vente på den asynkrone
bekreftelse+`fetch`-kjeden -- dialogen lukket seg altså alltid
umiddelbart, uavhengig av om `POST /rounds/{id}/complete` faktisk
lyktes (backend-en avviste korrekt med 409 `SETUP_INCOMPLETE` +
forklarende melding -- det var ren frontend som aldri fulgte opp
svaret).
**Fiks:** `onFinishRound`-kontrakten endret fra `() => void` til
`() => Promise<{ok, message} | null>` (`null` = brukeren avbrøt
bekreftelsen, dialogen blir stående). Modalen venter nå på svaret
før den lukker seg -- ved `ok: false` vises `message` inline i
dialogen i stedet. Ny `finishDisabledReason`-prop deaktiverer
knappen og viser samme `setup_message` som den inline fanen,
fjerner selve muligheten til å trykke seg forbi en ufullstendig
formatoppsett via denne veien. Ingen backend-endring -- valideringen
fantes allerede og var korrekt, kun frontend fulgte den aldri opp.
**Scratch-verifisert** (isolert `teecup_scratch`-DB, alle 55
migrasjoner kjørt friskt, isolert `teecup_app_scratch`-rolle,
isolert scratch-MinIO, engangs API-/frontend-container bygget fra
de faktiske endrede filene, ekte nettleser via magic-link-innlogging):
reproduserte brukerens NØYAKTIGE scenario (Københavner-runde, 1 av 3
spillere) -- bekreftet modalens "Fullfør runde" nå viser seg
deaktivert med "Københavner krever nøyaktig 3 spillere -- runden har
1." synlig i dialogen (samme tekst som den inline fanen). La til to
gjestespillere (nå 3/3), bekreftet knappen ble aktiv, klikket
gjennom bekreftelsesdialogen, og bekreftet både at modalen lukket
seg OG at `completed_at` faktisk ble satt server-side. Full
typesjekket produksjonsbuild av frontend kjørt som del av samme
Docker-bygg. Scratch-miljøet ryddet opp fullstendig etterpå.
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt: `docker
compose up -d --build teecup_frontend` (ingen migrasjon involvert),
containeren boot-et rent, `/health` og `/dashboard` → 200 over
https.
22. **Nytt format: "Scramble mot enkeltspiller" (`scramble_solo`),
slagspill-varianten — 2026-08-05 (migrasjon 056).** Første
ASYMMETRISKE to-siders-format i prosjektet: et scramble-lag
(variabel størrelse, N>=2, delt ball) mot ÉN individuell spiller
(egen ball), avgjort ved netto SLAGSPILL-totalsum (ikke matchspill)
-- laveste nettosum vinner. Bevisst utenfor omfang denne runden:
match-play-varianten av formatet (egen runde senere), org-
lagturneringer (kun frittstående runder nå).
**Kilder undersøkt FØR design** (CLAUDE.md sin regel for nye
formater): de tre HCP-PDF-ene dekker IKKE lag-vs-individuell eller
vilkårlig lagstørrelse -- bekreftet ved grundig lesing. Lagets
Playing Handicap er derfor en bevisst NY, enkel TeeCup-regel
(`TeamAverage` -- rent snitt av medlemmenes course handicap, for
N>=2), bekreftet eksplisitt med bruker, IKKE en kildebelagt formel
som `scramble_2`/`scramble_4` sine faste `RankedSplit`-tabeller
(som forutsetter fast lagstørrelse og derfor bevisst IKKE gjenbrukt
her). Individualspillerens allowance er en konfigurerbar prosent
(`scramble_solo_individual_pct`, std 100 %, arrangør-satt) --
egen bespoke kolonne, IKKE den generiske `allowance_override`-JSON-
en (som forutsetter ÉN delt strategi for hele runden, mens dette
formatet trenger to uavhengige strategier).
**Skjema** (migrasjon 056): `round.scramble_solo_individual_pct`
(smallint, std 100) og `round_side.side_role`
(`'team'`/`'individual'`, unik per rolle per runde). `round_hole`
sin eksisterende XOR-struktur (migrasjon 031) trengte INGEN endring
-- lagsiden bruker `round_side_id`-veien (samme som foursome/
scramble), individualsiden bruker `round_participant_id`-veien
(samme som singles), begge coexisterer allerede fritt i samme
tabell.
**Motor** (`handicap_engine.py`): ny `TeamAverage`-strategi og ny,
domeneagnostisk `net_stroke_play_margin(net_total_a, net_total_b)`
-- eksplisitt SØSKEN til, ikke del av, `compute_match_state`
(ingen hull-for-hull-tilstand for dette formatet). Selve per-side-
nettosummen gjenbruker eksisterende `allocate_strokes_by_index`/
`stroke_play_net_total`, ingen ny funksjon trengtes der. 8 nye
enhetstester (115/115 totalt, opp fra 107).
**Backend** (`app/routers/rounds.py`): ny `_ASYMMETRIC_SIDE_FORMATS`-
konstant (bevisst adskilt fra `_TWO_SIDED_FORMATS`, siden formatet
verken er symmetrisk eller matchspill). Asymmetrisk sidekapasitet i
`_check_side_capacity` (laget: ingen øvre grense, individualsiden:
maks 1) -- kan IKKE uttrykkes av det eksisterende
`_SIDE_PLAYER_COUNT`-oppslaget, som forutsetter ett fast tall for
HELE formatet. Ny `_individual_round_hole_needed`-hjelpefunksjon
(avgjør delt-ball vs. individuell-ball-lagring per DELTAKER, ikke
per format, siden de to sidene har ulik struktur). Ny
`_recompute_scramble_solo_handicaps` (søsterfunksjon til
`_recompute_side_handicaps`, som ikke kunne gjenbrukes -- to
uavhengige strategier, ikke én). Ny `_build_scramble_solo_result`
(samme "regn ut ved lesing, ingen cache"-filosofi som Nassau/
High-low-high), dispatchet FØR den generiske to-sidede grenen i
`_build_format_result`.
**Frontend -- ALT promptet til V0** (brukerens eksplisitte instruks
denne runden, gjentatt: "Alt av frontend skal promptes mot V0" --
ingen håndkoding/design-beslutning av meg). To V0-prompter skrevet
(forankret i `DESIGN_SYSTEM.md` + tilgjengelighetskravet), levert
som zip 30 (oppsett-steget) og zip 31 (resultatvisning, bygget på
30). Integrert -- IKKE blindt kopiert (V0 sin egen full-app-eksport
bruker fortsatt mock-data for alt utenom det som faktisk ble
promptet) -- kun de faktiske diff-relevante stykkene ble portet inn
i de allerede API-koblede filene:
- `new-round.tsx`: ny `ScrambleVsSoloSides`-komponent (Steg 4,
asymmetrisk sideoppsett -- "Laget" åpen liste/"minst 2"-hint,
"Individuell motstander" låst til 1), ny `SoloAllowanceControl`
(Steg 2, 0-100 %-stepper). `submitWizard()` fikk en egen
side-opprettelses-gren for formatet (sender `side_role`, som
TWO_SIDED-formatenes gren ikke gjør). "Avanserte handicap-
innstillinger"-panelet skjules helt for formatet (ingen av
feltene der har noen effekt siden formatet aldri bruker
`allowance_override`) -- funnet og rettet UNDER integrasjonen,
ikke en del av V0-eksporten.
- `scramble-solo-result.tsx`: NY fil, ren V0-eksport uendret
(selvstendig presentasjonskomponent, `ScrambleSoloResultView`).
Adapter fra `ApiFormatResult` til komponentens props-form skrevet
lokalt i `round-leaderboard.tsx` (`buildScrambleSoloResult`) og
`round-detail.tsx` (lokal kopi, prosjektets vanlige mønster) --
foretrekker hull-entryens `label`-felt (deltakerens faktiske
navn) over sidens egen (faste) label, med fallback.
- `round-scorecard.tsx`: eneste sted som trengte GENUINT ny logikk
(ikke bare registrering) -- formatet har BLANDEDE enhetstyper i
samme fane-liste (laget = side, individualspilleren =
participant). Løst ved å slå opp faktisk enhetstype fra selve
`unitId` (`round.sides.some(s => s.id === unitId)`) i stedet for
et format-bredt `isSharedBall`-flagg, som ikke kan uttrykke en
blanding -- denne oppslags-tilnærmingen forble samtidig korrekt
uendret for alle eksisterende rene formater.
- `round-detail.tsx`: INGEN endring i selve scoreføringen
(`SideScoreWizard` for laget, vanlig per-deltaker-veiviser for
individualspilleren fungerer begge uendret) -- kun
`FormatResultPanel` fikk en ny gren (MÅ avskjæres FØR den
generiske to-sidede fallback-koden, ellers ville den krasjet/vist
feil for dette asymmetriske formatet).
- `FORMAT_LABELS` lagt til i 8 filer (`round-card.tsx`,
`round-leaderboard.tsx`, `round-detail.tsx`, `round-scorecard.tsx`,
`round-stats.tsx`, `friend-profile.tsx`, `watch-round.tsx`) --
`public-live.tsx` bevisst IKKE (den nøkler på org-turneringers
`session.format`, utenfor omfang).
**Scratch-verifisert grundig, TO RUNDER** (samme "backend alene,
så full stack"-disiplin som Order of Merit-runden):
1. Backend alene: isolert `teecup_scratch`-DB (alle 56 migrasjoner
friskt), isolert `teecup_app_scratch`-rolle, isolert scratch-
MinIO, engangs API-container. Håndregnet Python-testskript, 45/45
sjekker bestått -- 3-manns lag (HCP 8/14/20, snitt 14) mot
individualspiller (HCP 10), begge 100 % og 50 % allowance,
inkl. eksplisitt bevis på at en hull-SI mellom de to
allowance-tersklene (SI 7) korrekt skifter fra 1 til 0 mottatte
slag. Kapasitetsgrenser bekreftet begge veier (laget avviser
ALDRI en 4. spiller, individualsiden avviser alltid en 2.).
"Fullfør runde"-gating eksplisitt testet med et ufullstendig lag
(1 spiller) -- avvist med riktig forklarende melding, samme
type sjekk som avdekket punkt 21 sin bug.
2. Full stack: ny isolert scratch-runde (DB/rolle/MinIO/API-
container pluss et ekte `docker build` av frontend-imaget --
den faktiske typesjekkede produksjonsbuilden, ikke bare lokal
`tsc`), ekte nettleser via magic-link-innlogging
(`TEECUP_DEV_LOG_MAGIC_LINKS`). Opprettet en ekte runde gjennom
hele veiviseren (inkl. V0 sin `ScrambleVsSoloSides`-UI), satte
score på tre hull via ekte PATCH-kall, og bekreftet at
`ScrambleSoloResultView` viste EKSAKT de håndregnede tallene
("Laget leder med 1 slag", 12 mot 13 netto) med riktig
grønn/oransje leder-fremheving per hull. `test_isolation.sql`
uendret 12/12 (migrasjonen er rent additiv). Scratch-miljøet
ryddet opp fullstendig etter BEGGE runder.
**Rullet ut live 2026-08-05**, bruker bekreftet eksplisitt: migrasjon
056 kjørt mot ekte `teecup_db` (`round.scramble_solo_individual_pct`,
`round_side.side_role` + `round_side_role_unique`-indeksen, bekreftet
tilstede etterpå), `docker compose up -d --build teecup_api
teecup_frontend`, begge containere boot-et rent, `/health` og
`/my-rounds/new` → 200 over https, `round_play_format_check` bekreftet
å inkludere `'scramble_solo'`.
23. **Matchspill-varianten av samme format (`scramble_solo_match`) —
2026-08-05 (migrasjon 057), PLUSS en kritisk regresjonsbug funnet og
rettet i samme runde som rammet BEGGE variantene (se eget punkt
under).** Samme asymmetriske lag-mot-individualspiller-struktur som
punkt 22, men avgjort hull-for-hull (matchspill) i stedet for netto
slagspill-totalsum. Vesentlig mindre bygg enn punkt 22 -- nesten alt
gjenbrukes uendret.
**Motor -- ingen endring:** `match_play_strokes`/`compute_match_state`
(`handicap_engine.py`) var allerede domeneagnostiske og brukes uendret
-- konverterer lagets `TeamAverage`/individualspillerens
`PerPlayerPercentage(pct/100)` (samme `_recompute_scramble_solo_
handicaps` som punkt 22, uendret) til RELATIVE slag (laveste side
spiller "av scratch"), i motsetning til slagspill-variantens
ABSOLUTTE, uavhengige allowance per side -- bevisst forskjell, riktig
fordi matchspill per definisjon betyr relativ slagfordeling.
**Skjema** (migrasjon 057): ren CHECK-utvidelse, INGEN nye kolonner
-- `round.scramble_solo_individual_pct`/`round_side.side_role`
(migrasjon 056) er generiske nok til å gjenbrukes uendret.
**Backend:** ny delt konstant `_SCRAMBLE_SOLO_FORMATS = {"scramble_
solo", "scramble_solo_match"}`, widened seks eksakte
`== "scramble_solo"`-strengsjekker i `app/routers/rounds.py` til
settmedlemskap. Ny `_build_scramble_solo_match_result` gjenbruker det
eksisterende blandede hull-oppslaget fra `_build_scramble_solo_result`
(samme to queries: `round_hole` via `round_side_id` for laget, via
`round_participant_id` for individualspilleren), bygger en
`list[HoleResult]` fra netto-sammenligning per hull, mater
`compute_match_state` -- gir lead/holes_remaining/is_dormie/
is_closed/`describe()` gratis.
**Frontend -- maksimalt gjenbruk, bekreftet eksplisitt med bruker
(ingen ny V0-prompt for denne varianten):** `new-round.tsx` sin
oppsett-UI (`ScrambleVsSoloSides`/`SoloAllowanceControl`) gjenbrukt
HELT uendret. Resultatvisning gjenbruker `TwoSidedBoard`
(round-leaderboard.tsx) og den generiske to-sidede
`sideIdentity`/`LeadZone`-grenen (round-detail.tsx) -- begge allerede
korrekte for en N-mot-1-side siden de grener på FAKTISK ANTALL
SPILLERE per side, ikke på rolle. Én kritisk, selv-oppdaget
korrekthetsbug FØR noen verifisering i det hele tatt: første utkast
av `_build_scramble_solo_match_result` brukte en FAST konvensjon
(laget alltid "a", individualspilleren alltid "b") -- resten av appen
(inkl. `TwoSidedBoard`) forutsetter derimot at "a" = `round.sides[0]`
SORTERT ETTER UUID, ikke rolle. Rettet til `team_is_a = team_side["id"]
< individual_side["id"]`, tredd gjennom hull-sammenligningen og
`FormatHoleEntry`-side-tagger. `round-scorecard.tsx` sin
`MatchScorecardGrid` fikk en liten, dedikert (ikke-V0) gren for å vise
ÉN rad per SIDE for dette formatet (samme mønster som `isSharedBall`-
grenen der) -- uten den ville laget vist N dupliserte rader, siden
alle lagmedlemmer deler samme `TeamAverage`-tall.
**KRITISK REGRESJONSBUG funnet under full nettleser-verifisering,
rammet OGSÅ den allerede live slagspill-varianten (punkt 22) --
ikke bare den nye matchspill-varianten:** `round-detail.tsx` sin
"Score"-fane (`SHARED_BALL_FORMATS`) inneholdt aldri `scramble_solo`/
`scramble_solo_match` (kun ekte delt-ball-formater som foursome/
scramble_2/scramble_4/chapman) -- rimelig nok isolert sett, siden
scramble mot enkeltspiller er HALVPARTEN delt-ball (laget) og
HALVPARTEN individuell (motstanderen), ikke rent det ene eller det
andre. Konsekvens: et klikk på en LAGSPILLERS scorekort rutet
(feilaktig) til den vanlige per-deltaker `ScoringWizard`/
`wizardPlayerId`-stien i stedet for den delte `SideScoreWizard`/
`wizardSideId`-stien. Siden lagspilleres hull ALDRI lagres per
`round_participant_id` (kun per `round_side_id`), returnerte
`GET .../participants/{id}/holes` alltid `[]` for dem -- og siden
SPINNER-porten i samme fil (`holes.length === 0`) også var avledet
fra akkurat DENNE deltakerens egen (evig tomme) liste, satt den seg
fast i en evig spinner, permanent, uten noen feilmelding. Siden
`SHARED_BALL_FORMATS` aldri var endret for `scramble_solo` i punkt
22 sin utrulling heller, var dette allerede live og reelt ødelagt
for ekte lagspillere på scramble_solo-runder FØR denne rettelsen.
Rettet: `hole`/spinner-porten hentes nå fra HVILKEN SOM HELST kilde
som allerede har lastet inn hull (deltaker- ELLER side-basert,
uavhengig av hvem som er "aktiv spiller") i stedet for kun aktiv
spillers egen liste; ny `openPlayerOrTeamSideEntry`-klikkhåndterer
ruter lagets spillere til `wizardSideId`, individualspilleren
fortsatt til `wizardPlayerId`; `advanceWizardPlayer`/`advanceWizardSide`
justert til å ikke lenger kunne "vandre inn i" den andre kjeden.
Funnet OG rettet FØR migrasjon 057/redeploy -- ikke en kjent,
ureparert feil et øyeblikk i produksjon.
**Verifisering:**
1. Håndregnet API-testskript (`test_scramble_solo_match.py`, 31/31
bestått): 3-manns lag (HCP 8/14/20 → TeamAverage 14) mot
individualspiller (HCP 10, 100 %), `match_play_strokes([14,10])`,
5 spilte hull → "2 UP", `hole_results`/`strokes_received` stemte
hånd-for-hånd mot forventet forløp. Dynamisk `team_is_a`-utledning
i selve testen (ikke antatt fast) for å faktisk teste sorterings-
konvensjonen, ikke bare den ene retningen.
2. Full stack, ekte nettleser (fjerde scratch-miljø, ekte
`docker build` av frontend-imaget): opprettet en runde gjennom
hele veiviseren, satte team/individualspiller opp, entret score
via BEGGE veiviserne (fant og rettet bug-en HER, se over), bekreftet
`LeadZone` viste korrekt "X UP" og skiftet leder korrekt, bekreftet
`MatchScorecardGrid` viste eksakt ÉN rad for laget (ikke tre) og
riktig "N UP"-progresjon per hull, fullførte runden via ekte
"Fullfør runde"-knapp.
3. Regresjonsbekreftet SEPARAT at samme fiks løser samme bug for den
allerede-live slagspill-varianten (`scramble_solo`): opprettet en
ny runde med det formatet i samme scratch-miljø, bekreftet at
`SideScoreWizard` nå åpner korrekt for lagsiden (før fiksen: samme
evige spinner).
4. `test_isolation.sql` uendret 12/12. Scratch-miljøet (DB/rolle/
MinIO/API-/frontend-containere/images) ryddet opp fullstendig
etter bruk.
**Rullet ut live 2026-08-05** (samme økt som punkt 22): migrasjon 057
kjørt mot ekte `teecup_db` (`round_play_format_check` bekreftet å nå
inkludere `'scramble_solo_match'`), `docker compose up -d --build
teecup_api teecup_frontend` (inkluderer BÅDE matchspill-formatet OG
bug-fiksen over -- fiksen var derfor allerede live for ekte
`scramble_solo`-lagspillere idet denne utrullingen fullførte), begge
containere boot-et rent, `/my-rounds/new` → 200 over https.
24. **"Bruk som mal for egen bane" viste seg for bredt — 2026-08-05,
rent frontend, ingen skjema-/backend-endring.** Brukeren rapporterte
at knappen (fork en eksisterende personlig bane til utgangspunkt for
en NY) dukket opp i det vanlige "Egen bane"-søket i opprett-runde-
veiviseren, uansett om målet var å VELGE en eksisterende bane å
spille på eller å opprette en helt ny -- feil sted for en
mal-handling som kun gir mening når brukeren faktisk har sagt at
målet er å opprette.
**Avklart med bruker (tre alternativer presentert)**: knappen skal
KUN vises når brukeren eksplisitt har trykket "Opprett ny bane" og
derfra velger å basere den nye banen på en eksisterende, ikke i det
vanlige søket.
**Fiks (`frontend/components/new-round.tsx`):** ny `S1Sub`-verdi
`"own-search-template"`, distinkt fra det vanlige `"own-search"`.
`OwnSearch`s `onUseAsTemplate`-prop gjort valgfri (var påkrevd) --
"Bruk som mal for egen bane" rendres nå kun `{onUseAsTemplate &&
(...)}`, akkurat som den allerede korrekt gaterte offisiell-bane-
varianten i `CourseList`. Det vanlige `"own-search"`-kallet (nådd fra
Step1 sitt "Egen bane"-kort) slutter å sende med `onUseAsTemplate` i
det hele tatt. Ny lenke "Basér på en eksisterende bane i stedet" lagt
til øverst i `OwnCreateStep`s blanke skjema (kun synlig når
`!initial`, altså før noen mal er valgt) -- trykk navigerer til
`"own-search-template"`, som gjenbruker `OwnSearch` UENDRET, denne
gangen MED `onUseAsTemplate` satt (samme forhåndsutfyllings-bro som
allerede fantes for offisielle baner). Ny `ownCreateOrigin`-state
(løftet til toppnivå-hook-en, ved siden av `s1Sub`) sporer hvor
"own-create" skal gå TILBAKE til (vanlig søk, mal-søk, eller broen
fra offisielle baner) -- uten denne ville tilbake-knappen fra
mal-søket hoppet feil sted.
**Scratch-verifisert i ekte nettleser** (femte scratch-miljø, ekte
`docker build` av frontend-imaget): bekreftet at "Bruk som mal for
egen bane" IKKE lenger vises i det vanlige "Egen bane"-søket;
bekreftet at "Basér på en eksisterende bane i stedet"-lenken vises i
det blanke opprett-skjemaet; trykket den, bekreftet at knappen NÅ
vises i det samme søket; trykket "Bruk som mal", bekreftet at
opprett-skjemaet forhåndsfylte navn/18 hull/utslag korrekt fra den
valgte banen; bekreftet at tilbake-knappen fra opprett-skjemaet gikk
til mal-søket (ikke det vanlige søket eller helt til `source`).
Ingen konsoll-feil. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-
containere/images) ryddet opp fullstendig etter bruk.
**Rullet ut live 2026-08-05** (kun `teecup_frontend` bygget/
restartet -- ren frontend-endring, ingen skjema-/API-endring).
`/my-rounds/new` → 200 over https etterpå.

View file

@ -21,10 +21,13 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
beskrivelse av hvordan HCP og mottatte/tildelte slag skal beregnes/
fordeles. Les disse FØR videre arbeid med `handicap_engine.py`,
`app/handicap.py`, allowance-strategier (ADR-005/014) eller
slagfordeling (ADR-008) — spesielt relevant for de fire
turneringsformatene som ennå ikke er designet (Københavner/High-low-high/
Robbins/Try all, se FEATURE_BACKLOG.md), siden disse dokumentene trolig
dekker akkurat de reglene som trengs der.
slagfordeling (ADR-008) for NYE turneringsformater. **Rettelse
2026-08-05:** de opprinnelige fire (Københavner/High-low-high/Robbins/
Try all) er ikke lenger de aktuelle — Københavner, High-low-high og
Try all (Chapman/Pinehurst) er alle bygget og live (se
FEATURE_BACKLOG.md), Robbins ble droppet av bruker. Gjelder nå enhver
FREMTIDIG ny turneringsform (f.eks. asymmetriske lag-vs-individuell-
formater) — les PDF-ene før slagfordelingen designes for slike.
- **Design-dokumentasjon, to filer med bevisst motsatt retning (2026-07-27):**
`DESIGN_SYSTEM.md` (innsjekket i git) er DESKRIPTIV — hva som faktisk ER i
koden i dag (farger/typografi/komponentmønstre/golfscore-språket), avledet

View file

@ -4014,6 +4014,180 @@ teecup_frontend`. Full detalj i CHANGELOG.md 2026-08-04 og ADR-041.
---
## Augusta-stil resultattavle for slagspill-turneringer — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04
Brukeren viste et bilde av en fysisk leaderboard-tavle fra Augusta
National (POS/PLAYER/TODAY/THRU/TOTAL/R1-R4) og ba om samme oppsett for
individuelle turneringer med brutto/netto/stableford-scoring — lederen
alltid øverst, valgfri automatisk rulling, må se bra ut på storskjerm
(ikke bare mobil). Bygget via V0 (zip 29) etter etablert mønster: ren
visuell komponent med mock-data først (`stroke-play-leaderboard.tsx`),
ekte backend-kobling etterpå. Ny `Tone`-tredeling ("under"/"even"/"over")
rettet inn i komponenten ved integrering — V0s eksport hadde ikke fått
med seg appens egen "E → ren tekst, ingen ramme"-regel for rundekolonnene
presist nok (kun for TODAY/TOTAL, ikke R1-Rn).
Backend (`individual_tournaments.py`, `individual_leaderboard`-
endepunktet) utvidet med POS/TODAY/THRU/TOTAL/runde-for-runde-data — kun
populert for brutto/netto/stableford, ingen endring for København/BBB.
"I dag" utledes som runden med høyest sekvensnummer noen har påbegynt
(ingen eksplisitt aktiv-runde-markering finnes eller trengs).
**Reell rangeringsbug funnet og rettet under scratch-verifiseringen**
(ikke antatt riktig fra koden alene): den eksisterende sorteringen
rangerte på RÅ slagtotal/poengtotal — riktig for én runde, men i en
flerrunde-turnering med deltakere på ulike stadier (noen midt i runde 2,
andre ikke startet den) er rå sum ikke sammenlignbar. En deltaker med
færre spilte hull kunne rangere FORAN den faktiske lederen kun fordi det
rå tallet var lavere. Rettet ved å innføre en til-par-normalisert
rangeringsnøkkel brukt til BÅDE sortering og uavgjort-håndtering.
Ingen migrasjon (rene response-felt-tillegg). Scratch-verifisert grundig
med et bevisst konstruert flerrunde-scenario (ekte uavgjort i toppen, én
spiller midt i runde, én ikke startet runde 2) — alle håndregnede tall
bekreftet eksakt riktige, både API-JSON og ekte nettleser på 390px mobil
og 1920px storskjerm. Full detalj i CHANGELOG.md 2026-08-04.
---
## Baneoppsett i turneringer: rekkefølge, TeeOff-import og delt bane-mal-bibliotek — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04 (ADR-042, migrasjon 054)
Brukeren oppdaget at "Klasser" (med Standardutslag-velgeren, ADR-041)
vises FØR bane-/rundeoppsett i turneringsmodulen — meningsløst å velge
utslag for en klasse før man vet hvilke utslag banen har. Samtidig
bemerket hen at TeeOff-baneimport ikke var tilgjengelig i
turneringsmodulen, og ba om en ny funksjon: "bruk en eksisterende
TeeOff-bane som mal" ved manuell baneoppretting, i BÅDE
turneringsmodulen og single-runde-modulen.
**Del I (rekkefølge + TeeOff-import-gap):** `ClassesCard` flyttet til å
vises etter runde-/baneoppsett i begge turneringstyper (individuelle:
etter `RoundsCard` i samme fane; lagturneringer: flyttet fysisk fra
"Lag og spillere"-siden til "Program"-siden). TeeOff-import
(`OfficialCourseSearch`-mønsteret) koblet inn i den individuelle
turneringsflyten, som manglet det helt fra før.
**Del II (mal-basert baneoppretting + delt bibliotek, ADR-042):** avdekket
under planlegging at turneringsmodulens "opprett manuell bane" i praksis
var ubrukelig (kun et navnefelt, ingen hull-/utslag-skjema fantes noe
sted der). Bygget: et nytt, kompakt hull-/utslag-editorskjema for
turneringsmodulen; en malvelger (fra bunnen av / TeeOff-bane som mal /
offentlig custom-bane som mal); gjenbruk av `personal_course`
(020_personal_rounds.sql) som et allerede-globalt, plattform-omfattende
bane-bibliotek brukerens egen oppfølging ba om ("disse banene bør lagres
og være offentlige") — se ADR-042 for de fem arkitektoniske
beslutningene (delt tabell fremfor ny, plattform- IKKE org-omfattende
synlighet, applikasjonslags-eierskap, fork-ved-fremmed-redigering +
egen dupliser-handling, engangs-kopi ingen vedvarende kobling). Ny
"Mine baner"-administrasjon i kontoinnstillinger (rediger/dupliser/
slett egne publiserte baner).
Reell nestet-`<form>`-HTML-bug funnet og rettet UNDER scratch-
verifisering (ikke antatt riktig fra koden alene) — se CHANGELOG.md
2026-08-04 for full detalj. 34/34 håndregnede API-sjekker + grundig
ekte nettleser-verifisering (to brukere, to organisasjoner) bestått,
`test_isolation.sql` 12/12 uendret.
**Bevisst utenfor omfang:** ingen moderasjon/vetting av offentlige
baner; ingen privat/kun-min-org-synlighet per bane (alt publisert via
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).
---
## Scramble mot enkeltspiller — ✅ BEGGE VARIANTER (slagspill + matchspill) BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-05 (migrasjon 056+057). Org-lagturneringer 📋 GJENSTÅR
Brukeren ba om et nytt, niende turneringsformat: et scramble-lag
(variabel størrelse, N>=2, delt ball) mot ÉN individuell spiller (egen
ball). Første ASYMMETRISKE to-siders-format i prosjektet -- de åtte
tidligere formatene (se seksjonen over) har alle SAMME struktur på
begge sider.
**Bygget denne runden (kun frittstående runder, kun slagspill-
sammenligning):** skjema (migrasjon 056: `round.scramble_solo_
individual_pct`, `round_side.side_role`), motor (`TeamAverage`,
`net_stroke_play_margin` -- 8 nye enhetstester, 115/115 totalt), full
API-wiring i `app/routers/rounds.py` (asymmetrisk sidekapasitet,
konfigurerbar individuell allowance, `_build_scramble_solo_result`).
Frontend ALT promptet til V0 (brukerens eksplisitte instruks denne
runden) -- ny `ScrambleSoloResultView`-komponent + `ScrambleVsSoloSides`-
sideoppsett i opprett-runde-veiviseren, integrert (ikke blindt kopiert)
mot de eksisterende API-koblede filene. Se CHANGELOG.md 2026-08-05
(punkt 22) for full bygge-/verifiseringsdetalj, inkl. to separate
scratch-verifiseringsrunder (backend alene, deretter full stack med
ekte nettleser).
**Kildesjekk før design:** ingen av de tre HCP-PDF-ene (se CLAUDE.md)
dekker lag-vs-individuell eller vilkårlig lagstørrelse -- lagets
Playing Handicap (`TeamAverage`, rent snitt) er derfor en bevisst NY
TeeCup-regel, bekreftet eksplisitt med bruker, ikke kildebelagt slik
`scramble_2`/`scramble_4` sine `RankedSplit`-tabeller er.
**Bevisst utenfor omfang, fortsatt (ikke glemt):**
- **Org-lagturneringer** (`session.format`) -- kun frittstående runder
nå. `public-live.tsx` sin `FORMAT_LABELS` (som nøkler på
`session.format`) fikk derfor bevisst IKKE formatet lagt til.
- Ingen kobling til Order of Merit for dette formatet.
**Matchspill-varianten (`scramble_solo_match`) bygget samme dag,
2026-08-05 (migrasjon 057):** samme lag-mot-individuell-idé, avgjort
hull-for-hull (`match_play_strokes`/`compute_match_state`, begge
gjenbrukt uendret) i stedet for netto slagspill-totalsum. Ren
CHECK-migrasjon (ingen nye kolonner). Frontend gjenbruker
`TwoSidedBoard`/`LeadZone`/oppsett-UI-en fra slagspill-varianten helt
uendret (bekreftet med bruker, ingen ny V0-prompt for denne
varianten) -- kun én liten, dedikert (ikke-V0) gren i
`MatchScorecardGrid` for én rad per lag-side.
**Kritisk regresjonsbug funnet OG rettet samme dag, FØR denne
utrullingen:** `round-detail.tsx` sin "Score"-fane rutet feilaktig et
klikk på en LAGSPILLERS scorekort til per-deltaker-veiviseren i stedet
for den delte lag-veiviseren -- satt seg fast i en evig spinner, uten
feilmelding. Rammet BEGGE variantene (var allerede live og ødelagt for
ekte lagspillere på slagspill-varianten siden 2026-08-05 tidligere
samme dag, inntil denne rettelsen). Se CHANGELOG.md punkt 23 for full
rotårsak/fiks-detalj.
**Rullet ut live 2026-08-05** (begge varianter + bug-fiksen, samme
utrulling), bruker bekreftet eksplisitt.
---
## Bevisst endret fra opprinnelige (Gemini-)råd
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte

View file

@ -70,7 +70,7 @@ class Settings:
# Offentlig URL-base for lenker i utsendt e-post (frontend, ikke API-et
# direkte). Ikke hemmelig -- valgfri med prod-riktig standard slik at
# scratch-/dev-testing kan overstyre uten kodeendring.
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/")
PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.golf").rstrip("/")
# Driftsvarsel til appens eier hver gang en HELT NY TeeCup-konto
# opprettes (2026-07-30, reist av bruker) -- ikke en hemmelighet, bare
@ -103,7 +103,7 @@ class Settings:
# andre hemmeligheter i prosjektet).
VAPID_PRIVATE_KEY: str | None = os.environ.get("TEECUP_VAPID_PRIVATE_KEY", "").strip() or None
VAPID_PUBLIC_KEY: str | None = os.environ.get("TEECUP_VAPID_PUBLIC_KEY", "").strip() or None
VAPID_SUBJECT: str = os.getenv("TEECUP_VAPID_SUBJECT", "https://teecup.teeoff.no")
VAPID_SUBJECT: str = os.getenv("TEECUP_VAPID_SUBJECT", "https://teecup.golf")
PUSH_CONFIGURED: bool = bool(VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY)

View file

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

View file

@ -23,10 +23,6 @@ from pydantic import BaseModel, Field
router = APIRouter()
class CourseCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class Course(BaseModel):
id: str
name: str
@ -45,24 +41,6 @@ async def list_courses(
return [Course(**dict(r)) for r in rows]
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
async def create_course(
body: CourseCreate,
organization_id: str = Depends(get_authorized_org),
) -> Course:
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
INSERT INTO course (organization_id, name, source)
VALUES ($1, $2, 'custom')
RETURNING {_COURSE_COLUMNS}
""",
organization_id,
body.name,
)
return Course(**dict(row))
# --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) --
#
# Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte
@ -282,6 +260,147 @@ async def create_holes(
return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number)
# --- Manuell baneoppretting med fullt hull-/utslagoppsett (2026-08-04) ------
#
# `create_course` over tok frem til nå KUN imot et navn -- ingen vei til
# hull/utslag fantes for en manuelt opprettet org-bane (kun de to ubrukte
# sub-ressurs-endepunktene over, `create_tee`/`create_holes`, som ALDRI fikk
# noe frontend-kallsted). Brukeren oppdaget dette hullet: "opprett manuell
# bane" i turneringsmodulen var i praksis ubrukelig uten et sted å taste inn
# hull/par/hcp-indeks/utslag etterpå.
#
# Denne utvidede varianten dekker begge deler i ÉN innsending (speiler
# `PersonalCourseCreate` i rounds.py sin form), OG publiserer samtidig en
# kopi til det delte, plattform-omfattende bane-biblioteket
# (`personal_course`, 020_personal_rounds.sql) -- brukeren bekreftet
# eksplisitt at custom-baner opprettet via den nye mal-baserte flyten skal
# "lagres og være offentlige", slik at samme bane kan brukes som mal av
# andre organisasjoner OG i single-runde-modulen. `personal_course` har
# ingen RLS/organization_id (bevisst globalt siden migrasjon 020) -- å skrive
# til den fra en org-scopet tilkobling er trygt, ingen policy er i veien.
class CourseCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
# Enten BEGGE er med, eller INGEN -- ufullstendig oppsett (kun holes,
# ikke tees) gir en tydelig 400 heller enn en course-rad som stille
# mangler halve dataene.
holes: list[HoleCreate] | None = None
tees: list[TeeCreate] | None = None
# Satt når banen opprettes med en offentlig custom-bane (personal_course)
# som mal -- ren attribusjon på den NYE personal_course-publiseringen
# under, ingen vedvarende kobling på selve org-bane-raden (samme
# "engangs-kopi"-filosofi som offisiell TeeOff-import, ADR-019).
template_personal_course_id: str | None = None
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
async def create_course(
body: CourseCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> Course:
if (body.holes is None) != (body.tees is None):
raise app_error(400, "VALIDATION_FAILED", "Hull og utslag må enten begge være med, eller ingen av dem.")
if body.holes is not None and body.tees is not None:
numbers = sorted(h.hole_number for h in body.holes)
if len(body.holes) != 18 or numbers != list(range(1, 19)):
raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull, nummerert 1-18.")
for t in body.tees:
genders = [r.gender for r in t.ratings]
if len(genders) != len(set(genders)):
raise app_error(
400, "VALIDATION_FAILED", "Kan ikke oppgi to ratinger for samme kjønn på ett utslag."
)
async with org_connection(organization_id) as conn, translate_db_errors():
async with conn.transaction():
row = await conn.fetchrow(
f"""
INSERT INTO course (organization_id, name, source)
VALUES ($1, $2, 'custom')
RETURNING {_COURSE_COLUMNS}
""",
organization_id,
body.name,
)
if body.holes is not None and body.tees is not None:
for h in body.holes:
await conn.execute(
"""
INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index)
VALUES ($1, $2, $3, $4, $5)
""",
organization_id,
row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
tee_row = await conn.fetchrow(
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
organization_id,
row["id"],
t.name,
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO tee_rating
(organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
""",
organization_id,
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
pc_row = await conn.fetchrow(
"""
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
VALUES ($1, $2, $3) RETURNING id
""",
body.name,
user.user_id,
body.template_personal_course_id,
)
for h in body.holes:
await conn.execute(
"""
INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index)
VALUES ($1, $2, $3, $4)
""",
pc_row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
pc_tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
pc_row["id"],
t.name,
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
pc_tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
return Course(**dict(row))
# --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------

View file

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

View file

@ -0,0 +1,511 @@
"""
Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering 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

File diff suppressed because it is too large Load diff

View file

@ -30,11 +30,13 @@ services:
TEECUP_SMTP_USER: ${TEECUP_SMTP_USER}
TEECUP_SMTP_PASS: ${TEECUP_SMTP_PASS}
TEECUP_FROM_EMAIL: ${TEECUP_FROM_EMAIL}
TEECUP_PUBLIC_BASE_URL: ${TEECUP_PUBLIC_BASE_URL}
TEECUP_MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET}
TEECUP_VAPID_PRIVATE_KEY: ${TEECUP_VAPID_PRIVATE_KEY}
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
networks:
- teeoff_default
restart: unless-stopped

View file

@ -13,7 +13,7 @@ export const metadata: Metadata = {
// Trengs for at relative og:image-URL-er (MinIO-runden, ikke bygget ennå)
// skal løses til en fullstendig URL -- uten denne advarer Next.js ved
// build og faller tilbake til en gjettet verdi.
metadataBase: new URL('https://teecup.teeoff.no'),
metadataBase: new URL('https://teecup.golf'),
title: 'TeeCup — Logg inn',
description: 'Logg inn på TeeCup, din golfturnerings-app. Passordløs innlogging med magisk lenke.',
generator: 'v0.app',

View file

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

View 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"} />
}

View file

@ -14,10 +14,13 @@ import {
Bell,
BellRing,
Camera,
Copy,
Flag,
KeyRound,
Lock,
LogOut,
Mail,
Pencil,
Plus,
ShieldCheck,
ShieldOff,
@ -31,6 +34,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer.
@ -284,6 +288,8 @@ export function AccountSettings() {
<PushNotificationSection />
<MyCoursesSection />
<section className="flex flex-col gap-3 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">
@ -1425,6 +1431,177 @@ function PushNotificationSection() {
)
}
// --- Mine baner (2026-08-04) -------------------------------------------------
// Administrasjon av `personal_course`-rader brukeren eier -- det delte,
// plattform-omfattende bane-biblioteket som både turneringsmodulen og
// single-runde-modulen nå bruker som mal-kilde. Redigering skjer i sted
// (PATCH, samme eier -- ingen fork utløses); dupliser/slett er egne,
// eksplisitte handlinger. Andres baner vises IKKE her -- kun i mal-søket
// i de respektive modulene (denne siden er ren eierskapsadministrasjon).
type ApiMyCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiMyCourseDetail = {
id: string
name: string
holes: CourseTemplateValues["holes"]
full_tees: CourseTemplateValues["tees"]
}
function MyCoursesSection() {
const [courses, setCourses] = useState<ApiMyCourse[] | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editingInitial, setEditingInitial] = useState<CourseTemplateValues | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function load() {
const res = await fetch("/personal-courses?mine=true", { credentials: "include" })
if (res.ok) setCourses(await res.json())
}
useEffect(() => {
void load()
}, [])
async function startEdit(id: string) {
setError(null)
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
if (!res.ok) {
setError("Klarte ikke å hente banedetaljer.")
return
}
const detail: ApiMyCourseDetail = await res.json()
setEditingInitial({ name: detail.name, holes: detail.holes, tees: detail.full_tees })
setEditingId(id)
}
async function saveEdit(values: CourseTemplateValues) {
if (!editingId) return
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/personal-courses/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(values),
})
if (!res.ok) throw new Error(`update: ${res.status}`)
setEditingId(null)
setEditingInitial(null)
await load()
} catch {
setError("Klarte ikke å lagre endringene.")
} finally {
setSubmitting(false)
}
}
async function duplicate(id: string) {
setError(null)
const res = await fetch(`/personal-courses/${id}/duplicate`, { method: "POST", credentials: "include" })
if (!res.ok) {
setError("Klarte ikke å duplisere banen.")
return
}
await load()
}
async function remove(id: string) {
if (!confirm("Slette denne banen? Dette kan ikke angres.")) return
setError(null)
const res = await fetch(`/personal-courses/${id}`, { method: "DELETE", credentials: "include" })
if (res.status === 204) {
await load()
return
}
const body = await res.json().catch(() => null)
setError(
body?.detail?.code === "IN_USE"
? "Denne banen er i bruk i én eller flere runder og kan ikke slettes."
: "Klarte ikke å slette banen.",
)
}
return (
<section className="flex flex-col gap-3 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">
<Flag aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h2 className="text-base font-bold text-foreground">Mine baner</h2>
<p className="text-xs text-muted-foreground text-pretty">
Egendefinerte baner du har opprettet -- offentlig synlige og brukbare som mal av andre,
i både turneringsmodulen og single-runde-modulen.
</p>
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{editingId ? (
<CourseTemplateEditor
initial={editingInitial ?? undefined}
submitLabel="Lagre endringer"
submitting={submitting}
error={null}
onSubmit={saveEdit}
onCancel={() => {
setEditingId(null)
setEditingInitial(null)
}}
/>
) : courses === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : courses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ingen egne baner ennå -- opprett en via "Egen bane" i ny-runde-veiviseren, eller "Opprett
bane med hull/utslag" i en turnering.
</p>
) : (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{courses.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-4 py-3">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{c.name}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => startEdit(c.id)}
aria-label={`Rediger ${c.name}`}
className="size-9 rounded-lg text-muted-foreground"
>
<Pencil aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => duplicate(c.id)}
aria-label={`Dupliser ${c.name}`}
className="size-9 rounded-lg text-muted-foreground"
>
<Copy aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(c.id)}
aria-label={`Slett ${c.name}`}
className="size-9 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
</section>
)
}
function NotificationEmailPrefsSection() {
const [types, setTypes] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)

View file

@ -0,0 +1,698 @@
"use client"
// Kompakt hull-/utslag-editor for org-turneringsmodulen (2026-08-04) --
// "opprett manuell bane" der var frem til nå KUN et navnefelt (ingen vei til
// hull/par/hcp-indeks/utslag fantes i det hele tatt, POST /orgs/{id}/courses
// tok bare imot `{name}`). Speiler single-runde-modulens `OwnCreateStep`
// (new-round.tsx) i FUNKSJON, men i et kompakt, kort-innebygd formspråk som
// matcher resten av turneringsmodulens skjemaer (h-10/h-11, text-sm) i
// stedet for enkeltrunde-modulens store veiviser-stil.
//
// Ren UI + lokal validering -- kalleren avgjør hvor dataene faktisk
// persisteres (org-`course` via POST /orgs/{id}/courses, som nå valgfritt
// tar imot samme `holes`/`tees`-form i tillegg til navn), matcher
// prosjektets etablerte "hold komponenten dum"-adaptermønster.
//
// `CourseTemplatePicker` under er selve inngangspunktet brukt fra
// tournament-program.tsx/individual-tournament-detail.tsx: tilbyr "fra
// bunnen av", "TeeOff-bane som mal" og "offentlig custom-bane som mal"
// (søk mot GET /personal-courses -- allerede, litt overraskende, et
// PLATTFORM-OMFATTENDE bibliotek uansett org/bruker, se rounds.py).
import { useEffect, useState } from "react"
import { Plus, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export type ApiCourseHole = { hole_number: number; par: number; stroke_index: number }
export type ApiCourseTeeRating = { gender: "m" | "f"; course_rating: number; slope_rating: number; par: number }
export type ApiCourseTee = { name: string; ratings: ApiCourseTeeRating[] }
export type CourseTemplateValues = { name: string; holes: ApiCourseHole[]; tees: ApiCourseTee[] }
export type ApiCourseSummary = { id: string; name: string }
function blankHoles(): ApiCourseHole[] {
return Array.from({ length: 18 }, (_, i) => ({ hole_number: i + 1, par: 4, stroke_index: i + 1 }))
}
function blankTees(): ApiCourseTee[] {
return [{ name: "", ratings: [{ gender: "m", course_rating: 70, slope_rating: 113, par: 72 }] }]
}
type TeeRow = {
key: string
name: string
men: boolean
women: boolean
menRating: { courseRating: string; slope: string; par: string }
womenRating: { courseRating: string; slope: string; par: string }
}
function teesToRows(tees: ApiCourseTee[]): TeeRow[] {
return tees.map((t, i) => {
const m = t.ratings.find((r) => r.gender === "m")
const f = t.ratings.find((r) => r.gender === "f")
return {
key: `${i}-${t.name}`,
name: t.name,
men: !!m,
women: !!f,
menRating: m
? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) }
: { courseRating: "", slope: "", par: "" },
womenRating: f
? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) }
: { courseRating: "", slope: "", par: "" },
}
})
}
/** Kompakt skjema: 18-hulls par/stroke-indeks-rutenett + utslag med rating
* per kjønn. Forhåndsutfylt fra `initial` når banen opprettes som mal-basert
* (fra TeeOff eller en offentlig custom-bane). */
export function CourseTemplateEditor({
initial,
submitLabel,
submitting,
error,
onSubmit,
onCancel,
}: {
initial?: CourseTemplateValues
submitLabel: string
submitting: boolean
error: string | null
onSubmit: (values: CourseTemplateValues) => void
onCancel: () => void
}) {
const [name, setName] = useState(initial?.name ?? "")
const [holes, setHoles] = useState<ApiCourseHole[]>(initial?.holes ?? blankHoles())
const [tees, setTees] = useState<TeeRow[]>(teesToRows(initial?.tees ?? blankTees()))
function updateHole(index: number, field: "par" | "stroke_index", value: number) {
setHoles((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)))
}
function updateTee(key: string, patch: Partial<TeeRow>) {
setTees((prev) => prev.map((t) => (t.key === key ? { ...t, ...patch } : t)))
}
function addTee() {
setTees((prev) => [
...prev,
{ key: `new-${Date.now()}`, name: "", men: false, women: false, menRating: { courseRating: "", slope: "", par: "" }, womenRating: { courseRating: "", slope: "", par: "" } },
])
}
function removeTee(key: string) {
setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.key !== key) : prev))
}
const indexesValid = new Set(holes.map((h) => h.stroke_index)).size === 18
const teesValid = tees.every(
(t) =>
t.name.trim() !== "" &&
(t.men || t.women) &&
(!t.men || (t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) &&
(!t.women || (t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())),
)
const canSubmit = name.trim() !== "" && indexesValid && teesValid && !submitting
// Bevisst IKKE et <form onSubmit>: denne komponenten rendres ofte inne i
// et YTRE skjema (NewRoundForm/CreateSessionCard), og nestede <form>-
// elementer er ugyldig HTML -- funnet ved en ekte scratch-verifisering
// 2026-08-04 (submit-knappen submittet i praksis det ytre runde-/økt-
// skjemaet i stedet, en reell side-navigasjon som vasket bort ?org=...
// fra URL-en). Samme feilklasse/fiks som OfficialCourseSearch fra før.
function handleSubmit() {
if (!canSubmit) return
const parseDecimal = (v: string) => Number(v.replace(",", "."))
onSubmit({
name: name.trim(),
holes,
tees: tees.map((t) => ({
name: t.name.trim(),
ratings: [
...(t.men
? [{ gender: "m" as const, course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }]
: []),
...(t.women
? [{ gender: "f" as const, course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.par) }]
: []),
],
})),
})
}
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ct-name" className="text-xs font-semibold">
Navn banen
</Label>
<Input id="ct-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="F.eks. Hjemmebanen" className="h-10 rounded-xl text-sm" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold">Hull -- par og stroke-indeks</span>
{!indexesValid && (
<p role="alert" className="text-xs font-medium text-destructive">
Hver stroke-indeks (1-18) brukes nøyaktig én gang.
</p>
)}
<div className="max-h-72 overflow-y-auto rounded-xl border border-border">
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border bg-muted/60 px-3 py-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-12">Hull</span>
<span>Par</span>
<span>SI</span>
</div>
{holes.map((h, index) => (
<div key={h.hole_number} className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border px-3 py-1.5 last:border-b-0">
<span className="w-12 text-sm font-semibold text-foreground">{h.hole_number}</span>
<select
aria-label={`Par for hull ${h.hole_number}`}
value={h.par}
onChange={(e) => updateHole(index, "par", Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<select
aria-label={`Stroke-indeks for hull ${h.hole_number}`}
value={h.stroke_index}
onChange={(e) => updateHole(index, "stroke_index", Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={si}>
{si}
</option>
))}
</select>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold">Utslag</span>
{tees.map((t, index) => (
<div key={t.key} className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3">
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor={`ct-tee-${t.key}`} className="text-xs font-semibold">
Navn utslag {index + 1}
</Label>
<Input id={`ct-tee-${t.key}`} value={t.name} onChange={(e) => updateTee(t.key, { name: e.target.value })} placeholder="F.eks. Gul" className="h-10 rounded-xl text-sm" />
</div>
{tees.length > 1 && (
<Button type="button" variant="ghost" onClick={() => removeTee(t.key)} className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground">
<Trash2 aria-hidden="true" className="size-4" />
</Button>
)}
</div>
<RatingRow
label="Herre"
enabled={t.men}
rating={t.menRating}
onToggle={(men) => updateTee(t.key, { men })}
onChange={(menRating) => updateTee(t.key, { menRating })}
idPrefix={`ct-men-${t.key}`}
/>
<RatingRow
label="Dame"
enabled={t.women}
rating={t.womenRating}
onToggle={(women) => updateTee(t.key, { women })}
onChange={(womenRating) => updateTee(t.key, { womenRating })}
idPrefix={`ct-women-${t.key}`}
/>
</div>
))}
<Button type="button" variant="outline" onClick={addTee} className="h-10 self-start rounded-xl text-xs font-bold">
<Plus aria-hidden="true" className="size-4" />
Legg til utslag
</Button>
</div>
<div className="flex gap-2">
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-11 flex-1 rounded-xl text-sm font-bold">
{submitting ? "Lagrer…" : submitLabel}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl text-sm font-bold text-muted-foreground">
Avbryt
</Button>
</div>
</div>
)
}
function RatingRow({
label,
enabled,
rating,
onToggle,
onChange,
idPrefix,
}: {
label: string
enabled: boolean
rating: { courseRating: string; slope: string; par: string }
onToggle: (enabled: boolean) => void
onChange: (rating: { courseRating: string; slope: string; par: string }) => void
idPrefix: string
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-background/60 p-2.5">
<label className="flex items-center gap-2 text-xs font-semibold text-foreground">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
className="size-4 shrink-0 accent-primary"
/>
{label}-rating
</label>
{enabled && (
<div className="grid grid-cols-3 gap-2">
<Input
aria-label={`${label} course rating`}
inputMode="decimal"
value={rating.courseRating}
onChange={(e) => onChange({ ...rating, courseRating: e.target.value })}
placeholder="CR"
className="h-9 rounded-lg text-sm"
/>
<Input
aria-label={`${label} slope`}
inputMode="numeric"
value={rating.slope}
onChange={(e) => onChange({ ...rating, slope: e.target.value })}
placeholder="Slope"
className="h-9 rounded-lg text-sm"
/>
<Input
aria-label={`${label} par`}
inputMode="numeric"
value={rating.par}
onChange={(e) => onChange({ ...rating, par: e.target.value })}
placeholder="Par"
className="h-9 rounded-lg text-sm"
/>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Mal-velger: "fra bunnen av" / "TeeOff-bane som mal" / "offentlig custom-
// bane som mal". Brukt som selve "opprett bane"-inngangen i org-
// turneringsmodulen (erstatter det tidligere bare-navnefeltet).
// ---------------------------------------------------------------------------
type ApiPublicCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiPublicCourseDetail = {
id: string
name: string
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}
type ApiOfficialFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean }
type ApiOfficialTeeOption = { name: string; genders: ("m" | "f")[] }
type ApiOfficialCourseDetail = { id: number; name: string; holes: { hole_number: number; par: number; stroke_index: number }[]; tees: (ApiOfficialTeeOption & { ratings?: ApiCourseTeeRating[] })[] }
type PickerStep =
| { kind: "choose" }
| { kind: "official-search" }
| { kind: "official-facility"; facility: ApiOfficialFacility }
| { kind: "public-search" }
| { kind: "editor"; initial?: CourseTemplateValues; templatePersonalCourseId?: string }
export function CourseTemplatePicker({
organizationId,
onCreated,
onCancel,
}: {
organizationId: string
onCreated: (course: ApiCourseSummary) => void
onCancel: () => void
}) {
const [step, setStep] = useState<PickerStep>({ kind: "choose" })
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submitCourse(values: CourseTemplateValues, templatePersonalCourseId?: string) {
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ...values, template_personal_course_id: templatePersonalCourseId ?? null }),
})
if (!res.ok) throw new Error(`create course: ${res.status}`)
const created: ApiCourseSummary = await res.json()
onCreated(created)
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
} finally {
setSubmitting(false)
}
}
if (step.kind === "choose") {
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-background p-4">
<span className="text-xs font-semibold">Opprett manuell bane</span>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => setStep({ kind: "editor" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
Fra bunnen av
</Button>
<Button type="button" variant="outline" onClick={() => setStep({ kind: "official-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
TeeOff-bane som mal
</Button>
<Button type="button" variant="outline" onClick={() => setStep({ kind: "public-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
Offentlig bane som mal
</Button>
</div>
<Button type="button" variant="ghost" onClick={onCancel} className="h-9 self-start rounded-xl text-xs font-semibold text-muted-foreground">
Avbryt
</Button>
</div>
)
}
if (step.kind === "official-search") {
return (
<OfficialFacilitySearch
organizationId={organizationId}
onPickFacility={(facility) => setStep({ kind: "official-facility", facility })}
onClose={() => setStep({ kind: "choose" })}
/>
)
}
if (step.kind === "official-facility") {
return (
<OfficialCourseList
organizationId={organizationId}
facility={step.facility}
onBack={() => setStep({ kind: "official-search" })}
onPicked={(values) => setStep({ kind: "editor", initial: values })}
/>
)
}
if (step.kind === "public-search") {
return (
<PublicCourseSearch
onPickDirect={(values, sourceId) => submitCourse(values, sourceId)}
onPickCustomize={(values, sourceId) => setStep({ kind: "editor", initial: values, templatePersonalCourseId: sourceId })}
onClose={() => setStep({ kind: "choose" })}
/>
)
}
return (
<CourseTemplateEditor
initial={step.initial}
submitLabel="Opprett bane"
submitting={submitting}
error={error}
onSubmit={(values) => submitCourse(values, step.templatePersonalCourseId)}
onCancel={() => setStep({ kind: "choose" })}
/>
)
}
function OfficialFacilitySearch({
organizationId,
onPickFacility,
onClose,
}: {
organizationId: string
onPickFacility: (facility: ApiOfficialFacility) => void
onClose: () => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch() {
setSearching(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">TeeOff-bane som mal</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk anleggsnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
runSearch()
}
}}
className="h-11 flex-1 rounded-xl text-base"
/>
<Button type="button" onClick={() => runSearch()} disabled={searching} className="h-11 shrink-0 rounded-xl font-bold">
{searching ? "Søker…" : "Søk"}
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button type="button" onClick={() => onPickFacility(f)} className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60">
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && <span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>}
</button>
</li>
))}
{facilities.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>}
</ul>
)}
</div>
)
}
function OfficialCourseList({
organizationId,
facility,
onBack,
onPicked,
}: {
organizationId: string
facility: ApiOfficialFacility
onBack: () => void
onPicked: (values: CourseTemplateValues) => void
}) {
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [loadingCourseId, setLoadingCourseId] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : { courses: [] }))
.then((detail: { courses: ApiOfficialCourseOption[] }) => {
if (!cancelled) setCourses(detail.courses)
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente baner for dette anlegget.")
})
return () => {
cancelled = true
}
}, [organizationId, facility.slug])
async function pick(course: ApiOfficialCourseOption) {
// Henter full bane-/utslagdata via samme offisiell-import-endepunkt som
// "vanlig" import bruker -- men i STEDET for å lagre importen som en
// ferdig org-bane, brukes svaret KUN til å forhåndsutfylle det
// redigerbare skjemaet (ingen kobling til TeeOff bevares etterpå, se
// migrasjon 054 sin kommentar om "engangs-kopi, ingen vedvarende
// kobling"). Trygt å kalle -- import er idempotent (courses.py).
setLoadingCourseId(course.teeoff_course_id)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ facility_slug: facility.slug, teeoff_course_id: course.teeoff_course_id }),
})
if (!res.ok) throw new Error(`import: ${res.status}`)
const imported: { id: string; name: string } = await res.json()
const [holesRes, teesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${imported.id}/tees`, { credentials: "include" }),
])
const holes: ApiCourseHole[] = holesRes.ok ? await holesRes.json() : []
const teesRaw: { name: string; ratings: ApiCourseTeeRating[] }[] = teesRes.ok ? await teesRes.json() : []
onPicked({ name: imported.name, holes, tees: teesRaw.map((t) => ({ name: t.name, ratings: t.ratings })) })
} catch {
setError("Klarte ikke å hente banedata fra teeoff. Prøv igjen.")
} finally {
setLoadingCourseId(null)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<button type="button" onClick={onBack} className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground">
{facility.name}
</button>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{courses === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courses.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={loadingCourseId !== null}
onClick={() => pick(c)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{c.name}
{loadingCourseId === c.teeoff_course_id && <span className="text-xs font-normal text-muted-foreground">Henter</span>}
</button>
</li>
))}
{courses.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen 18-hulls baner hos dette anlegget ennå.</li>}
</ul>
)}
</div>
)
}
function PublicCourseSearch({
onPickDirect,
onPickCustomize,
onClose,
}: {
onPickDirect: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
onPickCustomize: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
onClose: () => void
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPublicCourse[]>([])
const [loadingId, setLoadingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const timer = setTimeout(() => {
fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiPublicCourse[]) => {
if (!cancelled) setResults(data)
})
.catch(() => {})
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [query])
async function fetchDetail(course: ApiPublicCourse): Promise<CourseTemplateValues | null> {
setLoadingId(course.id)
setError(null)
try {
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
if (!res.ok) throw new Error(`detail: ${res.status}`)
const detail: ApiPublicCourseDetail = await res.json()
return { name: detail.name, holes: detail.holes, tees: detail.full_tees }
} catch {
setError("Klarte ikke å hente banedetaljer.")
return null
} finally {
setLoadingId(null)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Offentlig bane som mal</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Søk etter banenavn…" className="h-11 rounded-xl text-sm" />
<ul className="flex max-h-72 flex-col gap-2 overflow-auto">
{results.map((c) => (
<li key={c.id} className="flex flex-col gap-2 rounded-xl border border-border p-3">
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{c.name}</span>
<span className="text-xs text-muted-foreground">{c.is_mine ? "Opprettet av deg" : `Opprettet av ${c.created_by_display_name}`}</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={loadingId !== null}
onClick={async () => {
const values = await fetchDetail(c)
if (values) onPickDirect(values, c.id)
}}
className="h-9 flex-1 rounded-lg text-xs font-bold"
>
Bruk direkte
</Button>
<Button
type="button"
variant="outline"
disabled={loadingId !== null}
onClick={async () => {
const values = await fetchDetail(c)
if (values) onPickCustomize(values, c.id)
}}
className="h-9 flex-1 rounded-lg text-xs font-bold"
>
Tilpass før bruk
</Button>
</div>
</li>
))}
{results.length === 0 && (
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
{query.trim() ? `Ingen offentlige baner matcher «${query}».` : "Skriv for å søke."}
</li>
)}
</ul>
</div>
)
}

View file

@ -45,6 +45,8 @@ const FORMAT_LABELS: Record<string, string> = {
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })

View file

@ -41,6 +41,7 @@ import {
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { CourseTemplatePicker } from "@/components/course-template-editor"
import { cn } from "@/lib/utils"
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
@ -444,6 +445,10 @@ export function IndividualTournamentDetail({
return created
}
function handleCourseImported(course: ApiCourse) {
setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name)))
}
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
@ -551,6 +556,7 @@ export function IndividualTournamentDetail({
onAddRound={addRound}
onDeleteRound={deleteRound}
onAddCourse={addCourse}
onCourseImported={handleCourseImported}
onCreateClass={createClass}
onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass}
@ -620,6 +626,7 @@ function SetupTab({
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onCreateClass,
onDeleteClass,
onSetParticipantClass,
@ -640,6 +647,7 @@ function SetupTab({
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
onDeleteClass: (id: string) => Promise<void>
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
@ -700,6 +708,21 @@ function SetupTab({
)}
</section>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
<ClassesCard
classes={classes}
courses={courses}
@ -717,20 +740,6 @@ function SetupTab({
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
/>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onError={onError}
/>
</div>
)
}
@ -1150,6 +1159,7 @@ function RoundsCard({
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onError,
}: {
base: string
@ -1162,6 +1172,7 @@ function RoundsCard({
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onError: (message: string) => void
}) {
const [creating, setCreating] = useState(false)
@ -1210,7 +1221,9 @@ function RoundsCard({
</Button>
) : (
<NewRoundForm
organizationId={organizationId}
courses={courses}
onCourseImported={onCourseImported}
onCreate={async (name, courseId, holeConfig) => {
await onAddRound(name, courseId, holeConfig)
setCreating(false)
@ -1224,14 +1237,18 @@ function RoundsCard({
}
function NewRoundForm({
organizationId,
courses,
onCreate,
onCreateCourse,
onCourseImported,
onCancel,
}: {
organizationId: string
courses: ApiCourse[]
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onCreateCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
@ -1239,6 +1256,8 @@ function NewRoundForm({
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
const [newCourseName, setNewCourseName] = useState("")
const [addingCourse, setAddingCourse] = useState(false)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
async function handleCreateCourse() {
@ -1334,6 +1353,46 @@ function NewRoundForm({
Banen ha registrert hull (par/hcp-indeks) og utslag før scoring kan
begynne -- sett opp dette under organisasjonens baner om det mangler.
</p>
{!officialSearchOpen && !templatePickerOpen ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<button
type="button"
onClick={() => setTemplatePickerOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull/utslag i stedet
</button>
</div>
) : officialSearchOpen ? (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(course) => {
onCourseImported(course)
setCourseId(course.id)
setAddingCourse(false)
setOfficialSearchOpen(false)
}}
/>
) : (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(created) => {
onCourseImported({ id: created.id, name: created.name, source: "custom" })
setCourseId(created.id)
setAddingCourse(false)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</div>
<div className="flex flex-col gap-1">
@ -2279,3 +2338,223 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
</div>
)
}
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
// duplisering-mellom-de-to-turneringstype-filene-konvensjon som ClassesCard
// allerede fulgte. Var tidligere KUN tilgjengelig for lagturneringer; denne
// runden fikset gapet der individuelle turneringer manglet TeeOff-import helt.
type ApiOfficialFacility = {
slug: string
name: string
city: string | null
county: string | null
}
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
}
function OfficialCourseSearch({
organizationId,
onClose,
onImported,
}: {
organizationId: string
onClose: () => void
onImported: (course: ApiCourse) => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
const [searching, setSearching] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch() {
setSearching(true)
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
{ credentials: "include" },
)
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
async function pickFacility(facility: ApiOfficialFacility) {
setSelectedFacility(facility)
setError(null)
setCourseOptions(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setCourseOptions(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
setCourseOptions([])
}
}
async function importCourse(course: ApiOfficialCourseOption) {
if (!selectedFacility) return
setImporting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
facility_slug: selectedFacility.slug,
teeoff_course_id: course.teeoff_course_id,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
} else if (code === "DUPLICATE") {
setError("Denne banen er allerede importert til organisasjonen.")
} else {
setError("Klarte ikke å importere banen. Prøv igjen.")
}
return
}
onImported(await res.json())
} catch {
setError("Klarte ikke å importere banen. Prøv igjen.")
} finally {
setImporting(false)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground"
aria-label="Lukk"
>
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{!selectedFacility ? (
<>
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres inne
i NewRoundForm sitt <form onSubmit={handleSubmit}>, og nestede
<form>-elementer er ugyldig HTML (samme feilrapport-funn som i
tournament-program.tsx sin variant -- "Søk"-knappen ville ellers
i praksis submitte det YTRE runde-skjemaet). */}
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk anleggsnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
runSearch()
}
}}
className="h-11 flex-1 rounded-xl text-base"
/>
<Button
type="button"
onClick={() => runSearch()}
disabled={searching}
className="h-11 shrink-0 rounded-xl font-bold"
>
{searching ? "Søker…" : "Søk"}
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickFacility(f)}
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-xs text-muted-foreground">
{[f.city, f.county].filter(Boolean).join(", ")}
</span>
)}
</button>
</li>
))}
{facilities.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
)}
</>
) : (
<>
<button
type="button"
onClick={() => {
setSelectedFacility(null)
setCourseOptions(null)
}}
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
{selectedFacility.name}
</button>
{courseOptions === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courseOptions.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={importing}
onClick={() => importCourse(c)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{c.name}
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer</span>}
</button>
</li>
))}
{courseOptions.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen 18-hulls baner å importere hos dette anlegget ennå.
</li>
)}
</ul>
)}
</>
)}
</div>
)
}

View file

@ -25,11 +25,14 @@ import {
Globe,
Lock,
MapPin,
Minus,
Pencil,
Plus,
Search,
Sparkles,
Trophy,
User,
UserPlus,
Users,
X,
} from "lucide-react"
@ -39,6 +42,7 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
import type { ApiCourseHole, ApiCourseTee, CourseTemplateValues } from "@/components/course-template-editor"
// --- Types -------------------------------------------------------------------
@ -48,7 +52,15 @@ type ApiGender = "m" | "f"
// ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk
// backend selv håndhever ved innsending).
type Tee = { id: string; name: string; genders: ApiGender[] }
type Course = { id: string; name: string; tees: Tee[] }
type Course = {
id: string
name: string
tees: Tee[]
// Fullt hull-/utslagoppsett for "bruk som mal"-broen (2026-08-04) --
// fraværende/tom når kilden (teeoff) ikke har komplette nok data ennå.
templateHoles?: ApiCourseHole[]
templateTees?: ApiCourseTee[]
}
type OfficialClub = { id: string; name: string; location: string; courses: Course[] }
// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over
@ -84,7 +96,7 @@ type Player = {
type Side = { id: string; label: string }
type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-create" | "fields"
type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-search-template" | "own-create" | "fields"
// --- Formater ------------------------------------------------------------------
@ -105,6 +117,8 @@ type FormatId =
| "shamble"
| "moneyball"
| "highlowhigh"
| "scramblevssolo"
| "scramblevssolomatch"
const FORMATS: { id: FormatId; label: string; desc: string }[] = [
{ id: "slagspill", label: "Slagspill", desc: "Vanlig individuell runde." },
@ -123,8 +137,27 @@ const FORMATS: { id: FormatId; label: string; desc: string }[] = [
{ id: "shamble", label: "Shamble", desc: "2-4 spillere, alle slår ut, velger beste -- spiller så egen ball inn." },
{ id: "moneyball", label: "Money Ball", desc: 'Fire spillere, én roterer som "money ball" per hull.' },
{ id: "highlowhigh", label: "High-low-high", desc: "To lag à to -- høy mot høy, lav mot lav, per hull." },
{
id: "scramblevssolo",
label: "Scramble mot enkeltspiller",
desc: "Et lag med delt ball (minst 2 spillere) mot én enkeltspiller — nettoscore avgjør.",
},
{
id: "scramblevssolomatch",
label: "Scramble mot enkeltspiller (matchspill)",
desc: "Samme oppsett som over, men avgjort hull for hull i stedet for netto totalsum.",
},
]
// Begge scramble-mot-enkeltspiller-variantene er BEVISST IKKE i dette
// settet -- de to sidene er strukturelt ulike (variabel lagstørrelse mot
// nøyaktig én individualspiller), ikke to like sider som resten av
// formatene her. Se app/routers/rounds.py sin _TWO_SIDED_FORMATS/
// _SCRAMBLE_SOLO_FORMATS for samme skille på backend-siden. Egen samlet
// set siden begge variantene deler EKSAKT samme sideoppsett-UI
// (ScrambleVsSoloSides)/allowance-kontroll (SoloAllowanceControl)/
// side_role-innsendingslogikk -- kun selve resultatvisningen skiller dem.
const SCRAMBLE_SOLO_VARIANTS = new Set<FormatId>(["scramblevssolo", "scramblevssolomatch"])
const TWO_SIDED = new Set<FormatId>([
"match", "fourball", "foursome", "greensome", "scramble2", "scramble4", "chapman", "highlowhigh",
])
@ -139,7 +172,7 @@ const SIDE_IS_UNIT_FORMAT = new Set<FormatId>(["foursome", "greensome", "scrambl
const ORDINARY_FORMATS = new Set<FormatId>(["slagspill", "stableford"])
function usesStep4(f: FormatId) {
return TWO_SIDED.has(f) || f === "moneyball"
return TWO_SIDED.has(f) || f === "moneyball" || SCRAMBLE_SOLO_VARIANTS.has(f)
}
// Wizardens FormatId -> ekte round.play_format-verdi (RoundCreate).
@ -160,6 +193,8 @@ const FORMAT_TO_API: Record<FormatId, string> = {
shamble: "shamble",
moneyball: "money_ball",
highlowhigh: "high_low_high",
scramblevssolo: "scramble_solo",
scramblevssolomatch: "scramble_solo_match",
}
// Motsatt vei, brukt til å forhåndsutfylle format ved "legg til en flight
// til" (playFormat kommer fra URL som en ekte API-verdi).
@ -226,9 +261,22 @@ type ApiOfficialCourseOption = {
name: string
is_main_course: boolean
tees: ApiTeeOption[]
// Fullt hull-/utslagoppsett -- tom liste når teeoff ikke har komplette nok
// data til å brukes som mal (2026-08-04, "bruk denne banen som mal"-bro).
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}
type ApiPersonalCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiPersonalCourseDetail = {
id: string
name: string
tees: ApiTeeOption[]
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
is_mine: boolean
created_by_display_name: string
forked_from_id: string | null
}
type ApiPersonalCourse = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] }
type PersonMatch = {
id: string
first_name: string
@ -310,6 +358,11 @@ export function NewRound() {
// Step 1
const [s1Sub, setS1Sub] = useState<S1Sub>("source")
// Hvor "own-create" skal gå TILBAKE til (2026-08-05) -- avhenger av om
// brukeren kom fra det vanlige "finn en bane å spille"-søket, fra
// mal-søket ("Basér på en eksisterende bane" inne i opprett-skjemaet),
// eller broen fra offisielle baner.
const [ownCreateOrigin, setOwnCreateOrigin] = useState<S1Sub>("own-search")
const [courseSource, setCourseSource] = useState<"official" | "own">("official")
const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null)
const [course, setCourse] = useState<Course | null>(null)
@ -331,6 +384,7 @@ export function NewRound() {
const [skinsTie, setSkinsTie] = useState<"roll" | "split">("roll")
const [bbbBonus, setBbbBonus] = useState(false)
const [shambleBest, setShambleBest] = useState(2)
const [soloAllowance, setSoloAllowance] = useState(100)
const [advOpen, setAdvOpen] = useState(false)
const [useHcp, setUseHcp] = useState(true)
const [useCourseHcp, setUseCourseHcp] = useState(true)
@ -442,8 +496,10 @@ export function NewRound() {
setS1Sub("official-search")
} else if (s1Sub === "own-search") {
setS1Sub("source")
} else if (s1Sub === "own-search-template") {
setS1Sub("own-create")
} else if (s1Sub === "own-create") {
setS1Sub("own-search")
setS1Sub(ownCreateOrigin)
} else if (s1Sub === "fields") {
setS1Sub(courseSource === "official" ? "official-courses" : "own-search")
}
@ -482,6 +538,7 @@ export function NewRound() {
skins_tie_handling: format === "skins" ? (skinsTie === "roll" ? "carry" : "split") : null,
bbb_sweep_bonus_enabled: format === "bbb" ? bbbBonus : false,
shamble_best_n: format === "shamble" ? shambleBest : null,
scramble_solo_individual_pct: SCRAMBLE_SOLO_VARIANTS.has(format) ? soloAllowance : 100,
visibility_mode: visibility === "privat" ? "private" : visibility === "venner" ? "friends" : "public",
visible_categories: visibility === "venner" ? Array.from(categories) : [],
flight_group_id: flightGroupId || undefined,
@ -523,6 +580,29 @@ export function NewRound() {
const created: { id: string } = await res.json()
sideIdMap.set(side.id, created.id)
}
} else if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
// Asymmetrisk format (begge varianter, stroke- og matchspill) --
// de to sidene er faste (ikke organisator-opprettet via
// CreateSideCard som TWO_SIDED-formatene), og trenger side_role
// (backend kan ellers ikke skille laget fra individualspilleren,
// se RoundSideCreate.side_role). Lokale player.sideId-verdier
// "team"/"solo" (satt av ScrambleVsSoloSides) mappes her til de
// ekte, nyopprettede side-id-ene.
const roles: { localId: "team" | "solo"; label: string; side_role: "team" | "individual" }[] = [
{ localId: "team", label: "Laget", side_role: "team" },
{ localId: "solo", label: "Individuell motstander", side_role: "individual" },
]
for (const { localId, label, side_role } of roles) {
const res = await fetch(`/rounds/${roundId}/sides`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ label, side_role }),
})
if (!res.ok) throw new Error(`create side: ${res.status}`)
const created: { id: string } = await res.json()
sideIdMap.set(localId, created.id)
}
}
// Medspillere (alle unntatt eieren, som allerede finnes).
@ -567,7 +647,7 @@ export function NewRound() {
// Eierens egen side-tildeling (PATCH -- eieren finnes allerede FØR
// sidene ble opprettet over).
if (TWO_SIDED.has(format)) {
if (TWO_SIDED.has(format) || SCRAMBLE_SOLO_VARIANTS.has(format)) {
const owner = players.find((p) => p.isOwner)
const realSideId = owner?.sideId ? sideIdMap.get(owner.sideId) : undefined
if (realSideId) {
@ -697,6 +777,7 @@ export function NewRound() {
<Step1
s1Sub={s1Sub}
setS1Sub={setS1Sub}
setOwnCreateOrigin={setOwnCreateOrigin}
selectedClub={selectedClub}
setSelectedClub={setSelectedClub}
setCourseSource={setCourseSource}
@ -740,6 +821,8 @@ export function NewRound() {
setBbbBonus={setBbbBonus}
shambleBest={shambleBest}
setShambleBest={setShambleBest}
soloAllowance={soloAllowance}
setSoloAllowance={setSoloAllowance}
advOpen={advOpen}
setAdvOpen={setAdvOpen}
useHcp={useHcp}
@ -758,7 +841,14 @@ export function NewRound() {
)}
{stepN === 4 && (
<Step4 format={format} players={players} setPlayers={setPlayers} sides={sides} setSides={setSides} />
<Step4
format={format}
players={players}
setPlayers={setPlayers}
sides={sides}
setSides={setSides}
roundStatLevel={statLevel}
/>
)}
{stepN === 5 && (
@ -1000,6 +1090,7 @@ function HelperNote({ children }: { children: React.ReactNode }) {
function Step1(props: {
s1Sub: S1Sub
setS1Sub: (s: S1Sub) => void
setOwnCreateOrigin: (s: S1Sub) => void
selectedClub: OfficialClub | null
setSelectedClub: (c: OfficialClub | null) => void
setCourseSource: (s: "official" | "own") => void
@ -1017,8 +1108,11 @@ function Step1(props: {
startHole: string
setStartHole: (v: string) => void
}) {
const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
const { s1Sub, setS1Sub, setOwnCreateOrigin, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
const [resolvingClub, setResolvingClub] = useState(false)
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
const [templatePrefill, setTemplatePrefill] = useState<{ values: CourseTemplateValues; forkedFromId?: string } | null>(null)
if (s1Sub === "source") {
return (
@ -1062,7 +1156,13 @@ function Step1(props: {
id: facility.slug,
name: detail.name,
location: [facility.city, facility.county].filter(Boolean).join(", "),
courses: detail.courses.map((c) => ({ id: String(c.teeoff_course_id), name: c.name, tees: apiTeesToTees(c.tees) })),
courses: detail.courses.map((c) => ({
id: String(c.teeoff_course_id),
name: c.name,
tees: apiTeesToTees(c.tees),
templateHoles: c.holes,
templateTees: c.full_tees,
})),
}
setSelectedClub(club)
setS1Sub("official-courses")
@ -1083,10 +1183,22 @@ function Step1(props: {
description="Velg hvilken bane du skal spille."
courses={selectedClub.courses}
onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")}
onUseAsTemplate={(c) => {
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
setCourseSource("own")
setOwnCreateOrigin("official-courses")
setS1Sub("own-create")
}}
/>
)
}
// Vanlig "finn en bane å spille"-søk (2026-08-05) -- IKKE "Bruk som mal
// for egen bane" her lenger. Den knappen hører hjemme der brukeren
// FAKTISK har sagt at målet er å OPPRETTE en ny bane (se
// "own-search-template" under, nådd fra "Basér på en eksisterende
// bane"-lenken inne i det blanke opprett-skjemaet) -- ikke i det
// vanlige søket der målet som oftest bare er å velge en bane å spille.
if (s1Sub === "own-search") {
return (
<OwnSearch
@ -1100,7 +1212,48 @@ function Step1(props: {
"own",
)
}}
onCreate={() => setS1Sub("own-create")}
onCreate={() => {
setTemplatePrefill(null)
setOwnCreateOrigin("own-search")
setS1Sub("own-create")
}}
/>
)
}
// Mal-søk (2026-08-05) -- samme søkeskjerm som over, men nådd KUN fra
// "Basér på en eksisterende bane" inne i opprett-ny-bane-skjemaet, så
// "Bruk som mal for egen bane" er meningsfull her (brukeren har allerede
// sagt at målet er å opprette en ny bane).
if (s1Sub === "own-search-template") {
return (
<OwnSearch
onSelect={async (picked) => {
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
if (!res.ok) return
const detail: ApiPersonalCourseDetail = await res.json()
chooseCourse(
{ id: detail.id, name: detail.name, tees: apiTeesToTees(detail.tees) },
{ source: "custom", personalCourseId: detail.id },
"own",
)
}}
onUseAsTemplate={async (picked) => {
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
if (!res.ok) return
const detail: ApiPersonalCourseDetail = await res.json()
setTemplatePrefill({
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
forkedFromId: detail.id,
})
setOwnCreateOrigin("own-search-template")
setS1Sub("own-create")
}}
onCreate={() => {
setTemplatePrefill(null)
setOwnCreateOrigin("own-search-template")
setS1Sub("own-create")
}}
/>
)
}
@ -1108,8 +1261,14 @@ function Step1(props: {
if (s1Sub === "own-create") {
return (
<OwnCreateStep
initial={templatePrefill?.values}
forkedFromId={templatePrefill?.forkedFromId}
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
onCancel={() => setS1Sub("own-search")}
onSearchTemplate={() => setS1Sub("own-search-template")}
onCancel={() => {
setTemplatePrefill(null)
setS1Sub("own-search")
}}
/>
)
}
@ -1274,17 +1433,29 @@ function ClubRow({ name, location, distance, disabled, onClick }: { name: string
)
}
function CourseList({ title, description, courses, onSelect }: { title: string; description: string; courses: Course[]; onSelect: (c: Course) => void }) {
function CourseList({
title,
description,
courses,
onSelect,
onUseAsTemplate,
}: {
title: string
description: string
courses: Course[]
onSelect: (c: Course) => void
onUseAsTemplate?: (c: Course) => void
}) {
return (
<div className="flex flex-col gap-8">
<StepHeader title={title} description={description} />
<ul className="flex flex-col gap-3">
{courses.map((course) => (
<li key={course.id}>
<li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<button
type="button"
onClick={() => onSelect(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left 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"
className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 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">
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
@ -1294,6 +1465,16 @@ function CourseList({ title, description, courses, onSelect }: { title: string;
<span className="text-base text-muted-foreground">{course.tees.length} utslag</span>
</div>
</button>
{onUseAsTemplate && course.templateHoles && course.templateHoles.length > 0 && (
<Button
type="button"
variant="outline"
onClick={() => onUseAsTemplate(course)}
className="h-10 self-start rounded-xl text-sm font-bold"
>
Bruk som mal for egen bane
</Button>
)}
</li>
))}
{courses.length === 0 && (
@ -1306,7 +1487,18 @@ function CourseList({ title, description, courses, onSelect }: { title: string;
)
}
function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) => void; onCreate: () => void }) {
function OwnSearch({
onSelect,
onUseAsTemplate,
onCreate,
}: {
onSelect: (c: ApiPersonalCourse) => void
// Kun satt når søket er nådd med hensikten å OPPRETTE en ny bane (se
// "own-search-template" i Step1) -- undefined i det vanlige "finn en
// bane å spille"-søket, som skjuler knappen under (2026-08-05).
onUseAsTemplate?: (c: ApiPersonalCourse) => void
onCreate: () => void
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPersonalCourse[]>([])
@ -1328,7 +1520,14 @@ function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) =>
return (
<div className="flex flex-col gap-8">
<StepHeader title="Egen bane" description="Søk opp en personlig bane, eller opprett en ny." />
<StepHeader
title={onUseAsTemplate ? "Basér på en eksisterende bane" : "Egen bane"}
description={
onUseAsTemplate
? "Velg en av dine egne baner som utgangspunkt for den nye, eller fyll inn fra bunnen av."
: "Søk opp en personlig bane, eller opprett en ny."
}
/>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
@ -1345,19 +1544,32 @@ function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) =>
{results.length > 0 ? (
<ul className="flex flex-col gap-3">
{results.map((course) => (
<li key={course.id}>
<li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<button
type="button"
onClick={() => onSelect(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left 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"
className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 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">
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{course.name}</span>
<span className="text-sm text-muted-foreground">
{course.is_mine ? "Opprettet av deg" : `Opprettet av ${course.created_by_display_name}`}
</span>
</div>
</button>
{onUseAsTemplate && (
<Button
type="button"
variant="outline"
onClick={() => onUseAsTemplate(course)}
className="h-10 self-start rounded-xl text-sm font-bold"
>
Bruk som mal for egen bane
</Button>
)}
</li>
))}
</ul>
@ -1386,14 +1598,58 @@ function emptyHoles() {
return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1) }))
}
// "Bruk som mal"-broen (2026-08-04): konverterer fullt hull-/utslagoppsett
// (fra en TeeOff-bane eller en offentlig custom-bane) til det redigerbare
// lokale string-baserte skjema-formatet -- gjenbruker samme string-felt-
// tilnærming som resten av OwnCreateStep (tillater midlertidig ugyldig/
// tom input mens brukeren redigerer).
function templateToHoles(holes: ApiCourseHole[]) {
return holes
.slice()
.sort((a, b) => a.hole_number - b.hole_number)
.map((h) => ({ hole: h.hole_number, par: String(h.par), strokeIndex: String(h.stroke_index) }))
}
function templateToTees(tees: ApiCourseTee[]): CreateTee[] {
return tees.map((t, i) => {
const m = t.ratings.find((r) => r.gender === "m")
const f = t.ratings.find((r) => r.gender === "f")
return {
id: `tpl-${i}-${t.name}`,
name: t.name,
men: !!m,
women: !!f,
menRating: m ? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) } : undefined,
womenRating: f ? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) } : undefined,
}
})
}
function parseDecimal(value: string): number {
return Number(value.replace(",", "."))
}
function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) => void; onCancel: () => void }) {
const [name, setName] = useState("")
const [holes, setHoles] = useState(emptyHoles)
const [tees, setTees] = useState<CreateTee[]>([{ id: `nt-${Date.now()}`, name: "", men: true, women: false }])
function OwnCreateStep({
initial,
forkedFromId,
onCreated,
onSearchTemplate,
onCancel,
}: {
initial?: CourseTemplateValues
forkedFromId?: string
onCreated: (course: Course) => void
// "Basér på en eksisterende bane" (2026-08-05) -- kun tilbudt når skjemaet
// fortsatt er blankt (ingen mal valgt ennå); gir "Bruk som mal for egen
// bane" en riktig plassering: kun der brukeren FAKTISK har sagt at målet
// er å opprette en ny bane, ikke i det vanlige "finn en bane å spille"-søket.
onSearchTemplate?: () => void
onCancel: () => void
}) {
const [name, setName] = useState(initial?.name ?? "")
const [holes, setHoles] = useState(() => (initial ? templateToHoles(initial.holes) : emptyHoles()))
const [tees, setTees] = useState<CreateTee[]>(() =>
initial ? templateToTees(initial.tees) : [{ id: `nt-${Date.now()}`, name: "", men: true, women: false }],
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -1440,6 +1696,7 @@ function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) =>
: []),
],
})),
forked_from_id: forkedFromId ?? null,
}
const res = await fetch("/personal-courses", {
method: "POST",
@ -1469,6 +1726,16 @@ function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) =>
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
{onSearchTemplate && !initial && (
<button
type="button"
onClick={onSearchTemplate}
className="flex min-h-11 items-center gap-1.5 self-start text-base font-semibold text-primary underline underline-offset-2 hover:no-underline"
>
Basér en eksisterende bane i stedet
</button>
)}
{error && <p className="text-base font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
@ -1770,6 +2037,8 @@ function Step2(props: {
setBbbBonus: (v: boolean) => void
shambleBest: number
setShambleBest: (v: number) => void
soloAllowance: number
setSoloAllowance: (v: number) => void
advOpen: boolean
setAdvOpen: (v: boolean) => void
useHcp: boolean
@ -1784,7 +2053,7 @@ function Step2(props: {
const {
statLevel, setStatLevel, format, setFormat, holes, setHoles,
matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie,
bbbBonus, setBbbBonus, shambleBest, setShambleBest,
bbbBonus, setBbbBonus, shambleBest, setShambleBest, soloAllowance, setSoloAllowance,
advOpen, setAdvOpen, useHcp, setUseHcp, useCourseHcp, setUseCourseHcp,
useMatchplayHcp, setUseMatchplayHcp, hcpPercent, setHcpPercent,
} = props
@ -1843,45 +2112,49 @@ function Step2(props: {
setBbbBonus={setBbbBonus}
shambleBest={shambleBest}
setShambleBest={setShambleBest}
soloAllowance={soloAllowance}
setSoloAllowance={setSoloAllowance}
/>
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
<button
type="button"
onClick={() => setAdvOpen(!advOpen)}
aria-expanded={advOpen}
className="flex min-h-[56px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Sparkles aria-hidden="true" className="size-5 text-muted-foreground" />
<span className="flex-1 text-base font-bold text-foreground">Avanserte handicap-innstillinger</span>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", advOpen && "rotate-180")} />
</button>
{advOpen && (
<div className="flex flex-col gap-5 border-t border-border px-4 py-5">
<ToggleRow label="Bruk handicap" checked={useHcp} onChange={setUseHcp} />
{useHcp && !ORDINARY_FORMATS.has(format) && (
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
)}
{useHcp && twoSided && (
<div className="flex flex-col gap-1.5">
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Fordeler slagene som en differanse mellom sidene i stedet for hvert lags fulle handicap slås av
spilles matchen med brutto/netto per side.
</p>
</div>
)}
{useHcp && (
<div className="flex flex-col gap-2">
<Label htmlFor="hcp-percent" className="text-base font-semibold">
HCP-prosent (valgfritt)
</Label>
<Input id="hcp-percent" inputMode="decimal" value={hcpPercent} onChange={(e) => setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
</div>
)}
</div>
)}
</div>
{!SCRAMBLE_SOLO_VARIANTS.has(format) && (
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
<button
type="button"
onClick={() => setAdvOpen(!advOpen)}
aria-expanded={advOpen}
className="flex min-h-[56px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Sparkles aria-hidden="true" className="size-5 text-muted-foreground" />
<span className="flex-1 text-base font-bold text-foreground">Avanserte handicap-innstillinger</span>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", advOpen && "rotate-180")} />
</button>
{advOpen && (
<div className="flex flex-col gap-5 border-t border-border px-4 py-5">
<ToggleRow label="Bruk handicap" checked={useHcp} onChange={setUseHcp} />
{useHcp && !ORDINARY_FORMATS.has(format) && (
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
)}
{useHcp && twoSided && (
<div className="flex flex-col gap-1.5">
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Fordeler slagene som en differanse mellom sidene i stedet for hvert lags fulle handicap slås av
spilles matchen med brutto/netto per side.
</p>
</div>
)}
{useHcp && (
<div className="flex flex-col gap-2">
<Label htmlFor="hcp-percent" className="text-base font-semibold">
HCP-prosent (valgfritt)
</Label>
<Input id="hcp-percent" inputMode="decimal" value={hcpPercent} onChange={(e) => setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
</div>
)}
</div>
)}
</div>
)}
<div className="flex flex-col gap-3">
<SectionTitle title="Antall hull" />
@ -1930,6 +2203,8 @@ function FormatConfig({
setBbbBonus,
shambleBest,
setShambleBest,
soloAllowance,
setSoloAllowance,
}: {
format: FormatId
matchExcludeHcp: boolean
@ -1942,6 +2217,8 @@ function FormatConfig({
setBbbBonus: (v: boolean) => void
shambleBest: number
setShambleBest: (v: number) => void
soloAllowance: number
setSoloAllowance: (v: number) => void
}) {
if (format === "match") {
return (
@ -2018,6 +2295,10 @@ function FormatConfig({
return <HelperNote>Nøyaktig 3 spillere.</HelperNote>
}
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
return <SoloAllowanceControl value={soloAllowance} onChange={setSoloAllowance} />
}
if (TWO_SIDED.has(format)) {
return <HelperNote>Du setter opp lagene i neste steg.</HelperNote>
}
@ -2025,6 +2306,83 @@ function FormatConfig({
return null
}
// Kompakt 0-100 (steg 5) tallvelger for individualspillerens handicap-
// andel. Bevisst INGEN tilsvarende kontroll for lagsiden -- lagets
// handicap er alltid en fast beregning (TeamAverage), ikke konfigurerbar.
function SoloAllowanceControl({
value,
onChange,
}: {
value: number
onChange: (v: number) => void
}) {
const STEP = 5
const MIN = 0
const MAX = 100
function clamp(n: number) {
if (!Number.isFinite(n)) return MIN
return Math.min(MAX, Math.max(MIN, n))
}
function snap(n: number) {
return clamp(Math.round(n / STEP) * STEP)
}
return (
<div className="flex flex-col gap-2">
<Label htmlFor="solo-allowance" className="text-base font-semibold text-foreground">
Individualspillerens handicap-andel
</Label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={() => onChange(clamp(value - STEP))}
disabled={value <= MIN}
aria-label="Reduser handicap-andel"
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-6" />
</button>
<div className="relative flex-1">
<Input
id="solo-allowance"
inputMode="numeric"
value={String(value)}
onChange={(e) => {
const digits = e.target.value.replace(/[^0-9]/g, "")
onChange(digits === "" ? MIN : clamp(Number(digits)))
}}
onBlur={() => onChange(snap(value))}
aria-describedby="solo-allowance-help"
className="h-14 rounded-2xl pr-10 text-center text-lg font-bold tabular-nums"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-lg font-bold text-muted-foreground"
>
%
</span>
</div>
<button
type="button"
onClick={() => onChange(clamp(value + STEP))}
disabled={value >= MAX}
aria-label="Øk handicap-andel"
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-6" />
</button>
</div>
<p id="solo-allowance-help" className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Hvor stor andel av egen handicap individualspilleren spiller med. Standard 100 % (full
handicap).
</p>
</div>
)
}
// --- Step 3: Spillere ---------------------------------------------------------
function Step3({
@ -2548,13 +2906,19 @@ function Step4({
setPlayers,
sides,
setSides,
roundStatLevel,
}: {
format: FormatId
players: Player[]
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
sides: Side[]
setSides: React.Dispatch<React.SetStateAction<Side[]>>
roundStatLevel: StatLevel
}) {
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
return <ScrambleVsSoloSides players={players} setPlayers={setPlayers} roundStatLevel={roundStatLevel} />
}
if (format === "moneyball") {
return (
<div className="flex flex-col gap-6">
@ -2660,6 +3024,220 @@ function Step4({
)
}
// Asymmetrisk sideoppsett for "Scramble mot enkeltspiller": slot 1 (Laget)
// er en åpen liste (ingen øvre grense, "minst 2"-hint), slot 2 (Individuell
// motstander) er begrenset til nøyaktig én spiller. player.sideId bruker
// de faste literalene "team"/"solo" her (ikke en dynamisk opprettet Side-
// id som i den symmetriske Step4 over) -- submitWizard() oppretter de to
// ekte backend-sidene (med side_role) og mapper disse to nøklene til de
// virkelige side-id-ene ved innsending.
function ScrambleVsSoloSides({
players,
setPlayers,
roundStatLevel,
}: {
players: Player[]
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
roundStatLevel: StatLevel
}) {
const [addingTo, setAddingTo] = useState<"team" | "solo" | null>(null)
const team = players.filter((p) => p.sideId === "team")
const solo = players.filter((p) => p.sideId === "solo")
const unplaced = players.filter((p) => p.sideId !== "team" && p.sideId !== "solo")
const soloFull = solo.length >= 1
const teamOk = team.length >= 2
function assign(id: string, sideId: string | undefined) {
setPlayers((prev) => prev.map((p) => (p.id === id ? { ...p, sideId } : p)))
}
function addTo(sideId: "team" | "solo", p: Player) {
setPlayers((prev) => [...prev, { ...p, sideId }])
setAddingTo(null)
}
const existingNames = players.map((p) => p.name)
return (
<div className="flex flex-col gap-6">
<StepHeader
title="Lag og rekkefølge"
description="Ett lag med delt ball mot én enkeltspiller — sidene er ulike."
/>
{/* Slot 1: Laget (åpen liste) */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Users aria-hidden="true" className="size-5" />
</span>
<h2 className="text-xl font-bold text-foreground">Laget</h2>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-sm font-bold tabular-nums text-muted-foreground">
{team.length} {team.length === 1 ? "spiller" : "spillere"}
</span>
</div>
<div
className={cn(
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-semibold",
teamOk
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-muted-foreground",
)}
aria-live="polite"
>
{teamOk ? (
<Check aria-hidden="true" className="size-4 shrink-0" />
) : (
<Users aria-hidden="true" className="size-4 shrink-0" />
)}
<span>{teamOk ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}</span>
</div>
{team.length > 0 && (
<ul className="flex flex-col gap-2">
{team.map((p) => (
<MemberRow
key={p.id}
player={p}
onRemove={() => assign(p.id, undefined)}
removeLabel={`Fjern ${p.name} fra laget`}
/>
))}
</ul>
)}
{addingTo === "team" ? (
<AddPlayerPanel
onCancel={() => setAddingTo(null)}
onAdd={(p) => addTo("team", p)}
roundStatLevel={roundStatLevel}
existingNames={existingNames}
/>
) : (
<AddAffordance label="Legg til spiller" onClick={() => setAddingTo("team")} />
)}
</section>
{/* Slot 2: Individuell motstander (begrenset til 1) */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-brand-orange/15 text-brand-orange">
<User aria-hidden="true" className="size-5" />
</span>
<div className="flex flex-col">
<h2 className="text-xl font-bold text-foreground">Individuell motstander</h2>
<p className="text-sm text-muted-foreground">Nøyaktig én spiller</p>
</div>
</div>
{soloFull ? (
<ul className="flex flex-col gap-2">
{solo.map((p) => (
<MemberRow
key={p.id}
player={p}
onRemove={() => assign(p.id, undefined)}
removeLabel={`Fjern ${p.name} som motstander`}
/>
))}
</ul>
) : addingTo === "solo" ? (
<AddPlayerPanel
onCancel={() => setAddingTo(null)}
onAdd={(p) => addTo("solo", p)}
roundStatLevel={roundStatLevel}
existingNames={existingNames}
/>
) : (
<AddAffordance label="Legg til motstander" onClick={() => setAddingTo("solo")} />
)}
</section>
{/* Spillere i runden som ennå ikke er plassert på noen side */}
{unplaced.length > 0 && (
<div className="flex flex-col gap-3">
<SectionTitle title="Ikke plassert" hint="Spillere fra runden som ennå ikke er tildelt en side." />
<ul className="flex flex-col gap-2">
{unplaced.map((p) => (
<li
key={p.id}
className="flex flex-wrap items-center gap-2 rounded-2xl border border-border bg-card p-3 shadow-sm shadow-black/5"
>
<Avatar name={p.name} />
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{p.name}</span>
<button
type="button"
onClick={() => assign(p.id, "team")}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Users aria-hidden="true" className="size-4" />
Til laget
</button>
{!soloFull && (
<button
type="button"
onClick={() => assign(p.id, "solo")}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-brand-orange/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<User aria-hidden="true" className="size-4" />
Som motstander
</button>
)}
</li>
))}
</ul>
</div>
)}
</div>
)
}
function MemberRow({
player,
onRemove,
removeLabel,
}: {
player: Player
onRemove: () => void
removeLabel: string
}) {
return (
<li className="flex items-center gap-3 rounded-2xl border border-border bg-card p-3">
<Avatar name={player.name} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
{player.hcp.trim() !== "" && (
<span className="text-sm text-muted-foreground">HCP {player.hcp}</span>
)}
</div>
<button
type="button"
onClick={onRemove}
aria-label={removeLabel}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X aria-hidden="true" className="size-5" />
</button>
</li>
)
}
function AddAffordance({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex min-h-11 items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card/40 px-6 py-4 text-base font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<UserPlus aria-hidden="true" className="size-5" />
{label}
</button>
)
}
function CreateSideCard({ slot, onCreate, disabled }: { slot: number; onCreate: (label: string) => void; disabled: boolean }) {
const [label, setLabel] = useState("")
const fallback = slot === 0 ? "Side A" : "Side B"

View 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>
)
}

View 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 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 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>
)
}

View file

@ -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 && <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 tvers av turneringer</span>
</div>
</Link>
<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">
<Users aria-hidden="true" className="size-4" />

View file

@ -41,6 +41,29 @@ const STATUS_CONFIG: Record<RoundStatus, { label: string }> = {
completed: { label: "Fullført" },
}
// Egen lokal kopi per prosjektets konvensjon (liten duplisering fremfor
// cross-file-import, se samme mønster i round-detail.tsx/round-scorecard.tsx).
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
match: "Match",
skins: "Skins",
fourball: "Fourball",
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
chapman: "Chapman",
copenhagen: "Københavner",
bbb: "Bingo Bango Bongo",
flag: "Flaggturnering",
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", {
day: "numeric",
month: "short",
@ -120,6 +143,9 @@ export function RoundCard({ round }: { round: Round }) {
? round.differential.toFixed(1).replace(".", ",")
: null
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
// Skjules for "stroke" (samme "kun avvik fra standardformatet er verdt en
// pille"-konvensjon som round-scorecard.tsx sin sammendrags-stripe).
const formatLabel = round.playFormat && round.playFormat !== "stroke" ? FORMAT_LABELS[round.playFormat] ?? round.playFormat : null
// To-sidede formater (match/fourball/foursome/greensome/scramble,
// 2026-07-29): vis matchStatus i stedet for rå slagtall -- brutto
@ -178,8 +204,15 @@ export function RoundCard({ round }: { round: Round }) {
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
{STATUS_CONFIG[round.status].label}
<span className="flex items-center gap-2">
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
{STATUS_CONFIG[round.status].label}
</span>
{formatLabel && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
{formatLabel}
</span>
)}
</span>
{round.status === "active" && (
<span className="text-xs font-semibold tabular-nums text-muted-foreground">

View file

@ -40,6 +40,7 @@ import {
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
@ -193,6 +194,9 @@ type ApiParticipant = {
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type PersonMatch = {
@ -264,6 +268,14 @@ const TWO_SIDED_FORMATS = new Set([
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
])
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
// Scramble mot enkeltspiller (migrasjon 056/057) -- HALVPARTEN delt-ball
// (laget deler ball via round_side_id) og HALVPARTEN individuell
// (motstanderen scorer per deltaker via round_participant_id). Passer
// derfor IKKE inn i SHARED_BALL_FORMATS sin alt-eller-ingenting-bryter --
// se scrambleSoloTeamSideId lenger ned, som ruter KUN lagets spillere til
// SideScoreWizard, mens individualspilleren fortsatt bruker vanlig
// ScoringWizard.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
const SIDE_PLAYER_COUNT: Record<string, number> = {
match: 1,
fourball: 2,
@ -291,6 +303,8 @@ const FORMAT_LABELS: Record<string, string> = {
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
@ -833,11 +847,29 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
// loadRound() har satt currentHole fra round.start_hole.
const activeHole = currentHole ?? round?.start_hole ?? 1
const hole = holes.find((h) => h.holeNumber === activeHole) ?? null
// Par/index for aktivt hull -- samme for ALLE deltakere/sider (samme
// bane), hentet fra HVILKEN SOM HELST kilde som allerede har lastet inn
// hullene sine (2026-08-05, erstatter en tidligere versjon som kun så på
// activePlayerId sin egen liste -- brøt permanent for scramble_solo sine
// lagspillere, som ALDRI får egne round_participant_id-keyede hull-rader
// siden laget deler ball via round_side_id. En aktiv spiller på laget
// ga derfor en evig tom `holes`-liste og en evig spinner). Samme mønster
// som delt-ball-formatenes side-baserte oppslag alltid har brukt, nå
// generalisert til å også dekke deltaker-baserte kilder.
const hole: Hole | null = (() => {
for (const list of Object.values(holesByParticipant)) {
const h = list.find((x) => x.hole_number === activeHole)
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
}
for (const s of round?.sides ?? []) {
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
}
return null
})()
// Navigasjonsrekkefølge starter på øktens starthull og går rundt
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
@ -902,36 +934,60 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
function advanceWizardPlayer() {
const idx = players.findIndex((p) => p.id === wizardPlayerId)
const isLast = idx === -1 || idx === players.length - 1
if (!isLast) {
const next = players[idx + 1]
setActivePlayerId(next.id)
setWizardPlayerId(next.id)
} else {
setWizardPlayerId(null)
setActivePlayerId(players[0]?.id ?? null)
goNext()
}
}
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
// mening for en delt ball).
const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
// Scramble mot enkeltspiller (2026-08-05) -- KUN lagets side, aldri
// individualspillerens (den fortsetter å bruke vanlig ScoringWizard/
// wizardPlayerId). Null for alle andre formater.
const scrambleSoloTeamSideId =
round && SCRAMBLE_SOLO_FORMATS.has(round.play_format)
? round.sides.find((s) => s.side_role === "team")?.id ?? null
: null
const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null
// Par/index for aktivt hull -- samme for begge sider (samme bane), henter
// fra hvilken som helst av de to som allerede har lastet inn hullene sine.
const sharedHole: Hole | null = (() => {
for (const s of round?.sides ?? []) {
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
// Felles klikk-håndtering for PlayerHoleCards/ScorecardGrid sine
// "Registrer X sin score"-kort (2026-08-05) -- for scramble mot
// enkeltspiller skal LAGETS spillere åpne den delte SideScoreWizard
// (samme lagscore for alle, keyet på round_side_id), mens
// individualspilleren fortsatt åpner den vanlige, per-deltaker
// ScoringWizard. Alle andre formater (ingen team-side) oppfører seg
// som før.
function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) {
setCurrentHole(holeNumber)
const player = players.find((p) => p.id === playerId)
if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) {
setWizardSideId(scrambleSoloTeamSideId)
return
}
return null
})()
setActivePlayerId(playerId)
setWizardPlayerId(playerId)
}
// Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
// Hopper over lagets spillere for scramble_solo (2026-08-05) -- de scorer
// ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
// de skal aldri kunne bli et "neste"-mål her heller.
function advanceWizardPlayer() {
const chain = scrambleSoloTeamSideId ? players.filter((p) => p.roundSideId !== scrambleSoloTeamSideId) : players
const idx = chain.findIndex((p) => p.id === wizardPlayerId)
const isLast = idx === -1 || idx === chain.length - 1
if (!isLast) {
const next = chain[idx + 1]
setActivePlayerId(next.id)
setWizardPlayerId(next.id)
} else {
setWizardPlayerId(null)
setActivePlayerId(chain[0]?.id ?? null)
goNext()
}
}
// Auto-prompt "Fullfør runde" (2026-07-30): er ALLE hull ført for ALLE
// deltakere (individuelle formater) eller for BEGGE sider (delt-ball) --
@ -1002,6 +1058,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
function advanceWizardSide() {
if (!round) return
// Scramble mot enkeltspiller (2026-08-05): kun ÉN side er noensinne
// side-veiviser-styrt (laget) -- individualspilleren har sin egen,
// separate ScoringWizard-kjede (advanceWizardPlayer). Aldri "hopp til
// neste side" her, i motsetning til ekte delt-ball-formater der begge
// sider deler samme veiviser-kjede.
if (scrambleSoloTeamSideId) {
setWizardSideId(null)
goNext()
return
}
const idx = round.sides.findIndex((s) => s.id === wizardSideId)
const isLast = idx === -1 || idx === round.sides.length - 1
if (!isLast) {
@ -1459,7 +1525,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
"Vis hele scorekortet"-bryter under -- ingen informasjon er
fjernet. */}
{holes.length === 0 ? (
{hole === null ? (
<div className="mt-5 flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
@ -1481,11 +1547,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}}
onPrevHole={goPrev}
onNextHole={goNext}
onOpenEntry={(playerId, holeNumber) => {
setCurrentHole(holeNumber)
setActivePlayerId(playerId)
setWizardPlayerId(playerId)
}}
onOpenEntry={openPlayerOrTeamSideEntry}
/>
<div className="overflow-hidden rounded-2xl border border-border bg-card">
@ -1514,11 +1576,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
setCurrentHole(n)
scrollToHolePanel()
}}
onOpenEntry={(playerId, holeNumber) => {
setCurrentHole(holeNumber)
setActivePlayerId(playerId)
setWizardPlayerId(playerId)
}}
onOpenEntry={openPlayerOrTeamSideEntry}
/>
</div>
)}
@ -1534,12 +1592,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
{wizardSide && sharedHole && (
{wizardSide && hole && (
<SideScoreWizard
sides={round.sides}
side={wizardSide}
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
hole={sharedHole}
hole={hole}
score={wizardSideHole?.played ? wizardSideHole.score : null}
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
onSave={updateWizardSideScore}
@ -2573,6 +2631,11 @@ type ApiFormatResult = {
money_ball_team_score: number | null
hlh_points_a: number | null
hlh_points_b: number | null
// Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
// side "a" = laget, side "b" = individualspilleren. margin > 0 = laget leder.
scramble_solo_team_net: number | null
scramble_solo_individual_net: number | null
scramble_solo_margin: number | null
holes: ApiFormatHoleOut[] | null
}
@ -2622,6 +2685,50 @@ function matchStillingByHole(
// navn når nøyaktig én er tildelt siden (match, eller en komplett foursome-
// side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers
// sidens eget (evt. generiske) navn uten noen entydig HCP å vise.
// Samme adapter-logikk som round-leaderboard.tsx sin buildScrambleSoloResult
// (lokal kopi, prosjektets etablerte mønster) -- bygger ScrambleSoloResultView
// sin props-form fra det generiske ApiFormatResult-svaret.
function buildScrambleSoloResult(round: ApiRound, result: ApiFormatResult): ScrambleSoloResult {
const teamSide = round.sides.find((s) => s.side_role === "team")
const individualSide = round.sides.find((s) => s.side_role === "individual")
const firstHoleEntries = result.holes?.[0]?.entries ?? []
const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
const individualLabel =
firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"
const holes: ScrambleSoloResult["holes"] = (result.holes ?? []).map((h) => {
const teamEntry = h.entries.find((e) => e.side === "a")
const individualEntry = h.entries.find((e) => e.side === "b")
const holeResult: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
return {
holeNumber: h.hole_number,
par: h.par,
strokeIndex: h.stroke_index,
result: holeResult,
team: {
gross: teamEntry?.gross ?? 0,
net: teamEntry?.net ?? 0,
strokesReceived: teamEntry?.strokes_received ?? 0,
},
individual: {
gross: individualEntry?.gross ?? 0,
net: individualEntry?.net ?? 0,
strokesReceived: individualEntry?.strokes_received ?? 0,
},
}
})
return {
ready: result.ready,
teamLabel,
individualLabel,
teamNet: result.scramble_solo_team_net,
individualNet: result.scramble_solo_individual_net,
margin: result.scramble_solo_margin,
holes,
}
}
function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } {
const idx = side === "a" ? 0 : 1
const sideId = round.sides[idx]?.id
@ -3046,6 +3153,21 @@ function FormatResultPanel({
)
}
// Scramble mot enkeltspiller (migrasjon 056, 2026-08-05) -- netto
// slagspill-sammenligning, ikke matchspill -- MÅ avskjæres FØR den
// generiske to-sidede fallback-koden under, som forutsetter et
// symmetrisk match-play-format (ville krasjet/vist feil for dette
// formatet ellers). Gjenbruker samme ScrambleSoloResultView som den
// frittstående leaderboard-siden (round-leaderboard.tsx), ikke en egen
// kondensert visning.
if (round.play_format === "scramble_solo") {
return (
<div className="mb-5">
<ScrambleSoloResultView result={buildScrambleSoloResult(round, result)} />
</div>
)
}
// To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
if (!result.ready) {
return (

View file

@ -52,8 +52,17 @@ type RoundHeaderProps = {
tabHrefs?: Record<RoundTab, string>
/** Destination for the round-management screen ("Spillere og runde"). */
manageHref?: string
/** Mock admin actions. */
onFinishRound?: () => void
/** Fullfør runde. Returnerer `null` hvis brukeren avbrøt bekreftelsen
* (dialogen skal da bare forbli åpen, ingen feilmelding), ellers
* `{ ok, message }` -- `ok: false` viser `message` i dialogen i stedet
* for å lukke den (2026-08-04 bugfiks: denne handlingen fulgte
* tidligere aldri opp om `fetch`-kallet faktisk lyktes -- dialogen
* lukket seg uansett utfall, uten feilmelding, se CHANGELOG.md). */
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
/** Når satt: "Fullfør runde"-knappen deaktiveres og denne teksten vises
* i stedet (samme oppsett-ufullstendig-sjekk som round-detail.tsx sin
* egen, uavhengige "Spillere og runde"-fane allerede håndhever). */
finishDisabledReason?: string | null
onDeleteRound?: () => void
className?: string
}
@ -73,6 +82,7 @@ export function RoundHeader({
tabHrefs,
manageHref = "#",
onFinishRound,
finishDisabledReason,
onDeleteRound,
className,
}: RoundHeaderProps) {
@ -194,6 +204,7 @@ export function RoundHeader({
manageHref={manageHref}
onClose={() => setManageOpen(false)}
onFinishRound={onFinishRound}
finishDisabledReason={finishDisabledReason}
onDeleteRound={onDeleteRound}
/>
)}
@ -207,16 +218,34 @@ function ManageRoundDialog({
manageHref,
onClose,
onFinishRound,
finishDisabledReason,
onDeleteRound,
}: {
manageHref: string
onClose: () => void
onFinishRound?: () => void
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
finishDisabledReason?: string | null
onDeleteRound?: () => void
}) {
const closeRef = useRef<HTMLButtonElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const [mounted, setMounted] = useState(false)
const [finishing, setFinishing] = useState(false)
const [finishError, setFinishError] = useState<string | null>(null)
async function handleFinish() {
if (!onFinishRound || finishing) return
setFinishing(true)
setFinishError(null)
const result = await onFinishRound()
setFinishing(false)
if (result === null) return // brukeren avbrøt bekreftelsen -- la dialogen stå
if (result.ok) {
onClose()
} else {
setFinishError(result.message ?? "Klarte ikke å fullføre runden. Prøv igjen.")
}
}
// Escape to close + focus the close button on open + lock scroll.
useEffect(() => {
@ -293,15 +322,24 @@ function ManageRoundDialog({
{/* Administrative actions */}
<div className="flex flex-col gap-2">
<Button
onClick={() => {
onFinishRound?.()
onClose()
}}
onClick={handleFinish}
disabled={finishing || Boolean(finishDisabledReason)}
title={finishDisabledReason ?? undefined}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
>
<CheckCircle2 aria-hidden="true" className="size-5" />
Fullfør runde
{finishing ? "Fullfører…" : "Fullfør runde"}
</Button>
{finishDisabledReason && (
<p className="text-sm font-medium text-muted-foreground text-pretty">
{finishDisabledReason}
</p>
)}
{finishError && (
<p role="alert" className="text-sm font-semibold text-destructive text-pretty">
{finishError}
</p>
)}
{confirmDelete ? (
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">

View file

@ -24,6 +24,7 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
// --- API-typer ---------------------------------------------------------------
@ -65,6 +66,9 @@ type ApiRoundParticipant = {
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type ApiRoundHeader = {
@ -87,6 +91,16 @@ type ApiRoundHeader = {
// derfor vist en tom/meningsløs liste for dem uansett.
const TWO_SIDED_FORMATS = [
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman",
// scramble_solo_match (migrasjon 057) -- ekte hull-for-hull matchspill
// (compute_match_state på backend-siden), samme generiske match_lead/
// match_holes_played/-remaining/is_closed/is_dormie-felter som resten
// av denne listen. Asymmetrisk lag-mot-individuell-struktur (IKKE
// fast antall spillere per side), men TwoSidedBoard/buildSideTeams
// grener allerede på FAKTISK antall spillere per side, ikke et fast
// tall -- bekreftet trygt å gjenbruke uendret. scramble_solo (stroke-
// varianten, migrasjon 056) er BEVISST IKKE her, se SCRAMBLE_SOLO_
// FORMATS under.
"scramble_solo_match",
]
// High-low-high er et SÆRTILFELLE av de to-sidede formatene -- IKKE et
// hull-ledelse-konsept ("X UP"), se HighLowBoard.
@ -94,6 +108,11 @@ const HIGH_LOW_FORMATS = new Set(["high_low_high"])
// Shamble/Money Ball -- HELE runden er ETT lag, ikke to sider -- lagets
// EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard.
const TEAM_RESULT_FORMATS = new Set(["shamble", "money_ball"])
// Scramble mot enkeltspiller (migrasjon 056) -- BEVISST IKKE i
// TWO_SIDED_FORMATS: dette er netto SLAGSPILL-sammenligning
// (net_stroke_play_margin), ikke matchspill/hull-ledelse -- egen enkel
// visning, se ScrambleSoloResultView.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo"])
// Bingo Bango Bongo/Københavner har egne poengsystem, uavhengig av rå
// slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk
// Stableford (backend regner den likt uansett format), FEIL for disse to.
@ -101,6 +120,28 @@ const FORMAT_OWN_POINTS_FIELD: Partial<Record<string, "bbb_points" | "copenhagen
bbb: "bbb_points",
copenhagen: "copenhagen_points",
}
// Egen lokal kopi per prosjektets konvensjon (liten duplisering fremfor
// cross-file-import, se samme mønster i round-detail.tsx/round-scorecard.tsx).
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
match: "Match",
skins: "Skins",
fourball: "Fourball",
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
chapman: "Chapman",
copenhagen: "Københavner",
bbb: "Bingo Bango Bongo",
flag: "Flaggturnering",
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
@ -1059,6 +1100,12 @@ type ApiFormatResult = {
money_ball_team_score: number | null
hlh_points_a: number | null
hlh_points_b: number | null
// Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
// side "a" = laget, side "b" = individualspilleren (fast, se
// _build_scramble_solo_result). margin > 0 = laget leder.
scramble_solo_team_net: number | null
scramble_solo_individual_net: number | null
scramble_solo_margin: number | null
holes: ApiFormatHole[] | null
}
@ -1079,6 +1126,54 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
return result
}
// Bygger ScrambleSoloResultView sin props-form fra det generiske
// ApiFormatResult-svaret. Hull-entryen sitt `label`-felt (satt av
// _build_scramble_solo_result til deltakerens/lagets faktiske
// visningsnavn) foretrekkes over sidens EGEN label (som bare er den
// faste "Laget"/"Individuell motstander"-teksten satt ved opprettelse,
// se new-round.tsx sin submitWizard) -- faller tilbake til sidens label
// når ingen hull er spilt ennå.
function buildScrambleSoloResult(round: ApiRoundHeader, formatResult: ApiFormatResult | null): ScrambleSoloResult {
const teamSide = round.sides.find((s) => s.side_role === "team")
const individualSide = round.sides.find((s) => s.side_role === "individual")
const firstHoleEntries = formatResult?.holes?.[0]?.entries ?? []
const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
const individualLabel =
firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"
const holes: ScrambleSoloResult["holes"] = (formatResult?.holes ?? []).map((h) => {
const teamEntry = h.entries.find((e) => e.side === "a")
const individualEntry = h.entries.find((e) => e.side === "b")
const result: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
return {
holeNumber: h.hole_number,
par: h.par,
strokeIndex: h.stroke_index,
result,
team: {
gross: teamEntry?.gross ?? 0,
net: teamEntry?.net ?? 0,
strokesReceived: teamEntry?.strokes_received ?? 0,
},
individual: {
gross: individualEntry?.gross ?? 0,
net: individualEntry?.net ?? 0,
strokesReceived: individualEntry?.strokes_received ?? 0,
},
}
})
return {
ready: formatResult?.ready ?? false,
teamLabel,
individualLabel,
teamNet: formatResult?.scramble_solo_team_net ?? null,
individualNet: formatResult?.scramble_solo_individual_net ?? null,
margin: formatResult?.scramble_solo_margin ?? null,
holes,
}
}
type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number }
function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) {
@ -1309,6 +1404,7 @@ export function RoundLeaderboard({
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
const isTeamResult = TEAM_RESULT_FORMATS.has(round.play_format)
const isScrambleSolo = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format]
return (
@ -1329,8 +1425,15 @@ export function RoundLeaderboard({
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
<span className="flex flex-wrap items-center gap-2">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</span>
{round.play_format !== "stroke" && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
{FORMAT_LABELS[round.play_format] ?? round.play_format}
</span>
)}
</span>
<span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
@ -1435,6 +1538,8 @@ export function RoundLeaderboard({
/>
)
})()
) : isScrambleSolo ? (
!formatResult ? null : <ScrambleSoloResultView result={buildScrambleSoloResult(round, formatResult)} />
) : (
(() => {
const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult)
@ -1468,16 +1573,21 @@ export function RoundLeaderboardMini({ roundId }: { roundId: string }) {
if (!round || !leaderboard || round.participants.length < 2) return null
// To-sidede formater (match/fourball/foursome/greensome/scramble/
// high_low_high) vises allerede av FormatResultPanel rett under denne
// komponenten sitt kallsted i round-detail.tsx -- ingen individuell
// brutto-rangering å vise her. Shamble/Money Ball har heller ingen
// meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er det
// som teller, vist i TeamFlightBoard på selve leaderboard-siden i
// stedet for en misvisende råscore-rangering her.
// high_low_high/scramble_solo_match) vises allerede av FormatResultPanel
// rett under denne komponenten sitt kallsted i round-detail.tsx -- ingen
// individuell brutto-rangering å vise her. Shamble/Money Ball har heller
// ingen meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er
// det som teller, vist i TeamFlightBoard på selve leaderboard-siden i
// stedet for en misvisende råscore-rangering her. scramble_solo
// (stroke-varianten) har av samme grunn ingen meningsfull individuell
// rangering -- lag-mot-individuell netto-sammenligning vises av
// ScrambleSoloResultView i stedet (fikset her, opprinnelig glemt da
// scramble_solo ble bygget 2026-08-05).
if (
TWO_SIDED_FORMATS.includes(round.play_format) ||
HIGH_LOW_FORMATS.has(round.play_format) ||
TEAM_RESULT_FORMATS.has(round.play_format)
TEAM_RESULT_FORMATS.has(round.play_format) ||
SCRAMBLE_SOLO_FORMATS.has(round.play_format)
) {
return null
}

View file

@ -22,6 +22,8 @@ type ApiRoundHeaderFields = {
played_at: string
started_at: string | null
play_format: string
setup_complete: boolean
setup_message: string | null
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
@ -52,10 +54,15 @@ export function RoundPageShell({
}
}, [roundId])
async function onFinishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
async function onFinishRound(): Promise<{ ok: boolean; message?: string } | null> {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return null
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
if (res.ok) router.refresh()
if (res.ok) {
router.refresh()
return { ok: true }
}
const body = await res.json().catch(() => null)
return { ok: false, message: body?.detail?.message ?? "Klarte ikke å fullføre runden. Prøv igjen." }
}
async function onDeleteRound() {
@ -84,6 +91,9 @@ export function RoundPageShell({
}}
manageHref={`/my-rounds/${roundId}?tab=manage`}
onFinishRound={onFinishRound}
finishDisabledReason={
!round.setup_complete ? round.setup_message ?? "Oppsettet er ikke komplett ennå." : undefined
}
onDeleteRound={onDeleteRound}
/>
)}

View file

@ -48,6 +48,9 @@ type ApiParticipant = {
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type ApiRound = {
@ -86,7 +89,17 @@ type ApiHole = {
}
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", ...SHARED_BALL_FORMATS]
// scramble_solo_match (migrasjon 057) lagt til her -- KUN denne bruken
// (isTwoSided) gater MatchScorecardGrid, som får sin egen dedikerte
// scramble-solo-familie-gren for å unngå dupliserte rader (se der).
// SHARED_BALL_FORMATS/isSharedBall RØRES bevisst ikke -- se
// SCRAMBLE_SOLO_FORMATS under for den egne, riktige klassifiseringen.
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", "scramble_solo_match", ...SHARED_BALL_FORMATS]
// Begge scramble-mot-enkeltspiller-variantene deler NØYAKTIG samme
// asymmetriske side-/enhetsstruktur (laget = side, individualspilleren =
// participant) -- kun selve resultat-sammenligningen skiller dem. Én
// samlet klassifisering brukt for alt UNNTATT selve resultatvisningen.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
@ -104,6 +117,8 @@ const FORMAT_LABELS: Record<string, string> = {
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
@ -778,22 +793,55 @@ function MatchScorecardGrid({
if (!result) return null
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
// scramble_solo_match (migrasjon 057) -- ÉN rad per SIDE (som
// delt-ball-grenen under), IKKE én per deltaker, siden laget kan ha
// vilkårlig mange medlemmer som alle deler samme TeamAverage-verdi --
// uten denne egne grenen ville "else"-grenen under vist N dupliserte
// rader for laget. Individualsidens rad viser spillerens EGET navn
// (ikke bare sidens "Individuell motstander"-label) siden den siden
// alltid har nøyaktig én deltaker.
const isScrambleSoloMatch = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
const rows: MatchRow[] = isSharedBall
? round.sides.map((s, idx) => ({
id: s.id,
side: idx === 0 ? "a" : ("b" as const),
label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B"),
hcp: round.participants.find((p) => p.round_side_id === s.id)?.playing_handicap ?? null,
}))
: round.participants
.filter((p) => p.round_side_id)
.map((p) => ({
id: p.id,
side: (p.round_side_id === round.sides[0]?.id ? "a" : "b") as "a" | "b",
label: participantLabel(p, viewerId),
hcp: p.playing_handicap,
const rows: MatchRow[] = isScrambleSoloMatch
? round.sides.map((s, idx) => {
// side="a"/"b" er POSISJONELL (idx===0 -> "a"), samme sorterte-
// etter-id-konvensjon som backend sin _build_scramble_solo_match_
// result bruker (round.sides hentes ALLTID `ORDER BY id`, samme
// sortering backend legger til grunn for "a"/"b"-tildelingen --
// IKKE en fast lag=a/individuell=b-tildeling, som ville gitt feil
// ledelsesfarge når laget ikke er den lavest-sorterte UUID-en).
// row.id må for individualsiden være DELTAKERENS id (ikke sidens),
// siden FormatHoleEntry.id for den siden er individual_
// participant["id"] på backend-siden -- ellers finner
// h.entries.find(e => e.id === row.id) under aldri en match.
const sideParticipants = round.participants.filter((p) => p.round_side_id === s.id)
const isTeam = s.side_role === "team"
const label =
!isTeam && sideParticipants.length === 1
? participantLabel(sideParticipants[0], viewerId)
: s.label?.trim() || (isTeam ? "Laget" : "Individuell motstander")
return {
id: isTeam ? s.id : (sideParticipants[0]?.id ?? s.id),
side: idx === 0 ? "a" : ("b" as const),
label,
hcp: sideParticipants[0]?.playing_handicap ?? null,
}
})
: isSharedBall
? round.sides.map((s, idx) => ({
id: s.id,
side: idx === 0 ? "a" : ("b" as const),
label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B"),
hcp: round.participants.find((p) => p.round_side_id === s.id)?.playing_handicap ?? null,
}))
: round.participants
.filter((p) => p.round_side_id)
.map((p) => ({
id: p.id,
side: (p.round_side_id === round.sides[0]?.id ? "a" : "b") as "a" | "b",
label: participantLabel(p, viewerId),
hcp: p.playing_handicap,
}))
// Lagvis gruppering (2026-08-02, "det store grepet") -- individuell-ball-
// formater (match/fourball) bygde tidligere rader i ren
@ -1079,6 +1127,19 @@ export function RoundScorecard({
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
setUnitId((prev) => {
if (prev) return prev
// scramble_solo-familien (migrasjon 056/057) -- blandet enhetstype
// (laget = en SIDE, individualspilleren = en PARTICIPANT, se
// units-bygging under). Standardvalg: individualspillerens egen
// rad hvis DET er den spørrende brukeren, ellers lagsiden.
if (SCRAMBLE_SOLO_FORMATS.has(data.play_format)) {
const individualSide = data.sides.find((s) => s.side_role === "individual")
const individualParticipant = individualSide
? data.participants.find((p) => p.round_side_id === individualSide.id)
: undefined
if (viewerId && individualParticipant?.user_id === viewerId) return individualParticipant.id
const teamSide = data.sides.find((s) => s.side_role === "team")
return teamSide?.id ?? data.sides[0]?.id ?? null
}
if (isSharedBall) {
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
@ -1101,8 +1162,15 @@ export function RoundScorecard({
useEffect(() => {
if (!unitId || !round) return
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
const endpoint = isSharedBall
// scramble_solo (migrasjon 056) har BLANDEDE enhetstyper i samme
// fane-liste (laget = side, individualspilleren = participant) -- slå
// opp faktisk type fra selve unitId i stedet for et format-bredt
// isSharedBall-flagg, som ikke kan uttrykke en blanding. Denne
// oppslags-tilnærmingen er samtidig korrekt uendret for de rene
// delt-ball-/individuell-ball-formatene (alle enheter er da alltid
// den ene eller den andre typen).
const isSideUnit = round.sides.some((s) => s.id === unitId)
const endpoint = isSideUnit
? `/rounds/${roundId}/sides/${unitId}/holes`
: `/rounds/${roundId}/participants/${unitId}/holes`
let cancelled = false
@ -1140,12 +1208,14 @@ export function RoundScorecard({
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
const isSkins = round.play_format === "skins"
const isScrambleSolo = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
const formatLabel = FORMAT_LABELS[round.play_format] ?? round.play_format
// Delt-ball-formater trenger sidene opprettet (via runde-siden sin
// "Spillere og runde"-fane) FØR noe scorekort gir mening her -- vis en
// forklarende melding i stedet for en evig lastespinner.
if (isSharedBall && round.sides.length < 2) {
// Delt-ball-formater OG scramble_solo trenger sidene opprettet (via
// runde-siden sin "Spillere og runde"-fane) FØR noe scorekort gir
// mening her -- vis en forklarende melding i stedet for en evig
// lastespinner.
if ((isSharedBall || isScrambleSolo) && round.sides.length < 2) {
return (
<div className={cn(!embedded && "min-h-dvh bg-background")}>
{!embedded && (
@ -1163,17 +1233,35 @@ export function RoundScorecard({
)}
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene selve
runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.
{isScrambleSolo
? `${formatLabel} krever at både laget og individualspilleren er satt opp som egne sider. Opprett dem på selve runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.`
: `${formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.`}
</div>
</main>
</div>
)
}
const units: { id: string; label: string }[] = isSharedBall
? round.sides.map((s, idx) => ({ id: s.id, label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B") }))
: round.participants.map((p) => ({ id: p.id, label: participantLabel(p, viewerId) }))
// scramble_solo (migrasjon 056) -- blandet enhetsliste: laget er en
// SIDE-fane (delt ball), individualspilleren er en PARTICIPANT-fane
// (egen ball) -- IKKE lagt til SHARED_BALL_FORMATS, det ville feilaktig
// behandlet BEGGE fanene som sider (se hull-hente-effekten over, som
// slår opp faktisk type fra unitId).
const units: { id: string; label: string }[] = isScrambleSolo
? (() => {
const teamSide = round.sides.find((s) => s.side_role === "team")
const individualSide = round.sides.find((s) => s.side_role === "individual")
const individualParticipant = individualSide
? round.participants.find((p) => p.round_side_id === individualSide.id)
: undefined
const list: { id: string; label: string }[] = []
if (teamSide) list.push({ id: teamSide.id, label: teamSide.label?.trim() || "Laget" })
if (individualParticipant) list.push({ id: individualParticipant.id, label: participantLabel(individualParticipant, viewerId) })
return list
})()
: isSharedBall
? round.sides.map((s, idx) => ({ id: s.id, label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B") }))
: round.participants.map((p) => ({ id: p.id, label: participantLabel(p, viewerId) }))
if (!holes) {
return (
@ -1270,7 +1358,7 @@ export function RoundScorecard({
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */}
{units.length > 1 && (
<div role="tablist" aria-label={isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
<div role="tablist" aria-label={isScrambleSolo ? "Velg lag eller motstander" : isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
<div className="flex gap-2">
{units.map((u) => {
const active = u.id === unitId

View file

@ -407,6 +407,8 @@ const FORMAT_LABELS: Record<string, string> = {
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
chapman: "Chapman",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
type ApiHole = {

View file

@ -0,0 +1,350 @@
"use client"
// Resultatvisning for "Scramble mot enkeltspiller" (migrasjon 056,
// 2026-08-05) -- ren V0-eksport (zip 31), uendret fra levert komponent.
// Adapter fra det generiske API-svaret (ApiFormatResult) til denne
// komponentens ScrambleSoloResult-props ligger i round-leaderboard.tsx
// (buildScrambleSoloResult) og round-detail.tsx (lokal kopi, samme navn).
import { Check, Flag, Minus } from "lucide-react"
import { cn } from "@/lib/utils"
// ---------------------------------------------------------------------------
// Data shape (matches the API response this component is built around)
// ---------------------------------------------------------------------------
export interface ScrambleSoloHole {
holeNumber: number
par: number
strokeIndex: number
result: "a" | "b" | "halved" // "a" = team won hole, "b" = individual won, "halved" = tied
team: { gross: number; net: number; strokesReceived: number }
individual: { gross: number; net: number; strokesReceived: number }
}
export interface ScrambleSoloResult {
ready: boolean
teamLabel: string
individualLabel: string
teamNet: number | null
individualNet: number | null
margin: number | null // individualNet - teamNet; positive = team leads, negative = individual leads, 0 = tied
holes: ScrambleSoloHole[]
}
type Leader = "team" | "individual" | "tie"
function leaderFromMargin(margin: number | null): Leader {
if (margin === null) return "tie"
if (margin > 0) return "team"
if (margin < 0) return "individual"
return "tie"
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ScrambleSoloResultView({ result }: { result: ScrambleSoloResult }) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4">
{result.ready ? (
<>
<HeadlineCard result={result} />
<HoleLedger result={result} />
</>
) : (
<WaitingState />
)}
</div>
)
}
// --- Waiting state ---------------------------------------------------------
function WaitingState() {
return (
<section
className="flex flex-col items-center gap-3 rounded-3xl border border-border bg-card p-8 text-center shadow-md shadow-black/8"
aria-live="polite"
>
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<Flag aria-hidden="true" className="size-7" />
</span>
<h2 className="text-xl font-bold text-foreground text-balance">
Venter at oppsettet blir fullført
</h2>
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
Resultatet vises snart handicap og lag er ferdig satt opp for denne runden.
</p>
</section>
)
}
// --- Headline card ---------------------------------------------------------
function HeadlineCard({ result }: { result: ScrambleSoloResult }) {
const leader = leaderFromMargin(result.margin)
const strokes = result.margin === null ? 0 : Math.abs(result.margin)
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<MarginBanner leader={leader} strokes={strokes} result={result} />
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2">
<SideTotal
label={result.teamLabel}
kind="team"
net={result.teamNet}
leading={leader === "team"}
/>
<div className="flex items-center justify-center">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">mot</span>
</div>
<SideTotal
label={result.individualLabel}
kind="individual"
net={result.individualNet}
leading={leader === "individual"}
/>
</div>
</section>
)
}
function MarginBanner({
leader,
strokes,
result,
}: {
leader: Leader
strokes: number
result: ScrambleSoloResult
}) {
let text: string
let tone: "team" | "individual" | "tie"
if (leader === "tie") {
text = "Uavgjort"
tone = "tie"
} else if (leader === "team") {
text = `${result.teamLabel} leder med ${strokes} ${strokes === 1 ? "slag" : "slag"}`
tone = "team"
} else {
text = `${result.individualLabel} leder med ${strokes} slag`
tone = "individual"
}
return (
<div
className={cn(
"flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-center",
tone === "team" && "border-primary/40 bg-primary/10",
tone === "individual" && "border-brand-orange/40 bg-brand-orange/10",
tone === "tie" && "border-border bg-muted/60",
)}
aria-live="polite"
>
{tone === "tie" ? (
<Minus aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
) : (
<Check
aria-hidden="true"
className={cn(
"size-5 shrink-0",
tone === "team" ? "text-primary" : "text-brand-orange",
)}
/>
)}
<span
className={cn(
"text-lg font-extrabold text-balance",
tone === "team" && "text-primary",
tone === "individual" && "text-brand-orange",
tone === "tie" && "text-foreground",
)}
>
{text}
</span>
</div>
)
}
function SideTotal({
label,
kind,
net,
leading,
}: {
label: string
kind: "team" | "individual"
net: number | null
leading: boolean
}) {
return (
<div
className={cn(
"flex flex-col items-center gap-1 rounded-2xl border p-4 text-center transition-colors",
leading && kind === "team" && "border-primary/40 bg-primary/10",
leading && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
!leading && "border-border bg-card",
)}
>
<span
className={cn(
"line-clamp-2 text-base font-bold leading-tight text-pretty",
leading && kind === "team" && "text-primary",
leading && kind === "individual" && "text-brand-orange",
!leading && "text-foreground",
)}
>
{label}
</span>
<span
className={cn(
"text-5xl font-extrabold tabular-nums",
leading && kind === "team" && "text-primary",
leading && kind === "individual" && "text-brand-orange",
!leading && "text-foreground",
)}
>
{net ?? ""}
</span>
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
netto
</span>
{leading && (
<span
className={cn(
"mt-0.5 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
kind === "team" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
)}
>
Leder
</span>
)}
</div>
)
}
// --- Hole-by-hole ledger ---------------------------------------------------
function HoleLedger({ result }: { result: ScrambleSoloResult }) {
return (
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-3 px-1">
<h2 className="text-lg font-bold text-foreground">Hull for hull</h2>
<span className="text-sm text-muted-foreground">
{result.holes.length} {result.holes.length === 1 ? "hull spilt" : "hull spilt"}
</span>
</div>
{/* Column legend so the two net columns are unambiguous */}
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 px-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-14">Hull</span>
<span className="truncate text-right">{result.teamLabel}</span>
<span className="truncate text-right">{result.individualLabel}</span>
</div>
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{result.holes.map((hole) => (
<HoleRow key={hole.holeNumber} hole={hole} />
))}
</ol>
</section>
)
}
function HoleRow({ hole }: { hole: ScrambleSoloHole }) {
return (
<li className="grid grid-cols-[auto_1fr_1fr] items-stretch gap-2 p-3">
{/* Hole meta */}
<div className="flex w-14 flex-col justify-center">
<span className="text-lg font-extrabold tabular-nums text-foreground">{hole.holeNumber}</span>
<span className="text-xs text-muted-foreground tabular-nums">
Par {hole.par}
<span className="sr-only">, </span> · idx {hole.strokeIndex}
</span>
</div>
<SideCell
gross={hole.team.gross}
net={hole.team.net}
received={hole.team.strokesReceived}
won={hole.result === "a"}
halved={hole.result === "halved"}
kind="team"
/>
<SideCell
gross={hole.individual.gross}
net={hole.individual.net}
received={hole.individual.strokesReceived}
won={hole.result === "b"}
halved={hole.result === "halved"}
kind="individual"
/>
</li>
)
}
function SideCell({
gross,
net,
received,
won,
halved,
kind,
}: {
gross: number
net: number
received: number
won: boolean
halved: boolean
kind: "team" | "individual"
}) {
const wonLabel = won
? kind === "team"
? "Laget vant hullet"
: "Utfordreren vant hullet"
: halved
? "Delt hull"
: undefined
return (
<div
className={cn(
"flex flex-col items-end justify-center gap-0.5 rounded-xl border px-3 py-2",
won && kind === "team" && "border-primary/40 bg-primary/10",
won && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
!won && halved && "border-dashed border-border bg-muted/40",
!won && !halved && "border-transparent bg-transparent",
)}
>
{/* Net (the number that decides the hole) — largest, bold */}
<div className="flex items-center gap-1.5">
{won && (
<Check
aria-hidden="true"
className={cn("size-4 shrink-0", kind === "team" ? "text-primary" : "text-brand-orange")}
/>
)}
<span
className={cn(
"text-2xl font-extrabold tabular-nums leading-none",
won && kind === "team" && "text-primary",
won && kind === "individual" && "text-brand-orange",
!won && "text-foreground",
)}
>
{net}
</span>
</div>
{/* Gross + strokes received context */}
<span className="text-xs text-muted-foreground tabular-nums">
Brutto {gross}
{received > 0 && <> · {received} slag</>}
</span>
{wonLabel && <span className="sr-only">{wonLabel}</span>}
</div>
)
}

View file

@ -70,16 +70,6 @@ type ApiTournamentClass = {
default_tee_name: string | null
}
type ApiCourse = {
id: string
name: string
}
type ApiTee = {
id: string
name: string
}
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
// app-ens merkevare-grønn/oransje.
const TEAM_COLORS: { value: string; label: string }[] = [
@ -107,7 +97,6 @@ export function TournamentDetail({
const [joinCode, setJoinCode] = useState<string | null>(null)
const [status, setStatus] = useState<TournamentStatus | null>(null)
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -115,7 +104,7 @@ export function TournamentDetail({
let cancelled = false
async function load() {
try {
const [teamsRes, poolRes, tournamentsRes, classesRes, coursesRes] = await Promise.all([
const [teamsRes, poolRes, tournamentsRes, classesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
credentials: "include",
}),
@ -124,7 +113,6 @@ export function TournamentDetail({
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
credentials: "include",
}),
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
])
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
const teamsData: { id: string; name: string; color: string | null }[] =
@ -143,7 +131,6 @@ export function TournamentDetail({
}
}
if (classesRes.ok) setClasses(await classesRes.json())
if (coursesRes.ok) setCourses(await coursesRes.json())
const withRosters = await Promise.all(
teamsData.map(async (t) => {
@ -300,53 +287,6 @@ export function TournamentDetail({
}
}
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) throw new Error(`create class: ${res.status}`)
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
} catch {
setError("Klarte ikke å opprette klassen. Prøv igjen.")
}
}
async function deleteClass(classId: string) {
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
setClasses((prev) => prev.filter((c) => c.id !== classId))
// Roster-rader som pekte på klassen mister den lokalt også (backend
// satte allerede class_id=NULL via ON DELETE SET NULL) -- unngår en
// hengende, ugyldig klasse-referanse i UI-et inntil neste full reload.
setTeams(
(prev) =>
prev.map((t) =>
t
? {
...t,
roster: t.roster.map((r) =>
r.class_id === classId ? { ...r, class_id: null, class_name: null } : r,
),
}
: t,
) as [Team | null, Team | null],
)
} catch {
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
}
}
async function removePlayer(teamId: string, rosterId: string) {
setError(null)
try {
@ -527,13 +467,6 @@ export function TournamentDetail({
</div>
) : (
<>
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={createClass}
onDelete={deleteClass}
/>
<div className="grid gap-4 md:grid-cols-2">
{([0, 1] as const).map((slot) => {
const team = teams[slot]
@ -608,161 +541,6 @@ function JoinCodeChip({ code }: { code: string }) {
// --- Create team (State A) -------------------------------------------------
// --- Konkurranseklasser (2026-08-03) ----------------------------------------
// Fritt navngitte klasser med et valgfritt standardutslag -- se
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => void
onDelete: (classId: string) => void
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState("")
const [teeId, setTeeId] = useState("")
const [tees, setTees] = useState<ApiTee[]>([])
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!courseId) {
setTees([])
setTeeId("")
return
}
let cancelled = false
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTee[]) => {
if (!cancelled) {
setTees(data)
setTeeId(data[0]?.id ?? "")
}
})
return () => {
cancelled = true
}
}, [organizationId, courseId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (name.trim().length === 0 || adding) return
setAdding(true)
await onCreate(name.trim(), teeId || null)
setAdding(false)
setName("")
setCourseId("")
setTeeId("")
}
return (
<section className="mb-4 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">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
<p className="text-xs text-muted-foreground text-pretty">
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
</p>
</div>
</div>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{c.name}</span>
{c.default_tee_name && (
<span className="text-xs text-muted-foreground">
Standardutslag: {c.default_tee_name}
</span>
)}
</div>
<button
type="button"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-name">Klassenavn</Label>
<Input
id="class-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Damer"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
<select
id="class-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
<option value="">Ingen standardutslag</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{tees.length > 0 && (
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-tee">Standardutslag</Label>
<select
id="class-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
)}
<Button
type="submit"
variant="outline"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
function CreateTeamCard({
slot,
onCreate,

View file

@ -31,6 +31,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { CourseTemplatePicker } from "@/components/course-template-editor"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
@ -68,6 +69,25 @@ type ApiCourse = {
source: string
}
type ApiTee = {
id: string
name: string
}
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag brukt til å forhåndsutfylle riktig utslag når en spiller
// legges til en match (session-blind-draw.tsx). Ren utslag-bekvemmelighet
// her -- INGEN egen resultatliste for lagturneringer (bekreftet med
// bruker: poeng er knyttet til hele kamper, ikke enkeltspillere). Flyttet
// hit fra tournament-detail.tsx (2026-08-04) -- gir ingen mening å velge
// et standardutslag før en bane faktisk finnes i programmet.
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | null
}
type ApiSession = {
id: string
sequence: number
@ -128,6 +148,7 @@ export function TournamentProgram({
}) {
const [sessions, setSessions] = useState<ApiSession[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -135,11 +156,14 @@ export function TournamentProgram({
let cancelled = false
async function load() {
try {
const [sessionsRes, coursesRes] = await Promise.all([
const [sessionsRes, coursesRes, classesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
credentials: "include",
}),
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
credentials: "include",
}),
])
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
const sessionsData: ApiSession[] = await sessionsRes.json()
@ -147,6 +171,7 @@ export function TournamentProgram({
if (cancelled) return
setSessions(sessionsData)
setCourses(coursesData)
if (classesRes.ok) setClasses(await classesRes.json())
} catch {
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
} finally {
@ -159,6 +184,37 @@ export function TournamentProgram({
}
}, [organizationId, tournamentId])
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) throw new Error(`create class: ${res.status}`)
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
} catch {
setError("Klarte ikke å opprette klassen. Prøv igjen.")
}
}
async function deleteClass(classId: string) {
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
setClasses((prev) => prev.filter((c) => c.id !== classId))
} catch {
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
}
}
const coursesById = useMemo(() => {
const map = new Map<string, ApiCourse>()
for (const c of courses) map.set(c.id, c)
@ -384,6 +440,16 @@ export function TournamentProgram({
onCreate={addSession}
/>
</div>
<div className="mt-6">
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={createClass}
onDelete={deleteClass}
/>
</div>
</>
)}
</main>
@ -391,6 +457,163 @@ export function TournamentProgram({
)
}
// --- Konkurranseklasser (2026-08-03, flyttet hit 2026-08-04) ---------------
// Fritt navngitte klasser med et valgfritt standardutslag -- se
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
// Plassert her, ETTER økt-/baneoppsettet over, siden standardutslaget er
// meningsløst å velge før man vet hvilke utslag banen faktisk har.
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => void
onDelete: (classId: string) => void
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState("")
const [teeId, setTeeId] = useState("")
const [tees, setTees] = useState<ApiTee[]>([])
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!courseId) {
setTees([])
setTeeId("")
return
}
let cancelled = false
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTee[]) => {
if (!cancelled) {
setTees(data)
setTeeId(data[0]?.id ?? "")
}
})
return () => {
cancelled = true
}
}, [organizationId, courseId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (name.trim().length === 0 || adding) return
setAdding(true)
await onCreate(name.trim(), teeId || null)
setAdding(false)
setName("")
setCourseId("")
setTeeId("")
}
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">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
<p className="text-xs text-muted-foreground text-pretty">
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
</p>
</div>
</div>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{c.name}</span>
{c.default_tee_name && (
<span className="text-xs text-muted-foreground">
Standardutslag: {c.default_tee_name}
</span>
)}
</div>
<button
type="button"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-name">Klassenavn</Label>
<Input
id="class-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Damer"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
<select
id="class-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
<option value="">Ingen standardutslag</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{tees.length > 0 && (
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-tee">Standardutslag</Label>
<select
id="class-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
)}
<Button
type="submit"
variant="outline"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
// --- Session card (timeline item) ------------------------------------------
function SessionCard({
@ -776,6 +999,7 @@ function EditCourseField({
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
if (!open) {
return (
@ -849,15 +1073,24 @@ function EditCourseField({
)}
</ul>
{!officialSearchOpen ? (
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
) : (
{!officialSearchOpen && !templatePickerOpen ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<button
type="button"
onClick={() => setTemplatePickerOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull/utslag i stedet
</button>
</div>
) : officialSearchOpen ? (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
@ -867,6 +1100,17 @@ function EditCourseField({
setOfficialSearchOpen(false)
}}
/>
) : (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(created) => {
const course = { id: created.id, name: created.name, source: "custom" }
onCourseImported(course)
select(course)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</div>
)
@ -970,6 +1214,7 @@ function CreateSessionCard({
const [courseQuery, setCourseQuery] = useState("")
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [useHandicap, setUseHandicap] = useState(true)
@ -1186,13 +1431,22 @@ function CreateSessionCard({
</ul>
</div>
)}
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<button
type="button"
onClick={() => setTemplatePickerOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull/utslag i stedet
</button>
</div>
{officialSearchOpen && (
<OfficialCourseSearch
organizationId={organizationId}
@ -1204,6 +1458,17 @@ function CreateSessionCard({
}}
/>
)}
{templatePickerOpen && (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(course) => {
onCourseImported({ id: course.id, name: course.name, source: "custom" })
setSelectedCourseId(course.id)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</div>
)}
</div>

View file

@ -95,6 +95,8 @@ const FORMAT_LABELS: Record<string, string> = {
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const TWO_SIDED_FORMATS = new Set([
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",

File diff suppressed because one or more lines are too long

View file

@ -397,6 +397,23 @@ class RankedSplit:
return round_half_up(total)
@dataclass(frozen=True)
class TeamAverage:
"""Enkelt gjennomsnitt av ALLE lagmedlemmers course handicap, avrundet.
Brukt for `scramble_solo` (scramble-lag mot enkeltspiller, variabel
lagstørrelse N>=2) -- bevisst UAVHENGIG av RankedSplit (scramble_2/
scramble_4 sine faste 35/15-/25/20/15/10-tabeller), som forutsetter
FAST lagstørrelse og ikke er kildebelagt for vilkårlig N. Ingen
rangering/vekting her, rent snitt -- bekreftet med bruker 2026-08-05
som en bevisst ny, enkel TeeCup-regel, ikke hentet fra HCP-kildene.
"""
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
if len(course_handicaps) < 2:
raise ValueError("TeamAverage forventer minst to spillere")
return round_half_up(sum(course_handicaps) / len(course_handicaps))
# ---------------------------------------------------------------------------
# Format-register (STANDARDVERDIER — kan overstyres per turnering)
# ---------------------------------------------------------------------------
@ -536,6 +553,19 @@ def stableford_total(pars: Sequence[int], gross_strokes: Sequence[int], strokes_
)
# ---------------------------------------------------------------------------
# To-sidet netto-slagspill-sammenligning (scramble_solo): "laveste totalsum
# vinner", INGEN hull-for-hull-tilstand -- eksplisitt SØSKEN til, ikke del
# av, match-status-seksjonen under (som er for ekte match play). Hver sides
# nettosum beregnes med de allerede eksisterende allocate_strokes_by_index/
# stroke_play_net_total over -- ingen ny funksjon trengs for selve summen.
# ---------------------------------------------------------------------------
def net_stroke_play_margin(net_total_a: int, net_total_b: int) -> int:
"""Positiv = A leder (A har LAVEST nettosum), negativ = B leder, 0 = delt."""
return net_total_b - net_total_a
# ---------------------------------------------------------------------------
# Match-status ("2 UP", "dormie", "3&2", "AS")
# ---------------------------------------------------------------------------
@ -935,3 +965,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"/
"down"-terminologi), summert over alle spilte hull."""
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) 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)

69
teecup-beskrivelse.md Normal file
View file

@ -0,0 +1,69 @@
# TeeCup — hva det er, og hva det skal bli
## Kort sagt
TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget
for golfklubber, bedrifter og vennegjenger som spiller sammen. Navnet
smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup"
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en
konkurransedyktig runde, i ett ord.
## Hvor det startet
TeeCup begynte som en avlegger av teeoff.no (banedata/booking), bygget
rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull.
Det har siden vokst til en selvstendig, kommersiell produkt med egen
identitet (eget domene, teecup.golf, egen innlogging uavhengig av
teeoff) — solgt til klubber og bedrifter som kanskje aldri har hørt om
moderselskapet.
## Hva det er i dag
- **Ni turneringsformater** og voksende: fra vanlig slagspill og
Stableford, via klassiske to-mot-to-formater (Fourball, Foursome,
Greensome, Scramble), til mer eksotiske varianter som Chapman,
Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high
— og nyeste tilskudd: et scramble-lag mot én enkeltspiller.
- **Ekte, live scoreføring** — spillere fører slag hull for hull fra
telefonen sin, ute på banen. Leaderboard oppdaterer seg i sanntid.
- **Ekte handicap-system** — full WHS-indeksberegning (Score
Differential, Net Double Bogey, capping), ikke en forenklet
tilnærming.
- **Turneringer over tid** — individuelle turneringer over flere
runder, og en sesong-sammenlagt rangering (Order of Merit) på tvers
av flere arrangementer.
- **Sosialt lag** — venner, delte runder, følg-live for tilskuere,
personlig spillerprofil og historikk.
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
med push-varsler.
## Personlighet
Varm og energisk — klubbhus-stemning, ikke countryklubb-snobberi.
Vennlig og tilgjengelig fremfor bedriftsaktig eller luksuriøs. Trygg og
tydelig, men lekent — bygget for folk som spiller golf med venner en
lørdag, ikke bare turneringsproffer. Appen er dessuten bevisst bygget
for å være lesbar og betjenbar for noen med noe redusert syn, uten
briller — stor skrift, god kontrast, store trykkflater. Det skal gjerne
gjenspeiles i hvor ryddig og lesbar en logo er, selv i liten størrelse.
## Hva det skal bli
TeeCup fortsetter å utvide hvor mange måter man kan spille turnering på
— flere formater legges til etter hvert som brukere ber om dem, inkl.
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda
mer av det en golfklubb faktisk trenger for å drive turneringer — ikke
bare scoreføring, men hele reisen fra oppsett til sesongrangering.
Målet er å være det naturlige valget for klubber og bedrifter som vil
ha noe raskere og triveligere enn tunge, gamle turneringssystemer.
## Praktisk for logoen
- Farge-arven: frisk grønn (#8bc24a) og en varm oransje (#ff5722) er de
to kjernefargene i produktet i dag — nyttig referanse, ikke en
tvangstrøye for selve merket.
- Skal IKKE minne om eller gjenbruke teeoff.no sin logo — TeeCup er et
eget, selvstendig produkt.
- Må fungere som et lite app-ikon (hjemskjerm/favicon) like godt som en
ordmerke — les godt i miniatyr, ikke bare i stort format.

View file

@ -19,6 +19,7 @@ from handicap_engine import (
CombinedPercentage,
WeightedLowHigh,
RankedSplit,
TeamAverage,
DEFAULT_MATCHPLAY_ALLOWANCES,
adjusted_gross_score,
allocate_strokes_by_index,
@ -32,6 +33,7 @@ from handicap_engine import (
handicap_index_from_differentials,
low_handicap_index,
match_play_strokes,
net_stroke_play_margin,
compute_skins,
compute_skins_detail,
copenhagen_points_for_hole,
@ -45,6 +47,8 @@ from handicap_engine import (
money_ball_hole_score,
high_low_high_points_for_hole,
high_low_high_running_score,
order_of_merit_points_for_position,
order_of_merit_aggregate,
max_hole_score_for_handicap,
net_par,
round_counts_for_handicap,
@ -174,6 +178,39 @@ def test_scramble_2_ranked_split():
assert unit_playing_handicap(players, strat) == 5
def test_team_average_two_players():
# CH 6 og 18 -> (6+18)/2 = 12
players = [Player("A", 18.0, 113, 72.0, 72), Player("B", 6.0, 113, 72.0, 72)]
assert unit_playing_handicap(players, TeamAverage()) == 12
def test_team_average_three_players_rounds_half_up():
# CH 8, 14, 20 -> (8+14+20)/3 = 14,0 -> 14
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([8, 14, 20])]
assert unit_playing_handicap(players, TeamAverage()) == 14
def test_team_average_five_players_rounds_up_at_half():
# CH 5, 6, 7, 8, 9 -> sum 35 / 5 = 7,0 -> 7. Rekkefølge irrelevant.
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([9, 5, 8, 6, 7])]
assert unit_playing_handicap(players, TeamAverage()) == 7
def test_team_average_rounding_half_up_boundary():
# CH 4 og 5 -> 4,5 -> 5 (halvveis alltid opp, samme regel som round_half_up)
players = [Player("A", 4.0, 113, 72.0, 72), Player("B", 5.0, 113, 72.0, 72)]
assert unit_playing_handicap(players, TeamAverage()) == 5
def test_team_average_rejects_single_player():
try:
TeamAverage().playing_handicap([10.0])
except ValueError:
pass
else:
raise AssertionError("TeamAverage skulle avvist én spiller")
def test_allowance_is_configurable_not_hardcoded():
"""ADR-005: motoren skal godta en overstyrt allowance (f.eks. 75 %/3/4)."""
strat_75 = PerPlayerPercentage(0.75)
@ -317,6 +354,25 @@ def test_match_state_side_b_leads():
assert state.describe() == "1 UP (B)"
# ---------------------------------------------------------------------------
# Netto-slagspill-sammenligning mellom to sider (scramble_solo) -- SØSKEN
# til match-status over, ikke en del av den: ren totalsum-sammenligning,
# ingen hull-for-hull-tilstand.
# ---------------------------------------------------------------------------
def test_net_stroke_play_margin_a_wins():
# A har lavest nettosum -> A leder.
assert net_stroke_play_margin(net_total_a=68, net_total_b=72) == 4
def test_net_stroke_play_margin_b_wins():
assert net_stroke_play_margin(net_total_a=75, net_total_b=70) == -5
def test_net_stroke_play_margin_tie():
assert net_stroke_play_margin(net_total_a=71, net_total_b=71) == 0
# ---------------------------------------------------------------------------
# HCP-indeksberegning fra spilte runder (ADR-033)
#
@ -932,6 +988,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
# ---------------------------------------------------------------------------