Compare commits
7 commits
50653303f4
...
038516d236
| Author | SHA1 | Date | |
|---|---|---|---|
| 038516d236 | |||
| c5455d0220 | |||
| 158699fb36 | |||
| ebd60da746 | |||
| fea701091c | |||
| aee1662de2 | |||
| 828b508c53 |
44 changed files with 7118 additions and 515 deletions
|
|
@ -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",
|
||||
|
|
|
|||
23
054_personal_course_provenance.sql
Normal file
23
054_personal_course_provenance.sql
Normal 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
117
055_order_of_merit.sql
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Order of Merit: sesong-sammenlagt rangering på tvers av
|
||||
-- flere individuelle turneringer (migrasjon 055, ADR-043 [OOM])
|
||||
-- =====================================================================
|
||||
-- Bygges i seks stadier (se plan-loggen 2026-08-04) -- denne migrasjonen
|
||||
-- dekker skjemaet for ALLE stadier på én gang (billig å legge til
|
||||
-- kolonner nå, dyrere å migrere skjema seks ganger), men selve
|
||||
-- BACKEND-logikken bygges stadium for stadium, scratch-verifisert
|
||||
-- mellom hvert.
|
||||
--
|
||||
-- order_of_merit: selve OOM-en. `kind` ('player'|'team') låses etter
|
||||
-- opprettelse i app-laget, som GolfBox sin egen tilsvarende låsing.
|
||||
-- `result_type` (poeng/stableford/brutto/netto/pengeliste) og
|
||||
-- `aggregation_mode` (sum/snitt/eclectic) er de to uavhengige aksene
|
||||
-- GolfBox sitt gode mønster besto av -- HVA telles vs. HVORDAN det
|
||||
-- summeres. `count_best_n`/`min_results_required`/`birth_year_from/to`
|
||||
-- er alle valgfrie grenser (NULL = ingen grense), lagt til fra
|
||||
-- stadium 3, men kolonnene finnes fra start.
|
||||
-- order_of_merit_link: hvilke individuelle turneringer (ADR-037) som
|
||||
-- teller mot en gitt OOM, pluss per-lenke-konfig for resultattyper
|
||||
-- som trenger det (poeng-tabell, pengepremie-tabell) -- disse to
|
||||
-- JSONB-feltene er NULL med mindre `result_type` faktisk krever dem
|
||||
-- (håndhevet i app-laget, ikke en CHECK, siden reglene er
|
||||
-- resultattype-avhengige).
|
||||
-- order_of_merit_team/_team_member: KUN for kind='team' -- et
|
||||
-- sesong-lag satt opp DIREKTE i OOM-en (ikke hentet fra noen
|
||||
-- turnering), som summerer/velger-beste-N-av medlemmenes ALLEREDE
|
||||
-- beregnede individuelle OOM-resultater. Bevisst IKKE koblet til
|
||||
-- `team`/`team_roster` (ADR-011s lagturneringer) -- disse er
|
||||
-- strukturelt urelaterte konsepter (ett er en enkelt-turnering-
|
||||
-- kamp-side, det andre er en sesong-lang gruppering av spillere).
|
||||
--
|
||||
-- Bevisst forenklet vs. GolfBox (ikke glemt, kan utvides senere uten
|
||||
-- brudd): ingen gjenbrukbart "grense-mal"-system på tvers av flere OOM
|
||||
-- (hver OOM har sine egne grenser direkte på raden); ingen "verst N"
|
||||
-- eller kombinert beste+verst, kun "behold de N beste".
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
CREATE TABLE order_of_merit (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL REFERENCES organization (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
kind text NOT NULL CHECK (kind IN ('player', 'team')),
|
||||
result_type text NOT NULL CHECK (result_type IN ('points', 'stableford', 'gross', 'net', 'money')),
|
||||
aggregation_mode text NOT NULL DEFAULT 'sum' CHECK (aggregation_mode IN ('sum', 'average', 'eclectic')),
|
||||
count_best_n smallint CHECK (count_best_n IS NULL OR count_best_n >= 1),
|
||||
min_results_required smallint CHECK (min_results_required IS NULL OR min_results_required >= 1),
|
||||
birth_year_from smallint,
|
||||
birth_year_to smallint,
|
||||
public_visible boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (organization_id, id),
|
||||
-- Eclectic krever per-hull-granularitet -- poeng/pengeliste har ingen
|
||||
-- "beste hull" å hente (samme begrensning GolfBox selv håndhever).
|
||||
CHECK (aggregation_mode <> 'eclectic' OR result_type IN ('stableford', 'gross', 'net'))
|
||||
);
|
||||
CREATE INDEX ON order_of_merit (organization_id);
|
||||
|
||||
CREATE TABLE order_of_merit_link (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL,
|
||||
order_of_merit_id uuid NOT NULL REFERENCES order_of_merit (id) ON DELETE CASCADE,
|
||||
tournament_id uuid NOT NULL,
|
||||
-- Kun result_type='points': liste av heltall, indeks 0 = poeng for
|
||||
-- 1. plass, indeks 1 = 2. plass, osv.
|
||||
points_table jsonb,
|
||||
-- Kun result_type='money': premiepott + utbetaling per plassering.
|
||||
money_pool_total numeric(10, 2),
|
||||
money_payout_table jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (organization_id, tournament_id) REFERENCES tournament (organization_id, id) ON DELETE CASCADE,
|
||||
UNIQUE (order_of_merit_id, tournament_id)
|
||||
);
|
||||
CREATE INDEX ON order_of_merit_link (organization_id, order_of_merit_id);
|
||||
|
||||
CREATE TABLE order_of_merit_team (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL,
|
||||
order_of_merit_id uuid NOT NULL REFERENCES order_of_merit (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (order_of_merit_id, name)
|
||||
);
|
||||
CREATE INDEX ON order_of_merit_team (organization_id, order_of_merit_id);
|
||||
|
||||
CREATE TABLE order_of_merit_team_member (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL,
|
||||
order_of_merit_team_id uuid NOT NULL REFERENCES order_of_merit_team (id) ON DELETE CASCADE,
|
||||
player_id uuid NOT NULL REFERENCES player (id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (order_of_merit_team_id, player_id)
|
||||
);
|
||||
CREATE INDEX ON order_of_merit_team_member (organization_id, order_of_merit_team_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Row-Level Security (samme org_isolation-policy-mønster som 040/053)
|
||||
-- ---------------------------------------------------------------------
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'order_of_merit', 'order_of_merit_link',
|
||||
'order_of_merit_team', 'order_of_merit_team_member'
|
||||
]
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t);
|
||||
EXECUTE format($p$
|
||||
CREATE POLICY org_isolation ON %I
|
||||
USING (organization_id = current_setting('app.current_org', true)::uuid)
|
||||
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
|
||||
$p$, t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
45
056_scramble_solo_round.sql
Normal file
45
056_scramble_solo_round.sql
Normal 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;
|
||||
22
057_scramble_solo_match_round.sql
Normal file
22
057_scramble_solo_match_round.sql
Normal 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'));
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
730
CHANGELOG.md
730
CHANGELOG.md
|
|
@ -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å
|
||||
RÅ `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å.
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) ---------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,19 +1060,18 @@ class LeaderboardEntry(BaseModel):
|
|||
rounds: list[RoundCellOut] = []
|
||||
|
||||
|
||||
@router.get(
|
||||
# IKKE /leaderboard -- den stien er allerede tournaments.py sitt
|
||||
# LAG-leaderboard (points_side_a/b, forutsetter nøyaktig to lag). Samme
|
||||
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
||||
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
||||
response_model=list[LeaderboardEntry],
|
||||
)
|
||||
async def individual_leaderboard(
|
||||
tournament_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[LeaderboardEntry]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
async def _compute_individual_standings(conn, tournament_id: str) -> tuple[dict, list[LeaderboardEntry]]:
|
||||
"""Delt mellom `individual_leaderboard` under (denne ENE turneringens
|
||||
egen visning) og Order of Merit sitt leaderboard-endepunkt
|
||||
(`order_of_merit.py`, som summerer resultater over FLERE lenkede
|
||||
turneringer) -- faktorisert ut 2026-08-04 for å unngå å duplisere
|
||||
posisjon-/til-par-beregningen (inkl. den til-par-normaliserte
|
||||
rangeringsnøkkelen, se kommentar i `_attach_stroke_play_columns`).
|
||||
|
||||
Returnerer `(tournament_row, entries)` -- kalleren i OOM-sammenheng
|
||||
trenger `tournament_row["scoring_method"]` for å vite hvilket felt på
|
||||
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
||||
som faktisk er meningsfullt å lese videre."""
|
||||
tournament = await conn.fetchrow(
|
||||
"SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id
|
||||
)
|
||||
|
|
@ -1079,7 +1082,8 @@ async def individual_leaderboard(
|
|||
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT tp.id::text AS tournament_participant_id, p.display_name AS player_name,
|
||||
SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
|
||||
p.display_name AS player_name,
|
||||
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
|
||||
SUM(trs.gross_total)::int AS gross_total,
|
||||
SUM(trs.net_total)::int AS net_total,
|
||||
|
|
@ -1119,6 +1123,23 @@ async def individual_leaderboard(
|
|||
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
|
||||
# klasse feil som /rounds vs. /my-rounds tidligere i prosjektet -- unngått
|
||||
# her ved å gi den et eget, ikke-overlappende navn fra start.
|
||||
"/orgs/{organization_id}/tournaments/{tournament_id}/individual-leaderboard",
|
||||
response_model=list[LeaderboardEntry],
|
||||
)
|
||||
async def individual_leaderboard(
|
||||
tournament_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[LeaderboardEntry]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
_, entries = await _compute_individual_standings(conn, tournament_id)
|
||||
return entries
|
||||
|
||||
|
||||
|
|
|
|||
511
app/routers/order_of_merit.py
Normal file
511
app/routers/order_of_merit.py
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
"""
|
||||
Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering på tvers av
|
||||
FLERE individuelle turneringer (`tournament.format_type = 'individual'`,
|
||||
ADR-037) -- IKKE org-lagturneringer (ADR-011, poeng er der knyttet til
|
||||
hele kamper, ikke enkeltspillere, samme begrensning som
|
||||
konkurranseklassenes leaderboard-splitting, ADR-041).
|
||||
|
||||
To uavhengige akser (GolfBox-inspirert, se plan-loggen 2026-08-04):
|
||||
HVA telles per lenket turnering (`result_type`: poeng-etter-plassering/
|
||||
Stableford-sum/brutto/netto/pengeliste) og HVORDAN det summeres over
|
||||
flere turneringer (`aggregation_mode`: sum/snitt/eclectic -- eclectic
|
||||
ikke bygget i denne runden, se FEATURE_BACKLOG.md).
|
||||
|
||||
Gjenbruker `_compute_individual_standings` fra individual_tournaments.py
|
||||
for HVER lenket turnering sin egen leaderboard/plassering -- ingen
|
||||
duplisering av posisjon-/til-par-beregningen. "Regn ut ved lesing" for
|
||||
selve OOM-leaderboardet også (samme filosofi som Nassau/High-low-high/
|
||||
individual_leaderboard selv) -- ingen cache-tabell.
|
||||
|
||||
Bygget i stadier (se plan-loggen): denne runden dekker spiller-OOM med
|
||||
poeng/stableford/brutto/netto/pengeliste-resultattyper, sum/snitt-
|
||||
aggregering, og behold-N-beste/minimum-resultater/aldersgrense-grenser.
|
||||
Lag-OOM og Eclectic-aggregering er bevisst IKKE bygget ennå (egne,
|
||||
senere stadier) -- se FEATURE_BACKLOG.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth import get_authorized_org
|
||||
from ..db import org_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from handicap_engine import order_of_merit_aggregate, order_of_merit_points_for_position
|
||||
|
||||
from .individual_tournaments import _compute_individual_standings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_RESULT_TYPES = Literal["points", "stableford", "gross", "net", "money"]
|
||||
_AGGREGATION_MODES = Literal["sum", "average", "eclectic"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD: selve OOM-en
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrderOfMeritCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: Literal["player", "team"]
|
||||
result_type: _RESULT_TYPES
|
||||
aggregation_mode: _AGGREGATION_MODES = "sum"
|
||||
count_best_n: int | None = Field(default=None, ge=1)
|
||||
min_results_required: int | None = Field(default=None, ge=1)
|
||||
birth_year_from: int | None = None
|
||||
birth_year_to: int | None = None
|
||||
public_visible: bool = False
|
||||
|
||||
|
||||
class OrderOfMeritUpdate(BaseModel):
|
||||
"""Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som
|
||||
TournamentUpdate/SponsorUpdate/TournamentClassUpdate.
|
||||
|
||||
`kind` og `result_type` er IKKE med -- begge låses etter opprettelse.
|
||||
`kind` per plan (matcher GolfBox sin egen tilsvarende låsing).
|
||||
`result_type` er strukket til samme regel her: å endre den i
|
||||
etterkant ville gjort eksisterende lenkers `points_table`/
|
||||
`money_payout_table`-konfig meningsløs uten en egen migreringssti --
|
||||
opprett en ny OOM i stedet."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
aggregation_mode: _AGGREGATION_MODES | None = None
|
||||
count_best_n: int | None = None
|
||||
min_results_required: int | None = None
|
||||
birth_year_from: int | None = None
|
||||
birth_year_to: int | None = None
|
||||
public_visible: bool | None = None
|
||||
|
||||
|
||||
class OrderOfMeritOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: str
|
||||
result_type: str
|
||||
aggregation_mode: str
|
||||
count_best_n: int | None
|
||||
min_results_required: int | None
|
||||
birth_year_from: int | None
|
||||
birth_year_to: int | None
|
||||
public_visible: bool
|
||||
|
||||
|
||||
_OOM_COLUMNS = """
|
||||
id::text, name, kind, result_type, aggregation_mode, count_best_n,
|
||||
min_results_required, birth_year_from, birth_year_to, public_visible
|
||||
"""
|
||||
|
||||
|
||||
@router.get("/orgs/{organization_id}/order-of-merits", response_model=list[OrderOfMeritOut])
|
||||
async def list_order_of_merits(
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[OrderOfMeritOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
rows = await conn.fetch(f"SELECT {_OOM_COLUMNS} FROM order_of_merit ORDER BY created_at DESC")
|
||||
return [OrderOfMeritOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("/orgs/{organization_id}/order-of-merits", response_model=OrderOfMeritOut, status_code=201)
|
||||
async def create_order_of_merit(
|
||||
body: OrderOfMeritCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> OrderOfMeritOut:
|
||||
if body.kind == "team" and body.aggregation_mode == "eclectic":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO order_of_merit
|
||||
(organization_id, name, kind, result_type, aggregation_mode,
|
||||
count_best_n, min_results_required, birth_year_from, birth_year_to, public_visible)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING {_OOM_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
body.name,
|
||||
body.kind,
|
||||
body.result_type,
|
||||
body.aggregation_mode,
|
||||
body.count_best_n,
|
||||
body.min_results_required,
|
||||
body.birth_year_from,
|
||||
body.birth_year_to,
|
||||
body.public_visible,
|
||||
)
|
||||
return OrderOfMeritOut(**dict(row))
|
||||
|
||||
|
||||
@router.patch("/orgs/{organization_id}/order-of-merits/{oom_id}", response_model=OrderOfMeritOut)
|
||||
async def update_order_of_merit(
|
||||
oom_id: str,
|
||||
body: OrderOfMeritUpdate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> OrderOfMeritOut:
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||
|
||||
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||
values = list(updates.values())
|
||||
values.append(oom_id)
|
||||
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
existing = await conn.fetchrow("SELECT kind, result_type FROM order_of_merit WHERE id = $1", oom_id)
|
||||
if existing is None:
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
if updates.get("aggregation_mode") == "eclectic" and existing["kind"] == "team":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Eclectic er ikke støttet for lag-OOM ennå.")
|
||||
result_type = existing["result_type"]
|
||||
effective_mode = updates.get("aggregation_mode")
|
||||
if effective_mode == "eclectic" and result_type not in ("stableford", "gross", "net"):
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
||||
WHERE id = ${len(values)}
|
||||
RETURNING {_OOM_COLUMNS}
|
||||
""",
|
||||
*values,
|
||||
)
|
||||
return OrderOfMeritOut(**dict(row))
|
||||
|
||||
|
||||
@router.delete("/orgs/{organization_id}/order-of-merits/{oom_id}", status_code=204)
|
||||
async def delete_order_of_merit(
|
||||
oom_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
result = await conn.execute("DELETE FROM order_of_merit WHERE id = $1", oom_id)
|
||||
if result == "DELETE 0":
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lenkede turneringer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrderOfMeritLinkCreate(BaseModel):
|
||||
tournament_id: str
|
||||
# Kun gyldig (og påkrevd) når OOM-ens result_type='points': liste av
|
||||
# heltall, indeks 0 = poeng for 1. plass.
|
||||
points_table: list[int] | None = None
|
||||
# Kun gyldig (og påkrevd) når result_type='money'.
|
||||
money_pool_total: float | None = None
|
||||
money_payout_table: list[dict] | None = None
|
||||
|
||||
|
||||
class OrderOfMeritLinkOut(BaseModel):
|
||||
id: str
|
||||
tournament_id: str
|
||||
tournament_name: str
|
||||
points_table: list[int] | None
|
||||
money_pool_total: float | None
|
||||
money_payout_table: list[dict] | None
|
||||
|
||||
|
||||
_LINK_COLUMNS = """
|
||||
l.id::text, l.tournament_id::text, t.name AS tournament_name,
|
||||
l.points_table::text AS points_table, l.money_pool_total,
|
||||
l.money_payout_table::text AS money_payout_table
|
||||
"""
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||
response_model=list[OrderOfMeritLinkOut],
|
||||
)
|
||||
async def list_order_of_merit_links(
|
||||
oom_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[OrderOfMeritLinkOut]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_LINK_COLUMNS}
|
||||
FROM order_of_merit_link l
|
||||
JOIN tournament t ON t.id = l.tournament_id
|
||||
WHERE l.order_of_merit_id = $1
|
||||
ORDER BY l.created_at
|
||||
""",
|
||||
oom_id,
|
||||
)
|
||||
return [OrderOfMeritLinkOut(**_parse_link_row(r)) for r in rows]
|
||||
|
||||
|
||||
def _parse_link_row(r) -> dict:
|
||||
d = dict(r)
|
||||
d["points_table"] = json.loads(d["points_table"]) if d.get("points_table") else None
|
||||
d["money_payout_table"] = json.loads(d["money_payout_table"]) if d.get("money_payout_table") else None
|
||||
if d.get("money_pool_total") is not None:
|
||||
d["money_pool_total"] = float(d["money_pool_total"])
|
||||
return d
|
||||
|
||||
|
||||
@router.post(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||
response_model=OrderOfMeritLinkOut,
|
||||
status_code=201,
|
||||
)
|
||||
async def link_tournament_to_order_of_merit(
|
||||
oom_id: str,
|
||||
body: OrderOfMeritLinkCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> OrderOfMeritLinkOut:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
oom = await conn.fetchrow("SELECT result_type FROM order_of_merit WHERE id = $1", oom_id)
|
||||
if oom is None:
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
tournament = await conn.fetchrow(
|
||||
"SELECT format_type FROM tournament WHERE id = $1", body.tournament_id
|
||||
)
|
||||
if tournament is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
|
||||
if tournament["format_type"] != "individual":
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
|
||||
)
|
||||
|
||||
result_type = oom["result_type"]
|
||||
if result_type == "points" and not body.points_table:
|
||||
raise app_error(400, "VALIDATION_FAILED", "points_table er påkrevd for en poeng-basert Order of Merit.")
|
||||
if result_type != "points" and body.points_table:
|
||||
raise app_error(400, "VALIDATION_FAILED", "points_table er kun gyldig for en poeng-basert Order of Merit.")
|
||||
if result_type == "money" and not (body.money_pool_total and body.money_payout_table):
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "money_pool_total og money_payout_table er påkrevd for en pengeliste-basert Order of Merit."
|
||||
)
|
||||
if result_type != "money" and (body.money_pool_total or body.money_payout_table):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Pengefelt er kun gyldig for en pengeliste-basert Order of Merit.")
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
WITH inserted AS (
|
||||
INSERT INTO order_of_merit_link
|
||||
(organization_id, order_of_merit_id, tournament_id,
|
||||
points_table, money_pool_total, money_payout_table)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5, $6::jsonb)
|
||||
RETURNING id, tournament_id, points_table, money_pool_total, money_payout_table
|
||||
)
|
||||
SELECT inserted.id::text, inserted.tournament_id::text, t.name AS tournament_name,
|
||||
inserted.points_table::text AS points_table, inserted.money_pool_total,
|
||||
inserted.money_payout_table::text AS money_payout_table
|
||||
FROM inserted JOIN tournament t ON t.id = inserted.tournament_id
|
||||
""",
|
||||
organization_id,
|
||||
oom_id,
|
||||
body.tournament_id,
|
||||
json.dumps(body.points_table) if body.points_table else None,
|
||||
body.money_pool_total,
|
||||
json.dumps(body.money_payout_table) if body.money_payout_table else None,
|
||||
)
|
||||
return OrderOfMeritLinkOut(**_parse_link_row(row))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments/{link_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def unlink_tournament_from_order_of_merit(
|
||||
oom_id: str,
|
||||
link_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> None:
|
||||
async with org_connection(organization_id) as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM order_of_merit_link WHERE id = $1 AND order_of_merit_id = $2", link_id, oom_id
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
||||
# som Nassau/High-low-high/individual_leaderboard selv).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrderOfMeritLeaderboardEntry(BaseModel):
|
||||
player_id: str
|
||||
player_name: str
|
||||
results_counted: int
|
||||
results_available: int
|
||||
value: float | None
|
||||
position: str | None
|
||||
eligible: bool
|
||||
ineligible_reason: str | None = None
|
||||
|
||||
|
||||
def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
|
||||
"""Samme delt-plassering-regel som order_of_merit_points_for_position
|
||||
(uavgjorte deler samme utbetaling ved sin felles plassering) -- egen
|
||||
liten oppslagsfunksjon her (ikke i handicap_engine.py) siden dette er
|
||||
et rent JSON-konfig-oppslag, ikke golf-domenematematikk om uavgjort."""
|
||||
position = int(position_label.lstrip("T"))
|
||||
for entry in payout_table:
|
||||
if entry.get("position") == position:
|
||||
return float(entry.get("amount") or 0)
|
||||
return 0.0
|
||||
|
||||
|
||||
@router.get(
|
||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
|
||||
response_model=list[OrderOfMeritLeaderboardEntry],
|
||||
)
|
||||
async def order_of_merit_leaderboard(
|
||||
oom_id: str,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[OrderOfMeritLeaderboardEntry]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
oom = await conn.fetchrow(
|
||||
"""
|
||||
SELECT result_type, aggregation_mode, count_best_n, min_results_required,
|
||||
birth_year_from, birth_year_to, kind
|
||||
FROM order_of_merit WHERE id = $1
|
||||
""",
|
||||
oom_id,
|
||||
)
|
||||
if oom is None:
|
||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||
if oom["kind"] != "player":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Lag-OOM-leaderboard er ikke bygget ennå.")
|
||||
if oom["aggregation_mode"] == "eclectic":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
|
||||
|
||||
result_type = oom["result_type"]
|
||||
lower_is_better = result_type in ("gross", "net")
|
||||
|
||||
links = await conn.fetch(
|
||||
"""
|
||||
SELECT tournament_id::text, points_table::text AS points_table,
|
||||
money_payout_table::text AS money_payout_table
|
||||
FROM order_of_merit_link WHERE order_of_merit_id = $1
|
||||
""",
|
||||
oom_id,
|
||||
)
|
||||
|
||||
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
|
||||
values_by_player: dict[str, list[float]] = {}
|
||||
names_by_player: dict[str, str] = {}
|
||||
|
||||
for link in links:
|
||||
_, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
||||
points_table = _decode_json(link["points_table"])
|
||||
money_payout_table = _decode_json(link["money_payout_table"])
|
||||
for e in entries:
|
||||
contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table)
|
||||
if contribution is None:
|
||||
continue
|
||||
values_by_player.setdefault(e.player_id, []).append(contribution)
|
||||
names_by_player[e.player_id] = e.player_name
|
||||
|
||||
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
||||
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
||||
# bare gitt 0 poeng -- de skal ikke telle med som "sist").
|
||||
eligible_player_ids = set(values_by_player.keys())
|
||||
birth_years: dict[str, int | None] = {}
|
||||
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||
if eligible_player_ids:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id::text, EXTRACT(YEAR FROM birth_date)::int AS birth_year FROM player WHERE id = ANY($1::uuid[])",
|
||||
list(eligible_player_ids),
|
||||
)
|
||||
birth_years = {r["id"]: r["birth_year"] for r in rows}
|
||||
|
||||
results: list[OrderOfMeritLeaderboardEntry] = []
|
||||
for player_id, raw_values in values_by_player.items():
|
||||
results_available = len(raw_values)
|
||||
eligible = True
|
||||
ineligible_reason = None
|
||||
|
||||
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
||||
by = birth_years.get(player_id)
|
||||
if by is None:
|
||||
eligible = False
|
||||
ineligible_reason = "Ingen registrert fødselsdato."
|
||||
elif oom["birth_year_from"] is not None and by < oom["birth_year_from"]:
|
||||
eligible = False
|
||||
ineligible_reason = "Utenfor aldersgrensen."
|
||||
elif oom["birth_year_to"] is not None and by > oom["birth_year_to"]:
|
||||
eligible = False
|
||||
ineligible_reason = "Utenfor aldersgrensen."
|
||||
|
||||
if eligible and oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
|
||||
eligible = False
|
||||
ineligible_reason = f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
|
||||
|
||||
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
|
||||
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
|
||||
value = (-aggregated if lower_is_better and aggregated is not None else aggregated)
|
||||
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
||||
|
||||
results.append(
|
||||
OrderOfMeritLeaderboardEntry(
|
||||
player_id=player_id,
|
||||
player_name=names_by_player[player_id],
|
||||
results_counted=counted,
|
||||
results_available=results_available,
|
||||
value=value,
|
||||
position=None,
|
||||
eligible=eligible,
|
||||
ineligible_reason=ineligible_reason,
|
||||
)
|
||||
)
|
||||
|
||||
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for
|
||||
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
|
||||
# `value` er allerede tilbake-negert til ekte visningsverdi over,
|
||||
# så sorter på `value` med riktig retning per resultattype.
|
||||
eligible_results = [r for r in results if r.eligible and r.value is not None]
|
||||
ineligible_results = [r for r in results if not (r.eligible and r.value is not None)]
|
||||
eligible_results.sort(key=lambda r: r.value, reverse=not lower_is_better)
|
||||
|
||||
i, n = 0, len(eligible_results)
|
||||
while i < n:
|
||||
j = i
|
||||
while j < n and eligible_results[j].value == eligible_results[i].value:
|
||||
j += 1
|
||||
rank = i + 1
|
||||
tied = (j - i) > 1
|
||||
for k in range(i, j):
|
||||
eligible_results[k].position = f"T{rank}" if tied else str(rank)
|
||||
i = j
|
||||
|
||||
return eligible_results + ineligible_results
|
||||
|
||||
|
||||
def _decode_json(value):
|
||||
return json.loads(value) if value else None
|
||||
|
||||
|
||||
def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | None:
|
||||
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
|
||||
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
|
||||
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
|
||||
hopper over den).
|
||||
|
||||
`entry.position` (posisjon i DENNE turneringen) er KUN populert for
|
||||
scoring_method i (stroke_gross, stroke_net, stableford) -- se
|
||||
`_attach_stroke_play_columns` i individual_tournaments.py. Er OOM-en
|
||||
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
|
||||
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
|
||||
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
|
||||
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt."""
|
||||
if entry.rounds_played == 0:
|
||||
return None
|
||||
if result_type == "points":
|
||||
return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0
|
||||
if result_type == "money":
|
||||
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
|
||||
if result_type == "stableford":
|
||||
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
|
||||
if result_type == "gross":
|
||||
return float(entry.gross_total) if entry.gross_total is not None else 0.0
|
||||
if result_type == "net":
|
||||
return float(entry.net_total) if entry.net_total is not None else 0.0
|
||||
return 0.0
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { OrderOfMeritDetail } from "@/components/order-of-merit-detail"
|
||||
|
||||
export default async function OrderOfMeritDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string; oomId: string }>
|
||||
searchParams: Promise<{ name?: string }>
|
||||
}) {
|
||||
const { id, oomId } = await params
|
||||
const { name } = await searchParams
|
||||
return <OrderOfMeritDetail organizationId={id} oomId={oomId} orgName={name ?? "Organisasjon"} />
|
||||
}
|
||||
13
frontend/app/organizations/[id]/order-of-merit/page.tsx
Normal file
13
frontend/app/organizations/[id]/order-of-merit/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { OrderOfMeritList } from "@/components/order-of-merit-list"
|
||||
|
||||
export default async function OrderOfMeritListPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ name?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { name } = await searchParams
|
||||
return <OrderOfMeritList organizationId={id} orgName={name ?? "Organisasjon"} />
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
698
frontend/components/course-template-editor.tsx
Normal file
698
frontend/components/course-template-editor.tsx
Normal 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 på 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) må 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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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 må 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 på 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,8 +2112,11 @@ function Step2(props: {
|
|||
setBbbBonus={setBbbBonus}
|
||||
shambleBest={shambleBest}
|
||||
setShambleBest={setShambleBest}
|
||||
soloAllowance={soloAllowance}
|
||||
setSoloAllowance={setSoloAllowance}
|
||||
/>
|
||||
|
||||
{!SCRAMBLE_SOLO_VARIANTS.has(format) && (
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -1882,6 +2154,7 @@ function Step2(props: {
|
|||
</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"
|
||||
|
|
|
|||
739
frontend/components/order-of-merit-detail.tsx
Normal file
739
frontend/components/order-of-merit-detail.tsx
Normal file
|
|
@ -0,0 +1,739 @@
|
|||
"use client"
|
||||
|
||||
// Order of Merit-detalj (ADR-043 [OOM]): lenkede turneringer -> innstillinger
|
||||
// -> resultatliste, i den rekkefølgen -- resultater gir ikke mening før
|
||||
// turneringer er lenket. Håndkodet (ingen V0-credits denne runden), men
|
||||
// bevisst bygget til samme visuelle presisjon som resten av appen sine
|
||||
// V0-eksporter (samme tokens/kort-mønster).
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, ChevronDown, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type OomKind = "player" | "team"
|
||||
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||
type OomAggregationMode = "sum" | "average"
|
||||
|
||||
type ApiOrderOfMerit = {
|
||||
id: string
|
||||
name: string
|
||||
kind: OomKind
|
||||
result_type: OomResultType
|
||||
aggregation_mode: OomAggregationMode
|
||||
count_best_n: number | null
|
||||
min_results_required: number | null
|
||||
birth_year_from: number | null
|
||||
birth_year_to: number | null
|
||||
public_visible: boolean
|
||||
}
|
||||
|
||||
type ApiLink = {
|
||||
id: string
|
||||
tournament_id: string
|
||||
tournament_name: string
|
||||
points_table: number[] | null
|
||||
money_pool_total: number | null
|
||||
money_payout_table: { position: number; amount: number }[] | null
|
||||
}
|
||||
|
||||
type ApiLeaderboardEntry = {
|
||||
player_id: string
|
||||
player_name: string
|
||||
results_counted: number
|
||||
results_available: number
|
||||
value: number | null
|
||||
position: string | null
|
||||
eligible: boolean
|
||||
ineligible_reason: string | null
|
||||
}
|
||||
|
||||
type ApiTournament = { id: string; name: string; format_type: string }
|
||||
|
||||
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
|
||||
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||
points: "Poeng etter plassering",
|
||||
stableford: "Stableford-sum",
|
||||
gross: "Bruttoscore-sum",
|
||||
net: "Nettoscore-sum",
|
||||
money: "Pengeliste",
|
||||
}
|
||||
|
||||
function formatValue(value: number | null, resultType: OomResultType): string {
|
||||
if (value === null) return "–"
|
||||
if (resultType === "money") return `${Math.round(value).toLocaleString("nb-NO")} kr`
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
|
||||
export function OrderOfMeritDetail({
|
||||
organizationId,
|
||||
oomId,
|
||||
orgName,
|
||||
}: {
|
||||
organizationId: string
|
||||
oomId: string
|
||||
orgName: string
|
||||
}) {
|
||||
const [oom, setOom] = useState<ApiOrderOfMerit | null>(null)
|
||||
const [links, setLinks] = useState<ApiLink[] | null>(null)
|
||||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
|
||||
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notFound, setNotFound] = useState(false)
|
||||
|
||||
async function loadOom() {
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" })
|
||||
if (!res.ok) return
|
||||
const all: ApiOrderOfMerit[] = await res.json()
|
||||
const mine = all.find((o) => o.id === oomId)
|
||||
if (!mine) {
|
||||
setNotFound(true)
|
||||
return
|
||||
}
|
||||
setOom(mine)
|
||||
}
|
||||
|
||||
async function loadLinks() {
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, { credentials: "include" })
|
||||
if (res.ok) setLinks(await res.json())
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/leaderboard`, { credentials: "include" })
|
||||
if (res.ok) setLeaderboard(await res.json())
|
||||
else setLeaderboard([])
|
||||
}
|
||||
|
||||
async function loadTournaments() {
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" })
|
||||
if (res.ok) setAllTournaments(await res.json())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadOom()
|
||||
void loadLinks()
|
||||
void loadTournaments()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organizationId, oomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (oom?.kind === "player") void loadLeaderboard()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [oom?.kind, links])
|
||||
|
||||
async function linkTournament(input: {
|
||||
tournament_id: string
|
||||
points_table?: number[]
|
||||
money_pool_total?: number
|
||||
money_payout_table?: { position: number; amount: number }[]
|
||||
}) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
setError(body?.detail?.message ?? "Klarte ikke å lenke turneringen.")
|
||||
return
|
||||
}
|
||||
await loadLinks()
|
||||
}
|
||||
|
||||
async function unlinkTournament(linkId: string) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments/${linkId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.status !== 204) {
|
||||
setError("Klarte ikke å fjerne lenken.")
|
||||
return
|
||||
}
|
||||
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
|
||||
}
|
||||
|
||||
async function saveSettings(patch: Record<string, unknown>) {
|
||||
setError(null)
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setError("Klarte ikke å lagre innstillingene.")
|
||||
return
|
||||
}
|
||||
const updated: ApiOrderOfMerit = await res.json()
|
||||
setOom(updated)
|
||||
}
|
||||
|
||||
async function deleteOom() {
|
||||
if (!confirm("Slette denne Order of Merit-en? Dette kan ikke angres.")) return
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.status === 204) {
|
||||
window.location.href = `/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`
|
||||
}
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-3 bg-background px-5 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">Fant ikke denne Order of Merit-en.</p>
|
||||
<Link href={`/organizations/${organizationId}/order-of-merit`} className="text-sm font-semibold text-primary underline-offset-2 hover:underline">
|
||||
Tilbake til listen
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!oom) {
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const linkedTournamentIds = new Set((links ?? []).map((l) => l.tournament_id))
|
||||
const linkableTournaments = allTournaments.filter(
|
||||
(t) => t.format_type === "individual" && !linkedTournamentIds.has(t.id),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||
<Link
|
||||
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
|
||||
aria-label="Tilbake til Order of Merit-listen"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Order of Merit
|
||||
</span>
|
||||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{oom.name}</h1>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={deleteOom}
|
||||
className="h-10 shrink-0 rounded-xl px-3 text-sm font-semibold text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
Slett
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-5 py-6 sm:py-8">
|
||||
{error && (
|
||||
<p role="alert" className="text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LinkedTournamentsSection
|
||||
resultType={oom.result_type}
|
||||
links={links}
|
||||
linkableTournaments={linkableTournaments}
|
||||
onLink={linkTournament}
|
||||
onUnlink={unlinkTournament}
|
||||
/>
|
||||
|
||||
<SettingsSection oom={oom} onSave={saveSettings} />
|
||||
|
||||
<LeaderboardSection kind={oom.kind} resultType={oom.result_type} entries={leaderboard} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- B. Lenkede turneringer --------------------------------------------------
|
||||
|
||||
function LinkedTournamentsSection({
|
||||
resultType,
|
||||
links,
|
||||
linkableTournaments,
|
||||
onLink,
|
||||
onUnlink,
|
||||
}: {
|
||||
resultType: OomResultType
|
||||
links: ApiLink[] | null
|
||||
linkableTournaments: ApiTournament[]
|
||||
onLink: (input: {
|
||||
tournament_id: string
|
||||
points_table?: number[]
|
||||
money_pool_total?: number
|
||||
money_payout_table?: { position: number; amount: number }[]
|
||||
}) => Promise<void>
|
||||
onUnlink: (linkId: string) => void
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Lenkede turneringer</h2>
|
||||
</div>
|
||||
|
||||
{links === null ? (
|
||||
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||
) : links.length === 0 ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Ingen turneringer lenket ennå.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
||||
{links.map((l) => (
|
||||
<li key={l.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-foreground">{l.tournament_name}</span>
|
||||
{resultType === "points" && l.points_table && (
|
||||
<span className="text-xs text-muted-foreground">Poeng: {l.points_table.join("-")}</span>
|
||||
)}
|
||||
{resultType === "money" && l.money_pool_total !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Premiepott: {Math.round(l.money_pool_total).toLocaleString("nb-NO")} kr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onUnlink(l.id)}
|
||||
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
Fjern
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!adding ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdding(true)}
|
||||
disabled={linkableTournaments.length === 0}
|
||||
className="h-11 self-start rounded-xl text-sm font-bold"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Lenk turnering
|
||||
</Button>
|
||||
) : (
|
||||
<LinkTournamentForm
|
||||
resultType={resultType}
|
||||
candidates={linkableTournaments}
|
||||
onSubmit={async (input) => {
|
||||
await onLink(input)
|
||||
setAdding(false)
|
||||
}}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
)}
|
||||
{linkableTournaments.length === 0 && !adding && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ingen flere individuelle turneringer å lenke -- alle er allerede lenket, eller organisasjonen har ingen ennå.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkTournamentForm({
|
||||
resultType,
|
||||
candidates,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
resultType: OomResultType
|
||||
candidates: ApiTournament[]
|
||||
onSubmit: (input: {
|
||||
tournament_id: string
|
||||
points_table?: number[]
|
||||
money_pool_total?: number
|
||||
money_payout_table?: { position: number; amount: number }[]
|
||||
}) => Promise<void>
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [tournamentId, setTournamentId] = useState(candidates[0]?.id ?? "")
|
||||
const [pointsRows, setPointsRows] = useState<string[]>(["10", "6", "3"])
|
||||
const [moneyPool, setMoneyPool] = useState("")
|
||||
const [moneyRows, setMoneyRows] = useState<string[]>(["", "", ""])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function updateRow(setter: React.Dispatch<React.SetStateAction<string[]>>, index: number, value: string) {
|
||||
setter((prev) => prev.map((v, i) => (i === index ? value : v)))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!tournamentId || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (resultType === "points") {
|
||||
const points_table = pointsRows.map((v) => Number(v) || 0)
|
||||
await onSubmit({ tournament_id: tournamentId, points_table })
|
||||
} else if (resultType === "money") {
|
||||
const money_payout_table = moneyRows.map((v, i) => ({ position: i + 1, amount: Number(v) || 0 }))
|
||||
await onSubmit({ tournament_id: tournamentId, money_pool_total: Number(moneyPool) || 0, money_payout_table })
|
||||
} else {
|
||||
await onSubmit({ tournament_id: tournamentId })
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="link-tournament" className="text-xs font-semibold">
|
||||
Turnering
|
||||
</Label>
|
||||
<select
|
||||
id="link-tournament"
|
||||
value={tournamentId}
|
||||
onChange={(e) => setTournamentId(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
||||
>
|
||||
{candidates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{resultType === "points" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold">Poeng per plassering</span>
|
||||
{pointsRows.map((v, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="w-20 shrink-0 text-sm text-muted-foreground">{i + 1}. plass</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={v}
|
||||
onChange={(e) => updateRow(setPointsRows, i, e.target.value)}
|
||||
className="h-10 flex-1 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setPointsRows((prev) => [...prev, "0"])}
|
||||
className="h-9 self-start rounded-lg text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Legg til plassering
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultType === "money" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="money-pool" className="text-xs font-semibold">
|
||||
Premiepott (kr)
|
||||
</Label>
|
||||
<Input
|
||||
id="money-pool"
|
||||
inputMode="numeric"
|
||||
value={moneyPool}
|
||||
onChange={(e) => setMoneyPool(e.target.value)}
|
||||
className="h-10 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold">Utbetaling per plassering (kr)</span>
|
||||
{moneyRows.map((v, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="w-20 shrink-0 text-sm text-muted-foreground">{i + 1}. plass</span>
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
value={v}
|
||||
onChange={(e) => updateRow(setMoneyRows, i, e.target.value)}
|
||||
className="h-10 flex-1 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMoneyRows((prev) => [...prev, ""])}
|
||||
className="h-9 self-start rounded-lg text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Legg til plassering
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={!tournamentId || submitting} className="h-10 flex-1 rounded-xl text-sm font-bold">
|
||||
{submitting ? "Lenker…" : "Lenk"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 rounded-xl text-sm font-semibold text-muted-foreground">
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// --- C. Innstillinger ---------------------------------------------------------
|
||||
|
||||
function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch: Record<string, unknown>) => Promise<void> }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState(oom.name)
|
||||
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>(oom.aggregation_mode)
|
||||
const [bestN, setBestN] = useState(oom.count_best_n?.toString() ?? "")
|
||||
const [minResults, setMinResults] = useState(oom.min_results_required?.toString() ?? "")
|
||||
const [birthYearFrom, setBirthYearFrom] = useState(oom.birth_year_from?.toString() ?? "")
|
||||
const [birthYearTo, setBirthYearTo] = useState(oom.birth_year_to?.toString() ?? "")
|
||||
const [publicVisible, setPublicVisible] = useState(oom.public_visible)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
aggregation_mode: aggregationMode,
|
||||
count_best_n: bestN.trim() ? Number(bestN) : null,
|
||||
min_results_required: minResults.trim() ? Number(minResults) : null,
|
||||
birth_year_from: birthYearFrom.trim() ? Number(birthYearFrom) : null,
|
||||
birth_year_to: birthYearTo.trim() ? Number(birthYearTo) : null,
|
||||
public_visible: publicVisible,
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex min-h-11 items-center gap-2.5 text-left"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Settings aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="flex-1 text-base font-bold text-foreground">Innstillinger</h2>
|
||||
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Type ({KIND_LABELS[oom.kind]}) og resultattype ({RESULT_TYPE_LABELS[oom.result_type]}) kan ikke endres etter opprettelse.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="settings-name" className="text-sm font-semibold">
|
||||
Navn
|
||||
</Label>
|
||||
<Input id="settings-name" value={name} onChange={(e) => setName(e.target.value)} className="h-11 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||
Sum
|
||||
</SettingsSegment>
|
||||
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||
Snitt
|
||||
</SettingsSegment>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="best-n" className="text-sm font-semibold">
|
||||
Behold de N beste
|
||||
</Label>
|
||||
<Input
|
||||
id="best-n"
|
||||
inputMode="numeric"
|
||||
value={bestN}
|
||||
onChange={(e) => setBestN(e.target.value)}
|
||||
placeholder="Tom = tell alle resultater"
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="min-results" className="text-sm font-semibold">
|
||||
Minimum antall resultater
|
||||
</Label>
|
||||
<Input
|
||||
id="min-results"
|
||||
inputMode="numeric"
|
||||
value={minResults}
|
||||
onChange={(e) => setMinResults(e.target.value)}
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-semibold text-foreground">Aldersgrense</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="birth-from" className="text-xs text-muted-foreground">
|
||||
Fødselsår fra
|
||||
</Label>
|
||||
<Input id="birth-from" inputMode="numeric" value={birthYearFrom} onChange={(e) => setBirthYearFrom(e.target.value)} className="h-11 rounded-xl" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="birth-to" className="text-xs text-muted-foreground">
|
||||
Fødselsår til
|
||||
</Label>
|
||||
<Input id="birth-to" inputMode="numeric" value={birthYearTo} onChange={(e) => setBirthYearTo(e.target.value)} className="h-11 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex min-h-11 items-center justify-between gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||
<span className="text-sm font-semibold text-foreground">Offentlig synlig</span>
|
||||
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
|
||||
{saving ? "Lagrer…" : "Lagre"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-xl border px-3 py-2.5 text-sm font-semibold transition-colors",
|
||||
selected ? "border-primary bg-primary/10 text-foreground" : "border-border bg-background text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// --- D. Resultatliste ----------------------------------------------------------
|
||||
|
||||
function LeaderboardSection({
|
||||
kind,
|
||||
resultType,
|
||||
entries,
|
||||
}: {
|
||||
kind: OomKind
|
||||
resultType: OomResultType
|
||||
entries: ApiLeaderboardEntry[] | null
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||
<Medal aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
|
||||
</div>
|
||||
|
||||
{kind === "team" ? (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-6 text-center">
|
||||
<Users aria-hidden="true" className="mx-auto mb-2 size-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Lag-resultatliste er ikke bygget ennå.</p>
|
||||
</div>
|
||||
) : entries === null ? (
|
||||
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||
) : (
|
||||
<LeaderboardTable entries={entries} resultType={resultType} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
|
||||
const eligible = entries.filter((e) => e.eligible)
|
||||
const ineligible = entries.filter((e) => !e.eligible)
|
||||
|
||||
if (eligible.length === 0 && ineligible.length === 0) {
|
||||
return <p className="text-sm leading-relaxed text-muted-foreground text-pretty">Ingen resultater ennå -- lenk minst én turnering over.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||||
{eligible.map((e) => {
|
||||
const isLeader = e.position === "1"
|
||||
return (
|
||||
<li
|
||||
key={e.player_id}
|
||||
className={cn("flex items-center gap-3 px-4 py-3", isLeader ? "bg-gold text-gold-foreground" : "bg-card")}
|
||||
>
|
||||
<span className={cn("w-9 shrink-0 text-center text-sm font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
|
||||
{e.position}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className={cn("truncate text-sm font-bold", isLeader ? "text-gold-foreground" : "text-foreground")}>{e.player_name}</span>
|
||||
{e.results_counted !== e.results_available && (
|
||||
<span className={cn("text-xs", isLeader ? "text-gold-foreground/80" : "text-muted-foreground")}>
|
||||
{e.results_counted} av {e.results_available} resultater talt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("shrink-0 text-base font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
|
||||
{formatValue(e.value, resultType)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{ineligible.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Ikke kvalifisert</h3>
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border opacity-70">
|
||||
{ineligible.map((e) => (
|
||||
<li key={e.player_id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-semibold text-muted-foreground">{e.player_name}</span>
|
||||
{e.ineligible_reason && <span className="text-xs text-muted-foreground">{e.ineligible_reason}</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
306
frontend/components/order-of-merit-list.tsx
Normal file
306
frontend/components/order-of-merit-list.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
"use client"
|
||||
|
||||
// Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering av spillere
|
||||
// på tvers av flere individuelle turneringer (ADR-037) i samme
|
||||
// organisasjon. Håndkodet (ikke V0 -- ingen V0-credits tilgjengelig denne
|
||||
// runden), men bevisst bygget til å matche samme visuelle presisjon som
|
||||
// V0-eksportene ellers i appen (samme design-tokens, samme kort-/
|
||||
// header-mønster som org-members.tsx).
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, ChevronRight, Plus, Trophy, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type OomKind = "player" | "team"
|
||||
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||
type OomAggregationMode = "sum" | "average"
|
||||
|
||||
type ApiOrderOfMerit = {
|
||||
id: string
|
||||
name: string
|
||||
kind: OomKind
|
||||
result_type: OomResultType
|
||||
aggregation_mode: OomAggregationMode
|
||||
count_best_n: number | null
|
||||
min_results_required: number | null
|
||||
birth_year_from: number | null
|
||||
birth_year_to: number | null
|
||||
public_visible: boolean
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
|
||||
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||
points: "Poeng etter plassering",
|
||||
stableford: "Stableford-sum",
|
||||
gross: "Bruttoscore-sum",
|
||||
net: "Nettoscore-sum",
|
||||
money: "Pengeliste",
|
||||
}
|
||||
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = { sum: "Sum", average: "Snitt" }
|
||||
|
||||
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
||||
const router = useRouter()
|
||||
const [items, setItems] = useState<ApiOrderOfMerit[] | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" })
|
||||
if (res.ok) setItems(await res.json())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organizationId])
|
||||
|
||||
async function create(input: { name: string; kind: OomKind; result_type: OomResultType; aggregation_mode: OomAggregationMode }) {
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
if (!res.ok) throw new Error(`create: ${res.status}`)
|
||||
const created: ApiOrderOfMerit = await res.json()
|
||||
setCreating(false)
|
||||
router.push(`/organizations/${organizationId}/order-of-merit/${created.id}?name=${encodeURIComponent(orgName)}`)
|
||||
} catch {
|
||||
setError("Klarte ikke å opprette Order of Merit. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
|
||||
<Link
|
||||
href={`/organizations/${organizationId}/members?name=${encodeURIComponent(orgName)}`}
|
||||
aria-label="Tilbake til organisasjonen"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Order of Merit
|
||||
</span>
|
||||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{orgName}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
||||
{error && (
|
||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Sesong-sammenlagt rangering av spillere på tvers av flere turneringer.
|
||||
</p>
|
||||
{!creating && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreating(true)}
|
||||
className="h-11 shrink-0 rounded-2xl font-bold"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Ny Order of Merit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="mb-6">
|
||||
<CreateForm onCreate={create} onCancel={() => setCreating(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items === null ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
) : items.length === 0 && !creating ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Ingen Order of Merit-er ennå</h2>
|
||||
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Sett opp en sesong-sammenlagt rangering på tvers av flere turneringer.
|
||||
</p>
|
||||
<Button type="button" onClick={() => setCreating(true)} className="mt-2 h-11 rounded-2xl font-bold">
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Ny Order of Merit
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{items.map((oom) => (
|
||||
<li key={oom.id}>
|
||||
<Link
|
||||
href={`/organizations/${organizationId}/order-of-merit/${oom.id}?name=${encodeURIComponent(orgName)}`}
|
||||
className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||
{oom.kind === "team" ? (
|
||||
<Users aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
) : (
|
||||
<Trophy aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span className="truncate text-base font-bold text-foreground">{oom.name}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge>{KIND_LABELS[oom.kind]}</Badge>
|
||||
<Badge>{RESULT_TYPE_LABELS[oom.result_type]}</Badge>
|
||||
<Badge>{AGGREGATION_LABELS[oom.aggregation_mode]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentButton({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-xl border px-3 py-2.5 text-sm font-semibold transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateForm({
|
||||
onCreate,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreate: (input: { name: string; kind: OomKind; result_type: OomResultType; aggregation_mode: OomAggregationMode }) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [kind, setKind] = useState<OomKind>("player")
|
||||
const [resultType, setResultType] = useState<OomResultType>("stableford")
|
||||
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || submitting) return
|
||||
setSubmitting(true)
|
||||
await onCreate({ name: name.trim(), kind, result_type: resultType, aggregation_mode: aggregationMode })
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-5 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6"
|
||||
>
|
||||
<h2 className="text-base font-bold text-foreground">Ny Order of Merit</h2>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oom-name" className="text-sm font-semibold">
|
||||
Navn
|
||||
</Label>
|
||||
<Input
|
||||
id="oom-name"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="F.eks. Klubbmesterskap-serien 2026"
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Type</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}>
|
||||
Spiller
|
||||
</SegmentButton>
|
||||
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}>
|
||||
Lag
|
||||
</SegmentButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
||||
<SegmentButton key={rt} selected={resultType === rt} onClick={() => setResultType(rt)}>
|
||||
{RESULT_TYPE_LABELS[rt]}
|
||||
</SegmentButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||
Sum
|
||||
</SegmentButton>
|
||||
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||
Snitt
|
||||
</SegmentButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={!name.trim() || submitting} className="h-11 flex-1 rounded-xl font-bold">
|
||||
{submitting ? "Oppretter…" : "Opprett"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl font-semibold text-muted-foreground">
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
import type React from "react"
|
||||
import { 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 på 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" />
|
||||
|
|
|
|||
|
|
@ -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,9 +204,16 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<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">
|
||||
{played}/{round.holes} hull spilt
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
// 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 (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,9 +1425,16 @@ 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="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} · ` : ""}
|
||||
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,8 +793,41 @@ 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
|
||||
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),
|
||||
|
|
@ -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,15 +1233,33 @@ 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 på 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
|
||||
// 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) }))
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
350
frontend/components/scramble-solo-result.tsx
Normal file
350
frontend/components/scramble-solo-result.tsx
Normal 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 på at oppsettet blir fullført
|
||||
</h2>
|
||||
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
||||
Resultatet vises så 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,7 +1073,8 @@ function EditCourseField({
|
|||
)}
|
||||
</ul>
|
||||
|
||||
{!officialSearchOpen ? (
|
||||
{!officialSearchOpen && !templatePickerOpen ? (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOfficialSearchOpen(true)}
|
||||
|
|
@ -857,7 +1082,15 @@ function EditCourseField({
|
|||
>
|
||||
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,6 +1431,7 @@ function CreateSessionCard({
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOfficialSearchOpen(true)}
|
||||
|
|
@ -1193,6 +1439,14 @@ function CreateSessionCard({
|
|||
>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) må kalleren sende inn
|
||||
NEGERTE verdier, samme triks som allerede brukt i
|
||||
individual_tournaments.py sin til-par-normaliserte rangeringsnøkkel
|
||||
(2026-08-04)."""
|
||||
if not values:
|
||||
return None
|
||||
kept = sorted(values, reverse=True)[:best_n] if best_n is not None else list(values)
|
||||
if mode == "sum":
|
||||
return sum(kept)
|
||||
return sum(kept) / len(kept)
|
||||
|
|
|
|||
69
teecup-beskrivelse.md
Normal file
69
teecup-beskrivelse.md
Normal 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.
|
||||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue