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:
Erol Haagenrud 2026-07-28 18:17:49 +02:00
parent b21609915d
commit 071874e8c8
10 changed files with 1257 additions and 112 deletions

155
CLAUDE.md
View file

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

View file

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

View file

@ -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)
method: "PATCH", if (!navigator.onLine) {
headers: { "Content-Type": "application/json" }, await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
credentials: "include", return
body: JSON.stringify(statToPatchBody(merged)), }
}) let res: Response
try {
res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
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 }
method: "PATCH", if (!navigator.onLine) {
headers: { "Content-Type": "application/json" }, await queueSideHoleWrite(wizardSideId, activeHole, body)
credentials: "include", return
body: JSON.stringify({ played: true, score }), }
}) let res: Response
try {
res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
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 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"} 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
</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 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 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

View file

@ -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 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,12 +557,22 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-3"> {/* To-sidede formater (match/fourball/foursome/greensome/scramble)
<h2 className="text-base font-bold text-foreground">Stilling</h2> har ingen individuell rangering å vise -- kun en matchstatus
<ModeToggle mode={mode} onChange={setMode} /> (2026-07-28, se MatchStatusSection over). Delt-ball-formatene
</div> har i tillegg ALDRI individuelle round_hole-rader (ADR-039
Beslutning C), 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">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
{/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) -- {/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) --
IKKE separate kort med mellomrom lenger. Kun ytterkanten er IKKE separate kort med mellomrom lenger. Kun ytterkanten er
avrundet/rammet inn; radene skilles med en tynn linje avrundet/rammet inn; radene skilles med en tynn linje
(divide-y) og flyter sømløst i hverandre. En utvidet rad får en (divide-y) og flyter sømløst i hverandre. En utvidet rad får en
@ -506,10 +634,12 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
})} })}
</ul> </ul>
{mode !== "gross" && ranked.some((e) => e.rank === null) && ( {mode !== "gross" && ranked.some((e) => e.rank === null) && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
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

View file

@ -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 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)
// Standardvalg: SIN EGEN rad hvis man er en lenket medspiller, const isSharedBall = SHARED_BALL_FORMATS.includes(data.play_format)
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen). setUnitId((prev) => {
setParticipantId( if (prev) return prev
(prev) => if (isSharedBall) {
prev ?? 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,
// ellers eieren (samme som før ADR-036 fase 3-utvidelsen).
return (
(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 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,8 +804,15 @@ 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="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="flex flex-wrap items-center gap-2">
{round.name?.trim() || round.course_name_snapshot} <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.name?.trim() || round.course_name_snapshot}
</span>
{round.play_format !== "stroke" && (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
{formatLabel}
</span>
)}
</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} · ` : ""}
@ -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>
) )

View file

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

View file

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

View file

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