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:
parent
7c3f5c3a7a
commit
30c114a2de
7 changed files with 646 additions and 81 deletions
144
CLAUDE.md
144
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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<InfoCell label="Hull" value={round.holes} />
|
||||
<InfoCell label="Dato" value={formatDate(round.date)} />
|
||||
<InfoCell label="Spillere" value={`${round.playerCount} ${playerLabel}`} />
|
||||
{hasMatchStatus && (
|
||||
<InfoCell label="Resultat" value={round.matchStatus} valueClassName={cn("text-lg", matchStatusClassName)} />
|
||||
)}
|
||||
{showResultRow && (
|
||||
<>
|
||||
<InfoCell label="Resultat" value={round.totalScore} valueClassName="text-lg" />
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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<number | null>(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<ApiFormatResult | null>(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. */}
|
||||
<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
|
||||
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. */}
|
||||
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} />
|
||||
<FormatResultPanel round={round} players={players} result={formatResult} />
|
||||
|
||||
{/* 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<string, number> | 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<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 = {
|
||||
|
|
@ -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<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 === "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 (
|
||||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="mb-5 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-foreground">{FORMAT_LABELS[round.play_format] ?? round.play_format}</span>
|
||||
{result.match_is_closed && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
Avgjort
|
||||
</Badge>
|
||||
)}
|
||||
{result.match_is_dormie && !result.match_is_closed && (
|
||||
<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`
|
||||
}
|
||||
<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">
|
||||
<IdentitySide identity={identityA} side="a" />
|
||||
<div className="flex shrink-0 flex-col items-center px-2">
|
||||
{statusLabel ? (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||||
r === "a" && "bg-primary text-primary-foreground",
|
||||
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||||
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||||
"text-2xl font-extrabold leading-none tabular-nums",
|
||||
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{r === "halved" ? "½" : r.toUpperCase()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{isClosed ? (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<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 backSum = showSplit ? sumFor(player.id, back) : null
|
||||
const totalSum = sumFor(player.id, holeOrder)
|
||||
const playerSide = showMatchColors ? sideOf(player.id) : null
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -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"
|
||||
>
|
||||
<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>
|
||||
{player.isSelf && (
|
||||
<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 net =
|
||||
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 (
|
||||
<td key={n} className="border-l border-border p-0 text-center">
|
||||
<button
|
||||
|
|
@ -3935,13 +4080,35 @@ function ScorecardGrid({
|
|||
onClick={() => onOpenEntry(player.id, n)}
|
||||
aria-label={
|
||||
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}`
|
||||
}
|
||||
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} />
|
||||
{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>
|
||||
</td>
|
||||
)
|
||||
|
|
@ -3971,6 +4138,30 @@ function ScorecardGrid({
|
|||
</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>
|
||||
</table>
|
||||
</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>
|
||||
}
|
||||
|
||||
// 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 ---------------------------------------------------
|
||||
// "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 (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<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 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 (
|
||||
<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">
|
||||
|
|
@ -4178,9 +4417,17 @@ 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"
|
||||
>
|
||||
<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">
|
||||
{side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"}
|
||||
</span>
|
||||
|
|
@ -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 (
|
||||
<td key={n} className="border-l border-border p-0 text-center">
|
||||
<button
|
||||
|
|
@ -4199,12 +4449,29 @@ function SideScorecardGrid({
|
|||
onClick={() => onOpenEntry(side.id, n)}
|
||||
aria-label={
|
||||
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}`
|
||||
}
|
||||
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} />
|
||||
)}
|
||||
{/* 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>
|
||||
</td>
|
||||
)
|
||||
|
|
@ -4234,6 +4501,30 @@ function SideScorecardGrid({
|
|||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue