Rundeboksen på dashboardet (og på "Egne runder"/"Spilte baner"):

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.
This commit is contained in:
Erol Haagenrud 2026-07-29 15:46:49 +02:00
parent 7c3f5c3a7a
commit 30c114a2de
7 changed files with 646 additions and 81 deletions

144
CLAUDE.md
View file

@ -5088,6 +5088,150 @@ Ferdig og verifisert:
Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
→ 200 upåvirket. → 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: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen (2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen

View file

@ -576,6 +576,22 @@ StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
# Format-enum-navn ("singles") kun når handicap_engine.py/app.handicap # Format-enum-navn ("singles") kun når handicap_engine.py/app.handicap
# kalles, ikke noe sted ellers. # kalles, ikke noe sted ellers.
_TWO_SIDED_FORMATS = {"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"} _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 . 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 = { _ROUND_FORMAT_TO_ENGINE_FORMAT = {
"match": "singles", "match": "singles",
"fourball": "fourball", "fourball": "fourball",
@ -717,6 +733,20 @@ class RoundOut(BaseModel):
my_holes_played: int my_holes_played: int
my_total_score: int | None my_total_score: int | None
my_score_to_par: 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 # ADR-036 Beslutning B (fase 2, 2026-07-28) -- kun eieren (eller en
# medspiller, som ser dette som ren informasjon) trenger å se/endre # medspiller, som ser dette som ren informasjon) trenger å se/endre
# disse -- eksponert her siden dette ER den autentiserte eier-/ # 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 = ( my_score_to_par = (
viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None 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"]) setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
return RoundOut( return RoundOut(
id=round_row["id"], 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_holes_played=my_holes_played,
my_total_score=my_total_score, my_total_score=my_total_score,
my_score_to_par=my_score_to_par, 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"], visibility_mode=round_row["visibility_mode"],
visible_categories=[r["category"] for r in visible_category_rows], visible_categories=[r["category"] for r in visible_category_rows],
flight_group_id=round_row["flight_group_id"], flight_group_id=round_row["flight_group_id"],

View file

@ -36,6 +36,10 @@ type ApiRound = {
my_holes_played: number my_holes_played: number
my_total_score: number | null my_total_score: number | null
my_score_to_par: 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 { 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, holesPlayed: r.my_holes_played,
totalScore: r.my_total_score ?? undefined, totalScore: r.my_total_score ?? undefined,
toPar: r.my_score_to_par ?? 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, differential: me?.counts_for_handicap ? me.score_differential : null,
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
} }

View file

@ -103,6 +103,10 @@ type ApiRound = {
my_holes_played: number my_holes_played: number
my_total_score: number | null my_total_score: number | null
my_score_to_par: 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 } 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, holesPlayed: r.my_holes_played,
totalScore: r.my_total_score ?? undefined, totalScore: r.my_total_score ?? undefined,
toPar: r.my_score_to_par ?? 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, differential: me?.counts_for_handicap ? me.score_differential : null,
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
} }

View file

@ -34,6 +34,10 @@ type ApiRound = {
my_holes_played: number my_holes_played: number
my_total_score: number | null my_total_score: number | null
my_score_to_par: 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 // 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, holesPlayed: r.my_holes_played,
totalScore: r.my_total_score ?? undefined, totalScore: r.my_total_score ?? undefined,
toPar: r.my_score_to_par ?? 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, differential,
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId, isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
} }

View file

@ -19,6 +19,16 @@ export type Round = {
// Owner's score, present once at least one hole is recorded. // Owner's score, present once at least one hole is recorded.
totalScore?: number totalScore?: number
toPar?: 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). // Handicap differential, only when a completed round counted (one decimal).
differential?: number | null differential?: number | null
// Lowest score-to-par among the viewer's own completed rounds (computed // Lowest score-to-par among the viewer's own completed rounds (computed
@ -111,18 +121,32 @@ export function RoundCard({ round }: { round: Round }) {
: null : null
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true 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. // Concise summary so the compact score tile reads clearly for screen readers.
const scoreSummary = const scoreSummary = hasMatchStatus
round.status === "completed" && hasScore ? `Resultat ${round.matchStatus}.`
: hasScore
? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.` ? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.`
: `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.` : `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.`
const displayTitle = round.name?.trim() || round.courseName const displayTitle = round.name?.trim() || round.courseName
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}` const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
const played = round.holesPlayed ?? 0 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 toPar = round.toPar as number | undefined
const toParClassName = toPar === undefined ? "" : toPar > 0 ? "text-brand-orange" : toPar < 0 ? "text-primary" : "" 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 ( return (
// Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve // Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve
@ -176,10 +200,16 @@ export function RoundCard({ round }: { round: Round }) {
<InfoCell label="Hull" value={round.holes} /> <InfoCell label="Hull" value={round.holes} />
<InfoCell label="Dato" value={formatDate(round.date)} /> <InfoCell label="Dato" value={formatDate(round.date)} />
<InfoCell label="Spillere" value={`${round.playerCount} ${playerLabel}`} /> <InfoCell label="Spillere" value={`${round.playerCount} ${playerLabel}`} />
{hasMatchStatus && (
<InfoCell label="Resultat" value={round.matchStatus} valueClassName={cn("text-lg", matchStatusClassName)} />
)}
{showResultRow && ( {showResultRow && (
<> <>
<InfoCell label="Resultat" value={round.totalScore} valueClassName="text-lg" /> <InfoCell label="Resultat" value={round.totalScore} valueClassName="text-lg" />
<InfoCell label="Til par" value={formatToPar(toPar as number)} valueClassName={cn("text-lg", toParClassName)} /> <InfoCell label="Til par" value={formatToPar(toPar as number)} valueClassName={cn("text-lg", toParClassName)} />
{netToPar !== undefined && (
<InfoCell label="Netto" value={formatToPar(netToPar)} valueClassName={cn("text-lg", netToParClassName)} />
)}
</> </>
)} )}
</div> </div>

View file

@ -318,6 +318,10 @@ type ApiSideHole = {
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt // Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
// (migrasjon 036) -- helt valgfritt, null = ikke registrert. // (migrasjon 036) -- helt valgfritt, null = ikke registrert.
selected_participant_id: string | null 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 { function apiHoleToStat(h: ApiHole): HoleStat {
@ -389,8 +393,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const [currentHole, setCurrentHole] = useState<number | null>(null) const [currentHole, setCurrentHole] = useState<number | null>(null)
// ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins- // ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins-
// tavlen skjer (hull-registrering, websocket-signal), slik at // 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) 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<ApiFormatResult | null>(null)
const [showAddGuest, setShowAddGuest] = useState(false) const [showAddGuest, setShowAddGuest] = useState(false)
// Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/ // Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/
// spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26, // spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26,
@ -468,6 +477,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
void loadRound() void loadRound()
}, [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(() => { useEffect(() => {
let cancelled = false let cancelled = false
fetch("/auth/me", { credentials: "include" }) fetch("/auth/me", { credentials: "include" })
@ -1134,7 +1164,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{/* Løpende matchstatus (to-sidede formater) eller skins-tavle {/* Løpende matchstatus (to-sidede formater) eller skins-tavle
(ADR-039) -- avledet ved lesing, aldri lagret. */} (ADR-039) -- avledet ved lesing, aldri lagret. */}
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} /> <FormatResultPanel round={round} players={players} result={formatResult} />
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs {/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md. 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 etterspurt av bruker -- fantes fra før KUN under "Spillere og
runde", lett å overse siden scoring naturlig skjer her). runde", lett å overse siden scoring naturlig skjer her).
Samme komponent, ikke duplisert logikk. */} Samme komponent, ikke duplisert logikk. */}
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} /> <FormatResultPanel round={round} players={players} result={formatResult} />
{/* Completed summary banner */} {/* Completed summary banner */}
{completed && ( {completed && (
@ -1282,6 +1312,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
holesPlanned={round.holes_planned} holesPlanned={round.holes_planned}
currentHole={activeHole} currentHole={activeHole}
readOnly={readOnly} readOnly={readOnly}
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
formatResult={formatResult}
onSelectHole={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setCurrentHole(n)
scrollToHolePanel() scrollToHolePanel()
@ -1324,6 +1356,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
holesPlanned={round.holes_planned} holesPlanned={round.holes_planned}
currentHole={activeHole} currentHole={activeHole}
readOnly={readOnly} readOnly={readOnly}
sides={round.sides}
isTwoSided={TWO_SIDED_FORMATS.has(round.play_format)}
formatResult={formatResult}
onSelectHole={(n) => { onSelectHole={(n) => {
setCurrentHole(n) setCurrentHole(n)
scrollToHolePanel() scrollToHolePanel()
@ -2252,6 +2287,30 @@ function SidesPanel({
// filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger // filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger
// ingen egen visning her (eksisterende leaderboard dekker det). // 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 = { type ApiFormatResult = {
play_format: string play_format: string
ready: boolean ready: boolean
@ -2263,14 +2322,78 @@ type ApiFormatResult = {
match_status_text: string | null match_status_text: string | null
hole_results: string[] | null hole_results: string[] | null
skins_won: Record<string, number> | null skins_won: Record<string, number> | null
holes: ApiFormatHoleOut[] | null
} }
// match_status_text bruker bokstavelig "(A)"/"(B)" -- disse tilsvarer alltid // Kort statustekst UTEN backend sin bokstavelige "(A)"/"(B)"-suffiks --
// round.sides[0]/[1] (begge sortert på id, samme rekkefølge som backend sin // identitetsbanneret viser navnet separat (se IdentitySide), så suffikset
// egen sortering av side-id-er). Erstatt med det faktiske side-navnet. // ville vært en duplikat. Speiler handicap_engine.py sin
function substituteSideLabels(text: string, sides: ApiSide[]): string { // MatchState.describe() presist.
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}` function formatMatchStatus(lead: number, holesRemaining: number, isClosed: boolean, isDormie: boolean): string {
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`) 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<number, { label: string; tone: "a" | "b" | "neutral" }> {
const out = new Map<number, { label: string; tone: "a" | "b" | "neutral" }>()
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 (
<div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", side === "a" ? "items-start" : "items-end")}>
<div className={cn("flex min-w-0 items-center gap-1.5", side === "b" && "flex-row-reverse")}>
<span
aria-hidden="true"
className={cn("size-2 shrink-0 rounded-full", side === "a" ? "bg-primary" : "bg-brand-orange")}
/>
<span className="min-w-0 truncate text-sm font-extrabold text-foreground">{identity.name}</span>
</div>
{identity.hcp !== null && (
<span className="text-xs font-bold tabular-nums text-muted-foreground">HCP {identity.hcp}</span>
)}
</div>
)
} }
type ApiFlightSummary = { type ApiFlightSummary = {
@ -2411,32 +2534,14 @@ function FlightGroupPanel({
} }
function FormatResultPanel({ function FormatResultPanel({
roundId,
round, round,
players, players,
refreshTick, result,
}: { }: {
roundId: string
round: ApiRound round: ApiRound
players: Player[] players: Player[]
refreshTick: number result: ApiFormatResult | null
}) { }) {
const [result, setResult] = useState<ApiFormatResult | null>(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 === "stroke" || !result) return null
if (round.play_format === "skins") { 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 ( return (
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5"> <div className="mb-5 flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">{FORMAT_LABELS[round.play_format] ?? round.play_format}</span> <span className="text-sm font-bold text-foreground">{FORMAT_LABELS[round.play_format] ?? round.play_format}</span>
{result.match_is_closed && ( <div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-3 py-3 shadow-md shadow-black/8">
<Badge variant="default" className="shrink-0"> <IdentitySide identity={identityA} side="a" />
Avgjort <div className="flex shrink-0 flex-col items-center px-2">
</Badge> {statusLabel ? (
)} <>
{result.match_is_dormie && !result.match_is_closed && ( <span
<Badge variant="outline" className="shrink-0">
Dormie
</Badge>
)}
</div>
{statusText && <span className="text-2xl font-extrabold tracking-tight text-foreground">{statusText}</span>}
<span className="text-sm text-muted-foreground">
{result.match_holes_played} hull spilt
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`}
</span>
{result.hole_results && result.hole_results.length > 0 && (
<ul className="flex flex-wrap gap-1">
{result.hole_results.map((r, i) => (
<li
key={i}
aria-label={
r === "halved"
? `Hull ${i + 1}: delt`
: `Hull ${i + 1}: ${r === "a" ? round.sides[0]?.label?.trim() || "Side A" : round.sides[1]?.label?.trim() || "Side B"} vant`
}
className={cn( className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold", "text-2xl font-extrabold leading-none tabular-nums",
r === "a" && "bg-primary text-primary-foreground", tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
r === "b" && "bg-brand-orange text-brand-orange-foreground",
r === "halved" && "border border-border bg-muted text-muted-foreground",
)} )}
> >
{r === "halved" ? "½" : r.toUpperCase()} {statusLabel}
</li> </span>
))} {isClosed ? (
</ul> <span className="mt-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide text-muted-foreground">
Ferdig
</span>
) : (
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{holesPlayed} hull spilt
{holesRemaining > 0 && isDormie && " · dormie"}
</span>
)} )}
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
<IdentitySide identity={identityB} side="b" />
</div>
</div> </div>
) )
} }
@ -3755,6 +3865,9 @@ function ScorecardGrid({
holesPlanned, holesPlanned,
currentHole, currentHole,
readOnly, readOnly,
sides,
isTwoSided,
formatResult,
onSelectHole, onSelectHole,
onOpenEntry, onOpenEntry,
}: { }: {
@ -3764,6 +3877,12 @@ function ScorecardGrid({
holesPlanned: number holesPlanned: number
currentHole: number currentHole: number
readOnly: boolean 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 onSelectHole: (holeNumber: number) => void
onOpenEntry: (playerId: string, 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) 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 ( return (
<div className="overflow-x-auto rounded-2xl border border-border"> <div className="overflow-x-auto rounded-2xl border border-border">
<table className="w-full border-separate border-spacing-0 text-sm"> <table className="w-full border-separate border-spacing-0 text-sm">
@ -3897,6 +4028,7 @@ function ScorecardGrid({
const frontSum = showSplit ? sumFor(player.id, front) : null const frontSum = showSplit ? sumFor(player.id, front) : null
const backSum = showSplit ? sumFor(player.id, back) : null const backSum = showSplit ? sumFor(player.id, back) : null
const totalSum = sumFor(player.id, holeOrder) const totalSum = sumFor(player.id, holeOrder)
const playerSide = showMatchColors ? sideOf(player.id) : null
return ( return (
<tr key={player.id} className="border-b border-border last:border-b-0"> <tr key={player.id} className="border-b border-border last:border-b-0">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal"> <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
@ -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" 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"
> >
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{playerSide && (
<span
aria-hidden="true"
className={cn(
"size-2 shrink-0 rounded-full",
playerSide === "a" ? "bg-primary" : "bg-brand-orange",
)}
/>
)}
<span className="truncate text-sm font-bold text-foreground">{player.name}</span> <span className="truncate text-sm font-bold text-foreground">{player.name}</span>
{player.isSelf && ( {player.isSelf && (
<Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]"> <Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
@ -3927,6 +4068,10 @@ function ScorecardGrid({
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
const net = const net =
hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null
const formatHole = showMatchColors ? formatHolesByNumber.get(n) : undefined
const entry = formatHole?.entries.find((e) => e.id === player.id)
const won = Boolean(entry && playerSide && formatHole?.result === playerSide && entry.counted !== false)
const dimmed = Boolean(entry && entry.counted === false)
return ( return (
<td key={n} className="border-l border-border p-0 text-center"> <td key={n} className="border-l border-border p-0 text-center">
<button <button
@ -3935,13 +4080,35 @@ function ScorecardGrid({
onClick={() => onOpenEntry(player.id, n)} onClick={() => onOpenEntry(player.id, n)}
aria-label={ aria-label={
hasScore hasScore
? `${player.name}, hull ${n}, ${apiHole?.score} slag${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}` ? `${player.name}, hull ${n}, ${apiHole?.score} slag${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}${won ? ", vant hullet" : ""}`
: `Registrer ${player.name} sin score på hull ${n}` : `Registrer ${player.name} sin score på hull ${n}`
} }
className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60" className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
> >
{showMatchColors ? (
<MatchScorecardCell
value={hasScore ? (apiHole!.score as number) : null}
side={playerSide}
won={won}
dimmed={dimmed}
/>
) : (
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} /> <ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
{net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>} )}
{hasScore ? (
net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>
) : (
// Mottatte slag vist FØR hullet er fylt ut (2026-07-29,
// etterspurt av bruker) -- samme verdi som ville blitt
// trukket fra ved utfylling, kun når spilleren faktisk
// mottar minst ett slag her (ingen støy på de andre).
apiHole?.strokes_received != null &&
apiHole.strokes_received > 0 && (
<span className="text-[10px] font-semibold tabular-nums text-info">
{apiHole.strokes_received}
</span>
)
)}
</button> </button>
</td> </td>
) )
@ -3971,6 +4138,30 @@ function ScorecardGrid({
</tr> </tr>
) )
})} })}
{showMatchColors && (
<tr className="border-b border-border bg-muted/30 last:border-b-0">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
Stilling
</th>
{holeOrder.map((n) => {
const s = stillingByHole.get(n)
return (
<td
key={n}
className={cn(
"border-l border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
!s ? "text-muted-foreground" : s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{s?.label ?? ""}
</td>
)
})}
{showSplit && <td className="border-l border-border bg-muted" />}
{showSplit && <td className="border-l border-border bg-muted" />}
<td className="border-l border-border bg-muted" />
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -3995,6 +4186,39 @@ function ScorecardCell({ value, diff }: { value: number | null; diff: number | n
return <span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>{value}</span> return <span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>{value}</span>
} }
// 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 <span className={cn(shared, "text-muted-foreground")}></span>
if (won) {
return (
<span
className={cn(
shared,
"rounded-full",
side === "a" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
)}
>
{value}
</span>
)
}
return <span className={cn(shared, dimmed ? "text-muted-foreground/50" : "font-bold text-foreground")}>{value}</span>
}
// --- Statistikknivå-velger --------------------------------------------------- // --- Statistikknivå-velger ---------------------------------------------------
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt // "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
// overflødig -- score settes allerede automatisk til "spilt" idet et // overflødig -- score settes allerede automatisk til "spilt" idet et
@ -4031,6 +4255,8 @@ function SideScorecardGrid({
holesPlanned, holesPlanned,
currentHole, currentHole,
readOnly, readOnly,
isTwoSided,
formatResult,
onSelectHole, onSelectHole,
onOpenEntry, onOpenEntry,
}: { }: {
@ -4041,6 +4267,12 @@ function SideScorecardGrid({
holesPlanned: number holesPlanned: number
currentHole: number currentHole: number
readOnly: boolean 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 onSelectHole: (holeNumber: number) => void
onOpenEntry: (sideId: string, 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) 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 ( return (
<div className="overflow-x-auto rounded-2xl border border-border"> <div className="overflow-x-auto rounded-2xl border border-border">
<table className="w-full border-separate border-spacing-0 text-sm"> <table className="w-full border-separate border-spacing-0 text-sm">
@ -4168,6 +4406,7 @@ function SideScorecardGrid({
const backSum = showSplit ? sumFor(side.id, back) : null const backSum = showSplit ? sumFor(side.id, back) : null
const totalSum = sumFor(side.id, holeOrder) const totalSum = sumFor(side.id, holeOrder)
const assignedNames = players.filter((p) => p.roundSideId === side.id).map((p) => p.name) const assignedNames = players.filter((p) => p.roundSideId === side.id).map((p) => p.name)
const sideTone: "a" | "b" = sideIndex === 0 ? "a" : "b"
return ( return (
<tr key={side.id} className="border-b border-border last:border-b-0"> <tr key={side.id} className="border-b border-border last:border-b-0">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal"> <th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
@ -4178,9 +4417,17 @@ function SideScorecardGrid({
aria-label={`Registrer score for ${side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} på hull ${currentHole}`} 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" 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"
> >
<span className="flex items-center gap-1.5">
{showMatchColors && (
<span
aria-hidden="true"
className={cn("size-2 shrink-0 rounded-full", sideTone === "a" ? "bg-primary" : "bg-brand-orange")}
/>
)}
<span className="truncate text-sm font-bold text-foreground"> <span className="truncate text-sm font-bold text-foreground">
{side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} {side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`}
</span> </span>
</span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"} {assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"}
</span> </span>
@ -4191,6 +4438,9 @@ function SideScorecardGrid({
const meta = holeMeta[n] const meta = holeMeta[n]
const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : 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 ( return (
<td key={n} className="border-l border-border p-0 text-center"> <td key={n} className="border-l border-border p-0 text-center">
<button <button
@ -4199,12 +4449,29 @@ function SideScorecardGrid({
onClick={() => onOpenEntry(side.id, n)} onClick={() => onOpenEntry(side.id, n)}
aria-label={ aria-label={
hasScore hasScore
? `${side.label ?? "Side"}, hull ${n}, ${apiHole?.score} slag` ? `${side.label ?? "Side"}, hull ${n}, ${apiHole?.score} slag${won ? ", vant hullet" : ""}`
: `Registrer score for hull ${n}` : `Registrer score for hull ${n}`
} }
className="flex min-h-12 w-full items-center justify-center py-1 transition-colors hover:bg-accent/40 disabled:opacity-60" className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
> >
{showMatchColors ? (
<MatchScorecardCell
value={hasScore ? (apiHole!.score as number) : null}
side={sideTone}
won={won}
dimmed={false}
/>
) : (
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} /> <ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
)}
{/* Mottatte slag vist FØR hullet er fylt ut (2026-07-29) --
samme kombinerte side-verdi som ville blitt trukket fra
ved utfylling. */}
{!hasScore && apiHole?.strokes_received != null && apiHole.strokes_received > 0 && (
<span className="text-[10px] font-semibold tabular-nums text-info">
{apiHole.strokes_received}
</span>
)}
</button> </button>
</td> </td>
) )
@ -4234,6 +4501,30 @@ function SideScorecardGrid({
</tr> </tr>
) )
})} })}
{showMatchColors && (
<tr className="border-b border-border bg-muted/30 last:border-b-0">
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-muted/30 px-3 py-1.5 text-left text-xs font-extrabold text-foreground">
Stilling
</th>
{holeOrder.map((n) => {
const s = stillingByHole.get(n)
return (
<td
key={n}
className={cn(
"border-l border-border py-1.5 text-center text-xs font-extrabold tabular-nums",
!s ? "text-muted-foreground" : s.tone === "a" ? "text-primary" : s.tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{s?.label ?? ""}
</td>
)
})}
{showSplit && <td className="border-l border-border bg-muted" />}
{showSplit && <td className="border-l border-border bg-muted" />}
<td className="border-l border-border bg-muted" />
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>