Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline. Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays. Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren. Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene. Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
This commit is contained in:
parent
b21609915d
commit
071874e8c8
10 changed files with 1257 additions and 112 deletions
155
CLAUDE.md
155
CLAUDE.md
|
|
@ -4327,6 +4327,141 @@ Ferdig og verifisert:
|
||||||
migrasjon (ren frontend), `docker compose up -d --build
|
migrasjon (ren frontend), `docker compose up -d --build
|
||||||
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
||||||
→ 200, `teeoff.no` upåvirket.
|
→ 200, `teeoff.no` upåvirket.
|
||||||
|
- **Scorekort for spillformater (match/skins/fourball/foursome/greensome/
|
||||||
|
scramble): to reelle bugs bekreftet og fikset, BYGGET, SCRATCH-/
|
||||||
|
BROWSERVERIFISERT OG LIVE (2026-07-28):** brukeren ba om at scorekortene
|
||||||
|
faktisk sjekkes ved å simulere 3-4 spilte hull i hvert spillformat, med
|
||||||
|
et konkret forventningsbilde (en match bør vise hvem som vant hvilket
|
||||||
|
hull og HVORFOR -- brutto vs. netto, match-hcp). Simulert systematisk mot
|
||||||
|
en isolert scratch-backend (Python/urllib-testskript) FØR noe ble antatt
|
||||||
|
riktig -- fant to distinkte, bekreftede problemer, presentert til
|
||||||
|
brukeren som fikk velge omfang (AskUserQuestion) og valgte full løsning:
|
||||||
|
1. **Ekte blokkerende bug:** foursome/greensome/scramble (ADR-039,
|
||||||
|
delt-ball -- score lagres PER SIDE, `round_hole.round_participant_id`
|
||||||
|
settes ALDRI for disse) viste 0 spilte hull/ingen score på
|
||||||
|
`/my-rounds/[id]/scorecard`, `/leaderboard` OG `/stats`, uansett
|
||||||
|
faktisk fremdrift -- disse tre sidene spurte alle kun mot
|
||||||
|
deltaker-endepunktet (`round_participant_id`), som strukturelt aldri
|
||||||
|
kan ha data for disse formatene.
|
||||||
|
2. **Reell designmangel:** for match/fourball/skins var rå brutto/netto/
|
||||||
|
stableford riktig, men "hvem vant hvilket hull, og hvorfor" fantes
|
||||||
|
KUN i `FormatResultPanel` (manage-fanen i `round-detail.tsx`) som et
|
||||||
|
rent vinn/tap-merke -- ingen synlige tall (brutto vs. netto, slag
|
||||||
|
mottatt) noe sted, og ingenting av dette på de dedikerte Scorekort-/
|
||||||
|
Leaderboard-sidene brukeren faktisk testet.
|
||||||
|
**Backend:** ny `compute_skins_detail()` i `handicap_engine.py`
|
||||||
|
(hull-for-hull-forløp -- verdier/pott-før/tildelt/carried per hull),
|
||||||
|
`compute_skins()` omskrevet til en tynn wrapper rundt den (uendret
|
||||||
|
signatur/oppførsel, alle 50 eksisterende tester fortsatt grønne + 5 nye).
|
||||||
|
`GET /rounds/{id}/format-result` (ADR-039) utvidet med et nytt
|
||||||
|
`holes`-felt -- full hull-for-hull-oppløsning (brutto/netto/slag mottatt
|
||||||
|
PER enhet PER hull, pluss for fourball hvilken av de to partnernes netto
|
||||||
|
som faktisk talte for siden det hullet, R&A-regelen gjort synlig i
|
||||||
|
stedet for skjult). **Reell refactor-bug funnet OG fikset UNDER egen
|
||||||
|
scratch-verifisering, før noe ble stolt på:** en samlet `side_net()` for
|
||||||
|
BEGGE gren-typene (individuell-ball og delt-ball) brukte format-nivåets
|
||||||
|
`expected_players` (spiller-ANTALL, f.eks. 2 for foursome) som
|
||||||
|
fullstendighetssjekk også for delt-ball, der en side alltid er NØYAKTIG
|
||||||
|
ÉN enhet uansett spillerantall -- ga `match_holes_played=0`/tom
|
||||||
|
`holes`-liste for ALLE delt-ball-formater til tross for korrekt lagrede
|
||||||
|
side-scorer. Rettet med en egen `required_units`-variabel (1 for
|
||||||
|
delt-ball, `expected_players` for individuell-ball). `GET .../sides/
|
||||||
|
{id}/holes` fikk samtidig et nytt `strokes_received`-felt (samme
|
||||||
|
allokeringsalgoritme, nå basert på sidens kombinerte `playing_handicap`).
|
||||||
|
**Frontend:** `round-scorecard.tsx` bruker nå SIDER (ikke deltakere) som
|
||||||
|
"enhet" for delt-ball-formater -- samme visuelle `ScoreBlock`-tabell,
|
||||||
|
bare mot `/sides/{id}/holes`, med en forklarende melding hvis sidene
|
||||||
|
ikke er opprettet ennå. Ny `MatchProgressTable`-seksjon (to-sidede
|
||||||
|
formater: Hull/Par/Side A/Side B/Resultat, brutto→netto per enhet, ikke-
|
||||||
|
tellende fourball-partner tonet ned i stedet for fjernet) og
|
||||||
|
`SkinsProgressTable` (skins: hull-for-hull med hvem som vant/hvilket
|
||||||
|
hull som rullet videre, netto med brutto i parentes). `round-
|
||||||
|
leaderboard.tsx`: to-sidede formater viser nå en `MatchStatusSection`
|
||||||
|
(status + hull-merker + lenke til scorekortets fulle oppløsning) i
|
||||||
|
stedet for en individuell rangering som uansett ikke gir mening for et
|
||||||
|
1v1/lag-format (og alltid var tom for delt-ball) -- `RoundLeaderboardMini`
|
||||||
|
returnerer `null` for disse formatene i `round-detail.tsx` sin manage-
|
||||||
|
fane, siden `FormatResultPanel` allerede dekker akkurat det der. `round-
|
||||||
|
stats.tsx` viser en tydelig forklarende melding for delt-ball-formater
|
||||||
|
(individuell slag-for-slag-statistikk er strukturelt umulig der) i
|
||||||
|
stedet for en stille tom/misvisende side.
|
||||||
|
**Scratch-verifisert grundig, flere lag:** isolert `teecup_scratch`-
|
||||||
|
database + `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
|
||||||
|
API-container (samme mønster som hele prosjektet), 55/55
|
||||||
|
`handicap_engine`-tester, et Python/urllib-simuleringsskript som spilte
|
||||||
|
4 hull i alle 6 ikke-trivielle formater og sammenlignet rå API-svar før/
|
||||||
|
etter fiksen. **Deretter en FULL, ekte nettleser-gjennomgang** (Chrome
|
||||||
|
DevTools MCP, engangs `next dev` mot scratch-backend, ekte innlogging):
|
||||||
|
opprettet alle 7 formatene (inkl. `stroke` som regresjonssjekk) via
|
||||||
|
ekte API-kall fra en innlogget nettleserøkt, besøkte deretter
|
||||||
|
scorekort/leaderboard/stats-sidene for hver -- foursome sitt tidligere
|
||||||
|
BLANKE scorekort viste nå korrekte side-tabs + reelle score + riktig
|
||||||
|
`Matchforløp`-tabell; fourball sin `Matchforløp` viste presist BEGGE
|
||||||
|
partnernes brutto/netto med den ikke-tellende partneren korrekt tonet
|
||||||
|
ned; skins sin hull-for-hull-tabell viste riktig vinner-navn og
|
||||||
|
"Uavgjort — rullet videre" nøyaktig der forventet; leaderboardets
|
||||||
|
matchstatus stemte hull-for-hull med scorekortets egen utregning (bevisst
|
||||||
|
kryssjekket for hånd); fanebytte mellom sider (Side A/Side B) bekreftet
|
||||||
|
å faktisk refetche og re-rendre riktig data. Ekte typesjekket +
|
||||||
|
produksjonsbuild (alle 24 ruter) kjørt både før og etter refactor-bug-
|
||||||
|
fiksen. `test_isolation.sql` uendret (ingen migrasjon, ren kode-endring).
|
||||||
|
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: ingen
|
||||||
|
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`.
|
||||||
|
Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||||
|
upåvirket.
|
||||||
|
- **Offline-kø (ADR-028) utvidet til frittstående runder, BYGGET,
|
||||||
|
BROWSERVERIFISERT OG LIVE (2026-07-28):** brukeren ba om det som ble
|
||||||
|
identifisert som viktigste gjenstående hull etter forrige runde --
|
||||||
|
ADR-028s offline-skrivekø var kun koblet til turnering-scorekortet
|
||||||
|
(`session-scorecard.tsx`), IKKE frittstående runder
|
||||||
|
(`round-detail.tsx`), nettopp der man oftest står alene ute på banen
|
||||||
|
uten dekning, og nå som frittstående runder er bekreftet appens
|
||||||
|
hovedfokus var dette et reelt hull i kjerneflyten.
|
||||||
|
**Enklere port enn originalen, ikke bare en kopi:** turnering-
|
||||||
|
scorekortets PATCH-endepunkter er en append-historie (krever egne
|
||||||
|
`pendingStrokes`/`pendingResults`-verdi-overlays for å vise queued
|
||||||
|
verdier). Frittstående runders `PATCH .../participants/{id}/holes/{n}`
|
||||||
|
og `PATCH .../sides/{id}/holes/{n}` erstatter derimot HELE hull-raden
|
||||||
|
per kall, og `statToPatchBody()` sine feltnavn matcher `ApiHole` 1:1 --
|
||||||
|
en køet skriving kan dermed speiles direkte inn i
|
||||||
|
`holesByParticipant`/`holesBySide` med en enkel `{...h, ...body}`-spread,
|
||||||
|
ingen egen verdi-overlay nødvendig. Kun to lette `Set<string>` (nøkkel
|
||||||
|
`id:hullnummer`) beholdt, utelukkende til selve "lagret lokalt"-
|
||||||
|
indikatoren i veiviseren.
|
||||||
|
Samme kø/synk-mønster som originalen ellers: `navigator.onLine`-sjekk
|
||||||
|
FØR forsøk, `try/catch` rundt selve fetch-kallet som queuer ved en EKTE
|
||||||
|
nettverksfeil (ikke ved et avvist HTTP-svar -- det vises fortsatt som
|
||||||
|
vanlig feiltekst), auto-synk ved `window`s `online`-event, manuell
|
||||||
|
"Synkroniser nå"-knapp, og en engangs-sjekk for allerede køede
|
||||||
|
skrivinger fra en TIDLIGERE økt (f.eks. siden ble lukket mens offline)
|
||||||
|
ved mount. `flushPending()` matcher URL-mønsteret på hver synkronisert
|
||||||
|
kø-oppføring (`/participants/{id}/holes/{n}` vs. `/sides/{id}/holes/
|
||||||
|
{n}`) for å vite hvilke deltakere/sider som trenger en ekte refetch
|
||||||
|
etterpå (reconciles bl.a. `strokes_received`, som den optimistiske
|
||||||
|
speilingen ikke kan regne ut selv). Banner ("Du er offline"/"N
|
||||||
|
endringer venter") lagt til rett under fane-velgeren, synlig uansett
|
||||||
|
fane. "Lagret lokalt · venter på synk"-indikator lagt til i BÅDE
|
||||||
|
`ScoringWizard` (vanlig scoring) og `SideScoreWizard` (delt-ball).
|
||||||
|
**Browserverifisert grundig, IKKE bare kodegjennomgang/build denne
|
||||||
|
gangen** (i motsetning til den opprinnelige ADR-028-runden, som
|
||||||
|
brukeren selv måtte teste manuelt siden intet nettleserverktøy var
|
||||||
|
tilgjengelig da) -- Chrome DevTools MCP sin ekte nettverks-emulering
|
||||||
|
(`Offline`, bekreftet at `navigator.onLine` faktisk flippet til
|
||||||
|
`false`) mot en isolert scratch-backend: registrerte slag+putter
|
||||||
|
offline for en vanlig runde -- bekreftet 2 kø-oppføringer i ekte
|
||||||
|
IndexedDB, optimistisk oppdatert scorekort-grid, "1 endring venter"-
|
||||||
|
banner, "Lagret lokalt"-indikator i veiviseren; koblet til nett igjen
|
||||||
|
-- bekreftet AUTOMATISK synk (ingen manuelt trykk), kø tom etterpå, OG
|
||||||
|
et direkte API-kall som bekreftet serveren faktisk hadde de riktige
|
||||||
|
verdiene (score=5, putts=1, strokes_received=1). Gjentok hele syklusen
|
||||||
|
for en ny foursome-runde via `SideScoreWizard` (delt-ball) -- samme
|
||||||
|
resultat, kø tom og server bekreftet score=4 for siden etterpå. Ingen
|
||||||
|
konsollfeil i noen av rundene. Ekte typesjekket + full produksjonsbuild
|
||||||
|
(alle 24 ruter) kjørt før utrulling.
|
||||||
|
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: ingen
|
||||||
|
migrasjon (ren frontend), `docker compose up -d --build
|
||||||
|
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`
|
||||||
|
→ 200, `teeoff.no` upåvirket.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
|
|
@ -4531,16 +4666,16 @@ Neste steg:
|
||||||
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
|
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
|
||||||
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
||||||
integrering.
|
integrering.
|
||||||
10. **Resterende hull fra 2026-07-28-gjennomgangen av frittstående runder,
|
10. **Resterende hull fra 2026-07-28-gjennomgangen av frittstående runder**
|
||||||
fortsatt IKKE bygget** (det viktigste — faktisk HCP — er tettet, se
|
(det viktigste — faktisk HCP — er tettet, se ADR-038 over; offline-kø,
|
||||||
ADR-038 over): (a) offline-kø (ADR-028) er kun koblet til turnering-
|
punkt (a), er nå OGSÅ tettet, se status 2026-07-28 over — punktet
|
||||||
scorekortet (`session-scorecard.tsx`), IKKE frittstående runder
|
beholdes her kun for historikk): (a) ~~offline-kø (ADR-028) er kun
|
||||||
(`round-detail.tsx`/`ScoringWizard`) — nettopp der man oftest står
|
koblet til turnering-scorekortet~~ — ferdig, browserverifisert og
|
||||||
alene ute på banen uten dekning; (b) ingen Stableford-poengberegning
|
live; (b) ingen Stableford-poengberegning for frittstående runder (kun
|
||||||
for frittstående runder (kun rå slag/differensial), inkl. en uløst
|
rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand;
|
||||||
"plukket opp ballen"-tilstand; (c) rundedeling/visibility (ADR-036
|
(c) rundedeling/visibility (ADR-036 fase 2, public/private/friends)
|
||||||
fase 2, public/private/friends) fortsatt ikke bygget; (d) flere
|
fortsatt ikke bygget; (d) flere flighter i én frittstående runde
|
||||||
flighter i én frittstående runde fortsatt kun drøftet (se
|
fortsatt kun drøftet (se
|
||||||
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
||||||
til rundehendelser ennå.
|
til rundehendelser ennå.
|
||||||
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
|
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -52,7 +52,7 @@ from handicap_engine import (
|
||||||
allocate_strokes_by_index,
|
allocate_strokes_by_index,
|
||||||
apply_index_caps,
|
apply_index_caps,
|
||||||
compute_match_state,
|
compute_match_state,
|
||||||
compute_skins,
|
compute_skins_detail,
|
||||||
course_handicap,
|
course_handicap,
|
||||||
course_handicap_raw,
|
course_handicap_raw,
|
||||||
handicap_index_from_differentials,
|
handicap_index_from_differentials,
|
||||||
|
|
@ -2042,6 +2042,12 @@ class RoundSideHoleOut(BaseModel):
|
||||||
stroke_index: int
|
stroke_index: int
|
||||||
played: bool
|
played: bool
|
||||||
score: int | None
|
score: int | None
|
||||||
|
# Slag mottatt av SIDEN (kombinert playing_handicap, samme for begge
|
||||||
|
# partnere på siden) på dette hullet -- samme allokeringsalgoritme og
|
||||||
|
# samme "kun til visning av netto"-begrunnelse som RoundHoleOut sin
|
||||||
|
# strokes_received. None hvis siden ikke har en beregnet playing
|
||||||
|
# handicap ennå (f.eks. kun én av to partnere tildelt).
|
||||||
|
strokes_received: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
|
@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
|
||||||
|
|
@ -2060,7 +2066,22 @@ async def list_side_holes(
|
||||||
"WHERE round_side_id = $1 ORDER BY hole_number",
|
"WHERE round_side_id = $1 ORDER BY hole_number",
|
||||||
side_id,
|
side_id,
|
||||||
)
|
)
|
||||||
return [RoundSideHoleOut(**dict(r)) for r in rows]
|
playing_handicap = await conn.fetchval(
|
||||||
|
"SELECT playing_handicap FROM round_participant WHERE round_side_id = $1 LIMIT 1", side_id
|
||||||
|
)
|
||||||
|
|
||||||
|
strokes_received_by_hole: dict[int, int] | None = None
|
||||||
|
if playing_handicap is not None:
|
||||||
|
allocation = allocate_strokes_by_index(playing_handicap, [r["stroke_index"] for r in rows])
|
||||||
|
strokes_received_by_hole = {r["hole_number"]: a for r, a in zip(rows, allocation)}
|
||||||
|
|
||||||
|
return [
|
||||||
|
RoundSideHoleOut(
|
||||||
|
**dict(r),
|
||||||
|
strokes_received=strokes_received_by_hole[r["hole_number"]] if strokes_received_by_hole else None,
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SideHoleUpdate(BaseModel):
|
class SideHoleUpdate(BaseModel):
|
||||||
|
|
@ -2105,6 +2126,36 @@ async def update_side_hole(
|
||||||
# scorekort/leaderboard dekker det allerede.
|
# scorekort/leaderboard dekker det allerede.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class FormatHoleEntry(BaseModel):
|
||||||
|
"""Én "enhet" sitt tall på ett hull -- en deltaker for individuell-ball-
|
||||||
|
formater (match/fourball/skins), eller en SIDE for delt-ball-formater
|
||||||
|
(foursome/greensome/scramble, ADR-039 Beslutning C: én score for hele
|
||||||
|
siden, ingen individuell deltaker-rad å vise i stedet)."""
|
||||||
|
|
||||||
|
id: str # participant_id, eller round_side_id for delt-ball
|
||||||
|
label: str
|
||||||
|
side: Literal["a", "b"] | None = None # kun to-sidede formater
|
||||||
|
gross: int | None = None
|
||||||
|
net: int | None = None # None hvis ingen course handicap er klart (eller skins_scoring="gross")
|
||||||
|
strokes_received: int | None = None
|
||||||
|
# Fourball: KUN den ene av de to partnernes netto teller for siden det
|
||||||
|
# hullet (R&A-regelen: laveste netto blant lagets spillere) -- markerer
|
||||||
|
# HVILKEN, slik at "hvorfor vant siden" er synlig uten egen forklarende
|
||||||
|
# tekst. Alltid sann for formater med kun én enhet per side (match,
|
||||||
|
# foursome/greensome/scramble sin ene side-rad).
|
||||||
|
counted: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class FormatHoleOut(BaseModel):
|
||||||
|
hole_number: int
|
||||||
|
par: int
|
||||||
|
stroke_index: int
|
||||||
|
entries: list[FormatHoleEntry]
|
||||||
|
result: str | None = None # to-sidede formater: "a"/"b"/"halved"
|
||||||
|
winner_ids: list[str] | None = None # skins: hvem vant (evt. delte) hullets skin(s)
|
||||||
|
carried: bool | None = None # skins: hullet var uavgjort og potten rullet videre
|
||||||
|
|
||||||
|
|
||||||
class RoundFormatResultOut(BaseModel):
|
class RoundFormatResultOut(BaseModel):
|
||||||
play_format: str
|
play_format: str
|
||||||
ready: bool # False = handicap ikke klart for én eller begge sider ennå
|
ready: bool # False = handicap ikke klart for én eller begge sider ennå
|
||||||
|
|
@ -2118,6 +2169,11 @@ class RoundFormatResultOut(BaseModel):
|
||||||
hole_results: list[str] | None = None # "a"/"b"/"halved" per avgjort hull, i spillerekkefølge
|
hole_results: list[str] | None = None # "a"/"b"/"halved" per avgjort hull, i spillerekkefølge
|
||||||
# Skins:
|
# Skins:
|
||||||
skins_won: dict[str, float] | None = None
|
skins_won: dict[str, float] | 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,
|
||||||
|
# inkl. skins (som ikke har noe eget "ready"-konsept).
|
||||||
|
holes: list[FormatHoleOut] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]:
|
def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]:
|
||||||
|
|
@ -2149,48 +2205,63 @@ async def get_round_format_result(
|
||||||
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
||||||
|
|
||||||
if engine_format in SIDE_IS_UNIT:
|
if engine_format in SIDE_IS_UNIT:
|
||||||
# Delt ball -- én rad per SIDE per hull.
|
# Delt ball -- én rad per SIDE per hull. "Enheten" ER siden,
|
||||||
|
# så label = side.label (fallback "Side A"/"Side B" avgjøres
|
||||||
|
# under, samme sortert-på-id-konvensjon som frontend bruker
|
||||||
|
# for match_status_text sin bokstavelige "(A)"/"(B)").
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score
|
SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score
|
||||||
FROM round_hole rh
|
FROM round_hole rh
|
||||||
JOIN round_side rs ON rs.id = rh.round_side_id
|
JOIN round_side rs ON rs.id = rh.round_side_id
|
||||||
WHERE rs.round_id = $1
|
WHERE rs.round_id = $1
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
)
|
)
|
||||||
|
side_label_rows = await conn.fetch(
|
||||||
|
"SELECT id::text AS id, label FROM round_side WHERE round_id = $1", round_id
|
||||||
|
)
|
||||||
|
unit_label = {r["id"]: r["label"] for r in side_label_rows}
|
||||||
|
unit_to_side: dict[str, str] | None = None
|
||||||
else:
|
else:
|
||||||
# Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv
|
# Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv
|
||||||
# (samme som _relative_strokes_for_round sin nøkkel for
|
# (samme som _relative_strokes_for_round sin nøkkel for
|
||||||
# disse formatene), IKKE siden -- hvilken side hver deltaker
|
# disse formatene). Henter navn+side-tilhørighet i samme
|
||||||
# tilhører slås opp separat via unit_to_side under.
|
# spørring (én rundtur, ikke to).
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score
|
SELECT rp.id::text AS unit, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score
|
||||||
FROM round_hole rh
|
FROM round_hole rh
|
||||||
JOIN round_participant rp ON rp.id = rh.round_participant_id
|
JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||||
WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL
|
WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
)
|
)
|
||||||
|
participant_label_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT rp.id::text AS id, rp.round_side_id::text AS side_id,
|
||||||
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label
|
||||||
|
FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id
|
||||||
|
WHERE rp.round_id = $1
|
||||||
|
""",
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
unit_label = {r["id"]: r["label"] for r in participant_label_rows}
|
||||||
|
unit_to_side = {r["id"]: r["side_id"] for r in participant_label_rows}
|
||||||
|
|
||||||
by_unit_hole: dict[tuple[str, int], list] = {}
|
by_unit_hole: dict[tuple[str, int], list] = {}
|
||||||
stroke_index_by_hole: dict[int, int] = {}
|
stroke_index_by_hole: dict[int, int] = {}
|
||||||
|
par_by_hole: dict[int, int] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r["played"] and r["score"] is not None:
|
if r["played"] and r["score"] is not None:
|
||||||
by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"])
|
by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"])
|
||||||
stroke_index_by_hole[r["hole_number"]] = r["stroke_index"]
|
stroke_index_by_hole[r["hole_number"]] = r["stroke_index"]
|
||||||
|
par_by_hole[r["hole_number"]] = r["par"]
|
||||||
all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None
|
all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None
|
||||||
|
|
||||||
side_ids = sorted(relative.keys()) if engine_format in SIDE_IS_UNIT else None
|
if engine_format in SIDE_IS_UNIT:
|
||||||
if side_ids is None:
|
side_ids = sorted(relative.keys())
|
||||||
# Individuelle enheter er round_participant_id-er -- hent
|
else:
|
||||||
# hvilken side hver enhet faktisk tilhører for å vite "a"/"b".
|
|
||||||
unit_side = await conn.fetch(
|
|
||||||
"SELECT id::text AS id, round_side_id::text AS side_id FROM round_participant WHERE round_id = $1",
|
|
||||||
round_id,
|
|
||||||
)
|
|
||||||
unit_to_side = {r["id"]: r["side_id"] for r in unit_side}
|
|
||||||
side_ids = sorted({s for s in unit_to_side.values() if s is not None})
|
side_ids = sorted({s for s in unit_to_side.values() if s is not None})
|
||||||
|
|
||||||
if len(side_ids) != 2 or all_18_si is None:
|
if len(side_ids) != 2 or all_18_si is None:
|
||||||
|
|
@ -2201,21 +2272,24 @@ async def get_round_format_result(
|
||||||
for unit, v in relative.items()
|
for unit, v in relative.items()
|
||||||
}
|
}
|
||||||
side_a_id, side_b_id = side_ids
|
side_a_id, side_b_id = side_ids
|
||||||
|
side_label_of: dict[str, Literal["a", "b"]] = {side_a_id: "a", side_b_id: "b"}
|
||||||
expected_players = _SIDE_PLAYER_COUNT[play_format]
|
expected_players = _SIDE_PLAYER_COUNT[play_format]
|
||||||
|
|
||||||
def side_net(hole_number: int, side_id: str) -> int | None:
|
def units_for_side(side_id: str) -> list[str]:
|
||||||
if engine_format in SIDE_IS_UNIT:
|
if engine_format in SIDE_IS_UNIT:
|
||||||
scores = by_unit_hole.get((side_id, hole_number), [])
|
return [side_id]
|
||||||
if len(scores) != 1:
|
return [uid for uid, sid in unit_to_side.items() if sid == side_id]
|
||||||
return None
|
|
||||||
per_hole = strokes_per_hole.get(side_id)
|
# Delt ball -- siden ER enheten (nøyaktig én per side), uansett
|
||||||
if per_hole is None:
|
# hvor mange SPILLERE formatet krever. "expected_players" (2 for
|
||||||
return None
|
# foursome/greensome, 2 eller 4 for scramble) er PLAYER-antallet,
|
||||||
return scores[0] - per_hole[hole_number]
|
# ikke UNIT-antallet -- bruk kun for individuell ball, der hver
|
||||||
|
# spiller er sin egen enhet.
|
||||||
|
required_units = 1 if engine_format in SIDE_IS_UNIT else expected_players
|
||||||
|
|
||||||
|
def side_net(hole_number: int, side_id: str) -> int | None:
|
||||||
nets = []
|
nets = []
|
||||||
for unit_id, unit_side_id in unit_to_side.items():
|
for unit_id in units_for_side(side_id):
|
||||||
if unit_side_id != side_id:
|
|
||||||
continue
|
|
||||||
scores = by_unit_hole.get((unit_id, hole_number), [])
|
scores = by_unit_hole.get((unit_id, hole_number), [])
|
||||||
if len(scores) != 1:
|
if len(scores) != 1:
|
||||||
return None
|
return None
|
||||||
|
|
@ -2223,11 +2297,18 @@ async def get_round_format_result(
|
||||||
if per_hole is None:
|
if per_hole is None:
|
||||||
return None
|
return None
|
||||||
nets.append(scores[0] - per_hole[hole_number])
|
nets.append(scores[0] - per_hole[hole_number])
|
||||||
if len(nets) != expected_players:
|
if len(nets) != required_units:
|
||||||
return None
|
return None
|
||||||
return min(nets)
|
return min(nets)
|
||||||
|
|
||||||
hole_results: list[HoleResult] = []
|
hole_results: list[HoleResult] = []
|
||||||
|
# Hull-for-hull-forløp (2026-07-28) -- samme sannhetskilde/
|
||||||
|
# trunkering som hole_results: kun hull som er FERDIG avgjort
|
||||||
|
# (begge sider komplette) tas med, i samme rekkefølge. Et
|
||||||
|
# ufullstendig hull (noen har registrert, andre ikke) vises
|
||||||
|
# ikke her ennå -- samme "vent til begge er ferdig"-prinsipp
|
||||||
|
# som resten av match-motoren.
|
||||||
|
holes: list[FormatHoleOut] = []
|
||||||
for hole_number in play_order:
|
for hole_number in play_order:
|
||||||
net_a = side_net(hole_number, side_a_id)
|
net_a = side_net(hole_number, side_a_id)
|
||||||
net_b = side_net(hole_number, side_b_id)
|
net_b = side_net(hole_number, side_b_id)
|
||||||
|
|
@ -2240,6 +2321,38 @@ async def get_round_format_result(
|
||||||
else:
|
else:
|
||||||
hole_results.append(HoleResult.HALVED)
|
hole_results.append(HoleResult.HALVED)
|
||||||
|
|
||||||
|
entries: list[FormatHoleEntry] = []
|
||||||
|
for side_id, side_net_value in ((side_a_id, net_a), (side_b_id, net_b)):
|
||||||
|
for unit_id in units_for_side(side_id):
|
||||||
|
gross = by_unit_hole[(unit_id, hole_number)][0]
|
||||||
|
strokes = strokes_per_hole[unit_id][hole_number]
|
||||||
|
net = gross - strokes
|
||||||
|
entries.append(
|
||||||
|
FormatHoleEntry(
|
||||||
|
id=unit_id,
|
||||||
|
label=unit_label.get(unit_id, "Ukjent"),
|
||||||
|
side=side_label_of[side_id],
|
||||||
|
gross=gross,
|
||||||
|
net=net,
|
||||||
|
strokes_received=strokes,
|
||||||
|
# Fourball: kun laveste av de to partnernes
|
||||||
|
# netto teller for siden -- markerer HVILKEN,
|
||||||
|
# slik at "hvorfor vant siden" er synlig uten
|
||||||
|
# egen forklarende tekst (R&A-regelen, samme
|
||||||
|
# side_net()-funksjon over).
|
||||||
|
counted=(net == side_net_value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
holes.append(
|
||||||
|
FormatHoleOut(
|
||||||
|
hole_number=hole_number,
|
||||||
|
par=par_by_hole[hole_number],
|
||||||
|
stroke_index=stroke_index_by_hole[hole_number],
|
||||||
|
entries=entries,
|
||||||
|
result=_RESULT_LABEL[hole_results[-1]],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
state = compute_match_state(hole_results, total_holes=round_row["holes_planned"])
|
state = compute_match_state(hole_results, total_holes=round_row["holes_planned"])
|
||||||
return RoundFormatResultOut(
|
return RoundFormatResultOut(
|
||||||
play_format=play_format,
|
play_format=play_format,
|
||||||
|
|
@ -2251,16 +2364,23 @@ async def get_round_format_result(
|
||||||
match_is_dormie=state.is_dormie,
|
match_is_dormie=state.is_dormie,
|
||||||
match_status_text=state.describe(),
|
match_status_text=state.describe(),
|
||||||
hole_results=[_RESULT_LABEL[r] for r in hole_results],
|
hole_results=[_RESULT_LABEL[r] for r in hole_results],
|
||||||
|
holes=holes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if play_format == "skins":
|
if play_format == "skins":
|
||||||
participant_rows = await conn.fetch(
|
participant_rows = await conn.fetch(
|
||||||
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_id = $1",
|
"""
|
||||||
|
SELECT rp.id::text AS id, rp.course_handicap_snapshot,
|
||||||
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label
|
||||||
|
FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id
|
||||||
|
WHERE rp.round_id = $1
|
||||||
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
)
|
)
|
||||||
|
label_by_participant = {p["id"]: p["label"] for p in participant_rows}
|
||||||
hole_rows = await conn.fetch(
|
hole_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id::text AS participant_id, rh.hole_number, rh.stroke_index, rh.played, rh.score
|
SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, rh.stroke_index, rh.played, rh.score
|
||||||
FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id
|
FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||||
WHERE rp.round_id = $1
|
WHERE rp.round_id = $1
|
||||||
ORDER BY rh.hole_number
|
ORDER BY rh.hole_number
|
||||||
|
|
@ -2273,18 +2393,25 @@ async def get_round_format_result(
|
||||||
for r in hole_rows:
|
for r in hole_rows:
|
||||||
by_participant_holes.setdefault(r["participant_id"], []).append(r)
|
by_participant_holes.setdefault(r["participant_id"], []).append(r)
|
||||||
ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows}
|
ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows}
|
||||||
for pid, holes in by_participant_holes.items():
|
for pid, holes_for_participant in by_participant_holes.items():
|
||||||
ch = ch_by_participant.get(pid)
|
ch = ch_by_participant.get(pid)
|
||||||
if ch is None:
|
if ch is None:
|
||||||
continue
|
continue
|
||||||
allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes])
|
allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes_for_participant])
|
||||||
strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes, allocation)}
|
strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes_for_participant, allocation)}
|
||||||
|
|
||||||
|
is_net_mode = round_row["skins_scoring"] == "net"
|
||||||
|
gross_by_hole_number: dict[int, dict[str, int]] = {}
|
||||||
|
par_by_hole: dict[int, int] = {}
|
||||||
|
si_by_hole: dict[int, int] = {}
|
||||||
scores_by_hole_number: dict[int, list[tuple[str, int]]] = {}
|
scores_by_hole_number: dict[int, list[tuple[str, int]]] = {}
|
||||||
for r in hole_rows:
|
for r in hole_rows:
|
||||||
|
par_by_hole[r["hole_number"]] = r["par"]
|
||||||
|
si_by_hole[r["hole_number"]] = r["stroke_index"]
|
||||||
if not r["played"] or r["score"] is None:
|
if not r["played"] or r["score"] is None:
|
||||||
continue
|
continue
|
||||||
if round_row["skins_scoring"] == "net":
|
gross_by_hole_number.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"]
|
||||||
|
if is_net_mode:
|
||||||
per_hole = strokes_by_participant.get(r["participant_id"])
|
per_hole = strokes_by_participant.get(r["participant_id"])
|
||||||
if per_hole is None:
|
if per_hole is None:
|
||||||
continue
|
continue
|
||||||
|
|
@ -2295,8 +2422,37 @@ async def get_round_format_result(
|
||||||
|
|
||||||
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
||||||
scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order]
|
scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order]
|
||||||
skins_won = compute_skins(scores_by_hole, tie_handling=round_row["skins_tie_handling"])
|
skins_won, hole_log = compute_skins_detail(scores_by_hole, tie_handling=round_row["skins_tie_handling"])
|
||||||
return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won)
|
|
||||||
|
# Hull-for-hull-forløp -- kun hull der minst én deltaker faktisk
|
||||||
|
# har registrert en score tas med (et helt tomt hull har
|
||||||
|
# ingenting å vise frem).
|
||||||
|
holes = []
|
||||||
|
for hole_number, log_entry in zip(play_order, hole_log):
|
||||||
|
if not log_entry.values:
|
||||||
|
continue
|
||||||
|
gross_this_hole = gross_by_hole_number.get(hole_number, {})
|
||||||
|
entries = [
|
||||||
|
FormatHoleEntry(
|
||||||
|
id=pid,
|
||||||
|
label=label_by_participant.get(pid, "Ukjent"),
|
||||||
|
gross=gross_this_hole.get(pid),
|
||||||
|
net=value if is_net_mode else None,
|
||||||
|
strokes_received=strokes_by_participant.get(pid, {}).get(hole_number) if is_net_mode else None,
|
||||||
|
)
|
||||||
|
for pid, value in log_entry.values.items()
|
||||||
|
]
|
||||||
|
holes.append(
|
||||||
|
FormatHoleOut(
|
||||||
|
hole_number=hole_number,
|
||||||
|
par=par_by_hole[hole_number],
|
||||||
|
stroke_index=si_by_hole[hole_number],
|
||||||
|
entries=entries,
|
||||||
|
winner_ids=list(log_entry.awarded.keys()),
|
||||||
|
carried=log_entry.carried,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won, holes=holes)
|
||||||
|
|
||||||
return RoundFormatResultOut(play_format=play_format, ready=True)
|
return RoundFormatResultOut(play_format=play_format, ready=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,12 @@ import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Minus,
|
Minus,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
Target,
|
Target,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
WifiOff,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
@ -40,6 +42,7 @@ import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
// --- Types -----------------------------------------------------------------
|
// --- Types -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -387,6 +390,21 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
// SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette.
|
// SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette.
|
||||||
const [wizardSideId, setWizardSideId] = useState<string | null>(null)
|
const [wizardSideId, setWizardSideId] = useState<string | null>(null)
|
||||||
const [holesBySide, setHolesBySide] = useState<Record<string, ApiSideHole[]>>({})
|
const [holesBySide, setHolesBySide] = useState<Record<string, ApiSideHole[]>>({})
|
||||||
|
|
||||||
|
// --- Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) ------------
|
||||||
|
// Samme kø/synk-mønster som turnering-scorekortet (session-scorecard.tsx)
|
||||||
|
// -- portert hit siden frittstående runder er der man oftest står alene
|
||||||
|
// ute på banen uten dekning, og ADR-028s kø tidligere kun var koblet til
|
||||||
|
// turnering-scorekortet. Enklere her: PATCH-endepunktene erstatter HELE
|
||||||
|
// hull-raden per kall (ikke en append-historie), så en køet skriving kan
|
||||||
|
// speiles direkte inn i holesByParticipant/holesBySide -- ingen egen
|
||||||
|
// verdi-overlay nødvendig, kun et sett med nøkler for selve "lagret
|
||||||
|
// lokalt"-indikatoren i veiviseren.
|
||||||
|
const [isOnline, setIsOnline] = useState(true)
|
||||||
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
const [pendingParticipantHoles, setPendingParticipantHoles] = useState<Set<string>>(new Set())
|
||||||
|
const [pendingSideHoles, setPendingSideHoles] = useState<Set<string>>(new Set())
|
||||||
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
|
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
|
||||||
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
|
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
|
||||||
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
|
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
|
||||||
|
|
@ -483,6 +501,122 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
}
|
}
|
||||||
}, [round, holesBySide, loadSideHoles])
|
}, [round, holesBySide, loadSideHoles])
|
||||||
|
|
||||||
|
// --- Offline scoreregistrering, forts. -------------------------------
|
||||||
|
// Køer en PATCH i IndexedDB og speiler den samme kroppen optimistisk inn
|
||||||
|
// i lokal state -- body sine felt matcher ApiHole/ApiSideHole sine
|
||||||
|
// navn 1:1 (se statToPatchBody), så en enkel spread er nok, ingen egen
|
||||||
|
// oversettelse. Serveren får siste ord når synk faktisk skjer (bl.a.
|
||||||
|
// strokes_received kan derfor være utdatert helt til da).
|
||||||
|
async function queueParticipantHoleWrite(
|
||||||
|
participantId: string,
|
||||||
|
holeNumber: number,
|
||||||
|
body: ReturnType<typeof statToPatchBody>,
|
||||||
|
) {
|
||||||
|
const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`
|
||||||
|
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
|
||||||
|
setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`))
|
||||||
|
setPendingCount((c) => c + 1)
|
||||||
|
setHolesByParticipant((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[participantId]: (prev[participantId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queueSideHoleWrite(sideId: string, holeNumber: number, body: { played: boolean; score: number | null }) {
|
||||||
|
const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
|
||||||
|
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
|
||||||
|
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
|
||||||
|
setPendingCount((c) => c + 1)
|
||||||
|
setHolesBySide((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[sideId]: (prev[sideId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender køede skrivinger og henter deltakerens/sidens hull på nytt fra
|
||||||
|
// serveren for hver som faktisk fikk noe synkronisert -- reconciles bl.a.
|
||||||
|
// strokes_received, som den optimistiske speilingen over ikke kan regne
|
||||||
|
// ut selv. Matcher URL-mønsteret for å vite hvilken type/id det gjaldt
|
||||||
|
// (samme kø brukes til begge endepunktstypene).
|
||||||
|
async function flushPending() {
|
||||||
|
if (syncing) return
|
||||||
|
setSyncing(true)
|
||||||
|
try {
|
||||||
|
const outcomes = await flushQueue(roundId)
|
||||||
|
if (outcomes.length === 0) return
|
||||||
|
const syncedParticipants = new Set<string>()
|
||||||
|
const syncedSides = new Set<string>()
|
||||||
|
for (const o of outcomes) {
|
||||||
|
const participantMatch = o.entry.url.match(/\/participants\/([^/]+)\/holes\/(\d+)$/)
|
||||||
|
if (participantMatch) {
|
||||||
|
setPendingParticipantHoles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(`${participantMatch[1]}:${participantMatch[2]}`)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
syncedParticipants.add(participantMatch[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const sideMatch = o.entry.url.match(/\/sides\/([^/]+)\/holes\/(\d+)$/)
|
||||||
|
if (sideMatch) {
|
||||||
|
setPendingSideHoles((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(`${sideMatch[1]}:${sideMatch[2]}`)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
syncedSides.add(sideMatch[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const failed = outcomes.filter((o) => !o.ok)
|
||||||
|
if (failed.length > 0) {
|
||||||
|
setError(
|
||||||
|
`${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setPendingCount(await queueCount(roundId))
|
||||||
|
for (const id of syncedParticipants) void loadHoles(id)
|
||||||
|
for (const id of syncedSides) void loadSideHoles(id)
|
||||||
|
setFormatResultRefreshTick((n) => n + 1)
|
||||||
|
} finally {
|
||||||
|
setSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOnline(navigator.onLine)
|
||||||
|
function handleOnline() {
|
||||||
|
setIsOnline(true)
|
||||||
|
void flushPending()
|
||||||
|
}
|
||||||
|
function handleOffline() {
|
||||||
|
setIsOnline(false)
|
||||||
|
}
|
||||||
|
window.addEventListener("online", handleOnline)
|
||||||
|
window.addEventListener("offline", handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("online", handleOnline)
|
||||||
|
window.removeEventListener("offline", handleOffline)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
|
// Sjekk for allerede køede skrivinger fra en tidligere økt (f.eks. siden
|
||||||
|
// ble lukket mens offline) -- samme engangs-sjekk som session-scorecard.tsx.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
queueCount(roundId)
|
||||||
|
.then((count) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setPendingCount(count)
|
||||||
|
if (count > 0 && navigator.onLine) void flushPending()
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
// Alltid gjeldende deltaker-/side-id-er tilgjengelig INNI WebSocket-
|
// Alltid gjeldende deltaker-/side-id-er tilgjengelig INNI WebSocket-
|
||||||
// handleren under, uten å måtte koble socket-en til/fra ved hver ny
|
// handleren under, uten å måtte koble socket-en til/fra ved hver ny
|
||||||
// runde-lasting (en vanlig closure over `round` i effektens
|
// runde-lasting (en vanlig closure over `round` i effektens
|
||||||
|
|
@ -571,12 +705,25 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
async function updateWizardStat(patch: Partial<HoleStat>) {
|
async function updateWizardStat(patch: Partial<HoleStat>) {
|
||||||
if (readOnly || !wizardPlayerId) return
|
if (readOnly || !wizardPlayerId) return
|
||||||
const merged: HoleStat = { ...wizardStat, ...patch }
|
const merged: HoleStat = { ...wizardStat, ...patch }
|
||||||
const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
|
const body = statToPatchBody(merged)
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify(statToPatchBody(merged)),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
|
||||||
|
// for å tape registreringen, se ADR-028.
|
||||||
|
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const updated: ApiHole = await res.json()
|
const updated: ApiHole = await res.json()
|
||||||
setHolesByParticipant((prev) => ({
|
setHolesByParticipant((prev) => ({
|
||||||
|
|
@ -634,12 +781,23 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
async function updateWizardSideScore(score: number | null) {
|
async function updateWizardSideScore(score: number | null) {
|
||||||
if (readOnly || !wizardSideId) return
|
if (readOnly || !wizardSideId) return
|
||||||
const res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
|
const body = { played: true, score }
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ played: true, score }),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
|
} catch {
|
||||||
|
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const updated: ApiSideHole = await res.json()
|
const updated: ApiSideHole = await res.json()
|
||||||
setHolesBySide((prev) => ({
|
setHolesBySide((prev) => ({
|
||||||
|
|
@ -894,6 +1052,33 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Offline scoreregistrering (ADR-028-utvidelse) -- synlig uansett
|
||||||
|
fane, siden offline-tilstanden er global for hele siden. */}
|
||||||
|
{!isOnline && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||||
|
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake på nett.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
||||||
|
<span>
|
||||||
|
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering.
|
||||||
|
</span>
|
||||||
|
{isOnline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void flushPending()}
|
||||||
|
disabled={syncing}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
|
||||||
|
Synkroniser nå
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pageTab === "manage" && (
|
{pageTab === "manage" && (
|
||||||
<>
|
<>
|
||||||
{/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for
|
{/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for
|
||||||
|
|
@ -1126,6 +1311,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
onClose={() => setWizardSideId(null)}
|
onClose={() => setWizardSideId(null)}
|
||||||
onAdvance={advanceWizardSide}
|
onAdvance={advanceWizardSide}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
isPending={pendingSideHoles.has(`${wizardSide.id}:${activeHole}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{wizardPlayer && hole && (
|
{wizardPlayer && hole && (
|
||||||
|
|
@ -1139,6 +1325,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
onAdvance={advanceWizardPlayer}
|
onAdvance={advanceWizardPlayer}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
ownBagClubs={ownBagClubs}
|
ownBagClubs={ownBagClubs}
|
||||||
|
isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1192,6 +1379,7 @@ function ScoringWizard({
|
||||||
onAdvance,
|
onAdvance,
|
||||||
readOnly,
|
readOnly,
|
||||||
ownBagClubs,
|
ownBagClubs,
|
||||||
|
isPending,
|
||||||
}: {
|
}: {
|
||||||
players: Player[]
|
players: Player[]
|
||||||
player: Player
|
player: Player
|
||||||
|
|
@ -1202,6 +1390,10 @@ function ScoringWizard({
|
||||||
onAdvance: () => void
|
onAdvance: () => void
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
ownBagClubs: string[]
|
ownBagClubs: string[]
|
||||||
|
// Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- sann når
|
||||||
|
// gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen,
|
||||||
|
// ikke bekreftet av serveren ennå.
|
||||||
|
isPending: boolean
|
||||||
}) {
|
}) {
|
||||||
const playerIndex = players.findIndex((p) => p.id === player.id)
|
const playerIndex = players.findIndex((p) => p.id === player.id)
|
||||||
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
||||||
|
|
@ -1300,6 +1492,12 @@ function ScoringWizard({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
|
||||||
|
Lagret lokalt · venter på synk
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||||||
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
||||||
{step === "strokes" && (
|
{step === "strokes" && (
|
||||||
|
|
@ -3779,6 +3977,7 @@ function SideScoreWizard({
|
||||||
onClose,
|
onClose,
|
||||||
onAdvance,
|
onAdvance,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
isPending,
|
||||||
}: {
|
}: {
|
||||||
sides: ApiSide[]
|
sides: ApiSide[]
|
||||||
side: ApiSide
|
side: ApiSide
|
||||||
|
|
@ -3788,6 +3987,9 @@ function SideScoreWizard({
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onAdvance: () => void
|
onAdvance: () => void
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
|
// Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- se
|
||||||
|
// ScoringWizard sin samme prop for full begrunnelse.
|
||||||
|
isPending: boolean
|
||||||
}) {
|
}) {
|
||||||
const sideIndex = sides.findIndex((s) => s.id === side.id)
|
const sideIndex = sides.findIndex((s) => s.id === side.id)
|
||||||
const isLast = sideIndex === -1 || sideIndex === sides.length - 1
|
const isLast = sideIndex === -1 || sideIndex === sides.length - 1
|
||||||
|
|
@ -3852,6 +4054,12 @@ function SideScoreWizard({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isPending && (
|
||||||
|
<p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
|
||||||
|
Lagret lokalt · venter på synk
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||||||
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
||||||
<NumberPicker
|
<NumberPicker
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ type ApiRoundParticipant = {
|
||||||
user_id: string | null
|
user_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiSide = {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type ApiRoundHeader = {
|
type ApiRoundHeader = {
|
||||||
id: string
|
id: string
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|
@ -61,9 +66,19 @@ type ApiRoundHeader = {
|
||||||
played_at: string
|
played_at: string
|
||||||
start_hole: number
|
start_hole: number
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
|
play_format: string
|
||||||
|
sides: ApiSide[]
|
||||||
participants: ApiRoundParticipant[]
|
participants: ApiRoundParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// To-sidede formater har ingen individuell "rangering" -- to lag/spillere i
|
||||||
|
// en match har en STATUS (hvem leder, hull for hull), ikke en topp-3-liste.
|
||||||
|
// Delt-ball-formatene (foursome/greensome/scramble) har i tillegg ALDRI
|
||||||
|
// noen individuell round_hole-rad å rangere fra (ADR-039 Beslutning C: én
|
||||||
|
// score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville
|
||||||
|
// derfor vist en tom/meningsløs liste for dem uansett.
|
||||||
|
const TWO_SIDED_FORMATS = ["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"]
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
// --- Rangering ---------------------------------------------------------------
|
// --- Rangering ---------------------------------------------------------------
|
||||||
|
|
@ -314,6 +329,108 @@ function HoleStrip({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Matchstatus for to-sidede formater (2026-07-28) ------------------------
|
||||||
|
// En match/fourball/foursome/greensome/scramble har ingen individuell
|
||||||
|
// rangering -- kun en status (hvem leder, hull for hull). Egen, liten
|
||||||
|
// versjon her (samme mønster som HoleMark over -- lokal duplisering
|
||||||
|
// fremfor cross-file-import for et lite presentasjons-stykke), ikke en
|
||||||
|
// full brutto/netto-tabell (den finnes allerede på scorekort-siden sin
|
||||||
|
// "Matchforløp"-seksjon -- lenket til herfra i stedet for duplisert).
|
||||||
|
|
||||||
|
type ApiFormatResult = {
|
||||||
|
play_format: string
|
||||||
|
ready: boolean
|
||||||
|
match_holes_played: number | null
|
||||||
|
match_holes_remaining: number | null
|
||||||
|
match_is_closed: boolean | null
|
||||||
|
match_is_dormie: boolean | null
|
||||||
|
match_status_text: string | null
|
||||||
|
hole_results: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
||||||
|
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ApiFormatResult | null) => {
|
||||||
|
if (!cancelled) setResult(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, refreshKey])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// match_status_text bruker bokstavelig "(A)"/"(B)" -- disse tilsvarer alltid
|
||||||
|
// round.sides[0]/[1] (begge sortert på id, samme rekkefølge som backend sin
|
||||||
|
// egen sortering av side-id-er). Erstatt med det faktiske side-navnet.
|
||||||
|
function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
||||||
|
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||||||
|
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) {
|
||||||
|
const result = useFormatResult(roundId, refreshKey)
|
||||||
|
if (!result) return null
|
||||||
|
|
||||||
|
if (!result.ready) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||||
|
Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
|
||||||
|
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
|
||||||
|
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
|
||||||
|
</div>
|
||||||
|
{statusText && <span className="text-3xl font-extrabold tracking-tight text-foreground">{statusText}</span>}
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{result.match_holes_played} hull spilt
|
||||||
|
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`}
|
||||||
|
</span>
|
||||||
|
{result.hole_results && result.hole_results.length > 0 && (
|
||||||
|
<ul className="flex flex-wrap gap-1">
|
||||||
|
{result.hole_results.map((r, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
aria-label={
|
||||||
|
r === "halved"
|
||||||
|
? `Hull ${i + 1}: delt`
|
||||||
|
: `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||||||
|
r === "a" && "bg-primary text-primary-foreground",
|
||||||
|
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||||||
|
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r === "halved" ? "½" : r.toUpperCase()}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href={`/my-rounds/${roundId}/scorecard`}
|
||||||
|
className="text-sm font-semibold text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Se hull for hull med brutto/netto →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Full leaderboard-side -----------------------------------------------
|
// --- Full leaderboard-side -----------------------------------------------
|
||||||
|
|
||||||
function useLeaderboardData(roundId: string) {
|
function useLeaderboardData(roundId: string) {
|
||||||
|
|
@ -375,7 +492,7 @@ function useLeaderboardData(roundId: string) {
|
||||||
|
|
||||||
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
|
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
|
||||||
|
|
||||||
return { round, leaderboard, viewerParticipantId, error }
|
return { round, leaderboard, viewerParticipantId, error, refreshKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_MISSING_LABEL: Record<Mode, string> = {
|
const MODE_MISSING_LABEL: Record<Mode, string> = {
|
||||||
|
|
@ -385,7 +502,7 @@ const MODE_MISSING_LABEL: Record<Mode, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||||
const { round, leaderboard, viewerParticipantId, error } = useLeaderboardData(roundId)
|
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
|
||||||
const [mode, setMode] = useState<Mode>("gross")
|
const [mode, setMode] = useState<Mode>("gross")
|
||||||
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
|
// Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26,
|
||||||
// etterspurt av bruker) -- kollapset som default (samme "trekkspill
|
// etterspurt av bruker) -- kollapset som default (samme "trekkspill
|
||||||
|
|
@ -413,6 +530,7 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId)
|
const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId)
|
||||||
|
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh bg-background">
|
<div className="min-h-dvh bg-background">
|
||||||
|
|
@ -439,6 +557,16 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* To-sidede formater (match/fourball/foursome/greensome/scramble)
|
||||||
|
har ingen individuell rangering å vise -- kun en matchstatus
|
||||||
|
(2026-07-28, se MatchStatusSection over). Delt-ball-formatene
|
||||||
|
har i tillegg ALDRI individuelle round_hole-rader (ADR-039
|
||||||
|
Beslutning C), så den vanlige listen under ville uansett vært
|
||||||
|
tom for dem. */}
|
||||||
|
{isTwoSided ? (
|
||||||
|
<MatchStatusSection roundId={roundId} sides={round.sides} refreshKey={refreshKey} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
||||||
<ModeToggle mode={mode} onChange={setMode} />
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
|
@ -511,6 +639,8 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
|
||||||
Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering.
|
Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -525,6 +655,13 @@ export function RoundLeaderboardMini({ roundId }: { roundId: string }) {
|
||||||
|
|
||||||
if (!round || !leaderboard || round.participants.length < 2) return null
|
if (!round || !leaderboard || round.participants.length < 2) return null
|
||||||
|
|
||||||
|
// To-sidede formater (match/fourball/foursome/greensome/scramble) vises
|
||||||
|
// allerede av FormatResultPanel rett under denne komponenten sitt
|
||||||
|
// kallsted i round-detail.tsx -- ingen individuell brutto-rangering å
|
||||||
|
// vise her (og for delt-ball-formatene ville leaderboard.entries uansett
|
||||||
|
// alltid vært tom, ADR-039 Beslutning C).
|
||||||
|
if (TWO_SIDED_FORMATS.includes(round.play_format)) return null
|
||||||
|
|
||||||
const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId)
|
const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId)
|
||||||
const top = ranked.slice(0, 3)
|
const top = ranked.slice(0, 3)
|
||||||
const restCount = ranked.length - top.length
|
const restCount = ranked.length - top.length
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,22 @@
|
||||||
// `strokes_received` per hull (samme `allocate_strokes_by_index()`-
|
// `strokes_received` per hull (samme `allocate_strokes_by_index()`-
|
||||||
// algoritme som resten av appen), for å unngå to ulike implementasjoner av
|
// algoritme som resten av appen), for å unngå to ulike implementasjoner av
|
||||||
// samme HCP-slagfordeling.
|
// samme HCP-slagfordeling.
|
||||||
|
//
|
||||||
|
// Format-bevissthet lagt til 2026-07-28: brukeren rapporterte at siden kun
|
||||||
|
// noensinne viste brutto/netto/stableford, uansett spilleform -- bekreftet
|
||||||
|
// ved simulering at (a) foursome/greensome/scramble var HELT BLANKE her
|
||||||
|
// (delt-ball-formater lagrer score PER SIDE, ikke per deltaker -- se
|
||||||
|
// ADR-039 -- og denne siden spurte kun mot deltaker-endepunktet), og (b)
|
||||||
|
// match/fourball/skins viste riktige rå tall, men aldri HVORFOR et hull
|
||||||
|
// ble avgjort som det ble (brutto vs. netto, hvem sitt netto talte for
|
||||||
|
// siden i fourball, hvem som vant/hvilket hull som rullet videre i skins).
|
||||||
|
// Fikset ved å: (1) la delt-ball-formater bruke SIDER som "enhet" i stedet
|
||||||
|
// for deltakere (samme visuelle ScoreBlock-tabell, bare på
|
||||||
|
// `/rounds/{id}/sides/{id}/holes`, som fikk et nytt `strokes_received`-felt
|
||||||
|
// samtidig), og (2) legge til en egen "Matchforløp"/"Skins hull for hull"-
|
||||||
|
// seksjon drevet av `/rounds/{id}/format-result` sitt nye `holes`-felt
|
||||||
|
// (brutto/netto/slag mottatt PER enhet PER hull, pluss hvem som vant og
|
||||||
|
// hvorfor).
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
@ -25,6 +41,12 @@ type ApiParticipant = {
|
||||||
guest_name: string | null
|
guest_name: string | null
|
||||||
display_name: string
|
display_name: string
|
||||||
is_owner: boolean
|
is_owner: boolean
|
||||||
|
round_side_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiSide = {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiRound = {
|
type ApiRound = {
|
||||||
|
|
@ -35,7 +57,9 @@ type ApiRound = {
|
||||||
played_at: string
|
played_at: string
|
||||||
start_hole: number
|
start_hole: number
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
|
play_format: string
|
||||||
participants: ApiParticipant[]
|
participants: ApiParticipant[]
|
||||||
|
sides: ApiSide[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiHole = {
|
type ApiHole = {
|
||||||
|
|
@ -47,6 +71,19 @@ type ApiHole = {
|
||||||
strokes_received: number | null
|
strokes_received: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"]
|
||||||
|
const TWO_SIDED_FORMATS = ["match", "fourball", ...SHARED_BALL_FORMATS]
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
|
stroke: "Slagspill",
|
||||||
|
match: "Match",
|
||||||
|
skins: "Skins",
|
||||||
|
fourball: "Fourball",
|
||||||
|
foursome: "Foursome",
|
||||||
|
greensome: "Greensome",
|
||||||
|
scramble_2: "Scramble (2)",
|
||||||
|
scramble_4: "Scramble (4)",
|
||||||
|
}
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
||||||
|
|
||||||
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
|
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26) -- se samme
|
||||||
|
|
@ -311,11 +348,265 @@ function TotalTile({ label, value, tone }: { label: string; value: string; tone?
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Matchforløp / skins-forløp (2026-07-28) ---------------------------------
|
||||||
|
// Avledet ved LESING fra GET .../format-result -- viser HVORFOR et hull ble
|
||||||
|
// avgjort som det ble (brutto vs. netto, slag mottatt, hvem sitt netto
|
||||||
|
// talte for siden i fourball, hvem som vant/hvilket hull som rullet videre
|
||||||
|
// i skins) -- ikke bare et vinn/tap-merke.
|
||||||
|
|
||||||
|
type ApiFormatHoleEntry = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
side: "a" | "b" | null
|
||||||
|
gross: number | null
|
||||||
|
net: number | null
|
||||||
|
strokes_received: number | null
|
||||||
|
counted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiFormatHoleOut = {
|
||||||
|
hole_number: number
|
||||||
|
par: number
|
||||||
|
stroke_index: number
|
||||||
|
entries: ApiFormatHoleEntry[]
|
||||||
|
result: string | null
|
||||||
|
winner_ids: string[] | null
|
||||||
|
carried: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiFormatResult = {
|
||||||
|
play_format: string
|
||||||
|
ready: boolean
|
||||||
|
match_status_text: string | null
|
||||||
|
skins_won: Record<string, number> | null
|
||||||
|
holes: ApiFormatHoleOut[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
|
||||||
|
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data: ApiFormatResult | null) => {
|
||||||
|
if (!cancelled) setResult(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId, refreshKey])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultChip({ result }: { result: string | null }) {
|
||||||
|
if (!result) return <span className="text-sm text-muted-foreground">–</span>
|
||||||
|
if (result === "halved") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label="Hullet ble delt"
|
||||||
|
className="flex size-7 items-center justify-center rounded-full border border-border bg-muted text-xs font-extrabold text-muted-foreground"
|
||||||
|
>
|
||||||
|
½
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={result === "a" ? "Side A vant hullet" : "Side B vant hullet"}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||||||
|
result === "a" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Én kolonne-celle for en side -- viser hver enhets brutto → netto, med den
|
||||||
|
// enheten som IKKE talte for siden (fourball: partneren med høyest netto)
|
||||||
|
// tonet ned i stedet for fjernet, slik at "hvorfor" forblir synlig.
|
||||||
|
function SideEntryCell({ entries }: { entries: ApiFormatHoleEntry[] }) {
|
||||||
|
if (entries.length === 0) return <span className="text-sm text-muted-foreground">–</span>
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-baseline justify-between gap-2",
|
||||||
|
entries.length > 1 && !e.counted && "opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entries.length > 1 && (
|
||||||
|
<span className="min-w-0 truncate text-[11px] font-semibold text-muted-foreground">{e.label}</span>
|
||||||
|
)}
|
||||||
|
<span className={cn("flex items-baseline gap-1 tabular-nums", entries.length === 1 && "ml-0")}>
|
||||||
|
<span className="text-sm font-extrabold text-foreground">{e.gross ?? "–"}</span>
|
||||||
|
{e.net !== null && e.net !== e.gross && (
|
||||||
|
<span className="text-[11px] font-semibold text-muted-foreground">→ {e.net}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchProgressTable({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) {
|
||||||
|
const result = useFormatResult(roundId, refreshKey)
|
||||||
|
if (!result) return null
|
||||||
|
|
||||||
|
const sideALabel = sides[0]?.label?.trim() || "Side A"
|
||||||
|
const sideBLabel = sides[1]?.label?.trim() || "Side B"
|
||||||
|
|
||||||
|
if (!result.ready || !result.holes) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||||
|
Venter på at begge sider er komplette og har beregnet handicap før matchforløpet kan vises.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.holes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||||||
|
Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har registrert.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Matchforløp</h2>
|
||||||
|
{result.match_status_text && (
|
||||||
|
<span className="text-sm font-extrabold tabular-nums text-foreground">{result.match_status_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="-mx-3 overflow-x-auto px-3 sm:-mx-4 sm:px-4">
|
||||||
|
<table className="w-full min-w-[440px] table-fixed border-collapse text-sm">
|
||||||
|
<colgroup>
|
||||||
|
<col className="w-10" />
|
||||||
|
<col className="w-9" />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col className="w-10" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-left text-xs font-extrabold text-muted-foreground">
|
||||||
|
Hull
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground">
|
||||||
|
Par
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
|
||||||
|
{sideALabel}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="border border-border px-2 py-1.5 text-left text-xs font-extrabold text-foreground">
|
||||||
|
{sideBLabel}
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="border border-border bg-muted px-1 py-1.5 text-center text-xs font-extrabold text-muted-foreground">
|
||||||
|
Res.
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.holes.map((h) => (
|
||||||
|
<tr key={h.hole_number}>
|
||||||
|
<td className="border border-border px-1 py-1.5 text-xs font-semibold text-muted-foreground">{h.hole_number}</td>
|
||||||
|
<td className="border border-border px-1 py-1.5 text-center text-xs text-muted-foreground">{h.par}</td>
|
||||||
|
<td className="border border-border px-2 py-1.5">
|
||||||
|
<SideEntryCell entries={h.entries.filter((e) => e.side === "a")} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-2 py-1.5">
|
||||||
|
<SideEntryCell entries={h.entries.filter((e) => e.side === "b")} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-border px-1 py-1.5">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
<ResultChip result={h.result} />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Netto = brutto minus slag mottatt (kombinert match-hcp den dagen) -- vist med "→" der det avviker fra brutto.
|
||||||
|
{result.holes.some((h) => h.entries.length > 2) &&
|
||||||
|
" Nedtonet spiller sitt tall talte ikke for siden det hullet (laveste netto teller)."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
|
||||||
|
const result = useFormatResult(roundId, refreshKey)
|
||||||
|
if (!result || !result.holes || result.holes.length === 0) return null
|
||||||
|
|
||||||
|
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-3 shadow-sm shadow-black/5 sm:p-4">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Skins, hull for hull</h2>
|
||||||
|
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||||||
|
{result.holes.map((h) => (
|
||||||
|
<div key={h.hole_number} className="flex flex-col gap-1.5 px-3 py-2.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-bold text-muted-foreground">
|
||||||
|
Hull {h.hole_number} · Par {h.par}
|
||||||
|
</span>
|
||||||
|
{h.carried ? (
|
||||||
|
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-[11px] font-bold text-muted-foreground">
|
||||||
|
Uavgjort — rullet videre
|
||||||
|
</span>
|
||||||
|
) : h.winner_ids && h.winner_ids.length > 0 ? (
|
||||||
|
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground">
|
||||||
|
{h.winner_ids.length === 1
|
||||||
|
? `${h.entries.find((e) => e.id === h.winner_ids?.[0])?.label ?? "?"} vant skinnet`
|
||||||
|
: `Delt: ${h.winner_ids.map((id) => h.entries.find((e) => e.id === id)?.label ?? "?").join(", ")}`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{h.entries.map((e) => {
|
||||||
|
const isWinner = h.winner_ids?.includes(e.id) ?? false
|
||||||
|
const shown = hasNet && e.net !== null ? e.net : e.gross
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={e.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-baseline gap-1 text-sm",
|
||||||
|
isWinner ? "font-extrabold text-primary" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.label}: <span className="tabular-nums">{shown ?? "–"}</span>
|
||||||
|
{hasNet && e.net !== null && e.gross !== null && e.net !== e.gross && (
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">({e.gross} brutto)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasNet ? "Skins avgjøres på netto (brutto vist i parentes)." : "Skins avgjøres på brutto."} Et uavgjort hull
|
||||||
|
ruller potten videre til neste hull.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Screen ----------------------------------------------------------------
|
// --- Screen ----------------------------------------------------------------
|
||||||
|
|
||||||
export function RoundScorecard({ roundId }: { roundId: string }) {
|
export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
const [round, setRound] = useState<ApiRound | null>(null)
|
const [round, setRound] = useState<ApiRound | null>(null)
|
||||||
const [participantId, setParticipantId] = useState<string | null>(null)
|
const [unitId, setUnitId] = useState<string | null>(null)
|
||||||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [viewerId, setViewerId] = useState<string | null>(null)
|
const [viewerId, setViewerId] = useState<string | null>(null)
|
||||||
|
|
@ -360,16 +651,22 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
const data: ApiRound = await res.json()
|
const data: ApiRound = await res.json()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setRound(data)
|
setRound(data)
|
||||||
|
const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
|
||||||
|
setUnitId((prev) => {
|
||||||
|
if (prev) return prev
|
||||||
|
if (isSharedBall) {
|
||||||
|
const viewerParticipant = viewerId ? data.participants.find((p) => p.user_id === viewerId) : undefined
|
||||||
|
return viewerParticipant?.round_side_id ?? data.sides[0]?.id ?? null
|
||||||
|
}
|
||||||
// Standardvalg: SIN EGEN rad hvis man er en lenket medspiller,
|
// Standardvalg: SIN EGEN rad hvis man er en lenket medspiller,
|
||||||
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen).
|
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen).
|
||||||
setParticipantId(
|
return (
|
||||||
(prev) =>
|
|
||||||
prev ??
|
|
||||||
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
|
(viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ??
|
||||||
data.participants.find((p) => p.is_owner)?.id ??
|
data.participants.find((p) => p.is_owner)?.id ??
|
||||||
data.participants[0]?.id ??
|
data.participants[0]?.id ??
|
||||||
null,
|
null
|
||||||
)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
void load()
|
void load()
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -378,10 +675,14 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
}, [roundId, viewerId, refreshKey])
|
}, [roundId, viewerId, refreshKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!participantId) return
|
if (!unitId || !round) return
|
||||||
|
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||||
|
const endpoint = isSharedBall
|
||||||
|
? `/rounds/${roundId}/sides/${unitId}/holes`
|
||||||
|
: `/rounds/${roundId}/participants/${unitId}/holes`
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function loadHoles() {
|
async function loadHoles() {
|
||||||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
|
const res = await fetch(endpoint, { credentials: "include" })
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
const data: ApiHole[] = await res.json()
|
const data: ApiHole[] = await res.json()
|
||||||
if (!cancelled) setHoles(data)
|
if (!cancelled) setHoles(data)
|
||||||
|
|
@ -390,7 +691,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [roundId, participantId, refreshKey])
|
}, [roundId, unitId, round?.play_format, refreshKey])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -403,7 +704,51 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!round || !holes) {
|
if (!round) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
|
||||||
|
const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format)
|
||||||
|
const isSkins = round.play_format === "skins"
|
||||||
|
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) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={`/my-rounds/${roundId}`}
|
||||||
|
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||||
|
Scorekort
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const units: { id: string; label: string }[] = isSharedBall
|
||||||
|
? round.sides.map((s, idx) => ({ id: s.id, label: s.label?.trim() || (idx === 0 ? "Side A" : "Side B") }))
|
||||||
|
: round.participants.map((p) => ({ id: p.id, label: participantLabel(p, viewerId) }))
|
||||||
|
|
||||||
|
if (!holes) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
|
@ -459,9 +804,16 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
{/* Round summary strip */}
|
{/* Round summary strip */}
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
{round.name?.trim() || round.course_name_snapshot}
|
{round.name?.trim() || round.course_name_snapshot}
|
||||||
</span>
|
</span>
|
||||||
|
{round.play_format !== "stroke" && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
|
||||||
|
{formatLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="text-sm font-semibold text-muted-foreground">
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
||||||
{round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "}
|
{round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "}
|
||||||
|
|
@ -476,21 +828,21 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Spillervelger, kun når runden har flere deltakere */}
|
{/* Enhets-velger (spillere, eller SIDER for delt-ball-formater) */}
|
||||||
{round.participants.length > 1 && (
|
{units.length > 1 && (
|
||||||
<div role="tablist" aria-label="Velg spiller" className="-mx-4 overflow-x-auto px-4">
|
<div role="tablist" aria-label={isSharedBall ? "Velg side" : "Velg spiller"} className="-mx-4 overflow-x-auto px-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{round.participants.map((p) => {
|
{units.map((u) => {
|
||||||
const active = p.id === participantId
|
const active = u.id === unitId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={u.id}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setHoles(null)
|
setHoles(null)
|
||||||
setParticipantId(p.id)
|
setUnitId(u.id)
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-11 shrink-0 items-center rounded-2xl border px-4 text-base font-bold transition-colors",
|
"flex min-h-11 shrink-0 items-center rounded-2xl border px-4 text-base font-bold transition-colors",
|
||||||
|
|
@ -499,7 +851,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{participantLabel(p, viewerId)}
|
{u.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -529,6 +881,13 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
|
||||||
/>
|
/>
|
||||||
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : "–"} tone="green" />}
|
{showStableford && <TotalTile label="Poeng" value={totalPoints !== null ? String(totalPoints) : "–"} tone="green" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
|
||||||
|
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
|
||||||
|
{isTwoSided && round.sides.length === 2 && (
|
||||||
|
<MatchProgressTable roundId={roundId} sides={round.sides} refreshKey={refreshKey} />
|
||||||
|
)}
|
||||||
|
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -371,9 +371,25 @@ type ApiRound = {
|
||||||
played_at: string
|
played_at: string
|
||||||
start_hole: number
|
start_hole: number
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
|
play_format: string
|
||||||
participants: ApiParticipant[]
|
participants: ApiParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delt-ball-formater (foursome/greensome/scramble, ADR-039 Beslutning C)
|
||||||
|
// lagrer ÉN score PER SIDE per hull -- ingen individuell round_hole-rad
|
||||||
|
// eksisterer i det hele tatt for noen deltaker her, så all statistikken på
|
||||||
|
// denne siden (fairway/GIR/putt-fordeling osv., som forutsetter individuell
|
||||||
|
// slag-for-slag-registrering) er strukturelt umulig å vise for disse
|
||||||
|
// formatene. Vist som en forklarende melding i stedet for en stille tom/
|
||||||
|
// misvisende side (2026-07-28, funnet ved simulering av alle spillformater).
|
||||||
|
const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4"]
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
|
foursome: "Foursome",
|
||||||
|
greensome: "Greensome",
|
||||||
|
scramble_2: "Scramble (2)",
|
||||||
|
scramble_4: "Scramble (4)",
|
||||||
|
}
|
||||||
|
|
||||||
type ApiHole = {
|
type ApiHole = {
|
||||||
hole_number: number
|
hole_number: number
|
||||||
par: number
|
par: number
|
||||||
|
|
@ -630,7 +646,9 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
}, [roundId, viewerId, refreshKey])
|
}, [roundId, viewerId, refreshKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!participantId) return
|
// Delt-ball-formater har ingen individuell round_hole-rad å hente --
|
||||||
|
// se guarden under, som viser en forklarende melding i stedet.
|
||||||
|
if (!participantId || (round && SHARED_BALL_FORMATS.includes(round.play_format))) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function loadHoles() {
|
async function loadHoles() {
|
||||||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
|
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
|
||||||
|
|
@ -642,7 +660,7 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [roundId, participantId, refreshKey])
|
}, [roundId, participantId, round, refreshKey])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -655,7 +673,47 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!round || !holes) {
|
if (!round) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SHARED_BALL_FORMATS.includes(round.play_format)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={`/my-rounds/${roundId}`}
|
||||||
|
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||||
|
Statistikk
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<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">
|
||||||
|
Detaljert statistikk (fairwaytreff, GIR, putt-fordeling m.m.) er ikke tilgjengelig for{" "}
|
||||||
|
{FORMAT_LABELS[round.play_format] ?? round.play_format} -- disse formatene registrerer kun ETT felles
|
||||||
|
slagtall per side, ikke individuelle slag-for-slag-detaljer.{" "}
|
||||||
|
<Link
|
||||||
|
href={`/my-rounds/${roundId}/scorecard`}
|
||||||
|
className="font-semibold text-primary underline underline-offset-2"
|
||||||
|
>
|
||||||
|
Se scorekortet
|
||||||
|
</Link>{" "}
|
||||||
|
for hull-for-hull-forløpet i stedet.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!holes) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
||||||
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -592,6 +592,55 @@ def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy
|
||||||
# tilsvarende motorstøtte fantes fra før (ulikt match play over).
|
# tilsvarende motorstøtte fantes fra før (ulikt match play over).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkinsHoleLog:
|
||||||
|
"""Ett hulls bidrag til skins-oppgjøret -- brukt til å vise SELVE
|
||||||
|
hull-for-hull-forløpet (ikke bare sluttsummen), se compute_skins_detail."""
|
||||||
|
|
||||||
|
values: dict[str, int] # deltaker_id -> score (netto ELLER brutto, avhengig av kalleren)
|
||||||
|
pot_before: float # potten FØR dette hullets egen andel legges til
|
||||||
|
awarded: dict[str, float] # deltaker_id -> andel vunnet PÅ DETTE hullet (tom hvis carried)
|
||||||
|
carried: bool # sann kun ved uavgjort + tie_handling="carry" (potten ruller videre)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_skins_detail(
|
||||||
|
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
|
||||||
|
tie_handling: Literal["carry", "split"] = "carry",
|
||||||
|
) -> tuple[dict[str, float], list[SkinsHoleLog]]:
|
||||||
|
"""Samme beregning som compute_skins, men returnerer OGSÅ et hull-for-hull-
|
||||||
|
forløp (verdier/pott/vinner per hull) -- til bruk i en hull-for-hull-
|
||||||
|
visning der spilleren skal se HVORFOR potten endte som den gjorde, ikke
|
||||||
|
bare den ferdige totalen."""
|
||||||
|
winnings: dict[str, float] = {}
|
||||||
|
pot = 0.0
|
||||||
|
log: list[SkinsHoleLog] = []
|
||||||
|
for hole_scores in scores_by_hole:
|
||||||
|
if not hole_scores:
|
||||||
|
log.append(SkinsHoleLog(values={}, pot_before=pot, awarded={}, carried=False))
|
||||||
|
continue
|
||||||
|
pot_before = pot
|
||||||
|
pot += 1.0
|
||||||
|
lowest = min(score for _, score in hole_scores)
|
||||||
|
winners = [pid for pid, score in hole_scores if score == lowest]
|
||||||
|
awarded: dict[str, float] = {}
|
||||||
|
carried = False
|
||||||
|
if len(winners) == 1:
|
||||||
|
winnings[winners[0]] = winnings.get(winners[0], 0.0) + pot
|
||||||
|
awarded = {winners[0]: pot}
|
||||||
|
pot = 0.0
|
||||||
|
elif tie_handling == "split":
|
||||||
|
share = pot / len(winners)
|
||||||
|
for pid in winners:
|
||||||
|
winnings[pid] = winnings.get(pid, 0.0) + share
|
||||||
|
awarded = {pid: share for pid in winners}
|
||||||
|
pot = 0.0
|
||||||
|
else:
|
||||||
|
carried = True
|
||||||
|
# "carry": potten står urørt og tas med videre til neste hull.
|
||||||
|
log.append(SkinsHoleLog(values=dict(hole_scores), pot_before=pot_before, awarded=awarded, carried=carried))
|
||||||
|
return winnings, log
|
||||||
|
|
||||||
|
|
||||||
def compute_skins(
|
def compute_skins(
|
||||||
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
|
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
|
||||||
tie_handling: Literal["carry", "split"] = "carry",
|
tie_handling: Literal["carry", "split"] = "carry",
|
||||||
|
|
@ -614,21 +663,5 @@ def compute_skins(
|
||||||
Et hull uten noen registrerte scorer hopper stille over (verken potten
|
Et hull uten noen registrerte scorer hopper stille over (verken potten
|
||||||
eller noen vinnere endres) -- ikke en feiltilstand.
|
eller noen vinnere endres) -- ikke en feiltilstand.
|
||||||
"""
|
"""
|
||||||
winnings: dict[str, float] = {}
|
winnings, _log = compute_skins_detail(scores_by_hole, tie_handling)
|
||||||
pot = 0.0
|
|
||||||
for hole_scores in scores_by_hole:
|
|
||||||
if not hole_scores:
|
|
||||||
continue
|
|
||||||
pot += 1.0
|
|
||||||
lowest = min(score for _, score in hole_scores)
|
|
||||||
winners = [pid for pid, score in hole_scores if score == lowest]
|
|
||||||
if len(winners) == 1:
|
|
||||||
winnings[winners[0]] = winnings.get(winners[0], 0.0) + pot
|
|
||||||
pot = 0.0
|
|
||||||
elif tie_handling == "split":
|
|
||||||
share = pot / len(winners)
|
|
||||||
for pid in winners:
|
|
||||||
winnings[pid] = winnings.get(pid, 0.0) + share
|
|
||||||
pot = 0.0
|
|
||||||
# "carry": potten står urørt og tas med videre til neste hull.
|
|
||||||
return winnings
|
return winnings
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from handicap_engine import (
|
||||||
low_handicap_index,
|
low_handicap_index,
|
||||||
match_play_strokes,
|
match_play_strokes,
|
||||||
compute_skins,
|
compute_skins,
|
||||||
|
compute_skins_detail,
|
||||||
max_hole_score_for_handicap,
|
max_hole_score_for_handicap,
|
||||||
net_par,
|
net_par,
|
||||||
round_counts_for_handicap,
|
round_counts_for_handicap,
|
||||||
|
|
@ -559,6 +560,64 @@ def test_skins_no_winner_if_never_decided():
|
||||||
assert result == {}, result
|
assert result == {}, result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# compute_skins_detail (hull-for-hull-forløp, samme totaler som compute_skins)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_skins_detail_matches_compute_skins_totals():
|
||||||
|
scores = [
|
||||||
|
[("a", 4), ("b", 4), ("c", 5)],
|
||||||
|
[("a", 5), ("b", 5), ("c", 4)],
|
||||||
|
]
|
||||||
|
winnings = compute_skins(scores, tie_handling="carry")
|
||||||
|
detail_winnings, _log = compute_skins_detail(scores, tie_handling="carry")
|
||||||
|
assert detail_winnings == winnings == {"c": 2.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skins_detail_outright_win_log():
|
||||||
|
scores = [[("a", 4), ("b", 5), ("c", 6)], [("a", 3), ("b", 3), ("c", 5)]]
|
||||||
|
_winnings, log = compute_skins_detail(scores, tie_handling="carry")
|
||||||
|
assert len(log) == 2
|
||||||
|
assert log[0].values == {"a": 4, "b": 5, "c": 6}
|
||||||
|
assert log[0].pot_before == 0.0
|
||||||
|
assert log[0].awarded == {"a": 1.0}
|
||||||
|
assert log[0].carried is False
|
||||||
|
# Hull 2: a/b uavgjort -> carried, ingen tildeling, potten ruller (blir 1.0 for neste).
|
||||||
|
assert log[1].awarded == {}
|
||||||
|
assert log[1].carried is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_skins_detail_carry_then_win_log():
|
||||||
|
scores = [
|
||||||
|
[("a", 4), ("b", 4), ("c", 5)], # uavgjort -> carry
|
||||||
|
[("a", 5), ("b", 5), ("c", 4)], # c vinner -> tar hele potten (2)
|
||||||
|
]
|
||||||
|
_winnings, log = compute_skins_detail(scores, tie_handling="carry")
|
||||||
|
assert log[0].pot_before == 0.0
|
||||||
|
assert log[0].carried is True
|
||||||
|
assert log[0].awarded == {}
|
||||||
|
assert log[1].pot_before == 1.0
|
||||||
|
assert log[1].carried is False
|
||||||
|
assert log[1].awarded == {"c": 2.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skins_detail_split_log():
|
||||||
|
scores = [[("a", 4), ("b", 4), ("c", 5)]]
|
||||||
|
_winnings, log = compute_skins_detail(scores, tie_handling="split")
|
||||||
|
assert log[0].carried is False
|
||||||
|
assert log[0].awarded == {"a": 0.5, "b": 0.5}
|
||||||
|
|
||||||
|
|
||||||
|
def test_skins_detail_empty_hole_logged_but_inert():
|
||||||
|
scores = [[], [("a", 4), ("b", 5)]]
|
||||||
|
_winnings, log = compute_skins_detail(scores, tie_handling="carry")
|
||||||
|
assert log[0].values == {}
|
||||||
|
assert log[0].pot_before == 0.0
|
||||||
|
assert log[0].awarded == {}
|
||||||
|
assert log[0].carried is False
|
||||||
|
assert log[1].awarded == {"a": 1.0}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enkel selvsjekk uten pytest
|
# Enkel selvsjekk uten pytest
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue