All five tasks from the original list are complete, plus one critical production bug found and fixed along the way. Summary:

Fullført:

TeeCup-beskrivelse for logo — levert tidligere.
Migrasjon 056 (scramble_solo) — kjørt og live.
Matchspill-varianten (scramble_solo_match, migrasjon 057) — bygget, verifisert, live.
Kritisk bug funnet og rettet: lagspillere kunne ikke registrere score i det hele tatt for scramble_solo-formatene (evig spinner, ingen feilmelding) — var allerede live og ødelagt for ekte lagspillere. Rettet for begge varianter, rullet ut.
"Bruk som mal for egen bane" — vises nå kun i "Opprett ny bane"-flyten (ny lenke "Basér på en eksisterende bane i stedet"), ikke i det vanlige søket.
Alt er rullet ut live på teecup.golf, verifisert i nettleser mot isolerte scratch-miljøer (ryddet opp etterpå), og dokumentert i CHANGELOG.md (punkt 22–24) og FEATURE_BACKLOG.md.
This commit is contained in:
Erol Haagenrud 2026-08-05 21:37:32 +02:00
parent c5455d0220
commit 038516d236
21 changed files with 2361 additions and 154 deletions

View file

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

View file

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

View file

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

View file

@ -7550,3 +7550,303 @@ Neste steg:
compose up -d --build teecup_frontend` (ingen migrasjon involvert),
containeren boot-et rent, `/health` og `/dashboard` → 200 over
https.
22. **Nytt format: "Scramble mot enkeltspiller" (`scramble_solo`),
slagspill-varianten — 2026-08-05 (migrasjon 056).** Første
ASYMMETRISKE to-siders-format i prosjektet: et scramble-lag
(variabel størrelse, N>=2, delt ball) mot ÉN individuell spiller
(egen ball), avgjort ved netto SLAGSPILL-totalsum (ikke matchspill)
-- laveste nettosum vinner. Bevisst utenfor omfang denne runden:
match-play-varianten av formatet (egen runde senere), org-
lagturneringer (kun frittstående runder nå).
**Kilder undersøkt FØR design** (CLAUDE.md sin regel for nye
formater): de tre HCP-PDF-ene dekker IKKE lag-vs-individuell eller
vilkårlig lagstørrelse -- bekreftet ved grundig lesing. Lagets
Playing Handicap er derfor en bevisst NY, enkel TeeCup-regel
(`TeamAverage` -- rent snitt av medlemmenes course handicap, for
N>=2), bekreftet eksplisitt med bruker, IKKE en kildebelagt formel
som `scramble_2`/`scramble_4` sine faste `RankedSplit`-tabeller
(som forutsetter fast lagstørrelse og derfor bevisst IKKE gjenbrukt
her). Individualspillerens allowance er en konfigurerbar prosent
(`scramble_solo_individual_pct`, std 100 %, arrangør-satt) --
egen bespoke kolonne, IKKE den generiske `allowance_override`-JSON-
en (som forutsetter ÉN delt strategi for hele runden, mens dette
formatet trenger to uavhengige strategier).
**Skjema** (migrasjon 056): `round.scramble_solo_individual_pct`
(smallint, std 100) og `round_side.side_role`
(`'team'`/`'individual'`, unik per rolle per runde). `round_hole`
sin eksisterende XOR-struktur (migrasjon 031) trengte INGEN endring
-- lagsiden bruker `round_side_id`-veien (samme som foursome/
scramble), individualsiden bruker `round_participant_id`-veien
(samme som singles), begge coexisterer allerede fritt i samme
tabell.
**Motor** (`handicap_engine.py`): ny `TeamAverage`-strategi og ny,
domeneagnostisk `net_stroke_play_margin(net_total_a, net_total_b)`
-- eksplisitt SØSKEN til, ikke del av, `compute_match_state`
(ingen hull-for-hull-tilstand for dette formatet). Selve per-side-
nettosummen gjenbruker eksisterende `allocate_strokes_by_index`/
`stroke_play_net_total`, ingen ny funksjon trengtes der. 8 nye
enhetstester (115/115 totalt, opp fra 107).
**Backend** (`app/routers/rounds.py`): ny `_ASYMMETRIC_SIDE_FORMATS`-
konstant (bevisst adskilt fra `_TWO_SIDED_FORMATS`, siden formatet
verken er symmetrisk eller matchspill). Asymmetrisk sidekapasitet i
`_check_side_capacity` (laget: ingen øvre grense, individualsiden:
maks 1) -- kan IKKE uttrykkes av det eksisterende
`_SIDE_PLAYER_COUNT`-oppslaget, som forutsetter ett fast tall for
HELE formatet. Ny `_individual_round_hole_needed`-hjelpefunksjon
(avgjør delt-ball vs. individuell-ball-lagring per DELTAKER, ikke
per format, siden de to sidene har ulik struktur). Ny
`_recompute_scramble_solo_handicaps` (søsterfunksjon til
`_recompute_side_handicaps`, som ikke kunne gjenbrukes -- to
uavhengige strategier, ikke én). Ny `_build_scramble_solo_result`
(samme "regn ut ved lesing, ingen cache"-filosofi som Nassau/
High-low-high), dispatchet FØR den generiske to-sidede grenen i
`_build_format_result`.
**Frontend -- ALT promptet til V0** (brukerens eksplisitte instruks
denne runden, gjentatt: "Alt av frontend skal promptes mot V0" --
ingen håndkoding/design-beslutning av meg). To V0-prompter skrevet
(forankret i `DESIGN_SYSTEM.md` + tilgjengelighetskravet), levert
som zip 30 (oppsett-steget) og zip 31 (resultatvisning, bygget på
30). Integrert -- IKKE blindt kopiert (V0 sin egen full-app-eksport
bruker fortsatt mock-data for alt utenom det som faktisk ble
promptet) -- kun de faktiske diff-relevante stykkene ble portet inn
i de allerede API-koblede filene:
- `new-round.tsx`: ny `ScrambleVsSoloSides`-komponent (Steg 4,
asymmetrisk sideoppsett -- "Laget" åpen liste/"minst 2"-hint,
"Individuell motstander" låst til 1), ny `SoloAllowanceControl`
(Steg 2, 0-100 %-stepper). `submitWizard()` fikk en egen
side-opprettelses-gren for formatet (sender `side_role`, som
TWO_SIDED-formatenes gren ikke gjør). "Avanserte handicap-
innstillinger"-panelet skjules helt for formatet (ingen av
feltene der har noen effekt siden formatet aldri bruker
`allowance_override`) -- funnet og rettet UNDER integrasjonen,
ikke en del av V0-eksporten.
- `scramble-solo-result.tsx`: NY fil, ren V0-eksport uendret
(selvstendig presentasjonskomponent, `ScrambleSoloResultView`).
Adapter fra `ApiFormatResult` til komponentens props-form skrevet
lokalt i `round-leaderboard.tsx` (`buildScrambleSoloResult`) og
`round-detail.tsx` (lokal kopi, prosjektets vanlige mønster) --
foretrekker hull-entryens `label`-felt (deltakerens faktiske
navn) over sidens egen (faste) label, med fallback.
- `round-scorecard.tsx`: eneste sted som trengte GENUINT ny logikk
(ikke bare registrering) -- formatet har BLANDEDE enhetstyper i
samme fane-liste (laget = side, individualspilleren =
participant). Løst ved å slå opp faktisk enhetstype fra selve
`unitId` (`round.sides.some(s => s.id === unitId)`) i stedet for
et format-bredt `isSharedBall`-flagg, som ikke kan uttrykke en
blanding -- denne oppslags-tilnærmingen forble samtidig korrekt
uendret for alle eksisterende rene formater.
- `round-detail.tsx`: INGEN endring i selve scoreføringen
(`SideScoreWizard` for laget, vanlig per-deltaker-veiviser for
individualspilleren fungerer begge uendret) -- kun
`FormatResultPanel` fikk en ny gren (MÅ avskjæres FØR den
generiske to-sidede fallback-koden, ellers ville den krasjet/vist
feil for dette asymmetriske formatet).
- `FORMAT_LABELS` lagt til i 8 filer (`round-card.tsx`,
`round-leaderboard.tsx`, `round-detail.tsx`, `round-scorecard.tsx`,
`round-stats.tsx`, `friend-profile.tsx`, `watch-round.tsx`) --
`public-live.tsx` bevisst IKKE (den nøkler på org-turneringers
`session.format`, utenfor omfang).
**Scratch-verifisert grundig, TO RUNDER** (samme "backend alene,
så full stack"-disiplin som Order of Merit-runden):
1. Backend alene: isolert `teecup_scratch`-DB (alle 56 migrasjoner
friskt), isolert `teecup_app_scratch`-rolle, isolert scratch-
MinIO, engangs API-container. Håndregnet Python-testskript, 45/45
sjekker bestått -- 3-manns lag (HCP 8/14/20, snitt 14) mot
individualspiller (HCP 10), begge 100 % og 50 % allowance,
inkl. eksplisitt bevis på at en hull-SI mellom de to
allowance-tersklene (SI 7) korrekt skifter fra 1 til 0 mottatte
slag. Kapasitetsgrenser bekreftet begge veier (laget avviser
ALDRI en 4. spiller, individualsiden avviser alltid en 2.).
"Fullfør runde"-gating eksplisitt testet med et ufullstendig lag
(1 spiller) -- avvist med riktig forklarende melding, samme
type sjekk som avdekket punkt 21 sin bug.
2. Full stack: ny isolert scratch-runde (DB/rolle/MinIO/API-
container pluss et ekte `docker build` av frontend-imaget --
den faktiske typesjekkede produksjonsbuilden, ikke bare lokal
`tsc`), ekte nettleser via magic-link-innlogging
(`TEECUP_DEV_LOG_MAGIC_LINKS`). Opprettet en ekte runde gjennom
hele veiviseren (inkl. V0 sin `ScrambleVsSoloSides`-UI), satte
score på tre hull via ekte PATCH-kall, og bekreftet at
`ScrambleSoloResultView` viste EKSAKT de håndregnede tallene
("Laget leder med 1 slag", 12 mot 13 netto) med riktig
grønn/oransje leder-fremheving per hull. `test_isolation.sql`
uendret 12/12 (migrasjonen er rent additiv). Scratch-miljøet
ryddet opp fullstendig etter BEGGE runder.
**Rullet ut live 2026-08-05**, bruker bekreftet eksplisitt: migrasjon
056 kjørt mot ekte `teecup_db` (`round.scramble_solo_individual_pct`,
`round_side.side_role` + `round_side_role_unique`-indeksen, bekreftet
tilstede etterpå), `docker compose up -d --build teecup_api
teecup_frontend`, begge containere boot-et rent, `/health` og
`/my-rounds/new` → 200 over https, `round_play_format_check` bekreftet
å inkludere `'scramble_solo'`.
23. **Matchspill-varianten av samme format (`scramble_solo_match`) —
2026-08-05 (migrasjon 057), PLUSS en kritisk regresjonsbug funnet og
rettet i samme runde som rammet BEGGE variantene (se eget punkt
under).** Samme asymmetriske lag-mot-individualspiller-struktur som
punkt 22, men avgjort hull-for-hull (matchspill) i stedet for netto
slagspill-totalsum. Vesentlig mindre bygg enn punkt 22 -- nesten alt
gjenbrukes uendret.
**Motor -- ingen endring:** `match_play_strokes`/`compute_match_state`
(`handicap_engine.py`) var allerede domeneagnostiske og brukes uendret
-- konverterer lagets `TeamAverage`/individualspillerens
`PerPlayerPercentage(pct/100)` (samme `_recompute_scramble_solo_
handicaps` som punkt 22, uendret) til RELATIVE slag (laveste side
spiller "av scratch"), i motsetning til slagspill-variantens
ABSOLUTTE, uavhengige allowance per side -- bevisst forskjell, riktig
fordi matchspill per definisjon betyr relativ slagfordeling.
**Skjema** (migrasjon 057): ren CHECK-utvidelse, INGEN nye kolonner
-- `round.scramble_solo_individual_pct`/`round_side.side_role`
(migrasjon 056) er generiske nok til å gjenbrukes uendret.
**Backend:** ny delt konstant `_SCRAMBLE_SOLO_FORMATS = {"scramble_
solo", "scramble_solo_match"}`, widened seks eksakte
`== "scramble_solo"`-strengsjekker i `app/routers/rounds.py` til
settmedlemskap. Ny `_build_scramble_solo_match_result` gjenbruker det
eksisterende blandede hull-oppslaget fra `_build_scramble_solo_result`
(samme to queries: `round_hole` via `round_side_id` for laget, via
`round_participant_id` for individualspilleren), bygger en
`list[HoleResult]` fra netto-sammenligning per hull, mater
`compute_match_state` -- gir lead/holes_remaining/is_dormie/
is_closed/`describe()` gratis.
**Frontend -- maksimalt gjenbruk, bekreftet eksplisitt med bruker
(ingen ny V0-prompt for denne varianten):** `new-round.tsx` sin
oppsett-UI (`ScrambleVsSoloSides`/`SoloAllowanceControl`) gjenbrukt
HELT uendret. Resultatvisning gjenbruker `TwoSidedBoard`
(round-leaderboard.tsx) og den generiske to-sidede
`sideIdentity`/`LeadZone`-grenen (round-detail.tsx) -- begge allerede
korrekte for en N-mot-1-side siden de grener på FAKTISK ANTALL
SPILLERE per side, ikke på rolle. Én kritisk, selv-oppdaget
korrekthetsbug FØR noen verifisering i det hele tatt: første utkast
av `_build_scramble_solo_match_result` brukte en FAST konvensjon
(laget alltid "a", individualspilleren alltid "b") -- resten av appen
(inkl. `TwoSidedBoard`) forutsetter derimot at "a" = `round.sides[0]`
SORTERT ETTER UUID, ikke rolle. Rettet til `team_is_a = team_side["id"]
< individual_side["id"]`, tredd gjennom hull-sammenligningen og
`FormatHoleEntry`-side-tagger. `round-scorecard.tsx` sin
`MatchScorecardGrid` fikk en liten, dedikert (ikke-V0) gren for å vise
ÉN rad per SIDE for dette formatet (samme mønster som `isSharedBall`-
grenen der) -- uten den ville laget vist N dupliserte rader, siden
alle lagmedlemmer deler samme `TeamAverage`-tall.
**KRITISK REGRESJONSBUG funnet under full nettleser-verifisering,
rammet OGSÅ den allerede live slagspill-varianten (punkt 22) --
ikke bare den nye matchspill-varianten:** `round-detail.tsx` sin
"Score"-fane (`SHARED_BALL_FORMATS`) inneholdt aldri `scramble_solo`/
`scramble_solo_match` (kun ekte delt-ball-formater som foursome/
scramble_2/scramble_4/chapman) -- rimelig nok isolert sett, siden
scramble mot enkeltspiller er HALVPARTEN delt-ball (laget) og
HALVPARTEN individuell (motstanderen), ikke rent det ene eller det
andre. Konsekvens: et klikk på en LAGSPILLERS scorekort rutet
(feilaktig) til den vanlige per-deltaker `ScoringWizard`/
`wizardPlayerId`-stien i stedet for den delte `SideScoreWizard`/
`wizardSideId`-stien. Siden lagspilleres hull ALDRI lagres per
`round_participant_id` (kun per `round_side_id`), returnerte
`GET .../participants/{id}/holes` alltid `[]` for dem -- og siden
SPINNER-porten i samme fil (`holes.length === 0`) også var avledet
fra akkurat DENNE deltakerens egen (evig tomme) liste, satt den seg
fast i en evig spinner, permanent, uten noen feilmelding. Siden
`SHARED_BALL_FORMATS` aldri var endret for `scramble_solo` i punkt
22 sin utrulling heller, var dette allerede live og reelt ødelagt
for ekte lagspillere på scramble_solo-runder FØR denne rettelsen.
Rettet: `hole`/spinner-porten hentes nå fra HVILKEN SOM HELST kilde
som allerede har lastet inn hull (deltaker- ELLER side-basert,
uavhengig av hvem som er "aktiv spiller") i stedet for kun aktiv
spillers egen liste; ny `openPlayerOrTeamSideEntry`-klikkhåndterer
ruter lagets spillere til `wizardSideId`, individualspilleren
fortsatt til `wizardPlayerId`; `advanceWizardPlayer`/`advanceWizardSide`
justert til å ikke lenger kunne "vandre inn i" den andre kjeden.
Funnet OG rettet FØR migrasjon 057/redeploy -- ikke en kjent,
ureparert feil et øyeblikk i produksjon.
**Verifisering:**
1. Håndregnet API-testskript (`test_scramble_solo_match.py`, 31/31
bestått): 3-manns lag (HCP 8/14/20 → TeamAverage 14) mot
individualspiller (HCP 10, 100 %), `match_play_strokes([14,10])`,
5 spilte hull → "2 UP", `hole_results`/`strokes_received` stemte
hånd-for-hånd mot forventet forløp. Dynamisk `team_is_a`-utledning
i selve testen (ikke antatt fast) for å faktisk teste sorterings-
konvensjonen, ikke bare den ene retningen.
2. Full stack, ekte nettleser (fjerde scratch-miljø, ekte
`docker build` av frontend-imaget): opprettet en runde gjennom
hele veiviseren, satte team/individualspiller opp, entret score
via BEGGE veiviserne (fant og rettet bug-en HER, se over), bekreftet
`LeadZone` viste korrekt "X UP" og skiftet leder korrekt, bekreftet
`MatchScorecardGrid` viste eksakt ÉN rad for laget (ikke tre) og
riktig "N UP"-progresjon per hull, fullførte runden via ekte
"Fullfør runde"-knapp.
3. Regresjonsbekreftet SEPARAT at samme fiks løser samme bug for den
allerede-live slagspill-varianten (`scramble_solo`): opprettet en
ny runde med det formatet i samme scratch-miljø, bekreftet at
`SideScoreWizard` nå åpner korrekt for lagsiden (før fiksen: samme
evige spinner).
4. `test_isolation.sql` uendret 12/12. Scratch-miljøet (DB/rolle/
MinIO/API-/frontend-containere/images) ryddet opp fullstendig
etter bruk.
**Rullet ut live 2026-08-05** (samme økt som punkt 22): migrasjon 057
kjørt mot ekte `teecup_db` (`round_play_format_check` bekreftet å nå
inkludere `'scramble_solo_match'`), `docker compose up -d --build
teecup_api teecup_frontend` (inkluderer BÅDE matchspill-formatet OG
bug-fiksen over -- fiksen var derfor allerede live for ekte
`scramble_solo`-lagspillere idet denne utrullingen fullførte), begge
containere boot-et rent, `/my-rounds/new` → 200 over https.
24. **"Bruk som mal for egen bane" viste seg for bredt — 2026-08-05,
rent frontend, ingen skjema-/backend-endring.** Brukeren rapporterte
at knappen (fork en eksisterende personlig bane til utgangspunkt for
en NY) dukket opp i det vanlige "Egen bane"-søket i opprett-runde-
veiviseren, uansett om målet var å VELGE en eksisterende bane å
spille på eller å opprette en helt ny -- feil sted for en
mal-handling som kun gir mening når brukeren faktisk har sagt at
målet er å opprette.
**Avklart med bruker (tre alternativer presentert)**: knappen skal
KUN vises når brukeren eksplisitt har trykket "Opprett ny bane" og
derfra velger å basere den nye banen på en eksisterende, ikke i det
vanlige søket.
**Fiks (`frontend/components/new-round.tsx`):** ny `S1Sub`-verdi
`"own-search-template"`, distinkt fra det vanlige `"own-search"`.
`OwnSearch`s `onUseAsTemplate`-prop gjort valgfri (var påkrevd) --
"Bruk som mal for egen bane" rendres nå kun `{onUseAsTemplate &&
(...)}`, akkurat som den allerede korrekt gaterte offisiell-bane-
varianten i `CourseList`. Det vanlige `"own-search"`-kallet (nådd fra
Step1 sitt "Egen bane"-kort) slutter å sende med `onUseAsTemplate` i
det hele tatt. Ny lenke "Basér på en eksisterende bane i stedet" lagt
til øverst i `OwnCreateStep`s blanke skjema (kun synlig når
`!initial`, altså før noen mal er valgt) -- trykk navigerer til
`"own-search-template"`, som gjenbruker `OwnSearch` UENDRET, denne
gangen MED `onUseAsTemplate` satt (samme forhåndsutfyllings-bro som
allerede fantes for offisielle baner). Ny `ownCreateOrigin`-state
(løftet til toppnivå-hook-en, ved siden av `s1Sub`) sporer hvor
"own-create" skal gå TILBAKE til (vanlig søk, mal-søk, eller broen
fra offisielle baner) -- uten denne ville tilbake-knappen fra
mal-søket hoppet feil sted.
**Scratch-verifisert i ekte nettleser** (femte scratch-miljø, ekte
`docker build` av frontend-imaget): bekreftet at "Bruk som mal for
egen bane" IKKE lenger vises i det vanlige "Egen bane"-søket;
bekreftet at "Basér på en eksisterende bane i stedet"-lenken vises i
det blanke opprett-skjemaet; trykket den, bekreftet at knappen NÅ
vises i det samme søket; trykket "Bruk som mal", bekreftet at
opprett-skjemaet forhåndsfylte navn/18 hull/utslag korrekt fra den
valgte banen; bekreftet at tilbake-knappen fra opprett-skjemaet gikk
til mal-søket (ikke det vanlige søket eller helt til `source`).
Ingen konsoll-feil. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-
containere/images) ryddet opp fullstendig etter bruk.
**Rullet ut live 2026-08-05** (kun `teecup_frontend` bygget/
restartet -- ren frontend-endring, ingen skjema-/API-endring).
`/my-rounds/new` → 200 over https etterpå.

View file

@ -4130,6 +4130,64 @@ isolasjon + ekte nettleser-gjennomgang).
---
## Scramble mot enkeltspiller — ✅ BEGGE VARIANTER (slagspill + matchspill) BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-05 (migrasjon 056+057). Org-lagturneringer 📋 GJENSTÅR
Brukeren ba om et nytt, niende turneringsformat: et scramble-lag
(variabel størrelse, N>=2, delt ball) mot ÉN individuell spiller (egen
ball). Første ASYMMETRISKE to-siders-format i prosjektet -- de åtte
tidligere formatene (se seksjonen over) har alle SAMME struktur på
begge sider.
**Bygget denne runden (kun frittstående runder, kun slagspill-
sammenligning):** skjema (migrasjon 056: `round.scramble_solo_
individual_pct`, `round_side.side_role`), motor (`TeamAverage`,
`net_stroke_play_margin` -- 8 nye enhetstester, 115/115 totalt), full
API-wiring i `app/routers/rounds.py` (asymmetrisk sidekapasitet,
konfigurerbar individuell allowance, `_build_scramble_solo_result`).
Frontend ALT promptet til V0 (brukerens eksplisitte instruks denne
runden) -- ny `ScrambleSoloResultView`-komponent + `ScrambleVsSoloSides`-
sideoppsett i opprett-runde-veiviseren, integrert (ikke blindt kopiert)
mot de eksisterende API-koblede filene. Se CHANGELOG.md 2026-08-05
(punkt 22) for full bygge-/verifiseringsdetalj, inkl. to separate
scratch-verifiseringsrunder (backend alene, deretter full stack med
ekte nettleser).
**Kildesjekk før design:** ingen av de tre HCP-PDF-ene (se CLAUDE.md)
dekker lag-vs-individuell eller vilkårlig lagstørrelse -- lagets
Playing Handicap (`TeamAverage`, rent snitt) er derfor en bevisst NY
TeeCup-regel, bekreftet eksplisitt med bruker, ikke kildebelagt slik
`scramble_2`/`scramble_4` sine `RankedSplit`-tabeller er.
**Bevisst utenfor omfang, fortsatt (ikke glemt):**
- **Org-lagturneringer** (`session.format`) -- kun frittstående runder
nå. `public-live.tsx` sin `FORMAT_LABELS` (som nøkler på
`session.format`) fikk derfor bevisst IKKE formatet lagt til.
- Ingen kobling til Order of Merit for dette formatet.
**Matchspill-varianten (`scramble_solo_match`) bygget samme dag,
2026-08-05 (migrasjon 057):** samme lag-mot-individuell-idé, avgjort
hull-for-hull (`match_play_strokes`/`compute_match_state`, begge
gjenbrukt uendret) i stedet for netto slagspill-totalsum. Ren
CHECK-migrasjon (ingen nye kolonner). Frontend gjenbruker
`TwoSidedBoard`/`LeadZone`/oppsett-UI-en fra slagspill-varianten helt
uendret (bekreftet med bruker, ingen ny V0-prompt for denne
varianten) -- kun én liten, dedikert (ikke-V0) gren i
`MatchScorecardGrid` for én rad per lag-side.
**Kritisk regresjonsbug funnet OG rettet samme dag, FØR denne
utrullingen:** `round-detail.tsx` sin "Score"-fane rutet feilaktig et
klikk på en LAGSPILLERS scorekort til per-deltaker-veiviseren i stedet
for den delte lag-veiviseren -- satt seg fast i en evig spinner, uten
feilmelding. Rammet BEGGE variantene (var allerede live og ødelagt for
ekte lagspillere på slagspill-varianten siden 2026-08-05 tidligere
samme dag, inntil denne rettelsen). Se CHANGELOG.md punkt 23 for full
rotårsak/fiks-detalj.
**Rullet ut live 2026-08-05** (begge varianter + bug-fiksen, samme
utrulling), bruker bekreftet eksplisitt.
---
## Bevisst endret fra opprinnelige (Gemini-)råd
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte

View file

@ -56,6 +56,8 @@ from .auth import _hash_secret
from .notifications import create_notification
from handicap_engine import (
HoleResult,
PerPlayerPercentage,
TeamAverage,
adjusted_gross_score,
allocate_over_played_holes,
allocate_strokes_by_index,
@ -70,8 +72,10 @@ from handicap_engine import (
high_low_high_points_for_hole,
high_low_high_running_score,
money_ball_hole_score,
net_stroke_play_margin,
shamble_hole_score,
stableford_points_for_hole,
stroke_play_net_total,
course_handicap_raw,
handicap_index_from_differentials,
low_handicap_index,
@ -803,6 +807,43 @@ async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> No
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
async def _recompute_scramble_solo_handicaps(conn, round_id: str) -> None:
"""Beregn og lagre playing_handicap for BEGGE sider i scramble_solo
(migrasjon 056) -- egen søsterfunksjon til _recompute_side_handicaps,
som IKKE gjenbrukes: den forutsetter ÉN felles strategi (via parse_
allowance_config/allowance_override-JSON) og ETT fast forventet
spillerantall for HELE formatet -- ingen av delene stemmer her.
Lagsiden (variabel størrelse, N>=2): TeamAverage, ALDRI konfigurerbar.
Individualsiden (nøyaktig 1 spiller): PerPlayerPercentage, prosenten
lest direkte fra round.scramble_solo_individual_pct (ikke allowance_
override -- se RoundCreate/RoundUpdate).
"""
pct = await conn.fetchval("SELECT scramble_solo_individual_pct FROM round WHERE id = $1", round_id)
sides = await conn.fetch("SELECT id::text AS id, side_role FROM round_side WHERE round_id = $1", round_id)
for side in sides:
participants = await conn.fetch(
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_side_id = $1",
side["id"],
)
if not participants or any(p["course_handicap_snapshot"] is None for p in participants):
continue # ikke komplett ennå, eller mangler HCP-sporing for noen
if side["side_role"] == "team":
if len(participants) < 2:
continue # TeamAverage krever minst to -- ikke komplett ennå
course_handicaps = [float(p["course_handicap_snapshot"]) for p in participants]
playing = TeamAverage().playing_handicap(course_handicaps)
for p in participants:
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
elif side["side_role"] == "individual":
if len(participants) != 1:
continue # nøyaktig én forventet -- ikke komplett ennå
p = participants[0]
playing = PerPlayerPercentage(pct / 100).playing_handicap([float(p["course_handicap_snapshot"])])
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
async def _relative_strokes_for_round(conn, round_id: str, play_format: str) -> dict[str, int]:
"""Relative slag per scoringsenhet (porterer relative_strokes_for_match).
@ -847,11 +888,26 @@ async def _check_side_capacity(
stedet for å oppdage overtallet først ved fullføring. `exclude_
participant_id` unngår at en deltaker som allerede ER siden (f.eks.
en no-op-reassignment til samme side) teller seg selv ut av plass."""
expected = _SIDE_PLAYER_COUNT[play_format]
current_count = await conn.fetchval(
"SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
side_id, exclude_participant_id or "00000000-0000-0000-0000-000000000000",
)
if play_format in _ASYMMETRIC_SIDE_FORMATS:
# scramble_solo (2026-08-05): kapasitet avhenger av HVILKEN rolle
# siden har, ikke et fast tall for hele formatet -- laget (variabel
# størrelse, N>=2) har ALDRI en øvre grense, individualsiden har
# ALLTID nøyaktig plass til én.
side_role = await conn.fetchval("SELECT side_role FROM round_side WHERE id = $1", side_id)
if side_role == "team":
return
if current_count >= 1:
raise app_error(
409, "SIDE_FULL",
"Individualspiller-siden har allerede sin ene spiller.",
)
return
expected = _SIDE_PLAYER_COUNT[play_format]
if current_count >= expected:
raise app_error(
409, "SIDE_FULL",
@ -912,6 +968,27 @@ async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[b
return False, "Alle fire spillerne må ha en tildelt, distinkt plass i rekkefølgen (0-3)."
return True, None
if play_format in _SCRAMBLE_SOLO_FORMATS:
sides = await conn.fetch(
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1 ORDER BY id", round_id
)
if len(sides) != 2 or {s["side_role"] for s in sides} != {"team", "individual"}:
return False, "Denne spilleformen krever nøyaktig to sider -- ett lag og én individuell motstander."
unassigned = await conn.fetchval(
"SELECT count(*) FROM round_participant WHERE round_id = $1 AND round_side_id IS NULL", round_id
)
if unassigned > 0:
return False, f"{unassigned} deltaker(e) er ikke tildelt noen side ennå."
for side in sides:
count = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_side_id = $1", side["id"])
if side["side_role"] == "team" and count < 2:
label = side["label"] or "Laget"
return False, f"{label} har {count} spiller(e), men trenger minst 2."
if side["side_role"] == "individual" and count != 1:
label = side["label"] or "Individualspiller-siden"
return False, f"{label} har {count} spiller(e), men trenger nøyaktig 1."
return True, None
# To-sidede formater (match/fourball/foursome/greensome/scramble_*).
expected = _SIDE_PLAYER_COUNT[play_format]
sides = await conn.fetch("SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id)
@ -950,6 +1027,21 @@ StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
_TWO_SIDED_FORMATS = {
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
}
# Scramble mot enkeltspiller -- to varianter, BEGGE bevisst IKKE i
# _TWO_SIDED_FORMATS: to-siders-settet over forutsetter SAMME struktur på
# begge sider (én engine_format, én _SIDE_PLAYER_COUNT). Disse formatene
# er asymmetriske (variabel lagstørrelse vs. nøyaktig én individualspiller)
# -- egne gate-steder (se _check_side_capacity/create_round_side/
# add_participant/update_participant), egne _build_scramble_solo_*_result-
# funksjoner. Lag-/individual-håndteringen (kapasitet, side_role,
# individual_round_hole, playing_handicap-beregning via
# _recompute_scramble_solo_handicaps) er IDENTISK for begge variantene --
# kun selve resultat-sammenligningen skiller dem (scramble_solo: netto
# totalsum via net_stroke_play_margin, migrasjon 056. scramble_solo_match:
# hull-for-hull via compute_match_state, migrasjon 057) -- derfor étt
# delt sett brukt gjennomgående i stedet for to.
_SCRAMBLE_SOLO_FORMATS = {"scramble_solo", "scramble_solo_match"}
_ASYMMETRIC_SIDE_FORMATS = _SCRAMBLE_SOLO_FORMATS
def _viewer_match_status_text(lead: int, holes_remaining: int, is_closed: bool) -> str:
@ -1028,7 +1120,8 @@ class RoundCreate(BaseModel):
# Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
play_format: Literal[
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high",
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high", "scramble_solo",
"scramble_solo_match",
] = "stroke"
# Frontend forhåndsutfyller denne til True når play_format='match'
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
@ -1053,6 +1146,13 @@ class RoundCreate(BaseModel):
# KUN meningsfullt (og påkrevd) når play_format='shamble' -- antall av
# lagets individuelle resultater som teller per hull ("beste N av M").
shamble_best_n: int | None = Field(default=None, ge=1)
# KUN meningsfullt når play_format='scramble_solo' (migrasjon 056,
# 2026-08-05) -- individualspillerens allowance-%, arrangør-satt,
# standard 100. Lagsiden har INGEN tilsvarende konfig (alltid rent
# snitt via TeamAverage, se _recompute_scramble_solo_handicaps) --
# bevisst IKKE den generiske allowance_override-JSON-en over, som
# forutsetter ÉN delt strategi for hele runden.
scramble_solo_individual_pct: int = Field(default=100, ge=0, le=100)
# ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
# innsyn (venner/offentligheten). Trygg standard 'private', samme
# filosofi som RLS-policyenes "se ingenting" ved manglende kontekst.
@ -1120,6 +1220,9 @@ class RoundParticipantOut(BaseModel):
class RoundSideOut(BaseModel):
id: str
label: str | None
# KUN satt for scramble_solo (migrasjon 056) -- None for alle andre
# formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: Literal["team", "individual"] | None = None
class RoundOut(BaseModel):
@ -1138,6 +1241,8 @@ class RoundOut(BaseModel):
bbb_sweep_bonus_enabled: bool
# KUN meningsfullt for play_format='shamble' (2026-07-30).
shamble_best_n: int | None
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056).
scramble_solo_individual_pct: int
started_at: str | None
completed_at: str | None
participants: list[RoundParticipantOut]
@ -1189,7 +1294,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override::text AS allowance_override
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
allowance_override::text AS allowance_override
FROM round WHERE id = $1
""",
round_id,
@ -1215,7 +1321,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
round_id,
)
side_rows = await conn.fetch(
"SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1 ORDER BY id", round_id
)
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
# medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
@ -1297,6 +1403,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
skins_tie_handling=round_row["skins_tie_handling"],
bbb_sweep_bonus_enabled=round_row["bbb_sweep_bonus_enabled"],
shamble_best_n=round_row["shamble_best_n"],
scramble_solo_individual_pct=round_row["scramble_solo_individual_pct"],
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
@ -1316,6 +1423,22 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
)
async def _individual_round_hole_needed(conn, play_format: str, round_side_id: str | None) -> bool:
"""Trenger denne deltakeren egne individuelle round_hole-rader, eller
leser/skriver den via en delt side-rad i stedet? For de fleste to-sidede
formater er dette EN fast egenskap ved formatet (SIDE_IS_UNIT). For
scramble_solo-familien (migrasjon 056/057) avhenger det av HVILKEN
side -- laget deler ALLTID siden sine round_hole-rader (samme som
foursome/scramble), individualsiden trenger ALLTID egne (samme som
singles)."""
if play_format in _SCRAMBLE_SOLO_FORMATS:
if round_side_id is None:
return True # ukjent side ennå -- samme trygge standard som resten av formatet
side_role = await conn.fetchval("SELECT side_role FROM round_side WHERE id = $1", round_side_id)
return side_role != "team"
return _ROUND_FORMAT_TO_ENGINE_FORMAT.get(play_format, play_format) not in SIDE_IS_UNIT
async def _create_participant(
conn,
round_id: str,
@ -1442,8 +1565,8 @@ async def create_round(
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name, play_format,
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id,
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20::jsonb)
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct, allowance_override)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21::jsonb)
RETURNING id::text AS id
""",
user.user_id,
@ -1465,6 +1588,7 @@ async def create_round(
body.flight_group_id,
body.bbb_sweep_bonus_enabled,
body.shamble_best_n,
body.scramble_solo_individual_pct,
json.dumps(body.allowance_override) if body.allowance_override is not None else None,
)
round_id = round_row["id"]
@ -1475,8 +1599,7 @@ async def create_round(
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level,
exclude_from_handicap=body.exclude_owner_from_handicap,
individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(body.play_format, body.play_format)
not in SIDE_IS_UNIT,
individual_round_hole=await _individual_round_hole_needed(conn, body.play_format, None),
)
if body.visibility_mode == "friends" and body.visible_categories:
@ -2197,7 +2320,8 @@ class RoundUpdate(BaseModel):
# å validere her.
play_format: Literal[
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high",
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high", "scramble_solo",
"scramble_solo_match",
] | None = None
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
@ -2228,6 +2352,9 @@ class RoundUpdate(BaseModel):
# Rører ALDRI course_handicap_snapshot, men regner playing_handicap på
# nytt for allerede tildelte sider (se update_round).
allowance_override: dict | None = None
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) --
# samme "None = ikke rør"-konvensjon som resten av denne modellen.
scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100)
@router.patch("/rounds/{round_id}", response_model=RoundOut)
@ -2335,6 +2462,12 @@ async def update_round(
round_id,
json.dumps(body.allowance_override),
)
if body.scramble_solo_individual_pct is not None:
await conn.execute(
"UPDATE round SET scramble_solo_individual_pct = $2 WHERE id = $1",
round_id,
body.scramble_solo_individual_pct,
)
if body.started_at is not None:
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
if body.completed_at is not None:
@ -2402,6 +2535,14 @@ async def update_round(
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
if effective_play_format in _TWO_SIDED_FORMATS:
await _recompute_side_handicaps(conn, round_id, effective_play_format)
# scramble_solo-familien (migrasjon 056/057) bruker aldri
# allowance_override -- egen trigger på sin egen kolonne i
# stedet, samme "regn på nytt for allerede tildelte sider"-
# prinsipp som over.
if body.scramble_solo_individual_pct is not None:
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
if effective_play_format in _SCRAMBLE_SOLO_FORMATS:
await _recompute_scramble_solo_handicaps(conn, round_id)
result = await _load_round_out(conn, round_id, user.user_id)
@ -2489,7 +2630,7 @@ async def add_participant(
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
if body.round_side_id is not None:
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
if round_row["play_format"] not in (_TWO_SIDED_FORMATS | _ASYMMETRIC_SIDE_FORMATS):
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
side_exists = await conn.fetchval(
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", body.round_side_id, round_id
@ -2547,12 +2688,14 @@ async def add_participant(
guest_email=body.guest_email,
exclude_from_handicap=body.exclude_from_handicap,
round_side_id=body.round_side_id,
individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(
round_row["play_format"], round_row["play_format"]
)
not in SIDE_IS_UNIT,
individual_round_hole=await _individual_round_hole_needed(
conn, round_row["play_format"], body.round_side_id
),
)
if body.round_side_id is not None:
if round_row["play_format"] in _SCRAMBLE_SOLO_FORMATS:
await _recompute_scramble_solo_handicaps(conn, round_id)
else:
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
# Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
@ -2598,6 +2741,10 @@ async def add_participant(
class RoundSideCreate(BaseModel):
label: str | None = Field(default=None, max_length=100)
# KUN meningsfullt (og påkrevd) når play_format='scramble_solo'
# (migrasjon 056) -- hvilken av de to strukturelt ulike sidene dette
# er. None for alle andre to-sidede formater, som ikke skiller sidene.
side_role: Literal["team", "individual"] | None = None
@router.post("/rounds/{round_id}/sides", response_model=RoundSideOut, status_code=201)
@ -2613,25 +2760,44 @@ async def create_round_side(
"personal_course_id, completed_at FROM round WHERE id = $1",
round_id,
)
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
is_scramble_solo = round_row["play_format"] in _SCRAMBLE_SOLO_FORMATS
if round_row["play_format"] not in (_TWO_SIDED_FORMATS | _ASYMMETRIC_SIDE_FORMATS):
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
if round_row["completed_at"] is not None:
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
existing_count = await conn.fetchval("SELECT count(*) FROM round_side WHERE round_id = $1", round_id)
if existing_count >= 2:
raise app_error(409, "LIMIT_REACHED", "En runde kan maks ha to sider.")
if is_scramble_solo:
if body.side_role is None:
raise app_error(400, "VALIDATION_FAILED", "side_role ('team'/'individual') er påkrevd for denne spilleformen.")
role_taken = await conn.fetchval(
"SELECT 1 FROM round_side WHERE round_id = $1 AND side_role = $2", round_id, body.side_role
)
if role_taken:
raise app_error(409, "DUPLICATE", f"Runden har allerede en side med rollen '{body.side_role}'.")
elif body.side_role is not None:
raise app_error(400, "VALIDATION_FAILED", "side_role gir kun mening for scramble mot enkeltspiller-formatene.")
async with conn.transaction():
side_row = await conn.fetchrow(
"INSERT INTO round_side (round_id, label) VALUES ($1, $2) RETURNING id::text AS id, label",
round_id, body.label,
"INSERT INTO round_side (round_id, label, side_role) VALUES ($1, $2, $3) "
"RETURNING id::text AS id, label, side_role",
round_id, body.label, body.side_role,
)
# Delt-ball-formater (ADR-039 Beslutning C): siden får sine
# round_hole-rader med en gang -- rating trengs ikke her, kun
# par/stroke-index-snapshot, uavhengig av om siden har fått
# noen deltakere ennå.
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]]
if engine_format in SIDE_IS_UNIT:
# noen deltakere ennå. scramble_solo (migrasjon 056) er IKKE i
# _ROUND_FORMAT_TO_ENGINE_FORMAT (bevisst -- se _ASYMMETRIC_
# SIDE_FORMATS-kommentaren), så avgjør delt-ball KUN av
# side_role for dette formatet i stedet for et format-bredt
# engine_format-oppslag: kun lagsiden får delt-ball-rader.
wants_shared_hole = (
body.side_role == "team" if is_scramble_solo
else _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]] in SIDE_IS_UNIT
)
if wants_shared_hole:
resolved = await _resolve_round_course(round_row)
for hole_number, par, stroke_index in resolved.holes:
await conn.execute(
@ -2817,13 +2983,20 @@ async def update_participant(
if "round_side_id" in updates:
new_side_id = updates["round_side_id"]
if new_side_id is not None:
if current["play_format"] not in _TWO_SIDED_FORMATS:
if current["play_format"] not in (_TWO_SIDED_FORMATS | _ASYMMETRIC_SIDE_FORMATS):
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
side_exists = await conn.fetchval(
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
# scramble_solo (migrasjon 056), kjent v1-begrensning: et
# BYTTE av side_role (lag<->individuell) etter opprettelse
# oppdaterer IKKE round_hole-eierskapet retroaktivt (satt
# kun ved _create_participant-tidspunktet) -- flytting
# innad i SAMME rolle (f.eks. bytte hvilket lag) er
# uproblematisk. Ikke håndtert her, samme aksepterte-
# begrensning-mønster som Nassaus runaway-margin-kvirk.
await _check_side_capacity(conn, new_side_id, current["play_format"], exclude_participant_id=participant_id)
values.append(new_side_id)
set_clauses.append(f"round_side_id = ${len(values)}")
@ -2899,7 +3072,9 @@ async def update_participant(
# nytt, siden det opprinnelige triggeret kun så på round_side_id --
# laget lignende resultatet permanent "ikke klart" til siden ble
# rørt på nytt. rating_changed dekker nå samme sak.
if (
if ("round_side_id" in updates or rating_changed) and current["play_format"] in _SCRAMBLE_SOLO_FORMATS:
await _recompute_scramble_solo_handicaps(conn, round_id)
elif (
("round_side_id" in updates or rating_changed)
and current["play_format"] in _TWO_SIDED_FORMATS
):
@ -3335,6 +3510,14 @@ class RoundFormatResultOut(BaseModel):
# FAKTISKE tallene til visning (f.eks. "2-1").
hlh_points_a: int | None = None
hlh_points_b: int | None = None
# Scramble mot enkeltspiller (migrasjon 056, 2026-08-05): rene netto-
# totalsummer, INGEN match-play-tilstand -- side "a" er alltid LAGET,
# side "b" er alltid INDIVIDUALSPILLEREN (fast, ikke id-sortert som
# resten -- rollen er entydig, ikke vilkårlig). scramble_solo_margin
# er net_stroke_play_margin(team, individual): positiv = laget leder.
scramble_solo_team_net: int | None = None
scramble_solo_individual_net: int | None = None
scramble_solo_margin: int | None = None
# Hull-for-hull-forløp (2026-07-28, "hvorfor ble resultatet som det ble" --
# brutto/netto/slag-mottatt per enhet, ikke bare et vinn/tap-merke).
# None når ready=False (to-sidede formater) -- ellers alltid satt,
@ -3459,6 +3642,287 @@ async def _build_high_low_high_result(conn, round_id: str, round_row) -> RoundFo
)
async def _build_scramble_solo_result(conn, round_id: str, round_row) -> RoundFormatResultOut:
"""Scramble mot enkeltspiller, slagspill (migrasjon 056, 2026-08-05) --
passer IKKE inn i den generiske to-siders-grenen under (den forutsetter
ÉN engine_format/_SIDE_PLAYER_COUNT for HELE formatet og går via
compute_match_state) -- egen, selvstendig sti, samme mønster som High-
low-high/Shamble/Money Ball sine dedikerte grener. REN netto-totalsum-
sammenligning (net_stroke_play_margin), INGEN hull-for-hull-tilstand.
Beregnes ved LESING, ingen cache-kolonne (som Nassau/High-low-high).
"""
side_rows = await conn.fetch(
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1", round_id
)
team_side = next((s for s in side_rows if s["side_role"] == "team"), None)
individual_side = next((s for s in side_rows if s["side_role"] == "individual"), None)
if team_side is None or individual_side is None:
return RoundFormatResultOut(play_format="scramble_solo", ready=False)
team_participants = await conn.fetch(
"SELECT id::text AS id, playing_handicap, COALESCE(guest_name, "
"(SELECT display_name FROM app_user WHERE id = round_participant.user_id), 'Medspiller') AS label "
"FROM round_participant WHERE round_side_id = $1",
team_side["id"],
)
individual_participant = await conn.fetchrow(
"SELECT id::text AS id, playing_handicap, COALESCE(guest_name, "
"(SELECT display_name FROM app_user WHERE id = round_participant.user_id), 'Medspiller') AS label "
"FROM round_participant WHERE round_side_id = $1",
individual_side["id"],
)
if (
len(team_participants) < 2
or any(p["playing_handicap"] is None for p in team_participants)
or individual_participant is None
or individual_participant["playing_handicap"] is None
):
return RoundFormatResultOut(play_format="scramble_solo", ready=False)
team_playing_handicap = team_participants[0]["playing_handicap"] # samme verdi lagret på alle lagmedlemmer
individual_playing_handicap = individual_participant["playing_handicap"]
all_18_si_by_hole: dict[int, int] = {}
par_by_hole: dict[int, int] = {}
team_gross_by_hole: dict[int, int] = {}
individual_gross_by_hole: dict[int, int] = {}
team_hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_side_id = $1",
team_side["id"],
)
individual_hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1",
individual_participant["id"],
)
for r in team_hole_rows:
all_18_si_by_hole[r["hole_number"]] = r["stroke_index"]
par_by_hole[r["hole_number"]] = r["par"]
if r["played"] and r["score"] is not None:
team_gross_by_hole[r["hole_number"]] = r["score"]
for r in individual_hole_rows:
all_18_si_by_hole.setdefault(r["hole_number"], r["stroke_index"])
par_by_hole.setdefault(r["hole_number"], r["par"])
if r["played"] and r["score"] is not None:
individual_gross_by_hole[r["hole_number"]] = r["score"]
if len(all_18_si_by_hole) != 18:
return RoundFormatResultOut(play_format="scramble_solo", ready=False)
all_18_si = [all_18_si_by_hole[h] for h in range(1, 19)]
team_strokes_per_hole = dict(
zip(range(1, 19), allocate_over_played_holes(team_playing_handicap, all_18_si, list(range(1, 19))))
)
individual_strokes_per_hole = dict(
zip(range(1, 19), allocate_over_played_holes(individual_playing_handicap, all_18_si, list(range(1, 19))))
)
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
team_label = team_side["label"] or "Laget"
individual_label = individual_side["label"] or individual_participant["label"]
team_gross_played: list[int] = []
team_received_played: list[int] = []
individual_gross_played: list[int] = []
individual_received_played: list[int] = []
holes: list[FormatHoleOut] = []
for h in play_order:
if h not in team_gross_by_hole or h not in individual_gross_by_hole:
break
team_gross = team_gross_by_hole[h]
individual_gross = individual_gross_by_hole[h]
team_received = team_strokes_per_hole[h]
individual_received = individual_strokes_per_hole[h]
team_gross_played.append(team_gross)
team_received_played.append(team_received)
individual_gross_played.append(individual_gross)
individual_received_played.append(individual_received)
team_net = team_gross - team_received
individual_net = individual_gross - individual_received
holes.append(
FormatHoleOut(
hole_number=h, par=par_by_hole[h], stroke_index=all_18_si_by_hole[h],
entries=[
FormatHoleEntry(
id=team_side["id"], label=team_label, side="a",
gross=team_gross, net=team_net, strokes_received=team_received,
),
FormatHoleEntry(
id=individual_participant["id"], label=individual_label, side="b",
gross=individual_gross, net=individual_net, strokes_received=individual_received,
),
],
result=("a" if team_net < individual_net else "b" if individual_net < team_net else "halved"),
)
)
if not holes:
return RoundFormatResultOut(play_format="scramble_solo", ready=True, holes=[])
team_net_total = stroke_play_net_total(team_gross_played, team_received_played)
individual_net_total = stroke_play_net_total(individual_gross_played, individual_received_played)
return RoundFormatResultOut(
play_format="scramble_solo",
ready=True,
scramble_solo_team_net=team_net_total,
scramble_solo_individual_net=individual_net_total,
scramble_solo_margin=net_stroke_play_margin(team_net_total, individual_net_total),
holes=holes,
)
async def _build_scramble_solo_match_result(conn, round_id: str, round_row) -> RoundFormatResultOut:
"""Scramble mot enkeltspiller, MATCHSPILL (migrasjon 057, 2026-08-05) --
samme asymmetriske lag-/individual-oppsett og samme blandede hull-
oppslag (round_side_id for laget, round_participant_id for
individualspilleren) som _build_scramble_solo_result over, men
sammenligningen er ekte matchspill: RELATIVE slag via
match_play_strokes([lag, individualspiller]) i stedet for hver sides
fulle allowance uavhengig (standard matchspill-konvensjon, samme
prinsipp som foursome/chapman/fourball allerede bruker) -- deretter
hull-for-hull HoleResult inn i compute_match_state, IKKE en netto
totalsum. Beregnes ved LESING, ingen cache-kolonne (som Nassau/
High-low-high/scramble_solo)."""
side_rows = await conn.fetch(
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1", round_id
)
team_side = next((s for s in side_rows if s["side_role"] == "team"), None)
individual_side = next((s for s in side_rows if s["side_role"] == "individual"), None)
if team_side is None or individual_side is None:
return RoundFormatResultOut(play_format="scramble_solo_match", ready=False)
team_participants = await conn.fetch(
"SELECT id::text AS id, playing_handicap, COALESCE(guest_name, "
"(SELECT display_name FROM app_user WHERE id = round_participant.user_id), 'Medspiller') AS label "
"FROM round_participant WHERE round_side_id = $1",
team_side["id"],
)
individual_participant = await conn.fetchrow(
"SELECT id::text AS id, playing_handicap, COALESCE(guest_name, "
"(SELECT display_name FROM app_user WHERE id = round_participant.user_id), 'Medspiller') AS label "
"FROM round_participant WHERE round_side_id = $1",
individual_side["id"],
)
if (
len(team_participants) < 2
or any(p["playing_handicap"] is None for p in team_participants)
or individual_participant is None
or individual_participant["playing_handicap"] is None
):
return RoundFormatResultOut(play_format="scramble_solo_match", ready=False)
team_playing_handicap = team_participants[0]["playing_handicap"] # samme verdi lagret på alle lagmedlemmer
individual_playing_handicap = individual_participant["playing_handicap"]
# Matchspill-konvensjon (R&A Appendix C, samme som resten av appens
# to-sidede formater): RELATIVE slag, ikke hver sides fulle absolutte
# allowance uavhengig -- dette er den ENESTE forskjellen fra
# _build_scramble_solo_result sin handicap-håndtering.
team_relative, individual_relative = match_play_strokes([team_playing_handicap, individual_playing_handicap])
# "a"/"b" avgjøres av SORTERT side-id, IKKE en fast lag=a/individuell=b-
# tildeling -- samme konvensjon som ALLE andre to-sidede formater i
# appen (generiske grenen under bruker sorted(relative.keys()), High-
# low-high bruker sorted(by_side.keys())). round.sides hentes ALLTID
# `ORDER BY id` frontend-siden (_load_round_out), så frontend sin
# TwoSidedBoard/buildSideTeams (som brukes UENDRET for dette formatet,
# se new-round.tsx/round-leaderboard.tsx) forutsetter at backend sin
# "a" alltid er round.sides[0] -- en fast lag=a-tildeling ville gitt
# FEIL ledelsesfarge når laget tilfeldigvis IKKE er den lavest-
# sorterte UUID-en.
team_is_a = team_side["id"] < individual_side["id"]
team_side_label_tag: Literal["a", "b"] = "a" if team_is_a else "b"
individual_side_label_tag: Literal["a", "b"] = "b" if team_is_a else "a"
all_18_si_by_hole: dict[int, int] = {}
par_by_hole: dict[int, int] = {}
team_gross_by_hole: dict[int, int] = {}
individual_gross_by_hole: dict[int, int] = {}
team_hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_side_id = $1",
team_side["id"],
)
individual_hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1",
individual_participant["id"],
)
for r in team_hole_rows:
all_18_si_by_hole[r["hole_number"]] = r["stroke_index"]
par_by_hole[r["hole_number"]] = r["par"]
if r["played"] and r["score"] is not None:
team_gross_by_hole[r["hole_number"]] = r["score"]
for r in individual_hole_rows:
all_18_si_by_hole.setdefault(r["hole_number"], r["stroke_index"])
par_by_hole.setdefault(r["hole_number"], r["par"])
if r["played"] and r["score"] is not None:
individual_gross_by_hole[r["hole_number"]] = r["score"]
if len(all_18_si_by_hole) != 18:
return RoundFormatResultOut(play_format="scramble_solo_match", ready=False)
all_18_si = [all_18_si_by_hole[h] for h in range(1, 19)]
team_strokes_per_hole = dict(
zip(range(1, 19), allocate_over_played_holes(team_relative, all_18_si, list(range(1, 19))))
)
individual_strokes_per_hole = dict(
zip(range(1, 19), allocate_over_played_holes(individual_relative, all_18_si, list(range(1, 19))))
)
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
team_label = team_side["label"] or "Laget"
individual_label = individual_side["label"] or individual_participant["label"]
hole_results: list[HoleResult] = []
holes: list[FormatHoleOut] = []
for h in play_order:
if h not in team_gross_by_hole or h not in individual_gross_by_hole:
break
team_gross = team_gross_by_hole[h]
individual_gross = individual_gross_by_hole[h]
team_received = team_strokes_per_hole[h]
individual_received = individual_strokes_per_hole[h]
team_net = team_gross - team_received
individual_net = individual_gross - individual_received
a_net, b_net = (team_net, individual_net) if team_is_a else (individual_net, team_net)
if a_net < b_net:
hole_results.append(HoleResult.SIDE_A)
elif b_net < a_net:
hole_results.append(HoleResult.SIDE_B)
else:
hole_results.append(HoleResult.HALVED)
holes.append(
FormatHoleOut(
hole_number=h, par=par_by_hole[h], stroke_index=all_18_si_by_hole[h],
entries=[
FormatHoleEntry(
id=team_side["id"], label=team_label, side=team_side_label_tag,
gross=team_gross, net=team_net, strokes_received=team_received,
),
FormatHoleEntry(
id=individual_participant["id"], label=individual_label, side=individual_side_label_tag,
gross=individual_gross, net=individual_net, strokes_received=individual_received,
),
],
result=_RESULT_LABEL[hole_results[-1]],
)
)
if not holes:
return RoundFormatResultOut(play_format="scramble_solo_match", ready=True, holes=[])
state = compute_match_state(hole_results, total_holes=round_row["holes_planned"])
return RoundFormatResultOut(
play_format="scramble_solo_match",
ready=True,
match_lead=state.lead,
match_holes_played=state.holes_played,
match_holes_remaining=state.holes_remaining,
match_is_closed=state.is_closed,
match_is_dormie=state.is_dormie,
match_status_text=state.describe(),
hole_results=[_RESULT_LABEL[r] for r in hole_results],
holes=holes,
)
async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut:
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
/public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
@ -3473,6 +3937,12 @@ async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut:
if play_format == "high_low_high":
return await _build_high_low_high_result(conn, round_id, round_row)
if play_format == "scramble_solo":
return await _build_scramble_solo_result(conn, round_id, round_row)
if play_format == "scramble_solo_match":
return await _build_scramble_solo_match_result(conn, round_id, round_row)
if play_format in _TWO_SIDED_FORMATS:
relative = await _relative_strokes_for_round(conn, round_id, play_format)
if not relative:

View file

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

View file

@ -25,11 +25,14 @@ import {
Globe,
Lock,
MapPin,
Minus,
Pencil,
Plus,
Search,
Sparkles,
Trophy,
User,
UserPlus,
Users,
X,
} from "lucide-react"
@ -93,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 ------------------------------------------------------------------
@ -114,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." },
@ -132,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",
])
@ -148,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).
@ -169,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).
@ -332,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)
@ -353,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)
@ -464,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")
}
@ -504,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,
@ -545,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).
@ -589,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) {
@ -719,6 +777,7 @@ export function NewRound() {
<Step1
s1Sub={s1Sub}
setS1Sub={setS1Sub}
setOwnCreateOrigin={setOwnCreateOrigin}
selectedClub={selectedClub}
setSelectedClub={setSelectedClub}
setCourseSource={setCourseSource}
@ -762,6 +821,8 @@ export function NewRound() {
setBbbBonus={setBbbBonus}
shambleBest={shambleBest}
setShambleBest={setShambleBest}
soloAllowance={soloAllowance}
setSoloAllowance={setSoloAllowance}
advOpen={advOpen}
setAdvOpen={setAdvOpen}
useHcp={useHcp}
@ -780,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 && (
@ -1022,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
@ -1039,7 +1108,7 @@ 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.
@ -1117,13 +1186,46 @@ function Step1(props: {
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
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",
)
}}
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) => {
@ -1144,10 +1246,12 @@ function Step1(props: {
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")
}}
/>
@ -1160,6 +1264,7 @@ function Step1(props: {
initial={templatePrefill?.values}
forkedFromId={templatePrefill?.forkedFromId}
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
onSearchTemplate={() => setS1Sub("own-search-template")}
onCancel={() => {
setTemplatePrefill(null)
setS1Sub("own-search")
@ -1388,7 +1493,10 @@ function OwnSearch({
onCreate,
}: {
onSelect: (c: ApiPersonalCourse) => void
onUseAsTemplate: (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("")
@ -1412,7 +1520,14 @@ function OwnSearch({
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" />
@ -1445,6 +1560,7 @@ function OwnSearch({
</span>
</div>
</button>
{onUseAsTemplate && (
<Button
type="button"
variant="outline"
@ -1453,6 +1569,7 @@ function OwnSearch({
>
Bruk som mal for egen bane
</Button>
)}
</li>
))}
</ul>
@ -1515,11 +1632,17 @@ 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 ?? "")
@ -1603,6 +1726,16 @@ function OwnCreateStep({
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
{onSearchTemplate && !initial && (
<button
type="button"
onClick={onSearchTemplate}
className="flex min-h-11 items-center gap-1.5 self-start text-base font-semibold text-primary underline underline-offset-2 hover:no-underline"
>
Basér en eksisterende bane i stedet
</button>
)}
{error && <p className="text-base font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
@ -1904,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
@ -1918,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
@ -1977,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"
@ -2016,6 +2154,7 @@ function Step2(props: {
</div>
)}
</div>
)}
<div className="flex flex-col gap-3">
<SectionTitle title="Antall hull" />
@ -2064,6 +2203,8 @@ function FormatConfig({
setBbbBonus,
shambleBest,
setShambleBest,
soloAllowance,
setSoloAllowance,
}: {
format: FormatId
matchExcludeHcp: boolean
@ -2076,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 (
@ -2152,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>
}
@ -2159,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({
@ -2682,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">
@ -2794,6 +3024,220 @@ function Step4({
)
}
// Asymmetrisk sideoppsett for "Scramble mot enkeltspiller": slot 1 (Laget)
// er en åpen liste (ingen øvre grense, "minst 2"-hint), slot 2 (Individuell
// motstander) er begrenset til nøyaktig én spiller. player.sideId bruker
// de faste literalene "team"/"solo" her (ikke en dynamisk opprettet Side-
// id som i den symmetriske Step4 over) -- submitWizard() oppretter de to
// ekte backend-sidene (med side_role) og mapper disse to nøklene til de
// virkelige side-id-ene ved innsending.
function ScrambleVsSoloSides({
players,
setPlayers,
roundStatLevel,
}: {
players: Player[]
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
roundStatLevel: StatLevel
}) {
const [addingTo, setAddingTo] = useState<"team" | "solo" | null>(null)
const team = players.filter((p) => p.sideId === "team")
const solo = players.filter((p) => p.sideId === "solo")
const unplaced = players.filter((p) => p.sideId !== "team" && p.sideId !== "solo")
const soloFull = solo.length >= 1
const teamOk = team.length >= 2
function assign(id: string, sideId: string | undefined) {
setPlayers((prev) => prev.map((p) => (p.id === id ? { ...p, sideId } : p)))
}
function addTo(sideId: "team" | "solo", p: Player) {
setPlayers((prev) => [...prev, { ...p, sideId }])
setAddingTo(null)
}
const existingNames = players.map((p) => p.name)
return (
<div className="flex flex-col gap-6">
<StepHeader
title="Lag og rekkefølge"
description="Ett lag med delt ball mot én enkeltspiller — sidene er ulike."
/>
{/* Slot 1: Laget (åpen liste) */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Users aria-hidden="true" className="size-5" />
</span>
<h2 className="text-xl font-bold text-foreground">Laget</h2>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-sm font-bold tabular-nums text-muted-foreground">
{team.length} {team.length === 1 ? "spiller" : "spillere"}
</span>
</div>
<div
className={cn(
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-semibold",
teamOk
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-muted-foreground",
)}
aria-live="polite"
>
{teamOk ? (
<Check aria-hidden="true" className="size-4 shrink-0" />
) : (
<Users aria-hidden="true" className="size-4 shrink-0" />
)}
<span>{teamOk ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}</span>
</div>
{team.length > 0 && (
<ul className="flex flex-col gap-2">
{team.map((p) => (
<MemberRow
key={p.id}
player={p}
onRemove={() => assign(p.id, undefined)}
removeLabel={`Fjern ${p.name} fra laget`}
/>
))}
</ul>
)}
{addingTo === "team" ? (
<AddPlayerPanel
onCancel={() => setAddingTo(null)}
onAdd={(p) => addTo("team", p)}
roundStatLevel={roundStatLevel}
existingNames={existingNames}
/>
) : (
<AddAffordance label="Legg til spiller" onClick={() => setAddingTo("team")} />
)}
</section>
{/* Slot 2: Individuell motstander (begrenset til 1) */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-brand-orange/15 text-brand-orange">
<User aria-hidden="true" className="size-5" />
</span>
<div className="flex flex-col">
<h2 className="text-xl font-bold text-foreground">Individuell motstander</h2>
<p className="text-sm text-muted-foreground">Nøyaktig én spiller</p>
</div>
</div>
{soloFull ? (
<ul className="flex flex-col gap-2">
{solo.map((p) => (
<MemberRow
key={p.id}
player={p}
onRemove={() => assign(p.id, undefined)}
removeLabel={`Fjern ${p.name} som motstander`}
/>
))}
</ul>
) : addingTo === "solo" ? (
<AddPlayerPanel
onCancel={() => setAddingTo(null)}
onAdd={(p) => addTo("solo", p)}
roundStatLevel={roundStatLevel}
existingNames={existingNames}
/>
) : (
<AddAffordance label="Legg til motstander" onClick={() => setAddingTo("solo")} />
)}
</section>
{/* Spillere i runden som ennå ikke er plassert på noen side */}
{unplaced.length > 0 && (
<div className="flex flex-col gap-3">
<SectionTitle title="Ikke plassert" hint="Spillere fra runden som ennå ikke er tildelt en side." />
<ul className="flex flex-col gap-2">
{unplaced.map((p) => (
<li
key={p.id}
className="flex flex-wrap items-center gap-2 rounded-2xl border border-border bg-card p-3 shadow-sm shadow-black/5"
>
<Avatar name={p.name} />
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{p.name}</span>
<button
type="button"
onClick={() => assign(p.id, "team")}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Users aria-hidden="true" className="size-4" />
Til laget
</button>
{!soloFull && (
<button
type="button"
onClick={() => assign(p.id, "solo")}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-brand-orange/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<User aria-hidden="true" className="size-4" />
Som motstander
</button>
)}
</li>
))}
</ul>
</div>
)}
</div>
)
}
function MemberRow({
player,
onRemove,
removeLabel,
}: {
player: Player
onRemove: () => void
removeLabel: string
}) {
return (
<li className="flex items-center gap-3 rounded-2xl border border-border bg-card p-3">
<Avatar name={player.name} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
{player.hcp.trim() !== "" && (
<span className="text-sm text-muted-foreground">HCP {player.hcp}</span>
)}
</div>
<button
type="button"
onClick={onRemove}
aria-label={removeLabel}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X aria-hidden="true" className="size-5" />
</button>
</li>
)
}
function AddAffordance({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex min-h-11 items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card/40 px-6 py-4 text-base font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<UserPlus aria-hidden="true" className="size-5" />
{label}
</button>
)
}
function CreateSideCard({ slot, onCreate, disabled }: { slot: number; onCreate: (label: string) => void; disabled: boolean }) {
const [label, setLabel] = useState("")
const fallback = slot === 0 ? "Side A" : "Side B"

View file

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

View file

@ -40,6 +40,7 @@ import {
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
@ -193,6 +194,9 @@ type ApiParticipant = {
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type PersonMatch = {
@ -264,6 +268,14 @@ const TWO_SIDED_FORMATS = new Set([
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
])
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
// Scramble mot enkeltspiller (migrasjon 056/057) -- HALVPARTEN delt-ball
// (laget deler ball via round_side_id) og HALVPARTEN individuell
// (motstanderen scorer per deltaker via round_participant_id). Passer
// derfor IKKE inn i SHARED_BALL_FORMATS sin alt-eller-ingenting-bryter --
// se scrambleSoloTeamSideId lenger ned, som ruter KUN lagets spillere til
// SideScoreWizard, mens individualspilleren fortsatt bruker vanlig
// ScoringWizard.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
const SIDE_PLAYER_COUNT: Record<string, number> = {
match: 1,
fourball: 2,
@ -291,6 +303,8 @@ const FORMAT_LABELS: Record<string, string> = {
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
@ -833,11 +847,29 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
// loadRound() har satt currentHole fra round.start_hole.
const activeHole = currentHole ?? round?.start_hole ?? 1
const hole = holes.find((h) => h.holeNumber === activeHole) ?? null
// Par/index for aktivt hull -- samme for ALLE deltakere/sider (samme
// bane), hentet fra HVILKEN SOM HELST kilde som allerede har lastet inn
// hullene sine (2026-08-05, erstatter en tidligere versjon som kun så på
// activePlayerId sin egen liste -- brøt permanent for scramble_solo sine
// lagspillere, som ALDRI får egne round_participant_id-keyede hull-rader
// siden laget deler ball via round_side_id. En aktiv spiller på laget
// ga derfor en evig tom `holes`-liste og en evig spinner). Samme mønster
// som delt-ball-formatenes side-baserte oppslag alltid har brukt, nå
// generalisert til å også dekke deltaker-baserte kilder.
const hole: Hole | null = (() => {
for (const list of Object.values(holesByParticipant)) {
const h = list.find((x) => x.hole_number === activeHole)
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
}
for (const s of round?.sides ?? []) {
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
}
return null
})()
// Navigasjonsrekkefølge starter på øktens starthull og går rundt
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
@ -902,36 +934,60 @@ export function RoundDetail({ roundId }: { roundId: string }) {
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
function advanceWizardPlayer() {
const idx = players.findIndex((p) => p.id === wizardPlayerId)
const isLast = idx === -1 || idx === players.length - 1
if (!isLast) {
const next = players[idx + 1]
setActivePlayerId(next.id)
setWizardPlayerId(next.id)
} else {
setWizardPlayerId(null)
setActivePlayerId(players[0]?.id ?? null)
goNext()
}
}
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
// mening for en delt ball).
const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
// Scramble mot enkeltspiller (2026-08-05) -- KUN lagets side, aldri
// individualspillerens (den fortsetter å bruke vanlig ScoringWizard/
// wizardPlayerId). Null for alle andre formater.
const scrambleSoloTeamSideId =
round && SCRAMBLE_SOLO_FORMATS.has(round.play_format)
? round.sides.find((s) => s.side_role === "team")?.id ?? null
: null
const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null
// Par/index for aktivt hull -- samme for begge sider (samme bane), henter
// fra hvilken som helst av de to som allerede har lastet inn hullene sine.
const sharedHole: Hole | null = (() => {
for (const s of round?.sides ?? []) {
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
// Felles klikk-håndtering for PlayerHoleCards/ScorecardGrid sine
// "Registrer X sin score"-kort (2026-08-05) -- for scramble mot
// enkeltspiller skal LAGETS spillere åpne den delte SideScoreWizard
// (samme lagscore for alle, keyet på round_side_id), mens
// individualspilleren fortsatt åpner den vanlige, per-deltaker
// ScoringWizard. Alle andre formater (ingen team-side) oppfører seg
// som før.
function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) {
setCurrentHole(holeNumber)
const player = players.find((p) => p.id === playerId)
if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) {
setWizardSideId(scrambleSoloTeamSideId)
return
}
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 (

View file

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

View file

@ -48,6 +48,9 @@ type ApiParticipant = {
type ApiSide = {
id: string
label: string | null
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
// formater, som ikke skiller sidene fra hverandre strukturelt.
side_role: "team" | "individual" | null
}
type ApiRound = {
@ -86,7 +89,17 @@ type ApiHole = {
}
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", ...SHARED_BALL_FORMATS]
// scramble_solo_match (migrasjon 057) lagt til her -- KUN denne bruken
// (isTwoSided) gater MatchScorecardGrid, som får sin egen dedikerte
// scramble-solo-familie-gren for å unngå dupliserte rader (se der).
// SHARED_BALL_FORMATS/isSharedBall RØRES bevisst ikke -- se
// SCRAMBLE_SOLO_FORMATS under for den egne, riktige klassifiseringen.
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", "scramble_solo_match", ...SHARED_BALL_FORMATS]
// Begge scramble-mot-enkeltspiller-variantene deler NØYAKTIG samme
// asymmetriske side-/enhetsstruktur (laget = side, individualspilleren =
// participant) -- kun selve resultat-sammenligningen skiller dem. Én
// samlet klassifisering brukt for alt UNNTATT selve resultatvisningen.
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
@ -104,6 +117,8 @@ const FORMAT_LABELS: Record<string, string> = {
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
@ -778,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 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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

69
teecup-beskrivelse.md Normal file
View file

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

View file

@ -19,6 +19,7 @@ from handicap_engine import (
CombinedPercentage,
WeightedLowHigh,
RankedSplit,
TeamAverage,
DEFAULT_MATCHPLAY_ALLOWANCES,
adjusted_gross_score,
allocate_strokes_by_index,
@ -32,6 +33,7 @@ from handicap_engine import (
handicap_index_from_differentials,
low_handicap_index,
match_play_strokes,
net_stroke_play_margin,
compute_skins,
compute_skins_detail,
copenhagen_points_for_hole,
@ -176,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)
@ -319,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)
#