From 30c114a2de45f4be508ced53b3426782562a2300 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 29 Jul 2026 15:46:49 +0200 Subject: [PATCH] =?UTF-8?q?Rundeboksen=20p=C3=A5=20dashboardet=20(og=20p?= =?UTF-8?q?=C3=A5=20"Egne=20runder"/"Spilte=20baner"):?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For match-formater viser den nå spillerens eget resultat, viewer-relativt — "1 UP"/"AS"/"Vunnet 2&1" osv. (aldri en generisk side A/B-tekst), farget grønt ved ledelse og oransje ved etterslep. Vises nå både mens runden pågår og etter fullføring, ikke bare etterpå som før. For vanlig slagspill viser den fortsatt brutto "Til par", men har nå fått et eget "Netto"-felt ved siden av når HCP faktisk spores. Scorekortet (både match- og slagspill-formater, individuell- og delt-ball-grid): en liten blå "−N" vises nå inni den tomme ruten når spilleren/siden faktisk mottar et slag på det hullet — synlig før du fyller ut noe, ikke bare som netto-tall etterpå. Verifisert grundig mot en isolert scratch-backend (både API-svar og full nettleser-gjennomgang i lys/mørk modus, match- og foursome-runder), ingen konsollfeil, ingen migrasjon nødvendig. --- CLAUDE.md | 144 +++++++++ app/routers/rounds.py | 76 +++++ frontend/components/course-rounds.tsx | 8 + frontend/components/dashboard.tsx | 8 + frontend/components/own-rounds.tsx | 8 + frontend/components/round-card.tsx | 36 ++- frontend/components/round-detail.tsx | 447 +++++++++++++++++++++----- 7 files changed, 646 insertions(+), 81 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e28944..e94aeae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5088,6 +5088,150 @@ Ferdig og verifisert: Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` → 200 upåvirket. +- **Match-scorekort-redesignet også på selve LIVE scoringssiden + (`/my-rounds/{id}` sin Score-fane), BYGGET, GRUNDIG SCRATCH-/ + BROWSERVERIFISERT OG LIVE (2026-07-29), samme dag rett etter forrige + punkt:** brukeren viste tre skjermbilder (referansen på nytt, samt et + mobil- OG et PC-skjermbilde av "hvordan du har løst det nå") og spurte + direkte hvorfor det ikke så riktig ut ennå. **Presis diagnose FØR noe ble + bygget:** PC-skjermbildets URL (`teecup.teeoff.no/my-rounds/{id}`, uten + `/scorecard`) avslørte at forrige runde kun traff `round-scorecard.tsx` + (en egen, separat LESE-visning) og `session-scorecard.tsx` (turnering) -- + aldri `round-detail.tsx` sin egen, ELDRE `FormatResultPanel`+ + `ScorecardGrid`/`SideScorecardGrid`, som er det brukeren faktisk ser og + bruker under selve scoringen. `FormatResultPanel` viste dessuten en + reell, synlig svakhet: falt tilbake til det generiske "Side B" i stedet + for spillerens faktiske navn når siden ikke hadde et eget satt navn. + **Bygget:** `ApiFormatResult` utvidet med samme `holes`-per-hull- + oppløsning som round-scorecard.tsx allerede har (ingen backend-endring + -- feltet fantes allerede i API-svaret, bare ikke lest her). Selve + fetch-en LØFTET fra `FormatResultPanel` opp til hovedkomponenten (`Round + Detail`) som ny delt `formatResult`-state, siden BÅDE banneret OG de to + scorekort-gridene nå trenger den samme dataen (én henting, ikke to). + `FormatResultPanel` gjort om til en ren presentasjonskomponent (tar + `result` som prop) med et nytt identitetsbanner (navn+HCP, farget prikk, + stor sentrert løpende status, "Ferdig"-merke) -- samme visuelle språk + som `MatchScorecardGrid`/`TournamentMatchGrid` fra forrige runde, egen + lokal kopi per prosjektets "ett sted, én fil"-konvensjon. Den tidligere + hull-for-hull-sirkel-listen under banneret FJERNET (overflødig nå som + selve gridet under viser dette per hull). + `ScorecardGrid` (individuell-ball: match/fourball) og `SideScorecardGrid` + (delt-ball: foursome/greensome/scramble) fikk begge: en farget prikk ved + siden av spiller-/side-navnet (grønn=side A, oransje=side B), en ny + `MatchScorecardCell` som farger scorecellen etter HVEM SOM VANT hullet + (fylt sirkel) i stedet for over/under par -- men KUN når formatet + faktisk er to-sidet (`isTwoSided`-prop, gate på `TWO_SIDED_FORMATS`) -- + slagspill og skins beholder uendret over/under-par-fargelegging siden de + ikke har noe "vant hullet"-konsept. Fourball sin ikke-tellende + partner-score tones ned (samme "laveste netto teller"-nedtoning som + round-scorecard.tsx). Ny "Stilling"-rad nederst i BEGGE grid, regnet med + en lokal `matchStillingByHole()` (samme prefiks-vise + `compute_match_state()`-speiling som forrige rundes `computeRunning()`, + egen kopi her siden denne trenger å være justert til `holeOrder`, ikke + bare en flat liste). Alt dette er BEVISST lagt OPPÅ den eksisterende + klikk-for-å-registrere-interaksjonen (ScoringWizard/SideScoreWizard) + uendret -- ingen endring i selve registreringsflyten, kun presentasjonen + av allerede registrerte hull. + **Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle + + isolert scratch-MinIO + engangs API-container, alle 37 migrasjoner kjørt + friskt): tre nye runder bygget fra bunnen via API for å dekke alle tre + strukturelle grenene -- en MATCH-runde (Erol vs. en gjest, ulik HCP), + en FOURBALL-runde (2v2, individuell netto, bevisst konstruert med et + hull der "feil" partner sin lavere brutto ikke telte pga. handicap- + justering), og en FOURSOME-runde (delt ball). Ekte typesjekket + produksjonsbuild kompilerte rent. + **Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP, + samme `localhost`-ikke-`127.0.0.1`-lærdom fra forrige runde anvendt fra + start denne gangen): alle tre rundene bekreftet visuelt -- identitets- + banneret viste ekte spillernavn+HCP (ikke lenger "Side B"), gridcellene + fargela nøyaktig riktig hull som vunnet (kryssjekket presist mot de + faktiske innsendte tallene og aria-labelene, inkl. et hull der en + tilsynelatende brutto-uavgjort (4-4) faktisk ble avgjort på netto pga. + et stort HCP-gap -- bekreftet korrekt, ikke en bug), fourball sin + nedtonede ikke-tellende partner-celle bekreftet nøyaktig på det + konstruerte hullet, Stilling-raden fulgte riktig gjennom AS/1 UP/2 UP- + mønsteret for alle tre rundene. Bekreftet at et klikk på en scorecelle + FORTSATT åpner riktig veiviser (`ScoringWizard`/`SideScoreWizard`) + uendret, i begge grid-typer. Kjørte i tillegg en ren REGRESJONSSJEKK + mot en vanlig `stroke`-formatert runde -- bekreftet ingen Stilling-rad, + ingen fargede prikker, ingen bytte av cellespråk (uendret over/under- + par-styling som før). Ingen konsollfeil i noen av rundene, i verken lys + eller mørk modus. + **Rullet ut live 2026-07-29**, bruker bekreftet eksplisitt (ba om fiksen + rett etter diagnosen): ingen migrasjon, `docker compose up -d --build + teecup_api teecup_frontend`. Begge containere boot-et rent, + `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. **Alle tre stedene + et match-scorekort vises i appen (frittstående runde sin live Score- + fane, frittstående runde sin egen lesevisning, turnering-match) bruker + nå samme konsistente visuelle språk.** + +- **Dashbordets rundeboks: viewer-relativt matchresultat + netto til-par, + pluss mottatte slag vist FØR hullet fylles ut, BYGGET, SCRATCH-/ + BROWSERVERIFISERT OG LIVE (2026-07-29), samme dag:** brukeren ba om to + ting samtidig -- at rundeboksen (`round-card.tsx`, brukt av dashbordet, + "Egne runder" og "Spilte baner") viser spillerens eget resultat i + matchen/runden ("1 UP"/"+4 netto"/"-2 brutto" osv.), og at det er + tydelig hvor mange slag en spiller MOTTAR på et hull -- kommunisert på + scorekortet, allerede FØR hullet er fylt ut (ikke bare etterpå som + netto-tallet i dag). Fire load-bærende avklaringer bekreftet eksplisitt + (AskUserQuestion, alle anbefalte valg): vis resultat BÅDE for pågående + og fullførte runder (ikke bare fullførte som før), samme "UP"/"AS"/ + "X&Y"-golfkonvensjon som scorekortets Stilling-rad (ikke et eget + dagligdags språk kun for dashbordkortet), vis BÅDE netto og brutto + til-par for slagspill når HCP spores, og vis slag-indikatoren INNI selve + den tomme score-ruten (ikke en egen ny rad). + **Backend (`app/routers/rounds.py`):** `RoundOut`/`_load_round_out` + (delt av BÅDE `GET /rounds` (liste) OG `GET /rounds/{id}`, ingen egen + kode for listen) fikk tre nye felt: `my_net_score_to_par` (kun + `play_format=="stroke"` OG `course_handicap_snapshot` satt, samme + `allocate_strokes_by_index`-algoritme som list_holes/update_hole + allerede bruker -- ingen ny utregningsmåte), og `my_match_status`/ + `my_match_lead` (to-sidede formater KUN, ALLTID viewer-relativt -- + ny `_viewer_match_status_text()`-formatter tar en fortegns-FLIPPET + `lead` fra `_build_format_result` sitt allerede eksisterende, testede + resultat -- gjenbrukt UENDRET, ikke en ny match-motor -- slik at + positivt alltid betyr "spørrende bruker leder", uansett hvilken side de + faktisk sitter på). Et avgjort resultat vises som "Vunnet 2&1"/"Tapt + 2&1" (eksplisitt prefiks, siden "2&1" alene ikke lenger sier hvem sin + side det gjaldt når visningen ikke er side-A/B-basert). + **Frontend (`round-card.tsx`):** ny `Round.matchStatus`/`matchLead`/ + `netToPar`. To-sidede formater viser ett "Resultat"-felt med + matchstatusen (farget grønn ved ledelse, oransje ved etterslep, samme + "form+farge"-språk som resten av appen -- ALDRI backend sin bokstavelige + side A/B). Slagspill beholder "Resultat"+"Til par" (brutto) og fikk et + nytt "Netto"-felt ved siden av, kun når tilgjengelig. Resultatraden er + nå IKKE lenger gatet til kun fullførte runder -- vises også mens runden + pågår. Alle tre kallstedene (`dashboard.tsx`, `own-rounds.tsx`, + `course-rounds.tsx`) oppdatert til å lese og videresende de tre nye + API-feltene, samme mønster i alle tre (ingen egen logikk per fil). + **Slag-indikator FØR utfylling (`round-detail.tsx`):** `ScorecardGrid` + (individuell-ball) og `SideScorecardGrid` (delt-ball) viser nå en liten + blå "−N"-tekst inni den tomme score-ruten når spilleren/siden faktisk + mottar minst ett slag på det hullet -- samme `strokes_received`-verdi + som allerede ble hentet (kun aldri vist før noe var registrert). Egen + farge (`text-info`, den etablerte blåtonen) for å skille denne FØR- + visningen tydelig fra den eksisterende grå netto-visningen ETTER + utfylling. **Reelt, tidligere ubrukt hull funnet underveis:** + `SideScorecardGrid` sin egen frontend-type `ApiSideHole` manglet + `strokes_received` helt (backend har alltid eksponert feltet i + `RoundSideHoleOut`, bare aldri lest her) -- lagt til. + **Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle + + isolert scratch-MinIO + engangs API-container, alle 37 migrasjoner kjørt + friskt): en slagspill-runde ga korrekt brutto til-par 4 / netto til-par + 2 (både i enkelt-GET og i listen), en matchrunde der spilleren vant + begge registrerte hull ga korrekt `"2 UP"`/`lead=2`. Deretter en FULL, + ekte nettleser-gjennomgang (samme `localhost`-lærdom anvendt fra start): + rundeboksen viste "2 UP" i grønt for en pågående matchrunde og "Resultat + 17 / Til par +4 / Netto +2" (oransje) for en pågående slagspill-runde, + i BÅDE lys og mørk modus; scorekortet viste "−1"-hint i blått på + nøyaktig de tomme rutene der spilleren/siden faktisk mottar et slag, + bekreftet i BÅDE individuell-ball- (match) og delt-ball-grid (foursome). + Ingen konsollfeil i noen av rundene. + **Rullet ut live 2026-07-29**, 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. + Neste steg: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 1f1f3d4..26b0c54 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -576,6 +576,22 @@ StatLevel = Literal["strokes_only", "strokes_and_putts", "full"] # Format-enum-navn ("singles") kun når handicap_engine.py/app.handicap # kalles, ikke noe sted ellers. _TWO_SIDED_FORMATS = {"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"} + + +def _viewer_match_status_text(lead: int, holes_remaining: int, is_closed: bool) -> str: + """Kort, VIEWER-relativ matchstatus (2026-07-29, dashbord-rundeboks- + runden) -- positivt lead betyr alltid "spørrende bruker leder", uansett + hvilken side de faktisk sitter på. Speiler MatchState.describe() i + handicap_engine.py, men uten bokstavelig "(A)"/"(B)" og med et eksplisitt + Vunnet/Tapt-prefiks for et avgjort resultat (siden "2&1" alene ikke + sier hvem sin side det gjaldt når det ikke lenger er en ren leder-visning).""" + margin = abs(lead) + if is_closed: + suffix = f"{margin}&{holes_remaining}" if holes_remaining > 0 else f"{margin} UP" + return f"Vunnet {suffix}" if lead > 0 else f"Tapt {suffix}" + if lead == 0: + return "AS" + return f"{margin} UP" if lead > 0 else f"{margin} DOWN" _ROUND_FORMAT_TO_ENGINE_FORMAT = { "match": "singles", "fourball": "fourball", @@ -717,6 +733,20 @@ class RoundOut(BaseModel): my_holes_played: int my_total_score: int | None my_score_to_par: int | None + # 2026-07-29 (dashbord-rundeboks-runden): netto til-par, KUN for + # slagspill (`play_format == "stroke"`) og kun når HCP faktisk spores + # for spilleren (course_handicap_snapshot satt). Samme allokerings- + # algoritme som list_holes/update_hole -- ikke en egen utregning. + my_net_score_to_par: int | None + # Viewer-relativ løpende matchstatus for to-sidede formater (match/ + # fourball/foursome/greensome/scramble), None for slagspill/skins eller + # før sidene er komplette. "1 UP"/"AS"/"1 DOWN"/"Vunnet 2&1"/"Tapt 3&2" + # -- ALDRI backend sin bokstavelige "(A)"/"(B)", speiler alltid + # SPØRRENDE bruker sin egen side. my_match_lead er samme fortegn (>0 = + # leder, <0 = ligger under) -- brukt av frontend til fargelegging uten + # å måtte parse teksten. + my_match_status: str | None + my_match_lead: int | None # ADR-036 Beslutning B (fase 2, 2026-07-28) -- kun eieren (eller en # medspiller, som ser dette som ren informasjon) trenger å se/endre # disse -- eksponert her siden dette ER den autentiserte eier-/ @@ -781,6 +811,49 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: my_score_to_par = ( viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None ) + + # Netto til-par (2026-07-29, dashbord-rundeboks-runden) -- kun slagspill, + # kun når HCP faktisk spores for spilleren. Bruker samme allokerings- + # algoritme som list_holes/update_hole -- ingen ny utregningsmåte. + my_net_score_to_par: int | None = None + if round_row["play_format"] == "stroke" and my_holes_played > 0: + target_row = next((r for r in participant_rows if r["id"] == target_id), None) + if target_row is not None and target_row["course_handicap_snapshot"] is not None: + hole_rows = await conn.fetch( + "SELECT hole_number, stroke_index, played, score FROM round_hole " + "WHERE round_participant_id = $1 ORDER BY hole_number", + target_id, + ) + if len(hole_rows) == 18: + allocation = allocate_strokes_by_index( + target_row["course_handicap_snapshot"], [r["stroke_index"] for r in hole_rows] + ) + strokes_by_hole = dict(zip(range(1, 19), allocation)) + total_strokes_received = sum( + strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None + ) + my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received + + # Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN. + # Gjenbruker _build_format_result uendret (samme sannhetskilde som selve + # scorekortet) og flipper fortegnet slik at positivt ALLTID betyr + # "spørrende bruker leder", uansett hvilken side de faktisk sitter på. + my_match_status: str | None = None + my_match_lead: int | None = None + if round_row["play_format"] in _TWO_SIDED_FORMATS: + target_row = next((r for r in participant_rows if r["id"] == target_id), None) + viewer_side_id = target_row["round_side_id"] if target_row is not None else None + if viewer_side_id is not None and len(side_rows) == 2: + fr = await _build_format_result(conn, round_id) + if fr.ready and fr.match_lead is not None and fr.match_holes_played: + sorted_side_ids = sorted(r["id"] for r in side_rows) + viewer_is_side_a = viewer_side_id == sorted_side_ids[0] + lead = fr.match_lead if viewer_is_side_a else -fr.match_lead + my_match_lead = lead + my_match_status = _viewer_match_status_text( + lead, fr.match_holes_remaining or 0, fr.match_is_closed or False + ) + setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"]) return RoundOut( id=round_row["id"], @@ -803,6 +876,9 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: my_holes_played=my_holes_played, my_total_score=my_total_score, my_score_to_par=my_score_to_par, + my_net_score_to_par=my_net_score_to_par, + my_match_status=my_match_status, + my_match_lead=my_match_lead, visibility_mode=round_row["visibility_mode"], visible_categories=[r["category"] for r in visible_category_rows], flight_group_id=round_row["flight_group_id"], diff --git a/frontend/components/course-rounds.tsx b/frontend/components/course-rounds.tsx index 7dc7d80..8b29038 100644 --- a/frontend/components/course-rounds.tsx +++ b/frontend/components/course-rounds.tsx @@ -36,6 +36,10 @@ type ApiRound = { my_holes_played: number my_total_score: number | null my_score_to_par: number | null + my_net_score_to_par: number | null + play_format: string + my_match_status: string | null + my_match_lead: number | null } function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round { @@ -52,6 +56,10 @@ function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: stri holesPlayed: r.my_holes_played, totalScore: r.my_total_score ?? undefined, toPar: r.my_score_to_par ?? undefined, + netToPar: r.my_net_score_to_par ?? undefined, + playFormat: r.play_format, + matchStatus: r.my_match_status, + matchLead: r.my_match_lead, differential: me?.counts_for_handicap ? me.score_differential : null, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, } diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 023d461..f20b26b 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -103,6 +103,10 @@ type ApiRound = { my_holes_played: number my_total_score: number | null my_score_to_par: number | null + my_net_score_to_par: number | null + play_format: string + my_match_status: string | null + my_match_lead: number | null } type ApiHandicapPoint = { handicap_index: number; recorded_at: string } @@ -132,6 +136,10 @@ function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: stri holesPlayed: r.my_holes_played, totalScore: r.my_total_score ?? undefined, toPar: r.my_score_to_par ?? undefined, + netToPar: r.my_net_score_to_par ?? undefined, + playFormat: r.play_format, + matchStatus: r.my_match_status, + matchLead: r.my_match_lead, differential: me?.counts_for_handicap ? me.score_differential : null, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, } diff --git a/frontend/components/own-rounds.tsx b/frontend/components/own-rounds.tsx index 2b7dca9..ea1786a 100644 --- a/frontend/components/own-rounds.tsx +++ b/frontend/components/own-rounds.tsx @@ -34,6 +34,10 @@ type ApiRound = { my_holes_played: number my_total_score: number | null my_score_to_par: number | null + my_net_score_to_par: number | null + play_format: string + my_match_status: string | null + my_match_lead: number | null } // viewerId er null helt til /auth/me har svart -- faller da tilbake til @@ -53,6 +57,10 @@ function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: stri holesPlayed: r.my_holes_played, totalScore: r.my_total_score ?? undefined, toPar: r.my_score_to_par ?? undefined, + netToPar: r.my_net_score_to_par ?? undefined, + playFormat: r.play_format, + matchStatus: r.my_match_status, + matchLead: r.my_match_lead, differential, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, } diff --git a/frontend/components/round-card.tsx b/frontend/components/round-card.tsx index ca5fe94..120dd81 100644 --- a/frontend/components/round-card.tsx +++ b/frontend/components/round-card.tsx @@ -19,6 +19,16 @@ export type Round = { // Owner's score, present once at least one hole is recorded. totalScore?: number toPar?: number + // Netto til-par (2026-07-29) -- kun slagspill, kun når HCP faktisk + // spores for spilleren (samme betingelse som differensial-chipen). + netToPar?: number + // To-sidede formater (match/fourball/foursome/greensome/scramble, + // 2026-07-29): VIEWER-relativ løpende status ("1 UP"/"AS"/"Vunnet 2&1"), + // aldri backend sin bokstavelige side A/B. matchLead sitt fortegn (>0 = + // spilleren leder) styrer fargen -- unngår å måtte parse teksten. + playFormat?: string + matchStatus?: string | null + matchLead?: number | null // Handicap differential, only when a completed round counted (one decimal). differential?: number | null // Lowest score-to-par among the viewer's own completed rounds (computed @@ -111,18 +121,32 @@ export function RoundCard({ round }: { round: Round }) { : null const showPersonalBest = round.status === "completed" && round.isPersonalBest === true + // To-sidede formater (match/fourball/foursome/greensome/scramble, + // 2026-07-29): vis matchStatus i stedet for rå slagtall -- brutto + // slagtotal alene sier lite i matchspill. Alltid VIEWER-relativ tekst + // (aldri backend sin bokstavelige side A/B), samme kilde uansett om + // runden pågår eller er fullført (bruker bekreftet begge). + const hasMatchStatus = typeof round.matchStatus === "string" && round.matchStatus.length > 0 + const matchLead = round.matchLead ?? 0 + const matchStatusClassName = matchLead > 0 ? "text-primary" : matchLead < 0 ? "text-brand-orange" : "" + // Concise summary so the compact score tile reads clearly for screen readers. - const scoreSummary = - round.status === "completed" && hasScore + const scoreSummary = hasMatchStatus + ? `Resultat ${round.matchStatus}.` + : hasScore ? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.` : `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.` const displayTitle = round.name?.trim() || round.courseName const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}` const played = round.holesPlayed ?? 0 - const showResultRow = round.status === "completed" && hasScore + // Resultat vises nå BÅDE mens runden pågår og etter fullføring (2026-07-29, + // bekreftet av bruker) -- ikke lenger gatet til kun fullførte runder. + const showResultRow = !hasMatchStatus && hasScore const toPar = round.toPar as number | undefined const toParClassName = toPar === undefined ? "" : toPar > 0 ? "text-brand-orange" : toPar < 0 ? "text-primary" : "" + const netToPar = round.netToPar + const netToParClassName = netToPar === undefined ? "" : netToPar > 0 ? "text-brand-orange" : netToPar < 0 ? "text-primary" : "" return ( // Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve @@ -176,10 +200,16 @@ export function RoundCard({ round }: { round: Round }) { + {hasMatchStatus && ( + + )} {showResultRow && ( <> + {netToPar !== undefined && ( + + )} )} diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 373e1ca..684b74c 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -318,6 +318,10 @@ type ApiSideHole = { // Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt // (migrasjon 036) -- helt valgfritt, null = ikke registrert. selected_participant_id: string | null + // Sidens kombinerte playing_handicap sin allokering for DETTE hullet -- + // fantes allerede i API-svaret (RoundSideHoleOut), bare aldri lest her + // før nå (2026-07-29, "vis slag mottatt før hullet er fylt ut"). + strokes_received: number | null } function apiHoleToStat(h: ApiHole): HoleStat { @@ -389,8 +393,13 @@ export function RoundDetail({ roundId }: { roundId: string }) { const [currentHole, setCurrentHole] = useState(null) // ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins- // tavlen skjer (hull-registrering, websocket-signal), slik at - // FormatResultPanel vet den skal hente på nytt. + // formatResult-hentingen under vet den skal hente på nytt. const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0) + // Løftet OPP hit fra FormatResultPanel (2026-07-29) -- ScorecardGrid/ + // SideScorecardGrid trenger NÅ den samme per-hull-oppløsningen (hvem vant + // hvilket hull) for å farge scorecellene og vise en løpende Stilling-rad, + // ikke bare selve statusbanneret. Én henting, delt av begge. + const [formatResult, setFormatResult] = useState(null) const [showAddGuest, setShowAddGuest] = useState(false) // Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/ // spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26, @@ -468,6 +477,27 @@ export function RoundDetail({ roundId }: { roundId: string }) { void loadRound() }, [loadRound]) + // Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri + // lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid + // (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill trenger + // ingen egen henting. + useEffect(() => { + if (!round || round.play_format === "stroke") { + setFormatResult(null) + return + } + let cancelled = false + fetch(`/rounds/${roundId}/format-result`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: ApiFormatResult | null) => { + if (!cancelled) setFormatResult(data) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [roundId, round?.play_format, formatResultRefreshTick]) + useEffect(() => { let cancelled = false fetch("/auth/me", { credentials: "include" }) @@ -1134,7 +1164,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { {/* Løpende matchstatus (to-sidede formater) eller skins-tavle (ADR-039) -- avledet ved lesing, aldri lagret. */} - + {/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md. @@ -1249,7 +1279,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { etterspurt av bruker -- fantes fra før KUN under "Spillere og runde", lett å overse siden scoring naturlig skjer her). Samme komponent, ikke duplisert logikk. */} - + {/* Completed summary banner */} {completed && ( @@ -1282,6 +1312,8 @@ export function RoundDetail({ roundId }: { roundId: string }) { holesPlanned={round.holes_planned} currentHole={activeHole} readOnly={readOnly} + isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)} + formatResult={formatResult} onSelectHole={(n) => { setCurrentHole(n) scrollToHolePanel() @@ -1324,6 +1356,9 @@ export function RoundDetail({ roundId }: { roundId: string }) { holesPlanned={round.holes_planned} currentHole={activeHole} readOnly={readOnly} + sides={round.sides} + isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)} + formatResult={formatResult} onSelectHole={(n) => { setCurrentHole(n) scrollToHolePanel() @@ -2252,6 +2287,30 @@ function SidesPanel({ // filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger // ingen egen visning her (eksisterende leaderboard dekker det). +// Per-hull-oppløsning (2026-07-29, samme kontrakt som round-scorecard.tsx sin +// ApiFormatHoleEntry/ApiFormatHoleOut) -- gjør det mulig å farge hver +// scorecelle etter hvem som VANT hullet, og å regne en løpende Stilling-rad, +// i stedet for kun et sluttresultat. +type ApiFormatHoleEntry = { + id: string + label: string + side: "a" | "b" | null + gross: number | null + net: number | null + strokes_received: number | null + counted: boolean | null +} + +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 @@ -2263,14 +2322,78 @@ type ApiFormatResult = { match_status_text: string | null hole_results: string[] | null skins_won: Record | null + holes: ApiFormatHoleOut[] | null } -// 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)})`) +// Kort statustekst UTEN backend sin bokstavelige "(A)"/"(B)"-suffiks -- +// identitetsbanneret viser navnet separat (se IdentitySide), så suffikset +// ville vært en duplikat. Speiler handicap_engine.py sin +// MatchState.describe() presist. +function formatMatchStatus(lead: number, holesRemaining: number, isClosed: boolean, isDormie: boolean): string { + const margin = Math.abs(lead) + if (isClosed) return holesRemaining === 0 && margin > 0 ? `${margin} UP` : `${margin}&${holesRemaining}` + if (lead === 0) return "AS" + return isDormie ? `Dormie ${margin}` : `${margin} UP` +} + +// Speiler samme funksjon i handicap_engine.py/round-scorecard.tsx, men +// regnet ETT PREFIKS om gangen langs `holeOrder` (spillerekkefølgen, ikke +// nødvendigvis fysisk hullnummer) -- gir en løpende stilling PER HULL i +// stedet for kun sluttresultatet. Stopper (viser ingen verdi videre) ved +// første hull uten et registrert resultat, siden hull avgjøres sekvensielt. +function matchStillingByHole( + holeOrder: number[], + formatResult: ApiFormatResult | null, + totalHoles: number, +): Map { + const out = new Map() + if (!formatResult?.holes) return out + const byHole = new Map(formatResult.holes.map((h) => [h.hole_number, h])) + let lead = 0 + let holesPlayed = 0 + for (const n of holeOrder) { + const h = byHole.get(n) + if (!h || !h.result) break + holesPlayed += 1 + lead += h.result === "a" ? 1 : h.result === "b" ? -1 : 0 + const holesRemaining = totalHoles - holesPlayed + const isClosed = Math.abs(lead) > holesRemaining + const isDormie = !isClosed && holesRemaining > 0 && Math.abs(lead) === holesRemaining + out.set(n, { + label: formatMatchStatus(lead, holesRemaining, isClosed, isDormie), + tone: lead > 0 ? "a" : lead < 0 ? "b" : "neutral", + }) + } + return out +} + +// Identitet (navn+HCP) for en side i banneret/gridet -- viser SPILLERENS +// navn når nøyaktig én er tildelt siden (match, eller en komplett foursome- +// side sett fra én spiller... nei, alltid nøyaktig 1 for match), ellers +// sidens eget (evt. generiske) navn uten noen entydig HCP å vise. +function sideIdentity(round: ApiRound, players: Player[], side: "a" | "b"): { name: string; hcp: number | null } { + const idx = side === "a" ? 0 : 1 + const sideId = round.sides[idx]?.id + const sidePlayers = players.filter((p) => p.roundSideId === sideId) + if (sidePlayers.length === 1) return { name: sidePlayers[0].name, hcp: sidePlayers[0].playingHandicap } + return { name: round.sides[idx]?.label?.trim() || (side === "a" ? "Side A" : "Side B"), hcp: null } +} + +function IdentitySide({ identity, side }: { identity: { name: string; hcp: number | null }; side: "a" | "b" }) { + return ( +
+
+
+ {identity.hcp !== null && ( + HCP {identity.hcp} + )} +
+ ) } type ApiFlightSummary = { @@ -2411,32 +2534,14 @@ function FlightGroupPanel({ } function FormatResultPanel({ - roundId, round, players, - refreshTick, + result, }: { - roundId: string round: ApiRound players: Player[] - refreshTick: number + result: ApiFormatResult | null }) { - const [result, setResult] = useState(null) - - useEffect(() => { - if (round.play_format === "stroke") return - 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, round.play_format, refreshTick]) - if (round.play_format === "stroke" || !result) return null if (round.play_format === "skins") { @@ -2474,50 +2579,55 @@ function FormatResultPanel({ ) } - const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, round.sides) : null + // Identitetsbanner (2026-07-29, erstatter den tidligere plainere teksten + + // den nå overflødige hull-for-hull-sirkel-listen -- selve scorekortet + // under viser dette per hull nå). Navn+HCP enten side, stor sentrert + // løpende status, "Ferdig"-merke når avgjort -- samme visuelle språk som + // MatchScorecardGrid (round-scorecard.tsx) og TournamentMatchGrid + // (session-scorecard.tsx), egen lokal kopi per prosjektets konvensjon. + const identityA = sideIdentity(round, players, "a") + const identityB = sideIdentity(round, players, "b") + const lead = result.match_lead ?? 0 + const holesPlayed = result.match_holes_played ?? 0 + const holesRemaining = result.match_holes_remaining ?? 0 + const isClosed = result.match_is_closed ?? false + const isDormie = result.match_is_dormie ?? false + const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral" + const statusLabel = holesPlayed > 0 ? formatMatchStatus(lead, holesRemaining, isClosed, isDormie) : null return ( -
-
- {FORMAT_LABELS[round.play_format] ?? round.play_format} - {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) => ( -
  • + {FORMAT_LABELS[round.play_format] ?? round.play_format} +
    + +
    + {statusLabel ? ( + <> + + {statusLabel} + + {isClosed ? ( + + Ferdig + + ) : ( + + {holesPlayed} hull spilt + {holesRemaining > 0 && isDormie && " · dormie"} + )} - > - {r === "halved" ? "½" : r.toUpperCase()} -
  • - ))} -
- )} + + ) : ( + Ingen hull spilt ennå + )} +
+ + ) } @@ -3755,6 +3865,9 @@ function ScorecardGrid({ holesPlanned, currentHole, readOnly, + sides, + isTwoSided, + formatResult, onSelectHole, onOpenEntry, }: { @@ -3764,6 +3877,12 @@ function ScorecardGrid({ holesPlanned: number currentHole: number readOnly: boolean + // Match-aware fargelegging + Stilling-rad (2026-07-29) -- kun for + // to-sidede individuell-ball-formater (match/fourball), aldri slagspill/ + // skins (ingen sider/lead-begrep der). + sides: ApiSide[] + isTwoSided: boolean + formatResult: ApiFormatResult | null onSelectHole: (holeNumber: number) => void onOpenEntry: (playerId: string, holeNumber: number) => void }) { @@ -3797,6 +3916,18 @@ function ScorecardGrid({ const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) + // Match-aware fargelegging (2026-07-29): entries[].id == round_participant.id + // for individuell-ball-formater (match/fourball) -- se _build_format_result + // i app/routers/rounds.py. Kun relevant når formatResult faktisk er klart. + const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes) + const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h])) + const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map() + function sideOf(playerId: string): "a" | "b" | null { + const p = players.find((x) => x.id === playerId) + if (!p?.roundSideId) return null + return p.roundSideId === sides[0]?.id ? "a" : "b" + } + return (
@@ -3897,6 +4028,7 @@ function ScorecardGrid({ const frontSum = showSplit ? sumFor(player.id, front) : null const backSum = showSplit ? sumFor(player.id, back) : null const totalSum = sumFor(player.id, holeOrder) + const playerSide = showMatchColors ? sideOf(player.id) : null return ( ) @@ -3971,6 +4138,30 @@ function ScorecardGrid({ ) })} + {showMatchColors && ( + + + {holeOrder.map((n) => { + const s = stillingByHole.get(n) + return ( + + ) + })} + {showSplit && + )}
@@ -3908,6 +4040,15 @@ function ScorecardGrid({ className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60" > + {playerSide && ( +
+ Stilling + + {s?.label ?? "–"} + } + {showSplit && } + +
@@ -3995,6 +4186,39 @@ function ScorecardCell({ value, diff }: { value: number | null; diff: number | n return {value} } +// Match-aware variant av ScorecardCell (2026-07-29) -- fargen følger HVEM SOM +// VANT hullet (grønn=side A, oransje=side B), ikke over/under par, siden +// det er det som betyr noe i en match. Nedtonet tekst for en fourball- +// partner sin ikke-tellende score (laveste netto teller for siden). +function MatchScorecardCell({ + value, + side, + won, + dimmed, +}: { + value: number | null + side: "a" | "b" | null + won: boolean + dimmed: boolean +}) { + const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums" + if (value === null) return + if (won) { + return ( + + {value} + + ) + } + return {value} +} + // --- Statistikknivå-velger --------------------------------------------------- // "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt // overflødig -- score settes allerede automatisk til "spilt" idet et @@ -4031,6 +4255,8 @@ function SideScorecardGrid({ holesPlanned, currentHole, readOnly, + isTwoSided, + formatResult, onSelectHole, onOpenEntry, }: { @@ -4041,6 +4267,12 @@ function SideScorecardGrid({ holesPlanned: number currentHole: number readOnly: boolean + // Match-aware fargelegging + Stilling-rad (2026-07-29) -- delt-ball- + // formater (foursome/greensome/scramble) er ALLE to-sidede per ADR-039, + // så denne er reelt alltid sann her, men holdt eksplisitt for symmetri + // med ScorecardGrid. + isTwoSided: boolean + formatResult: ApiFormatResult | null onSelectHole: (holeNumber: number) => void onOpenEntry: (sideId: string, holeNumber: number) => void }) { @@ -4070,6 +4302,12 @@ function SideScorecardGrid({ const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) + // entries[].id == round_side.id for delt-ball-formater (samme + // _build_format_result-kontrakt som ScorecardGrid bruker for spillere). + const showMatchColors = isTwoSided && Boolean(formatResult?.ready) && Boolean(formatResult?.holes) + const formatHolesByNumber = new Map((formatResult?.holes ?? []).map((h) => [h.hole_number, h])) + const stillingByHole = showMatchColors ? matchStillingByHole(holeOrder, formatResult, holesPlanned) : new Map() + return (
@@ -4168,6 +4406,7 @@ function SideScorecardGrid({ const backSum = showSplit ? sumFor(side.id, back) : null const totalSum = sumFor(side.id, holeOrder) const assignedNames = players.filter((p) => p.roundSideId === side.id).map((p) => p.name) + const sideTone: "a" | "b" = sideIndex === 0 ? "a" : "b" return ( ) @@ -4234,6 +4501,30 @@ function SideScorecardGrid({ ) })} + {showMatchColors && ( + + + {holeOrder.map((n) => { + const s = stillingByHole.get(n) + return ( + + ) + })} + {showSplit && + )}
@@ -4178,8 +4417,16 @@ function SideScorecardGrid({ aria-label={`Registrer score for ${side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} på hull ${currentHole}`} className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60" > - - {side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} + + {showMatchColors && ( + {assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"} @@ -4191,6 +4438,9 @@ function SideScorecardGrid({ const meta = holeMeta[n] const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null + const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined + const entry = formatHole?.entries.find((e) => e.id === side.id) + const won = Boolean(entry && formatHole?.result === sideTone) return (
+ Stilling + + {s?.label ?? "–"} + } + {showSplit && } + +