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:
parent
c5455d0220
commit
038516d236
21 changed files with 2361 additions and 154 deletions
|
|
@ -395,7 +395,13 @@
|
||||||
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)",
|
"Bash(curl -s -o /dev/null -w \"more: %{http_code}\\\\n\" https://teecup.teeoff.no/more)",
|
||||||
"Bash(echo \"tsc exit: $?\")",
|
"Bash(echo \"tsc exit: $?\")",
|
||||||
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)",
|
"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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
45
056_scramble_solo_round.sql
Normal file
45
056_scramble_solo_round.sql
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — Scramble mot enkeltspiller, slagspill-varianten (frittstående
|
||||||
|
-- runder), migrasjon 056.
|
||||||
|
--
|
||||||
|
-- Første ASYMMETRISKE to-siders-format i prosjektet: et scramble-lag
|
||||||
|
-- (variabel størrelse, N>=2, delt ball) spiller netto slagspill mot ÉN
|
||||||
|
-- individuell spiller (egen ball) -- laveste nettosum vinner. Bevisst
|
||||||
|
-- utenfor omfang: match-play-varianten (egen runde senere), org-
|
||||||
|
-- lagturneringer (kun frittstående runder nå).
|
||||||
|
--
|
||||||
|
-- Lagets Playing Handicap = enkelt gjennomsnitt av medlemmenes course
|
||||||
|
-- handicap (handicap_engine.py sin nye TeamAverage) -- bevisst uavhengig
|
||||||
|
-- av scramble_2/scramble_4 sine faste RankedSplit-tabeller, som
|
||||||
|
-- forutsetter fast lagstørrelse. Ingen av de tre HCP-PDF-ene (se
|
||||||
|
-- CLAUDE.md) dekker lag-vs-individuell eller vilkårlig lagstørrelse --
|
||||||
|
-- dette er en bevisst ny, enkel TeeCup-regel, bekreftet med bruker
|
||||||
|
-- 2026-08-05, ikke en kildebelagt formel.
|
||||||
|
-- =====================================================================
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||||
|
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||||
|
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball', 'high_low_high', 'scramble_solo'));
|
||||||
|
|
||||||
|
-- Individualspillerens konfigurerbare allowance-%, arrangør-satt per
|
||||||
|
-- runde, standard 100. Bespoke kolonne (samme mønster som bbb_sweep_
|
||||||
|
-- bonus_enabled/shamble_best_n) -- IKKE den generiske allowance_override-
|
||||||
|
-- JSON-en (migrasjon 051), som forutsetter ÉN delt strategi for hele
|
||||||
|
-- runden. Dette formatet trenger to UAVHENGIGE strategier: lagsiden har
|
||||||
|
-- ingen konfig i det hele tatt (alltid rent snitt via TeamAverage),
|
||||||
|
-- individualsiden har nøyaktig denne ene justerbare prosenten.
|
||||||
|
ALTER TABLE round ADD COLUMN scramble_solo_individual_pct smallint NOT NULL DEFAULT 100
|
||||||
|
CHECK (scramble_solo_individual_pct BETWEEN 0 AND 100);
|
||||||
|
|
||||||
|
-- Side-rolle-disambiguering. Nullable, meningsløs/NULL for alle andre
|
||||||
|
-- formater. round_side kan slettes/gjenopprettes fritt (delete_round_
|
||||||
|
-- side) uten garantert stabil opprettelsesrekkefølge -- rekkefølge-basert
|
||||||
|
-- "side 1 = lag, side 2 = individuell" er derfor IKKE pålitelig identitet.
|
||||||
|
-- round_side_id IS NULL for individualspilleren ville dessuten brutt
|
||||||
|
-- eksisterende "er denne deltakeren med i matchen"-sjekker
|
||||||
|
-- (_two_sided_hole_results m.fl.), som bruker nettopp round_side_id IS
|
||||||
|
-- NOT NULL som signal -- individualspilleren får derfor en ekte
|
||||||
|
-- round_side-rad (rollen 'individual'), ikke NULL.
|
||||||
|
ALTER TABLE round_side ADD COLUMN side_role text CHECK (side_role IN ('team', 'individual'));
|
||||||
|
CREATE UNIQUE INDEX round_side_role_unique ON round_side (round_id, side_role) WHERE side_role IS NOT NULL;
|
||||||
22
057_scramble_solo_match_round.sql
Normal file
22
057_scramble_solo_match_round.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — Scramble mot enkeltspiller, MATCHSPILL-varianten (frittstående
|
||||||
|
-- runder), migrasjon 057.
|
||||||
|
--
|
||||||
|
-- Samme asymmetriske to-siders-struktur som scramble_solo (migrasjon
|
||||||
|
-- 056, stroke-play-varianten) -- variabelt scramble-lag mot nøyaktig én
|
||||||
|
-- individuell spiller -- men avgjort hull-for-hull ("X UP"/dormie/
|
||||||
|
-- avgjort) i stedet for netto totalsum-sammenligning.
|
||||||
|
--
|
||||||
|
-- REN CHECK-utvidelse -- ingen nye kolonner. round_side.side_role og
|
||||||
|
-- round.scramble_solo_individual_pct (begge fra migrasjon 056) er
|
||||||
|
-- allerede generiske nok til å gjenbrukes uendret: samme betydning
|
||||||
|
-- (hvem er laget/individualspilleren, individualspillerens allowance-%)
|
||||||
|
-- gjelder uansett om sammenligningen er netto totalsum eller matchspill.
|
||||||
|
-- handicap_engine.py sin match_play_strokes()/compute_match_state()
|
||||||
|
-- brukes uendret (begge allerede domeneagnostiske) -- ingen ny motorkode.
|
||||||
|
-- =====================================================================
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||||
|
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||||
|
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball', 'high_low_high', 'scramble_solo', 'scramble_solo_match'));
|
||||||
300
CHANGELOG.md
300
CHANGELOG.md
|
|
@ -7550,3 +7550,303 @@ Neste steg:
|
||||||
compose up -d --build teecup_frontend` (ingen migrasjon involvert),
|
compose up -d --build teecup_frontend` (ingen migrasjon involvert),
|
||||||
containeren boot-et rent, `/health` og `/dashboard` → 200 over
|
containeren boot-et rent, `/health` og `/dashboard` → 200 over
|
||||||
https.
|
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å.
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||||
|
|
||||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -56,6 +56,8 @@ from .auth import _hash_secret
|
||||||
from .notifications import create_notification
|
from .notifications import create_notification
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
HoleResult,
|
HoleResult,
|
||||||
|
PerPlayerPercentage,
|
||||||
|
TeamAverage,
|
||||||
adjusted_gross_score,
|
adjusted_gross_score,
|
||||||
allocate_over_played_holes,
|
allocate_over_played_holes,
|
||||||
allocate_strokes_by_index,
|
allocate_strokes_by_index,
|
||||||
|
|
@ -70,8 +72,10 @@ from handicap_engine import (
|
||||||
high_low_high_points_for_hole,
|
high_low_high_points_for_hole,
|
||||||
high_low_high_running_score,
|
high_low_high_running_score,
|
||||||
money_ball_hole_score,
|
money_ball_hole_score,
|
||||||
|
net_stroke_play_margin,
|
||||||
shamble_hole_score,
|
shamble_hole_score,
|
||||||
stableford_points_for_hole,
|
stableford_points_for_hole,
|
||||||
|
stroke_play_net_total,
|
||||||
course_handicap_raw,
|
course_handicap_raw,
|
||||||
handicap_index_from_differentials,
|
handicap_index_from_differentials,
|
||||||
low_handicap_index,
|
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"])
|
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]:
|
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).
|
"""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_
|
stedet for å oppdage overtallet først ved fullføring. `exclude_
|
||||||
participant_id` unngår at en deltaker som allerede ER på siden (f.eks.
|
participant_id` unngår at en deltaker som allerede ER på siden (f.eks.
|
||||||
en no-op-reassignment til samme side) teller seg selv ut av plass."""
|
en no-op-reassignment til samme side) teller seg selv ut av plass."""
|
||||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
|
||||||
current_count = await conn.fetchval(
|
current_count = await conn.fetchval(
|
||||||
"SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
|
"SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
|
||||||
side_id, exclude_participant_id or "00000000-0000-0000-0000-000000000000",
|
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:
|
if current_count >= expected:
|
||||||
raise app_error(
|
raise app_error(
|
||||||
409, "SIDE_FULL",
|
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 False, "Alle fire spillerne må ha en tildelt, distinkt plass i rekkefølgen (0-3)."
|
||||||
return True, None
|
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_*).
|
# To-sidede formater (match/fourball/foursome/greensome/scramble_*).
|
||||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
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)
|
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 = {
|
_TWO_SIDED_FORMATS = {
|
||||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
"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:
|
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.
|
# Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
|
||||||
play_format: Literal[
|
play_format: Literal[
|
||||||
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
|
"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"
|
] = "stroke"
|
||||||
# Frontend forhåndsutfyller denne til True når play_format='match'
|
# Frontend forhåndsutfyller denne til True når play_format='match'
|
||||||
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
|
# (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
|
# KUN meningsfullt (og påkrevd) når play_format='shamble' -- antall av
|
||||||
# lagets individuelle resultater som teller per hull ("beste N av M").
|
# lagets individuelle resultater som teller per hull ("beste N av M").
|
||||||
shamble_best_n: int | None = Field(default=None, ge=1)
|
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
|
# ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||||||
# innsyn (venner/offentligheten). Trygg standard 'private', samme
|
# innsyn (venner/offentligheten). Trygg standard 'private', samme
|
||||||
# filosofi som RLS-policyenes "se ingenting" ved manglende kontekst.
|
# filosofi som RLS-policyenes "se ingenting" ved manglende kontekst.
|
||||||
|
|
@ -1120,6 +1220,9 @@ class RoundParticipantOut(BaseModel):
|
||||||
class RoundSideOut(BaseModel):
|
class RoundSideOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
label: str | None
|
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):
|
class RoundOut(BaseModel):
|
||||||
|
|
@ -1138,6 +1241,8 @@ class RoundOut(BaseModel):
|
||||||
bbb_sweep_bonus_enabled: bool
|
bbb_sweep_bonus_enabled: bool
|
||||||
# KUN meningsfullt for play_format='shamble' (2026-07-30).
|
# KUN meningsfullt for play_format='shamble' (2026-07-30).
|
||||||
shamble_best_n: int | None
|
shamble_best_n: int | None
|
||||||
|
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056).
|
||||||
|
scramble_solo_individual_pct: int
|
||||||
started_at: str | None
|
started_at: str | None
|
||||||
completed_at: str | None
|
completed_at: str | None
|
||||||
participants: list[RoundParticipantOut]
|
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,
|
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,
|
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,
|
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
|
FROM round WHERE id = $1
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -1215,7 +1321,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
round_id,
|
round_id,
|
||||||
)
|
)
|
||||||
side_rows = await conn.fetch(
|
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
|
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
|
||||||
# medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
|
# 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"],
|
skins_tie_handling=round_row["skins_tie_handling"],
|
||||||
bbb_sweep_bonus_enabled=round_row["bbb_sweep_bonus_enabled"],
|
bbb_sweep_bonus_enabled=round_row["bbb_sweep_bonus_enabled"],
|
||||||
shamble_best_n=round_row["shamble_best_n"],
|
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,
|
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,
|
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
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(
|
async def _create_participant(
|
||||||
conn,
|
conn,
|
||||||
round_id: str,
|
round_id: str,
|
||||||
|
|
@ -1442,8 +1565,8 @@ async def create_round(
|
||||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||||
start_hole, holes_planned, started_at, name, play_format,
|
start_hole, holes_planned, started_at, name, play_format,
|
||||||
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id,
|
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id,
|
||||||
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override)
|
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::jsonb)
|
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
|
RETURNING id::text AS id
|
||||||
""",
|
""",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
|
|
@ -1465,6 +1588,7 @@ async def create_round(
|
||||||
body.flight_group_id,
|
body.flight_group_id,
|
||||||
body.bbb_sweep_bonus_enabled,
|
body.bbb_sweep_bonus_enabled,
|
||||||
body.shamble_best_n,
|
body.shamble_best_n,
|
||||||
|
body.scramble_solo_individual_pct,
|
||||||
json.dumps(body.allowance_override) if body.allowance_override is not None else None,
|
json.dumps(body.allowance_override) if body.allowance_override is not None else None,
|
||||||
)
|
)
|
||||||
round_id = round_row["id"]
|
round_id = round_row["id"]
|
||||||
|
|
@ -1475,8 +1599,7 @@ async def create_round(
|
||||||
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
|
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
|
||||||
stat_level=body.stat_level,
|
stat_level=body.stat_level,
|
||||||
exclude_from_handicap=body.exclude_owner_from_handicap,
|
exclude_from_handicap=body.exclude_owner_from_handicap,
|
||||||
individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(body.play_format, body.play_format)
|
individual_round_hole=await _individual_round_hole_needed(conn, body.play_format, None),
|
||||||
not in SIDE_IS_UNIT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if body.visibility_mode == "friends" and body.visible_categories:
|
if body.visibility_mode == "friends" and body.visible_categories:
|
||||||
|
|
@ -2197,7 +2320,8 @@ class RoundUpdate(BaseModel):
|
||||||
# å validere her.
|
# å validere her.
|
||||||
play_format: Literal[
|
play_format: Literal[
|
||||||
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
|
"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
|
] | None = None
|
||||||
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
|
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
|
||||||
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
|
# 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å
|
# Rører ALDRI course_handicap_snapshot, men regner playing_handicap på
|
||||||
# nytt for allerede tildelte sider (se update_round).
|
# nytt for allerede tildelte sider (se update_round).
|
||||||
allowance_override: dict | None = None
|
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)
|
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||||
|
|
@ -2335,6 +2462,12 @@ async def update_round(
|
||||||
round_id,
|
round_id,
|
||||||
json.dumps(body.allowance_override),
|
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:
|
if body.started_at is not None:
|
||||||
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
|
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
|
||||||
if body.completed_at is not None:
|
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"]
|
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:
|
if effective_play_format in _TWO_SIDED_FORMATS:
|
||||||
await _recompute_side_handicaps(conn, round_id, effective_play_format)
|
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)
|
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.")
|
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
|
||||||
|
|
||||||
if body.round_side_id is not None:
|
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.")
|
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||||
side_exists = await conn.fetchval(
|
side_exists = await conn.fetchval(
|
||||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", body.round_side_id, round_id
|
"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,
|
guest_email=body.guest_email,
|
||||||
exclude_from_handicap=body.exclude_from_handicap,
|
exclude_from_handicap=body.exclude_from_handicap,
|
||||||
round_side_id=body.round_side_id,
|
round_side_id=body.round_side_id,
|
||||||
individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(
|
individual_round_hole=await _individual_round_hole_needed(
|
||||||
round_row["play_format"], round_row["play_format"]
|
conn, round_row["play_format"], body.round_side_id
|
||||||
)
|
),
|
||||||
not in SIDE_IS_UNIT,
|
|
||||||
)
|
)
|
||||||
if body.round_side_id is not None:
|
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"])
|
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
|
||||||
|
|
||||||
# Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
|
# Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
|
||||||
|
|
@ -2598,6 +2741,10 @@ async def add_participant(
|
||||||
|
|
||||||
class RoundSideCreate(BaseModel):
|
class RoundSideCreate(BaseModel):
|
||||||
label: str | None = Field(default=None, max_length=100)
|
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)
|
@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",
|
"personal_course_id, completed_at FROM round WHERE id = $1",
|
||||||
round_id,
|
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.")
|
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||||
if round_row["completed_at"] is not None:
|
if round_row["completed_at"] is not None:
|
||||||
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
|
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)
|
existing_count = await conn.fetchval("SELECT count(*) FROM round_side WHERE round_id = $1", round_id)
|
||||||
if existing_count >= 2:
|
if existing_count >= 2:
|
||||||
raise app_error(409, "LIMIT_REACHED", "En runde kan maks ha to sider.")
|
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():
|
async with conn.transaction():
|
||||||
side_row = await conn.fetchrow(
|
side_row = await conn.fetchrow(
|
||||||
"INSERT INTO round_side (round_id, label) VALUES ($1, $2) RETURNING id::text AS id, label",
|
"INSERT INTO round_side (round_id, label, side_role) VALUES ($1, $2, $3) "
|
||||||
round_id, body.label,
|
"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
|
# Delt-ball-formater (ADR-039 Beslutning C): siden får sine
|
||||||
# round_hole-rader med en gang -- rating trengs ikke her, kun
|
# round_hole-rader med en gang -- rating trengs ikke her, kun
|
||||||
# par/stroke-index-snapshot, uavhengig av om siden har fått
|
# par/stroke-index-snapshot, uavhengig av om siden har fått
|
||||||
# noen deltakere ennå.
|
# noen deltakere ennå. scramble_solo (migrasjon 056) er IKKE i
|
||||||
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]]
|
# _ROUND_FORMAT_TO_ENGINE_FORMAT (bevisst -- se _ASYMMETRIC_
|
||||||
if engine_format in SIDE_IS_UNIT:
|
# 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)
|
resolved = await _resolve_round_course(round_row)
|
||||||
for hole_number, par, stroke_index in resolved.holes:
|
for hole_number, par, stroke_index in resolved.holes:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|
@ -2817,13 +2983,20 @@ async def update_participant(
|
||||||
if "round_side_id" in updates:
|
if "round_side_id" in updates:
|
||||||
new_side_id = updates["round_side_id"]
|
new_side_id = updates["round_side_id"]
|
||||||
if new_side_id is not None:
|
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.")
|
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||||
side_exists = await conn.fetchval(
|
side_exists = await conn.fetchval(
|
||||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
|
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
|
||||||
)
|
)
|
||||||
if not side_exists:
|
if not side_exists:
|
||||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
|
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)
|
await _check_side_capacity(conn, new_side_id, current["play_format"], exclude_participant_id=participant_id)
|
||||||
values.append(new_side_id)
|
values.append(new_side_id)
|
||||||
set_clauses.append(f"round_side_id = ${len(values)}")
|
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 --
|
# nytt, siden det opprinnelige triggeret kun så på round_side_id --
|
||||||
# laget lignende resultatet permanent "ikke klart" til siden ble
|
# laget lignende resultatet permanent "ikke klart" til siden ble
|
||||||
# rørt på nytt. rating_changed dekker nå samme sak.
|
# 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)
|
("round_side_id" in updates or rating_changed)
|
||||||
and current["play_format"] in _TWO_SIDED_FORMATS
|
and current["play_format"] in _TWO_SIDED_FORMATS
|
||||||
):
|
):
|
||||||
|
|
@ -3335,6 +3510,14 @@ class RoundFormatResultOut(BaseModel):
|
||||||
# FAKTISKE tallene til visning (f.eks. "2-1").
|
# FAKTISKE tallene til visning (f.eks. "2-1").
|
||||||
hlh_points_a: int | None = None
|
hlh_points_a: int | None = None
|
||||||
hlh_points_b: 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" --
|
# 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).
|
# brutto/netto/slag-mottatt per enhet, ikke bare et vinn/tap-merke).
|
||||||
# None når ready=False (to-sidede formater) -- ellers alltid satt,
|
# 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 nå 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:
|
async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut:
|
||||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||||
/public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
|
/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":
|
if play_format == "high_low_high":
|
||||||
return await _build_high_low_high_result(conn, round_id, round_row)
|
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:
|
if play_format in _TWO_SIDED_FORMATS:
|
||||||
relative = await _relative_strokes_for_round(conn, round_id, play_format)
|
relative = await _relative_strokes_for_round(conn, round_id, play_format)
|
||||||
if not relative:
|
if not relative:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
greensome: "Greensome",
|
greensome: "Greensome",
|
||||||
scramble_2: "Scramble (2)",
|
scramble_2: "Scramble (2)",
|
||||||
scramble_4: "Scramble (4)",
|
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" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,14 @@ import {
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
Minus,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
User,
|
||||||
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
@ -93,7 +96,7 @@ type Player = {
|
||||||
|
|
||||||
type Side = { id: string; label: string }
|
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 ------------------------------------------------------------------
|
// --- Formater ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -114,6 +117,8 @@ type FormatId =
|
||||||
| "shamble"
|
| "shamble"
|
||||||
| "moneyball"
|
| "moneyball"
|
||||||
| "highlowhigh"
|
| "highlowhigh"
|
||||||
|
| "scramblevssolo"
|
||||||
|
| "scramblevssolomatch"
|
||||||
|
|
||||||
const FORMATS: { id: FormatId; label: string; desc: string }[] = [
|
const FORMATS: { id: FormatId; label: string; desc: string }[] = [
|
||||||
{ id: "slagspill", label: "Slagspill", desc: "Vanlig individuell runde." },
|
{ 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: "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: "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: "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>([
|
const TWO_SIDED = new Set<FormatId>([
|
||||||
"match", "fourball", "foursome", "greensome", "scramble2", "scramble4", "chapman", "highlowhigh",
|
"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"])
|
const ORDINARY_FORMATS = new Set<FormatId>(["slagspill", "stableford"])
|
||||||
|
|
||||||
function usesStep4(f: FormatId) {
|
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).
|
// Wizardens FormatId -> ekte round.play_format-verdi (RoundCreate).
|
||||||
|
|
@ -169,6 +193,8 @@ const FORMAT_TO_API: Record<FormatId, string> = {
|
||||||
shamble: "shamble",
|
shamble: "shamble",
|
||||||
moneyball: "money_ball",
|
moneyball: "money_ball",
|
||||||
highlowhigh: "high_low_high",
|
highlowhigh: "high_low_high",
|
||||||
|
scramblevssolo: "scramble_solo",
|
||||||
|
scramblevssolomatch: "scramble_solo_match",
|
||||||
}
|
}
|
||||||
// Motsatt vei, brukt til å forhåndsutfylle format ved "legg til en flight
|
// Motsatt vei, brukt til å forhåndsutfylle format ved "legg til en flight
|
||||||
// til" (playFormat kommer fra URL som en ekte API-verdi).
|
// til" (playFormat kommer fra URL som en ekte API-verdi).
|
||||||
|
|
@ -332,6 +358,11 @@ export function NewRound() {
|
||||||
|
|
||||||
// Step 1
|
// Step 1
|
||||||
const [s1Sub, setS1Sub] = useState<S1Sub>("source")
|
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 [courseSource, setCourseSource] = useState<"official" | "own">("official")
|
||||||
const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null)
|
const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null)
|
||||||
const [course, setCourse] = useState<Course | null>(null)
|
const [course, setCourse] = useState<Course | null>(null)
|
||||||
|
|
@ -353,6 +384,7 @@ export function NewRound() {
|
||||||
const [skinsTie, setSkinsTie] = useState<"roll" | "split">("roll")
|
const [skinsTie, setSkinsTie] = useState<"roll" | "split">("roll")
|
||||||
const [bbbBonus, setBbbBonus] = useState(false)
|
const [bbbBonus, setBbbBonus] = useState(false)
|
||||||
const [shambleBest, setShambleBest] = useState(2)
|
const [shambleBest, setShambleBest] = useState(2)
|
||||||
|
const [soloAllowance, setSoloAllowance] = useState(100)
|
||||||
const [advOpen, setAdvOpen] = useState(false)
|
const [advOpen, setAdvOpen] = useState(false)
|
||||||
const [useHcp, setUseHcp] = useState(true)
|
const [useHcp, setUseHcp] = useState(true)
|
||||||
const [useCourseHcp, setUseCourseHcp] = useState(true)
|
const [useCourseHcp, setUseCourseHcp] = useState(true)
|
||||||
|
|
@ -464,8 +496,10 @@ export function NewRound() {
|
||||||
setS1Sub("official-search")
|
setS1Sub("official-search")
|
||||||
} else if (s1Sub === "own-search") {
|
} else if (s1Sub === "own-search") {
|
||||||
setS1Sub("source")
|
setS1Sub("source")
|
||||||
|
} else if (s1Sub === "own-search-template") {
|
||||||
|
setS1Sub("own-create")
|
||||||
} else if (s1Sub === "own-create") {
|
} else if (s1Sub === "own-create") {
|
||||||
setS1Sub("own-search")
|
setS1Sub(ownCreateOrigin)
|
||||||
} else if (s1Sub === "fields") {
|
} else if (s1Sub === "fields") {
|
||||||
setS1Sub(courseSource === "official" ? "official-courses" : "own-search")
|
setS1Sub(courseSource === "official" ? "official-courses" : "own-search")
|
||||||
}
|
}
|
||||||
|
|
@ -504,6 +538,7 @@ export function NewRound() {
|
||||||
skins_tie_handling: format === "skins" ? (skinsTie === "roll" ? "carry" : "split") : null,
|
skins_tie_handling: format === "skins" ? (skinsTie === "roll" ? "carry" : "split") : null,
|
||||||
bbb_sweep_bonus_enabled: format === "bbb" ? bbbBonus : false,
|
bbb_sweep_bonus_enabled: format === "bbb" ? bbbBonus : false,
|
||||||
shamble_best_n: format === "shamble" ? shambleBest : null,
|
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",
|
visibility_mode: visibility === "privat" ? "private" : visibility === "venner" ? "friends" : "public",
|
||||||
visible_categories: visibility === "venner" ? Array.from(categories) : [],
|
visible_categories: visibility === "venner" ? Array.from(categories) : [],
|
||||||
flight_group_id: flightGroupId || undefined,
|
flight_group_id: flightGroupId || undefined,
|
||||||
|
|
@ -545,6 +580,29 @@ export function NewRound() {
|
||||||
const created: { id: string } = await res.json()
|
const created: { id: string } = await res.json()
|
||||||
sideIdMap.set(side.id, created.id)
|
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).
|
// Medspillere (alle unntatt eieren, som allerede finnes).
|
||||||
|
|
@ -589,7 +647,7 @@ export function NewRound() {
|
||||||
|
|
||||||
// Eierens egen side-tildeling (PATCH -- eieren finnes allerede FØR
|
// Eierens egen side-tildeling (PATCH -- eieren finnes allerede FØR
|
||||||
// sidene ble opprettet over).
|
// 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 owner = players.find((p) => p.isOwner)
|
||||||
const realSideId = owner?.sideId ? sideIdMap.get(owner.sideId) : undefined
|
const realSideId = owner?.sideId ? sideIdMap.get(owner.sideId) : undefined
|
||||||
if (realSideId) {
|
if (realSideId) {
|
||||||
|
|
@ -719,6 +777,7 @@ export function NewRound() {
|
||||||
<Step1
|
<Step1
|
||||||
s1Sub={s1Sub}
|
s1Sub={s1Sub}
|
||||||
setS1Sub={setS1Sub}
|
setS1Sub={setS1Sub}
|
||||||
|
setOwnCreateOrigin={setOwnCreateOrigin}
|
||||||
selectedClub={selectedClub}
|
selectedClub={selectedClub}
|
||||||
setSelectedClub={setSelectedClub}
|
setSelectedClub={setSelectedClub}
|
||||||
setCourseSource={setCourseSource}
|
setCourseSource={setCourseSource}
|
||||||
|
|
@ -762,6 +821,8 @@ export function NewRound() {
|
||||||
setBbbBonus={setBbbBonus}
|
setBbbBonus={setBbbBonus}
|
||||||
shambleBest={shambleBest}
|
shambleBest={shambleBest}
|
||||||
setShambleBest={setShambleBest}
|
setShambleBest={setShambleBest}
|
||||||
|
soloAllowance={soloAllowance}
|
||||||
|
setSoloAllowance={setSoloAllowance}
|
||||||
advOpen={advOpen}
|
advOpen={advOpen}
|
||||||
setAdvOpen={setAdvOpen}
|
setAdvOpen={setAdvOpen}
|
||||||
useHcp={useHcp}
|
useHcp={useHcp}
|
||||||
|
|
@ -780,7 +841,14 @@ export function NewRound() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stepN === 4 && (
|
{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 && (
|
{stepN === 5 && (
|
||||||
|
|
@ -1022,6 +1090,7 @@ function HelperNote({ children }: { children: React.ReactNode }) {
|
||||||
function Step1(props: {
|
function Step1(props: {
|
||||||
s1Sub: S1Sub
|
s1Sub: S1Sub
|
||||||
setS1Sub: (s: S1Sub) => void
|
setS1Sub: (s: S1Sub) => void
|
||||||
|
setOwnCreateOrigin: (s: S1Sub) => void
|
||||||
selectedClub: OfficialClub | null
|
selectedClub: OfficialClub | null
|
||||||
setSelectedClub: (c: OfficialClub | null) => void
|
setSelectedClub: (c: OfficialClub | null) => void
|
||||||
setCourseSource: (s: "official" | "own") => void
|
setCourseSource: (s: "official" | "own") => void
|
||||||
|
|
@ -1039,7 +1108,7 @@ function Step1(props: {
|
||||||
startHole: string
|
startHole: string
|
||||||
setStartHole: (v: string) => void
|
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)
|
const [resolvingClub, setResolvingClub] = useState(false)
|
||||||
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
|
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
|
||||||
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
|
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
|
||||||
|
|
@ -1117,13 +1186,46 @@ function Step1(props: {
|
||||||
onUseAsTemplate={(c) => {
|
onUseAsTemplate={(c) => {
|
||||||
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
|
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
|
||||||
setCourseSource("own")
|
setCourseSource("own")
|
||||||
|
setOwnCreateOrigin("official-courses")
|
||||||
setS1Sub("own-create")
|
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") {
|
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 (
|
return (
|
||||||
<OwnSearch
|
<OwnSearch
|
||||||
onSelect={async (picked) => {
|
onSelect={async (picked) => {
|
||||||
|
|
@ -1144,10 +1246,12 @@ function Step1(props: {
|
||||||
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
|
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
|
||||||
forkedFromId: detail.id,
|
forkedFromId: detail.id,
|
||||||
})
|
})
|
||||||
|
setOwnCreateOrigin("own-search-template")
|
||||||
setS1Sub("own-create")
|
setS1Sub("own-create")
|
||||||
}}
|
}}
|
||||||
onCreate={() => {
|
onCreate={() => {
|
||||||
setTemplatePrefill(null)
|
setTemplatePrefill(null)
|
||||||
|
setOwnCreateOrigin("own-search-template")
|
||||||
setS1Sub("own-create")
|
setS1Sub("own-create")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1160,6 +1264,7 @@ function Step1(props: {
|
||||||
initial={templatePrefill?.values}
|
initial={templatePrefill?.values}
|
||||||
forkedFromId={templatePrefill?.forkedFromId}
|
forkedFromId={templatePrefill?.forkedFromId}
|
||||||
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
|
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
|
||||||
|
onSearchTemplate={() => setS1Sub("own-search-template")}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setTemplatePrefill(null)
|
setTemplatePrefill(null)
|
||||||
setS1Sub("own-search")
|
setS1Sub("own-search")
|
||||||
|
|
@ -1388,7 +1493,10 @@ function OwnSearch({
|
||||||
onCreate,
|
onCreate,
|
||||||
}: {
|
}: {
|
||||||
onSelect: (c: ApiPersonalCourse) => void
|
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
|
onCreate: () => void
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
|
|
@ -1412,7 +1520,14 @@ function OwnSearch({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<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">
|
<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" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{onUseAsTemplate && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -1453,6 +1569,7 @@ function OwnSearch({
|
||||||
>
|
>
|
||||||
Bruk som mal for egen bane
|
Bruk som mal for egen bane
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -1515,11 +1632,17 @@ function OwnCreateStep({
|
||||||
initial,
|
initial,
|
||||||
forkedFromId,
|
forkedFromId,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
onSearchTemplate,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: CourseTemplateValues
|
initial?: CourseTemplateValues
|
||||||
forkedFromId?: string
|
forkedFromId?: string
|
||||||
onCreated: (course: Course) => void
|
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
|
onCancel: () => void
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(initial?.name ?? "")
|
const [name, setName] = useState(initial?.name ?? "")
|
||||||
|
|
@ -1603,6 +1726,16 @@ function OwnCreateStep({
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
||||||
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
|
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
|
||||||
|
|
||||||
|
{onSearchTemplate && !initial && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSearchTemplate}
|
||||||
|
className="flex min-h-11 items-center gap-1.5 self-start text-base font-semibold text-primary underline underline-offset-2 hover:no-underline"
|
||||||
|
>
|
||||||
|
Basér på en eksisterende bane i stedet
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="text-base font-medium text-destructive">{error}</p>}
|
{error && <p className="text-base font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -1904,6 +2037,8 @@ function Step2(props: {
|
||||||
setBbbBonus: (v: boolean) => void
|
setBbbBonus: (v: boolean) => void
|
||||||
shambleBest: number
|
shambleBest: number
|
||||||
setShambleBest: (v: number) => void
|
setShambleBest: (v: number) => void
|
||||||
|
soloAllowance: number
|
||||||
|
setSoloAllowance: (v: number) => void
|
||||||
advOpen: boolean
|
advOpen: boolean
|
||||||
setAdvOpen: (v: boolean) => void
|
setAdvOpen: (v: boolean) => void
|
||||||
useHcp: boolean
|
useHcp: boolean
|
||||||
|
|
@ -1918,7 +2053,7 @@ function Step2(props: {
|
||||||
const {
|
const {
|
||||||
statLevel, setStatLevel, format, setFormat, holes, setHoles,
|
statLevel, setStatLevel, format, setFormat, holes, setHoles,
|
||||||
matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie,
|
matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie,
|
||||||
bbbBonus, setBbbBonus, shambleBest, setShambleBest,
|
bbbBonus, setBbbBonus, shambleBest, setShambleBest, soloAllowance, setSoloAllowance,
|
||||||
advOpen, setAdvOpen, useHcp, setUseHcp, useCourseHcp, setUseCourseHcp,
|
advOpen, setAdvOpen, useHcp, setUseHcp, useCourseHcp, setUseCourseHcp,
|
||||||
useMatchplayHcp, setUseMatchplayHcp, hcpPercent, setHcpPercent,
|
useMatchplayHcp, setUseMatchplayHcp, hcpPercent, setHcpPercent,
|
||||||
} = props
|
} = props
|
||||||
|
|
@ -1977,8 +2112,11 @@ function Step2(props: {
|
||||||
setBbbBonus={setBbbBonus}
|
setBbbBonus={setBbbBonus}
|
||||||
shambleBest={shambleBest}
|
shambleBest={shambleBest}
|
||||||
setShambleBest={setShambleBest}
|
setShambleBest={setShambleBest}
|
||||||
|
soloAllowance={soloAllowance}
|
||||||
|
setSoloAllowance={setSoloAllowance}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!SCRAMBLE_SOLO_VARIANTS.has(format) && (
|
||||||
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
|
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -2016,6 +2154,7 @@ function Step2(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<SectionTitle title="Antall hull" />
|
<SectionTitle title="Antall hull" />
|
||||||
|
|
@ -2064,6 +2203,8 @@ function FormatConfig({
|
||||||
setBbbBonus,
|
setBbbBonus,
|
||||||
shambleBest,
|
shambleBest,
|
||||||
setShambleBest,
|
setShambleBest,
|
||||||
|
soloAllowance,
|
||||||
|
setSoloAllowance,
|
||||||
}: {
|
}: {
|
||||||
format: FormatId
|
format: FormatId
|
||||||
matchExcludeHcp: boolean
|
matchExcludeHcp: boolean
|
||||||
|
|
@ -2076,6 +2217,8 @@ function FormatConfig({
|
||||||
setBbbBonus: (v: boolean) => void
|
setBbbBonus: (v: boolean) => void
|
||||||
shambleBest: number
|
shambleBest: number
|
||||||
setShambleBest: (v: number) => void
|
setShambleBest: (v: number) => void
|
||||||
|
soloAllowance: number
|
||||||
|
setSoloAllowance: (v: number) => void
|
||||||
}) {
|
}) {
|
||||||
if (format === "match") {
|
if (format === "match") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -2152,6 +2295,10 @@ function FormatConfig({
|
||||||
return <HelperNote>Nøyaktig 3 spillere.</HelperNote>
|
return <HelperNote>Nøyaktig 3 spillere.</HelperNote>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
|
||||||
|
return <SoloAllowanceControl value={soloAllowance} onChange={setSoloAllowance} />
|
||||||
|
}
|
||||||
|
|
||||||
if (TWO_SIDED.has(format)) {
|
if (TWO_SIDED.has(format)) {
|
||||||
return <HelperNote>Du setter opp lagene i neste steg.</HelperNote>
|
return <HelperNote>Du setter opp lagene i neste steg.</HelperNote>
|
||||||
}
|
}
|
||||||
|
|
@ -2159,6 +2306,83 @@ function FormatConfig({
|
||||||
return null
|
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 ---------------------------------------------------------
|
// --- Step 3: Spillere ---------------------------------------------------------
|
||||||
|
|
||||||
function Step3({
|
function Step3({
|
||||||
|
|
@ -2682,13 +2906,19 @@ function Step4({
|
||||||
setPlayers,
|
setPlayers,
|
||||||
sides,
|
sides,
|
||||||
setSides,
|
setSides,
|
||||||
|
roundStatLevel,
|
||||||
}: {
|
}: {
|
||||||
format: FormatId
|
format: FormatId
|
||||||
players: Player[]
|
players: Player[]
|
||||||
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
|
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
|
||||||
sides: Side[]
|
sides: Side[]
|
||||||
setSides: React.Dispatch<React.SetStateAction<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") {
|
if (format === "moneyball") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<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 }) {
|
function CreateSideCard({ slot, onCreate, disabled }: { slot: number; onCreate: (label: string) => void; disabled: boolean }) {
|
||||||
const [label, setLabel] = useState("")
|
const [label, setLabel] = useState("")
|
||||||
const fallback = slot === 0 ? "Side A" : "Side B"
|
const fallback = slot === 0 ? "Side A" : "Side B"
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,29 @@ const STATUS_CONFIG: Record<RoundStatus, { label: string }> = {
|
||||||
completed: { label: "Fullført" },
|
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", {
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|
@ -120,6 +143,9 @@ export function RoundCard({ round }: { round: Round }) {
|
||||||
? round.differential.toFixed(1).replace(".", ",")
|
? round.differential.toFixed(1).replace(".", ",")
|
||||||
: null
|
: null
|
||||||
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
|
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,
|
// To-sidede formater (match/fourball/foursome/greensome/scramble,
|
||||||
// 2026-07-29): vis matchStatus i stedet for rå slagtall -- brutto
|
// 2026-07-29): vis matchStatus i stedet for rå slagtall -- brutto
|
||||||
|
|
@ -178,9 +204,16 @@ export function RoundCard({ round }: { round: Round }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<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])}>
|
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
|
||||||
{STATUS_CONFIG[round.status].label}
|
{STATUS_CONFIG[round.status].label}
|
||||||
</span>
|
</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" && (
|
{round.status === "active" && (
|
||||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||||
{played}/{round.holes} hull spilt
|
{played}/{round.holes} hull spilt
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
||||||
|
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
@ -193,6 +194,9 @@ type ApiParticipant = {
|
||||||
type ApiSide = {
|
type ApiSide = {
|
||||||
id: string
|
id: string
|
||||||
label: string | null
|
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 = {
|
type PersonMatch = {
|
||||||
|
|
@ -264,6 +268,14 @@ const TWO_SIDED_FORMATS = new Set([
|
||||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
"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"])
|
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> = {
|
const SIDE_PLAYER_COUNT: Record<string, number> = {
|
||||||
match: 1,
|
match: 1,
|
||||||
fourball: 2,
|
fourball: 2,
|
||||||
|
|
@ -291,6 +303,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
shamble: "Shamble",
|
shamble: "Shamble",
|
||||||
money_ball: "Money Ball",
|
money_ball: "Money Ball",
|
||||||
high_low_high: "High-low-high",
|
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,
|
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
|
||||||
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
|
// 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 activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
|
||||||
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
|
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
|
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
|
||||||
// loadRound() har satt currentHole fra round.start_hole.
|
// loadRound() har satt currentHole fra round.start_hole.
|
||||||
const activeHole = currentHole ?? round?.start_hole ?? 1
|
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
|
// Navigasjonsrekkefølge starter på øktens starthull og går rundt
|
||||||
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
|
// (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
|
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||||||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
// 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.
|
// 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)
|
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
|
||||||
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
|
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
|
||||||
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
|
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
|
||||||
// mening for en delt ball).
|
// mening for en delt ball).
|
||||||
const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
|
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 wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
|
||||||
const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : 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.
|
// Felles klikk-håndtering for PlayerHoleCards/ScorecardGrid sine
|
||||||
const sharedHole: Hole | null = (() => {
|
// "Registrer X sin score"-kort (2026-08-05) -- for scramble mot
|
||||||
for (const s of round?.sides ?? []) {
|
// enkeltspiller skal LAGETS spillere åpne den delte SideScoreWizard
|
||||||
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
|
// (samme lagscore for alle, keyet på round_side_id), mens
|
||||||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
// 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
|
// Auto-prompt "Fullfør runde" (2026-07-30): er ALLE hull ført for ALLE
|
||||||
// deltakere (individuelle formater) eller for BEGGE sider (delt-ball) --
|
// deltakere (individuelle formater) eller for BEGGE sider (delt-ball) --
|
||||||
|
|
@ -1002,6 +1058,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
function advanceWizardSide() {
|
function advanceWizardSide() {
|
||||||
if (!round) return
|
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 idx = round.sides.findIndex((s) => s.id === wizardSideId)
|
||||||
const isLast = idx === -1 || idx === round.sides.length - 1
|
const isLast = idx === -1 || idx === round.sides.length - 1
|
||||||
if (!isLast) {
|
if (!isLast) {
|
||||||
|
|
@ -1459,7 +1525,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
|
full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
|
||||||
"Vis hele scorekortet"-bryter under -- ingen informasjon er
|
"Vis hele scorekortet"-bryter under -- ingen informasjon er
|
||||||
fjernet. */}
|
fjernet. */}
|
||||||
{holes.length === 0 ? (
|
{hole === null ? (
|
||||||
<div className="mt-5 flex justify-center py-8">
|
<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 aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1481,11 +1547,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
}}
|
}}
|
||||||
onPrevHole={goPrev}
|
onPrevHole={goPrev}
|
||||||
onNextHole={goNext}
|
onNextHole={goNext}
|
||||||
onOpenEntry={(playerId, holeNumber) => {
|
onOpenEntry={openPlayerOrTeamSideEntry}
|
||||||
setCurrentHole(holeNumber)
|
|
||||||
setActivePlayerId(playerId)
|
|
||||||
setWizardPlayerId(playerId)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
|
@ -1514,11 +1576,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
setCurrentHole(n)
|
setCurrentHole(n)
|
||||||
scrollToHolePanel()
|
scrollToHolePanel()
|
||||||
}}
|
}}
|
||||||
onOpenEntry={(playerId, holeNumber) => {
|
onOpenEntry={openPlayerOrTeamSideEntry}
|
||||||
setCurrentHole(holeNumber)
|
|
||||||
setActivePlayerId(playerId)
|
|
||||||
setWizardPlayerId(playerId)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1534,12 +1592,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
|
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
|
||||||
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
|
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
|
||||||
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
|
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
|
||||||
{wizardSide && sharedHole && (
|
{wizardSide && hole && (
|
||||||
<SideScoreWizard
|
<SideScoreWizard
|
||||||
sides={round.sides}
|
sides={round.sides}
|
||||||
side={wizardSide}
|
side={wizardSide}
|
||||||
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
|
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
|
||||||
hole={sharedHole}
|
hole={hole}
|
||||||
score={wizardSideHole?.played ? wizardSideHole.score : null}
|
score={wizardSideHole?.played ? wizardSideHole.score : null}
|
||||||
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
|
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
|
||||||
onSave={updateWizardSideScore}
|
onSave={updateWizardSideScore}
|
||||||
|
|
@ -2573,6 +2631,11 @@ type ApiFormatResult = {
|
||||||
money_ball_team_score: number | null
|
money_ball_team_score: number | null
|
||||||
hlh_points_a: number | null
|
hlh_points_a: number | null
|
||||||
hlh_points_b: 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
|
holes: ApiFormatHoleOut[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2622,6 +2685,50 @@ function matchStillingByHole(
|
||||||
// navn når nøyaktig én er tildelt siden (match, eller en komplett foursome-
|
// 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
|
// side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers
|
||||||
// sidens eget (evt. generiske) navn uten noen entydig HCP å vise.
|
// 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 } {
|
function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } {
|
||||||
const idx = side === "a" ? 0 : 1
|
const idx = side === "a" ? 0 : 1
|
||||||
const sideId = round.sides[idx]?.id
|
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).
|
// To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
|
||||||
if (!result.ready) {
|
if (!result.ready) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
|
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||||
|
|
||||||
// --- API-typer ---------------------------------------------------------------
|
// --- API-typer ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -65,6 +66,9 @@ type ApiRoundParticipant = {
|
||||||
type ApiSide = {
|
type ApiSide = {
|
||||||
id: string
|
id: string
|
||||||
label: string | null
|
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 = {
|
type ApiRoundHeader = {
|
||||||
|
|
@ -87,6 +91,16 @@ type ApiRoundHeader = {
|
||||||
// derfor vist en tom/meningsløs liste for dem uansett.
|
// derfor vist en tom/meningsløs liste for dem uansett.
|
||||||
const TWO_SIDED_FORMATS = [
|
const TWO_SIDED_FORMATS = [
|
||||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman",
|
"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
|
// High-low-high er et SÆRTILFELLE av de to-sidede formatene -- IKKE et
|
||||||
// hull-ledelse-konsept ("X UP"), se HighLowBoard.
|
// 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
|
// Shamble/Money Ball -- HELE runden er ETT lag, ikke to sider -- lagets
|
||||||
// EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard.
|
// EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard.
|
||||||
const TEAM_RESULT_FORMATS = new Set(["shamble", "money_ball"])
|
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å
|
// Bingo Bango Bongo/Københavner har egne poengsystem, uavhengig av rå
|
||||||
// slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk
|
// slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk
|
||||||
// Stableford (backend regner den likt uansett format), FEIL for disse to.
|
// 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",
|
bbb: "bbb_points",
|
||||||
copenhagen: "copenhagen_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" })
|
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
|
money_ball_team_score: number | null
|
||||||
hlh_points_a: number | null
|
hlh_points_a: number | null
|
||||||
hlh_points_b: 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
|
holes: ApiFormatHole[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1079,6 +1126,54 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
|
||||||
return result
|
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 }
|
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) {
|
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 isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
|
||||||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||||
const isTeamResult = TEAM_RESULT_FORMATS.has(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]
|
const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format]
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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">
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
{round.name?.trim() || round.course_name_snapshot}
|
{round.name?.trim() || round.course_name_snapshot}
|
||||||
</span>
|
</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">
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||||
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
|
{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)
|
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
|
if (!round || !leaderboard || round.participants.length < 2) return null
|
||||||
|
|
||||||
// To-sidede formater (match/fourball/foursome/greensome/scramble/
|
// To-sidede formater (match/fourball/foursome/greensome/scramble/
|
||||||
// high_low_high) vises allerede av FormatResultPanel rett under denne
|
// high_low_high/scramble_solo_match) vises allerede av FormatResultPanel
|
||||||
// komponenten sitt kallsted i round-detail.tsx -- ingen individuell
|
// rett under denne komponenten sitt kallsted i round-detail.tsx -- ingen
|
||||||
// brutto-rangering å vise her. Shamble/Money Ball har heller ingen
|
// individuell brutto-rangering å vise her. Shamble/Money Ball har heller
|
||||||
// meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er det
|
// ingen meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er
|
||||||
// som teller, vist i TeamFlightBoard på selve leaderboard-siden i
|
// det som teller, vist i TeamFlightBoard på selve leaderboard-siden i
|
||||||
// stedet for en misvisende råscore-rangering her.
|
// 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 (
|
if (
|
||||||
TWO_SIDED_FORMATS.includes(round.play_format) ||
|
TWO_SIDED_FORMATS.includes(round.play_format) ||
|
||||||
HIGH_LOW_FORMATS.has(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
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ type ApiParticipant = {
|
||||||
type ApiSide = {
|
type ApiSide = {
|
||||||
id: string
|
id: string
|
||||||
label: string | null
|
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 = {
|
type ApiRound = {
|
||||||
|
|
@ -86,7 +89,17 @@ type ApiHole = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
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> = {
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
stroke: "Slagspill",
|
stroke: "Slagspill",
|
||||||
stableford: "Stableford",
|
stableford: "Stableford",
|
||||||
|
|
@ -104,6 +117,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
shamble: "Shamble",
|
shamble: "Shamble",
|
||||||
money_ball: "Money Ball",
|
money_ball: "Money Ball",
|
||||||
high_low_high: "High-low-high",
|
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" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
@ -778,8 +793,41 @@ function MatchScorecardGrid({
|
||||||
if (!result) return null
|
if (!result) return null
|
||||||
|
|
||||||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
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) => ({
|
? round.sides.map((s, idx) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
side: idx === 0 ? "a" : ("b" as const),
|
side: idx === 0 ? "a" : ("b" as const),
|
||||||
|
|
@ -1079,6 +1127,19 @@ export function RoundScorecard({
|
||||||
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
|
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
|
||||||
setUnitId((prev) => {
|
setUnitId((prev) => {
|
||||||
if (prev) return 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) {
|
if (isSharedBall) {
|
||||||
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
|
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
|
||||||
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
|
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
|
||||||
|
|
@ -1101,8 +1162,15 @@ export function RoundScorecard({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!unitId || !round) return
|
if (!unitId || !round) return
|
||||||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
// scramble_solo (migrasjon 056) har BLANDEDE enhetstyper i samme
|
||||||
const endpoint = isSharedBall
|
// 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}/sides/${unitId}/holes`
|
||||||
: `/rounds/${roundId}/participants/${unitId}/holes`
|
: `/rounds/${roundId}/participants/${unitId}/holes`
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -1140,12 +1208,14 @@ export function RoundScorecard({
|
||||||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||||
const isSkins = round.play_format === "skins"
|
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
|
const formatLabel = FORMAT_LABELS[round.play_format] ?? round.play_format
|
||||||
|
|
||||||
// Delt-ball-formater trenger sidene opprettet (via runde-siden sin
|
// Delt-ball-formater OG scramble_solo trenger sidene opprettet (via
|
||||||
// "Spillere og runde"-fane) FØR noe scorekort gir mening her -- vis en
|
// runde-siden sin "Spillere og runde"-fane) FØR noe scorekort gir
|
||||||
// forklarende melding i stedet for en evig lastespinner.
|
// mening her -- vis en forklarende melding i stedet for en evig
|
||||||
if (isSharedBall && round.sides.length < 2) {
|
// lastespinner.
|
||||||
|
if ((isSharedBall || isScrambleSolo) && round.sides.length < 2) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
|
|
@ -1163,15 +1233,33 @@ export function RoundScorecard({
|
||||||
)}
|
)}
|
||||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
<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">
|
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve
|
{isScrambleSolo
|
||||||
runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.
|
? `${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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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.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) }))
|
: 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) */}
|
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */}
|
||||||
{units.length > 1 && (
|
{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">
|
<div className="flex gap-2">
|
||||||
{units.map((u) => {
|
{units.map((u) => {
|
||||||
const active = u.id === unitId
|
const active = u.id === unitId
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
scramble_2: "Scramble (2)",
|
scramble_2: "Scramble (2)",
|
||||||
scramble_4: "Scramble (4)",
|
scramble_4: "Scramble (4)",
|
||||||
chapman: "Chapman",
|
chapman: "Chapman",
|
||||||
|
scramble_solo: "Scramble mot enkeltspiller",
|
||||||
|
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiHole = {
|
type ApiHole = {
|
||||||
|
|
|
||||||
350
frontend/components/scramble-solo-result.tsx
Normal file
350
frontend/components/scramble-solo-result.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Resultatvisning for "Scramble mot enkeltspiller" (migrasjon 056,
|
||||||
|
// 2026-08-05) -- ren V0-eksport (zip 31), uendret fra levert komponent.
|
||||||
|
// Adapter fra det generiske API-svaret (ApiFormatResult) til denne
|
||||||
|
// komponentens ScrambleSoloResult-props ligger i round-leaderboard.tsx
|
||||||
|
// (buildScrambleSoloResult) og round-detail.tsx (lokal kopi, samme navn).
|
||||||
|
|
||||||
|
import { Check, Flag, Minus } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data shape (matches the API response this component is built around)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ScrambleSoloHole {
|
||||||
|
holeNumber: number
|
||||||
|
par: number
|
||||||
|
strokeIndex: number
|
||||||
|
result: "a" | "b" | "halved" // "a" = team won hole, "b" = individual won, "halved" = tied
|
||||||
|
team: { gross: number; net: number; strokesReceived: number }
|
||||||
|
individual: { gross: number; net: number; strokesReceived: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScrambleSoloResult {
|
||||||
|
ready: boolean
|
||||||
|
teamLabel: string
|
||||||
|
individualLabel: string
|
||||||
|
teamNet: number | null
|
||||||
|
individualNet: number | null
|
||||||
|
margin: number | null // individualNet - teamNet; positive = team leads, negative = individual leads, 0 = tied
|
||||||
|
holes: ScrambleSoloHole[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Leader = "team" | "individual" | "tie"
|
||||||
|
|
||||||
|
function leaderFromMargin(margin: number | null): Leader {
|
||||||
|
if (margin === null) return "tie"
|
||||||
|
if (margin > 0) return "team"
|
||||||
|
if (margin < 0) return "individual"
|
||||||
|
return "tie"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ScrambleSoloResultView({ result }: { result: ScrambleSoloResult }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4">
|
||||||
|
{result.ready ? (
|
||||||
|
<>
|
||||||
|
<HeadlineCard result={result} />
|
||||||
|
<HoleLedger result={result} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<WaitingState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Waiting state ---------------------------------------------------------
|
||||||
|
|
||||||
|
function WaitingState() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="flex flex-col items-center gap-3 rounded-3xl border border-border bg-card p-8 text-center shadow-md shadow-black/8"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||||
|
<Flag aria-hidden="true" className="size-7" />
|
||||||
|
</span>
|
||||||
|
<h2 className="text-xl font-bold text-foreground text-balance">
|
||||||
|
Venter på at oppsettet blir fullført
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Resultatet vises så snart handicap og lag er ferdig satt opp for denne runden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Headline card ---------------------------------------------------------
|
||||||
|
|
||||||
|
function HeadlineCard({ result }: { result: ScrambleSoloResult }) {
|
||||||
|
const leader = leaderFromMargin(result.margin)
|
||||||
|
const strokes = result.margin === null ? 0 : Math.abs(result.margin)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
||||||
|
<MarginBanner leader={leader} strokes={strokes} result={result} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2">
|
||||||
|
<SideTotal
|
||||||
|
label={result.teamLabel}
|
||||||
|
kind="team"
|
||||||
|
net={result.teamNet}
|
||||||
|
leading={leader === "team"}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">mot</span>
|
||||||
|
</div>
|
||||||
|
<SideTotal
|
||||||
|
label={result.individualLabel}
|
||||||
|
kind="individual"
|
||||||
|
net={result.individualNet}
|
||||||
|
leading={leader === "individual"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarginBanner({
|
||||||
|
leader,
|
||||||
|
strokes,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
leader: Leader
|
||||||
|
strokes: number
|
||||||
|
result: ScrambleSoloResult
|
||||||
|
}) {
|
||||||
|
let text: string
|
||||||
|
let tone: "team" | "individual" | "tie"
|
||||||
|
|
||||||
|
if (leader === "tie") {
|
||||||
|
text = "Uavgjort"
|
||||||
|
tone = "tie"
|
||||||
|
} else if (leader === "team") {
|
||||||
|
text = `${result.teamLabel} leder med ${strokes} ${strokes === 1 ? "slag" : "slag"}`
|
||||||
|
tone = "team"
|
||||||
|
} else {
|
||||||
|
text = `${result.individualLabel} leder med ${strokes} slag`
|
||||||
|
tone = "individual"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-center",
|
||||||
|
tone === "team" && "border-primary/40 bg-primary/10",
|
||||||
|
tone === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
||||||
|
tone === "tie" && "border-border bg-muted/60",
|
||||||
|
)}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{tone === "tie" ? (
|
||||||
|
<Minus aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Check
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-5 shrink-0",
|
||||||
|
tone === "team" ? "text-primary" : "text-brand-orange",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-extrabold text-balance",
|
||||||
|
tone === "team" && "text-primary",
|
||||||
|
tone === "individual" && "text-brand-orange",
|
||||||
|
tone === "tie" && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideTotal({
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
net,
|
||||||
|
leading,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
kind: "team" | "individual"
|
||||||
|
net: number | null
|
||||||
|
leading: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1 rounded-2xl border p-4 text-center transition-colors",
|
||||||
|
leading && kind === "team" && "border-primary/40 bg-primary/10",
|
||||||
|
leading && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
||||||
|
!leading && "border-border bg-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"line-clamp-2 text-base font-bold leading-tight text-pretty",
|
||||||
|
leading && kind === "team" && "text-primary",
|
||||||
|
leading && kind === "individual" && "text-brand-orange",
|
||||||
|
!leading && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-5xl font-extrabold tabular-nums",
|
||||||
|
leading && kind === "team" && "text-primary",
|
||||||
|
leading && kind === "individual" && "text-brand-orange",
|
||||||
|
!leading && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{net ?? "–"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
netto
|
||||||
|
</span>
|
||||||
|
{leading && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||||
|
kind === "team" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Leder
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hole-by-hole ledger ---------------------------------------------------
|
||||||
|
|
||||||
|
function HoleLedger({ result }: { result: ScrambleSoloResult }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-baseline justify-between gap-3 px-1">
|
||||||
|
<h2 className="text-lg font-bold text-foreground">Hull for hull</h2>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{result.holes.length} {result.holes.length === 1 ? "hull spilt" : "hull spilt"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column legend so the two net columns are unambiguous */}
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 px-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span className="w-14">Hull</span>
|
||||||
|
<span className="truncate text-right">{result.teamLabel}</span>
|
||||||
|
<span className="truncate text-right">{result.individualLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
{result.holes.map((hole) => (
|
||||||
|
<HoleRow key={hole.holeNumber} hole={hole} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoleRow({ hole }: { hole: ScrambleSoloHole }) {
|
||||||
|
return (
|
||||||
|
<li className="grid grid-cols-[auto_1fr_1fr] items-stretch gap-2 p-3">
|
||||||
|
{/* Hole meta */}
|
||||||
|
<div className="flex w-14 flex-col justify-center">
|
||||||
|
<span className="text-lg font-extrabold tabular-nums text-foreground">{hole.holeNumber}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
Par {hole.par}
|
||||||
|
<span className="sr-only">, </span> · idx {hole.strokeIndex}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SideCell
|
||||||
|
gross={hole.team.gross}
|
||||||
|
net={hole.team.net}
|
||||||
|
received={hole.team.strokesReceived}
|
||||||
|
won={hole.result === "a"}
|
||||||
|
halved={hole.result === "halved"}
|
||||||
|
kind="team"
|
||||||
|
/>
|
||||||
|
<SideCell
|
||||||
|
gross={hole.individual.gross}
|
||||||
|
net={hole.individual.net}
|
||||||
|
received={hole.individual.strokesReceived}
|
||||||
|
won={hole.result === "b"}
|
||||||
|
halved={hole.result === "halved"}
|
||||||
|
kind="individual"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideCell({
|
||||||
|
gross,
|
||||||
|
net,
|
||||||
|
received,
|
||||||
|
won,
|
||||||
|
halved,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
gross: number
|
||||||
|
net: number
|
||||||
|
received: number
|
||||||
|
won: boolean
|
||||||
|
halved: boolean
|
||||||
|
kind: "team" | "individual"
|
||||||
|
}) {
|
||||||
|
const wonLabel = won
|
||||||
|
? kind === "team"
|
||||||
|
? "Laget vant hullet"
|
||||||
|
: "Utfordreren vant hullet"
|
||||||
|
: halved
|
||||||
|
? "Delt hull"
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-end justify-center gap-0.5 rounded-xl border px-3 py-2",
|
||||||
|
won && kind === "team" && "border-primary/40 bg-primary/10",
|
||||||
|
won && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
||||||
|
!won && halved && "border-dashed border-border bg-muted/40",
|
||||||
|
!won && !halved && "border-transparent bg-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Net (the number that decides the hole) — largest, bold */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{won && (
|
||||||
|
<Check
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("size-4 shrink-0", kind === "team" ? "text-primary" : "text-brand-orange")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-extrabold tabular-nums leading-none",
|
||||||
|
won && kind === "team" && "text-primary",
|
||||||
|
won && kind === "individual" && "text-brand-orange",
|
||||||
|
!won && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{net}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Gross + strokes received context */}
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
Brutto {gross}
|
||||||
|
{received > 0 && <> · {received} slag</>}
|
||||||
|
</span>
|
||||||
|
{wonLabel && <span className="sr-only">{wonLabel}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
||||||
shamble: "Shamble",
|
shamble: "Shamble",
|
||||||
money_ball: "Money Ball",
|
money_ball: "Money Ball",
|
||||||
high_low_high: "High-low-high",
|
high_low_high: "High-low-high",
|
||||||
|
scramble_solo: "Scramble mot enkeltspiller",
|
||||||
|
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||||
}
|
}
|
||||||
const TWO_SIDED_FORMATS = new Set([
|
const TWO_SIDED_FORMATS = new Set([
|
||||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -397,6 +397,23 @@ class RankedSplit:
|
||||||
return round_half_up(total)
|
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)
|
# 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")
|
# Match-status ("2 UP", "dormie", "3&2", "AS")
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
69
teecup-beskrivelse.md
Normal file
69
teecup-beskrivelse.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# TeeCup — hva det er, og hva det skal bli
|
||||||
|
|
||||||
|
## Kort sagt
|
||||||
|
|
||||||
|
TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget
|
||||||
|
for golfklubber, bedrifter og vennegjenger som spiller sammen. Navnet
|
||||||
|
smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup"
|
||||||
|
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en
|
||||||
|
konkurransedyktig runde, i ett ord.
|
||||||
|
|
||||||
|
## Hvor det startet
|
||||||
|
|
||||||
|
TeeCup begynte som en avlegger av teeoff.no (banedata/booking), bygget
|
||||||
|
rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull.
|
||||||
|
Det har siden vokst til en selvstendig, kommersiell produkt med egen
|
||||||
|
identitet (eget domene, teecup.golf, egen innlogging uavhengig av
|
||||||
|
teeoff) — solgt til klubber og bedrifter som kanskje aldri har hørt om
|
||||||
|
moderselskapet.
|
||||||
|
|
||||||
|
## Hva det er i dag
|
||||||
|
|
||||||
|
- **Ni turneringsformater** og voksende: fra vanlig slagspill og
|
||||||
|
Stableford, via klassiske to-mot-to-formater (Fourball, Foursome,
|
||||||
|
Greensome, Scramble), til mer eksotiske varianter som Chapman,
|
||||||
|
Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high
|
||||||
|
— og nyeste tilskudd: et scramble-lag mot én enkeltspiller.
|
||||||
|
- **Ekte, live scoreføring** — spillere fører slag hull for hull fra
|
||||||
|
telefonen sin, ute på banen. Leaderboard oppdaterer seg i sanntid.
|
||||||
|
- **Ekte handicap-system** — full WHS-indeksberegning (Score
|
||||||
|
Differential, Net Double Bogey, capping), ikke en forenklet
|
||||||
|
tilnærming.
|
||||||
|
- **Turneringer over tid** — individuelle turneringer over flere
|
||||||
|
runder, og en sesong-sammenlagt rangering (Order of Merit) på tvers
|
||||||
|
av flere arrangementer.
|
||||||
|
- **Sosialt lag** — venner, delte runder, følg-live for tilskuere,
|
||||||
|
personlig spillerprofil og historikk.
|
||||||
|
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
|
||||||
|
med push-varsler.
|
||||||
|
|
||||||
|
## Personlighet
|
||||||
|
|
||||||
|
Varm og energisk — klubbhus-stemning, ikke countryklubb-snobberi.
|
||||||
|
Vennlig og tilgjengelig fremfor bedriftsaktig eller luksuriøs. Trygg og
|
||||||
|
tydelig, men lekent — bygget for folk som spiller golf med venner en
|
||||||
|
lørdag, ikke bare turneringsproffer. Appen er dessuten bevisst bygget
|
||||||
|
for å være lesbar og betjenbar for noen med noe redusert syn, uten
|
||||||
|
briller — stor skrift, god kontrast, store trykkflater. Det skal gjerne
|
||||||
|
gjenspeiles i hvor ryddig og lesbar en logo er, selv i liten størrelse.
|
||||||
|
|
||||||
|
## Hva det skal bli
|
||||||
|
|
||||||
|
TeeCup fortsetter å utvide hvor mange måter man kan spille turnering på
|
||||||
|
— flere formater legges til etter hvert som brukere ber om dem, inkl.
|
||||||
|
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
|
||||||
|
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda
|
||||||
|
mer av det en golfklubb faktisk trenger for å drive turneringer — ikke
|
||||||
|
bare scoreføring, men hele reisen fra oppsett til sesongrangering.
|
||||||
|
Målet er å være det naturlige valget for klubber og bedrifter som vil
|
||||||
|
ha noe raskere og triveligere enn tunge, gamle turneringssystemer.
|
||||||
|
|
||||||
|
## Praktisk for logoen
|
||||||
|
|
||||||
|
- Farge-arven: frisk grønn (#8bc24a) og en varm oransje (#ff5722) er de
|
||||||
|
to kjernefargene i produktet i dag — nyttig referanse, ikke en
|
||||||
|
tvangstrøye for selve merket.
|
||||||
|
- Skal IKKE minne om eller gjenbruke teeoff.no sin logo — TeeCup er et
|
||||||
|
eget, selvstendig produkt.
|
||||||
|
- Må fungere som et lite app-ikon (hjemskjerm/favicon) like godt som en
|
||||||
|
ordmerke — les godt i miniatyr, ikke bare i stort format.
|
||||||
|
|
@ -19,6 +19,7 @@ from handicap_engine import (
|
||||||
CombinedPercentage,
|
CombinedPercentage,
|
||||||
WeightedLowHigh,
|
WeightedLowHigh,
|
||||||
RankedSplit,
|
RankedSplit,
|
||||||
|
TeamAverage,
|
||||||
DEFAULT_MATCHPLAY_ALLOWANCES,
|
DEFAULT_MATCHPLAY_ALLOWANCES,
|
||||||
adjusted_gross_score,
|
adjusted_gross_score,
|
||||||
allocate_strokes_by_index,
|
allocate_strokes_by_index,
|
||||||
|
|
@ -32,6 +33,7 @@ from handicap_engine import (
|
||||||
handicap_index_from_differentials,
|
handicap_index_from_differentials,
|
||||||
low_handicap_index,
|
low_handicap_index,
|
||||||
match_play_strokes,
|
match_play_strokes,
|
||||||
|
net_stroke_play_margin,
|
||||||
compute_skins,
|
compute_skins,
|
||||||
compute_skins_detail,
|
compute_skins_detail,
|
||||||
copenhagen_points_for_hole,
|
copenhagen_points_for_hole,
|
||||||
|
|
@ -176,6 +178,39 @@ def test_scramble_2_ranked_split():
|
||||||
assert unit_playing_handicap(players, strat) == 5
|
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():
|
def test_allowance_is_configurable_not_hardcoded():
|
||||||
"""ADR-005: motoren skal godta en overstyrt allowance (f.eks. 75 %/3/4)."""
|
"""ADR-005: motoren skal godta en overstyrt allowance (f.eks. 75 %/3/4)."""
|
||||||
strat_75 = PerPlayerPercentage(0.75)
|
strat_75 = PerPlayerPercentage(0.75)
|
||||||
|
|
@ -319,6 +354,25 @@ def test_match_state_side_b_leads():
|
||||||
assert state.describe() == "1 UP (B)"
|
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)
|
# HCP-indeksberegning fra spilte runder (ADR-033)
|
||||||
#
|
#
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue