diff --git a/.claude/settings.json b/.claude/settings.json index 066c2c9..5192318 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -49,7 +49,39 @@ "Bash(grep -n \"participants/\\\\${\" /opt/teecup/frontend/components/round-detail.tsx)", "Bash(python3 -)", "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)", - "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")" + "Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")", + "Bash(tee /tmp/ui_round_setup.json)", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/ui_round_setup.json'\\)\\)['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/ui_round_setup.json'\\)\\)['session_cookie']\\)\")", + "Bash(docker cp *)", + "Bash(mkdir -p /opt/teecup/frontend/app/my-rounds/\\\\[id\\\\]/leaderboard)", + "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_leaderboard_rank_logic.mjs)", + "Bash(tee /tmp/lb_setup.json)", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup.json'\\)\\)['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup.json'\\)\\)['session_cookie']\\)\")", + "Bash(grep -n '^---$' /opt/teecup/FEATURE_BACKLOG.md)", + "Bash(awk -F: '$1 > 1225 && $1 < 1260')", + "Bash(tee /tmp/lb_setup2.json)", + "Bash(python3 -c ' *)", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup2.json'\\)\\)['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup2.json'\\)\\)['session_cookie']\\)\")", + "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_leaderboard_points_mode.mjs)", + "Bash(tee /tmp/lb_setup3.json)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(d['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup3.json'\\)\\)['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup3.json'\\)\\)['session_cookie']\\)\")", + "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hole_secondary.mjs)", + "Bash(tee /tmp/lb_setup4.json)", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup4.json'\\)\\)['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/lb_setup4.json'\\)\\)['session_cookie']\\)\")", + "Bash(ffprobe -v error -show_entries format=duration,size -show_entries stream=width,height,codec_name -of default=noprint_wrappers=0 /opt/teecup/screen-20260726-152057-1785072037213.mp4)", + "Bash(node /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_scoring_wizard_logic.mjs)", + "Bash(tee /tmp/wizard_setup.json)", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['round_id']\\)\")", + "Bash(python3 -c \"import json; print\\(json.load\\(open\\('/tmp/wizard_setup.json'\\)\\)['session_cookie']\\)\")" + ], + "additionalDirectories": [ + "/opt/teecup/frontend/app/my-rounds/[id]" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 8079036..da6f866 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3205,19 +3205,224 @@ Ferdig og verifisert: `test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d --build teecup_api teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket. +- **Spillerliste-redesign HÅNDKODET (2026-07-26), IKKE ENNÅ RULLET UT:** + brukeren gikk tom for V0-credits rett etter at prompten over ble sendt. + Spurt eksplisitt (AskUserQuestion): bygg direkte, eller vent på fornyede + credits — svar: bygg direkte. Bygget nøyaktig etter samme prompt/data- + kontrakt som allerede var skrevet (se FEATURE_BACKLOG.md for full + detalj), i samme Tailwind/shadcn-stil som resten av `round-detail.tsx`. + Ny `PlayerList` (erstatter `PlayerTabs`) — vertikal liste av spillerkort, + hvert kort en stor "velg som aktiv spiller"-knapp + separate Rediger-/ + fjern-knapper (unngår nestede interaktive elementer), "Deg"/"Eier" som + `Badge`-komponenter. `EditParticipantPanel` utvidet med statistikknivå + (den frittstående `StatLevelPicker` er nå død kode, fjernet) og — kun + for gjester — Navn/Kjønn/E-post, med reaktivt utslagsfilter ved + kjønnsendring. "+ Medspiller"-flyten flyttet inn i selve `PlayerList`. + **Verifisert:** ekte typesjekket produksjonsbuild kompilerte rent, pluss + en engangs `next dev`-container mot scratch-backend (ekte runde med en + gjest med avvikende utslag/kjønn/e-post) — siden ga 200, ingen Next.js- + feilside. **Ærlig begrensning:** siden er en klient-komponent (data + hentes etter hydrering), så server-HTML-en viser kun last-skjelettet — + INGEN ekte nettleser-interaksjonstest utført (intet nettleserverktøy + tilgjengelig). + **Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt: + ingen ny migrasjon, `docker compose up -d --build teecup_api + teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` + → 200, `teeoff.no` upåvirket. **Bruker bør selv klikke gjennom flyten** + (spesielt gjeste-kjønnsendringens reaktive utslagsfilter og "velg vs. + rediger"-trykkflatene) før full tillit. +- **Tildelte slag + jevn korthøyde + rundeleaderboard, HÅNDKODET, IKKE ENNÅ + RULLET UT (2026-07-26):** to oppfølgingspunkter samme dag, full detalj i + FEATURE_BACKLOG.md. (1) Spillerkortet manglet "tildelte slag" (course + handicap for runden) og hadde variabel høyde — rettet: `Player` fikk + `courseHandicap`, kortet fikk fast `min-h-[68px]` med begge tekstlinjer + trunkert til én linje (ikke wrap), alle kort like høye uansett innhold. + (2) Brukeren spurte om jeg "med designerbrillene på" trodde jeg kunne få + rundeleaderboardet til å se like profesjonelt ut som V0 — svarte ja + (gjenbruk av etablerte mønstre, ikke fri utforskning) og bygget det: ny + `components/round-leaderboard.tsx` + rute `/my-rounds/[id]/leaderboard`, + gjenbruker `ScoreMark`s "form + farge"-språk fra `round-scorecard.tsx` + (som `ToParMark`), samme WS-sanntid-mønster som `round-stats.tsx`, delt + plassering ("T-N"), brutto/netto-veksling, mini-variant på rundens egen + side. Rangeringslogikken VERIFISERT UAVHENGIG i et frittstående + Node-script (19/19, inkl. tie-håndtering og uspilte spillere), pluss en + fersk scratch-runde med ekte API-kall som bekreftet leaderboard-JSON-en + stemte. Ekte typesjekket produksjonsbuild kompilerte rent. Samme ærlige + begrensning som over: ingen ekte nettleser-interaksjonstest. + **Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt: + ingen migrasjon, `docker compose up -d --build teecup_api + teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` + → 200, `teeoff.no` upåvirket. +- **Hullscorer i leaderboardet + lenke fra rundelisten, BYGGET, IKKE ENNÅ + RULLET UT (2026-07-26):** brukeren presiserte rett etter forrige rulling + tre krav: leaderboardet skal ha en egen visning (allerede tilfellet, se + over), med lenke fra scoreføringssiden (allerede der, `RoundLeaderboardMini`) + OG fra rundelisten (`/my-rounds`, manglet), og skal vise hullscorer + (manglet helt -- kun aggregerte tall fantes). + **Backend:** `LeaderboardEntryOut` (`GET /rounds/{id}/leaderboard`) fikk + et nytt `holes: list[LeaderboardHoleOut]`-felt (hole_number/par/ + stroke_index/played/score) -- data var ALLEREDE hentet per deltaker for + å beregne total_score/net_score_to_par, bare aldri eksponert rått før nå. + Ren tilføyelse, ingen migrasjon. + **Frontend:** `round-leaderboard.tsx` sine rader er nå klikkbare + (utvider/kollapser, default kollapset -- samme "trekkspill starter + lukket"-konvensjon som `round-stats.tsx`) og viser en horisontal strip + med per-hull-merker (`HoleMark`/`HoleStrip`, egen lokal variant av + `ScoreMark`s "form + farge"-språk fra `round-scorecard.tsx`, tilpasset en + kompakt flerspiller-liste i stedet for én tabell). `round-card.tsx` + (brukt av BÅDE `/my-rounds`-listen og dashbordets "Kommende runder") fikk + en ny "Se leaderboard"-lenke for runder med mer enn én deltaker -- + krevde å gjøre om kortets ytre element fra selve lenken til en `
` + med lenken som ETT av to barn (unngår nestet ``, samme feilklasse som + tidligere nestet-form-/knapp-feller i prosjektet). + **Verifisert:** backend-endringen bekreftet mot en fersk scratch-runde + (ekte API-kall, `holes`-arrayet inneholder riktige 18 rader per deltaker + inkl. korrekt `played`/`score` for både spilte og uspilte hull), full + regresjon av forrige rundes 35-punkts co-player-testsuite fortsatt grønn, + `test_isolation.sql` 12/12 (uendret, ingen migrasjon). Ekte typesjekket + produksjonsbuild kompilerte rent (ingen nye ruter, kun endrede + komponenter). Samme engangs `next dev`-container-sjekk som tidligere -- + `/my-rounds`, `/my-rounds/[id]` og `/my-rounds/[id]/leaderboard` ga alle + 200, ingen Next.js-feilside. + **Samme ærlige begrensning som tidligere håndkodede runder:** ingen ekte + nettleser-interaksjonstest (klikk for å utvide en rad, se hull-stripen, + se "Se leaderboard"-lenken i rundelisten) er utført. + **Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt: + ingen migrasjon, `docker compose up -d --build teecup_api + teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard` + → 200, `teeoff.no` upåvirket. +- **Leaderboard-oppfølging: poeng-modus + minimalistiske rader, BYGGET, IKKE + ENNÅ RULLET UT (2026-07-26):** brukeren påpekte at brutto/netto ble + presentert forskjellig (bryteren byttet BÅDE hvilket tall som vises OG + hele rangeringsrekkefølgen -- forvirrende hopp), og spurte om et eget + stableford-alternativ burde vært med. Foreslo og fikk bekreftet: gi alle + tre modiene (nå: Brutto/Netto/**Poeng**) IDENTISK radform (rangering, + navn, ETT tall, ferdig) i stedet for å prøve å vise flere tall samtidig. + Samtidig ba brukeren om å fjerne alt fra den kollapsede raden utover + navn+tall (Deg/Eier/pokal/hull-spilt-tekst) og fjerne selve + "kort"-følelsen -- radene skal flyte sømløst sammen, kun ekspandere ved + klikk. + **Backend:** `LeaderboardHoleOut` fikk `strokes_received` (samme + allerede-beregnede allokering som `net_score_to_par`, nå eksponert per + hull også). `LeaderboardEntryOut` fikk `total_points` (stableford-total, + samme formel/betingelse som `net_score_to_par` -- null uten HCP). Ingen + migrasjon. + **Frontend (`round-leaderboard.tsx`):** `Mode` utvidet til tre verdier, + poeng rangeres SYNKENDE (motsatt av til-par). Ny `PointsMark`/`ValueMark` + (poeng har ingen retning, kun fylt/uthevet for lederen). Listen er nå ÉN + sammenhengende `
- )} - - {showEditParticipant && isOwnerViewer && !readOnly && ( - setShowEditParticipant(false)} - /> - )} - {/* Skjules når runden er fullført (2026-07-25, rapportert av bruker som "veldig mye dobbel informasjon") -- CompletedBanner sin "Se full rundestatistikk"-lenke dekker nå akkurat det samme, @@ -774,14 +787,6 @@ export function RoundDetail({ roundId }: { roundId: string }) { mens runden pågår. */} {!completed && } - {showAddGuest && !readOnly && isOwnerViewer && ( - setShowAddGuest(false)} - /> - )} - {/* Hole navigation */} {holes.length === 0 ? (
@@ -844,6 +849,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]} expandLabel="10+" parValue={hole.par} + showGolfTerms onChange={(v) => updateStat({ strokes: v, played: true })} readOnly={readOnly} /> @@ -982,15 +988,34 @@ export function RoundDetail({ roundId }: { roundId: string }) { )}
-
- - +
+
+ + {/* Samlebånd-knapp (2026-07-26): går til NESTE SPILLER på + samme hull, eller -- er aktiv spiller den siste -- + videre til neste hull og starter på spiller 1 igjen. + Erstatter den manuelle "skroll opp, velg neste + spiller"-runden man tidligere måtte gjøre for hånd. */} + +
+ {!readOnly && !canAdvance && ( +

+ {activePlayer?.statLevel === "strokes_only" + ? "Registrer slag for å gå videre." + : "Registrer slag og putter for å gå videre."} +

+ )}
)} @@ -1339,7 +1364,14 @@ function DistributionBar({ segments }: { segments: { label: string; count: numbe // --- Player tabs ----------------------------------------------------------- -function PlayerTabs({ +// --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) --- +// Vertikal liste av spillerkort -- hvert kort er BÅDE "velg meg som aktiv +// spiller" (hovedtrykkflaten) og bærer et eget "Rediger"-panel (utslag/HCP/ +// statistikk, pluss navn/kjønn/e-post for en gjest) som en utvidbar +// underseksjon. Skalerer til mange spillere uten fane-scroll. + +function PlayerList({ + roundId, players, activePlayerId, onSelect, @@ -1348,7 +1380,13 @@ function PlayerTabs({ addOpen, readOnly, canManage, + editingPlayerId, + onToggleEdit, + onPatchParticipant, + onAddGuest, + onAddSearched, }: { + roundId: string players: Player[] activePlayerId: string onSelect: (id: string) => void @@ -1356,58 +1394,131 @@ function PlayerTabs({ onAdd: () => void addOpen: boolean readOnly: boolean - // Kun runde-eieren kan legge til/fjerne medspillere (ADR-036 fase - // 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt BYTTE - // mellom faner for å registrere score for hele flighten. + // Kun runde-eieren kan legge til/fjerne/redigere medspillere (ADR-036 + // fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt + // VELGE kort for å registrere score for hele flighten. canManage: boolean + editingPlayerId: string | null + onToggleEdit: (id: string) => void + onPatchParticipant: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> + onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void + onAddSearched: (userId: string, statLevel: StatLevel) => Promise }) { return ( -
-
- {players.map((player) => { - const active = player.id === activePlayerId - const canRemove = !player.isSelf && !readOnly && canManage - return ( -
- - {canRemove && ( - - )} + {player.isOwner && ( + + Eier + + )} + + + Utslag: {player.teeName} + {" · "} + HCP:{" "} + + {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"} + + {/* Tildelte slag (course handicap) -- KUN vist når HCP + faktisk spores for spilleren (samme betingelse som + "spilles med hcp" -- er den null, spores ikke HCP her). */} + {player.courseHandicap !== null && ( + <> + {" · "} + Tildelte slag: {player.courseHandicap} + + )} + + +
+ {canEdit && ( + + )} + {canRemove && ( + + )} +
- ) - })} - {!readOnly && canManage && ( + + {editing && canEdit && ( + onToggleEdit(player.id)} + /> + )} +
+ ) + })} + + {!readOnly && canManage && ( + addOpen ? ( + + ) : ( - )} -
+ ) + )}
) } @@ -1844,9 +1955,19 @@ function EditParticipantPanel({ onPatch: (participantId: string, body: Record) => Promise<{ ok: true } | { ok: false; message: string }> onClose: () => void }) { + // En "midlertidig spiller" (gjest uten TeeCup-konto) har ikke egen profil + // -- navn/kjønn/e-post kan derfor redigeres her. En lenket bruker har sin + // egen konto, og disse feltene gir ingen mening (backend avviser dem også + // eksplisitt, 2026-07-26). + const isGuest = player.userId === null + const [teeOptions, setTeeOptions] = useState(null) const [teeName, setTeeName] = useState(player.teeName) const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "") + const [statLevel, setStatLevel] = useState(player.statLevel) + const [guestName, setGuestName] = useState(player.rawName) + const [gender, setGender] = useState(player.gender) + const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "") const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -1865,7 +1986,10 @@ function EditParticipantPanel({ } }, [roundId]) - const apiGender = uiGenderToApi(player.gender) + // Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da + // umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket + // bruker er `gender` uendret gjennom hele panelets levetid. + const apiGender = uiGenderToApi(gender) const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender)) async function save() { @@ -1876,6 +2000,15 @@ function EditParticipantPanel({ const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp + if (statLevel !== player.statLevel) body.stat_level = statLevel + if (isGuest) { + const trimmedName = guestName.trim() + if (trimmedName && trimmedName !== player.rawName) body.guest_name = trimmedName + if (gender !== player.gender) body.gender = apiGender + const trimmedEmail = guestEmail.trim() + const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail + if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail + } if (Object.keys(body).length === 0) { setSaving(false) onClose() @@ -1891,26 +2024,63 @@ function EditParticipantPanel({ } return ( -
-
- Rediger {player.name} for denne runden - -
- -

- Gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil. -

- +
{error &&

{error}

} + {isGuest && ( + <> +
+ + setGuestName(e.target.value)} + className="h-11 rounded-xl text-base" + /> +
+ + setGender(v as Gender)} + readOnly={false} + /> + +
+ + setGuestEmail(e.target.value)} + placeholder="navn@eksempel.no" + className="h-11 rounded-xl text-base" + /> +
+ +
+ + )} + +

+ Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil. +

+
-
-
- + setStatLevel(v as StatLevel)} + readOnly={false} + /> + +
+ + +
) } @@ -2260,39 +2447,23 @@ function HoleNav({ // slagtall velges (se onChange på Slag-NumberPicker under). Fjernet // 2026-07-24 på brukerens eksplisitte bekreftelse. -function StatLevelPicker({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) { - const options: { value: StatLevel; label: string }[] = [ - { value: "strokes_only", label: "Kun slag" }, - { value: "strokes_and_putts", label: "Slag og putter" }, - { value: "full", label: "All statistikk" }, - ] - return ( -
- Statistikk -
- {options.map((opt) => ( - - ))} -
-
- ) -} - // --- Number picker --------------------------------------------------------- +// Golf-term per slagtall relativt til par (2026-07-26, inspirert av en +// konkurrentapps taltastatur der HVER knapp er merket kontekstuelt -- +// fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller +// dobbel bogey?" i hodet). Kun brukt for Slag-velgeren. +function golfTermForScore(n: number, par: number): string | null { + const diff = n - par + if (diff <= -3) return "Albatross" + if (diff === -2) return "Eagle" + if (diff === -1) return "Birdie" + if (diff === 0) return "Par" + if (diff === 1) return "Bogey" + if (diff === 2) return "Dobbel bogey" + return null +} + function NumberPicker({ label, value, @@ -2300,6 +2471,7 @@ function NumberPicker({ expandValues, expandLabel, parValue, + showGolfTerms, maxValue, onChange, readOnly, @@ -2309,10 +2481,12 @@ function NumberPicker({ directValues: number[] expandValues: number[] expandLabel: string - // Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst - // (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten - // å måtte huske det fra hull-overskriften. + // Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt. parValue?: number + // Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til + // parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt + // for Slag-velgeren, ikke Putter/Anywayslag. + showGolfTerms?: boolean // Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige // antall slag registrert på hullet) -- ingen vits i å tilby et tall som // uansett ville vært selvmotsigende. @@ -2338,14 +2512,14 @@ function NumberPicker({
{visibleValues.map((n) => { const selected = value === n - const isPar = n === parValue + const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null return ( diff --git a/frontend/components/round-leaderboard.tsx b/frontend/components/round-leaderboard.tsx new file mode 100644 index 0000000..649e36c --- /dev/null +++ b/frontend/components/round-leaderboard.tsx @@ -0,0 +1,560 @@ +"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. +// +// 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. + +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 { cn } from "@/lib/utils" + +// --- API-typer --------------------------------------------------------------- + +type ApiLeaderboardHole = { + hole_number: number + par: number + stroke_index: number + played: boolean + score: number | null + strokes_received: number | null +} + +type ApiLeaderboardEntry = { + participant_id: string + display_name: string + is_owner: boolean + holes_played: number + total_score: number | null + score_to_par: number | null + net_score_to_par: number | null + total_points: number | null + holes: ApiLeaderboardHole[] +} + +type ApiLeaderboard = { + holes_planned: number + completed: boolean + entries: ApiLeaderboardEntry[] +} + +type ApiRoundParticipant = { + id: string + user_id: string | null +} + +type ApiRoundHeader = { + id: string + name: string | null + course_name_snapshot: string + played_at: string + start_hole: number + holes_planned: number + participants: ApiRoundParticipant[] +} + +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). + +type Mode = "gross" | "net" | "points" + +type RankedEntry = ApiLeaderboardEntry & { + rank: number | null + tied: boolean + isSelf: boolean +} + +function valueFor(entry: ApiLeaderboardEntry, mode: Mode): number | null { + if (mode === "gross") return entry.score_to_par + if (mode === "net") return entry.net_score_to_par + return entry.total_points +} + +function rankEntries(entries: ApiLeaderboardEntry[], mode: Mode, viewerParticipantId: string | null): RankedEntry[] { + const withValue = entries.filter((e) => valueFor(e, mode) !== null) + const withoutValue = entries.filter((e) => valueFor(e, mode) === null) + const direction = mode === "points" ? -1 : 1 + withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number))) + + const ranked: RankedEntry[] = [] + let rank = 0 + let prevValue: number | null = null + for (let i = 0; i < withValue.length; i++) { + const e = withValue[i] + const v = valueFor(e, mode) as number + if (v !== prevValue) { + rank = i + 1 + prevValue = v + } + const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1 + ranked.push({ ...e, rank, tied, isSelf: e.participant_id === viewerParticipantId }) + } + for (const e of withoutValue) { + ranked.push({ ...e, rank: null, tied: false, isSelf: e.participant_id === viewerParticipantId }) + } + return ranked +} + +function formatToPar(value: number): string { + if (value === 0) return "E" + 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. + +function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) { + if (value === 0) { + return E + } + const under = value < 0 + return ( + + {formatToPar(value)} + + ) +} + +function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) { + return ( + + {value} + + ) +} + +function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) { + return mode === "points" ? : +} + +function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) { + return ( + + ) +} + +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" }, + ] + return ( +
+ {options.map((opt) => ( + + ))} +
+ ) +} + +// --- 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" +} + +// 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. + +function netForHole(hole: ApiLeaderboardHole): number | null { + if (!hole.played || hole.score === null || hole.strokes_received === null) return null + return hole.score - hole.strokes_received +} + +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" + const markClass = + !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.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 : "–"} + + )} + + ) +} + +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} + + )} +
+
+ {ordered.map((h) => ( + + ))} +
+
+
+ ) +} + +// --- 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(() => { + let cancelled = false + fetch("/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { id: string } | null) => { + if (!cancelled && data) setViewerId(data.id) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`) + socket.onmessage = () => setRefreshKey((k) => k + 1) + return () => socket.close() + }, [roundId]) + + useEffect(() => { + let cancelled = false + async function load() { + const [roundRes, leaderboardRes] = await Promise.all([ + fetch(`/rounds/${roundId}`, { credentials: "include" }), + fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }), + ]) + if (roundRes.status === 403 || roundRes.status === 404) { + if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.") + return + } + if (!roundRes.ok || !leaderboardRes.ok) { + if (!cancelled) setError("Klarte ikke å hente leaderboardet. Prøv igjen om litt.") + return + } + const roundData: ApiRoundHeader = await roundRes.json() + const leaderboardData: ApiLeaderboard = await leaderboardRes.json() + if (!cancelled) { + setRound(roundData) + setLeaderboard(leaderboardData) + } + } + void load() + return () => { + cancelled = true + } + }, [roundId, refreshKey]) + + const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null + + return { round, leaderboard, viewerParticipantId, error } +} + +const MODE_MISSING_LABEL: Record = { + gross: "", + net: "netto-tall", + points: "poengsum", +} + +export function RoundLeaderboard({ roundId }: { roundId: string }) { + const { round, leaderboard, viewerParticipantId, error } = 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) + + if (error) { + return ( +
+

{error}

+ + Tilbake til egne runder + +
+ ) + } + + if (!round || !leaderboard) { + return ( +
+ + ) + } + + const ranked = rankEntries(leaderboard.entries, mode, viewerParticipantId) + + return ( +
+
+
+ +
+
+ +
+
+ + {round.name?.trim() || round.course_name_snapshot} + + + {round.name?.trim() ? `${round.course_name_snapshot} · ` : ""} + {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))} + +
+ +
+

Stilling

+ +
+ + {/* É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 + 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. +

+ )} +
+
+ ) +} + +// --- Kompakt mini-variant, til rundens egen detaljside -------------------- +// Topp 3 (brutto) + "og N til", lenke til hele leaderboardet. Kun relevant +// for runder med mer enn én deltaker. + +export function RoundLeaderboardMini({ roundId }: { roundId: string }) { + const { round, leaderboard, viewerParticipantId } = useLeaderboardData(roundId) + + if (!round || !leaderboard || round.participants.length < 2) return null + + const ranked = rankEntries(leaderboard.entries, "gross", viewerParticipantId) + const top = ranked.slice(0, 3) + const restCount = ranked.length - top.length + + return ( + + + + + + + {top.map((entry) => ( + + + {entry.rank !== null ? (entry.tied ? `T-${entry.rank}` : `#${entry.rank}`) : "–"} + + {entry.display_name} + + {entry.score_to_par !== null ? formatToPar(entry.score_to_par) : "–"} + + + ))} + + {restCount > 0 && og {restCount} til} + + ) +} diff --git a/screen-20260726-152057-1785072037213.mp4 b/screen-20260726-152057-1785072037213.mp4 new file mode 100644 index 0000000..ee9b773 Binary files /dev/null and b/screen-20260726-152057-1785072037213.mp4 differ