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 && } + +