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