From 071874e8c8372b63cafb8436035a42f9f1ffbf67 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 28 Jul 2026 18:17:49 +0200 Subject: [PATCH] Ferdig og live. Oppsummert: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 155 +++++++- __pycache__/handicap_engine.cpython-312.pyc | Bin 26823 -> 28319 bytes app/routers/rounds.py | 228 +++++++++-- frontend/components/round-detail.tsx | 232 ++++++++++- frontend/components/round-leaderboard.tsx | 159 +++++++- frontend/components/round-scorecard.tsx | 403 ++++++++++++++++++-- frontend/components/round-stats.tsx | 64 +++- frontend/tsconfig.tsbuildinfo | 2 +- handicap_engine.py | 67 +++- test_handicap_engine.py | 59 +++ 10 files changed, 1257 insertions(+), 112 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9c4c8d0..e13f97e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4327,6 +4327,141 @@ Ferdig og verifisert: migrasjon (ren frontend), `docker compose up -d --build teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` → 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` (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: 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 V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR integrering. -10. **Resterende hull fra 2026-07-28-gjennomgangen av frittstående runder, - fortsatt IKKE bygget** (det viktigste — faktisk HCP — er tettet, se - ADR-038 over): (a) offline-kø (ADR-028) er kun koblet til turnering- - scorekortet (`session-scorecard.tsx`), IKKE frittstående runder - (`round-detail.tsx`/`ScoringWizard`) — nettopp der man oftest står - alene ute på banen uten dekning; (b) ingen Stableford-poengberegning - for frittstående runder (kun rå slag/differensial), inkl. en uløst - "plukket opp ballen"-tilstand; (c) rundedeling/visibility (ADR-036 - fase 2, public/private/friends) fortsatt ikke bygget; (d) flere - flighter i én frittstående runde fortsatt kun drøftet (se +10. **Resterende hull fra 2026-07-28-gjennomgangen av frittstående runder** + (det viktigste — faktisk HCP — er tettet, se ADR-038 over; offline-kø, + punkt (a), er nå OGSÅ tettet, se status 2026-07-28 over — punktet + beholdes her kun for historikk): (a) ~~offline-kø (ADR-028) er kun + koblet til turnering-scorekortet~~ — ferdig, browserverifisert og + live; (b) ingen Stableford-poengberegning for frittstående runder (kun + rå slag/differensial), inkl. en uløst "plukket opp ballen"-tilstand; + (c) rundedeling/visibility (ADR-036 fase 2, public/private/friends) + fortsatt ikke bygget; (d) flere flighter i én frittstående runde + fortsatt kun drøftet (se FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke til rundehendelser ennå. 11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/ diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index 4b62741615d1e5b0bf7498baf609d687c92f79be..3c04064901c7448d28ffb63aaf95fc00eda615de 100644 GIT binary patch delta 2504 zcmZuyTTC2P7(Qn%dzsw@1eOKb!f2`ODwJvqtynKfi_jaiZljj6(A_=5c3{}uo|(l+ zX0n)IvQ3D9)5K~_Y+^M{>KiXjUu?sJw=_X%>Pn*a#TQ>(t|M}1P zujfDK`{(m-@P|L(imw9!3Bh>&vy-D!$@z+Ue0hE|!9(*i>-G&3@3b&b?L`m&krPA$ zSq+I1AI!BRPW&(jNq|&*fFIN+iTPr4Ga*&eu7Qvj5#>QKTagiaD?WWBNuCBL>&Z#7 z9p(*0B|BhldRQMUMo92h2pG?tVNRg-n5)t1g=lB0{4sA9-%ZE~Du#|%fh_^~{p>WUocKKM$vB*S11 z+D39|+E(JyR9n210lO{g*q9>6V^ooKEo&Hhc6?k(g(zSj}X4K19smM&Qy9hesm_`lXqSeHOkPEV`;@;Zly)wHdp0ndX)$@rW%5Lr41fpT5K9uc zba5J);@*4jscd7p+3u`o)|-In2`&nDgOHQuvAm|&bdPO;qv9EVr|FsJW-0|_NlXL@ z`v_wIWUWAkly+LBSeX%H@a$Jom*4+>Pa7QD)W)5Q!Z)!Kv)HP$01Lm;sxc zal`3+7T7{;j(9Eq!^JlsVriIS`Mjpf^qKy(u44|*V2H<=!Uz|EXc#4U%WHXFFYz4X z!cz7h@ED1~+n3`CRgx(+m6Y}L_$mf+3~{Y8fI|knWoZi3sNCNZS$sA|F-=Kr zrTtKZm%;)S7|29M+>Ce_v40xO1UIkM66|Pnf6LLZi+%~_C4fH1xGqLwKvo3S3Bncj z*KY4EZQr+f!PoX+^dQTzHaQ4-2?JtI& z$+hjcQP*)UF{hvHo}XN*>&W*2ag=V>HRgM6dVI6eY3a=F^Tve(%bRyy8CW`YVrlf$ z^%{D^qZiv*X2osDUpp7cKer^*-@dgG)$GH!f3HKKPQ4mRan%>tTj{ylAOKyglJ*8& z_xG_Kh_xW6k+lHxE;67DrC}oT9^RdX!dbitCWO+Dh>wyb<=vQSEdV_I}{JVQx zNz?v2W{B(B7%pKfJFdqS5okoN{6sDaO~|<+ zmbRGQQtKGp!14l&v#&w{onXI&US;xNAxyEGVnf9=45F$Tt!EB#cj5MuK2fSumrk}?tJLNJmP|G+NavE=-1GST9=DFC^R7lmfBrO_HL+o9V zt5rGTsVaDh19&moXI!vIj5>0`{zf+Hueas7 zmb)@a(dw=D?76?CnwLfi3XL+7?Og2pz3n279;)&G>4nl+ZFwmor02j*shzEcp3xlE z5l&;i9jyfitTz&uWYo{ISCJ~oyptIbw<8x~ z!T69CSL@v2iRnPx=V?ukL|bVsKc`y8ZAv)sFXE4-ZE_lBCqnINA+q=q?O}Q&785;2 gA*pXNq#EkXI4>Il#&3CNcWOf>a{f;<&g>Zd1>FhgivR!s diff --git a/app/routers/rounds.py b/app/routers/rounds.py index c6dcb7e..c182c44 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -52,7 +52,7 @@ from handicap_engine import ( allocate_strokes_by_index, apply_index_caps, compute_match_state, - compute_skins, + compute_skins_detail, course_handicap, course_handicap_raw, handicap_index_from_differentials, @@ -2042,6 +2042,12 @@ class RoundSideHoleOut(BaseModel): stroke_index: int played: bool 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]) @@ -2060,7 +2066,22 @@ async def list_side_holes( "WHERE round_side_id = $1 ORDER BY hole_number", 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): @@ -2105,6 +2126,36 @@ async def update_side_hole( # 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): play_format: str 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 # Skins: 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]: @@ -2149,48 +2205,63 @@ async def get_round_format_result( play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) 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( """ - 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 JOIN round_side rs ON rs.id = rh.round_side_id WHERE rs.round_id = $1 """, 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: # Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv # (samme som _relative_strokes_for_round sin nøkkel for - # disse formatene), IKKE siden -- hvilken side hver deltaker - # tilhører slås opp separat via unit_to_side under. + # disse formatene). Henter navn+side-tilhørighet i samme + # spørring (én rundtur, ikke to). 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 JOIN round_participant rp ON rp.id = rh.round_participant_id WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL """, 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] = {} stroke_index_by_hole: dict[int, int] = {} + par_by_hole: dict[int, int] = {} for r in rows: if r["played"] and r["score"] is not None: by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"]) 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 - side_ids = sorted(relative.keys()) if engine_format in SIDE_IS_UNIT else None - if side_ids is None: - # Individuelle enheter er round_participant_id-er -- hent - # 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} + if engine_format in SIDE_IS_UNIT: + side_ids = sorted(relative.keys()) + else: 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: @@ -2201,21 +2272,24 @@ async def get_round_format_result( for unit, v in relative.items() } 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] - 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: - scores = by_unit_hole.get((side_id, hole_number), []) - if len(scores) != 1: - return None - per_hole = strokes_per_hole.get(side_id) - if per_hole is None: - return None - return scores[0] - per_hole[hole_number] + return [side_id] + return [uid for uid, sid in unit_to_side.items() if sid == side_id] + + # Delt ball -- siden ER enheten (nøyaktig én per side), uansett + # hvor mange SPILLERE formatet krever. "expected_players" (2 for + # foursome/greensome, 2 eller 4 for scramble) er PLAYER-antallet, + # 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 = [] - for unit_id, unit_side_id in unit_to_side.items(): - if unit_side_id != side_id: - continue + for unit_id in units_for_side(side_id): scores = by_unit_hole.get((unit_id, hole_number), []) if len(scores) != 1: return None @@ -2223,11 +2297,18 @@ async def get_round_format_result( if per_hole is None: return None nets.append(scores[0] - per_hole[hole_number]) - if len(nets) != expected_players: + if len(nets) != required_units: return None return min(nets) 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: net_a = side_net(hole_number, side_a_id) net_b = side_net(hole_number, side_b_id) @@ -2240,6 +2321,38 @@ async def get_round_format_result( else: 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"]) return RoundFormatResultOut( play_format=play_format, @@ -2251,16 +2364,23 @@ async def get_round_format_result( match_is_dormie=state.is_dormie, match_status_text=state.describe(), hole_results=[_RESULT_LABEL[r] for r in hole_results], + holes=holes, ) if play_format == "skins": 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, ) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} 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 WHERE rp.round_id = $1 ORDER BY rh.hole_number @@ -2273,18 +2393,25 @@ async def get_round_format_result( for r in hole_rows: by_participant_holes.setdefault(r["participant_id"], []).append(r) 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) if ch is None: continue - allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes]) - strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes, allocation)} + 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_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]]] = {} 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: 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"]) if per_hole is None: continue @@ -2295,8 +2422,37 @@ async def get_round_format_result( 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] - skins_won = compute_skins(scores_by_hole, tie_handling=round_row["skins_tie_handling"]) - return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won) + skins_won, hole_log = compute_skins_detail(scores_by_hole, tie_handling=round_row["skins_tie_handling"]) + + # 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) diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index bdf8a1e..1eaffad 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -28,10 +28,12 @@ import { MapPin, Minus, Plus, + RefreshCw, Search, Settings2, Target, Trophy, + WifiOff, X, } from "lucide-react" import { Badge } from "@/components/ui/badge" @@ -40,6 +42,7 @@ import { RoundLeaderboardMini } from "@/components/round-leaderboard" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { cn } from "@/lib/utils" +import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" // --- Types ----------------------------------------------------------------- @@ -387,6 +390,21 @@ export function RoundDetail({ roundId }: { roundId: string }) { // SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette. const [wizardSideId, setWizardSideId] = useState(null) const [holesBySide, setHolesBySide] = useState>({}) + + // --- 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>(new Set()) + const [pendingSideHoles, setPendingSideHoles] = useState>(new Set()) // Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger // 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 @@ -483,6 +501,122 @@ export function RoundDetail({ roundId }: { roundId: string }) { } }, [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, + ) { + 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() + const syncedSides = new Set() + 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- // handleren under, uten å måtte koble socket-en til/fra ved hver ny // runde-lasting (en vanlig closure over `round` i effektens @@ -571,12 +705,25 @@ export function RoundDetail({ roundId }: { roundId: string }) { async function updateWizardStat(patch: Partial) { if (readOnly || !wizardPlayerId) return const merged: HoleStat = { ...wizardStat, ...patch } - const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(statToPatchBody(merged)), - }) + 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", + 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 const updated: ApiHole = await res.json() setHolesByParticipant((prev) => ({ @@ -634,12 +781,23 @@ export function RoundDetail({ roundId }: { roundId: string }) { async function updateWizardSideScore(score: number | null) { if (readOnly || !wizardSideId) return - const res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ played: true, score }), - }) + 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", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }) + } catch { + await queueSideHoleWrite(wizardSideId, activeHole, body) + return + } if (!res.ok) return const updated: ApiSideHole = await res.json() setHolesBySide((prev) => ({ @@ -894,6 +1052,33 @@ export function RoundDetail({ roundId }: { roundId: string }) { + {/* Offline scoreregistrering (ADR-028-utvidelse) -- synlig uansett + fane, siden offline-tilstanden er global for hele siden. */} + {!isOnline && ( +
+
+ )} + {pendingCount > 0 && ( +
+ + {pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering. + + {isOnline && ( + + )} +
+ )} + {pageTab === "manage" && ( <> {/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for @@ -1126,6 +1311,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { onClose={() => setWizardSideId(null)} onAdvance={advanceWizardSide} readOnly={readOnly} + isPending={pendingSideHoles.has(`${wizardSide.id}:${activeHole}`)} /> )} {wizardPlayer && hole && ( @@ -1139,6 +1325,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { onAdvance={advanceWizardPlayer} readOnly={readOnly} ownBagClubs={ownBagClubs} + isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)} /> )} @@ -1192,6 +1379,7 @@ function ScoringWizard({ onAdvance, readOnly, ownBagClubs, + isPending, }: { players: Player[] player: Player @@ -1202,6 +1390,10 @@ function ScoringWizard({ onAdvance: () => void readOnly: boolean 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 isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1 @@ -1300,6 +1492,12 @@ function ScoringWizard({ ))} + {isPending && ( +

+ Lagret lokalt · venter på synk +

+ )} +
{step === "strokes" && ( @@ -3779,6 +3977,7 @@ function SideScoreWizard({ onClose, onAdvance, readOnly, + isPending, }: { sides: ApiSide[] side: ApiSide @@ -3788,6 +3987,9 @@ function SideScoreWizard({ onClose: () => void onAdvance: () => void 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 isLast = sideIndex === -1 || sideIndex === sides.length - 1 @@ -3852,6 +4054,12 @@ function SideScoreWizard({ ))}
+ {isPending && ( +

+ Lagret lokalt · venter på synk +

+ )} +
(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 ( +
+ Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises. +
+ ) + } + + const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null + + return ( +
+
+ Matchstatus + {result.match_is_closed && Avgjort} + {result.match_is_dormie && !result.match_is_closed && Dormie} +
+ {statusText && {statusText}} + + {result.match_holes_played} hull spilt + {result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`} + + {result.hole_results && result.hole_results.length > 0 && ( +
    + {result.hole_results.map((r, i) => ( +
  • + {r === "halved" ? "½" : r.toUpperCase()} +
  • + ))} +
+ )} + + Se hull for hull med brutto/netto → + +
+ ) +} + // --- Full leaderboard-side ----------------------------------------------- function useLeaderboardData(roundId: string) { @@ -375,7 +492,7 @@ function useLeaderboardData(roundId: string) { 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 = { @@ -385,7 +502,7 @@ const MODE_MISSING_LABEL: Record = { } 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("gross") // Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26, // 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 isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format) return (
@@ -439,12 +557,22 @@ export function RoundLeaderboard({ roundId }: { roundId: string }) {
-
-

Stilling

- -
+ {/* 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 ? ( + + ) : ( + <> +
+

Stilling

+ +
- {/* É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 avrundet/rammet inn; radene skilles med en tynn linje (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 }) { })} - {mode !== "gross" && ranked.some((e) => e.rank === null) && ( -

- Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering. -

+ {mode !== "gross" && ranked.some((e) => e.rank === null) && ( +

+ Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering. +

+ )} + )}
@@ -525,6 +655,13 @@ export function RoundLeaderboardMini({ roundId }: { roundId: string }) { 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 top = ranked.slice(0, 3) const restCount = ranked.length - top.length diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx index fa46a0c..6d34a58 100644 --- a/frontend/components/round-scorecard.tsx +++ b/frontend/components/round-scorecard.tsx @@ -11,6 +11,22 @@ // `strokes_received` per hull (samme `allocate_strokes_by_index()`- // algoritme som resten av appen), for å unngå to ulike implementasjoner av // 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 Link from "next/link" @@ -25,6 +41,12 @@ type ApiParticipant = { guest_name: string | null display_name: string is_owner: boolean + round_side_id: string | null +} + +type ApiSide = { + id: string + label: string | null } type ApiRound = { @@ -35,7 +57,9 @@ type ApiRound = { played_at: string start_hole: number holes_planned: number + play_format: string participants: ApiParticipant[] + sides: ApiSide[] } type ApiHole = { @@ -47,6 +71,19 @@ type ApiHole = { 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 = { + 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" }) // 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 | null + holes: ApiFormatHoleOut[] | null +} + +function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null { + const [result, setResult] = useState(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 + if (result === "halved") { + return ( + + ½ + + ) + } + return ( + + {result.toUpperCase()} + + ) +} + +// É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 + return ( +
+ {entries.map((e) => ( +
1 && !e.counted && "opacity-40", + )} + > + {entries.length > 1 && ( + {e.label} + )} + + {e.gross ?? "–"} + {e.net !== null && e.net !== e.gross && ( + → {e.net} + )} + +
+ ))} +
+ ) +} + +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 ( +
+ Venter på at begge sider er komplette og har beregnet handicap før matchforløpet kan vises. +
+ ) + } + + if (result.holes.length === 0) { + return ( +
+ Ingen hull er avgjort ennå. Matchforløpet vises her hull for hull etter hvert som begge sider har registrert. +
+ ) + } + + return ( +
+
+

Matchforløp

+ {result.match_status_text && ( + {result.match_status_text} + )} +
+
+ + + + + + + + + + + + + + + + + + + {result.holes.map((h) => ( + + + + + + + + ))} + +
+ Hull + + Par + + {sideALabel} + + {sideBLabel} + + Res. +
{h.hole_number}{h.par} + e.side === "a")} /> + + e.side === "b")} /> + + + + +
+
+

+ 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)."} +

+
+ ) +} + +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 ( +
+

Skins, hull for hull

+
+ {result.holes.map((h) => ( +
+
+ + Hull {h.hole_number} · Par {h.par} + + {h.carried ? ( + + Uavgjort — rullet videre + + ) : h.winner_ids && h.winner_ids.length > 0 ? ( + + {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(", ")}`} + + ) : null} +
+
+ {h.entries.map((e) => { + const isWinner = h.winner_ids?.includes(e.id) ?? false + const shown = hasNet && e.net !== null ? e.net : e.gross + return ( + + {e.label}: {shown ?? "–"} + {hasNet && e.net !== null && e.gross !== null && e.net !== e.gross && ( + ({e.gross} brutto) + )} + + ) + })} +
+
+ ))} +
+

+ {hasNet ? "Skins avgjøres på netto (brutto vist i parentes)." : "Skins avgjøres på brutto."} Et uavgjort hull + ruller potten videre til neste hull. +

+
+ ) +} + // --- Screen ---------------------------------------------------------------- export function RoundScorecard({ roundId }: { roundId: string }) { const [round, setRound] = useState(null) - const [participantId, setParticipantId] = useState(null) + const [unitId, setUnitId] = useState(null) const [holes, setHoles] = useState(null) const [error, setError] = useState(null) const [viewerId, setViewerId] = useState(null) @@ -360,16 +651,22 @@ export function RoundScorecard({ roundId }: { roundId: string }) { const data: ApiRound = await res.json() if (cancelled) return setRound(data) - // Standardvalg: SIN EGEN rad hvis man er en lenket medspiller, - // ellers eieren (samme som før ADR-036 fase 3-utvidelsen). - setParticipantId( - (prev) => - prev ?? + 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, + // ellers eieren (samme som før ADR-036 fase 3-utvidelsen). + return ( (viewerId && data.participants.find((p) => p.user_id === viewerId)?.id) ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? - null, - ) + null + ) + }) } void load() return () => { @@ -378,10 +675,14 @@ export function RoundScorecard({ roundId }: { roundId: string }) { }, [roundId, viewerId, refreshKey]) 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 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 const data: ApiHole[] = await res.json() if (!cancelled) setHoles(data) @@ -390,7 +691,7 @@ export function RoundScorecard({ roundId }: { roundId: string }) { return () => { cancelled = true } - }, [roundId, participantId, refreshKey]) + }, [roundId, unitId, round?.play_format, refreshKey]) if (error) { return ( @@ -403,7 +704,51 @@ export function RoundScorecard({ roundId }: { roundId: string }) { ) } - if (!round || !holes) { + if (!round) { + return ( +
+ + ) + } + + 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 ( +
+
+
+ +
+
+
+
+ {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. +
+
+
+ ) + } + + 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 (
) diff --git a/frontend/components/round-stats.tsx b/frontend/components/round-stats.tsx index 75c3f37..32ef69c 100644 --- a/frontend/components/round-stats.tsx +++ b/frontend/components/round-stats.tsx @@ -371,9 +371,25 @@ type ApiRound = { played_at: string start_hole: number holes_planned: number + play_format: string 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 = { + foursome: "Foursome", + greensome: "Greensome", + scramble_2: "Scramble (2)", + scramble_4: "Scramble (4)", +} + type ApiHole = { hole_number: number par: number @@ -630,7 +646,9 @@ export function RoundStats({ roundId }: { roundId: string }) { }, [roundId, viewerId, refreshKey]) 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 async function loadHoles() { const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" }) @@ -642,7 +660,7 @@ export function RoundStats({ roundId }: { roundId: string }) { return () => { cancelled = true } - }, [roundId, participantId, refreshKey]) + }, [roundId, participantId, round, refreshKey]) if (error) { return ( @@ -655,7 +673,47 @@ export function RoundStats({ roundId }: { roundId: string }) { ) } - if (!round || !holes) { + if (!round) { + return ( +
+ + ) + } + + if (SHARED_BALL_FORMATS.includes(round.play_format)) { + return ( +
+
+
+ +
+
+
+
+ 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.{" "} + + Se scorekortet + {" "} + for hull-for-hull-forløpet i stedet. +
+
+
+ ) + } + + if (!holes) { return (