From 76fe6f6c2ca89330450d88428c84d1697afb25f2 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 3 Aug 2026 17:30:57 +0200 Subject: [PATCH] Full V0-tro omskriving av rundeleaderboard, tab-navigasjonsbug, formatbryter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brukeren testet en Slagspill-runde etter forrige leaderboard-utrulling og så ingen visuell endring - forrige runde hadde gjenbrukt gamle håndkodede komponenter for individuell/to-sidet visning i stedet for å implementere V0s tegning fullt ut. Skrev om round-leaderboard.tsx fra bunnen mot V0s faktiske komponenter, portet manuelt mot ekte API-data. Fant og rettet fem reelle bugs underveis (courseHcp-matte, manglende hcpIndex, ødelagt TeamFlightBoard-pille, CSS-trunkering i SideName, feil kildetekst for matchoverskrift). Fikset også: "Spillere og runde"-snarveien som ikke virket fra Score-fanen (useEffect manglet på searchParams-endring), play-format-bryteren i Rediger runde-panelet utvidet fra 3 til 16 formater, 8 nye statistikkrader i scorekortet (Fairway/Putt/GIR/Innspill/Chip/Bunker/Straffeslag/Anyway). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 164 +++ frontend/components/round-detail.tsx | 23 +- frontend/components/round-leaderboard.tsx | 1583 +++++++++++++++------ frontend/components/round-scorecard.tsx | 256 +++- 4 files changed, 1582 insertions(+), 444 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eac449..6abe707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6520,6 +6520,170 @@ Ferdig og verifisert: spiller-rad-tilfellet (`ScorecardGrid`), og det fantes ingen tilsvarende referanse å bygge en "side-kort"-variant mot. Fortsatt det brede gridet for de formatene, uendret. +- **Fem separate brukerpunkter fra samme økt, ALLE BYGGET, SCRATCH- + VERIFISERT OG LIVE (2026-08-03), samme dag som score-kortrunden + over:** + 1. **Ekte bug funnet via opplastet skjermopptak, rettet:** brukeren + rapporterte at "snarveien" (Administrer-dialogens "Spillere og + runde"-lenke, `?tab=manage`) ikke virket fra Score-fanen, men + virket fra Scorekort/Leaderboard. Video analysert bilde for bilde + (ffmpeg) -- bekreftet presist: URL-en endret seg riktig til + `?tab=manage`, men selve fanen ble stående på "Score". Rotårsak: + `useState(initialTab)` i round-detail.tsx leste kun + `useSearchParams()` ved FØRSTE mount -- en navigasjon FRA Score- + siden til seg selv med kun en ny query-parameter er ingen ekte + remount i Next.js App Router, så verdien ble aldri regnet på nytt. + Fra Scorekort/Leaderboard virket det fordi det ER en remount + (annen rute). Fikset med en `useEffect` som reagerer på selve + parameter-ENDRINGEN (ensrettet -- hopper TIL "manage", tvinger + aldri tilbake til "score"). Reprodusert i scratch FØR fiksen + (bekreftet feilen), verifisert etterpå fra alle tre + inngangspunkter (Score/Scorekort/Leaderboard) + at lokal + "Score"-pille fortsatt virker uendret. + 2. **`EditRoundPanel` sin spilleform-bryter utvidet fra 3 til 16 + format:** var blitt hengende igjen på stroke/match/stableford fra + FØR de åtte nye formatene (ADR-039) ble bygget -- en ren + forglemmelse. Rettet BÅDE i backend (`RoundUpdate.play_format` + i rounds.py hadde samme gamle 3-verdis-Literal) OG frontend (ny + `EDITABLE_PLAY_FORMATS`-liste, samme etiketter som new-round.tsx). + Verifisert live i scratch: byttet en pågående runde fra Slagspill + til Fourball midt i runden, `SidesPanel` sin eksisterende "opprett + sider først"-melding tok over korrekt uten noen krasj. + 3. **`completed_at`-feltet for å overstyre fullført-tidspunkt manuelt + bekreftet FORTSATT TIL STEDE, uendret siden 2026-07-24** -- ren + kodeverifisering, ingen endring nødvendig. Feltet vises (med + hensikt) kun ETTER at "Fullfør runde" er trykket -- forvirringen + var trolig et resultat av bug (1) over (kom seg aldri til + "Spillere og runde" for å finne knappen). + 4. **`round-scorecard.tsx` sitt individuelle scorekort: kolonnetekst- + overflow rettet + 8 nye detaljrader lagt til** (Fairway/Putt/ + GIR/Innspill/Chip/Bunkerslag/Straffeslag/Anywayslag), alle med + forkortede radetiketter + kompakte lucide-ikoner (bøyde piler for + Fairway, rette piler+blink for Innspill, sjekkmerke for GIR) -- + samme golfscore-språk som resten av appen. Data var allerede + tilgjengelig i `RoundHoleOut` (bare ikke lest av denne siden før). + Vises kun når minst ett hull faktisk har dataen (samme "vis kun + det som finnes"-prinsipp). GIR-formelen gjenbruker EKSAKT samme + `score - putts <= par - 2`-regel som round-stats.tsx/round- + detail.tsx allerede bruker. + 5. **Rundeleaderboard (V0-eksport "zip 28") -- IKKE V0 sin skyld at + forrige versjon "så rart sammenskrudd ut", presisert til bruker: + forrige leaderboard var HÅNDKODET (ikke V0), bygget 2026-07-26 da + brukeren gikk tom for V0-credits. Zip 28 dekket alle hullene i + V0-prompten (skrevet samme økt) presist -- portet MANUELT mot ekte + data, IKKE en full filerstatning: gjenbrukte eksisterende + `rankEntries`/`ValueMark`/`RankBadge`/`ModeToggle`/`HoleStrip`/ + matchstatus-tug-of-war-baren (allerede nær identisk med V0s egen + visuelle idé, lavere risiko å beholde enn å skrive om). Kun TRE + genuint manglende/feil biter bygget: + - **High-low-high** falt tidligere inn i den generiske to-sidede + "X UP"-matchstatusen (bekreftet FEIL for dette formatet -- HLH + er 0-2 poeng-per-hull, ikke hull-ledelse). Ny `HighLowSection`: + lav/høy-duell-rollen per spiller per hull utledes fra + `entries[].points` (allerede MIN/MAKS av lagkameratenes + Stableford-poeng den hullet, samme regel som + `handicap_engine.py` sin `high_low_high_points_for_hole` -- + ingen backend-endring trengtes). Verifisert tall-for-tall i + scratch: per-hull lav/høy-dueller summerte EKSAKT til + `hlh_points_a`/`hlh_points_b` sine totaler. + - **Bingo Bango Bongo/Københavner** sitt "Poeng"-tall var + `ApiLeaderboardEntry.total_points` -- ALLTID generisk Stableford + fra backend, uansett format (bekreftet reell bug: disse to + formatene har sine EGNE poengsystem, aldri vist noe sted). + Erstattet med faktiske `bbb_points`/`copenhagen_points` fra + format-result FØR rangeringen bygges. Fanget og rettet EN egen + bug UNDER scratch-verifiseringen: en deltaker med 0 ekte + BBB/Københavner-poeng manglet fra dict-en (ikke `0`, fraværende + nøkkel) -- første versjon falt da feilaktig tilbake til generisk + Stableford i stedet for `0`. Rettet, verifisert: Københavner- + poeng summerte EKSAKT til 6 per hull (regelen), BBB viste riktig + `0` for en spiller uten noen bingo/bango/bongo-vinst. + - **Shamble/Money Ball** sitt offisielle LAGRESULTAT (hele runden + = ett lag) lå tidligere IKKE synlig noe sted -- kun spillernes + råtall. Ny `TeamResultSection`: lagets til-par (fra + `shamble_team_score`/`money_ball_team_score` + hullenes par-sum, + begge allerede i format-result), pluss en NY flight-gruppe- + sammenligning (henter søsken-rundenes EGNE format-result via et + nytt `useFlightTeamStandings`-kall) mot andre lag i samme utgang + -- tidligere fantes INGEN slik sammenligning i det hele tatt. + Verifisert tall-for-tall i scratch (Shamble beste-2-av-3, Money + Ball sin rotasjon) mot to separate flight-koblede runder. + `RoundLeaderboardMini` (forhåndsvisningen i "Spillere og runde") + oppdatert til å skjule seg for Shamble/Money Ball også (samme + "ikke vis noe fremfor å vise en misvisende rangering"-prinsipp den + allerede fulgte for to-sidede format). + Regresjonstestet: vanlig Slagspill og Fourball uendret (identisk + visning/matchstatus som før refaktoreringen som lot + `MatchStatusSection` motta `result` som prop i stedet for å hente + selv). + **Rullet ut sammen med de fire punktene over, 2026-08-03**: BÅDE + `teecup_api` (RoundUpdate.play_format-utvidelsen) OG + `teecup_frontend` bygget og restartet, begge boot-et rent, + bekreftet via kompilert bundle-grep + en faktisk backend- + spørring mot den nye Literal-en. Zip-en slettet etter merge. +- **Rundeleaderboard (zip 28) — punkt 5 over ERSTATTET, full omskriving + (2026-08-03, samme dag):** brukeren testet en vanlig Slagspill-runde + (den klart vanligste stien) etter forrige punkts utrulling og så + NULL visuell endring, sendte skjermdump og spurte hva som hadde + skjedd. Årsak: forrige punkt gjenbrukte bevisst de gamle håndkodede + `RankBadge`/`ValueMark`/`ModeToggle`/`HoleStrip`/matchstatus-baren for + individuell/to-sidet visning fordi jeg vurderte dem som "strukturelt + nære nok" V0s tegning — akkurat den stien brukeren testet var derfor + uendret. Brukerens eksplisitte, ordrette svar (bevart for ettertiden): + *"Ja, jeg vil ABSOLUTT at du implementerer V0 sin tolkning av hvordan + Leaderboardet skal se ut. Jeg vil ikke at du skal gjøre personlige + endringer eller holde tilbake på noe i det hele tatt når det gjelder + dette."* Lagret som stående feedback-memory (`v0-full-fidelity`) — + gjelder ALL fremtidig V0-integrering i dette prosjektet, ikke bare + denne filen. + Fulgte opp med en fullstendig omskriving av `round-leaderboard.tsx`, + denne gangen med HVER V0-komponent portet manuelt mot ekte data (ikke + gjenbrukt): `Badge`/`MetricPill`/`ScoreMark`+`classify`/ + `IndividualBoard`/`MetricToggle`/`HoleByHole`/`Legend` (individuelt), + `TwoSidedBoard`/`SideName`/`HoleWinners` (to-sidet), `HighLowBoard`/ + `DuelRow` (High-low-high), `TeamFlightBoard` (Shamble/Money Ball) — de + gamle håndkodede visningskomponentene slettet i sin helhet, ikke + beholdt ved siden av. + Fem reelle bugs funnet og rettet UNDER omskrivingen (ikke antatt + riktig fra en ren typesjekk alene — funnet ved skjermdump-inspeksjon i + scratch): + 1. `courseHcp` var meningsløs plassholder-matematikk + (`strokes_received * 18` uansett hva slagindeksen faktisk var) — + V0s mockup antok klientside-utregning av spillehandicap, men + backend returnerer allerede korrekt per-hull `strokes_received` + direkte. Rettet: `IndividualPlayer` fikk et ekte + `strokesReceived`-array lest direkte derfra, netto/Stableford + regnet fra DET, ikke gjenutledet. + 2. `hcpIndex` var hardkodet `null` alltid — feltet + (`handicap_index_snapshot`) fantes allerede i det hentede + `/rounds/{id}`-svaret, bare ikke lest av filens egen, snevrere + lokale type. Utvidet typen, lest verdien. + 3. `TeamFlightBoard` sin råscore-pille hadde et komma-operator-uttrykk + som alltid evaluerte til hardkodet `0` uansett faktisk score. + Rettet med den allerede korrekte `grossToPar(m, holes)`. + 4. CSS-trunkeringsbug i `SideName`: det ledende laget sin flagg- + ikonrad manglet `min-w-0` på det indre flex-elementet, så navnet + ("Lag Bjørk") trunkerte til nesten ingenting ("L..") mens det ikke- + ledende laget viste seg fullt, selv i like brede `flex-1`- + containere. Rettet. + 5. Rotårsaken til (4) var egentlig et feil datavalg: brukte + `match_status_text` (en backend-streng som ALLEREDE inkluderer + lagnavnet, designet for en annen UI-plassering) i V0s + overskrifts-slot, som var designet for en mye kortere tekst siden + lagnavn vises separat via `SideName`. Rettet ved å utlede V0s + egen korte frase ("2 opp"/"Dormie 2 opp"/"Ferdig 2&1"/"Vant 2 + opp"/"Delt"/"Alt likt") direkte fra de rå numeriske feltene + (`match_lead`/`match_holes_played`/`match_holes_remaining`/ + `match_is_dormie`/`match_is_closed`) — fortsatt serverens + autoritative tall, bare riktig tekstformat. + **Scratch-verifisert på alle åtte formater** (Slagspill, BBB, + Københavner, High-low-high, Fourball, Shamble, Money Ball, pluss + `RoundLeaderboardMini`-regresjon) med tall håndverifisert mot samme + testdata som resten av ADR-039-arbeidet — se punkt 5 over for selve + tallutregningen, uendret av denne omskrivingen. **Rullet ut live + 2026-08-03** (kun `teecup_frontend` bygget/restartet — backend- + endringen fra punktene over var allerede live). Scratch-miljøet + (DB/rolle/MinIO/API-/frontend-containere) ryddet opp etter bruk. Neste steg: 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 97d619e..7fd6f39 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -400,9 +400,17 @@ export function RoundDetail({ roundId }: { roundId: string }) { const router = useRouter() // Delt header (ADR-040) sin "Administrer"-dialog lenker hit med // ?tab=manage for å nå "Spillere og runde" fra Scorekort-/Leaderboard- - // siden -- lest KUN ved mount (bevisst ikke reaktiv på senere endringer, - // matcher den eksisterende lokale pill-vekslingen som fortsatt fungerer - // uendret for navigering INNAD på siden). + // siden. Fungerte derfra fordi det er en navigasjon MELLOM to ulike + // rute-komponenter (ekte remount, `initialTab` regnes ut på nytt) -- + // men fra selve Score-siden er `/my-rounds/{id}` -> `/my-rounds/{id} + // ?tab=manage` KUN en søkeparameter-endring på SAMME rute, ingen + // remount, så `useState(initialTab)` sitt opprinnelige mount-tidspunkt- + // resultat ble stående uendret -- knappen så ut til å ikke gjøre noe + // (rapportert av bruker via video 2026-08-03, bekreftet: URL-en endret + // seg riktig til ?tab=manage, men selve fanen forble "Score"). Fikset + // med en effekt som reagerer på selve søkeparameter-ENDRINGEN, ikke + // bare mount -- ensrettet (kun til "manage"), rører aldri brukerens + // egen lokale "Score"-valg via pillen etterpå. const searchParams = useSearchParams() const initialTab = searchParams.get("tab") === "manage" ? "manage" : "score" const [round, setRound] = useState(null) @@ -442,6 +450,15 @@ export function RoundDetail({ roundId }: { roundId: string }) { // når frem til oppgaven). "Score" er default -- den ENESTE fanen de // fleste trenger under selve spillingen. const [pageTab, setPageTab] = useState<"score" | "manage">(initialTab) + // Fikser "Administrer"-snarveien fra selve Score-siden (se begrunnelse + // ved searchParams over) -- reagerer på selve parameter-ENDRINGEN i + // stedet for kun mount-tidspunktet. Ensrettet med vilje: hopper TIL + // "manage" når URL-en ber om det, men tvinger aldri tilbake til "score" + // om parameteren mangler/fjernes (det ville kunnet rive brukeren vekk + // fra et bevisst lokalt "Score"-valg de allerede har gjort). + useEffect(() => { + if (searchParams.get("tab") === "manage") setPageTab("manage") + }, [searchParams]) const [completing, setCompleting] = useState(false) const [deleting, setDeleting] = useState(false) const [showEditRound, setShowEditRound] = useState(false) diff --git a/frontend/components/round-leaderboard.tsx b/frontend/components/round-leaderboard.tsx index 14228ff..cc3d98a 100644 --- a/frontend/components/round-leaderboard.tsx +++ b/frontend/components/round-leaderboard.tsx @@ -1,23 +1,28 @@ "use client" -// Leaderboard for en frittstående runde (ADR-033), 2026-07-26. -// Håndkodet (ikke via V0 -- brukeren gikk tom for credits, ba meg bygge -// direkte etter samme designspesifikasjon som var skrevet til V0-prompten, -// se FEATURE_BACKLOG.md). Gjenbruker etablerte mønstre fra -// round-scorecard.tsx (signedToPar-formatering, form-før-farge-språket fra -// ScoreMark, stableford-formelen) og round-stats.tsx/round-scorecard.tsx sin -// WS-sanntid-/viewerId-oppkobling, i stedet for å finne opp nye. +// Leaderboard for en frittstående runde (ADR-033). // -// Radene er BEVISST minimale i kollapset tilstand (kun rangering/navn/tall, -// 2026-07-26 -- brukeren ba om å fjerne "Deg"/"Eier"/pokal/hull-spilt- -// teksten derfra og la radene flyte sømløst i hverandre uten egne -// kort-rammer) -- den informasjonen flyttet inn i den utvidbare seksjonen -// sammen med hullscorene, ikke fjernet helt. +// FULLSTENDIG OMBYGGET 2026-08-03 etter V0-eksport "zip 28": brukeren ba +// EKSPLISITT om V0 sin visuelle tolkning i FULL BREDDE, ikke bare de +// formatene som var reelt ødelagte/manglet ("Jeg vil ABSOLUTT at du +// implementerer V0 sin tolkning... Jeg vil ikke at du skal gjøre +// personlige endringer eller holde tilbake på noe i det hele tatt"). +// Forrige runde samme dag portet kun High-low-high/BBB-Københavner/ +// Shamble-Money-Ball (reelle bugs) og lot resten av det gamle, +// HÅNDKODEDE designet (2026-07-26, bygget FØR V0 da brukeren gikk tom +// for credits) stå urørt -- brukeren testet en vanlig slagspill-runde +// (det vanligste tilfellet) og så null visuell endring, med rette. +// +// Datalaget (typer, useLeaderboardData/useFormatResult/ +// useFlightTeamStandings, rankEntries brukt av RoundLeaderboardMini) er +// UENDRET fra forrige runde -- kun det visuelle laget er nå V0 sin egen +// kode, portet mot ekte data i stedet for V0 sine mock-arrays (samme +// "aldri blind-kopier en fil"-disiplin som alltid, bare nå anvendt på +// ALLE komponentene eksporten rørte, ikke et utvalg). import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft, ChevronDown, ChevronRight, Trophy } from "lucide-react" -import { Badge } from "@/components/ui/badge" +import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react" import { cn } from "@/lib/utils" // --- API-typer --------------------------------------------------------------- @@ -53,6 +58,8 @@ type ApiLeaderboard = { type ApiRoundParticipant = { id: string user_id: string | null + round_side_id: string | null + handicap_index_snapshot: number | null } type ApiSide = { @@ -79,17 +86,25 @@ type ApiRoundHeader = { // score PER SIDE) -- den vanlige brutto/netto/poeng-rangeringen under ville // derfor vist en tom/meningsløs liste for dem uansett. const TWO_SIDED_FORMATS = [ - "match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high", + "match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", ] +// High-low-high er et SÆRTILFELLE av de to-sidede formatene -- IKKE et +// hull-ledelse-konsept ("X UP"), se HighLowBoard. +const HIGH_LOW_FORMATS = new Set(["high_low_high"]) +// Shamble/Money Ball -- HELE runden er ETT lag, ikke to sider -- lagets +// EGEN samlede hullscore + flight-gruppe-sammenligning, se TeamFlightBoard. +const TEAM_RESULT_FORMATS = new Set(["shamble", "money_ball"]) +// Bingo Bango Bongo/Københavner har egne poengsystem, uavhengig av rå +// slagtall -- `ApiLeaderboardEntry.total_points` er ALLTID generisk +// Stableford (backend regner den likt uansett format), FEIL for disse to. +const FORMAT_OWN_POINTS_FIELD: Partial> = { + bbb: "bbb_points", + copenhagen: "copenhagen_points", +} const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) -// --- Rangering --------------------------------------------------------------- -// Delt plassering (likt resultat) skal vises som "T-N", ikke to ulike tall. -// Deltakere uten et tall for gjeldende visning (brutto/netto/poeng) havner -// samlet nederst, uten rangeringstall -- IKKE skjult, IKKE en krasj. -// Poeng (stableford) rangeres SYNKENDE (høyest vinner) -- motsatt av -// brutto/netto til-par (lavest vinner). +// --- Rangering (brukt av RoundLeaderboardMini) ------------------------------ type Mode = "gross" | "net" | "points" @@ -135,250 +150,898 @@ function formatToPar(value: number): string { return value > 0 ? `+${value}` : `−${Math.abs(value)}` } -// --- Verdi-merke (brutto/netto til-par, eller poeng) ------------------------ -// Samme "form + farge, aldri kun farge"-språk som ScoreMark i -// round-scorecard.tsx: sirkel = under par, firkant = over par, ren tekst ved -// jevnt med par. Poeng har ingen retning (høyere er alltid bedre) -- én fast -// sirkelform, men samme visuelle vekt/fyll-mønster for lederen. Lederen -// (emphasize) får en FYLT variant for litt ekstra visuell vekt. +// =========================================================================== +// V0 sitt visuelle vokabular (zip 28, 2026-08-03) -- portet verbatim, kun +// HOLES/TOTAL_HOLES er ikke lenger en hardkodet modul-konstant (V0 sin +// egen mock-bane) men en `holes: Hole[]`/`totalHoles: number`-parameter +// bygget fra RUNDENS EGNE par/indeks (se buildHoles under) -- ellers +// samme kode, samme klassenavn, samme struktur. +// =========================================================================== -function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) { +type Hole = { holeNumber: number; par: number; index: number } + +const SIDE = { + a: { fill: "bg-primary", text: "text-primary-foreground", ink: "text-primary" }, + b: { fill: "bg-brand-orange", text: "text-brand-orange-foreground", ink: "text-brand-orange" }, +} as const + +type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" + +function classify(gross: number, par: number): ScoreClass { + const d = gross - par + if (d <= -2) return "eagle" + if (d === -1) return "birdie" + if (d === 0) return "par" + if (d === 1) return "bogey" + return "double" +} + +function scoreClassLabel(gross: number, par: number): string { + const d = gross - par + if (d <= -1) return "under par" + if (d === 0) return "par" + return "over par" +} + +function ScoreMark({ gross, par, size = "md" }: { gross: number; par: number; size?: "sm" | "md" }) { + const kind = classify(gross, par) + const dim = size === "sm" ? "size-8 text-sm" : "size-9 text-base" + const shared = cn("inline-flex items-center justify-center font-extrabold tabular-nums", dim) + let cls = "" + if (kind === "eagle") cls = "rounded-full bg-primary text-primary-foreground" + else if (kind === "birdie") cls = "rounded-full border-2 border-primary bg-primary/10 text-primary" + else if (kind === "bogey") cls = "rounded-[6px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange" + else if (kind === "double") cls = "rounded-[6px] bg-brand-orange text-brand-orange-foreground" + else cls = "text-foreground" + return ( + + {gross} + ({scoreClassLabel(gross, par)}) + + ) +} + +function toParLabel(diff: number): string { + if (diff === 0) return "E" + return diff > 0 ? `+${diff}` : `${diff}` +} + +function toParWords(diff: number): string { + if (diff === 0) return "likt med par" + return diff > 0 ? `${diff} over par` : `${Math.abs(diff)} under par` +} + +const NF1 = new Intl.NumberFormat("no-NO", { maximumFractionDigits: 1 }) + +function Badge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "self" | "owner" }) { + const map = { + neutral: "bg-muted text-muted-foreground", + self: "bg-primary/15 text-primary", + owner: "bg-gold/25 text-gold-foreground", + } as const + return ( + + {children} + + ) +} + +function MetricPill({ + kind, + value, + unit, + size = "md", +}: { + kind: "topar" | "points" + value: number + unit?: string + size?: "md" | "lg" +}) { + const dims = size === "lg" ? "min-h-12 min-w-12 px-3 text-xl" : "min-h-11 min-w-11 px-2.5 text-lg" + const font = "font-extrabold tabular-nums" + if (kind === "points") { + return ( + + {value} + {unit ? {unit} : null} + + ) + } + const label = toParLabel(value) if (value === 0) { - return E + return ( + + {label} + {toParWords(value)} + + ) } const under = value < 0 return ( - {formatToPar(value)} + {label} + {toParWords(value)} ) } -function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) { +// =========================================================================== +// 1. INDIVIDUAL leaderboard (stroke / stableford / skins / flag / BBB / KBH) +// =========================================================================== + +type MetricKey = "brutto" | "netto" | "stableford" | "points" + +type IndividualPlayer = { + id: string + name: string + isSelf?: boolean + isOwner?: boolean + // Ekte per-hull mottatte slag (allerede korrekt utregnet av backend, + // samme allokering som resten av appen) -- IKKE en rå course handicap + + // en egen klientsidig strokes_received-utregning, som V0 sin mock- + // versjon gjorde uten et ekte backend å hente fra. `null` for et hull + // betyr "ingen HCP spores for denne spilleren", ikke "0 slag". + strokesReceived: (number | null)[] + hcpIndex: number | null + finished?: boolean + gross: (number | null)[] + holePoints?: (number | null)[] +} + +function hasHcp(p: IndividualPlayer): boolean { + return p.strokesReceived.some((v) => v !== null) +} + +const METRIC_LABEL: Record = { + brutto: "Brutto", + netto: "Netto", + stableford: "Stableford", + points: "Poeng", +} + +function playedHoles(p: IndividualPlayer): number { + return p.gross.filter((g) => g !== null).length +} + +function grossToPar(p: IndividualPlayer, holes: Hole[]): number { + let diff = 0 + p.gross.forEach((g, i) => { + if (g !== null) diff += g - holes[i].par + }) + return diff +} + +function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null { + if (!hasHcp(p)) return null + let diff = 0 + p.gross.forEach((g, i) => { + const received = p.strokesReceived[i] + if (g !== null && received !== null) diff += g - received - holes[i].par + }) + return diff +} + +function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null { + if (!hasHcp(p)) return null + let pts = 0 + p.gross.forEach((g, i) => { + const received = p.strokesReceived[i] + if (g !== null && received !== null) pts += Math.max(0, 2 + (holes[i].par - (g - received))) + }) + return pts +} + +function pointsTotal(p: IndividualPlayer): number { + return (p.holePoints ?? []).reduce((s, v) => s + (v ?? 0), 0) +} + +function evalMetric( + p: IndividualPlayer, + metric: MetricKey, + pointsUnit: string, + holes: Hole[], +): { + rankable: boolean + sortVal: number + higherWins: boolean + reason?: string + pill: React.ReactNode +} { + if (metric === "brutto") { + const v = grossToPar(p, holes) + return { rankable: true, sortVal: v, higherWins: false, pill: } + } + if (metric === "netto") { + const v = nettoToPar(p, holes) + if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: } + return { rankable: true, sortVal: v, higherWins: false, pill: } + } + if (metric === "stableford") { + const v = stablefordTotal(p, holes) + if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: } + return { rankable: true, sortVal: v, higherWins: true, pill: } + } + const v = pointsTotal(p) + return { rankable: true, sortVal: v, higherWins: true, pill: } +} + +function NoData() { return ( - - {value} + + –ingen rangering ) } -function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) { - return mode === "points" ? : -} +function IndividualBoard({ + players, + metrics, + defaultMetric, + pointsUnit = "p", + holes, +}: { + players: IndividualPlayer[] + metrics: MetricKey[] + defaultMetric: MetricKey + pointsUnit?: string + holes: Hole[] +}) { + const [metric, setMetric] = useState(defaultMetric) + const [openId, setOpenId] = useState(null) + + const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes) })) + const rankable = evaluated.filter((e) => e.rankable) + const unranked = evaluated.filter((e) => !e.rankable) + rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal)) + + const rankInfo: { rank: number; tied: boolean }[] = [] + const counts: Record = {} + rankable.forEach((e, i) => { + const rank = i > 0 && rankable[i - 1].sortVal === e.sortVal ? rankInfo[i - 1].rank : i + 1 + rankInfo.push({ rank, tied: false }) + counts[rank] = (counts[rank] ?? 0) + 1 + }) + rankInfo.forEach((x) => (x.tied = counts[x.rank] > 1)) -function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) { return ( - +
+ + +
    + {rankable.map((e, i) => ( + setOpenId((cur) => (cur === e.player.id ? null : e.player.id))} + /> + ))} + {unranked.map((e) => ( + setOpenId((cur) => (cur === e.player.id ? null : e.player.id))} + /> + ))} +
+ + +
) } -function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) { - const options: { value: Mode; label: string }[] = [ - { value: "gross", label: "Brutto" }, - { value: "net", label: "Netto" }, - { value: "points", label: "Poeng" }, - ] +function MetricToggle({ + metrics, + value, + onChange, + pointsUnit, +}: { + metrics: MetricKey[] + value: MetricKey + onChange: (m: MetricKey) => void + pointsUnit: string +}) { + if (metrics.length < 2) return null return ( -
- {options.map((opt) => ( - + ) + })} +
+ ) +} + +function IndividualRow({ + player, + rank, + tied, + isLeader, + pill, + reason, + metric, + holes, + open, + onToggle, +}: { + player: IndividualPlayer + rank: number | null + tied: boolean + isLeader: boolean + pill: React.ReactNode + reason?: string + metric: MetricKey + holes: Hole[] + open: boolean + onToggle: () => void +}) { + const played = playedHoles(player) + const totalHoles = holes.length + const progress = player.finished ? "Ferdig" : `${played}/${totalHoles} hull` + const panelId = `hbh-${player.id}` + return ( +
  • + + + {open && ( +
    + +
    + )} +
  • + ) +} + +function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metric: MetricKey; holes: Hole[] }) { + const front = holes.slice(0, 9) + const back = holes.slice(9) + return ( +
    +

    + Hull-for-hull · brutto (form) og netto (tall). Sirkel = under par, firkant = over par. +

    + {[front, back].filter((set) => set.length > 0).map((set, si) => { + const offset = si === 0 ? 0 : front.length + return ( +
    + + + + + {set.map((h) => ( + + ))} + + + + + {set.map((h) => ( + + ))} + + + + + + {set.map((h, i) => { + const g = player.gross[offset + i] + return ( + + ) + })} + + + {hasHcp(player) && ( + + + {set.map((h, i) => { + const g = player.gross[offset + i] + const received = player.strokesReceived[offset + i] + if (g === null || received === null) return + return + })} + + )} + {metric === "points" && player.holePoints && ( + + + {set.map((h, i) => { + const pt = player.holePoints?.[offset + i] + return + })} + + + )} + +
    Hull{h.holeNumber}{si === 0 ? "Ut" : "Inn"}
    Par · idx{h.par}·{h.index} +
    Brutto + {g === null ? : } + + {set.reduce((s, h, i) => s + (player.gross[offset + i] ?? 0), 0) || "–"} +
    Netto{g - received} +
    Poeng{pt ?? "–"} + {set.reduce((s, h, i) => s + (player.holePoints?.[offset + i] ?? 0), 0)} +
    +
    + ) + })} +
    + ) +} + +function Legend() { + return ( +
    + + 3 + Under par (sirkel) + + + 5 + Over par (firkant) + + + +
    + ) +} + +// =========================================================================== +// 2. TWO-SIDED match status (match / fourball / foursome / greensome / …) +// =========================================================================== + +type SideTeamInfo = { id: "a" | "b"; name: string; players: string[] } +type HoleWinner = "a" | "b" | "halved" | null + +// Statusteksten/lederen/dormie/avgjort kommer FERDIG REGNET fra backend +// (samme tall som allerede vises andre steder i appen, f.eks. +// round-detail.tsx sin FormatResultPanel) -- V0 sin egen `matchStatus()` +// regnet dette på nytt fra `results` klientsidig; her brukes heller de +// allerede korrekte/testede server-tallene, med SAMME visuelle "tug of +// war"-presentasjon V0 tegnet. Unngår to divergerende utregninger av +// samme ting. +function TwoSidedBoard({ + sideA, + sideB, + results, + totalHoles, + formatName, + headline, + sub, + leader, + isDormie, + isClosed, +}: { + sideA: SideTeamInfo + sideB: SideTeamInfo + results: HoleWinner[] + totalHoles: number + formatName: string + headline: string + sub: string + leader: "a" | "b" | null + isDormie: boolean + isClosed: boolean +}) { + const holesPlayed = results.filter((r) => r !== null).length + let aWins = 0 + let bWins = 0 + results.forEach((r) => { + if (r === "a") aWins++ + else if (r === "b") bWins++ + }) + const diff = Math.abs(aWins - bWins) + const maxUp = Math.max(3, diff) + const offsetPct = (diff / maxUp) * 50 + const markerLeft = leader === "a" ? 50 - offsetPct : leader === "b" ? 50 + offsetPct : 50 + + return ( +
    +
    + + {formatName} + {isClosed && Avgjort} + {isDormie && !isClosed && Dormie} + +
    + +
    + {headline} + {sub} +
    + +
    + + + ) +} + +function SideName({ side, team, leading, align = "left" }: { side: "a" | "b"; team: SideTeamInfo; leading: boolean; align?: "left" | "right" }) { + return ( +
    + + + {team.players.join(" / ")} + {leading && leder} +
    + ) +} + +function HoleWinners({ sideA, sideB, results, totalHoles }: { sideA: SideTeamInfo; sideB: SideTeamInfo; results: HoleWinner[]; totalHoles: number }) { + return ( +
    +

    Hull-for-hull

    +
    +
    + {Array.from({ length: totalHoles }, (_, i) => i + 1).map((holeNumber, i) => { + const r = results[i] ?? null + const label = r === "a" ? sideA.name : r === "b" ? sideB.name : r === "halved" ? "delt" : "ikke spilt" + return ( +
    + {holeNumber} + + {r === "a" ? "A" : r === "b" ? "B" : r === "halved" ? "=" : ""} + Hull {holeNumber}: {label} + +
    + ) + })} +
    +
    +
    + A{sideA.name} + B{sideB.name} + =Delt +
    +
    + ) +} + +// =========================================================================== +// 3. HIGH-LOW-HIGH -- to duller per hull, poeng per side (IKKE hull-opp) +// =========================================================================== + +type Duel = "a" | "b" | "halved" | null +type HoleDuel = { low: Duel; high: Duel } + +function HighLowBoard({ + sideA, + sideB, + duels, + totalA, + totalB, + formatName, +}: { + sideA: SideTeamInfo + sideB: SideTeamInfo + duels: HoleDuel[] + totalA: number + totalB: number + formatName: string +}) { + const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null + const fmt = (n: number) => NF1.format(n) + return ( +
    +
    + {formatName} · totale poeng +
    + {(["a", "b"] as const).map((s) => { + const team = s === "a" ? sideA : sideB + const val = s === "a" ? totalA : totalB + const isLead = leader === s + return ( +
    + + + {fmt(val)} + poeng {isLead ? "· leder" : ""} +
    + ) + })} +
    +

    + To uavhengige dueller per hull: laveste mot laveste og høyeste mot høyeste netto/Stableford-poeng. 0–2 poeng per hull. +

    +
    + +
    +

    Hull-for-hull · lav / høy

    +
    +
    + ({ text: String(i + 1), head: true }))} /> + duelCell(d.low))} /> + duelCell(d.high))} /> +
    +
    +
    + A{sideA.name} + B{sideB.name} + =Delt (½–½) +
    +
    +
    + ) +} + +function duelCell(d: Duel): { text: string; tone: Duel } { + return { text: d === "a" ? "A" : d === "b" ? "B" : d === "halved" ? "=" : "–", tone: d } +} + +function DuelRow({ label, cells }: { label: string; cells: { text: string; head?: boolean; tone?: Duel }[] }) { + return ( +
    + {label} + {cells.map((c, i) => ( + - {opt.label} - + {c.text} + ))}
    ) } -// --- Hullscorer per deltaker (2026-07-26, etterspurt av bruker) ----------- -// Samme "form + farge, aldri kun farge"-visuelle språk som ScoreMark i -// round-scorecard.tsx (sirkel/firkant/fylt-ved-2+-av) -- egen, liten -// lokal versjon her (ikke en delt import) siden round-scorecard.tsx sin -// ScoreMark er skreddersydd for ETT hull om gangen i en tabell, ikke en -// kompakt horisontal strip for MANGE deltakere i en liste. - -type HoleScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" - -function classifyHoleScore(gross: number, par: number): HoleScoreClass { - const diff = gross - par - if (diff <= -2) return "eagle" - if (diff === -1) return "birdie" - if (diff === 0) return "par" - if (diff === 1) return "bogey" - return "double" +// Lav/høy-rollen per spiller per hull er ikke tagget eksplisitt av +// backend, men er alltid MIN/MAKS av de to lagkameratenes Stableford- +// poeng den hullet (samme regel som handicap_engine.py sin +// high_low_high_points_for_hole) -- utledes trygt her uten noen +// backend-endring. +function highLowDuelForHole(hole: ApiFormatHole): HoleDuel { + const aPoints = hole.entries.filter((e) => e.side === "a" && e.points !== null).map((e) => e.points as number) + const bPoints = hole.entries.filter((e) => e.side === "b" && e.points !== null).map((e) => e.points as number) + if (aPoints.length < 2 || bPoints.length < 2) return { low: null, high: null } + const lowA = Math.min(...aPoints) + const highA = Math.max(...aPoints) + const lowB = Math.min(...bPoints) + const highB = Math.max(...bPoints) + return { + low: lowA > lowB ? "a" : lowB > lowA ? "b" : "halved", + high: highA > highB ? "a" : highB > highA ? "b" : "halved", + } } -// Netto/poeng PER HULL (2026-07-26, etterspurt av bruker etter å ha vist -// referansebilder fra en konkurrentapp -- bevisst IKKE en kopi av den appens -// layout/farger, kun det strukturelle prinsippet "brutto fremhevet øverst, -// netto/poeng plassert rolig under" gjenskapt i TeeCups egen stil). -// `strokes_received` er allerede backend sin ferdigberegnede allokering -// (samme som resten av appen) -- ingen egen slagfordeling regnes ut her. +// =========================================================================== +// 4. TEAM / FLIGHT (Shamble / Money Ball) +// =========================================================================== -function netForHole(hole: ApiLeaderboardHole): number | null { - if (!hole.played || hole.score === null || hole.strokes_received === null) return null - return hole.score - hole.strokes_received -} +type FlightTeam = { id: string; name: string; toPar: number; through: number; isThisTeam?: boolean } -function pointsForHole(hole: ApiLeaderboardHole): number | null { - const net = netForHole(hole) - if (net === null) return null - return Math.max(0, hole.par - net + 2) -} - -function HoleMark({ hole, secondaryValue }: { hole: ApiLeaderboardHole; secondaryValue: number | null | undefined }) { - const shared = "flex size-7 items-center justify-center text-xs font-extrabold tabular-nums" - // "Plukket opp" (migrasjon 038, Stableford) -- 0 poeng er impliserte, - // vises som "PU" i stedet for det underliggende Net Double Bogey-tallet. - const markClass = hole.picked_up - ? "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-[10px] text-brand-orange" - : !hole.played || hole.score === null - ? "text-muted-foreground" - : (() => { - const kind = classifyHoleScore(hole.score, hole.par) - if (kind === "eagle") return "rounded-full bg-primary text-primary-foreground" - if (kind === "birdie") return "rounded-full border-2 border-primary bg-primary/10 text-primary" - if (kind === "bogey") return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange" - if (kind === "double") return "rounded-[4px] bg-brand-orange text-brand-orange-foreground" - return "font-bold text-foreground" - })() - return ( - - - {/* Brutto -- fremhevet, samme "form + farge"-språk som resten av - appen (fylt/farget merke), øverst -- den fremste raden. */} - - {hole.picked_up ? "PU" : hole.played && hole.score !== null ? hole.score : "–"} - - {/* Netto/poeng -- rolig, umerket tekst RETT under, samme prinsipp - som referansebildene (aldri fremhevet på samme måte som brutto). - Utelates helt i brutto-modus (ingenting nytt å vise der). */} - {secondaryValue !== undefined && ( - - {secondaryValue !== null ? secondaryValue : "–"} - - )} - - ) -} - -// Par/Hcp-referanserad (2026-07-29, etterspurt av bruker -- manglet helt) -// -- vist ÉN gang for hele leaderboardet (par/stroke-index er banedata, -// identisk for alle deltakere), i stedet for gjentatt i hver enkelt -// deltakers utvidbare HoleStrip. Samme kolonnebredde (w-8) som HoleMark, -// slik at radene visuelt stemmer overens med hver deltakers strip under. -function HoleReferenceStrip({ +function TeamFlightBoard({ + teamName, + toPar, + through, + totalHoles, + flight, + members, holes, - startHole, - holesPlanned, + formatName, }: { - holes: ApiLeaderboardHole[] - startHole: number - holesPlanned: number + teamName: string + toPar: number | null + through: number + totalHoles: number + flight: FlightTeam[] + members: IndividualPlayer[] + holes: Hole[] + formatName: string }) { - const order = Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1) - const ordered = order.map((n) => holes.find((h) => h.hole_number === n)).filter((h): h is ApiLeaderboardHole => h !== undefined) - if (ordered.length === 0) return null + const [showRaw, setShowRaw] = useState(false) + const ranked = [...flight].sort((a, b) => a.toPar - b.toPar) + const myRank = ranked.findIndex((t) => t.isThisTeam) + 1 + return ( -
    - Hull · Par · Hcp -
    -
    - {ordered.map((h) => ( - - {h.hole_number} - {h.par} - {h.stroke_index} +
    +
    + {formatName} · ditt lag +
    +
    + {teamName} + + {through === totalHoles ? "Ferdig" : `${through}/${totalHoles} hull`} + {flight.length > 1 && myRank > 0 ? ` · ${myRank}. i flighten` : ""} - ))} +
    + {toPar !== null ? : Ingen hull ferdig ennå}
    -
    - ) -} -function HoleStrip({ - holes, - startHole, - holesPlanned, - mode, -}: { - holes: ApiLeaderboardHole[] - startHole: number - holesPlanned: number - mode: Mode -}) { - // Samme sirkulære start_hole-rekkefølge som round-detail.tsx/ - // round-scorecard.tsx/round-stats.tsx -- viser hullene i FAKTISK - // spillerekkefølge, ikke nødvendigvis rå hullnummer 1-18. - const order = Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1) - const ordered = order.map((n) => holes.find((h) => h.hole_number === n)).filter((h): h is ApiLeaderboardHole => h !== undefined) - const secondaryLabel = mode === "net" ? "Netto" : mode === "points" ? "Poeng" : null - return ( -
    - {secondaryLabel && ( - - Slag · {secondaryLabel} - + {ranked.length > 1 && ( +
    +

    Flight-gruppe

    +
      + {ranked.map((t, i) => ( +
    1. + {i + 1} + + + {i === 0 && + {t.through === totalHoles ? "Ferdig" : `${t.through}/${totalHoles} hull`} + + +
    2. + ))} +
    +
    )} -
    -
    - {ordered.map((h) => ( - - ))} -
    + +
    + + {showRaw && ( +
      + {members.map((m) => ( +
    1. + + + {m.name} + {m.isSelf && Deg} + + + {m.hcpIndex !== null ? `Hcp ${NF1.format(m.hcpIndex)}` : "Uten HCP"} · {playedHoles(m)}/{totalHoles} hull + + + +
    2. + ))} +
    + )}
    ) } -// --- Matchstatus for to-sidede formater (2026-07-28) ------------------------ -// En match/fourball/foursome/greensome/scramble har ingen individuell -// rangering -- kun en status (hvem leder, hull for hull). Egen, liten -// versjon her (samme mønster som HoleMark over -- lokal duplisering -// fremfor cross-file-import for et lite presentasjons-stykke), ikke en -// full brutto/netto-tabell (den finnes allerede på scorekort-siden sin -// "Matchforløp"-seksjon -- lenket til herfra i stedet for duplisert). +// =========================================================================== +// Datalag: fetch-hooks (uendret fra forrige runde) +// =========================================================================== + +type ApiFormatHoleEntry = { + id: string + label: string + side: "a" | "b" | null + gross: number | null + net: number | null + strokes_received: number | null + counted: boolean + points: number | null +} + +type ApiFormatHole = { + hole_number: number + par: number + stroke_index: number + entries: ApiFormatHoleEntry[] + result: string | null +} type ApiFormatResult = { play_format: string @@ -390,18 +1053,13 @@ type ApiFormatResult = { match_is_dormie: boolean | null match_status_text: string | null hole_results: string[] | null -} - -// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i -// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx) -- den ledende -// sidens sone strekker seg proporsjonalt forbi midtlinjen. Ingen navn her -// (vises allerede andre steder på leaderboard-siden), kun fargesonene + -// den sentrerte statuspillen, for full visuell konsistens på tvers av alle -// steder en matchstatus vises. -function leadZoneFraction(lead: number, totalHoles: number): number { - if (totalHoles <= 0) return 0.5 - const fraction = 0.5 + (lead / totalHoles) * 0.5 - return Math.min(0.82, Math.max(0.18, fraction)) + copenhagen_points: Record | null + bbb_points: Record | null + shamble_team_score: number | null + money_ball_team_score: number | null + hlh_points_a: number | null + hlh_points_b: number | null + holes: ApiFormatHole[] | null } function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null { @@ -421,113 +1079,46 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | return result } -// match_status_text bruker bokstavelig "(A)"/"(B)" -- disse tilsvarer alltid -// round.sides[0]/[1] (begge sortert på id, samme rekkefølge som backend sin -// egen sortering av side-id-er). Erstatt med det faktiske side-navnet. -function substituteSideLabels(text: string, sides: ApiSide[]): string { - const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}` - return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`) +type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number } + +function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) { + const [standings, setStandings] = useState(null) + useEffect(() => { + let cancelled = false + async function load() { + const flightRes = await fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" }) + if (!flightRes.ok) return + const flight: { flight_group_id: string | null; flights: { round_id: string; name: string | null; is_anchor: boolean }[] } = + await flightRes.json() + if (flight.flights.length < 2) { + if (!cancelled) setStandings(null) + return + } + const results = await Promise.all( + flight.flights.map(async (f) => { + const r = await fetch(`/rounds/${f.round_id}/format-result`, { credentials: "include" }) + const data: ApiFormatResult | null = r.ok ? await r.json() : null + const teamScore = playFormat === "shamble" ? data?.shamble_team_score ?? null : data?.money_ball_team_score ?? null + const holes = data?.holes ?? [] + const par = holes.length > 0 ? holes.reduce((s, h) => s + h.par, 0) : null + return { round_id: f.round_id, name: f.name, isAnchor: f.is_anchor, teamScore, par, through: holes.length } + }), + ) + if (!cancelled) setStandings(results) + } + void load() + return () => { + cancelled = true + } + }, [roundId, playFormat, refreshKey]) + return standings } -function MatchStatusSection({ roundId, sides, refreshKey }: { roundId: string; sides: ApiSide[]; refreshKey: number }) { - const result = useFormatResult(roundId, refreshKey) - if (!result) return null - - if (!result.ready) { - return ( -
    - Venter på at begge sider er komplette og har beregnet handicap før matchstatus kan vises. -
    - ) - } - - const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, sides) : null - const lead = result.match_lead ?? 0 - const holesPlayed = result.match_holes_played ?? 0 - const holesRemaining = result.match_holes_remaining ?? 0 - const totalHoles = holesPlayed + holesRemaining - const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral" - const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50 - const widthB = 100 - widthA - const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted" - const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted" - - return ( -
    -
    - Matchstatus - {result.match_is_closed && Avgjort} - {result.match_is_dormie && !result.match_is_closed && Dormie} -
    -
    -
    -
    -
    - {statusText ? ( - <> - - {statusText} - - - {holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`} - - - ) : ( - Ingen hull spilt ennå - )} -
    -
    -
    -
    - {result.hole_results && result.hole_results.length > 0 && ( -
      - {result.hole_results.map((r, i) => ( -
    • - {r === "halved" ? "½" : r.toUpperCase()} -
    • - ))} -
    - )} - - Se hull for hull med brutto/netto → - -
    - ) -} - -// --- Full leaderboard-side ----------------------------------------------- - function useLeaderboardData(roundId: string) { const [round, setRound] = useState(null) const [leaderboard, setLeaderboard] = useState(null) const [viewerId, setViewerId] = useState(null) const [error, setError] = useState(null) - // Sanntid (samme "noe endret seg, hent på nytt"-mønster som - // round-stats.tsx/round-scorecard.tsx sin refreshKey). const [refreshKey, setRefreshKey] = useState(0) useEffect(() => { @@ -583,29 +1174,116 @@ function useLeaderboardData(roundId: string) { return { round, leaderboard, viewerParticipantId, error, refreshKey } } -const MODE_MISSING_LABEL: Record = { - gross: "", - net: "netto-tall", - points: "poengsum", +// =========================================================================== +// Adaptere: ekte API-data -> V0 sine props (ingen mock-data noe sted) +// =========================================================================== + +function buildHoleOrder(startHole: number, holesPlanned: number): number[] { + return Array.from({ length: holesPlanned }, (_, i) => ((startHole - 1 + i) % 18) + 1) } +function buildHoles(entries: ApiLeaderboardEntry[], holeOrder: number[]): Hole[] { + const anyEntryWithHoles = entries.find((e) => e.holes.length > 0) + if (!anyEntryWithHoles) return [] + return holeOrder + .map((n) => anyEntryWithHoles.holes.find((h) => h.hole_number === n)) + .filter((h): h is ApiLeaderboardHole => h !== undefined) + .map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index })) +} + +function buildIndividualPlayers( + entries: ApiLeaderboardEntry[], + participants: ApiRoundParticipant[], + holeOrder: number[], + viewerParticipantId: string | null, + ownPointsField: "bbb_points" | "copenhagen_points" | undefined, + formatResult: ApiFormatResult | null, +): IndividualPlayer[] { + const pointsByHoleNumber = new Map>() + if (ownPointsField && formatResult?.holes) { + for (const h of formatResult.holes) { + const m = new Map() + for (const e of h.entries) { + if (e.points !== null) m.set(e.id, e.points) + } + pointsByHoleNumber.set(h.hole_number, m) + } + } + const hcpByParticipant = new Map(participants.map((p) => [p.id, p.handicap_index_snapshot])) + return entries.map((e) => { + const gross = holeOrder.map((n) => { + const h = e.holes.find((x) => x.hole_number === n) + return h && h.played && h.score !== null ? h.score : null + }) + // Ekte per-hull mottatte slag -- allerede korrekt utregnet av backend + // (samme allokering `allocate_strokes_by_index` som resten av appen + // bruker), null når HCP ikke spores for deltakeren. IKKE en egen + // klientsidig course-hcp+stroke-indeks-utregning (V0 sin mock- + // versjon måtte gjøre det siden den ikke hadde et ekte backend). + const strokesReceived = holeOrder.map((n) => { + const h = e.holes.find((x) => x.hole_number === n) + return h?.strokes_received ?? null + }) + const holePoints = ownPointsField + ? holeOrder.map((n) => pointsByHoleNumber.get(n)?.get(e.participant_id) ?? null) + : undefined + return { + id: e.participant_id, + name: e.display_name, + isSelf: e.participant_id === viewerParticipantId, + isOwner: e.is_owner, + strokesReceived, + hcpIndex: hcpByParticipant.get(e.participant_id) ?? null, + finished: false, + gross, + holePoints, + } + }) +} + +function buildSideTeams(round: ApiRoundHeader, entries: ApiLeaderboardEntry[]): [SideTeamInfo, SideTeamInfo] { + const nameByParticipant = new Map(entries.map((e) => [e.participant_id, e.display_name])) + const sideIds = round.sides.map((s) => s.id) + const namesFor = (sideId: string) => + round.participants.filter((p) => p.round_side_id === sideId).map((p) => nameByParticipant.get(p.id) ?? "Medspiller") + return [ + { id: "a", name: round.sides[0]?.label?.trim() || "Side A", players: namesFor(sideIds[0]) }, + { id: "b", name: round.sides[1]?.label?.trim() || "Side B", players: namesFor(sideIds[1]) }, + ] +} + +function buildHoleWinners(holeResults: string[] | null, totalHoles: number): HoleWinner[] { + const out: HoleWinner[] = Array.from({ length: totalHoles }, () => null) + if (!holeResults) return out + holeResults.forEach((r, i) => { + if (i < totalHoles && (r === "a" || r === "b" || r === "halved")) out[i] = r + }) + return out +} + +const METRICS_FOR_FORMAT: Record = { + stroke: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" }, + stableford: { metrics: ["stableford", "netto", "brutto"], defaultMetric: "stableford", pointsUnit: "p" }, + skins: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" }, + flag: { metrics: ["brutto", "netto"], defaultMetric: "brutto", pointsUnit: "p" }, + bbb: { metrics: ["points", "brutto", "netto"], defaultMetric: "points", pointsUnit: "p" }, + copenhagen: { metrics: ["points", "brutto", "netto"], defaultMetric: "points", pointsUnit: "p" }, +} + +// =========================================================================== +// Full leaderboard-side +// =========================================================================== + export function RoundLeaderboard({ roundId, embedded = false, }: { roundId: string - // ADR-040 ("det store grepet") -- dropper denne komponentens egen - // sticky header når den rendres under den delte `RoundHeader` - // (via `RoundPageShell`) i stedet for standalone. embedded?: boolean }) { const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId) - const [mode, setMode] = useState("gross") - // Hullscorer vises som en utvidbar strip PER deltaker (2026-07-26, - // etterspurt av bruker) -- kollapset som default (samme "trekkspill - // starter lukket"-konvensjon som round-stats.tsx sine StatCard-seksjoner) - // slik at listen forblir kompakt og skalerer til mange spillere. - const [expandedId, setExpandedId] = useState(null) + const formatResult = useFormatResult(roundId, refreshKey) + const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey) if (error) { return ( @@ -626,8 +1304,12 @@ export function RoundLeaderboard({ ) } - const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId) + const holeOrder = buildHoleOrder(round.start_hole, round.holes_planned) + const holes = buildHoles(leaderboard.entries, holeOrder) + const isHighLow = HIGH_LOW_FORMATS.has(round.play_format) const isTwoSided = TWO_SIDED_FORMATS.includes(round.play_format) + const isTeamResult = TEAM_RESULT_FORMATS.has(round.play_format) + const ownPointsField = FORMAT_OWN_POINTS_FIELD[round.play_format] return (
    @@ -656,97 +1338,120 @@ export function RoundLeaderboard({
    - {/* To-sidede formater (match/fourball/foursome/greensome/scramble) - har ingen individuell rangering å vise -- kun en matchstatus - (2026-07-28, se MatchStatusSection over). Delt-ball-formatene - har i tillegg ALDRI individuelle round_hole-rader (ADR-039 - Beslutning C), så den vanlige listen under ville uansett vært - tom for dem. */} - {isTwoSided ? ( - - ) : ( - <> -
    -

    Stilling

    - + {isTwoSided || isHighLow ? ( + !formatResult ? null : !formatResult.ready ? ( +
    + Venter på at begge sider er komplette og har beregnet handicap før stillingen kan vises.
    - - {leaderboard.entries[0]?.holes && ( - - )} - - {/* Én sammenhengende liste (2026-07-26, etterspurt av bruker) -- - IKKE separate kort med mellomrom lenger. Kun ytterkanten er - avrundet/rammet inn; radene skilles med en tynn linje - (divide-y) og flyter sømløst i hverandre. En utvidet rad får en - svak bakgrunnstone som eneste "dette er åpnet"-signal. */} -
      - {ranked.map((entry) => { - const value = valueFor(entry, mode) - const isLeader = entry.rank === 1 - const expanded = expandedId === entry.participant_id + ) : isHighLow ? ( + (() => { + const [sideA, sideB] = buildSideTeams(round, leaderboard.entries) + const duels = (formatResult.holes ?? []).map(highLowDuelForHole) + return ( + + ) + })() + ) : ( + (() => { + const [sideA, sideB] = buildSideTeams(round, leaderboard.entries) + const results = buildHoleWinners(formatResult.hole_results, round.holes_planned) + const lead = formatResult.match_lead ?? 0 + const leader: "a" | "b" | null = lead > 0 ? "a" : lead < 0 ? "b" : null + const holesPlayed = formatResult.match_holes_played ?? 0 + const holesRemaining = formatResult.match_holes_remaining ?? 0 + const diff = Math.abs(lead) + // V0 sin egen, korte headline-tekst ("2 opp"/"Dormie 2 opp"), + // IKKE backend sin `match_status_text` (som bevisst bygger inn + // side-NAVNET, "2 UP (Lag Bjørk)" -- designet for et sted i + // appen UTEN separate side-navn ved siden av, ulikt her hvor + // SideName allerede viser navnet på hver side. Gjenbrukt + // ordlyd, men mot EKTE tall (match_lead/-holes_played/ + // -remaining/-is_dormie/-is_closed) -- ingen egen klientsidig + // utregning av selve stillingen. + const headline = + holesPlayed === 0 + ? "Ingen hull spilt ennå" + : formatResult.match_is_closed + ? `Ferdig ${diff}&${holesRemaining}` + : holesRemaining === 0 + ? leader + ? `Vant ${diff} opp` + : "Delt" + : !leader + ? "Alt likt" + : formatResult.match_is_dormie + ? `Dormie ${diff} opp` + : `${diff} opp` + const sub = holesPlayed > 0 ? `${holesPlayed} hull spilt${holesRemaining > 0 ? `, ${holesRemaining} igjen` : ""}` : "" + return ( + + ) + })() + ) + ) : isTeamResult ? ( + (() => { + const teamScore = round.play_format === "shamble" ? formatResult?.shamble_team_score ?? null : formatResult?.money_ball_team_score ?? null + const resultHoles = formatResult?.holes ?? [] + const par = resultHoles.length > 0 ? resultHoles.reduce((s, h) => s + h.par, 0) : null + const toPar = teamScore !== null && par !== null ? teamScore - par : null + const flight: FlightTeam[] = (standings ?? []) + .filter((s) => s.teamScore !== null && s.par !== null) + .map((s) => ({ + id: s.round_id, + name: s.name || round.course_name_snapshot, + toPar: (s.teamScore as number) - (s.par as number), + through: s.through, + isThisTeam: s.round_id === roundId, + })) + const members = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, undefined, null) return ( -
    • - - - {expanded && ( -
      - {/* Det som lå i selve raden før 2026-07-26 (Deg/Eier/ - leder-ikon/fremdrift) -- fortsatt tilgjengelig, bare - flyttet hit slik at den kollapsede listen forblir - ren "rangering, navn, tall". */} -
      - {entry.isSelf && Deg} - {entry.is_owner && Eier} - {isLeader &&
      - {entry.holes_played > 0 ? ( - - ) : ( -

      Ingen hull registrert ennå.

      - )} -
      - )} -
    • + ) - })} -
    - - {mode !== "gross" && ranked.some((e) => e.rank === null) && ( -

    - Spillere uten {MODE_MISSING_LABEL[mode]} (ingen HCP registrert for runden) vises uten rangering. -

    - )} - + })() + ) : ( + (() => { + const players = buildIndividualPlayers(leaderboard.entries, round.participants, holeOrder, viewerParticipantId, ownPointsField, formatResult) + const config = METRICS_FOR_FORMAT[round.play_format] ?? METRICS_FOR_FORMAT.stroke + return ( + <> +

    Stilling

    + + + ) + })() )}
    @@ -762,12 +1467,20 @@ export function RoundLeaderboardMini({ roundId }: { roundId: string }) { if (!round || !leaderboard || round.participants.length < 2) return null - // To-sidede formater (match/fourball/foursome/greensome/scramble) vises - // allerede av FormatResultPanel rett under denne komponenten sitt - // kallsted i round-detail.tsx -- ingen individuell brutto-rangering å - // vise her (og for delt-ball-formatene ville leaderboard.entries uansett - // alltid vært tom, ADR-039 Beslutning C). - if (TWO_SIDED_FORMATS.includes(round.play_format)) return null + // To-sidede formater (match/fourball/foursome/greensome/scramble/ + // high_low_high) vises allerede av FormatResultPanel rett under denne + // komponenten sitt kallsted i round-detail.tsx -- ingen individuell + // brutto-rangering å vise her. Shamble/Money Ball har heller ingen + // meningsfull individuell topp-3 -- lagets EGEN hullscore-sum er det + // som teller, vist i TeamFlightBoard på selve leaderboard-siden i + // stedet for en misvisende råscore-rangering her. + if ( + TWO_SIDED_FORMATS.includes(round.play_format) || + HIGH_LOW_FORMATS.has(round.play_format) || + TEAM_RESULT_FORMATS.has(round.play_format) + ) { + return null + } const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId) const top = ranked.slice(0, 3) diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx index a654108..77d551e 100644 --- a/frontend/components/round-scorecard.tsx +++ b/frontend/components/round-scorecard.tsx @@ -30,7 +30,7 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { ArrowLeft } from "lucide-react" +import { ArrowLeft, ArrowUp, ArrowDown, ArrowRight, ArrowUpLeft, ArrowUpRight, Check, Target } from "lucide-react" import { cn } from "@/lib/utils" // --- API-typer --------------------------------------------------------------- @@ -71,6 +71,18 @@ type ApiHole = { score: number | null picked_up: boolean strokes_received: number | null + // Detaljfelt (2026-08-03) -- allerede returnert av samme + // RoundHoleOut/`/rounds/.../holes`-endepunkt denne siden lenge har brukt, + // bare ikke tidligere lest her. Presentert som egne, forkortede rader i + // ScoreBlock i stedet for kun å ligge tilgjengelig i round-detail.tsx sin + // "Så langt i runden"-seksjon. + putts: number | null + tee_shot_result: "fairway" | "left" | "right" | null + approach_result: "hit" | "short" | "long" | "left" | "right" | null + chip_count: number | null + bunker_shot_count: number | null + penalty_strokes: number | null + anyway_strokes: number | null } const SHARED_BALL_FORMATS = ["foursome", "greensome", "scramble_2", "scramble_4", "chapman"] @@ -115,6 +127,13 @@ function stablefordPoints(h: ApiHole): number | null { return Math.max(0, h.par - net + 2) } +// Samme GIR-formel som round-stats.tsx sin isGir/round-detail.tsx sin +// GIR-auto-inferens: slag minus putt garanterer green i regulering når +// resultatet er par-2 eller bedre. Krever kjent slag+putt-tall. +function isGir(h: ApiHole): boolean { + return h.score !== null && h.putts !== null && h.score - h.putts <= h.par - 2 +} + type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" function classify(gross: number, par: number): ScoreClass { @@ -193,14 +212,53 @@ function ScoreMark({ gross, par, pickedUp }: { gross: number | null; par: number // --- One nine-hole block (a real table for row/column semantics) ----------- +// Kompakte retnings-/treff-ikoner for de nye detaljradene (2026-08-03) -- +// samme ikonvalg som DirectionCross (round-detail.tsx sin registrerings- +// veiviser) allerede bruker, kun skalert ned for en tett tabellcelle. +// Bøyde piler for Fairway (utslaget DRIVER av til en side, ulikt et +// innspill som bommer rett til siden -- derfor ArrowUpLeft/-Right her, +// ikke de rette ArrowLeft/-Right Innspill-raden bruker). +function FairwayIcon({ result }: { result: ApiHole["tee_shot_result"] }) { + if (result === null) return null + if (result === "fairway") return