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å)
|
||||
|
||||
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
|
||||
egenhendig bekreftet mot ekte produksjonsdata. Bekreft ved neste
|
||||
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
|
||||
|
||||
- 🔀 **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)
|
||||
# =====================================================================
|
||||
|
||||
class RoundCellOut(BaseModel):
|
||||
round_number: int
|
||||
label: str
|
||||
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
|
||||
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
tournament_participant_id: str
|
||||
player_name: str
|
||||
|
|
@ -1038,6 +1044,16 @@ class LeaderboardEntry(BaseModel):
|
|||
# rangering per klasse). Sorteringen under er UENDRET -- ren tillegg.
|
||||
class_id: 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(
|
||||
|
|
@ -1083,10 +1099,18 @@ async def individual_leaderboard(
|
|||
entries = [LeaderboardEntry(**dict(r)) for r in rows]
|
||||
|
||||
method = tournament["scoring_method"]
|
||||
if method == "stableford":
|
||||
entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0)))
|
||||
elif method == "stroke_net":
|
||||
entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0))
|
||||
if method in ("stroke_gross", "stroke_net", "stableford"):
|
||||
# VIKTIG: kan IKKE sorteres på rå gross_total/stableford_total her
|
||||
# (slik de andre metodene gjør) -- i en flerrunde-turnering har
|
||||
# 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":
|
||||
# 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)))
|
||||
|
|
@ -1094,9 +1118,182 @@ async def individual_leaderboard(
|
|||
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
|
||||
else:
|
||||
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
|
||||
|
||||
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
|
||||
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
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 { cn } from "@/lib/utils"
|
||||
|
||||
|
|
@ -114,6 +115,12 @@ type ApiHole = {
|
|||
strokes_received: number | null
|
||||
}
|
||||
|
||||
type ApiRoundCell = {
|
||||
round_number: number
|
||||
label: string
|
||||
tone: "under" | "even" | "over" | null
|
||||
}
|
||||
|
||||
type ApiLeaderboardEntry = {
|
||||
tournament_participant_id: string
|
||||
player_name: string
|
||||
|
|
@ -125,6 +132,14 @@ type ApiLeaderboardEntry = {
|
|||
bbb_total: number | null
|
||||
class_id: 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 = {
|
||||
|
|
@ -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))
|
||||
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) {
|
||||
const { label, value } = valueFor(entry)
|
||||
const isLeader = rank === 1 && value !== null
|
||||
|
|
@ -2229,9 +2264,16 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
|||
{group.label}
|
||||
</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">
|
||||
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
|
||||
</ul>
|
||||
)}
|
||||
</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