Compare commits
2 commits
7bda1bf789
...
50653303f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 50653303f4 | |||
| 38168b2871 |
6 changed files with 1138 additions and 15 deletions
|
|
@ -3819,6 +3819,86 @@ ikke noe brukeren har bedt om.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-041: Konkurranseklasser ("Damer fra 44, Herrer fra 50+")
|
||||||
|
|
||||||
|
**Kontekst:** Brukeren spurte om det var mulig å sette opp runder i en
|
||||||
|
turnering slik at f.eks. damer spiller fra utslag 44 mens herrer spiller
|
||||||
|
fra 50+. Undersøkelse viste at DELER allerede virket (hvert
|
||||||
|
`match_participant`/`round_participant` har alltid hatt sitt eget
|
||||||
|
`tee_id`, uavhengig av de andre, med server-side validering av at
|
||||||
|
utslaget har rating for spillerens kjønn, ADR-029) — men det var et
|
||||||
|
manuelt valg per spiller hver gang, ingen gjenbrukbar "klasse"-
|
||||||
|
gruppering. Ingen "klasse"/kategori-konsept for konkurranse fantes fra
|
||||||
|
før noe sted i skjemaet (kun den urelaterte venne-kategoriseringen,
|
||||||
|
ADR-036).
|
||||||
|
|
||||||
|
**Beslutning A — Klasser er fritt navngitte, ikke en fast taksonomi.**
|
||||||
|
Organisator oppretter klasser med eget navn (f.eks. "Damer", "Herrer A",
|
||||||
|
"Junior") og et VALGFRITT standardutslag — ikke en kjønns- eller
|
||||||
|
alders-spesifikk datamodell. Bekreftet eksplisitt med bruker
|
||||||
|
(`AskUserQuestion`) framfor en fast kjønn+alder-kombinasjon: dekker
|
||||||
|
kjønn/alder/HCP eller annet uten at systemet må forstå forskjellen,
|
||||||
|
samme fleksibilitets-prinsipp som `friend_categorization` (ADR-036).
|
||||||
|
|
||||||
|
**Beslutning B — Delt tabell, ikke duplisert per turneringstype.** Ny
|
||||||
|
`tournament_class`-tabell (migrasjon 053) FK'et til `tournament`, ikke
|
||||||
|
en utvidelse av selve `tournament`-raden — samme sidetabell-mønster som
|
||||||
|
`tournament_sponsor`. Siden BÅDE lagturneringer (ADR-011) og
|
||||||
|
individuelle turneringer (ADR-037) peker til samme delte `tournament`-
|
||||||
|
tabell (`format_type`-diskriminator), dekker ÉN tabell begge uten
|
||||||
|
duplisering. Klassetilhørighet lever på `team_roster.class_id`
|
||||||
|
(lagturneringer) og `tournament_participant.class_id` (individuelle
|
||||||
|
turneringer) — begge nullable, `ON DELETE SET NULL` (samme prinsipp som
|
||||||
|
`tournament_round_bbb_hole`s FK-er, migrasjon 044): sletter man en
|
||||||
|
klasse, forsvinner ikke roster-/deltaker-radene, de mister bare
|
||||||
|
merkelappen.
|
||||||
|
|
||||||
|
**Beslutning C — Standardutslag er en frontend-bekvemmelighet, ikke en
|
||||||
|
ny backend-mekanisme.** `tee_id` er ALLEREDE required og satt per
|
||||||
|
deltaker (ikke per økt/runde) ved både `POST .../matches/{id}/
|
||||||
|
participants` og `POST .../rounds/{id}/participants` — klassens
|
||||||
|
standardutslag forhåndsvelger bare dette feltet i UI-et (fortsatt fritt
|
||||||
|
overstyrbart), backend-kontrakten er uendret. Samme mønster som
|
||||||
|
`new-round.tsx`s eksisterende kjønnsfilter/-default for personlige
|
||||||
|
runder. Forhåndsvelgingen sjekker eksplisitt at utslaget faktisk finnes
|
||||||
|
på DEN aktuelle øktens/rundens bane før det brukes — en klasses
|
||||||
|
standardutslag kan tilhøre en annen bane enn den som er i bruk akkurat
|
||||||
|
da.
|
||||||
|
|
||||||
|
**Beslutning D — Egen resultatliste KUN i individuelle turneringer,
|
||||||
|
aldri i lagturneringer.** Den mest substansielle avklaringen
|
||||||
|
(`AskUserQuestion`, med begrunnelse presentert først): i lagturneringer
|
||||||
|
er poeng knyttet til hele KAMPER (lag mot lag, `match.points_side_a/b`),
|
||||||
|
ikke enkeltspillere — å dele opp dette per klasse gir ikke naturlig
|
||||||
|
mening, og brukeren bekreftet eksplisitt at klasse der KUN skal foreslå
|
||||||
|
utslag. Det finnes fra før et smalt, PER-ØKT individuelt leaderboard for
|
||||||
|
singles/fourball-slagspill (`fetch_individual_leaderboard`,
|
||||||
|
`tournaments.py`) — bevisst IKKE utvidet eller gjenbrukt av denne runden,
|
||||||
|
det er en annen, allerede eksisterende funksjon med annet formål
|
||||||
|
(sesjonsscoped, format-begrenset). I individuelle turneringer (flatt
|
||||||
|
felt, ADR-037) er splitting derimot en naturlig match: `individual_
|
||||||
|
leaderboard`-endepunktet fikk `class_id`/`class_name` lagt til i
|
||||||
|
SELECT+respons, men selve summerings-/sorteringslogikken er UENDRET —
|
||||||
|
frontend grupperer den allerede korrekt sorterte listen visuelt (stabil
|
||||||
|
gruppering bevarer riktig rangering per klasse), ingen ny motorlogikk i
|
||||||
|
`handicap_engine.py` trengtes (Copenhagen/BBB var allerede scope-
|
||||||
|
agnostiske på deltaker-mengde).
|
||||||
|
|
||||||
|
**Konsekvens:** `053_tournament_classes.sql`. Ny klasse-CRUD i
|
||||||
|
`tournaments.py` (delt, siden `tournament`-tabellen er delt). `Roster
|
||||||
|
EntryUpdate` (tidligere KUN `is_captain: bool` påkrevd) lagt om til
|
||||||
|
`exclude_unset`-PATCH-semantikk. Ny `PATCH .../participants/{id}` i
|
||||||
|
`individual_tournaments.py` (fantes ikke før). Frontend: "Klasser"-kort
|
||||||
|
i BÅDE `tournament-detail.tsx` og `individual-tournament-detail.tsx`,
|
||||||
|
utslag-forhåndsutfylling i `session-blind-draw.tsx`s `AddSlotForm` og
|
||||||
|
`individual-tournament-detail.tsx`s `AssignRoundParticipantControl`,
|
||||||
|
klasse-gruppert `LeaderboardTab` (individuelle turneringer). Se
|
||||||
|
CHANGELOG.md 2026-08-04 for full bygge-/verifiseringsdetalj (inkl. tall-
|
||||||
|
for-tall-bekreftet leaderboard-rangering per klasse i ekte nettleser).
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
88
CHANGELOG.md
88
CHANGELOG.md
|
|
@ -7032,3 +7032,91 @@ Neste steg:
|
||||||
så dette er samme build som scratch-verifisert, ikke i tillegg
|
så dette er samme build som scratch-verifisert, ikke i tillegg
|
||||||
egenhendig bekreftet mot ekte produksjonsdata. Bekreft ved neste
|
egenhendig bekreftet mot ekte produksjonsdata. Bekreft ved neste
|
||||||
faktiske turnering.
|
faktiske turnering.
|
||||||
|
17. **Konkurranseklasser ("Damer fra 44, Herrer fra 50+") — BYGGET,
|
||||||
|
SCRATCH-VERIFISERT OG LIVE 2026-08-04, migrasjon 053.** Brukeren
|
||||||
|
spurte om det var mulig å sette opp runder i en turnering slik at
|
||||||
|
f.eks. damer spiller fra utslag 44 mens herrer spiller fra 50+.
|
||||||
|
Svaret var at DELER allerede virket (hver deltaker i en match/runde
|
||||||
|
fikk allerede sitt eget `tee_id`, backend validerte allerede at
|
||||||
|
utslaget hadde rating for spillerens kjønn) -- men et manuelt valg
|
||||||
|
per spiller hver gang, ingen gjenbrukbar "klasse". Brukeren ba om en
|
||||||
|
ekte klasse-mekanisme, "samt andre parametre som er relevante i en
|
||||||
|
slik problemstilling."
|
||||||
|
Full plan-modus-runde med to `AskUserQuestion`-avklaringer FØR
|
||||||
|
bygging (samme disiplin som ADR-037/ADR-039): (1) klasser er FRITT
|
||||||
|
NAVNGITTE (ikke bare kjønn) med et valgfritt standardutslag, ikke en
|
||||||
|
fast kjønn+alder-modell -- dekker kjønn/alder/HCP eller annet uten at
|
||||||
|
systemet må forstå forskjellen; (2) klasse gir EGEN RESULTATLISTE --
|
||||||
|
men KUN i individuelle turneringer (ADR-037, flatt felt). I
|
||||||
|
lagturneringer (ADR-011, to lag) er poeng knyttet til hele kamper,
|
||||||
|
ikke enkeltspillere -- brukeren bekreftet eksplisitt at klasse der
|
||||||
|
KUN skal foreslå utslag, ingen leaderboard-splitting (det finnes fra
|
||||||
|
før et smalt per-økt individuelt leaderboard for singles/fourball-
|
||||||
|
slagspill, `fetch_individual_leaderboard` -- IKKE noe denne runden
|
||||||
|
bygger videre på); (3) gjelder org-lagturneringer OG org-individuelle
|
||||||
|
turneringer, ikke frittstående personlige runder.
|
||||||
|
**Skjema** (`053_tournament_classes.sql`): ny delt `tournament_class`-
|
||||||
|
tabell (id/organization_id/tournament_id/name/default_tee_id,
|
||||||
|
`UNIQUE(tournament_id, name)`) -- delt mellom begge turneringstyper
|
||||||
|
siden begge peker til samme `tournament`-tabell (ADR-037s
|
||||||
|
`format_type`-mønster), unngår duplisering. To nye NULLABLE FK-
|
||||||
|
kolonner (`ON DELETE SET NULL`, samme mønster som `tournament_round_
|
||||||
|
bbb_hole`): `team_roster.class_id` og `tournament_participant.
|
||||||
|
class_id`. Samme `org_isolation`-RLS-loop som migrasjon 040.
|
||||||
|
**Backend**: ny klasse-CRUD (`GET/POST/PATCH/DELETE .../classes`) i
|
||||||
|
`tournaments.py` (delt fil, siden `tournament`-tabellen er delt).
|
||||||
|
`RosterEntryCreate`/`RosterEntry` fikk `class_id`/`class_name`;
|
||||||
|
`RosterEntryUpdate` (tidligere KUN `is_captain: bool` påkrevd)
|
||||||
|
lagt om til `exclude_unset`-PATCH-semantikk (samme mønster som
|
||||||
|
`TournamentUpdate`/`SponsorUpdate` fra presentasjons-panel-runden)
|
||||||
|
slik at klasse kan settes uten å tvinge et samtidig kaptein-valg.
|
||||||
|
`TournamentParticipantCreate`/`Out` (individual_tournaments.py) fikk
|
||||||
|
samme felt, pluss en NY `PATCH .../participants/{id}` (fantes ikke
|
||||||
|
før -- kun POST/GET/DELETE). `individual_leaderboard` utvidet med
|
||||||
|
`class_id`/`class_name` i SELECT+respons -- selve summerings-/
|
||||||
|
sorteringslogikken UENDRET, frontend grupperer den allerede sorterte
|
||||||
|
listen visuelt (stabil gruppering bevarer riktig rangering per
|
||||||
|
klasse, ingen ny motorlogikk i `handicap_engine.py` -- verifisert:
|
||||||
|
98/98 eksisterende enhetstester fortsatt grønne, ingen regresjon).
|
||||||
|
**Frontend**: "Klasser"-kort (opprett/slett, kaskaderende bane→
|
||||||
|
utslag-velger for standardutslag) i BÅDE `tournament-detail.tsx`
|
||||||
|
("Lag og spillere"-fanen) og `individual-tournament-detail.tsx`
|
||||||
|
(Oppsett-fanen). Utslag-forhåndsutfylling i to steder --
|
||||||
|
`session-blind-draw.tsx`s `AddSlotForm` (lagturneringer) og
|
||||||
|
`individual-tournament-detail.tsx`s `AssignRoundParticipantControl`
|
||||||
|
(individuelle turneringer) -- begge en `useEffect` som slår opp valgt
|
||||||
|
spillers klasse → standardutslag når spilleren velges, KUN hvis det
|
||||||
|
utslaget faktisk finnes på DENNE øktens/rundens bane (klassens
|
||||||
|
standardutslag kan tilhøre en annen bane), fortsatt fritt
|
||||||
|
overstyrbart. Ren frontend-bekvemmelighet, samme "gjenbruk et
|
||||||
|
eksisterende felt, ikke en ny backend-mekanisme"-prinsipp som
|
||||||
|
`new-round.tsx`s eksisterende kjønnsfilter/-default. `LeaderboardTab`
|
||||||
|
(individuelle turneringer) grupperer den mottatte, allerede sorterte
|
||||||
|
listen på `class_id`, hver klasse får egen seksjonsoverskrift +
|
||||||
|
egen 1/2/3-rangering INNAD i klassen -- ingen klasser opprettet =
|
||||||
|
identisk med tidligere flat visning, bakoverkompatibelt uten
|
||||||
|
migrasjonsflagg.
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-/frontend-container, ekte
|
||||||
|
nettleser-innlogging inkl. 2FA): full API-rundtur (klasse-CRUD,
|
||||||
|
roster-/deltaker-PATCH med class_id, `ON DELETE SET NULL` bekreftet
|
||||||
|
ved klasseslettelse), ekte nettleser-klikk gjennom BEGGE
|
||||||
|
turneringstyper -- lagturnering: opprettet klasser via UI-skjemaet
|
||||||
|
(kaskaderende bane→utslag bekreftet), tildelte klasse via
|
||||||
|
dropdown-menyen, bekreftet utslag forhåndsvalgt korrekt idet en
|
||||||
|
spiller med klasse ble valgt i `AddSlotForm` (Kari→44, Ola→56);
|
||||||
|
individuell turnering: samme mønster i `AssignRoundParticipantControl`
|
||||||
|
(Bjørn Ege→56), pluss det klasse-delte leaderboardet bekreftet
|
||||||
|
visuelt korrekt -- "DAMER"-seksjon viste Kari (72 slag, rang 1) foran
|
||||||
|
Siv (108 slag, rang 2), "HERRER" viste Ola alene (90 slag, rang 1),
|
||||||
|
tallene håndregnet og stemte eksakt (par/+18/+36 mot faktisk
|
||||||
|
innsendt score). `test_isolation.sql` 12/12 uendret, ekte
|
||||||
|
typesjekket produksjonsbuild (fanget og rettet ETT reelt
|
||||||
|
TypeScript-funn under selve verifiseringen: `classes`-propen manglet
|
||||||
|
på `TeamColumn` i `session-blind-draw.tsx` -- `AddSlotForm` ligger i
|
||||||
|
en underkomponent som ikke automatisk arver overordnet komponents
|
||||||
|
state, måtte tres eksplisitt gjennom).
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt: migrasjon
|
||||||
|
053 mot ekte `teecup_db`, `docker compose up -d --build teecup_api
|
||||||
|
teecup_frontend`, begge containere boot-et rent, `/health`/
|
||||||
|
`/dashboard` → 200.
|
||||||
|
|
|
||||||
|
|
@ -3967,6 +3967,53 @@ IKKE stilt til V0 ennå. Egen, senere, liten vurdering om ønskelig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Konkurranseklasser ("Damer fra 44, Herrer fra 50+") — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04 (ADR-041)
|
||||||
|
|
||||||
|
Brukeren spurte om det var mulig å sette opp runder i en turnering slik
|
||||||
|
at f.eks. damer spiller fra utslag 44 mens herrer spiller fra 50+. Full
|
||||||
|
plan-modus-runde med to `AskUserQuestion`-avklaringer FØR bygging (samme
|
||||||
|
disiplin som ADR-037/039) — se ADR-041 for de fire load-bærende
|
||||||
|
beslutningene (fritt navngitte klasser, delt tabell mellom begge
|
||||||
|
turneringstyper, standardutslag som ren frontend-bekvemmelighet, og —
|
||||||
|
den mest substansielle avklaringen — egen resultatliste KUN i
|
||||||
|
individuelle turneringer, aldri i lagturneringer siden poeng der er
|
||||||
|
knyttet til hele kamper).
|
||||||
|
|
||||||
|
Kort: ny `tournament_class`-tabell (migrasjon 053, org-isolert RLS) +
|
||||||
|
nullable `class_id` på `team_roster`/`tournament_participant` (`ON
|
||||||
|
DELETE SET NULL`). Organisator oppretter klasser med navn + valgfritt
|
||||||
|
standardutslag i et nytt "Klasser"-kort (BÅDE `tournament-detail.tsx` og
|
||||||
|
`individual-tournament-detail.tsx`). Standardutslaget forhåndsvelges
|
||||||
|
automatisk når en spiller med klasse legges til en match/runde (fortsatt
|
||||||
|
overstyrbart) — INGEN endring i selve `tee_id`-kontrakten, som allerede
|
||||||
|
var required og satt per deltaker. I individuelle turneringer deler
|
||||||
|
`individual_leaderboard` seg nå i egne, riktig rangerte seksjoner per
|
||||||
|
klasse; i lagturneringer er klasse bevisst KUN et utslag-forslag, ingen
|
||||||
|
leaderboard-endring (bekreftet med bruker — matchpoeng er lag-mot-lag,
|
||||||
|
ikke enkeltspiller-atomisk).
|
||||||
|
|
||||||
|
**Scratch-/browserverifisert grundig** (isolert `teecup_app_scratch`-
|
||||||
|
rolle + engangs API-/frontend-container, ekte nettleser-innlogging inkl.
|
||||||
|
2FA): full API-rundtur, ekte klikk gjennom BEGGE turneringstyper —
|
||||||
|
kaskaderende bane→utslag-velger ved klasse-opprettelse, dropdown-
|
||||||
|
klassetildeling, utslag-forhåndsutfylling bekreftet i `AddSlotForm` OG
|
||||||
|
`AssignRoundParticipantControl`, og det klasse-delte leaderboardet
|
||||||
|
tall-for-tall bekreftet korrekt (Damer: Kari 72 slag rang 1, Siv 108
|
||||||
|
slag rang 2; Herrer: Ola 90 slag rang 1 — alle mot håndregnet
|
||||||
|
slagsum). 98/98 eksisterende `handicap_engine.py`-enhetstester uendret
|
||||||
|
(ingen ny motorlogikk), `test_isolation.sql` 12/12 uendret. Ett reelt
|
||||||
|
TypeScript-funn under selve verifiseringen (ikke antatt riktig fra
|
||||||
|
implementasjonen alene): `classes`-propen manglet på `TeamColumn` i
|
||||||
|
`session-blind-draw.tsx` (egen underkomponent, arver ikke overordnet
|
||||||
|
komponents state automatisk) — fanget av produksjonsbuildens
|
||||||
|
typesjekk, rettet før utrulling.
|
||||||
|
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt: migrasjon
|
||||||
|
053 mot ekte `teecup_db`, `docker compose up -d --build teecup_api
|
||||||
|
teecup_frontend`. Full detalj i CHANGELOG.md 2026-08-04 og ADR-041.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Bevisst endret fra opprinnelige (Gemini-)råd
|
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||||
|
|
||||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||||
|
|
|
||||||
|
|
@ -1024,6 +1024,12 @@ async def list_round_participant_holes(
|
||||||
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
|
# Leaderboard (summert VED LESING på tvers av alle runder, Beslutning C)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
|
class RoundCellOut(BaseModel):
|
||||||
|
round_number: int
|
||||||
|
label: str
|
||||||
|
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntry(BaseModel):
|
class LeaderboardEntry(BaseModel):
|
||||||
tournament_participant_id: str
|
tournament_participant_id: str
|
||||||
player_name: str
|
player_name: str
|
||||||
|
|
@ -1038,6 +1044,16 @@ class LeaderboardEntry(BaseModel):
|
||||||
# rangering per klasse). Sorteringen under er UENDRET -- ren tillegg.
|
# rangering per klasse). Sorteringen under er UENDRET -- ren tillegg.
|
||||||
class_id: str | None
|
class_id: str | None
|
||||||
class_name: str | None
|
class_name: str | None
|
||||||
|
# Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx) --
|
||||||
|
# KUN populert for scoring_method i (stroke_gross, stroke_net, stableford).
|
||||||
|
# Alltid None/tom liste for copenhagen/bingo_bango_bongo/flag -- frontend
|
||||||
|
# bruker fortsatt den enkle listen for de formatene.
|
||||||
|
position: str | None = None
|
||||||
|
is_leader: bool = False
|
||||||
|
today_label: str | None = None
|
||||||
|
thru_label: str | None = None
|
||||||
|
total_label: str | None = None
|
||||||
|
rounds: list[RoundCellOut] = []
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -1083,10 +1099,18 @@ async def individual_leaderboard(
|
||||||
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
||||||
|
|
||||||
method = tournament["scoring_method"]
|
method = tournament["scoring_method"]
|
||||||
if method == "stableford":
|
if method in ("stroke_gross", "stroke_net", "stableford"):
|
||||||
entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0)))
|
# VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her
|
||||||
elif method == "stroke_net":
|
# (slik de andre metodene gjør) -- i en flerrunde-turnering har
|
||||||
entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0))
|
# deltakere spilt ULIKT antall hull til enhver tid (noen midt i
|
||||||
|
# runde 2, andre ikke startet ennå), så rå sum er ikke
|
||||||
|
# sammenlignbar. _attach_stroke_play_columns sorterer selv, på
|
||||||
|
# til-par (normalisert for antall hull spilt), FØR posisjon
|
||||||
|
# tildeles -- reell bug funnet under scratch-verifisering: en
|
||||||
|
# deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran
|
||||||
|
# deltakere med -5 til par over 27-36 hull, kun fordi rått
|
||||||
|
# slagtall er mindre jo færre hull man har spilt.
|
||||||
|
await _attach_stroke_play_columns(conn, tournament_id, method, entries)
|
||||||
elif method == "copenhagen":
|
elif method == "copenhagen":
|
||||||
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
|
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
|
||||||
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
|
||||||
|
|
@ -1094,9 +1118,182 @@ async def individual_leaderboard(
|
||||||
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
||||||
else:
|
else:
|
||||||
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
||||||
|
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_par_label(v: int) -> str:
|
||||||
|
if v == 0:
|
||||||
|
return "E"
|
||||||
|
return f"+{v}" if v > 0 else str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_par_tone(v: int) -> str:
|
||||||
|
if v < 0:
|
||||||
|
return "under"
|
||||||
|
if v > 0:
|
||||||
|
return "over"
|
||||||
|
return "even"
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_stroke_play_columns(
|
||||||
|
conn, tournament_id: str, method: str, entries: list[LeaderboardEntry]
|
||||||
|
) -> None:
|
||||||
|
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
||||||
|
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
|
||||||
|
kalleren) i stedet for å bygge en ny liste -- gjenbruker eksisterende
|
||||||
|
gross_total/net_total/stableford_total som ALLEREDE er summert riktig,
|
||||||
|
legger kun til det som mangler: par-for-spilte-hull (for til-par-tall),
|
||||||
|
per-runde nedbrytning, og posisjon-med-uavgjort-håndtering.
|
||||||
|
"""
|
||||||
|
rounds_meta = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT sequence, hole_config::text AS hole_config
|
||||||
|
FROM tournament_round
|
||||||
|
WHERE tournament_id = $1
|
||||||
|
ORDER BY sequence
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
hole_count_by_seq = {
|
||||||
|
r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta
|
||||||
|
}
|
||||||
|
|
||||||
|
# Én rad per (runde, deltaker) som faktisk er PÅBEGYNT (holes_played > 0).
|
||||||
|
# par_played = summen av par for KUN de hullene deltakeren faktisk har
|
||||||
|
# ført score på i den runden -- riktig grunnlag for et til-par-tall
|
||||||
|
# midt i en runde (ikke hele rundens par, som ville misvist før runden
|
||||||
|
# er ferdigspilt).
|
||||||
|
round_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT tr.sequence,
|
||||||
|
trs.tournament_participant_id::text AS tournament_participant_id,
|
||||||
|
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
||||||
|
COALESCE(pp.par_played, 0)::int AS par_played
|
||||||
|
FROM tournament_round tr
|
||||||
|
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT SUM(h.par) AS par_played
|
||||||
|
FROM tournament_round_participant trp
|
||||||
|
JOIN tournament_round_hole trh ON trh.tournament_round_participant_id = trp.id
|
||||||
|
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
|
||||||
|
WHERE trp.tournament_round_id = tr.id
|
||||||
|
AND trp.tournament_participant_id = trs.tournament_participant_id
|
||||||
|
AND trh.gross_strokes IS NOT NULL
|
||||||
|
) pp ON true
|
||||||
|
WHERE tr.tournament_id = $1 AND trs.holes_played > 0
|
||||||
|
ORDER BY tr.sequence
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_participant: dict[str, dict[int, object]] = {}
|
||||||
|
started_sequences: set[int] = set()
|
||||||
|
for r in round_rows:
|
||||||
|
by_participant.setdefault(r["tournament_participant_id"], {})[r["sequence"]] = r
|
||||||
|
started_sequences.add(r["sequence"])
|
||||||
|
# "I dag" = runden med høyest sekvensnummer som NOEN har påbegynt -- ikke
|
||||||
|
# nødvendigvis fullført (bekreftet med bruker: "det er den sist
|
||||||
|
# påbegynte"). Ingen eksplisitt status-/aktiv-markering finnes på
|
||||||
|
# tournament_round i dag, så dette utledes.
|
||||||
|
today_sequence = max(started_sequences) if started_sequences else None
|
||||||
|
|
||||||
|
value_col = "stableford_points" if method == "stableford" else (
|
||||||
|
"net_total" if method == "stroke_net" else "gross_total"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
|
||||||
|
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
|
||||||
|
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
|
||||||
|
# flerrunde-turnering har deltakere spilt ulikt antall hull til enhver
|
||||||
|
# tid, så rå sum er ikke sammenlignbar (se kallstedets kommentar).
|
||||||
|
NO_SCORE = 1_000_000_000
|
||||||
|
rank_key_by_id: dict[str, int] = {}
|
||||||
|
|
||||||
|
for e in entries:
|
||||||
|
pmap = by_participant.get(e.tournament_participant_id, {})
|
||||||
|
|
||||||
|
rounds_out: list[RoundCellOut] = []
|
||||||
|
total_par_played = 0
|
||||||
|
total_holes_played = 0
|
||||||
|
for seq, row in sorted(pmap.items()):
|
||||||
|
if row["holes_played"] == 0:
|
||||||
|
continue
|
||||||
|
total_par_played += row["par_played"]
|
||||||
|
total_holes_played += row["holes_played"]
|
||||||
|
if method == "stableford":
|
||||||
|
points = row["stableford_points"] or 0
|
||||||
|
expected = 2 * row["holes_played"]
|
||||||
|
rounds_out.append(
|
||||||
|
RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
val = row[value_col]
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
rounds_out.append(
|
||||||
|
RoundCellOut(
|
||||||
|
round_number=seq,
|
||||||
|
label=str(val),
|
||||||
|
tone=_to_par_tone(val - row["par_played"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
e.rounds = rounds_out
|
||||||
|
|
||||||
|
# TODAY / THRU
|
||||||
|
today_row = pmap.get(today_sequence) if today_sequence is not None else None
|
||||||
|
if today_row is not None and today_row["holes_played"] > 0:
|
||||||
|
hole_count = hole_count_by_seq.get(today_sequence, 18)
|
||||||
|
e.thru_label = "F" if today_row["holes_played"] >= hole_count else str(today_row["holes_played"])
|
||||||
|
if method == "stableford":
|
||||||
|
e.today_label = f"{today_row['stableford_points'] or 0} p"
|
||||||
|
else:
|
||||||
|
val = today_row[value_col]
|
||||||
|
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
|
||||||
|
else:
|
||||||
|
e.thru_label = "-"
|
||||||
|
e.today_label = None
|
||||||
|
|
||||||
|
# TOTAL -- gjenbruker den allerede korrekt summerte gross_total/
|
||||||
|
# net_total/stableford_total (uendret av denne funksjonen), trekker
|
||||||
|
# kun fra summert par-for-spilte-hull for til-par-formatet.
|
||||||
|
if method == "stableford":
|
||||||
|
e.total_label = f"{e.stableford_total or 0} p"
|
||||||
|
rank_key_by_id[e.tournament_participant_id] = (
|
||||||
|
NO_SCORE if e.stableford_total is None or total_holes_played == 0
|
||||||
|
else -(e.stableford_total - 2 * total_holes_played)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_val = e.net_total if method == "stroke_net" else e.gross_total
|
||||||
|
e.total_label = _to_par_label(total_val - total_par_played) if total_val is not None else "E"
|
||||||
|
rank_key_by_id[e.tournament_participant_id] = (
|
||||||
|
NO_SCORE if total_val is None or total_par_played == 0 else total_val - total_par_played
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sorter på til-par-nøkkelen (lavere = bedre), IKKE på rå total -- se
|
||||||
|
# kallstedets kommentar for hvorfor rå total ikke er sammenlignbar på
|
||||||
|
# tvers av ulikt antall hull spilt.
|
||||||
|
entries.sort(key=lambda e: rank_key_by_id[e.tournament_participant_id])
|
||||||
|
|
||||||
|
# POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste
|
||||||
|
# rangering hopper over like mange plasser som antall uavgjorte).
|
||||||
|
i, n = 0, len(entries)
|
||||||
|
while i < n:
|
||||||
|
j = i
|
||||||
|
key_i = rank_key_by_id[entries[i].tournament_participant_id]
|
||||||
|
while j < n and rank_key_by_id[entries[j].tournament_participant_id] == key_i:
|
||||||
|
j += 1
|
||||||
|
rank = i + 1
|
||||||
|
tied = (j - i) > 1
|
||||||
|
for k in range(i, j):
|
||||||
|
entries[k].position = f"T{rank}" if tied else str(rank)
|
||||||
|
entries[k].is_leader = rank == 1
|
||||||
|
i = j
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
|
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
|
||||||
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
||||||
|
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
||||||
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -114,6 +115,12 @@ type ApiHole = {
|
||||||
strokes_received: number | null
|
strokes_received: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiRoundCell = {
|
||||||
|
round_number: number
|
||||||
|
label: string
|
||||||
|
tone: "under" | "even" | "over" | null
|
||||||
|
}
|
||||||
|
|
||||||
type ApiLeaderboardEntry = {
|
type ApiLeaderboardEntry = {
|
||||||
tournament_participant_id: string
|
tournament_participant_id: string
|
||||||
player_name: string
|
player_name: string
|
||||||
|
|
@ -125,6 +132,14 @@ type ApiLeaderboardEntry = {
|
||||||
bbb_total: number | null
|
bbb_total: number | null
|
||||||
class_id: string | null
|
class_id: string | null
|
||||||
class_name: string | null
|
class_name: string | null
|
||||||
|
// Augusta-stil resultattavle (2026-08-04) -- kun populert for
|
||||||
|
// scoring_method brutto/netto/stableford, se stroke-play-leaderboard.tsx.
|
||||||
|
position: string | null
|
||||||
|
is_leader: boolean
|
||||||
|
today_label: string | null
|
||||||
|
thru_label: string | null
|
||||||
|
total_label: string | null
|
||||||
|
rounds: ApiRoundCell[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiFlagResult = {
|
type ApiFlagResult = {
|
||||||
|
|
@ -2184,6 +2199,26 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
groups.sort((a, b) => (a.key === "__none__" ? 1 : b.key === "__none__" ? -1 : 0))
|
groups.sort((a, b) => (a.key === "__none__" ? 1 : b.key === "__none__" ? -1 : 0))
|
||||||
const showSectionHeaders = groups.length > 1 || groups[0]?.key !== "__none__"
|
const showSectionHeaders = groups.length > 1 || groups[0]?.key !== "__none__"
|
||||||
|
|
||||||
|
// Augusta-stil resultattavle (2026-08-04) -- kun for brutto/netto/
|
||||||
|
// stableford, backend har allerede regnet ut alt (POS med uavgjort,
|
||||||
|
// TODAY/THRU for gjeldende runde, TOTAL, R1-Rn). Denne funksjonen er ren
|
||||||
|
// formvending, ingen domenelogikk.
|
||||||
|
const isStrokePlay = method === "stroke_gross" || method === "stroke_net" || method === "stableford"
|
||||||
|
|
||||||
|
function toStrokePlayRow(entry: ApiLeaderboardEntry): StrokePlayRow {
|
||||||
|
return {
|
||||||
|
position: entry.position ?? "-",
|
||||||
|
playerName: entry.player_name,
|
||||||
|
todayLabel: entry.today_label,
|
||||||
|
todayIsUnderPar: null,
|
||||||
|
thruLabel: entry.thru_label ?? "-",
|
||||||
|
totalLabel: entry.total_label ?? "E",
|
||||||
|
totalIsUnderPar: false,
|
||||||
|
isLeader: entry.is_leader,
|
||||||
|
rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
|
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
|
||||||
const { label, value } = valueFor(entry)
|
const { label, value } = valueFor(entry)
|
||||||
const isLeader = rank === 1 && value !== null
|
const isLeader = rank === 1 && value !== null
|
||||||
|
|
@ -2229,9 +2264,16 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
|
{isStrokePlay ? (
|
||||||
|
<StrokePlayLeaderboard
|
||||||
|
rows={group.entries.map(toStrokePlayRow)}
|
||||||
|
caption={showSectionHeaders ? `Resultattavle – ${group.label}` : "Resultattavle"}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
||||||
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
|
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
|
||||||
</ul>
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
669
frontend/components/stroke-play-leaderboard.tsx
Normal file
669
frontend/components/stroke-play-leaderboard.tsx
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { Flag, Pause, Play, Trophy } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// --- Types -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type RoundCell = {
|
||||||
|
roundNumber: number // 1, 2, 3, or 4
|
||||||
|
label: string // "70", "38 p" (Stableford) — or "" if not yet played
|
||||||
|
// "even" is its own state (not folded into "over") -- matches this app's
|
||||||
|
// established Golfscore-språket (DESIGN_SYSTEM.md): E gets plain neutral
|
||||||
|
// text, no circle/square frame, never the over-par treatment.
|
||||||
|
tone: "under" | "even" | "over" | null // null = not yet played, render as an empty/dashed cell
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeaderboardRow = {
|
||||||
|
position: string // "1", "T2", "T5" — already formatted, ties included
|
||||||
|
playerName: string
|
||||||
|
todayLabel: string | null // "-2", "+4", "E", "38 p" — null = today's round not started yet
|
||||||
|
todayIsUnderPar: boolean | null
|
||||||
|
thruLabel: string // "F" (finished), "14" (holes played), "-" (not started)
|
||||||
|
totalLabel: string // "-13", "+7", "146 p"
|
||||||
|
totalIsUnderPar: boolean
|
||||||
|
isLeader: boolean // true for every row sharing the current lead position (ties possible)
|
||||||
|
rounds: RoundCell[] // one entry per round played SO FAR (1-4 entries, not always 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mock seed data --------------------------------------------------------
|
||||||
|
// A par-72, 4-round event that is currently 2 rounds in (round 2 live), so the
|
||||||
|
// table only shows R1 + R2 columns. Mix of finished / mid-round / not-started,
|
||||||
|
// with a tie for the lead (two T1 rows).
|
||||||
|
|
||||||
|
const t = (n: string): RoundCell["label"] => n
|
||||||
|
function completed(roundNumber: number, label: string, tone: "under" | "even" | "over"): RoundCell {
|
||||||
|
return { roundNumber, label, tone }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_ROWS: LeaderboardRow[] = [
|
||||||
|
{
|
||||||
|
position: "T1",
|
||||||
|
playerName: "Ingrid Berg",
|
||||||
|
todayLabel: "-5",
|
||||||
|
todayIsUnderPar: true,
|
||||||
|
thruLabel: "F",
|
||||||
|
totalLabel: "-8",
|
||||||
|
totalIsUnderPar: true,
|
||||||
|
isLeader: true,
|
||||||
|
rounds: [completed(1, t("69"), "under"), completed(2, t("67"), "under")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "T1",
|
||||||
|
playerName: "Sofie Dahl",
|
||||||
|
todayLabel: "-2",
|
||||||
|
todayIsUnderPar: true,
|
||||||
|
thruLabel: "F",
|
||||||
|
totalLabel: "-8",
|
||||||
|
totalIsUnderPar: true,
|
||||||
|
isLeader: true,
|
||||||
|
rounds: [completed(1, t("66"), "under"), completed(2, t("70"), "under")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "3",
|
||||||
|
playerName: "Anna Ruud",
|
||||||
|
todayLabel: "-5",
|
||||||
|
todayIsUnderPar: true,
|
||||||
|
thruLabel: "14",
|
||||||
|
totalLabel: "-7",
|
||||||
|
totalIsUnderPar: true,
|
||||||
|
isLeader: false,
|
||||||
|
// Round 2 in progress -> only R1 completed.
|
||||||
|
rounds: [completed(1, t("70"), "under")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "4",
|
||||||
|
playerName: "Kari Holt",
|
||||||
|
todayLabel: "-4",
|
||||||
|
todayIsUnderPar: true,
|
||||||
|
thruLabel: "12",
|
||||||
|
totalLabel: "-5",
|
||||||
|
totalIsUnderPar: true,
|
||||||
|
isLeader: false,
|
||||||
|
rounds: [completed(1, t("71"), "under")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "5",
|
||||||
|
playerName: "Mette Lie",
|
||||||
|
todayLabel: "+1",
|
||||||
|
todayIsUnderPar: false,
|
||||||
|
thruLabel: "16",
|
||||||
|
totalLabel: "-3",
|
||||||
|
totalIsUnderPar: true,
|
||||||
|
isLeader: false,
|
||||||
|
rounds: [completed(1, t("68"), "under")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "6",
|
||||||
|
playerName: "Bjørg Sund",
|
||||||
|
todayLabel: "-1",
|
||||||
|
todayIsUnderPar: true,
|
||||||
|
thruLabel: "F",
|
||||||
|
totalLabel: "-2",
|
||||||
|
totalIsUnderPar: true,
|
||||||
|
isLeader: false,
|
||||||
|
rounds: [completed(1, t("71"), "under"), completed(2, t("72"), "even")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "7",
|
||||||
|
playerName: "Live Aas",
|
||||||
|
todayLabel: "E",
|
||||||
|
todayIsUnderPar: null,
|
||||||
|
thruLabel: "9",
|
||||||
|
totalLabel: "+1",
|
||||||
|
totalIsUnderPar: false,
|
||||||
|
isLeader: false,
|
||||||
|
rounds: [completed(1, t("73"), "over")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "8",
|
||||||
|
playerName: "Randi Vik",
|
||||||
|
todayLabel: null, // round 2 not started yet
|
||||||
|
todayIsUnderPar: null,
|
||||||
|
thruLabel: "-",
|
||||||
|
totalLabel: "+2",
|
||||||
|
totalIsUnderPar: false,
|
||||||
|
isLeader: false,
|
||||||
|
rounds: [completed(1, t("74"), "over")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: "9",
|
||||||
|
playerName: "Tuva Moen",
|
||||||
|
todayLabel: "+3",
|
||||||
|
todayIsUnderPar: false,
|
||||||
|
thruLabel: "6",
|
||||||
|
totalLabel: "+6",
|
||||||
|
totalIsUnderPar: false,
|
||||||
|
isLeader: false,
|
||||||
|
rounds: [completed(1, t("75"), "over")],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
type Tone = "under" | "over" | "even" | "none"
|
||||||
|
|
||||||
|
// Determine tone from a signed label so meaning never relies on color alone.
|
||||||
|
function toneFromLabel(label: string | null): Tone {
|
||||||
|
if (label == null) return "none"
|
||||||
|
const v = label.trim()
|
||||||
|
if (v === "") return "none"
|
||||||
|
if (v.startsWith("-")) return "under"
|
||||||
|
if (v.startsWith("+")) return "over"
|
||||||
|
return "even" // "E", "38 p", "146 p"
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneText: Record<Tone, string> = {
|
||||||
|
under: "text-primary",
|
||||||
|
over: "text-brand-orange",
|
||||||
|
even: "text-foreground",
|
||||||
|
none: "text-muted-foreground",
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePrefersReducedMotion() {
|
||||||
|
const [reduced, setReduced] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
|
||||||
|
setReduced(mq.matches)
|
||||||
|
const onChange = () => setReduced(mq.matches)
|
||||||
|
mq.addEventListener("change", onChange)
|
||||||
|
return () => mq.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
return reduced
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Column geometry (shared widths so header/leaders/body align) ----------
|
||||||
|
|
||||||
|
const COL = {
|
||||||
|
pos: "w-12 lg:w-24",
|
||||||
|
player: "min-w-[8.5rem] lg:min-w-[16rem]",
|
||||||
|
today: "w-16 lg:w-28",
|
||||||
|
thru: "w-14 lg:w-24",
|
||||||
|
total: "w-20 lg:w-36",
|
||||||
|
round: "w-14 lg:w-24",
|
||||||
|
}
|
||||||
|
// Sticky-left offset for the PLAYER column must equal the POS column width.
|
||||||
|
const PLAYER_LEFT = "left-12 lg:left-24"
|
||||||
|
|
||||||
|
const cellPad = "px-2 py-3 lg:px-5 lg:py-5"
|
||||||
|
|
||||||
|
// --- Component -------------------------------------------------------------
|
||||||
|
|
||||||
|
export function StrokePlayLeaderboard({
|
||||||
|
rows = MOCK_ROWS,
|
||||||
|
caption = "Resultattavle",
|
||||||
|
}: {
|
||||||
|
rows?: LeaderboardRow[]
|
||||||
|
caption?: string
|
||||||
|
}) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion()
|
||||||
|
const [playing, setPlaying] = useState(false)
|
||||||
|
|
||||||
|
const leaders = useMemo(() => rows.filter((r) => r.isLeader), [rows])
|
||||||
|
const field = useMemo(() => rows.filter((r) => !r.isLeader), [rows])
|
||||||
|
const roundCount = useMemo(
|
||||||
|
() => rows.reduce((max, r) => Math.max(max, ...r.rounds.map((c) => c.roundNumber), 0), 0),
|
||||||
|
[rows],
|
||||||
|
)
|
||||||
|
const roundNumbers = useMemo(
|
||||||
|
() => Array.from({ length: roundCount }, (_, i) => i + 1),
|
||||||
|
[roundCount],
|
||||||
|
)
|
||||||
|
const totalCols = 5 + roundCount
|
||||||
|
|
||||||
|
// Measure the header height so pinned leader rows stick right beneath it.
|
||||||
|
const theadRef = useRef<HTMLTableSectionElement>(null)
|
||||||
|
const [headTop, setHeadTop] = useState(0)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = theadRef.current
|
||||||
|
if (!el) return
|
||||||
|
const update = () => setHeadTop(el.getBoundingClientRect().height)
|
||||||
|
update()
|
||||||
|
const ro = new ResizeObserver(update)
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auto-scroll ticker over the non-leader field.
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || reducedMotion) return
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
let raf = 0
|
||||||
|
let last = performance.now()
|
||||||
|
const speed = 26 // px per second
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.05)
|
||||||
|
last = now
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
if (max > 1) {
|
||||||
|
let next = el.scrollTop + speed * dt
|
||||||
|
if (next >= max) next = 0 // loop back to top of field
|
||||||
|
el.scrollTop = next
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [playing, reducedMotion])
|
||||||
|
|
||||||
|
// Any manual gesture on the list pauses the ticker (does not react to the
|
||||||
|
// programmatic scrollTop writes above).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
const pause = () => setPlaying(false)
|
||||||
|
const opts: AddEventListenerOptions = { passive: true }
|
||||||
|
el.addEventListener("wheel", pause, opts)
|
||||||
|
el.addEventListener("touchstart", pause, opts)
|
||||||
|
el.addEventListener("pointerdown", pause)
|
||||||
|
el.addEventListener("keydown", pause)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("wheel", pause, opts)
|
||||||
|
el.removeEventListener("touchstart", pause, opts)
|
||||||
|
el.removeEventListener("pointerdown", pause)
|
||||||
|
el.removeEventListener("keydown", pause)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={caption}
|
||||||
|
className="flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm lg:rounded-3xl"
|
||||||
|
>
|
||||||
|
{/* Control bar (part of the component, not page chrome) */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border bg-background px-3 py-3 lg:px-5 lg:py-4">
|
||||||
|
<Legend />
|
||||||
|
{!reducedMotion && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={playing ? "secondary" : "outline"}
|
||||||
|
size="lg"
|
||||||
|
onClick={() => setPlaying((p) => !p)}
|
||||||
|
aria-pressed={playing}
|
||||||
|
className="h-11 gap-2 px-4 text-sm lg:h-14 lg:px-6 lg:text-base"
|
||||||
|
>
|
||||||
|
{playing ? (
|
||||||
|
<Pause aria-hidden="true" className="size-4 lg:size-5" />
|
||||||
|
) : (
|
||||||
|
<Play aria-hidden="true" className="size-4 lg:size-5" />
|
||||||
|
)}
|
||||||
|
{playing ? "Stopp rulling" : "Rull automatisk"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* One scroll container drives both frozen-column horizontal scroll (narrow
|
||||||
|
screens) and the vertical ticker. On large "storskjerm" viewports the
|
||||||
|
cap is removed so the whole board fits with no scrolling. */}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Resultatliste"
|
||||||
|
className="relative max-h-[24rem] overflow-auto outline-none md:max-h-[32rem] lg:max-h-none lg:overflow-visible"
|
||||||
|
>
|
||||||
|
<table className="w-full border-separate border-spacing-0 text-left tabular-nums">
|
||||||
|
<colgroup>
|
||||||
|
<col className={COL.pos} />
|
||||||
|
<col className={COL.player} />
|
||||||
|
<col className={COL.today} />
|
||||||
|
<col className={COL.thru} />
|
||||||
|
<col className={COL.total} />
|
||||||
|
{roundNumbers.map((n) => (
|
||||||
|
<col key={n} className={COL.round} />
|
||||||
|
))}
|
||||||
|
</colgroup>
|
||||||
|
|
||||||
|
<thead ref={theadRef}>
|
||||||
|
<tr className="bg-foreground text-background">
|
||||||
|
<Th sticky="pos" className="z-50 text-center">
|
||||||
|
POS<span className="sr-only"> (posisjon)</span>
|
||||||
|
</Th>
|
||||||
|
<Th sticky="player" className="z-50 text-left">
|
||||||
|
PLAYER<span className="sr-only"> (spiller)</span>
|
||||||
|
</Th>
|
||||||
|
<Th className="text-right">
|
||||||
|
TODAY<span className="sr-only"> (i dag)</span>
|
||||||
|
</Th>
|
||||||
|
<Th className="text-right">
|
||||||
|
THRU<span className="sr-only"> (hull spilt)</span>
|
||||||
|
</Th>
|
||||||
|
<Th className="text-right">
|
||||||
|
TOTAL<span className="sr-only"> (totalt mot par)</span>
|
||||||
|
</Th>
|
||||||
|
{roundNumbers.map((n) => (
|
||||||
|
<Th key={n} className="text-right">
|
||||||
|
R{n}
|
||||||
|
<span className="sr-only"> (runde {n})</span>
|
||||||
|
</Th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{/* Pinned leader row(s) — stick beneath the header, never in the ticker. */}
|
||||||
|
{leaders.map((row, i) => (
|
||||||
|
<LeaderRow
|
||||||
|
key={`leader-${row.playerName}`}
|
||||||
|
row={row}
|
||||||
|
roundNumbers={roundNumbers}
|
||||||
|
top={headTop}
|
||||||
|
isLast={i === leaders.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{field.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={totalCols}
|
||||||
|
className="px-4 py-8 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
Ingen spillere i feltet ennå.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{field.map((row, i) => (
|
||||||
|
<FieldRow
|
||||||
|
key={`field-${row.playerName}`}
|
||||||
|
row={row}
|
||||||
|
roundNumbers={roundNumbers}
|
||||||
|
striped={i % 2 === 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legend ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function Legend() {
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs font-medium text-muted-foreground lg:text-sm">
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex size-5 items-center justify-center rounded-full bg-primary/15 text-[10px] font-bold text-primary lg:size-6"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</span>
|
||||||
|
Under par
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex size-5 items-center justify-center rounded-md bg-brand-orange/15 text-[10px] font-bold text-brand-orange lg:size-6"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
Over par
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex size-5 items-center justify-center rounded-md text-[10px] font-bold text-foreground lg:size-6"
|
||||||
|
>
|
||||||
|
E
|
||||||
|
</span>
|
||||||
|
Par
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Header cell -----------------------------------------------------------
|
||||||
|
|
||||||
|
function Th({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
sticky,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
sticky?: "pos" | "player"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className={cn(
|
||||||
|
cellPad,
|
||||||
|
"sticky top-0 z-40 bg-foreground text-[0.7rem] font-extrabold uppercase tracking-wider text-background lg:text-base",
|
||||||
|
sticky === "pos" && "left-0",
|
||||||
|
sticky === "player" && PLAYER_LEFT,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Leader row (pinned) ---------------------------------------------------
|
||||||
|
|
||||||
|
function LeaderRow({
|
||||||
|
row,
|
||||||
|
roundNumbers,
|
||||||
|
top,
|
||||||
|
isLast,
|
||||||
|
}: {
|
||||||
|
row: LeaderboardRow
|
||||||
|
roundNumbers: number[]
|
||||||
|
top: number
|
||||||
|
isLast: boolean
|
||||||
|
}) {
|
||||||
|
const stickyStyle = { top }
|
||||||
|
const rowBg = "bg-gold text-gold-foreground"
|
||||||
|
const border = isLast ? "border-b-2 border-gold-foreground/30" : "border-b border-gold-foreground/15"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={rowBg}>
|
||||||
|
{/* POS + trophy (sticky both top and left, above field + own row) */}
|
||||||
|
<td
|
||||||
|
style={stickyStyle}
|
||||||
|
className={cn(cellPad, rowBg, border, "sticky left-0 z-30 text-center align-middle")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
|
<Trophy aria-hidden="true" className="size-4 shrink-0 lg:size-6" />
|
||||||
|
<span className="text-sm font-extrabold lg:text-3xl">{row.position}</span>
|
||||||
|
<span className="sr-only">, leder</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* PLAYER */}
|
||||||
|
<td
|
||||||
|
style={stickyStyle}
|
||||||
|
className={cn(cellPad, rowBg, border, "sticky z-30 text-left", PLAYER_LEFT)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 text-sm font-extrabold lg:text-2xl">
|
||||||
|
<Flag aria-hidden="true" className="size-3.5 shrink-0 lg:size-5" />
|
||||||
|
<span className="truncate">{row.playerName}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<ValueTd style={stickyStyle} rowBg={rowBg} border={border} label={row.todayLabel} emphasis="today" leader />
|
||||||
|
<ThruTd style={stickyStyle} rowBg={rowBg} border={border} thru={row.thruLabel} leader />
|
||||||
|
<ValueTd style={stickyStyle} rowBg={rowBg} border={border} label={row.totalLabel} emphasis="total" leader />
|
||||||
|
|
||||||
|
{roundNumbers.map((n) => {
|
||||||
|
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||||
|
return <RoundTd key={n} style={stickyStyle} rowBg={rowBg} border={border} cell={cell} leader />
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Field row -------------------------------------------------------------
|
||||||
|
|
||||||
|
function FieldRow({
|
||||||
|
row,
|
||||||
|
roundNumbers,
|
||||||
|
striped,
|
||||||
|
}: {
|
||||||
|
row: LeaderboardRow
|
||||||
|
roundNumbers: number[]
|
||||||
|
striped: boolean
|
||||||
|
}) {
|
||||||
|
const rowBg = striped ? "bg-muted/40" : "bg-card"
|
||||||
|
const border = "border-b border-border"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={rowBg}>
|
||||||
|
<td className={cn(cellPad, rowBg, border, "sticky left-0 z-10 text-center")}>
|
||||||
|
<span className="text-sm font-bold text-foreground lg:text-2xl">{row.position}</span>
|
||||||
|
</td>
|
||||||
|
<td className={cn(cellPad, rowBg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
|
||||||
|
<span className="block truncate text-sm font-semibold text-foreground lg:text-xl">
|
||||||
|
{row.playerName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<ValueTd rowBg={rowBg} border={border} label={row.todayLabel} emphasis="today" />
|
||||||
|
<ThruTd rowBg={rowBg} border={border} thru={row.thruLabel} />
|
||||||
|
<ValueTd rowBg={rowBg} border={border} label={row.totalLabel} emphasis="total" />
|
||||||
|
|
||||||
|
{roundNumbers.map((n) => {
|
||||||
|
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||||
|
return <RoundTd key={n} rowBg={rowBg} border={border} cell={cell} />
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Value cell (TODAY / TOTAL) --------------------------------------------
|
||||||
|
|
||||||
|
function ValueTd({
|
||||||
|
label,
|
||||||
|
emphasis,
|
||||||
|
rowBg,
|
||||||
|
border,
|
||||||
|
style,
|
||||||
|
leader,
|
||||||
|
}: {
|
||||||
|
label: string | null
|
||||||
|
emphasis: "today" | "total"
|
||||||
|
rowBg: string
|
||||||
|
border: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
leader?: boolean
|
||||||
|
}) {
|
||||||
|
const tone = toneFromLabel(label)
|
||||||
|
const isTotal = emphasis === "total"
|
||||||
|
|
||||||
|
// On the gold leader band, keep values readable on gold rather than tinting
|
||||||
|
// them green/orange (which fails contrast on gold); the sign still encodes tone.
|
||||||
|
const color = leader ? "text-gold-foreground" : toneText[tone]
|
||||||
|
|
||||||
|
const size = isTotal
|
||||||
|
? "text-base font-extrabold lg:text-4xl"
|
||||||
|
: "text-sm font-bold lg:text-2xl"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||||||
|
{label == null ? (
|
||||||
|
<span className="text-sm text-muted-foreground lg:text-xl" aria-label="ikke startet">
|
||||||
|
–
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn(size, color)}>{label}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- THRU cell -------------------------------------------------------------
|
||||||
|
|
||||||
|
function ThruTd({
|
||||||
|
thru,
|
||||||
|
rowBg,
|
||||||
|
border,
|
||||||
|
style,
|
||||||
|
leader,
|
||||||
|
}: {
|
||||||
|
thru: string
|
||||||
|
rowBg: string
|
||||||
|
border: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
leader?: boolean
|
||||||
|
}) {
|
||||||
|
const finished = thru === "F"
|
||||||
|
const notStarted = thru === "-"
|
||||||
|
const color = leader ? "text-gold-foreground" : "text-foreground"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||||||
|
{notStarted ? (
|
||||||
|
<span className="text-sm text-muted-foreground lg:text-xl">–</span>
|
||||||
|
) : finished ? (
|
||||||
|
<span className={cn("text-sm font-bold lg:text-xl", color)}>
|
||||||
|
F<span className="sr-only"> (ferdig)</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn("text-sm font-semibold lg:text-xl", leader ? "text-gold-foreground" : "text-muted-foreground")}>
|
||||||
|
{thru}
|
||||||
|
<span className="sr-only"> hull spilt</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Round cell (Rn) -------------------------------------------------------
|
||||||
|
// Shape encodes tone in addition to color: under-par = round pill, over-par =
|
||||||
|
// square pill — so meaning survives without color perception.
|
||||||
|
|
||||||
|
function RoundTd({
|
||||||
|
cell,
|
||||||
|
rowBg,
|
||||||
|
border,
|
||||||
|
style,
|
||||||
|
leader,
|
||||||
|
}: {
|
||||||
|
cell?: RoundCell
|
||||||
|
rowBg: string
|
||||||
|
border: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
leader?: boolean
|
||||||
|
}) {
|
||||||
|
const played = cell && cell.label !== "" && cell.tone !== null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td style={style} className={cn(cellPad, rowBg, border, "text-right", leader && "sticky z-20")}>
|
||||||
|
{!played ? (
|
||||||
|
<span className="text-sm text-muted-foreground lg:text-xl" aria-label="ikke spilt">
|
||||||
|
–
|
||||||
|
</span>
|
||||||
|
) : cell!.tone === "even" ? (
|
||||||
|
// "E" er alltid ren tekst, ingen ramme -- samme regel som resten av
|
||||||
|
// appens Golfscore-språk (DESIGN_SYSTEM.md), aldri over-par-behandling.
|
||||||
|
<span className={cn("text-sm font-bold lg:text-lg", leader ? "text-gold-foreground" : "text-foreground")}>
|
||||||
|
{cell!.label}
|
||||||
|
<span className="sr-only"> par</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-w-8 items-center justify-center px-1.5 py-0.5 text-sm font-bold lg:min-w-12 lg:px-2 lg:py-1 lg:text-lg",
|
||||||
|
// Shape encodes tone (circle=under, square=over) so meaning survives
|
||||||
|
// without color. On the gold leader band, use a solid opaque chip so
|
||||||
|
// the low-opacity tint doesn't wash out against gold.
|
||||||
|
cell!.tone === "under" ? "rounded-full" : "rounded-md",
|
||||||
|
leader
|
||||||
|
? cn("bg-card", cell!.tone === "under" ? "text-primary" : "text-brand-orange")
|
||||||
|
: cell!.tone === "under"
|
||||||
|
? "bg-primary/15 text-primary"
|
||||||
|
: "bg-brand-orange/15 text-brand-orange",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cell!.label}
|
||||||
|
<span className="sr-only">{cell!.tone === "under" ? " under par" : " over par"}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue