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(echo \"tsc exit: $?\")",
|
||||
"Bash(docker rm -f teecup_frontend_scratch teecup_api_scratch teecup-minio-scratch > /dev/null 2>&1 *)",
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)"
|
||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)",
|
||||
"Bash(xargs -I{} basename {})",
|
||||
"Bash(python3 test_part2_backend.py)",
|
||||
"Bash(python3 test_oom_backend.py)",
|
||||
"Bash(grep -n \"Dine organisasjoner\\\\|organizations/\\\\[id\\\\]\\\\|/organizations/\\\\${\" /opt/teecup/frontend/components/dashboard.tsx)",
|
||||
"Bash(python3 -c \"import fastapi\")",
|
||||
"Bash(openssl rand *)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
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),
|
||||
containeren boot-et rent, `/health` og `/dashboard` → 200 over
|
||||
https.
|
||||
|
||||
22. **Nytt format: "Scramble mot enkeltspiller" (`scramble_solo`),
|
||||
slagspill-varianten — 2026-08-05 (migrasjon 056).** Første
|
||||
ASYMMETRISKE to-siders-format i prosjektet: et scramble-lag
|
||||
(variabel størrelse, N>=2, delt ball) mot ÉN individuell spiller
|
||||
(egen ball), avgjort ved netto SLAGSPILL-totalsum (ikke matchspill)
|
||||
-- laveste nettosum vinner. Bevisst utenfor omfang denne runden:
|
||||
match-play-varianten av formatet (egen runde senere), org-
|
||||
lagturneringer (kun frittstående runder nå).
|
||||
|
||||
**Kilder undersøkt FØR design** (CLAUDE.md sin regel for nye
|
||||
formater): de tre HCP-PDF-ene dekker IKKE lag-vs-individuell eller
|
||||
vilkårlig lagstørrelse -- bekreftet ved grundig lesing. Lagets
|
||||
Playing Handicap er derfor en bevisst NY, enkel TeeCup-regel
|
||||
(`TeamAverage` -- rent snitt av medlemmenes course handicap, for
|
||||
N>=2), bekreftet eksplisitt med bruker, IKKE en kildebelagt formel
|
||||
som `scramble_2`/`scramble_4` sine faste `RankedSplit`-tabeller
|
||||
(som forutsetter fast lagstørrelse og derfor bevisst IKKE gjenbrukt
|
||||
her). Individualspillerens allowance er en konfigurerbar prosent
|
||||
(`scramble_solo_individual_pct`, std 100 %, arrangør-satt) --
|
||||
egen bespoke kolonne, IKKE den generiske `allowance_override`-JSON-
|
||||
en (som forutsetter ÉN delt strategi for hele runden, mens dette
|
||||
formatet trenger to uavhengige strategier).
|
||||
|
||||
**Skjema** (migrasjon 056): `round.scramble_solo_individual_pct`
|
||||
(smallint, std 100) og `round_side.side_role`
|
||||
(`'team'`/`'individual'`, unik per rolle per runde). `round_hole`
|
||||
sin eksisterende XOR-struktur (migrasjon 031) trengte INGEN endring
|
||||
-- lagsiden bruker `round_side_id`-veien (samme som foursome/
|
||||
scramble), individualsiden bruker `round_participant_id`-veien
|
||||
(samme som singles), begge coexisterer allerede fritt i samme
|
||||
tabell.
|
||||
|
||||
**Motor** (`handicap_engine.py`): ny `TeamAverage`-strategi og ny,
|
||||
domeneagnostisk `net_stroke_play_margin(net_total_a, net_total_b)`
|
||||
-- eksplisitt SØSKEN til, ikke del av, `compute_match_state`
|
||||
(ingen hull-for-hull-tilstand for dette formatet). Selve per-side-
|
||||
nettosummen gjenbruker eksisterende `allocate_strokes_by_index`/
|
||||
`stroke_play_net_total`, ingen ny funksjon trengtes der. 8 nye
|
||||
enhetstester (115/115 totalt, opp fra 107).
|
||||
|
||||
**Backend** (`app/routers/rounds.py`): ny `_ASYMMETRIC_SIDE_FORMATS`-
|
||||
konstant (bevisst adskilt fra `_TWO_SIDED_FORMATS`, siden formatet
|
||||
verken er symmetrisk eller matchspill). Asymmetrisk sidekapasitet i
|
||||
`_check_side_capacity` (laget: ingen øvre grense, individualsiden:
|
||||
maks 1) -- kan IKKE uttrykkes av det eksisterende
|
||||
`_SIDE_PLAYER_COUNT`-oppslaget, som forutsetter ett fast tall for
|
||||
HELE formatet. Ny `_individual_round_hole_needed`-hjelpefunksjon
|
||||
(avgjør delt-ball vs. individuell-ball-lagring per DELTAKER, ikke
|
||||
per format, siden de to sidene har ulik struktur). Ny
|
||||
`_recompute_scramble_solo_handicaps` (søsterfunksjon til
|
||||
`_recompute_side_handicaps`, som ikke kunne gjenbrukes -- to
|
||||
uavhengige strategier, ikke én). Ny `_build_scramble_solo_result`
|
||||
(samme "regn ut ved lesing, ingen cache"-filosofi som Nassau/
|
||||
High-low-high), dispatchet FØR den generiske to-sidede grenen i
|
||||
`_build_format_result`.
|
||||
|
||||
**Frontend -- ALT promptet til V0** (brukerens eksplisitte instruks
|
||||
denne runden, gjentatt: "Alt av frontend skal promptes mot V0" --
|
||||
ingen håndkoding/design-beslutning av meg). To V0-prompter skrevet
|
||||
(forankret i `DESIGN_SYSTEM.md` + tilgjengelighetskravet), levert
|
||||
som zip 30 (oppsett-steget) og zip 31 (resultatvisning, bygget på
|
||||
30). Integrert -- IKKE blindt kopiert (V0 sin egen full-app-eksport
|
||||
bruker fortsatt mock-data for alt utenom det som faktisk ble
|
||||
promptet) -- kun de faktiske diff-relevante stykkene ble portet inn
|
||||
i de allerede API-koblede filene:
|
||||
- `new-round.tsx`: ny `ScrambleVsSoloSides`-komponent (Steg 4,
|
||||
asymmetrisk sideoppsett -- "Laget" åpen liste/"minst 2"-hint,
|
||||
"Individuell motstander" låst til 1), ny `SoloAllowanceControl`
|
||||
(Steg 2, 0-100 %-stepper). `submitWizard()` fikk en egen
|
||||
side-opprettelses-gren for formatet (sender `side_role`, som
|
||||
TWO_SIDED-formatenes gren ikke gjør). "Avanserte handicap-
|
||||
innstillinger"-panelet skjules helt for formatet (ingen av
|
||||
feltene der har noen effekt siden formatet aldri bruker
|
||||
`allowance_override`) -- funnet og rettet UNDER integrasjonen,
|
||||
ikke en del av V0-eksporten.
|
||||
- `scramble-solo-result.tsx`: NY fil, ren V0-eksport uendret
|
||||
(selvstendig presentasjonskomponent, `ScrambleSoloResultView`).
|
||||
Adapter fra `ApiFormatResult` til komponentens props-form skrevet
|
||||
lokalt i `round-leaderboard.tsx` (`buildScrambleSoloResult`) og
|
||||
`round-detail.tsx` (lokal kopi, prosjektets vanlige mønster) --
|
||||
foretrekker hull-entryens `label`-felt (deltakerens faktiske
|
||||
navn) over sidens egen (faste) label, med fallback.
|
||||
- `round-scorecard.tsx`: eneste sted som trengte GENUINT ny logikk
|
||||
(ikke bare registrering) -- formatet har BLANDEDE enhetstyper i
|
||||
samme fane-liste (laget = side, individualspilleren =
|
||||
participant). Løst ved å slå opp faktisk enhetstype fra selve
|
||||
`unitId` (`round.sides.some(s => s.id === unitId)`) i stedet for
|
||||
et format-bredt `isSharedBall`-flagg, som ikke kan uttrykke en
|
||||
blanding -- denne oppslags-tilnærmingen forble samtidig korrekt
|
||||
uendret for alle eksisterende rene formater.
|
||||
- `round-detail.tsx`: INGEN endring i selve scoreføringen
|
||||
(`SideScoreWizard` for laget, vanlig per-deltaker-veiviser for
|
||||
individualspilleren fungerer begge uendret) -- kun
|
||||
`FormatResultPanel` fikk en ny gren (MÅ avskjæres FØR den
|
||||
generiske to-sidede fallback-koden, ellers ville den krasjet/vist
|
||||
feil for dette asymmetriske formatet).
|
||||
- `FORMAT_LABELS` lagt til i 8 filer (`round-card.tsx`,
|
||||
`round-leaderboard.tsx`, `round-detail.tsx`, `round-scorecard.tsx`,
|
||||
`round-stats.tsx`, `friend-profile.tsx`, `watch-round.tsx`) --
|
||||
`public-live.tsx` bevisst IKKE (den nøkler på org-turneringers
|
||||
`session.format`, utenfor omfang).
|
||||
|
||||
**Scratch-verifisert grundig, TO RUNDER** (samme "backend alene,
|
||||
så full stack"-disiplin som Order of Merit-runden):
|
||||
1. Backend alene: isolert `teecup_scratch`-DB (alle 56 migrasjoner
|
||||
friskt), isolert `teecup_app_scratch`-rolle, isolert scratch-
|
||||
MinIO, engangs API-container. Håndregnet Python-testskript, 45/45
|
||||
sjekker bestått -- 3-manns lag (HCP 8/14/20, snitt 14) mot
|
||||
individualspiller (HCP 10), begge 100 % og 50 % allowance,
|
||||
inkl. eksplisitt bevis på at en hull-SI mellom de to
|
||||
allowance-tersklene (SI 7) korrekt skifter fra 1 til 0 mottatte
|
||||
slag. Kapasitetsgrenser bekreftet begge veier (laget avviser
|
||||
ALDRI en 4. spiller, individualsiden avviser alltid en 2.).
|
||||
"Fullfør runde"-gating eksplisitt testet med et ufullstendig lag
|
||||
(1 spiller) -- avvist med riktig forklarende melding, samme
|
||||
type sjekk som avdekket punkt 21 sin bug.
|
||||
2. Full stack: ny isolert scratch-runde (DB/rolle/MinIO/API-
|
||||
container pluss et ekte `docker build` av frontend-imaget --
|
||||
den faktiske typesjekkede produksjonsbuilden, ikke bare lokal
|
||||
`tsc`), ekte nettleser via magic-link-innlogging
|
||||
(`TEECUP_DEV_LOG_MAGIC_LINKS`). Opprettet en ekte runde gjennom
|
||||
hele veiviseren (inkl. V0 sin `ScrambleVsSoloSides`-UI), satte
|
||||
score på tre hull via ekte PATCH-kall, og bekreftet at
|
||||
`ScrambleSoloResultView` viste EKSAKT de håndregnede tallene
|
||||
("Laget leder med 1 slag", 12 mot 13 netto) med riktig
|
||||
grønn/oransje leder-fremheving per hull. `test_isolation.sql`
|
||||
uendret 12/12 (migrasjonen er rent additiv). Scratch-miljøet
|
||||
ryddet opp fullstendig etter BEGGE runder.
|
||||
|
||||
**Rullet ut live 2026-08-05**, bruker bekreftet eksplisitt: migrasjon
|
||||
056 kjørt mot ekte `teecup_db` (`round.scramble_solo_individual_pct`,
|
||||
`round_side.side_role` + `round_side_role_unique`-indeksen, bekreftet
|
||||
tilstede etterpå), `docker compose up -d --build teecup_api
|
||||
teecup_frontend`, begge containere boot-et rent, `/health` og
|
||||
`/my-rounds/new` → 200 over https, `round_play_format_check` bekreftet
|
||||
å inkludere `'scramble_solo'`.
|
||||
|
||||
23. **Matchspill-varianten av samme format (`scramble_solo_match`) —
|
||||
2026-08-05 (migrasjon 057), PLUSS en kritisk regresjonsbug funnet og
|
||||
rettet i samme runde som rammet BEGGE variantene (se eget punkt
|
||||
under).** Samme asymmetriske lag-mot-individualspiller-struktur som
|
||||
punkt 22, men avgjort hull-for-hull (matchspill) i stedet for netto
|
||||
slagspill-totalsum. Vesentlig mindre bygg enn punkt 22 -- nesten alt
|
||||
gjenbrukes uendret.
|
||||
|
||||
**Motor -- ingen endring:** `match_play_strokes`/`compute_match_state`
|
||||
(`handicap_engine.py`) var allerede domeneagnostiske og brukes uendret
|
||||
-- konverterer lagets `TeamAverage`/individualspillerens
|
||||
`PerPlayerPercentage(pct/100)` (samme `_recompute_scramble_solo_
|
||||
handicaps` som punkt 22, uendret) til RELATIVE slag (laveste side
|
||||
spiller "av scratch"), i motsetning til slagspill-variantens
|
||||
ABSOLUTTE, uavhengige allowance per side -- bevisst forskjell, riktig
|
||||
fordi matchspill per definisjon betyr relativ slagfordeling.
|
||||
|
||||
**Skjema** (migrasjon 057): ren CHECK-utvidelse, INGEN nye kolonner
|
||||
-- `round.scramble_solo_individual_pct`/`round_side.side_role`
|
||||
(migrasjon 056) er generiske nok til å gjenbrukes uendret.
|
||||
|
||||
**Backend:** ny delt konstant `_SCRAMBLE_SOLO_FORMATS = {"scramble_
|
||||
solo", "scramble_solo_match"}`, widened seks eksakte
|
||||
`== "scramble_solo"`-strengsjekker i `app/routers/rounds.py` til
|
||||
settmedlemskap. Ny `_build_scramble_solo_match_result` gjenbruker det
|
||||
eksisterende blandede hull-oppslaget fra `_build_scramble_solo_result`
|
||||
(samme to queries: `round_hole` via `round_side_id` for laget, via
|
||||
`round_participant_id` for individualspilleren), bygger en
|
||||
`list[HoleResult]` fra netto-sammenligning per hull, mater
|
||||
`compute_match_state` -- gir lead/holes_remaining/is_dormie/
|
||||
is_closed/`describe()` gratis.
|
||||
|
||||
**Frontend -- maksimalt gjenbruk, bekreftet eksplisitt med bruker
|
||||
(ingen ny V0-prompt for denne varianten):** `new-round.tsx` sin
|
||||
oppsett-UI (`ScrambleVsSoloSides`/`SoloAllowanceControl`) gjenbrukt
|
||||
HELT uendret. Resultatvisning gjenbruker `TwoSidedBoard`
|
||||
(round-leaderboard.tsx) og den generiske to-sidede
|
||||
`sideIdentity`/`LeadZone`-grenen (round-detail.tsx) -- begge allerede
|
||||
korrekte for en N-mot-1-side siden de grener på FAKTISK ANTALL
|
||||
SPILLERE per side, ikke på rolle. Én kritisk, selv-oppdaget
|
||||
korrekthetsbug FØR noen verifisering i det hele tatt: første utkast
|
||||
av `_build_scramble_solo_match_result` brukte en FAST konvensjon
|
||||
(laget alltid "a", individualspilleren alltid "b") -- resten av appen
|
||||
(inkl. `TwoSidedBoard`) forutsetter derimot at "a" = `round.sides[0]`
|
||||
SORTERT ETTER UUID, ikke rolle. Rettet til `team_is_a = team_side["id"]
|
||||
< individual_side["id"]`, tredd gjennom hull-sammenligningen og
|
||||
`FormatHoleEntry`-side-tagger. `round-scorecard.tsx` sin
|
||||
`MatchScorecardGrid` fikk en liten, dedikert (ikke-V0) gren for å vise
|
||||
ÉN rad per SIDE for dette formatet (samme mønster som `isSharedBall`-
|
||||
grenen der) -- uten den ville laget vist N dupliserte rader, siden
|
||||
alle lagmedlemmer deler samme `TeamAverage`-tall.
|
||||
|
||||
**KRITISK REGRESJONSBUG funnet under full nettleser-verifisering,
|
||||
rammet OGSÅ den allerede live slagspill-varianten (punkt 22) --
|
||||
ikke bare den nye matchspill-varianten:** `round-detail.tsx` sin
|
||||
"Score"-fane (`SHARED_BALL_FORMATS`) inneholdt aldri `scramble_solo`/
|
||||
`scramble_solo_match` (kun ekte delt-ball-formater som foursome/
|
||||
scramble_2/scramble_4/chapman) -- rimelig nok isolert sett, siden
|
||||
scramble mot enkeltspiller er HALVPARTEN delt-ball (laget) og
|
||||
HALVPARTEN individuell (motstanderen), ikke rent det ene eller det
|
||||
andre. Konsekvens: et klikk på en LAGSPILLERS scorekort rutet
|
||||
(feilaktig) til den vanlige per-deltaker `ScoringWizard`/
|
||||
`wizardPlayerId`-stien i stedet for den delte `SideScoreWizard`/
|
||||
`wizardSideId`-stien. Siden lagspilleres hull ALDRI lagres per
|
||||
`round_participant_id` (kun per `round_side_id`), returnerte
|
||||
`GET .../participants/{id}/holes` alltid `[]` for dem -- og siden
|
||||
SPINNER-porten i samme fil (`holes.length === 0`) også var avledet
|
||||
fra akkurat DENNE deltakerens egen (evig tomme) liste, satt den seg
|
||||
fast i en evig spinner, permanent, uten noen feilmelding. Siden
|
||||
`SHARED_BALL_FORMATS` aldri var endret for `scramble_solo` i punkt
|
||||
22 sin utrulling heller, var dette allerede live og reelt ødelagt
|
||||
for ekte lagspillere på scramble_solo-runder FØR denne rettelsen.
|
||||
Rettet: `hole`/spinner-porten hentes nå fra HVILKEN SOM HELST kilde
|
||||
som allerede har lastet inn hull (deltaker- ELLER side-basert,
|
||||
uavhengig av hvem som er "aktiv spiller") i stedet for kun aktiv
|
||||
spillers egen liste; ny `openPlayerOrTeamSideEntry`-klikkhåndterer
|
||||
ruter lagets spillere til `wizardSideId`, individualspilleren
|
||||
fortsatt til `wizardPlayerId`; `advanceWizardPlayer`/`advanceWizardSide`
|
||||
justert til å ikke lenger kunne "vandre inn i" den andre kjeden.
|
||||
Funnet OG rettet FØR migrasjon 057/redeploy -- ikke en kjent,
|
||||
ureparert feil et øyeblikk i produksjon.
|
||||
|
||||
**Verifisering:**
|
||||
1. Håndregnet API-testskript (`test_scramble_solo_match.py`, 31/31
|
||||
bestått): 3-manns lag (HCP 8/14/20 → TeamAverage 14) mot
|
||||
individualspiller (HCP 10, 100 %), `match_play_strokes([14,10])`,
|
||||
5 spilte hull → "2 UP", `hole_results`/`strokes_received` stemte
|
||||
hånd-for-hånd mot forventet forløp. Dynamisk `team_is_a`-utledning
|
||||
i selve testen (ikke antatt fast) for å faktisk teste sorterings-
|
||||
konvensjonen, ikke bare den ene retningen.
|
||||
2. Full stack, ekte nettleser (fjerde scratch-miljø, ekte
|
||||
`docker build` av frontend-imaget): opprettet en runde gjennom
|
||||
hele veiviseren, satte team/individualspiller opp, entret score
|
||||
via BEGGE veiviserne (fant og rettet bug-en HER, se over), bekreftet
|
||||
`LeadZone` viste korrekt "X UP" og skiftet leder korrekt, bekreftet
|
||||
`MatchScorecardGrid` viste eksakt ÉN rad for laget (ikke tre) og
|
||||
riktig "N UP"-progresjon per hull, fullførte runden via ekte
|
||||
"Fullfør runde"-knapp.
|
||||
3. Regresjonsbekreftet SEPARAT at samme fiks løser samme bug for den
|
||||
allerede-live slagspill-varianten (`scramble_solo`): opprettet en
|
||||
ny runde med det formatet i samme scratch-miljø, bekreftet at
|
||||
`SideScoreWizard` nå åpner korrekt for lagsiden (før fiksen: samme
|
||||
evige spinner).
|
||||
4. `test_isolation.sql` uendret 12/12. Scratch-miljøet (DB/rolle/
|
||||
MinIO/API-/frontend-containere/images) ryddet opp fullstendig
|
||||
etter bruk.
|
||||
|
||||
**Rullet ut live 2026-08-05** (samme økt som punkt 22): migrasjon 057
|
||||
kjørt mot ekte `teecup_db` (`round_play_format_check` bekreftet å nå
|
||||
inkludere `'scramble_solo_match'`), `docker compose up -d --build
|
||||
teecup_api teecup_frontend` (inkluderer BÅDE matchspill-formatet OG
|
||||
bug-fiksen over -- fiksen var derfor allerede live for ekte
|
||||
`scramble_solo`-lagspillere idet denne utrullingen fullførte), begge
|
||||
containere boot-et rent, `/my-rounds/new` → 200 over https.
|
||||
|
||||
24. **"Bruk som mal for egen bane" viste seg for bredt — 2026-08-05,
|
||||
rent frontend, ingen skjema-/backend-endring.** Brukeren rapporterte
|
||||
at knappen (fork en eksisterende personlig bane til utgangspunkt for
|
||||
en NY) dukket opp i det vanlige "Egen bane"-søket i opprett-runde-
|
||||
veiviseren, uansett om målet var å VELGE en eksisterende bane å
|
||||
spille på eller å opprette en helt ny -- feil sted for en
|
||||
mal-handling som kun gir mening når brukeren faktisk har sagt at
|
||||
målet er å opprette.
|
||||
|
||||
**Avklart med bruker (tre alternativer presentert)**: knappen skal
|
||||
KUN vises når brukeren eksplisitt har trykket "Opprett ny bane" og
|
||||
derfra velger å basere den nye banen på en eksisterende, ikke i det
|
||||
vanlige søket.
|
||||
|
||||
**Fiks (`frontend/components/new-round.tsx`):** ny `S1Sub`-verdi
|
||||
`"own-search-template"`, distinkt fra det vanlige `"own-search"`.
|
||||
`OwnSearch`s `onUseAsTemplate`-prop gjort valgfri (var påkrevd) --
|
||||
"Bruk som mal for egen bane" rendres nå kun `{onUseAsTemplate &&
|
||||
(...)}`, akkurat som den allerede korrekt gaterte offisiell-bane-
|
||||
varianten i `CourseList`. Det vanlige `"own-search"`-kallet (nådd fra
|
||||
Step1 sitt "Egen bane"-kort) slutter å sende med `onUseAsTemplate` i
|
||||
det hele tatt. Ny lenke "Basér på en eksisterende bane i stedet" lagt
|
||||
til øverst i `OwnCreateStep`s blanke skjema (kun synlig når
|
||||
`!initial`, altså før noen mal er valgt) -- trykk navigerer til
|
||||
`"own-search-template"`, som gjenbruker `OwnSearch` UENDRET, denne
|
||||
gangen MED `onUseAsTemplate` satt (samme forhåndsutfyllings-bro som
|
||||
allerede fantes for offisielle baner). Ny `ownCreateOrigin`-state
|
||||
(løftet til toppnivå-hook-en, ved siden av `s1Sub`) sporer hvor
|
||||
"own-create" skal gå TILBAKE til (vanlig søk, mal-søk, eller broen
|
||||
fra offisielle baner) -- uten denne ville tilbake-knappen fra
|
||||
mal-søket hoppet feil sted.
|
||||
|
||||
**Scratch-verifisert i ekte nettleser** (femte scratch-miljø, ekte
|
||||
`docker build` av frontend-imaget): bekreftet at "Bruk som mal for
|
||||
egen bane" IKKE lenger vises i det vanlige "Egen bane"-søket;
|
||||
bekreftet at "Basér på en eksisterende bane i stedet"-lenken vises i
|
||||
det blanke opprett-skjemaet; trykket den, bekreftet at knappen NÅ
|
||||
vises i det samme søket; trykket "Bruk som mal", bekreftet at
|
||||
opprett-skjemaet forhåndsfylte navn/18 hull/utslag korrekt fra den
|
||||
valgte banen; bekreftet at tilbake-knappen fra opprett-skjemaet gikk
|
||||
til mal-søket (ikke det vanlige søket eller helt til `source`).
|
||||
Ingen konsoll-feil. Scratch-miljøet (DB/rolle/MinIO/API-/frontend-
|
||||
containere/images) ryddet opp fullstendig etter bruk.
|
||||
|
||||
**Rullet ut live 2026-08-05** (kun `teecup_frontend` bygget/
|
||||
restartet -- ren frontend-endring, ingen skjema-/API-endring).
|
||||
`/my-rounds/new` → 200 over https etterpå.
|
||||
|
|
|
|||
|
|
@ -4130,6 +4130,64 @@ isolasjon + ekte nettleser-gjennomgang).
|
|||
|
||||
---
|
||||
|
||||
## Scramble mot enkeltspiller — ✅ BEGGE VARIANTER (slagspill + matchspill) BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-05 (migrasjon 056+057). Org-lagturneringer 📋 GJENSTÅR
|
||||
|
||||
Brukeren ba om et nytt, niende turneringsformat: et scramble-lag
|
||||
(variabel størrelse, N>=2, delt ball) mot ÉN individuell spiller (egen
|
||||
ball). Første ASYMMETRISKE to-siders-format i prosjektet -- de åtte
|
||||
tidligere formatene (se seksjonen over) har alle SAMME struktur på
|
||||
begge sider.
|
||||
|
||||
**Bygget denne runden (kun frittstående runder, kun slagspill-
|
||||
sammenligning):** skjema (migrasjon 056: `round.scramble_solo_
|
||||
individual_pct`, `round_side.side_role`), motor (`TeamAverage`,
|
||||
`net_stroke_play_margin` -- 8 nye enhetstester, 115/115 totalt), full
|
||||
API-wiring i `app/routers/rounds.py` (asymmetrisk sidekapasitet,
|
||||
konfigurerbar individuell allowance, `_build_scramble_solo_result`).
|
||||
Frontend ALT promptet til V0 (brukerens eksplisitte instruks denne
|
||||
runden) -- ny `ScrambleSoloResultView`-komponent + `ScrambleVsSoloSides`-
|
||||
sideoppsett i opprett-runde-veiviseren, integrert (ikke blindt kopiert)
|
||||
mot de eksisterende API-koblede filene. Se CHANGELOG.md 2026-08-05
|
||||
(punkt 22) for full bygge-/verifiseringsdetalj, inkl. to separate
|
||||
scratch-verifiseringsrunder (backend alene, deretter full stack med
|
||||
ekte nettleser).
|
||||
|
||||
**Kildesjekk før design:** ingen av de tre HCP-PDF-ene (se CLAUDE.md)
|
||||
dekker lag-vs-individuell eller vilkårlig lagstørrelse -- lagets
|
||||
Playing Handicap (`TeamAverage`, rent snitt) er derfor en bevisst NY
|
||||
TeeCup-regel, bekreftet eksplisitt med bruker, ikke kildebelagt slik
|
||||
`scramble_2`/`scramble_4` sine `RankedSplit`-tabeller er.
|
||||
|
||||
**Bevisst utenfor omfang, fortsatt (ikke glemt):**
|
||||
- **Org-lagturneringer** (`session.format`) -- kun frittstående runder
|
||||
nå. `public-live.tsx` sin `FORMAT_LABELS` (som nøkler på
|
||||
`session.format`) fikk derfor bevisst IKKE formatet lagt til.
|
||||
- Ingen kobling til Order of Merit for dette formatet.
|
||||
|
||||
**Matchspill-varianten (`scramble_solo_match`) bygget samme dag,
|
||||
2026-08-05 (migrasjon 057):** samme lag-mot-individuell-idé, avgjort
|
||||
hull-for-hull (`match_play_strokes`/`compute_match_state`, begge
|
||||
gjenbrukt uendret) i stedet for netto slagspill-totalsum. Ren
|
||||
CHECK-migrasjon (ingen nye kolonner). Frontend gjenbruker
|
||||
`TwoSidedBoard`/`LeadZone`/oppsett-UI-en fra slagspill-varianten helt
|
||||
uendret (bekreftet med bruker, ingen ny V0-prompt for denne
|
||||
varianten) -- kun én liten, dedikert (ikke-V0) gren i
|
||||
`MatchScorecardGrid` for én rad per lag-side.
|
||||
|
||||
**Kritisk regresjonsbug funnet OG rettet samme dag, FØR denne
|
||||
utrullingen:** `round-detail.tsx` sin "Score"-fane rutet feilaktig et
|
||||
klikk på en LAGSPILLERS scorekort til per-deltaker-veiviseren i stedet
|
||||
for den delte lag-veiviseren -- satt seg fast i en evig spinner, uten
|
||||
feilmelding. Rammet BEGGE variantene (var allerede live og ødelagt for
|
||||
ekte lagspillere på slagspill-varianten siden 2026-08-05 tidligere
|
||||
samme dag, inntil denne rettelsen). Se CHANGELOG.md punkt 23 for full
|
||||
rotårsak/fiks-detalj.
|
||||
|
||||
**Rullet ut live 2026-08-05** (begge varianter + bug-fiksen, samme
|
||||
utrulling), bruker bekreftet eksplisitt.
|
||||
|
||||
---
|
||||
|
||||
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||
|
||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -56,6 +56,8 @@ from .auth import _hash_secret
|
|||
from .notifications import create_notification
|
||||
from handicap_engine import (
|
||||
HoleResult,
|
||||
PerPlayerPercentage,
|
||||
TeamAverage,
|
||||
adjusted_gross_score,
|
||||
allocate_over_played_holes,
|
||||
allocate_strokes_by_index,
|
||||
|
|
@ -70,8 +72,10 @@ from handicap_engine import (
|
|||
high_low_high_points_for_hole,
|
||||
high_low_high_running_score,
|
||||
money_ball_hole_score,
|
||||
net_stroke_play_margin,
|
||||
shamble_hole_score,
|
||||
stableford_points_for_hole,
|
||||
stroke_play_net_total,
|
||||
course_handicap_raw,
|
||||
handicap_index_from_differentials,
|
||||
low_handicap_index,
|
||||
|
|
@ -803,6 +807,43 @@ async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> No
|
|||
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
|
||||
|
||||
|
||||
async def _recompute_scramble_solo_handicaps(conn, round_id: str) -> None:
|
||||
"""Beregn og lagre playing_handicap for BEGGE sider i scramble_solo
|
||||
(migrasjon 056) -- egen søsterfunksjon til _recompute_side_handicaps,
|
||||
som IKKE gjenbrukes: den forutsetter ÉN felles strategi (via parse_
|
||||
allowance_config/allowance_override-JSON) og ETT fast forventet
|
||||
spillerantall for HELE formatet -- ingen av delene stemmer her.
|
||||
|
||||
Lagsiden (variabel størrelse, N>=2): TeamAverage, ALDRI konfigurerbar.
|
||||
Individualsiden (nøyaktig 1 spiller): PerPlayerPercentage, prosenten
|
||||
lest direkte fra round.scramble_solo_individual_pct (ikke allowance_
|
||||
override -- se RoundCreate/RoundUpdate).
|
||||
"""
|
||||
pct = await conn.fetchval("SELECT scramble_solo_individual_pct FROM round WHERE id = $1", round_id)
|
||||
sides = await conn.fetch("SELECT id::text AS id, side_role FROM round_side WHERE round_id = $1", round_id)
|
||||
for side in sides:
|
||||
participants = await conn.fetch(
|
||||
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_side_id = $1",
|
||||
side["id"],
|
||||
)
|
||||
if not participants or any(p["course_handicap_snapshot"] is None for p in participants):
|
||||
continue # ikke komplett ennå, eller mangler HCP-sporing for noen
|
||||
|
||||
if side["side_role"] == "team":
|
||||
if len(participants) < 2:
|
||||
continue # TeamAverage krever minst to -- ikke komplett ennå
|
||||
course_handicaps = [float(p["course_handicap_snapshot"]) for p in participants]
|
||||
playing = TeamAverage().playing_handicap(course_handicaps)
|
||||
for p in participants:
|
||||
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
|
||||
elif side["side_role"] == "individual":
|
||||
if len(participants) != 1:
|
||||
continue # nøyaktig én forventet -- ikke komplett ennå
|
||||
p = participants[0]
|
||||
playing = PerPlayerPercentage(pct / 100).playing_handicap([float(p["course_handicap_snapshot"])])
|
||||
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
|
||||
|
||||
|
||||
async def _relative_strokes_for_round(conn, round_id: str, play_format: str) -> dict[str, int]:
|
||||
"""Relative slag per scoringsenhet (porterer relative_strokes_for_match).
|
||||
|
||||
|
|
@ -847,11 +888,26 @@ async def _check_side_capacity(
|
|||
stedet for å oppdage overtallet først ved fullføring. `exclude_
|
||||
participant_id` unngår at en deltaker som allerede ER på siden (f.eks.
|
||||
en no-op-reassignment til samme side) teller seg selv ut av plass."""
|
||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
||||
current_count = await conn.fetchval(
|
||||
"SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
|
||||
side_id, exclude_participant_id or "00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
if play_format in _ASYMMETRIC_SIDE_FORMATS:
|
||||
# scramble_solo (2026-08-05): kapasitet avhenger av HVILKEN rolle
|
||||
# siden har, ikke et fast tall for hele formatet -- laget (variabel
|
||||
# størrelse, N>=2) har ALDRI en øvre grense, individualsiden har
|
||||
# ALLTID nøyaktig plass til én.
|
||||
side_role = await conn.fetchval("SELECT side_role FROM round_side WHERE id = $1", side_id)
|
||||
if side_role == "team":
|
||||
return
|
||||
if current_count >= 1:
|
||||
raise app_error(
|
||||
409, "SIDE_FULL",
|
||||
"Individualspiller-siden har allerede sin ene spiller.",
|
||||
)
|
||||
return
|
||||
|
||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
||||
if current_count >= expected:
|
||||
raise app_error(
|
||||
409, "SIDE_FULL",
|
||||
|
|
@ -912,6 +968,27 @@ async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[b
|
|||
return False, "Alle fire spillerne må ha en tildelt, distinkt plass i rekkefølgen (0-3)."
|
||||
return True, None
|
||||
|
||||
if play_format in _SCRAMBLE_SOLO_FORMATS:
|
||||
sides = await conn.fetch(
|
||||
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1 ORDER BY id", round_id
|
||||
)
|
||||
if len(sides) != 2 or {s["side_role"] for s in sides} != {"team", "individual"}:
|
||||
return False, "Denne spilleformen krever nøyaktig to sider -- ett lag og én individuell motstander."
|
||||
unassigned = await conn.fetchval(
|
||||
"SELECT count(*) FROM round_participant WHERE round_id = $1 AND round_side_id IS NULL", round_id
|
||||
)
|
||||
if unassigned > 0:
|
||||
return False, f"{unassigned} deltaker(e) er ikke tildelt noen side ennå."
|
||||
for side in sides:
|
||||
count = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_side_id = $1", side["id"])
|
||||
if side["side_role"] == "team" and count < 2:
|
||||
label = side["label"] or "Laget"
|
||||
return False, f"{label} har {count} spiller(e), men trenger minst 2."
|
||||
if side["side_role"] == "individual" and count != 1:
|
||||
label = side["label"] or "Individualspiller-siden"
|
||||
return False, f"{label} har {count} spiller(e), men trenger nøyaktig 1."
|
||||
return True, None
|
||||
|
||||
# To-sidede formater (match/fourball/foursome/greensome/scramble_*).
|
||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
||||
sides = await conn.fetch("SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id)
|
||||
|
|
@ -950,6 +1027,21 @@ StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
|
|||
_TWO_SIDED_FORMATS = {
|
||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||
}
|
||||
# Scramble mot enkeltspiller -- to varianter, BEGGE bevisst IKKE i
|
||||
# _TWO_SIDED_FORMATS: to-siders-settet over forutsetter SAMME struktur på
|
||||
# begge sider (én engine_format, én _SIDE_PLAYER_COUNT). Disse formatene
|
||||
# er asymmetriske (variabel lagstørrelse vs. nøyaktig én individualspiller)
|
||||
# -- egne gate-steder (se _check_side_capacity/create_round_side/
|
||||
# add_participant/update_participant), egne _build_scramble_solo_*_result-
|
||||
# funksjoner. Lag-/individual-håndteringen (kapasitet, side_role,
|
||||
# individual_round_hole, playing_handicap-beregning via
|
||||
# _recompute_scramble_solo_handicaps) er IDENTISK for begge variantene --
|
||||
# kun selve resultat-sammenligningen skiller dem (scramble_solo: netto
|
||||
# totalsum via net_stroke_play_margin, migrasjon 056. scramble_solo_match:
|
||||
# hull-for-hull via compute_match_state, migrasjon 057) -- derfor étt
|
||||
# delt sett brukt gjennomgående i stedet for to.
|
||||
_SCRAMBLE_SOLO_FORMATS = {"scramble_solo", "scramble_solo_match"}
|
||||
_ASYMMETRIC_SIDE_FORMATS = _SCRAMBLE_SOLO_FORMATS
|
||||
|
||||
|
||||
def _viewer_match_status_text(lead: int, holes_remaining: int, is_closed: bool) -> str:
|
||||
|
|
@ -1028,7 +1120,8 @@ class RoundCreate(BaseModel):
|
|||
# Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
|
||||
play_format: Literal[
|
||||
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
|
||||
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high",
|
||||
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high", "scramble_solo",
|
||||
"scramble_solo_match",
|
||||
] = "stroke"
|
||||
# Frontend forhåndsutfyller denne til True når play_format='match'
|
||||
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
|
||||
|
|
@ -1053,6 +1146,13 @@ class RoundCreate(BaseModel):
|
|||
# KUN meningsfullt (og påkrevd) når play_format='shamble' -- antall av
|
||||
# lagets individuelle resultater som teller per hull ("beste N av M").
|
||||
shamble_best_n: int | None = Field(default=None, ge=1)
|
||||
# KUN meningsfullt når play_format='scramble_solo' (migrasjon 056,
|
||||
# 2026-08-05) -- individualspillerens allowance-%, arrangør-satt,
|
||||
# standard 100. Lagsiden har INGEN tilsvarende konfig (alltid rent
|
||||
# snitt via TeamAverage, se _recompute_scramble_solo_handicaps) --
|
||||
# bevisst IKKE den generiske allowance_override-JSON-en over, som
|
||||
# forutsetter ÉN delt strategi for hele runden.
|
||||
scramble_solo_individual_pct: int = Field(default=100, ge=0, le=100)
|
||||
# ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||||
# innsyn (venner/offentligheten). Trygg standard 'private', samme
|
||||
# filosofi som RLS-policyenes "se ingenting" ved manglende kontekst.
|
||||
|
|
@ -1120,6 +1220,9 @@ class RoundParticipantOut(BaseModel):
|
|||
class RoundSideOut(BaseModel):
|
||||
id: str
|
||||
label: str | None
|
||||
# KUN satt for scramble_solo (migrasjon 056) -- None for alle andre
|
||||
# formater, som ikke skiller sidene fra hverandre strukturelt.
|
||||
side_role: Literal["team", "individual"] | None = None
|
||||
|
||||
|
||||
class RoundOut(BaseModel):
|
||||
|
|
@ -1138,6 +1241,8 @@ class RoundOut(BaseModel):
|
|||
bbb_sweep_bonus_enabled: bool
|
||||
# KUN meningsfullt for play_format='shamble' (2026-07-30).
|
||||
shamble_best_n: int | None
|
||||
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056).
|
||||
scramble_solo_individual_pct: int
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
participants: list[RoundParticipantOut]
|
||||
|
|
@ -1189,7 +1294,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
|
||||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
|
||||
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override::text AS allowance_override
|
||||
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
|
||||
allowance_override::text AS allowance_override
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -1215,7 +1321,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
round_id,
|
||||
)
|
||||
side_rows = await conn.fetch(
|
||||
"SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
|
||||
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1 ORDER BY id", round_id
|
||||
)
|
||||
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
|
||||
# medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
|
||||
|
|
@ -1297,6 +1403,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
skins_tie_handling=round_row["skins_tie_handling"],
|
||||
bbb_sweep_bonus_enabled=round_row["bbb_sweep_bonus_enabled"],
|
||||
shamble_best_n=round_row["shamble_best_n"],
|
||||
scramble_solo_individual_pct=round_row["scramble_solo_individual_pct"],
|
||||
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
|
||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
|
|
@ -1316,6 +1423,22 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
)
|
||||
|
||||
|
||||
async def _individual_round_hole_needed(conn, play_format: str, round_side_id: str | None) -> bool:
|
||||
"""Trenger denne deltakeren egne individuelle round_hole-rader, eller
|
||||
leser/skriver den via en delt side-rad i stedet? For de fleste to-sidede
|
||||
formater er dette EN fast egenskap ved formatet (SIDE_IS_UNIT). For
|
||||
scramble_solo-familien (migrasjon 056/057) avhenger det av HVILKEN
|
||||
side -- laget deler ALLTID siden sine round_hole-rader (samme som
|
||||
foursome/scramble), individualsiden trenger ALLTID egne (samme som
|
||||
singles)."""
|
||||
if play_format in _SCRAMBLE_SOLO_FORMATS:
|
||||
if round_side_id is None:
|
||||
return True # ukjent side ennå -- samme trygge standard som resten av formatet
|
||||
side_role = await conn.fetchval("SELECT side_role FROM round_side WHERE id = $1", round_side_id)
|
||||
return side_role != "team"
|
||||
return _ROUND_FORMAT_TO_ENGINE_FORMAT.get(play_format, play_format) not in SIDE_IS_UNIT
|
||||
|
||||
|
||||
async def _create_participant(
|
||||
conn,
|
||||
round_id: str,
|
||||
|
|
@ -1442,8 +1565,8 @@ async def create_round(
|
|||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||
start_hole, holes_planned, started_at, name, play_format,
|
||||
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id,
|
||||
bbb_sweep_bonus_enabled, shamble_best_n, allowance_override)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20::jsonb)
|
||||
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct, allowance_override)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21::jsonb)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
user.user_id,
|
||||
|
|
@ -1465,6 +1588,7 @@ async def create_round(
|
|||
body.flight_group_id,
|
||||
body.bbb_sweep_bonus_enabled,
|
||||
body.shamble_best_n,
|
||||
body.scramble_solo_individual_pct,
|
||||
json.dumps(body.allowance_override) if body.allowance_override is not None else None,
|
||||
)
|
||||
round_id = round_row["id"]
|
||||
|
|
@ -1475,8 +1599,7 @@ async def create_round(
|
|||
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
|
||||
stat_level=body.stat_level,
|
||||
exclude_from_handicap=body.exclude_owner_from_handicap,
|
||||
individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(body.play_format, body.play_format)
|
||||
not in SIDE_IS_UNIT,
|
||||
individual_round_hole=await _individual_round_hole_needed(conn, body.play_format, None),
|
||||
)
|
||||
|
||||
if body.visibility_mode == "friends" and body.visible_categories:
|
||||
|
|
@ -2197,7 +2320,8 @@ class RoundUpdate(BaseModel):
|
|||
# å validere her.
|
||||
play_format: Literal[
|
||||
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford",
|
||||
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high",
|
||||
"chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high", "scramble_solo",
|
||||
"scramble_solo_match",
|
||||
] | None = None
|
||||
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i
|
||||
# etterkant (f.eks. glemte å trykke "Fullfør runde" før flere timer
|
||||
|
|
@ -2228,6 +2352,9 @@ class RoundUpdate(BaseModel):
|
|||
# Rører ALDRI course_handicap_snapshot, men regner playing_handicap på
|
||||
# nytt for allerede tildelte sider (se update_round).
|
||||
allowance_override: dict | None = None
|
||||
# KUN meningsfullt for play_format='scramble_solo' (migrasjon 056) --
|
||||
# samme "None = ikke rør"-konvensjon som resten av denne modellen.
|
||||
scramble_solo_individual_pct: int | None = Field(default=None, ge=0, le=100)
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||
|
|
@ -2335,6 +2462,12 @@ async def update_round(
|
|||
round_id,
|
||||
json.dumps(body.allowance_override),
|
||||
)
|
||||
if body.scramble_solo_individual_pct is not None:
|
||||
await conn.execute(
|
||||
"UPDATE round SET scramble_solo_individual_pct = $2 WHERE id = $1",
|
||||
round_id,
|
||||
body.scramble_solo_individual_pct,
|
||||
)
|
||||
if body.started_at is not None:
|
||||
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
|
||||
if body.completed_at is not None:
|
||||
|
|
@ -2402,6 +2535,14 @@ async def update_round(
|
|||
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
|
||||
if effective_play_format in _TWO_SIDED_FORMATS:
|
||||
await _recompute_side_handicaps(conn, round_id, effective_play_format)
|
||||
# scramble_solo-familien (migrasjon 056/057) bruker aldri
|
||||
# allowance_override -- egen trigger på sin egen kolonne i
|
||||
# stedet, samme "regn på nytt for allerede tildelte sider"-
|
||||
# prinsipp som over.
|
||||
if body.scramble_solo_individual_pct is not None:
|
||||
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
|
||||
if effective_play_format in _SCRAMBLE_SOLO_FORMATS:
|
||||
await _recompute_scramble_solo_handicaps(conn, round_id)
|
||||
|
||||
result = await _load_round_out(conn, round_id, user.user_id)
|
||||
|
||||
|
|
@ -2489,7 +2630,7 @@ async def add_participant(
|
|||
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
|
||||
|
||||
if body.round_side_id is not None:
|
||||
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
|
||||
if round_row["play_format"] not in (_TWO_SIDED_FORMATS | _ASYMMETRIC_SIDE_FORMATS):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||
side_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", body.round_side_id, round_id
|
||||
|
|
@ -2547,12 +2688,14 @@ async def add_participant(
|
|||
guest_email=body.guest_email,
|
||||
exclude_from_handicap=body.exclude_from_handicap,
|
||||
round_side_id=body.round_side_id,
|
||||
individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(
|
||||
round_row["play_format"], round_row["play_format"]
|
||||
)
|
||||
not in SIDE_IS_UNIT,
|
||||
individual_round_hole=await _individual_round_hole_needed(
|
||||
conn, round_row["play_format"], body.round_side_id
|
||||
),
|
||||
)
|
||||
if body.round_side_id is not None:
|
||||
if round_row["play_format"] in _SCRAMBLE_SOLO_FORMATS:
|
||||
await _recompute_scramble_solo_handicaps(conn, round_id)
|
||||
else:
|
||||
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
|
||||
|
||||
# Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
|
||||
|
|
@ -2598,6 +2741,10 @@ async def add_participant(
|
|||
|
||||
class RoundSideCreate(BaseModel):
|
||||
label: str | None = Field(default=None, max_length=100)
|
||||
# KUN meningsfullt (og påkrevd) når play_format='scramble_solo'
|
||||
# (migrasjon 056) -- hvilken av de to strukturelt ulike sidene dette
|
||||
# er. None for alle andre to-sidede formater, som ikke skiller sidene.
|
||||
side_role: Literal["team", "individual"] | None = None
|
||||
|
||||
|
||||
@router.post("/rounds/{round_id}/sides", response_model=RoundSideOut, status_code=201)
|
||||
|
|
@ -2613,25 +2760,44 @@ async def create_round_side(
|
|||
"personal_course_id, completed_at FROM round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
|
||||
is_scramble_solo = round_row["play_format"] in _SCRAMBLE_SOLO_FORMATS
|
||||
if round_row["play_format"] not in (_TWO_SIDED_FORMATS | _ASYMMETRIC_SIDE_FORMATS):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||
if round_row["completed_at"] is not None:
|
||||
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
|
||||
existing_count = await conn.fetchval("SELECT count(*) FROM round_side WHERE round_id = $1", round_id)
|
||||
if existing_count >= 2:
|
||||
raise app_error(409, "LIMIT_REACHED", "En runde kan maks ha to sider.")
|
||||
if is_scramble_solo:
|
||||
if body.side_role is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "side_role ('team'/'individual') er påkrevd for denne spilleformen.")
|
||||
role_taken = await conn.fetchval(
|
||||
"SELECT 1 FROM round_side WHERE round_id = $1 AND side_role = $2", round_id, body.side_role
|
||||
)
|
||||
if role_taken:
|
||||
raise app_error(409, "DUPLICATE", f"Runden har allerede en side med rollen '{body.side_role}'.")
|
||||
elif body.side_role is not None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "side_role gir kun mening for scramble mot enkeltspiller-formatene.")
|
||||
|
||||
async with conn.transaction():
|
||||
side_row = await conn.fetchrow(
|
||||
"INSERT INTO round_side (round_id, label) VALUES ($1, $2) RETURNING id::text AS id, label",
|
||||
round_id, body.label,
|
||||
"INSERT INTO round_side (round_id, label, side_role) VALUES ($1, $2, $3) "
|
||||
"RETURNING id::text AS id, label, side_role",
|
||||
round_id, body.label, body.side_role,
|
||||
)
|
||||
# Delt-ball-formater (ADR-039 Beslutning C): siden får sine
|
||||
# round_hole-rader med en gang -- rating trengs ikke her, kun
|
||||
# par/stroke-index-snapshot, uavhengig av om siden har fått
|
||||
# noen deltakere ennå.
|
||||
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]]
|
||||
if engine_format in SIDE_IS_UNIT:
|
||||
# noen deltakere ennå. scramble_solo (migrasjon 056) er IKKE i
|
||||
# _ROUND_FORMAT_TO_ENGINE_FORMAT (bevisst -- se _ASYMMETRIC_
|
||||
# SIDE_FORMATS-kommentaren), så avgjør delt-ball KUN av
|
||||
# side_role for dette formatet i stedet for et format-bredt
|
||||
# engine_format-oppslag: kun lagsiden får delt-ball-rader.
|
||||
wants_shared_hole = (
|
||||
body.side_role == "team" if is_scramble_solo
|
||||
else _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]] in SIDE_IS_UNIT
|
||||
)
|
||||
if wants_shared_hole:
|
||||
resolved = await _resolve_round_course(round_row)
|
||||
for hole_number, par, stroke_index in resolved.holes:
|
||||
await conn.execute(
|
||||
|
|
@ -2817,13 +2983,20 @@ async def update_participant(
|
|||
if "round_side_id" in updates:
|
||||
new_side_id = updates["round_side_id"]
|
||||
if new_side_id is not None:
|
||||
if current["play_format"] not in _TWO_SIDED_FORMATS:
|
||||
if current["play_format"] not in (_TWO_SIDED_FORMATS | _ASYMMETRIC_SIDE_FORMATS):
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||
side_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
|
||||
)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
|
||||
# scramble_solo (migrasjon 056), kjent v1-begrensning: et
|
||||
# BYTTE av side_role (lag<->individuell) etter opprettelse
|
||||
# oppdaterer IKKE round_hole-eierskapet retroaktivt (satt
|
||||
# kun ved _create_participant-tidspunktet) -- flytting
|
||||
# innad i SAMME rolle (f.eks. bytte hvilket lag) er
|
||||
# uproblematisk. Ikke håndtert her, samme aksepterte-
|
||||
# begrensning-mønster som Nassaus runaway-margin-kvirk.
|
||||
await _check_side_capacity(conn, new_side_id, current["play_format"], exclude_participant_id=participant_id)
|
||||
values.append(new_side_id)
|
||||
set_clauses.append(f"round_side_id = ${len(values)}")
|
||||
|
|
@ -2899,7 +3072,9 @@ async def update_participant(
|
|||
# nytt, siden det opprinnelige triggeret kun så på round_side_id --
|
||||
# laget lignende resultatet permanent "ikke klart" til siden ble
|
||||
# rørt på nytt. rating_changed dekker nå samme sak.
|
||||
if (
|
||||
if ("round_side_id" in updates or rating_changed) and current["play_format"] in _SCRAMBLE_SOLO_FORMATS:
|
||||
await _recompute_scramble_solo_handicaps(conn, round_id)
|
||||
elif (
|
||||
("round_side_id" in updates or rating_changed)
|
||||
and current["play_format"] in _TWO_SIDED_FORMATS
|
||||
):
|
||||
|
|
@ -3335,6 +3510,14 @@ class RoundFormatResultOut(BaseModel):
|
|||
# FAKTISKE tallene til visning (f.eks. "2-1").
|
||||
hlh_points_a: int | None = None
|
||||
hlh_points_b: int | None = None
|
||||
# Scramble mot enkeltspiller (migrasjon 056, 2026-08-05): rene netto-
|
||||
# totalsummer, INGEN match-play-tilstand -- side "a" er alltid LAGET,
|
||||
# side "b" er alltid INDIVIDUALSPILLEREN (fast, ikke id-sortert som
|
||||
# resten -- rollen er entydig, ikke vilkårlig). scramble_solo_margin
|
||||
# er net_stroke_play_margin(team, individual): positiv = laget leder.
|
||||
scramble_solo_team_net: int | None = None
|
||||
scramble_solo_individual_net: int | None = None
|
||||
scramble_solo_margin: int | None = None
|
||||
# Hull-for-hull-forløp (2026-07-28, "hvorfor ble resultatet som det ble" --
|
||||
# brutto/netto/slag-mottatt per enhet, ikke bare et vinn/tap-merke).
|
||||
# None når ready=False (to-sidede formater) -- ellers alltid satt,
|
||||
|
|
@ -3459,6 +3642,287 @@ async def _build_high_low_high_result(conn, round_id: str, round_row) -> RoundFo
|
|||
)
|
||||
|
||||
|
||||
async def _build_scramble_solo_result(conn, round_id: str, round_row) -> RoundFormatResultOut:
|
||||
"""Scramble mot enkeltspiller, slagspill (migrasjon 056, 2026-08-05) --
|
||||
passer IKKE inn i den generiske to-siders-grenen under (den forutsetter
|
||||
ÉN engine_format/_SIDE_PLAYER_COUNT for HELE formatet og går via
|
||||
compute_match_state) -- egen, selvstendig sti, samme mønster som High-
|
||||
low-high/Shamble/Money Ball sine dedikerte grener. REN netto-totalsum-
|
||||
sammenligning (net_stroke_play_margin), INGEN hull-for-hull-tilstand.
|
||||
Beregnes ved LESING, ingen cache-kolonne (som Nassau/High-low-high).
|
||||
"""
|
||||
side_rows = await conn.fetch(
|
||||
"SELECT id::text AS id, label, side_role FROM round_side WHERE round_id = $1", round_id
|
||||
)
|
||||
team_side = next((s for s in side_rows if s["side_role"] == "team"), None)
|
||||
individual_side = next((s for s in side_rows if s["side_role"] == "individual"), None)
|
||||
if team_side is None or individual_side is None:
|
||||
return RoundFormatResultOut(play_format="scramble_solo", ready=False)
|
||||
|
||||
team_participants = await conn.fetch(
|
||||
"SELECT id::text AS id, playing_handicap, COALESCE(guest_name, "
|
||||
"(SELECT display_name FROM app_user WHERE id = round_participant.user_id), 'Medspiller') AS label "
|
||||
"FROM round_participant WHERE round_side_id = $1",
|
||||
team_side["id"],
|
||||
)
|
||||
individual_participant = await conn.fetchrow(
|
||||
"SELECT id::text AS id, playing_handicap, COALESCE(guest_name, "
|
||||
"(SELECT display_name FROM app_user WHERE id = round_participant.user_id), 'Medspiller') AS label "
|
||||
"FROM round_participant WHERE round_side_id = $1",
|
||||
individual_side["id"],
|
||||
)
|
||||
if (
|
||||
len(team_participants) < 2
|
||||
or any(p["playing_handicap"] is None for p in team_participants)
|
||||
or individual_participant is None
|
||||
or individual_participant["playing_handicap"] is None
|
||||
):
|
||||
return RoundFormatResultOut(play_format="scramble_solo", ready=False)
|
||||
|
||||
team_playing_handicap = team_participants[0]["playing_handicap"] # samme verdi lagret på alle lagmedlemmer
|
||||
individual_playing_handicap = individual_participant["playing_handicap"]
|
||||
|
||||
all_18_si_by_hole: dict[int, int] = {}
|
||||
par_by_hole: dict[int, int] = {}
|
||||
team_gross_by_hole: dict[int, int] = {}
|
||||
individual_gross_by_hole: dict[int, int] = {}
|
||||
|
||||
team_hole_rows = await conn.fetch(
|
||||
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_side_id = $1",
|
||||
team_side["id"],
|
||||
)
|
||||
individual_hole_rows = await conn.fetch(
|
||||
"SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1",
|
||||
individual_participant["id"],
|
||||
)
|
||||
for r in team_hole_rows:
|
||||
all_18_si_by_hole[r["hole_number"]] = r["stroke_index"]
|
||||
par_by_hole[r["hole_number"]] = r["par"]
|
||||
if r["played"] and r["score"] is not None:
|
||||
team_gross_by_hole[r["hole_number"]] = r["score"]
|
||||
for r in individual_hole_rows:
|
||||
all_18_si_by_hole.setdefault(r["hole_number"], r["stroke_index"])
|
||||
par_by_hole.setdefault(r["hole_number"], r["par"])
|
||||
if r["played"] and r["score"] is not None:
|
||||
individual_gross_by_hole[r["hole_number"]] = r["score"]
|
||||
if len(all_18_si_by_hole) != 18:
|
||||
return RoundFormatResultOut(play_format="scramble_solo", ready=False)
|
||||
all_18_si = [all_18_si_by_hole[h] for h in range(1, 19)]
|
||||
|
||||
team_strokes_per_hole = dict(
|
||||
zip(range(1, 19), allocate_over_played_holes(team_playing_handicap, all_18_si, list(range(1, 19))))
|
||||
)
|
||||
individual_strokes_per_hole = dict(
|
||||
zip(range(1, 19), allocate_over_played_holes(individual_playing_handicap, all_18_si, list(range(1, 19))))
|
||||
)
|
||||
|
||||
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
||||
team_label = team_side["label"] or "Laget"
|
||||
individual_label = individual_side["label"] or individual_participant["label"]
|
||||
|
||||
team_gross_played: list[int] = []
|
||||
team_received_played: list[int] = []
|
||||
individual_gross_played: list[int] = []
|
||||
individual_received_played: list[int] = []
|
||||
holes: list[FormatHoleOut] = []
|
||||
for h in play_order:
|
||||
if h not in team_gross_by_hole or h not in individual_gross_by_hole:
|
||||
break
|
||||
team_gross = team_gross_by_hole[h]
|
||||
individual_gross = individual_gross_by_hole[h]
|
||||
team_received = team_strokes_per_hole[h]
|
||||
individual_received = individual_strokes_per_hole[h]
|
||||
team_gross_played.append(team_gross)
|
||||
team_received_played.append(team_received)
|
||||
individual_gross_played.append(individual_gross)
|
||||
individual_received_played.append(individual_received)
|
||||
team_net = team_gross - team_received
|
||||
individual_net = individual_gross - individual_received
|
||||
holes.append(
|
||||
FormatHoleOut(
|
||||
hole_number=h, par=par_by_hole[h], stroke_index=all_18_si_by_hole[h],
|
||||
entries=[
|
||||
FormatHoleEntry(
|
||||
id=team_side["id"], label=team_label, side="a",
|
||||
gross=team_gross, net=team_net, strokes_received=team_received,
|
||||
),
|
||||
FormatHoleEntry(
|
||||
id=individual_participant["id"], label=individual_label, side="b",
|
||||
gross=individual_gross, net=individual_net, strokes_received=individual_received,
|
||||
),
|
||||
],
|
||||
result=("a" if team_net < individual_net else "b" if individual_net < team_net else "halved"),
|
||||
)
|
||||
)
|
||||
|
||||
if not holes:
|
||||
return RoundFormatResultOut(play_format="scramble_solo", ready=True, holes=[])
|
||||
|
||||
team_net_total = stroke_play_net_total(team_gross_played, team_received_played)
|
||||
individual_net_total = stroke_play_net_total(individual_gross_played, individual_received_played)
|
||||
return RoundFormatResultOut(
|
||||
play_format="scramble_solo",
|
||||
ready=True,
|
||||
scramble_solo_team_net=team_net_total,
|
||||
scramble_solo_individual_net=individual_net_total,
|
||||
scramble_solo_margin=net_stroke_play_margin(team_net_total, individual_net_total),
|
||||
holes=holes,
|
||||
)
|
||||
|
||||
|
||||
async def _build_scramble_solo_match_result(conn, round_id: str, round_row) -> RoundFormatResultOut:
|
||||
"""Scramble mot enkeltspiller, MATCHSPILL (migrasjon 057, 2026-08-05) --
|
||||
samme asymmetriske lag-/individual-oppsett og samme blandede hull-
|
||||
oppslag (round_side_id for laget, round_participant_id for
|
||||
individualspilleren) som _build_scramble_solo_result over, men
|
||||
sammenligningen er 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:
|
||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||
/public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk,
|
||||
|
|
@ -3473,6 +3937,12 @@ async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut:
|
|||
if play_format == "high_low_high":
|
||||
return await _build_high_low_high_result(conn, round_id, round_row)
|
||||
|
||||
if play_format == "scramble_solo":
|
||||
return await _build_scramble_solo_result(conn, round_id, round_row)
|
||||
|
||||
if play_format == "scramble_solo_match":
|
||||
return await _build_scramble_solo_match_result(conn, round_id, round_row)
|
||||
|
||||
if play_format in _TWO_SIDED_FORMATS:
|
||||
relative = await _relative_strokes_for_round(conn, round_id, play_format)
|
||||
if not relative:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
|||
greensome: "Greensome",
|
||||
scramble_2: "Scramble (2)",
|
||||
scramble_4: "Scramble (4)",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||
|
|
|
|||
|
|
@ -25,11 +25,14 @@ import {
|
|||
Globe,
|
||||
Lock,
|
||||
MapPin,
|
||||
Minus,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
User,
|
||||
UserPlus,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
|
@ -93,7 +96,7 @@ type Player = {
|
|||
|
||||
type Side = { id: string; label: string }
|
||||
|
||||
type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-create" | "fields"
|
||||
type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-search-template" | "own-create" | "fields"
|
||||
|
||||
// --- Formater ------------------------------------------------------------------
|
||||
|
||||
|
|
@ -114,6 +117,8 @@ type FormatId =
|
|||
| "shamble"
|
||||
| "moneyball"
|
||||
| "highlowhigh"
|
||||
| "scramblevssolo"
|
||||
| "scramblevssolomatch"
|
||||
|
||||
const FORMATS: { id: FormatId; label: string; desc: string }[] = [
|
||||
{ id: "slagspill", label: "Slagspill", desc: "Vanlig individuell runde." },
|
||||
|
|
@ -132,8 +137,27 @@ const FORMATS: { id: FormatId; label: string; desc: string }[] = [
|
|||
{ id: "shamble", label: "Shamble", desc: "2-4 spillere, alle slår ut, velger beste -- spiller så egen ball inn." },
|
||||
{ id: "moneyball", label: "Money Ball", desc: 'Fire spillere, én roterer som "money ball" per hull.' },
|
||||
{ id: "highlowhigh", label: "High-low-high", desc: "To lag à to -- høy mot høy, lav mot lav, per hull." },
|
||||
{
|
||||
id: "scramblevssolo",
|
||||
label: "Scramble mot enkeltspiller",
|
||||
desc: "Et lag med delt ball (minst 2 spillere) mot én enkeltspiller — nettoscore avgjør.",
|
||||
},
|
||||
{
|
||||
id: "scramblevssolomatch",
|
||||
label: "Scramble mot enkeltspiller (matchspill)",
|
||||
desc: "Samme oppsett som over, men avgjort hull for hull i stedet for netto totalsum.",
|
||||
},
|
||||
]
|
||||
|
||||
// Begge scramble-mot-enkeltspiller-variantene er BEVISST IKKE i dette
|
||||
// settet -- de to sidene er strukturelt ulike (variabel lagstørrelse mot
|
||||
// nøyaktig én individualspiller), ikke to like sider som resten av
|
||||
// formatene her. Se app/routers/rounds.py sin _TWO_SIDED_FORMATS/
|
||||
// _SCRAMBLE_SOLO_FORMATS for samme skille på backend-siden. Egen samlet
|
||||
// set siden begge variantene deler EKSAKT samme sideoppsett-UI
|
||||
// (ScrambleVsSoloSides)/allowance-kontroll (SoloAllowanceControl)/
|
||||
// side_role-innsendingslogikk -- kun selve resultatvisningen skiller dem.
|
||||
const SCRAMBLE_SOLO_VARIANTS = new Set<FormatId>(["scramblevssolo", "scramblevssolomatch"])
|
||||
const TWO_SIDED = new Set<FormatId>([
|
||||
"match", "fourball", "foursome", "greensome", "scramble2", "scramble4", "chapman", "highlowhigh",
|
||||
])
|
||||
|
|
@ -148,7 +172,7 @@ const SIDE_IS_UNIT_FORMAT = new Set<FormatId>(["foursome", "greensome", "scrambl
|
|||
const ORDINARY_FORMATS = new Set<FormatId>(["slagspill", "stableford"])
|
||||
|
||||
function usesStep4(f: FormatId) {
|
||||
return TWO_SIDED.has(f) || f === "moneyball"
|
||||
return TWO_SIDED.has(f) || f === "moneyball" || SCRAMBLE_SOLO_VARIANTS.has(f)
|
||||
}
|
||||
|
||||
// Wizardens FormatId -> ekte round.play_format-verdi (RoundCreate).
|
||||
|
|
@ -169,6 +193,8 @@ const FORMAT_TO_API: Record<FormatId, string> = {
|
|||
shamble: "shamble",
|
||||
moneyball: "money_ball",
|
||||
highlowhigh: "high_low_high",
|
||||
scramblevssolo: "scramble_solo",
|
||||
scramblevssolomatch: "scramble_solo_match",
|
||||
}
|
||||
// Motsatt vei, brukt til å forhåndsutfylle format ved "legg til en flight
|
||||
// til" (playFormat kommer fra URL som en ekte API-verdi).
|
||||
|
|
@ -332,6 +358,11 @@ export function NewRound() {
|
|||
|
||||
// Step 1
|
||||
const [s1Sub, setS1Sub] = useState<S1Sub>("source")
|
||||
// Hvor "own-create" skal gå TILBAKE til (2026-08-05) -- avhenger av om
|
||||
// brukeren kom fra det vanlige "finn en bane å spille"-søket, fra
|
||||
// mal-søket ("Basér på en eksisterende bane" inne i opprett-skjemaet),
|
||||
// eller broen fra offisielle baner.
|
||||
const [ownCreateOrigin, setOwnCreateOrigin] = useState<S1Sub>("own-search")
|
||||
const [courseSource, setCourseSource] = useState<"official" | "own">("official")
|
||||
const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null)
|
||||
const [course, setCourse] = useState<Course | null>(null)
|
||||
|
|
@ -353,6 +384,7 @@ export function NewRound() {
|
|||
const [skinsTie, setSkinsTie] = useState<"roll" | "split">("roll")
|
||||
const [bbbBonus, setBbbBonus] = useState(false)
|
||||
const [shambleBest, setShambleBest] = useState(2)
|
||||
const [soloAllowance, setSoloAllowance] = useState(100)
|
||||
const [advOpen, setAdvOpen] = useState(false)
|
||||
const [useHcp, setUseHcp] = useState(true)
|
||||
const [useCourseHcp, setUseCourseHcp] = useState(true)
|
||||
|
|
@ -464,8 +496,10 @@ export function NewRound() {
|
|||
setS1Sub("official-search")
|
||||
} else if (s1Sub === "own-search") {
|
||||
setS1Sub("source")
|
||||
} else if (s1Sub === "own-search-template") {
|
||||
setS1Sub("own-create")
|
||||
} else if (s1Sub === "own-create") {
|
||||
setS1Sub("own-search")
|
||||
setS1Sub(ownCreateOrigin)
|
||||
} else if (s1Sub === "fields") {
|
||||
setS1Sub(courseSource === "official" ? "official-courses" : "own-search")
|
||||
}
|
||||
|
|
@ -504,6 +538,7 @@ export function NewRound() {
|
|||
skins_tie_handling: format === "skins" ? (skinsTie === "roll" ? "carry" : "split") : null,
|
||||
bbb_sweep_bonus_enabled: format === "bbb" ? bbbBonus : false,
|
||||
shamble_best_n: format === "shamble" ? shambleBest : null,
|
||||
scramble_solo_individual_pct: SCRAMBLE_SOLO_VARIANTS.has(format) ? soloAllowance : 100,
|
||||
visibility_mode: visibility === "privat" ? "private" : visibility === "venner" ? "friends" : "public",
|
||||
visible_categories: visibility === "venner" ? Array.from(categories) : [],
|
||||
flight_group_id: flightGroupId || undefined,
|
||||
|
|
@ -545,6 +580,29 @@ export function NewRound() {
|
|||
const created: { id: string } = await res.json()
|
||||
sideIdMap.set(side.id, created.id)
|
||||
}
|
||||
} else if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
|
||||
// Asymmetrisk format (begge varianter, stroke- og matchspill) --
|
||||
// de to sidene er faste (ikke organisator-opprettet via
|
||||
// CreateSideCard som TWO_SIDED-formatene), og trenger side_role
|
||||
// (backend kan ellers ikke skille laget fra individualspilleren,
|
||||
// se RoundSideCreate.side_role). Lokale player.sideId-verdier
|
||||
// "team"/"solo" (satt av ScrambleVsSoloSides) mappes her til de
|
||||
// ekte, nyopprettede side-id-ene.
|
||||
const roles: { localId: "team" | "solo"; label: string; side_role: "team" | "individual" }[] = [
|
||||
{ localId: "team", label: "Laget", side_role: "team" },
|
||||
{ localId: "solo", label: "Individuell motstander", side_role: "individual" },
|
||||
]
|
||||
for (const { localId, label, side_role } of roles) {
|
||||
const res = await fetch(`/rounds/${roundId}/sides`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ label, side_role }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`create side: ${res.status}`)
|
||||
const created: { id: string } = await res.json()
|
||||
sideIdMap.set(localId, created.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Medspillere (alle unntatt eieren, som allerede finnes).
|
||||
|
|
@ -589,7 +647,7 @@ export function NewRound() {
|
|||
|
||||
// Eierens egen side-tildeling (PATCH -- eieren finnes allerede FØR
|
||||
// sidene ble opprettet over).
|
||||
if (TWO_SIDED.has(format)) {
|
||||
if (TWO_SIDED.has(format) || SCRAMBLE_SOLO_VARIANTS.has(format)) {
|
||||
const owner = players.find((p) => p.isOwner)
|
||||
const realSideId = owner?.sideId ? sideIdMap.get(owner.sideId) : undefined
|
||||
if (realSideId) {
|
||||
|
|
@ -719,6 +777,7 @@ export function NewRound() {
|
|||
<Step1
|
||||
s1Sub={s1Sub}
|
||||
setS1Sub={setS1Sub}
|
||||
setOwnCreateOrigin={setOwnCreateOrigin}
|
||||
selectedClub={selectedClub}
|
||||
setSelectedClub={setSelectedClub}
|
||||
setCourseSource={setCourseSource}
|
||||
|
|
@ -762,6 +821,8 @@ export function NewRound() {
|
|||
setBbbBonus={setBbbBonus}
|
||||
shambleBest={shambleBest}
|
||||
setShambleBest={setShambleBest}
|
||||
soloAllowance={soloAllowance}
|
||||
setSoloAllowance={setSoloAllowance}
|
||||
advOpen={advOpen}
|
||||
setAdvOpen={setAdvOpen}
|
||||
useHcp={useHcp}
|
||||
|
|
@ -780,7 +841,14 @@ export function NewRound() {
|
|||
)}
|
||||
|
||||
{stepN === 4 && (
|
||||
<Step4 format={format} players={players} setPlayers={setPlayers} sides={sides} setSides={setSides} />
|
||||
<Step4
|
||||
format={format}
|
||||
players={players}
|
||||
setPlayers={setPlayers}
|
||||
sides={sides}
|
||||
setSides={setSides}
|
||||
roundStatLevel={statLevel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{stepN === 5 && (
|
||||
|
|
@ -1022,6 +1090,7 @@ function HelperNote({ children }: { children: React.ReactNode }) {
|
|||
function Step1(props: {
|
||||
s1Sub: S1Sub
|
||||
setS1Sub: (s: S1Sub) => void
|
||||
setOwnCreateOrigin: (s: S1Sub) => void
|
||||
selectedClub: OfficialClub | null
|
||||
setSelectedClub: (c: OfficialClub | null) => void
|
||||
setCourseSource: (s: "official" | "own") => void
|
||||
|
|
@ -1039,7 +1108,7 @@ function Step1(props: {
|
|||
startHole: string
|
||||
setStartHole: (v: string) => void
|
||||
}) {
|
||||
const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
|
||||
const { s1Sub, setS1Sub, setOwnCreateOrigin, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
|
||||
const [resolvingClub, setResolvingClub] = useState(false)
|
||||
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
|
||||
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
|
||||
|
|
@ -1117,13 +1186,46 @@ function Step1(props: {
|
|||
onUseAsTemplate={(c) => {
|
||||
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
|
||||
setCourseSource("own")
|
||||
setOwnCreateOrigin("official-courses")
|
||||
setS1Sub("own-create")
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Vanlig "finn en bane å spille"-søk (2026-08-05) -- IKKE "Bruk som mal
|
||||
// for egen bane" her lenger. Den knappen hører hjemme der brukeren
|
||||
// FAKTISK har sagt at målet er å OPPRETTE en ny bane (se
|
||||
// "own-search-template" under, nådd fra "Basér på en eksisterende
|
||||
// bane"-lenken inne i det blanke opprett-skjemaet) -- ikke i det
|
||||
// vanlige søket der målet som oftest bare er å velge en bane å spille.
|
||||
if (s1Sub === "own-search") {
|
||||
return (
|
||||
<OwnSearch
|
||||
onSelect={async (picked) => {
|
||||
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
|
||||
if (!res.ok) return
|
||||
const detail: ApiPersonalCourseDetail = await res.json()
|
||||
chooseCourse(
|
||||
{ id: detail.id, name: detail.name, tees: apiTeesToTees(detail.tees) },
|
||||
{ source: "custom", personalCourseId: detail.id },
|
||||
"own",
|
||||
)
|
||||
}}
|
||||
onCreate={() => {
|
||||
setTemplatePrefill(null)
|
||||
setOwnCreateOrigin("own-search")
|
||||
setS1Sub("own-create")
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Mal-søk (2026-08-05) -- samme søkeskjerm som over, men nådd KUN fra
|
||||
// "Basér på en eksisterende bane" inne i opprett-ny-bane-skjemaet, så
|
||||
// "Bruk som mal for egen bane" er meningsfull her (brukeren har allerede
|
||||
// sagt at målet er å opprette en ny bane).
|
||||
if (s1Sub === "own-search-template") {
|
||||
return (
|
||||
<OwnSearch
|
||||
onSelect={async (picked) => {
|
||||
|
|
@ -1144,10 +1246,12 @@ function Step1(props: {
|
|||
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
|
||||
forkedFromId: detail.id,
|
||||
})
|
||||
setOwnCreateOrigin("own-search-template")
|
||||
setS1Sub("own-create")
|
||||
}}
|
||||
onCreate={() => {
|
||||
setTemplatePrefill(null)
|
||||
setOwnCreateOrigin("own-search-template")
|
||||
setS1Sub("own-create")
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1160,6 +1264,7 @@ function Step1(props: {
|
|||
initial={templatePrefill?.values}
|
||||
forkedFromId={templatePrefill?.forkedFromId}
|
||||
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
|
||||
onSearchTemplate={() => setS1Sub("own-search-template")}
|
||||
onCancel={() => {
|
||||
setTemplatePrefill(null)
|
||||
setS1Sub("own-search")
|
||||
|
|
@ -1388,7 +1493,10 @@ function OwnSearch({
|
|||
onCreate,
|
||||
}: {
|
||||
onSelect: (c: ApiPersonalCourse) => void
|
||||
onUseAsTemplate: (c: ApiPersonalCourse) => void
|
||||
// Kun satt når søket er nådd med hensikten å OPPRETTE en ny bane (se
|
||||
// "own-search-template" i Step1) -- undefined i det vanlige "finn en
|
||||
// bane å spille"-søket, som skjuler knappen under (2026-08-05).
|
||||
onUseAsTemplate?: (c: ApiPersonalCourse) => void
|
||||
onCreate: () => void
|
||||
}) {
|
||||
const [query, setQuery] = useState("")
|
||||
|
|
@ -1412,7 +1520,14 @@ function OwnSearch({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<StepHeader title="Egen bane" description="Søk opp en personlig bane, eller opprett en ny." />
|
||||
<StepHeader
|
||||
title={onUseAsTemplate ? "Basér på en eksisterende bane" : "Egen bane"}
|
||||
description={
|
||||
onUseAsTemplate
|
||||
? "Velg en av dine egne baner som utgangspunkt for den nye, eller fyll inn fra bunnen av."
|
||||
: "Søk opp en personlig bane, eller opprett en ny."
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Search aria-hidden="true" className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
|
|
@ -1445,6 +1560,7 @@ function OwnSearch({
|
|||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{onUseAsTemplate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -1453,6 +1569,7 @@ function OwnSearch({
|
|||
>
|
||||
Bruk som mal for egen bane
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -1515,11 +1632,17 @@ function OwnCreateStep({
|
|||
initial,
|
||||
forkedFromId,
|
||||
onCreated,
|
||||
onSearchTemplate,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: CourseTemplateValues
|
||||
forkedFromId?: string
|
||||
onCreated: (course: Course) => void
|
||||
// "Basér på en eksisterende bane" (2026-08-05) -- kun tilbudt når skjemaet
|
||||
// fortsatt er blankt (ingen mal valgt ennå); gir "Bruk som mal for egen
|
||||
// bane" en riktig plassering: kun der brukeren FAKTISK har sagt at målet
|
||||
// er å opprette en ny bane, ikke i det vanlige "finn en bane å spille"-søket.
|
||||
onSearchTemplate?: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "")
|
||||
|
|
@ -1603,6 +1726,16 @@ function OwnCreateStep({
|
|||
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
|
||||
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
|
||||
|
||||
{onSearchTemplate && !initial && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchTemplate}
|
||||
className="flex min-h-11 items-center gap-1.5 self-start text-base font-semibold text-primary underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
Basér på en eksisterende bane i stedet
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-base font-medium text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
@ -1904,6 +2037,8 @@ function Step2(props: {
|
|||
setBbbBonus: (v: boolean) => void
|
||||
shambleBest: number
|
||||
setShambleBest: (v: number) => void
|
||||
soloAllowance: number
|
||||
setSoloAllowance: (v: number) => void
|
||||
advOpen: boolean
|
||||
setAdvOpen: (v: boolean) => void
|
||||
useHcp: boolean
|
||||
|
|
@ -1918,7 +2053,7 @@ function Step2(props: {
|
|||
const {
|
||||
statLevel, setStatLevel, format, setFormat, holes, setHoles,
|
||||
matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie,
|
||||
bbbBonus, setBbbBonus, shambleBest, setShambleBest,
|
||||
bbbBonus, setBbbBonus, shambleBest, setShambleBest, soloAllowance, setSoloAllowance,
|
||||
advOpen, setAdvOpen, useHcp, setUseHcp, useCourseHcp, setUseCourseHcp,
|
||||
useMatchplayHcp, setUseMatchplayHcp, hcpPercent, setHcpPercent,
|
||||
} = props
|
||||
|
|
@ -1977,8 +2112,11 @@ function Step2(props: {
|
|||
setBbbBonus={setBbbBonus}
|
||||
shambleBest={shambleBest}
|
||||
setShambleBest={setShambleBest}
|
||||
soloAllowance={soloAllowance}
|
||||
setSoloAllowance={setSoloAllowance}
|
||||
/>
|
||||
|
||||
{!SCRAMBLE_SOLO_VARIANTS.has(format) && (
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2016,6 +2154,7 @@ function Step2(props: {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionTitle title="Antall hull" />
|
||||
|
|
@ -2064,6 +2203,8 @@ function FormatConfig({
|
|||
setBbbBonus,
|
||||
shambleBest,
|
||||
setShambleBest,
|
||||
soloAllowance,
|
||||
setSoloAllowance,
|
||||
}: {
|
||||
format: FormatId
|
||||
matchExcludeHcp: boolean
|
||||
|
|
@ -2076,6 +2217,8 @@ function FormatConfig({
|
|||
setBbbBonus: (v: boolean) => void
|
||||
shambleBest: number
|
||||
setShambleBest: (v: number) => void
|
||||
soloAllowance: number
|
||||
setSoloAllowance: (v: number) => void
|
||||
}) {
|
||||
if (format === "match") {
|
||||
return (
|
||||
|
|
@ -2152,6 +2295,10 @@ function FormatConfig({
|
|||
return <HelperNote>Nøyaktig 3 spillere.</HelperNote>
|
||||
}
|
||||
|
||||
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
|
||||
return <SoloAllowanceControl value={soloAllowance} onChange={setSoloAllowance} />
|
||||
}
|
||||
|
||||
if (TWO_SIDED.has(format)) {
|
||||
return <HelperNote>Du setter opp lagene i neste steg.</HelperNote>
|
||||
}
|
||||
|
|
@ -2159,6 +2306,83 @@ function FormatConfig({
|
|||
return null
|
||||
}
|
||||
|
||||
// Kompakt 0-100 (steg 5) tallvelger for individualspillerens handicap-
|
||||
// andel. Bevisst INGEN tilsvarende kontroll for lagsiden -- lagets
|
||||
// handicap er alltid en fast beregning (TeamAverage), ikke konfigurerbar.
|
||||
function SoloAllowanceControl({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const STEP = 5
|
||||
const MIN = 0
|
||||
const MAX = 100
|
||||
|
||||
function clamp(n: number) {
|
||||
if (!Number.isFinite(n)) return MIN
|
||||
return Math.min(MAX, Math.max(MIN, n))
|
||||
}
|
||||
function snap(n: number) {
|
||||
return clamp(Math.round(n / STEP) * STEP)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="solo-allowance" className="text-base font-semibold text-foreground">
|
||||
Individualspillerens handicap-andel
|
||||
</Label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clamp(value - STEP))}
|
||||
disabled={value <= MIN}
|
||||
aria-label="Reduser handicap-andel"
|
||||
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Minus aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="solo-allowance"
|
||||
inputMode="numeric"
|
||||
value={String(value)}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/[^0-9]/g, "")
|
||||
onChange(digits === "" ? MIN : clamp(Number(digits)))
|
||||
}}
|
||||
onBlur={() => onChange(snap(value))}
|
||||
aria-describedby="solo-allowance-help"
|
||||
className="h-14 rounded-2xl pr-10 text-center text-lg font-bold tabular-nums"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-lg font-bold text-muted-foreground"
|
||||
>
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(clamp(value + STEP))}
|
||||
disabled={value >= MAX}
|
||||
aria-label="Øk handicap-andel"
|
||||
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<p id="solo-allowance-help" className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Hvor stor andel av egen handicap individualspilleren spiller med. Standard 100 % (full
|
||||
handicap).
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Step 3: Spillere ---------------------------------------------------------
|
||||
|
||||
function Step3({
|
||||
|
|
@ -2682,13 +2906,19 @@ function Step4({
|
|||
setPlayers,
|
||||
sides,
|
||||
setSides,
|
||||
roundStatLevel,
|
||||
}: {
|
||||
format: FormatId
|
||||
players: Player[]
|
||||
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
|
||||
sides: Side[]
|
||||
setSides: React.Dispatch<React.SetStateAction<Side[]>>
|
||||
roundStatLevel: StatLevel
|
||||
}) {
|
||||
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
|
||||
return <ScrambleVsSoloSides players={players} setPlayers={setPlayers} roundStatLevel={roundStatLevel} />
|
||||
}
|
||||
|
||||
if (format === "moneyball") {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -2794,6 +3024,220 @@ function Step4({
|
|||
)
|
||||
}
|
||||
|
||||
// Asymmetrisk sideoppsett for "Scramble mot enkeltspiller": slot 1 (Laget)
|
||||
// er en åpen liste (ingen øvre grense, "minst 2"-hint), slot 2 (Individuell
|
||||
// motstander) er begrenset til nøyaktig én spiller. player.sideId bruker
|
||||
// de faste literalene "team"/"solo" her (ikke en dynamisk opprettet Side-
|
||||
// id som i den symmetriske Step4 over) -- submitWizard() oppretter de to
|
||||
// ekte backend-sidene (med side_role) og mapper disse to nøklene til de
|
||||
// virkelige side-id-ene ved innsending.
|
||||
function ScrambleVsSoloSides({
|
||||
players,
|
||||
setPlayers,
|
||||
roundStatLevel,
|
||||
}: {
|
||||
players: Player[]
|
||||
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
|
||||
roundStatLevel: StatLevel
|
||||
}) {
|
||||
const [addingTo, setAddingTo] = useState<"team" | "solo" | null>(null)
|
||||
|
||||
const team = players.filter((p) => p.sideId === "team")
|
||||
const solo = players.filter((p) => p.sideId === "solo")
|
||||
const unplaced = players.filter((p) => p.sideId !== "team" && p.sideId !== "solo")
|
||||
const soloFull = solo.length >= 1
|
||||
const teamOk = team.length >= 2
|
||||
|
||||
function assign(id: string, sideId: string | undefined) {
|
||||
setPlayers((prev) => prev.map((p) => (p.id === id ? { ...p, sideId } : p)))
|
||||
}
|
||||
function addTo(sideId: "team" | "solo", p: Player) {
|
||||
setPlayers((prev) => [...prev, { ...p, sideId }])
|
||||
setAddingTo(null)
|
||||
}
|
||||
|
||||
const existingNames = players.map((p) => p.name)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<StepHeader
|
||||
title="Lag og rekkefølge"
|
||||
description="Ett lag med delt ball mot én enkeltspiller — sidene er ulike."
|
||||
/>
|
||||
|
||||
{/* Slot 1: Laget (åpen liste) */}
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||||
<Users aria-hidden="true" className="size-5" />
|
||||
</span>
|
||||
<h2 className="text-xl font-bold text-foreground">Laget</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-sm font-bold tabular-nums text-muted-foreground">
|
||||
{team.length} {team.length === 1 ? "spiller" : "spillere"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-semibold",
|
||||
teamOk
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-muted/50 text-muted-foreground",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{teamOk ? (
|
||||
<Check aria-hidden="true" className="size-4 shrink-0" />
|
||||
) : (
|
||||
<Users aria-hidden="true" className="size-4 shrink-0" />
|
||||
)}
|
||||
<span>{teamOk ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}</span>
|
||||
</div>
|
||||
|
||||
{team.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{team.map((p) => (
|
||||
<MemberRow
|
||||
key={p.id}
|
||||
player={p}
|
||||
onRemove={() => assign(p.id, undefined)}
|
||||
removeLabel={`Fjern ${p.name} fra laget`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{addingTo === "team" ? (
|
||||
<AddPlayerPanel
|
||||
onCancel={() => setAddingTo(null)}
|
||||
onAdd={(p) => addTo("team", p)}
|
||||
roundStatLevel={roundStatLevel}
|
||||
existingNames={existingNames}
|
||||
/>
|
||||
) : (
|
||||
<AddAffordance label="Legg til spiller" onClick={() => setAddingTo("team")} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Slot 2: Individuell motstander (begrenset til 1) */}
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-10 items-center justify-center rounded-xl bg-brand-orange/15 text-brand-orange">
|
||||
<User aria-hidden="true" className="size-5" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-xl font-bold text-foreground">Individuell motstander</h2>
|
||||
<p className="text-sm text-muted-foreground">Nøyaktig én spiller</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{soloFull ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{solo.map((p) => (
|
||||
<MemberRow
|
||||
key={p.id}
|
||||
player={p}
|
||||
onRemove={() => assign(p.id, undefined)}
|
||||
removeLabel={`Fjern ${p.name} som motstander`}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : addingTo === "solo" ? (
|
||||
<AddPlayerPanel
|
||||
onCancel={() => setAddingTo(null)}
|
||||
onAdd={(p) => addTo("solo", p)}
|
||||
roundStatLevel={roundStatLevel}
|
||||
existingNames={existingNames}
|
||||
/>
|
||||
) : (
|
||||
<AddAffordance label="Legg til motstander" onClick={() => setAddingTo("solo")} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Spillere i runden som ennå ikke er plassert på noen side */}
|
||||
{unplaced.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<SectionTitle title="Ikke plassert" hint="Spillere fra runden som ennå ikke er tildelt en side." />
|
||||
<ul className="flex flex-col gap-2">
|
||||
{unplaced.map((p) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-2xl border border-border bg-card p-3 shadow-sm shadow-black/5"
|
||||
>
|
||||
<Avatar name={p.name} />
|
||||
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{p.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => assign(p.id, "team")}
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<Users aria-hidden="true" className="size-4" />
|
||||
Til laget
|
||||
</button>
|
||||
{!soloFull && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => assign(p.id, "solo")}
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-brand-orange/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<User aria-hidden="true" className="size-4" />
|
||||
Som motstander
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
player,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}: {
|
||||
player: Player
|
||||
onRemove: () => void
|
||||
removeLabel: string
|
||||
}) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 rounded-2xl border border-border bg-card p-3">
|
||||
<Avatar name={player.name} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
|
||||
{player.hcp.trim() !== "" && (
|
||||
<span className="text-sm text-muted-foreground">HCP {player.hcp}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={removeLabel}
|
||||
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAffordance({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex min-h-11 items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card/40 px-6 py-4 text-base font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
<UserPlus aria-hidden="true" className="size-5" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateSideCard({ slot, onCreate, disabled }: { slot: number; onCreate: (label: string) => void; disabled: boolean }) {
|
||||
const [label, setLabel] = useState("")
|
||||
const fallback = slot === 0 ? "Side A" : "Side B"
|
||||
|
|
|
|||
|
|
@ -41,6 +41,29 @@ const STATUS_CONFIG: Record<RoundStatus, { label: string }> = {
|
|||
completed: { label: "Fullført" },
|
||||
}
|
||||
|
||||
// Egen lokal kopi per prosjektets konvensjon (liten duplisering fremfor
|
||||
// cross-file-import, se samme mønster i round-detail.tsx/round-scorecard.tsx).
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
stroke: "Slagspill",
|
||||
stableford: "Stableford",
|
||||
match: "Match",
|
||||
skins: "Skins",
|
||||
fourball: "Fourball",
|
||||
foursome: "Foursome",
|
||||
greensome: "Greensome",
|
||||
scramble_2: "Scramble (2)",
|
||||
scramble_4: "Scramble (4)",
|
||||
chapman: "Chapman",
|
||||
copenhagen: "Københavner",
|
||||
bbb: "Bingo Bango Bongo",
|
||||
flag: "Flaggturnering",
|
||||
shamble: "Shamble",
|
||||
money_ball: "Money Ball",
|
||||
high_low_high: "High-low-high",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
|
|
@ -120,6 +143,9 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
? round.differential.toFixed(1).replace(".", ",")
|
||||
: null
|
||||
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
|
||||
// Skjules for "stroke" (samme "kun avvik fra standardformatet er verdt en
|
||||
// pille"-konvensjon som round-scorecard.tsx sin sammendrags-stripe).
|
||||
const formatLabel = round.playFormat && round.playFormat !== "stroke" ? FORMAT_LABELS[round.playFormat] ?? round.playFormat : null
|
||||
|
||||
// To-sidede formater (match/fourball/foursome/greensome/scramble,
|
||||
// 2026-07-29): vis matchStatus i stedet for rå slagtall -- brutto
|
||||
|
|
@ -178,9 +204,16 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
|
||||
{STATUS_CONFIG[round.status].label}
|
||||
</span>
|
||||
{formatLabel && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
|
||||
{formatLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{round.status === "active" && (
|
||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||
{played}/{round.holes} hull spilt
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
||||
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
|
@ -193,6 +194,9 @@ type ApiParticipant = {
|
|||
type ApiSide = {
|
||||
id: string
|
||||
label: string | null
|
||||
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
|
||||
// formater, som ikke skiller sidene fra hverandre strukturelt.
|
||||
side_role: "team" | "individual" | null
|
||||
}
|
||||
|
||||
type PersonMatch = {
|
||||
|
|
@ -264,6 +268,14 @@ const TWO_SIDED_FORMATS = new Set([
|
|||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||
])
|
||||
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
|
||||
// Scramble mot enkeltspiller (migrasjon 056/057) -- HALVPARTEN delt-ball
|
||||
// (laget deler ball via round_side_id) og HALVPARTEN individuell
|
||||
// (motstanderen scorer per deltaker via round_participant_id). Passer
|
||||
// derfor IKKE inn i SHARED_BALL_FORMATS sin alt-eller-ingenting-bryter --
|
||||
// se scrambleSoloTeamSideId lenger ned, som ruter KUN lagets spillere til
|
||||
// SideScoreWizard, mens individualspilleren fortsatt bruker vanlig
|
||||
// ScoringWizard.
|
||||
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
|
||||
const SIDE_PLAYER_COUNT: Record<string, number> = {
|
||||
match: 1,
|
||||
fourball: 2,
|
||||
|
|
@ -291,6 +303,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
|||
shamble: "Shamble",
|
||||
money_ball: "Money Ball",
|
||||
high_low_high: "High-low-high",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
// Flatt felt, egne tilleggsvisninger i FormatResultPanel -- ingen sider,
|
||||
// bruker vanlig individuell-ball scorekort/veiviser for selve registreringen.
|
||||
|
|
@ -833,11 +847,29 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
|
||||
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
|
||||
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
|
||||
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
|
||||
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
|
||||
// loadRound() har satt currentHole fra round.start_hole.
|
||||
const activeHole = currentHole ?? round?.start_hole ?? 1
|
||||
const hole = holes.find((h) => h.holeNumber === activeHole) ?? null
|
||||
// Par/index for aktivt hull -- samme for ALLE deltakere/sider (samme
|
||||
// bane), hentet fra HVILKEN SOM HELST kilde som allerede har lastet inn
|
||||
// hullene sine (2026-08-05, erstatter en tidligere versjon som kun så på
|
||||
// activePlayerId sin egen liste -- brøt permanent for scramble_solo sine
|
||||
// lagspillere, som ALDRI får egne round_participant_id-keyede hull-rader
|
||||
// siden laget deler ball via round_side_id. En aktiv spiller på laget
|
||||
// ga derfor en evig tom `holes`-liste og en evig spinner). Samme mønster
|
||||
// som delt-ball-formatenes side-baserte oppslag alltid har brukt, nå
|
||||
// generalisert til å også dekke deltaker-baserte kilder.
|
||||
const hole: Hole | null = (() => {
|
||||
for (const list of Object.values(holesByParticipant)) {
|
||||
const h = list.find((x) => x.hole_number === activeHole)
|
||||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
||||
}
|
||||
for (const s of round?.sides ?? []) {
|
||||
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
|
||||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
// Navigasjonsrekkefølge starter på øktens starthull og går rundt
|
||||
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
|
||||
|
|
@ -902,36 +934,60 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
||||
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
|
||||
function advanceWizardPlayer() {
|
||||
const idx = players.findIndex((p) => p.id === wizardPlayerId)
|
||||
const isLast = idx === -1 || idx === players.length - 1
|
||||
if (!isLast) {
|
||||
const next = players[idx + 1]
|
||||
setActivePlayerId(next.id)
|
||||
setWizardPlayerId(next.id)
|
||||
} else {
|
||||
setWizardPlayerId(null)
|
||||
setActivePlayerId(players[0]?.id ?? null)
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
|
||||
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
|
||||
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
|
||||
// mening for en delt ball).
|
||||
const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
|
||||
// Scramble mot enkeltspiller (2026-08-05) -- KUN lagets side, aldri
|
||||
// individualspillerens (den fortsetter å bruke vanlig ScoringWizard/
|
||||
// wizardPlayerId). Null for alle andre formater.
|
||||
const scrambleSoloTeamSideId =
|
||||
round && SCRAMBLE_SOLO_FORMATS.has(round.play_format)
|
||||
? round.sides.find((s) => s.side_role === "team")?.id ?? null
|
||||
: null
|
||||
const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
|
||||
const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null
|
||||
// Par/index for aktivt hull -- samme for begge sider (samme bane), henter
|
||||
// fra hvilken som helst av de to som allerede har lastet inn hullene sine.
|
||||
const sharedHole: Hole | null = (() => {
|
||||
for (const s of round?.sides ?? []) {
|
||||
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
|
||||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
||||
|
||||
// Felles klikk-håndtering for PlayerHoleCards/ScorecardGrid sine
|
||||
// "Registrer X sin score"-kort (2026-08-05) -- for scramble mot
|
||||
// enkeltspiller skal LAGETS spillere åpne den delte SideScoreWizard
|
||||
// (samme lagscore for alle, keyet på round_side_id), mens
|
||||
// individualspilleren fortsatt åpner den vanlige, per-deltaker
|
||||
// ScoringWizard. Alle andre formater (ingen team-side) oppfører seg
|
||||
// som før.
|
||||
function openPlayerOrTeamSideEntry(playerId: string, holeNumber: number) {
|
||||
setCurrentHole(holeNumber)
|
||||
const player = players.find((p) => p.id === playerId)
|
||||
if (scrambleSoloTeamSideId && player?.roundSideId === scrambleSoloTeamSideId) {
|
||||
setWizardSideId(scrambleSoloTeamSideId)
|
||||
return
|
||||
}
|
||||
setActivePlayerId(playerId)
|
||||
setWizardPlayerId(playerId)
|
||||
}
|
||||
|
||||
// Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
|
||||
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
||||
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
|
||||
// Hopper over lagets spillere for scramble_solo (2026-08-05) -- de scorer
|
||||
// ALDRI via denne per-deltaker-veiviseren (se scrambleSoloTeamSideId), så
|
||||
// de skal aldri kunne bli et "neste"-mål her heller.
|
||||
function advanceWizardPlayer() {
|
||||
const chain = scrambleSoloTeamSideId ? players.filter((p) => p.roundSideId !== scrambleSoloTeamSideId) : players
|
||||
const idx = chain.findIndex((p) => p.id === wizardPlayerId)
|
||||
const isLast = idx === -1 || idx === chain.length - 1
|
||||
if (!isLast) {
|
||||
const next = chain[idx + 1]
|
||||
setActivePlayerId(next.id)
|
||||
setWizardPlayerId(next.id)
|
||||
} else {
|
||||
setWizardPlayerId(null)
|
||||
setActivePlayerId(chain[0]?.id ?? null)
|
||||
goNext()
|
||||
}
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
// Auto-prompt "Fullfør runde" (2026-07-30): er ALLE hull ført for ALLE
|
||||
// deltakere (individuelle formater) eller for BEGGE sider (delt-ball) --
|
||||
|
|
@ -1002,6 +1058,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
|
||||
function advanceWizardSide() {
|
||||
if (!round) return
|
||||
// Scramble mot enkeltspiller (2026-08-05): kun ÉN side er noensinne
|
||||
// side-veiviser-styrt (laget) -- individualspilleren har sin egen,
|
||||
// separate ScoringWizard-kjede (advanceWizardPlayer). Aldri "hopp til
|
||||
// neste side" her, i motsetning til ekte delt-ball-formater der begge
|
||||
// sider deler samme veiviser-kjede.
|
||||
if (scrambleSoloTeamSideId) {
|
||||
setWizardSideId(null)
|
||||
goNext()
|
||||
return
|
||||
}
|
||||
const idx = round.sides.findIndex((s) => s.id === wizardSideId)
|
||||
const isLast = idx === -1 || idx === round.sides.length - 1
|
||||
if (!isLast) {
|
||||
|
|
@ -1459,7 +1525,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
full begrunnelse. Gridet selv er UENDRET, bare flyttet til en
|
||||
"Vis hele scorekortet"-bryter under -- ingen informasjon er
|
||||
fjernet. */}
|
||||
{holes.length === 0 ? (
|
||||
{hole === null ? (
|
||||
<div className="mt-5 flex justify-center py-8">
|
||||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||
</div>
|
||||
|
|
@ -1481,11 +1547,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
}}
|
||||
onPrevHole={goPrev}
|
||||
onNextHole={goNext}
|
||||
onOpenEntry={(playerId, holeNumber) => {
|
||||
setCurrentHole(holeNumber)
|
||||
setActivePlayerId(playerId)
|
||||
setWizardPlayerId(playerId)
|
||||
}}
|
||||
onOpenEntry={openPlayerOrTeamSideEntry}
|
||||
/>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||
|
|
@ -1514,11 +1576,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
setCurrentHole(n)
|
||||
scrollToHolePanel()
|
||||
}}
|
||||
onOpenEntry={(playerId, holeNumber) => {
|
||||
setCurrentHole(holeNumber)
|
||||
setActivePlayerId(playerId)
|
||||
setWizardPlayerId(playerId)
|
||||
}}
|
||||
onOpenEntry={openPlayerOrTeamSideEntry}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1534,12 +1592,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
|
||||
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
|
||||
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
|
||||
{wizardSide && sharedHole && (
|
||||
{wizardSide && hole && (
|
||||
<SideScoreWizard
|
||||
sides={round.sides}
|
||||
side={wizardSide}
|
||||
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
|
||||
hole={sharedHole}
|
||||
hole={hole}
|
||||
score={wizardSideHole?.played ? wizardSideHole.score : null}
|
||||
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
|
||||
onSave={updateWizardSideScore}
|
||||
|
|
@ -2573,6 +2631,11 @@ type ApiFormatResult = {
|
|||
money_ball_team_score: number | null
|
||||
hlh_points_a: number | null
|
||||
hlh_points_b: number | null
|
||||
// Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
|
||||
// side "a" = laget, side "b" = individualspilleren. margin > 0 = laget leder.
|
||||
scramble_solo_team_net: number | null
|
||||
scramble_solo_individual_net: number | null
|
||||
scramble_solo_margin: number | null
|
||||
holes: ApiFormatHoleOut[] | null
|
||||
}
|
||||
|
||||
|
|
@ -2622,6 +2685,50 @@ function matchStillingByHole(
|
|||
// navn når nøyaktig én er tildelt siden (match, eller en komplett foursome-
|
||||
// side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers
|
||||
// sidens eget (evt. generiske) navn uten noen entydig HCP å vise.
|
||||
// Samme adapter-logikk som round-leaderboard.tsx sin buildScrambleSoloResult
|
||||
// (lokal kopi, prosjektets etablerte mønster) -- bygger ScrambleSoloResultView
|
||||
// sin props-form fra det generiske ApiFormatResult-svaret.
|
||||
function buildScrambleSoloResult(round: ApiRound, result: ApiFormatResult): ScrambleSoloResult {
|
||||
const teamSide = round.sides.find((s) => s.side_role === "team")
|
||||
const individualSide = round.sides.find((s) => s.side_role === "individual")
|
||||
const firstHoleEntries = result.holes?.[0]?.entries ?? []
|
||||
const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
|
||||
const individualLabel =
|
||||
firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"
|
||||
|
||||
const holes: ScrambleSoloResult["holes"] = (result.holes ?? []).map((h) => {
|
||||
const teamEntry = h.entries.find((e) => e.side === "a")
|
||||
const individualEntry = h.entries.find((e) => e.side === "b")
|
||||
const holeResult: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
|
||||
return {
|
||||
holeNumber: h.hole_number,
|
||||
par: h.par,
|
||||
strokeIndex: h.stroke_index,
|
||||
result: holeResult,
|
||||
team: {
|
||||
gross: teamEntry?.gross ?? 0,
|
||||
net: teamEntry?.net ?? 0,
|
||||
strokesReceived: teamEntry?.strokes_received ?? 0,
|
||||
},
|
||||
individual: {
|
||||
gross: individualEntry?.gross ?? 0,
|
||||
net: individualEntry?.net ?? 0,
|
||||
strokesReceived: individualEntry?.strokes_received ?? 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ready: result.ready,
|
||||
teamLabel,
|
||||
individualLabel,
|
||||
teamNet: result.scramble_solo_team_net,
|
||||
individualNet: result.scramble_solo_individual_net,
|
||||
margin: result.scramble_solo_margin,
|
||||
holes,
|
||||
}
|
||||
}
|
||||
|
||||
function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } {
|
||||
const idx = side === "a" ? 0 : 1
|
||||
const sideId = round.sides[idx]?.id
|
||||
|
|
@ -3046,6 +3153,21 @@ function FormatResultPanel({
|
|||
)
|
||||
}
|
||||
|
||||
// Scramble mot enkeltspiller (migrasjon 056, 2026-08-05) -- netto
|
||||
// slagspill-sammenligning, ikke matchspill -- MÅ avskjæres FØR den
|
||||
// generiske to-sidede fallback-koden under, som forutsetter et
|
||||
// symmetrisk match-play-format (ville krasjet/vist feil for dette
|
||||
// formatet ellers). Gjenbruker samme ScrambleSoloResultView som den
|
||||
// frittstående leaderboard-siden (round-leaderboard.tsx), ikke en egen
|
||||
// kondensert visning.
|
||||
if (round.play_format === "scramble_solo") {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<ScrambleSoloResultView result={buildScrambleSoloResult(round, result)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// To-sidede formater (match/fourball/foursome/greensome/scramble/chapman).
|
||||
if (!result.ready) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { useEffect, useState } from "react"
|
|||
import Link from "next/link"
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||
|
||||
// --- API-typer ---------------------------------------------------------------
|
||||
|
||||
|
|
@ -65,6 +66,9 @@ type ApiRoundParticipant = {
|
|||
type ApiSide = {
|
||||
id: string
|
||||
label: string | null
|
||||
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
|
||||
// formater, som ikke skiller sidene fra hverandre strukturelt.
|
||||
side_role: "team" | "individual" | null
|
||||
}
|
||||
|
||||
type ApiRoundHeader = {
|
||||
|
|
@ -87,6 +91,16 @@ type ApiRoundHeader = {
|
|||
// derfor vist en tom/meningsløs liste for dem uansett.
|
||||
const TWO_SIDED_FORMATS = [
|
||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman",
|
||||
// scramble_solo_match (migrasjon 057) -- ekte hull-for-hull matchspill
|
||||
// (compute_match_state på backend-siden), samme generiske match_lead/
|
||||
// match_holes_played/-remaining/is_closed/is_dormie-felter som resten
|
||||
// av denne listen. Asymmetrisk lag-mot-individuell-struktur (IKKE
|
||||
// fast antall spillere per side), men TwoSidedBoard/buildSideTeams
|
||||
// grener allerede på FAKTISK antall spillere per side, ikke et fast
|
||||
// tall -- bekreftet trygt å gjenbruke uendret. scramble_solo (stroke-
|
||||
// varianten, migrasjon 056) er BEVISST IKKE her, se SCRAMBLE_SOLO_
|
||||
// FORMATS under.
|
||||
"scramble_solo_match",
|
||||
]
|
||||
// High-low-high er et SÆRTILFELLE av de to-sidede formatene -- IKKE et
|
||||
// hull-ledelse-konsept ("X UP"), se HighLowBoard.
|
||||
|
|
@ -94,6 +108,11 @@ const HIGH_LOW_FORMATS = new Set(["high_low_high"])
|
|||
// Shamble/Money Ball -- HELE runden er ETT lag, ikke to sider -- lagets
|
||||
// EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard.
|
||||
const TEAM_RESULT_FORMATS = new Set(["shamble", "money_ball"])
|
||||
// Scramble mot enkeltspiller (migrasjon 056) -- BEVISST IKKE i
|
||||
// TWO_SIDED_FORMATS: dette er netto SLAGSPILL-sammenligning
|
||||
// (net_stroke_play_margin), ikke matchspill/hull-ledelse -- egen enkel
|
||||
// visning, se ScrambleSoloResultView.
|
||||
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo"])
|
||||
// Bingo Bango Bongo/Københavner har egne poengsystem, uavhengig av rå
|
||||
// slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk
|
||||
// Stableford (backend regner den likt uansett format), FEIL for disse to.
|
||||
|
|
@ -101,6 +120,28 @@ const FORMAT_OWN_POINTS_FIELD: Partial<Record<string, "bbb_points" | "copenhagen
|
|||
bbb: "bbb_points",
|
||||
copenhagen: "copenhagen_points",
|
||||
}
|
||||
// Egen lokal kopi per prosjektets konvensjon (liten duplisering fremfor
|
||||
// cross-file-import, se samme mønster i round-detail.tsx/round-scorecard.tsx).
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
stroke: "Slagspill",
|
||||
stableford: "Stableford",
|
||||
match: "Match",
|
||||
skins: "Skins",
|
||||
fourball: "Fourball",
|
||||
foursome: "Foursome",
|
||||
greensome: "Greensome",
|
||||
scramble_2: "Scramble (2)",
|
||||
scramble_4: "Scramble (4)",
|
||||
chapman: "Chapman",
|
||||
copenhagen: "Københavner",
|
||||
bbb: "Bingo Bango Bongo",
|
||||
flag: "Flaggturnering",
|
||||
shamble: "Shamble",
|
||||
money_ball: "Money Ball",
|
||||
high_low_high: "High-low-high",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||
|
||||
|
|
@ -1059,6 +1100,12 @@ type ApiFormatResult = {
|
|||
money_ball_team_score: number | null
|
||||
hlh_points_a: number | null
|
||||
hlh_points_b: number | null
|
||||
// Scramble mot enkeltspiller (migrasjon 056) -- rene netto-totalsummer,
|
||||
// side "a" = laget, side "b" = individualspilleren (fast, se
|
||||
// _build_scramble_solo_result). margin > 0 = laget leder.
|
||||
scramble_solo_team_net: number | null
|
||||
scramble_solo_individual_net: number | null
|
||||
scramble_solo_margin: number | null
|
||||
holes: ApiFormatHole[] | null
|
||||
}
|
||||
|
||||
|
|
@ -1079,6 +1126,54 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
|
|||
return result
|
||||
}
|
||||
|
||||
// Bygger ScrambleSoloResultView sin props-form fra det generiske
|
||||
// ApiFormatResult-svaret. Hull-entryen sitt `label`-felt (satt av
|
||||
// _build_scramble_solo_result til deltakerens/lagets faktiske
|
||||
// visningsnavn) foretrekkes over sidens EGEN label (som bare er den
|
||||
// faste "Laget"/"Individuell motstander"-teksten satt ved opprettelse,
|
||||
// se new-round.tsx sin submitWizard) -- faller tilbake til sidens label
|
||||
// når ingen hull er spilt ennå.
|
||||
function buildScrambleSoloResult(round: ApiRoundHeader, formatResult: ApiFormatResult | null): ScrambleSoloResult {
|
||||
const teamSide = round.sides.find((s) => s.side_role === "team")
|
||||
const individualSide = round.sides.find((s) => s.side_role === "individual")
|
||||
const firstHoleEntries = formatResult?.holes?.[0]?.entries ?? []
|
||||
const teamLabel = firstHoleEntries.find((e) => e.side === "a")?.label || teamSide?.label || "Laget"
|
||||
const individualLabel =
|
||||
firstHoleEntries.find((e) => e.side === "b")?.label || individualSide?.label || "Individuell motstander"
|
||||
|
||||
const holes: ScrambleSoloResult["holes"] = (formatResult?.holes ?? []).map((h) => {
|
||||
const teamEntry = h.entries.find((e) => e.side === "a")
|
||||
const individualEntry = h.entries.find((e) => e.side === "b")
|
||||
const result: "a" | "b" | "halved" = h.result === "a" || h.result === "b" ? h.result : "halved"
|
||||
return {
|
||||
holeNumber: h.hole_number,
|
||||
par: h.par,
|
||||
strokeIndex: h.stroke_index,
|
||||
result,
|
||||
team: {
|
||||
gross: teamEntry?.gross ?? 0,
|
||||
net: teamEntry?.net ?? 0,
|
||||
strokesReceived: teamEntry?.strokes_received ?? 0,
|
||||
},
|
||||
individual: {
|
||||
gross: individualEntry?.gross ?? 0,
|
||||
net: individualEntry?.net ?? 0,
|
||||
strokesReceived: individualEntry?.strokes_received ?? 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ready: formatResult?.ready ?? false,
|
||||
teamLabel,
|
||||
individualLabel,
|
||||
teamNet: formatResult?.scramble_solo_team_net ?? null,
|
||||
individualNet: formatResult?.scramble_solo_individual_net ?? null,
|
||||
margin: formatResult?.scramble_solo_margin ?? null,
|
||||
holes,
|
||||
}
|
||||
}
|
||||
|
||||
type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number }
|
||||
|
||||
function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) {
|
||||
|
|
@ -1309,6 +1404,7 @@ export function RoundLeaderboard({
|
|||
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
|
||||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||
const isTeamResult = TEAM_RESULT_FORMATS.has(round.play_format)
|
||||
const isScrambleSolo = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
|
||||
const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format]
|
||||
|
||||
return (
|
||||
|
|
@ -1329,9 +1425,16 @@ export function RoundLeaderboard({
|
|||
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||
{round.name?.trim() || round.course_name_snapshot}
|
||||
</span>
|
||||
{round.play_format !== "stroke" && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
|
||||
{FORMAT_LABELS[round.play_format] ?? round.play_format}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground">
|
||||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||
{round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
|
||||
|
|
@ -1435,6 +1538,8 @@ export function RoundLeaderboard({
|
|||
/>
|
||||
)
|
||||
})()
|
||||
) : isScrambleSolo ? (
|
||||
!formatResult ? null : <ScrambleSoloResultView result={buildScrambleSoloResult(round, formatResult)} />
|
||||
) : (
|
||||
(() => {
|
||||
const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult)
|
||||
|
|
@ -1468,16 +1573,21 @@ export function RoundLeaderboardMini({ roundId }: { roundId: string }) {
|
|||
if (!round || !leaderboard || round.participants.length < 2) return null
|
||||
|
||||
// To-sidede formater (match/fourball/foursome/greensome/scramble/
|
||||
// high_low_high) vises allerede av FormatResultPanel rett under denne
|
||||
// komponenten sitt kallsted i round-detail.tsx -- ingen individuell
|
||||
// brutto-rangering å vise her. Shamble/Money Ball har heller ingen
|
||||
// meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er det
|
||||
// som teller, vist i TeamFlightBoard på selve leaderboard-siden i
|
||||
// stedet for en misvisende råscore-rangering her.
|
||||
// high_low_high/scramble_solo_match) vises allerede av FormatResultPanel
|
||||
// rett under denne komponenten sitt kallsted i round-detail.tsx -- ingen
|
||||
// individuell brutto-rangering å vise her. Shamble/Money Ball har heller
|
||||
// ingen meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er
|
||||
// det som teller, vist i TeamFlightBoard på selve leaderboard-siden i
|
||||
// stedet for en misvisende råscore-rangering her. scramble_solo
|
||||
// (stroke-varianten) har av samme grunn ingen meningsfull individuell
|
||||
// rangering -- lag-mot-individuell netto-sammenligning vises av
|
||||
// ScrambleSoloResultView i stedet (fikset her, opprinnelig glemt da
|
||||
// scramble_solo ble bygget 2026-08-05).
|
||||
if (
|
||||
TWO_SIDED_FORMATS.includes(round.play_format) ||
|
||||
HIGH_LOW_FORMATS.has(round.play_format) ||
|
||||
TEAM_RESULT_FORMATS.has(round.play_format)
|
||||
TEAM_RESULT_FORMATS.has(round.play_format) ||
|
||||
SCRAMBLE_SOLO_FORMATS.has(round.play_format)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ type ApiParticipant = {
|
|||
type ApiSide = {
|
||||
id: string
|
||||
label: string | null
|
||||
// KUN satt for scramble_solo (migrasjon 056) -- null for alle andre
|
||||
// formater, som ikke skiller sidene fra hverandre strukturelt.
|
||||
side_role: "team" | "individual" | null
|
||||
}
|
||||
|
||||
type ApiRound = {
|
||||
|
|
@ -86,7 +89,17 @@ type ApiHole = {
|
|||
}
|
||||
|
||||
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"]
|
||||
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", ...SHARED_BALL_FORMATS]
|
||||
// scramble_solo_match (migrasjon 057) lagt til her -- KUN denne bruken
|
||||
// (isTwoSided) gater MatchScorecardGrid, som får sin egen dedikerte
|
||||
// scramble-solo-familie-gren for å unngå dupliserte rader (se der).
|
||||
// SHARED_BALL_FORMATS/isSharedBall RØRES bevisst ikke -- se
|
||||
// SCRAMBLE_SOLO_FORMATS under for den egne, riktige klassifiseringen.
|
||||
const TWO_SIDED_FORMATS = ["match", "fourball", "high_low_high", "scramble_solo_match", ...SHARED_BALL_FORMATS]
|
||||
// Begge scramble-mot-enkeltspiller-variantene deler NØYAKTIG samme
|
||||
// asymmetriske side-/enhetsstruktur (laget = side, individualspilleren =
|
||||
// participant) -- kun selve resultat-sammenligningen skiller dem. Én
|
||||
// samlet klassifisering brukt for alt UNNTATT selve resultatvisningen.
|
||||
const SCRAMBLE_SOLO_FORMATS = new Set(["scramble_solo", "scramble_solo_match"])
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
stroke: "Slagspill",
|
||||
stableford: "Stableford",
|
||||
|
|
@ -104,6 +117,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
|||
shamble: "Shamble",
|
||||
money_ball: "Money Ball",
|
||||
high_low_high: "High-low-high",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||
|
|
@ -778,8 +793,41 @@ function MatchScorecardGrid({
|
|||
if (!result) return null
|
||||
|
||||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||
// scramble_solo_match (migrasjon 057) -- ÉN rad per SIDE (som
|
||||
// delt-ball-grenen under), IKKE én per deltaker, siden laget kan ha
|
||||
// vilkårlig mange medlemmer som alle deler samme TeamAverage-verdi --
|
||||
// uten denne egne grenen ville "else"-grenen under vist N dupliserte
|
||||
// rader for laget. Individualsidens rad viser spillerens EGET navn
|
||||
// (ikke bare sidens "Individuell motstander"-label) siden den siden
|
||||
// alltid har nøyaktig én deltaker.
|
||||
const isScrambleSoloMatch = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
|
||||
|
||||
const rows: MatchRow[] = isSharedBall
|
||||
const rows: MatchRow[] = isScrambleSoloMatch
|
||||
? round.sides.map((s, idx) => {
|
||||
// side="a"/"b" er POSISJONELL (idx===0 -> "a"), samme sorterte-
|
||||
// etter-id-konvensjon som backend sin _build_scramble_solo_match_
|
||||
// result bruker (round.sides hentes ALLTID `ORDER BY id`, samme
|
||||
// sortering backend legger til grunn for "a"/"b"-tildelingen --
|
||||
// IKKE en fast lag=a/individuell=b-tildeling, som ville gitt feil
|
||||
// ledelsesfarge når laget ikke er den lavest-sorterte UUID-en).
|
||||
// row.id må for individualsiden være DELTAKERENS id (ikke sidens),
|
||||
// siden FormatHoleEntry.id for den siden er individual_
|
||||
// participant["id"] på backend-siden -- ellers finner
|
||||
// h.entries.find(e => e.id === row.id) under aldri en match.
|
||||
const sideParticipants = round.participants.filter((p) => p.round_side_id === s.id)
|
||||
const isTeam = s.side_role === "team"
|
||||
const label =
|
||||
!isTeam && sideParticipants.length === 1
|
||||
? participantLabel(sideParticipants[0], viewerId)
|
||||
: s.label?.trim() || (isTeam ? "Laget" : "Individuell motstander")
|
||||
return {
|
||||
id: isTeam ? s.id : (sideParticipants[0]?.id ?? s.id),
|
||||
side: idx === 0 ? "a" : ("b" as const),
|
||||
label,
|
||||
hcp: sideParticipants[0]?.playing_handicap ?? null,
|
||||
}
|
||||
})
|
||||
: isSharedBall
|
||||
? round.sides.map((s, idx) => ({
|
||||
id: s.id,
|
||||
side: idx === 0 ? "a" : ("b" as const),
|
||||
|
|
@ -1079,6 +1127,19 @@ export function RoundScorecard({
|
|||
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
|
||||
setUnitId((prev) => {
|
||||
if (prev) return prev
|
||||
// scramble_solo-familien (migrasjon 056/057) -- blandet enhetstype
|
||||
// (laget = en SIDE, individualspilleren = en PARTICIPANT, se
|
||||
// units-bygging under). Standardvalg: individualspillerens egen
|
||||
// rad hvis DET er den spørrende brukeren, ellers lagsiden.
|
||||
if (SCRAMBLE_SOLO_FORMATS.has(data.play_format)) {
|
||||
const individualSide = data.sides.find((s) => s.side_role === "individual")
|
||||
const individualParticipant = individualSide
|
||||
? data.participants.find((p) => p.round_side_id === individualSide.id)
|
||||
: undefined
|
||||
if (viewerId && individualParticipant?.user_id === viewerId) return individualParticipant.id
|
||||
const teamSide = data.sides.find((s) => s.side_role === "team")
|
||||
return teamSide?.id ?? data.sides[0]?.id ?? null
|
||||
}
|
||||
if (isSharedBall) {
|
||||
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
|
||||
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
|
||||
|
|
@ -1101,8 +1162,15 @@ export function RoundScorecard({
|
|||
|
||||
useEffect(() => {
|
||||
if (!unitId || !round) return
|
||||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||
const endpoint = isSharedBall
|
||||
// scramble_solo (migrasjon 056) har BLANDEDE enhetstyper i samme
|
||||
// fane-liste (laget = side, individualspilleren = participant) -- slå
|
||||
// opp faktisk type fra selve unitId i stedet for et format-bredt
|
||||
// isSharedBall-flagg, som ikke kan uttrykke en blanding. Denne
|
||||
// oppslags-tilnærmingen er samtidig korrekt uendret for de rene
|
||||
// delt-ball-/individuell-ball-formatene (alle enheter er da alltid
|
||||
// den ene eller den andre typen).
|
||||
const isSideUnit = round.sides.some((s) => s.id === unitId)
|
||||
const endpoint = isSideUnit
|
||||
? `/rounds/${roundId}/sides/${unitId}/holes`
|
||||
: `/rounds/${roundId}/participants/${unitId}/holes`
|
||||
let cancelled = false
|
||||
|
|
@ -1140,12 +1208,14 @@ export function RoundScorecard({
|
|||
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||
const isSkins = round.play_format === "skins"
|
||||
const isScrambleSolo = SCRAMBLE_SOLO_FORMATS.has(round.play_format)
|
||||
const formatLabel = FORMAT_LABELS[round.play_format] ?? round.play_format
|
||||
|
||||
// Delt-ball-formater trenger sidene opprettet (via runde-siden sin
|
||||
// "Spillere og runde"-fane) FØR noe scorekort gir mening her -- vis en
|
||||
// forklarende melding i stedet for en evig lastespinner.
|
||||
if (isSharedBall && round.sides.length < 2) {
|
||||
// Delt-ball-formater OG scramble_solo trenger sidene opprettet (via
|
||||
// runde-siden sin "Spillere og runde"-fane) FØR noe scorekort gir
|
||||
// mening her -- vis en forklarende melding i stedet for en evig
|
||||
// lastespinner.
|
||||
if ((isSharedBall || isScrambleSolo) && round.sides.length < 2) {
|
||||
return (
|
||||
<div className={cn(!embedded && "min-h-dvh bg-background")}>
|
||||
{!embedded && (
|
||||
|
|
@ -1163,15 +1233,33 @@ export function RoundScorecard({
|
|||
)}
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
{formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve
|
||||
runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.
|
||||
{isScrambleSolo
|
||||
? `${formatLabel} krever at både laget og individualspilleren er satt opp som egne sider. Opprett dem på selve runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.`
|
||||
: `${formatLabel} spilles med to sider (ett felles slagtall per side). Opprett sidene på selve runde-siden (fanen "Spillere og runde") før scorekortet kan vises her.`}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const units: { id: string; label: string }[] = isSharedBall
|
||||
// scramble_solo (migrasjon 056) -- blandet enhetsliste: laget er en
|
||||
// SIDE-fane (delt ball), individualspilleren er en PARTICIPANT-fane
|
||||
// (egen ball) -- IKKE lagt til SHARED_BALL_FORMATS, det ville feilaktig
|
||||
// behandlet BEGGE fanene som sider (se hull-hente-effekten over, som
|
||||
// slår opp faktisk type fra unitId).
|
||||
const units: { id: string; label: string }[] = isScrambleSolo
|
||||
? (() => {
|
||||
const teamSide = round.sides.find((s) => s.side_role === "team")
|
||||
const individualSide = round.sides.find((s) => s.side_role === "individual")
|
||||
const individualParticipant = individualSide
|
||||
? round.participants.find((p) => p.round_side_id === individualSide.id)
|
||||
: undefined
|
||||
const list: { id: string; label: string }[] = []
|
||||
if (teamSide) list.push({ id: teamSide.id, label: teamSide.label?.trim() || "Laget" })
|
||||
if (individualParticipant) list.push({ id: individualParticipant.id, label: participantLabel(individualParticipant, viewerId) })
|
||||
return list
|
||||
})()
|
||||
: isSharedBall
|
||||
? round.sides.map((s, idx) => ({ id: s.id, label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B") }))
|
||||
: round.participants.map((p) => ({ id: p.id, label: participantLabel(p, viewerId) }))
|
||||
|
||||
|
|
@ -1270,7 +1358,7 @@ export function RoundScorecard({
|
|||
|
||||
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */}
|
||||
{units.length > 1 && (
|
||||
<div role="tablist" aria-label={isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
|
||||
<div role="tablist" aria-label={isScrambleSolo ? "Velg lag eller motstander" : isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
|
||||
<div className="flex gap-2">
|
||||
{units.map((u) => {
|
||||
const active = u.id === unitId
|
||||
|
|
|
|||
|
|
@ -407,6 +407,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
|||
scramble_2: "Scramble (2)",
|
||||
scramble_4: "Scramble (4)",
|
||||
chapman: "Chapman",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
|
||||
type ApiHole = {
|
||||
|
|
|
|||
350
frontend/components/scramble-solo-result.tsx
Normal file
350
frontend/components/scramble-solo-result.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"use client"
|
||||
|
||||
// Resultatvisning for "Scramble mot enkeltspiller" (migrasjon 056,
|
||||
// 2026-08-05) -- ren V0-eksport (zip 31), uendret fra levert komponent.
|
||||
// Adapter fra det generiske API-svaret (ApiFormatResult) til denne
|
||||
// komponentens ScrambleSoloResult-props ligger i round-leaderboard.tsx
|
||||
// (buildScrambleSoloResult) og round-detail.tsx (lokal kopi, samme navn).
|
||||
|
||||
import { Check, Flag, Minus } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data shape (matches the API response this component is built around)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScrambleSoloHole {
|
||||
holeNumber: number
|
||||
par: number
|
||||
strokeIndex: number
|
||||
result: "a" | "b" | "halved" // "a" = team won hole, "b" = individual won, "halved" = tied
|
||||
team: { gross: number; net: number; strokesReceived: number }
|
||||
individual: { gross: number; net: number; strokesReceived: number }
|
||||
}
|
||||
|
||||
export interface ScrambleSoloResult {
|
||||
ready: boolean
|
||||
teamLabel: string
|
||||
individualLabel: string
|
||||
teamNet: number | null
|
||||
individualNet: number | null
|
||||
margin: number | null // individualNet - teamNet; positive = team leads, negative = individual leads, 0 = tied
|
||||
holes: ScrambleSoloHole[]
|
||||
}
|
||||
|
||||
type Leader = "team" | "individual" | "tie"
|
||||
|
||||
function leaderFromMargin(margin: number | null): Leader {
|
||||
if (margin === null) return "tie"
|
||||
if (margin > 0) return "team"
|
||||
if (margin < 0) return "individual"
|
||||
return "tie"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ScrambleSoloResultView({ result }: { result: ScrambleSoloResult }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4">
|
||||
{result.ready ? (
|
||||
<>
|
||||
<HeadlineCard result={result} />
|
||||
<HoleLedger result={result} />
|
||||
</>
|
||||
) : (
|
||||
<WaitingState />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Waiting state ---------------------------------------------------------
|
||||
|
||||
function WaitingState() {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col items-center gap-3 rounded-3xl border border-border bg-card p-8 text-center shadow-md shadow-black/8"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Flag aria-hidden="true" className="size-7" />
|
||||
</span>
|
||||
<h2 className="text-xl font-bold text-foreground text-balance">
|
||||
Venter på at oppsettet blir fullført
|
||||
</h2>
|
||||
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
||||
Resultatet vises så snart handicap og lag er ferdig satt opp for denne runden.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Headline card ---------------------------------------------------------
|
||||
|
||||
function HeadlineCard({ result }: { result: ScrambleSoloResult }) {
|
||||
const leader = leaderFromMargin(result.margin)
|
||||
const strokes = result.margin === null ? 0 : Math.abs(result.margin)
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
||||
<MarginBanner leader={leader} strokes={strokes} result={result} />
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2">
|
||||
<SideTotal
|
||||
label={result.teamLabel}
|
||||
kind="team"
|
||||
net={result.teamNet}
|
||||
leading={leader === "team"}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">mot</span>
|
||||
</div>
|
||||
<SideTotal
|
||||
label={result.individualLabel}
|
||||
kind="individual"
|
||||
net={result.individualNet}
|
||||
leading={leader === "individual"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MarginBanner({
|
||||
leader,
|
||||
strokes,
|
||||
result,
|
||||
}: {
|
||||
leader: Leader
|
||||
strokes: number
|
||||
result: ScrambleSoloResult
|
||||
}) {
|
||||
let text: string
|
||||
let tone: "team" | "individual" | "tie"
|
||||
|
||||
if (leader === "tie") {
|
||||
text = "Uavgjort"
|
||||
tone = "tie"
|
||||
} else if (leader === "team") {
|
||||
text = `${result.teamLabel} leder med ${strokes} ${strokes === 1 ? "slag" : "slag"}`
|
||||
tone = "team"
|
||||
} else {
|
||||
text = `${result.individualLabel} leder med ${strokes} slag`
|
||||
tone = "individual"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-center",
|
||||
tone === "team" && "border-primary/40 bg-primary/10",
|
||||
tone === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
||||
tone === "tie" && "border-border bg-muted/60",
|
||||
)}
|
||||
aria-live="polite"
|
||||
>
|
||||
{tone === "tie" ? (
|
||||
<Minus aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
tone === "team" ? "text-primary" : "text-brand-orange",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-lg font-extrabold text-balance",
|
||||
tone === "team" && "text-primary",
|
||||
tone === "individual" && "text-brand-orange",
|
||||
tone === "tie" && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SideTotal({
|
||||
label,
|
||||
kind,
|
||||
net,
|
||||
leading,
|
||||
}: {
|
||||
label: string
|
||||
kind: "team" | "individual"
|
||||
net: number | null
|
||||
leading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-2xl border p-4 text-center transition-colors",
|
||||
leading && kind === "team" && "border-primary/40 bg-primary/10",
|
||||
leading && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
||||
!leading && "border-border bg-card",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-2 text-base font-bold leading-tight text-pretty",
|
||||
leading && kind === "team" && "text-primary",
|
||||
leading && kind === "individual" && "text-brand-orange",
|
||||
!leading && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-5xl font-extrabold tabular-nums",
|
||||
leading && kind === "team" && "text-primary",
|
||||
leading && kind === "individual" && "text-brand-orange",
|
||||
!leading && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{net ?? "–"}
|
||||
</span>
|
||||
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
netto
|
||||
</span>
|
||||
{leading && (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
kind === "team" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
|
||||
)}
|
||||
>
|
||||
Leder
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Hole-by-hole ledger ---------------------------------------------------
|
||||
|
||||
function HoleLedger({ result }: { result: ScrambleSoloResult }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-baseline justify-between gap-3 px-1">
|
||||
<h2 className="text-lg font-bold text-foreground">Hull for hull</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{result.holes.length} {result.holes.length === 1 ? "hull spilt" : "hull spilt"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column legend so the two net columns are unambiguous */}
|
||||
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 px-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="w-14">Hull</span>
|
||||
<span className="truncate text-right">{result.teamLabel}</span>
|
||||
<span className="truncate text-right">{result.individualLabel}</span>
|
||||
</div>
|
||||
|
||||
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||
{result.holes.map((hole) => (
|
||||
<HoleRow key={hole.holeNumber} hole={hole} />
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function HoleRow({ hole }: { hole: ScrambleSoloHole }) {
|
||||
return (
|
||||
<li className="grid grid-cols-[auto_1fr_1fr] items-stretch gap-2 p-3">
|
||||
{/* Hole meta */}
|
||||
<div className="flex w-14 flex-col justify-center">
|
||||
<span className="text-lg font-extrabold tabular-nums text-foreground">{hole.holeNumber}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Par {hole.par}
|
||||
<span className="sr-only">, </span> · idx {hole.strokeIndex}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SideCell
|
||||
gross={hole.team.gross}
|
||||
net={hole.team.net}
|
||||
received={hole.team.strokesReceived}
|
||||
won={hole.result === "a"}
|
||||
halved={hole.result === "halved"}
|
||||
kind="team"
|
||||
/>
|
||||
<SideCell
|
||||
gross={hole.individual.gross}
|
||||
net={hole.individual.net}
|
||||
received={hole.individual.strokesReceived}
|
||||
won={hole.result === "b"}
|
||||
halved={hole.result === "halved"}
|
||||
kind="individual"
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function SideCell({
|
||||
gross,
|
||||
net,
|
||||
received,
|
||||
won,
|
||||
halved,
|
||||
kind,
|
||||
}: {
|
||||
gross: number
|
||||
net: number
|
||||
received: number
|
||||
won: boolean
|
||||
halved: boolean
|
||||
kind: "team" | "individual"
|
||||
}) {
|
||||
const wonLabel = won
|
||||
? kind === "team"
|
||||
? "Laget vant hullet"
|
||||
: "Utfordreren vant hullet"
|
||||
: halved
|
||||
? "Delt hull"
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-end justify-center gap-0.5 rounded-xl border px-3 py-2",
|
||||
won && kind === "team" && "border-primary/40 bg-primary/10",
|
||||
won && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
||||
!won && halved && "border-dashed border-border bg-muted/40",
|
||||
!won && !halved && "border-transparent bg-transparent",
|
||||
)}
|
||||
>
|
||||
{/* Net (the number that decides the hole) — largest, bold */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{won && (
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className={cn("size-4 shrink-0", kind === "team" ? "text-primary" : "text-brand-orange")}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-2xl font-extrabold tabular-nums leading-none",
|
||||
won && kind === "team" && "text-primary",
|
||||
won && kind === "individual" && "text-brand-orange",
|
||||
!won && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{net}
|
||||
</span>
|
||||
</div>
|
||||
{/* Gross + strokes received context */}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Brutto {gross}
|
||||
{received > 0 && <> · {received} slag</>}
|
||||
</span>
|
||||
{wonLabel && <span className="sr-only">{wonLabel}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -95,6 +95,8 @@ const FORMAT_LABELS: Record<string, string> = {
|
|||
shamble: "Shamble",
|
||||
money_ball: "Money Ball",
|
||||
high_low_high: "High-low-high",
|
||||
scramble_solo: "Scramble mot enkeltspiller",
|
||||
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
|
||||
}
|
||||
const TWO_SIDED_FORMATS = new Set([
|
||||
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -397,6 +397,23 @@ class RankedSplit:
|
|||
return round_half_up(total)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TeamAverage:
|
||||
"""Enkelt gjennomsnitt av ALLE lagmedlemmers course handicap, avrundet.
|
||||
|
||||
Brukt for `scramble_solo` (scramble-lag mot enkeltspiller, variabel
|
||||
lagstørrelse N>=2) -- bevisst UAVHENGIG av RankedSplit (scramble_2/
|
||||
scramble_4 sine faste 35/15-/25/20/15/10-tabeller), som forutsetter
|
||||
FAST lagstørrelse og ikke er kildebelagt for vilkårlig N. Ingen
|
||||
rangering/vekting her, rent snitt -- bekreftet med bruker 2026-08-05
|
||||
som en bevisst ny, enkel TeeCup-regel, ikke hentet fra HCP-kildene.
|
||||
"""
|
||||
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
||||
if len(course_handicaps) < 2:
|
||||
raise ValueError("TeamAverage forventer minst to spillere")
|
||||
return round_half_up(sum(course_handicaps) / len(course_handicaps))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format-register (STANDARDVERDIER — kan overstyres per turnering)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -536,6 +553,19 @@ def stableford_total(pars: Sequence[int], gross_strokes: Sequence[int], strokes_
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# To-sidet netto-slagspill-sammenligning (scramble_solo): "laveste totalsum
|
||||
# vinner", INGEN hull-for-hull-tilstand -- eksplisitt SØSKEN til, ikke del
|
||||
# av, match-status-seksjonen under (som er for ekte match play). Hver sides
|
||||
# nettosum beregnes med de allerede eksisterende allocate_strokes_by_index/
|
||||
# stroke_play_net_total over -- ingen ny funksjon trengs for selve summen.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def net_stroke_play_margin(net_total_a: int, net_total_b: int) -> int:
|
||||
"""Positiv = A leder (A har LAVEST nettosum), negativ = B leder, 0 = delt."""
|
||||
return net_total_b - net_total_a
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Match-status ("2 UP", "dormie", "3&2", "AS")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
69
teecup-beskrivelse.md
Normal file
69
teecup-beskrivelse.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# TeeCup — hva det er, og hva det skal bli
|
||||
|
||||
## Kort sagt
|
||||
|
||||
TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget
|
||||
for golfklubber, bedrifter og vennegjenger som spiller sammen. Navnet
|
||||
smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup"
|
||||
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en
|
||||
konkurransedyktig runde, i ett ord.
|
||||
|
||||
## Hvor det startet
|
||||
|
||||
TeeCup begynte som en avlegger av teeoff.no (banedata/booking), bygget
|
||||
rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull.
|
||||
Det har siden vokst til en selvstendig, kommersiell produkt med egen
|
||||
identitet (eget domene, teecup.golf, egen innlogging uavhengig av
|
||||
teeoff) — solgt til klubber og bedrifter som kanskje aldri har hørt om
|
||||
moderselskapet.
|
||||
|
||||
## Hva det er i dag
|
||||
|
||||
- **Ni turneringsformater** og voksende: fra vanlig slagspill og
|
||||
Stableford, via klassiske to-mot-to-formater (Fourball, Foursome,
|
||||
Greensome, Scramble), til mer eksotiske varianter som Chapman,
|
||||
Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high
|
||||
— og nyeste tilskudd: et scramble-lag mot én enkeltspiller.
|
||||
- **Ekte, live scoreføring** — spillere fører slag hull for hull fra
|
||||
telefonen sin, ute på banen. Leaderboard oppdaterer seg i sanntid.
|
||||
- **Ekte handicap-system** — full WHS-indeksberegning (Score
|
||||
Differential, Net Double Bogey, capping), ikke en forenklet
|
||||
tilnærming.
|
||||
- **Turneringer over tid** — individuelle turneringer over flere
|
||||
runder, og en sesong-sammenlagt rangering (Order of Merit) på tvers
|
||||
av flere arrangementer.
|
||||
- **Sosialt lag** — venner, delte runder, følg-live for tilskuere,
|
||||
personlig spillerprofil og historikk.
|
||||
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
|
||||
med push-varsler.
|
||||
|
||||
## Personlighet
|
||||
|
||||
Varm og energisk — klubbhus-stemning, ikke countryklubb-snobberi.
|
||||
Vennlig og tilgjengelig fremfor bedriftsaktig eller luksuriøs. Trygg og
|
||||
tydelig, men lekent — bygget for folk som spiller golf med venner en
|
||||
lørdag, ikke bare turneringsproffer. Appen er dessuten bevisst bygget
|
||||
for å være lesbar og betjenbar for noen med noe redusert syn, uten
|
||||
briller — stor skrift, god kontrast, store trykkflater. Det skal gjerne
|
||||
gjenspeiles i hvor ryddig og lesbar en logo er, selv i liten størrelse.
|
||||
|
||||
## Hva det skal bli
|
||||
|
||||
TeeCup fortsetter å utvide hvor mange måter man kan spille turnering på
|
||||
— flere formater legges til etter hvert som brukere ber om dem, inkl.
|
||||
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
|
||||
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda
|
||||
mer av det en golfklubb faktisk trenger for å drive turneringer — ikke
|
||||
bare scoreføring, men hele reisen fra oppsett til sesongrangering.
|
||||
Målet er å være det naturlige valget for klubber og bedrifter som vil
|
||||
ha noe raskere og triveligere enn tunge, gamle turneringssystemer.
|
||||
|
||||
## Praktisk for logoen
|
||||
|
||||
- Farge-arven: frisk grønn (#8bc24a) og en varm oransje (#ff5722) er de
|
||||
to kjernefargene i produktet i dag — nyttig referanse, ikke en
|
||||
tvangstrøye for selve merket.
|
||||
- Skal IKKE minne om eller gjenbruke teeoff.no sin logo — TeeCup er et
|
||||
eget, selvstendig produkt.
|
||||
- Må fungere som et lite app-ikon (hjemskjerm/favicon) like godt som en
|
||||
ordmerke — les godt i miniatyr, ikke bare i stort format.
|
||||
|
|
@ -19,6 +19,7 @@ from handicap_engine import (
|
|||
CombinedPercentage,
|
||||
WeightedLowHigh,
|
||||
RankedSplit,
|
||||
TeamAverage,
|
||||
DEFAULT_MATCHPLAY_ALLOWANCES,
|
||||
adjusted_gross_score,
|
||||
allocate_strokes_by_index,
|
||||
|
|
@ -32,6 +33,7 @@ from handicap_engine import (
|
|||
handicap_index_from_differentials,
|
||||
low_handicap_index,
|
||||
match_play_strokes,
|
||||
net_stroke_play_margin,
|
||||
compute_skins,
|
||||
compute_skins_detail,
|
||||
copenhagen_points_for_hole,
|
||||
|
|
@ -176,6 +178,39 @@ def test_scramble_2_ranked_split():
|
|||
assert unit_playing_handicap(players, strat) == 5
|
||||
|
||||
|
||||
def test_team_average_two_players():
|
||||
# CH 6 og 18 -> (6+18)/2 = 12
|
||||
players = [Player("A", 18.0, 113, 72.0, 72), Player("B", 6.0, 113, 72.0, 72)]
|
||||
assert unit_playing_handicap(players, TeamAverage()) == 12
|
||||
|
||||
|
||||
def test_team_average_three_players_rounds_half_up():
|
||||
# CH 8, 14, 20 -> (8+14+20)/3 = 14,0 -> 14
|
||||
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([8, 14, 20])]
|
||||
assert unit_playing_handicap(players, TeamAverage()) == 14
|
||||
|
||||
|
||||
def test_team_average_five_players_rounds_up_at_half():
|
||||
# CH 5, 6, 7, 8, 9 -> sum 35 / 5 = 7,0 -> 7. Rekkefølge irrelevant.
|
||||
players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([9, 5, 8, 6, 7])]
|
||||
assert unit_playing_handicap(players, TeamAverage()) == 7
|
||||
|
||||
|
||||
def test_team_average_rounding_half_up_boundary():
|
||||
# CH 4 og 5 -> 4,5 -> 5 (halvveis alltid opp, samme regel som round_half_up)
|
||||
players = [Player("A", 4.0, 113, 72.0, 72), Player("B", 5.0, 113, 72.0, 72)]
|
||||
assert unit_playing_handicap(players, TeamAverage()) == 5
|
||||
|
||||
|
||||
def test_team_average_rejects_single_player():
|
||||
try:
|
||||
TeamAverage().playing_handicap([10.0])
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("TeamAverage skulle avvist én spiller")
|
||||
|
||||
|
||||
def test_allowance_is_configurable_not_hardcoded():
|
||||
"""ADR-005: motoren skal godta en overstyrt allowance (f.eks. 75 %/3/4)."""
|
||||
strat_75 = PerPlayerPercentage(0.75)
|
||||
|
|
@ -319,6 +354,25 @@ def test_match_state_side_b_leads():
|
|||
assert state.describe() == "1 UP (B)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Netto-slagspill-sammenligning mellom to sider (scramble_solo) -- SØSKEN
|
||||
# til match-status over, ikke en del av den: ren totalsum-sammenligning,
|
||||
# ingen hull-for-hull-tilstand.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_net_stroke_play_margin_a_wins():
|
||||
# A har lavest nettosum -> A leder.
|
||||
assert net_stroke_play_margin(net_total_a=68, net_total_b=72) == 4
|
||||
|
||||
|
||||
def test_net_stroke_play_margin_b_wins():
|
||||
assert net_stroke_play_margin(net_total_a=75, net_total_b=70) == -5
|
||||
|
||||
|
||||
def test_net_stroke_play_margin_tie():
|
||||
assert net_stroke_play_margin(net_total_a=71, net_total_b=71) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HCP-indeksberegning fra spilte runder (ADR-033)
|
||||
#
|
||||
|
|
|
|||
Loading…
Reference in a new issue