Hull-for-hull-utvidelse i den autentiserte leaderboard-fanen (ADR-101)

Klikk på en spiller i slagspill/netto/Stableford-leaderboardet
ekspanderer nå raden til Ut/Inn-tabeller med hull-for-hull-score, portert
fra det tilsvarende mønsteret på den offentlige "Følg live"-siden. Nytt
lite, isolert backend-endepunkt (tournament_participant_id-nøkkede
hull), ingen migrasjon. StrokePlayLeaderboard sin LeaderboardRow-type
er uendret -- nytt valgfritt prop-sett på komponenten selv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-22 05:26:46 +02:00
parent 4a415ac377
commit dcc2a9275c
6 changed files with 506 additions and 41 deletions

View file

@ -10149,6 +10149,85 @@ mot ekte containere ennå.
--- ---
## ADR-101: Hull-for-hull-utvidelse i den autentiserte leaderboard-fanen (2026-08-22)
**Kontekst:** bruker, midt i forrige runde: "Leaderboard: Det må være
mulig å klikke på en enkeltspiller og få ekspandert raden, slik at man
ser scoren på hvert enkelt hull vedkommende har spilt (og summen av
runden(e) og første og siste ni)." `LeaderboardTab` hadde utvidelse
KUN for eclectic -- slagspill/netto/Stableford (`isStrokePlay`, vist
via `StrokePlayLeaderboard`, den klart vanligste gruppen) hadde INGEN
utvidelse. Mønsteret fantes allerede, ferdig bygget og verifisert, for
den OFFENTLIGE siden (`public-individual-live.tsx` sin `LeaderboardRow`
+ `HoleNine`, bygget samme uke) -- en reell asymmetri: tilskuersiden
hadde en funksjon den autentiserte org-siden manglet. Denne runden
PORTERER mønsteret (ingen ny V0-runde -- gjenbruker et allerede
designet/godkjent visuelt mønster), tilpasset den autentiserte
flerrunde-visningen.
**Beslutning A -- omfang: slagspill/netto/Stableford, ikke Copenhagen/
BBB/eclectic.** Eclectic har allerede sitt eget hull-for-hull-konsept
(beste-per-hull). Copenhagen (poeng) og BBB (observasjon om HELE
feltet) passer ikke inn i "hvert hull + sum + ut/inn"-rammen uten en
egen vurdering -- bevisst utenfor omfanget her.
**Beslutning B -- `stroke-play-leaderboard.tsx` sin `LeaderboardRow`-
TYPE ("eksakt kontrakt -- ikke endre") er UENDRET.** Nytt, valgfritt
prop-sett på selve `StrokePlayLeaderboard`-KOMPONENTEN i stedet
(`expandedId`/`onToggleExpand`/`renderExpanded`) -- forelderen
(`LeaderboardTab`) eier state og henting, komponenten er ren
presentasjon, samme "eier-logikk-i-forelder"-prinsipp som
`TournamentScoringWizard`/`ScoreTab` (ADR-100). Ingen effekt når
propsettet ikke sendes.
**Beslutning C -- kun FELT-rader (ikke ledere) er utvidbare.**
`StrokePlayLeaderboard` er en autoscrollende "kiosk-ticker"
(sticky-posisjonerte ledere, `leaderTops` beregnet via ResizeObserver)
-- en utvidet LEDER-rad ville i teorien håndteres riktig av den
eksisterende ResizeObserver-kaskaden, men risikoen for et sjeldent
geometri-avvik ble vurdert unødvendig når lederen(e) uansett allerede
er fremhevet og synlig øverst uten å måtte utvides. Rene felt-rader
har ingen slik sticky-avhengighet.
**Beslutning D -- nytt lite backend-endepunkt, IKKE gjenbruk av det
eksisterende round_participant_id-nøkkede.** `RoundCellOut` (per-runde-
cellen i leaderboard-responsen) += `tournament_round_id` (rent
tillegg, populert fra en spørring som allerede har `tr` i scope). Nytt
endepunkt `GET .../individual-leaderboard/{tournament_participant_id}
/rounds/{round_id}/holes` -- speiler `registration.py` sitt offentlige
`tournament_participant_id`-nøkkede endepunkt nøyaktig (samme
oppløsning til `tournament_round_participant.id` internt), men under
et EGET stiprefiks: samme stiform som den eksisterende
`.../participants/{round_participant_id}/holes` ville kollidert
(FastAPI kan ikke skille to param med ulik betydning på samme
posisjon). Leaderboardet viser ALLE spilte runder stablet ved
utvidelse (ikke ett hull-sett per valgt runde som den offentlige
siden) -- `StrokePlayLeaderboard` viser allerede én kolonne per runde,
ingen ekstra rundevelger trengs.
**Beslutning E -- netto-par-bevisst golfscore-form, ikke offentlig
sidens enklere brutto-only variant.** Nye `LeaderboardHoleNine`/
`LeaderboardRoundHoles` bruker filens EGNE `classify`/
`scoreMarkClasses` (samme `strokes_received`-bevisste logikk som
`HoleGrid`), ikke `public-individual-live.tsx` sin enklere `toneOf` --
konsistent med resten av den autentiserte scoreførings-/scorekort-
visningen.
**Status:** 165/165 pytest (164 + én ny dedikert test for det nye
endepunktet: riktig hull-data, 404 for ukjent runde). `tsc --noEmit`
rent. Full-stack scratch-verifisering (egen scratch-db, egen API-
container, egen MinIO, ekte innlogging, ingen fetch-mocking) med en
flerrunde-turnering (2 runder, én spiller begge, én kun runde 1)
bekreftet: utvidelse viser riktig Ut/Inn-seksjon (kun én, ingen
"Runde N"-overskrift siden spilleren bare spilte én runde), korrekt
Par-/Slag-sum per ni (36/63 verifisert mot faktisk seed-data), korrekt
golfscore-form (fylt oransje ved 3 over par, matcher "2+ fra
par"-regelen), chevron-rotasjon, riktig i mørk OG lys modus, smal
mobilskjerm. Alle scratch-ressurser ryddet opp. IKKE bygget/rullet ut
mot ekte containere ennå.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14343,3 +14343,36 @@ Neste steg:
backend-endring). `docker compose build teecup_api teecup_frontend backend-endring). `docker compose build teecup_api teecup_frontend
&& up -d`, begge containere friske, rene logger. && up -d`, begge containere friske, rene logger.
`https://teecup.golf/health` 200 etter utrulling. `https://teecup.golf/health` 200 etter utrulling.
158. **Hull-for-hull-utvidelse i den autentiserte leaderboard-fanen
(ADR-101), 2026-08-22.** Bruker, mens forrige runde pågikk: klikk
på en spiller skal ekspandere raden og vise scoren på hvert hull,
pluss sum per runde og for ut/inn. `LeaderboardTab` hadde utvidelse
kun for eclectic -- slagspill/netto/Stableford (StrokePlayLeaderboard,
den vanligste gruppen) hadde ingen. Mønsteret fantes allerede,
ferdig bygget, for den OFFENTLIGE siden (`public-individual-
live.tsx` sin LeaderboardRow/HoleNine) -- portert derfra (ingen ny
V0-runde), ikke bygget fra bunnen.
**Backend, to små tillegg, ingen migrasjon:** `RoundCellOut` +=
`tournament_round_id`. Nytt endepunkt
`.../individual-leaderboard/{tournament_participant_id}/rounds/
{round_id}/holes`, speiler registration.py sitt offentlige
endepunkt (eget stiprefiks -- unngår kollisjon med det eksisterende
round_participant_id-nøkkede).
**Frontend:** `StrokePlayLeaderboard` sin `LeaderboardRow`-type
("eksakt kontrakt") UENDRET -- nytt valgfritt prop-sett på selve
komponenten i stedet (`expandedId`/`onToggleExpand`/
`renderExpanded`), forelderen eier state/henting. Kun felt-rader
(ikke ledere) er utvidbare -- unngår risiko i den sticky
leder-geometrien. Nye `LeaderboardHoleNine`/`LeaderboardRoundHoles`
bruker filens egne netto-par-bevisste `classify`/`scoreMarkClasses`
(samme som HoleGrid), ikke den offentlige sidens enklere variant.
**Verifisert:** 165/165 pytest (+1 ny), `tsc --noEmit` rent.
Full-stack scratch (egen db/API-container/MinIO, ekte innlogging,
ingen fetch-mocking) med en flerrunde-turnering (2 runder, én
spiller begge, én kun runde 1) -- riktig Ut/Inn-seksjon(er), riktig
Par-/Slag-sum, riktig golfscore-form, mørk og lys modus, smal
mobilskjerm. Alle scratch-ressurser ryddet opp.

View file

@ -1690,6 +1690,11 @@ class RoundCellOut(BaseModel):
round_number: int round_number: int
label: str label: str
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
# Hull-for-hull-utvidelse i leaderboardet (2026-08-22) -- lar frontend
# hente denne rundens hull direkte via det nye
# individual-leaderboard/{tp}/rounds/{round_id}/holes-endepunktet uten
# en ekstra oppslags-runde.
tournament_round_id: str
class EclecticHoleCellOut(BaseModel): class EclecticHoleCellOut(BaseModel):
@ -1904,6 +1909,70 @@ async def individual_leaderboard(
return entries return entries
@router.get(
# Eget stiprefiks (2026-08-22) -- IKKE
# .../rounds/{round_id}/participants/{x}/holes (samme stiform som den
# eksisterende round_participant_id-nøkkede list_round_participant_holes
# over, FastAPI kan ikke skille dem). Autentisert speilbilde av
# registration.py sitt offentlige, tournament_participant_id-nøkkede
# endepunkt -- bygget for leaderboardets hull-for-hull-utvidelse
# (ADR-101), som kun kjenner tournament_participant_id (leaderboard-
# radens egen nøkkel), ikke det interne tournament_round_participant_id.
"/orgs/{organization_id}/tournaments/{tournament_id}"
"/individual-leaderboard/{tournament_participant_id}/rounds/{round_id}/holes",
response_model=list[RoundHoleOut],
)
async def leaderboard_participant_round_holes(
tournament_id: str,
tournament_participant_id: str,
round_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[RoundHoleOut]:
async with org_connection(organization_id) as conn:
round_row = await conn.fetchrow(
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1 AND tournament_id = $2",
round_id,
tournament_id,
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
participant_row = await conn.fetchrow(
"SELECT id::text AS id, playing_handicap FROM tournament_round_participant "
"WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
round_id,
tournament_participant_id,
)
if participant_row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren har ikke spilt denne runden.")
holes = await conn.fetch(
"""
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes,
trh.putts, trh.club_off_tee, trh.tee_shot_result, trh.approach_result,
trh.chip_count, trh.bunker_shot_count, trh.penalty_strokes,
trh.first_putt_distance_bucket, trh.anyway_strokes,
COALESCE(trh.version, 1) AS version
FROM hole h
LEFT JOIN tournament_round_hole trh
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
WHERE h.course_id = $2
ORDER BY h.hole_number
""",
participant_row["id"],
round_row["course_id"],
)
allocation = (
allocate_over_played_holes(
participant_row["playing_handicap"], [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes]
)
if participant_row["playing_handicap"] is not None
else [None] * len(holes)
)
return [
RoundHoleOut(strokes_received=a, **dict(h))
for h, a in zip(holes, allocation)
]
class CutResult(BaseModel): class CutResult(BaseModel):
cut_after_round: int cut_after_round: int
cut_size: int cut_size: int
@ -2266,7 +2335,7 @@ async def _attach_stroke_play_columns(
# er ferdigspilt). # er ferdigspilt).
round_rows = await conn.fetch( round_rows = await conn.fetch(
""" """
SELECT tr.sequence, SELECT tr.sequence, tr.id::text AS tournament_round_id,
trs.tournament_participant_id::text AS tournament_participant_id, trs.tournament_participant_id::text AS tournament_participant_id,
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points, trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
COALESCE(pp.par_played, 0)::int AS par_played COALESCE(pp.par_played, 0)::int AS par_played
@ -2327,7 +2396,12 @@ async def _attach_stroke_play_columns(
points = row["stableford_points"] or 0 points = row["stableford_points"] or 0
expected = 2 * row["holes_played"] expected = 2 * row["holes_played"]
rounds_out.append( rounds_out.append(
RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected)) RoundCellOut(
round_number=seq,
label=f"{points} p",
tone=_to_par_tone(points - expected),
tournament_round_id=row["tournament_round_id"],
)
) )
else: else:
val = row[value_col] val = row[value_col]
@ -2338,6 +2412,7 @@ async def _attach_stroke_play_columns(
round_number=seq, round_number=seq,
label=str(val), label=str(val),
tone=_to_par_tone(val - row["par_played"]), tone=_to_par_tone(val - row["par_played"]),
tournament_round_id=row["tournament_round_id"],
) )
) )
e.rounds = rounds_out e.rounds = rounds_out

View file

@ -236,6 +236,8 @@ type ApiRoundCell = {
round_number: number round_number: number
label: string label: string
tone: "under" | "even" | "over" | null tone: "under" | "even" | "over" | null
// Hull-for-hull-utvidelse i leaderboardet (ADR-101).
tournament_round_id: string
} }
type ApiEclecticHoleCell = { type ApiEclecticHoleCell = {
@ -4925,6 +4927,18 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
// resten av appen, ikke alle utvidet samtidig). // resten av appen, ikke alle utvidet samtidig).
const [expandedEclecticId, setExpandedEclecticId] = useState<string | null>(null) const [expandedEclecticId, setExpandedEclecticId] = useState<string | null>(null)
// Hull-for-hull-utvidelse for slagspill/netto/stableford (ADR-101,
// 2026-08-22) -- portert fra public-individual-live.tsx sin
// LeaderboardRow, tilpasset den autentiserte flerrunde-visningen (ALLE
// spilte runder vises stablet ved utvidelse, ikke ett hull-sett per
// valgt runde -- StrokePlayLeaderboard viser allerede én kolonne per
// runde, ingen ekstra rundevelger trengs her). `expandedStrokeRowKey`
// nøkkeltes på `player_name`, samme identitet StrokePlayLeaderboard
// allerede bruker som sin egen React-key (raden bærer ingen egen id).
const [expandedStrokeRowKey, setExpandedStrokeRowKey] = useState<string | null>(null)
const [holesByRound, setHolesByRound] = useState<Record<string, ApiHole[] | undefined>>({})
const [loadingRoundKey, setLoadingRoundKey] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
async function load() { async function load() {
@ -5045,6 +5059,62 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
} }
} }
// StrokePlayRow bærer ingen id (kun playerName, samme identitet
// komponenten selv bruker som React-key) -- dette oppslaget lar
// toggleStrokeExpand/renderStrokeExpanded finne tilbake til den ekte
// ApiLeaderboardEntry-en (med tournament_participant_id og rundenes
// tournament_round_id) for en gitt rad.
const entryByPlayerName = new Map(entries.map((e) => [e.player_name, e]))
async function toggleStrokeExpand(row: StrokePlayRow) {
const next = expandedStrokeRowKey === row.playerName ? null : row.playerName
setExpandedStrokeRowKey(next)
if (next === null) return
const entry = entryByPlayerName.get(row.playerName)
if (!entry) return
for (const r of entry.rounds) {
const key = `${entry.tournament_participant_id}:${r.tournament_round_id}`
if (holesByRound[key] !== undefined) continue
setLoadingRoundKey(key)
const data = await getJson<ApiHole[]>(
`${base}/individual-leaderboard/${entry.tournament_participant_id}/rounds/${r.tournament_round_id}/holes`,
)
setHolesByRound((prev) => ({ ...prev, [key]: data ?? [] }))
setLoadingRoundKey(null)
}
}
function renderStrokeExpanded(row: StrokePlayRow) {
const entry = entryByPlayerName.get(row.playerName)
if (!entry || entry.rounds.length === 0) {
return (
<p className="px-4 py-3 text-sm text-muted-foreground">Ingen hull registrert ennå.</p>
)
}
return (
<div className="flex flex-col gap-3 px-3 py-3">
{entry.rounds.map((r) => {
const key = `${entry.tournament_participant_id}:${r.tournament_round_id}`
const holes = holesByRound[key]
return (
<div key={r.tournament_round_id} className="flex flex-col gap-1.5">
{entry.rounds.length > 1 && (
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Runde {r.round_number}
</span>
)}
{loadingRoundKey === key || holes === undefined ? (
<p className="text-sm text-muted-foreground">Laster hull </p>
) : (
<LeaderboardRoundHoles holes={holes} />
)}
</div>
)
})}
</div>
)
}
const isEclectic = method !== null && ECLECTIC_SCORING_METHODS.has(method) const isEclectic = method !== null && ECLECTIC_SCORING_METHODS.has(method)
function renderRow(entry: ApiLeaderboardEntry, rank: number) { function renderRow(entry: ApiLeaderboardEntry, rank: number) {
@ -5143,6 +5213,9 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
<StrokePlayLeaderboard <StrokePlayLeaderboard
rows={group.entries.map(toStrokePlayRow)} rows={group.entries.map(toStrokePlayRow)}
caption={showSectionHeaders ? `Resultattavle ${group.label}` : "Resultattavle"} caption={showSectionHeaders ? `Resultattavle ${group.label}` : "Resultattavle"}
expandedId={expandedStrokeRowKey}
onToggleExpand={toggleStrokeExpand}
renderExpanded={renderStrokeExpanded}
/> />
) : ( ) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8"> <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
@ -5155,6 +5228,90 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
) )
} }
// Hull-for-hull-utvidelse for slagspill/netto/stableford (ADR-101,
// 2026-08-22) -- Ut/Inn-tabeller portert fra public-individual-live.tsx
// sin HoleNine, men netto-par-bevisst (bruker filens EGNE classify/
// scoreMarkClasses via strokes_received, samme som HoleGrid over) i
// stedet for den offentlige sidens enklere brutto-only inndeling --
// konsistent med resten av den autentiserte scoreføringen/scorekortet.
function LeaderboardHoleNine({ holes, label }: { holes: ApiHole[]; label: string }) {
const parSum = holes.reduce((s, h) => s + h.par, 0)
const played = holes.filter((h) => h.gross_strokes !== null)
const strokeSum = played.length > 0 ? played.reduce((s, h) => s + (h.gross_strokes as number), 0) : null
return (
<div className="overflow-hidden rounded-xl border border-border">
<div
className="grid items-center bg-muted/60 text-center text-[11px] font-semibold text-muted-foreground"
style={{ gridTemplateColumns: `2rem repeat(${holes.length}, minmax(0, 1fr)) 2.4rem` }}
>
<div className="py-1">{label}</div>
{holes.map((h) => (
<div key={h.hole_number} className="py-1 tabular-nums">
{h.hole_number}
</div>
))}
<div className="py-1">Sum</div>
</div>
<div
className="grid items-center border-t border-border text-center text-[11px] text-muted-foreground"
style={{ gridTemplateColumns: `2rem repeat(${holes.length}, minmax(0, 1fr)) 2.4rem` }}
>
<div className="py-1 font-semibold">Par</div>
{holes.map((h) => (
<div key={h.hole_number} className="py-1 tabular-nums">
{h.par}
</div>
))}
<div className="py-1 font-semibold tabular-nums">{parSum}</div>
</div>
<div
className="grid items-center justify-items-center border-t border-border py-1.5 text-center"
style={{ gridTemplateColumns: `2rem repeat(${holes.length}, minmax(0, 1fr)) 2.4rem` }}
>
<div className="text-[11px] font-semibold text-foreground">Slag</div>
{holes.map((h) => {
const gross = h.gross_strokes
const net = gross !== null && h.strokes_received !== null && h.strokes_received !== undefined ? gross - h.strokes_received : gross
const diff = net !== null ? net - h.par : null
const kind = diff !== null ? classify(diff) : null
return gross !== null ? (
<span
key={h.hole_number}
className={cn(
"flex size-7 items-center justify-center text-xs font-extrabold tabular-nums",
kind ? scoreMarkClasses(kind) : "rounded-[4px] border-2 border-border text-foreground",
)}
>
{gross}
</span>
) : (
<span key={h.hole_number} className="text-xs text-muted-foreground" aria-hidden="true">
</span>
)
})}
<div className="text-xs font-bold tabular-nums text-foreground">{strokeSum ?? ""}</div>
</div>
</div>
)
}
function LeaderboardRoundHoles({ holes }: { holes: ApiHole[] }) {
if (holes.length === 0) {
return <p className="text-sm text-muted-foreground">Ingen hull registrert for denne runden ennå.</p>
}
const sorted = [...holes].sort((a, b) => a.hole_number - b.hole_number)
const front = sorted.filter((h) => h.hole_number <= 9)
const back = sorted.filter((h) => h.hole_number > 9)
return (
<div className="flex flex-col gap-2">
{front.length > 0 && <LeaderboardHoleNine holes={front} label="Ut" />}
{back.length > 0 && <LeaderboardHoleNine holes={back} label="Inn" />}
</div>
)
}
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning: // Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning:
// hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra. // hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra.
// Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det // Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { Clock, Flag, Pause, Play, Trophy } from "lucide-react" import { ChevronDown, Clock, Flag, Pause, Play, Trophy } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// --- Types (exact contract — do not change) -------------------------------- // --- Types (exact contract — do not change) --------------------------------
@ -119,9 +119,26 @@ const cellPad = "px-3 py-4 sm:px-4 xl:px-6 xl:py-6"
export function StrokePlayLeaderboard({ export function StrokePlayLeaderboard({
rows = MOCK_ROWS, rows = MOCK_ROWS,
caption = "Resultattavle", caption = "Resultattavle",
expandedId = null,
onToggleExpand,
renderExpanded,
}: { }: {
rows?: LeaderboardRow[] rows?: LeaderboardRow[]
caption?: string caption?: string
// Hull-for-hull-utvidelse (2026-08-22, ADR-101) -- IKKE en del av
// LeaderboardRow-kontrakten over ("eksakt kontrakt -- ikke endre"), et
// helt separat, valgfritt prop-sett på selve komponenten. Forelderen
// (LeaderboardTab) eier state + henting, akkurat som
// TournamentScoringWizard/ScoreTab -- denne komponenten er ren
// presentasjon. `playerName` er nøkkelen (samme identitet komponenten
// allerede bruker som React-key), siden LeaderboardRow ikke bærer noen
// egen id. Kun FELT-rader (ikke ledere) er utvidbare -- en utvidet
// ledertrad ville krevd at leaderTops-beregningen over tok høyde for
// utvidelsens ekstra høyde i den STICKY posisjoneringen, en unødvendig
// risiko når lederen(e) uansett allerede er fremhevet og synlig øverst.
expandedId?: string | null
onToggleExpand?: (row: LeaderboardRow) => void
renderExpanded?: (row: LeaderboardRow) => React.ReactNode
}) { }) {
const reducedMotion = usePrefersReducedMotion() const reducedMotion = usePrefersReducedMotion()
const [playing, setPlaying] = useState(true) const [playing, setPlaying] = useState(true)
@ -358,7 +375,15 @@ export function StrokePlayLeaderboard({
return field.map((row, i) => ( return field.map((row, i) => (
<Fragment key={`field-${row.playerName}`}> <Fragment key={`field-${row.playerName}`}>
{i === firstCutIndex && <CutLineRow totalCols={totalCols} />} {i === firstCutIndex && <CutLineRow totalCols={totalCols} />}
<FieldRow row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} /> <FieldRow
row={row}
roundNumbers={roundNumbers}
striped={i % 2 === 1}
expanded={expandedId === row.playerName}
onToggleExpand={onToggleExpand ? () => onToggleExpand(row) : undefined}
renderExpanded={renderExpanded}
totalCols={totalCols}
/>
</Fragment> </Fragment>
)) ))
})()} })()}
@ -481,10 +506,18 @@ function FieldRow({
row, row,
roundNumbers, roundNumbers,
striped, striped,
expanded,
onToggleExpand,
renderExpanded,
totalCols,
}: { }: {
row: LeaderboardRow row: LeaderboardRow
roundNumbers: number[] roundNumbers: number[]
striped: boolean striped: boolean
expanded: boolean
onToggleExpand?: () => void
renderExpanded?: (row: LeaderboardRow) => React.ReactNode
totalCols: number
}) { }) {
const status = row.totalLabel != null && isStatus(row.totalLabel) const status = row.totalLabel != null && isStatus(row.totalLabel)
// Hasn't played a single hole yet anywhere in the tournament (backend // Hasn't played a single hole yet anywhere in the tournament (backend
@ -495,16 +528,47 @@ function FieldRow({
const notStarted = !status && row.totalLabel == null const notStarted = !status && row.totalLabel == null
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card" const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
const border = "border-b border-clubhouse-border" const border = "border-b border-clubhouse-border"
const canExpand = Boolean(onToggleExpand && renderExpanded)
const panelId = `stroke-play-holes-${row.playerName}`
return ( return (
<tr className={cn(bg, (status || notStarted) && "opacity-70")}> <>
<tr
className={cn(bg, (status || notStarted) && "opacity-70", canExpand && "cursor-pointer")}
{...(canExpand
? {
onClick: onToggleExpand,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggleExpand?.()
}
},
tabIndex: 0,
role: "button",
"aria-expanded": expanded,
"aria-controls": panelId,
}
: {})}
>
<td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}> <td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}>
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span> <span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span>
</td> </td>
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}> <td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
<span className="flex items-center gap-1.5">
<span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}> <span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
{row.playerName} {row.playerName}
</span> </span>
{canExpand && (
<ChevronDown
aria-hidden="true"
className={cn(
"size-4 shrink-0 text-clubhouse-muted transition-transform duration-200 xl:size-6",
expanded && "rotate-180",
)}
/>
)}
</span>
</td> </td>
{status ? ( {status ? (
@ -537,6 +601,14 @@ function FieldRow({
</> </>
)} )}
</tr> </tr>
{canExpand && expanded && (
<tr id={panelId} className={bg}>
<td colSpan={totalCols} className={cn(bg, border, "p-0")}>
{renderExpanded?.(row)}
</td>
</tr>
)}
</>
) )
} }

View file

@ -6,10 +6,14 @@ har et oppgitt klokkeslett/starthull, vise det i stedet. Kaller den ekte
router-funksjonen direkte (samme mønster som test_order_of_merit.py). router-funksjonen direkte (samme mønster som test_order_of_merit.py).
""" """
from fastapi import HTTPException
import pytest
from app.auth import CurrentUser from app.auth import CurrentUser
from app.routers.individual_tournaments import ( from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate, HoleUpdate as TournamentHoleUpdate,
individual_leaderboard, individual_leaderboard,
leaderboard_participant_round_holes,
update_hole as tournament_update_hole, update_hole as tournament_update_hole,
) )
@ -119,3 +123,48 @@ async def test_all_not_started_has_no_leader(pool):
assert entries[0].total_label is None assert entries[0].total_label is None
assert entries[0].position is None assert entries[0].position is None
assert entries[0].is_leader is False assert entries[0].is_leader is False
async def test_leaderboard_round_id_and_holes_endpoint(pool):
"""ADR-101: RoundCellOut.tournament_round_id skal peke til riktig runde,
og det nye tournament_participant_id-nøkkede hull-endepunktet (bygget for
leaderboardets hull-for-hull-utvidelse) skal returnere de samme hullene
som den eksisterende round_participant_id-nøkkede varianten -- kun
oppslagsveien er ny."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id = await create_tournament(org_id, name="Hull-for-hull-turnering")
course_id = await create_course(org_id, name="Testbanen")
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
tournament_id,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
player_id = await create_player(org_id, display_name="Spiller Ett")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
await _score_all_holes(tournament_id, round_id, rp_id, org_id, owner_id, score=5)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
assert len(entry.rounds) == 1
assert entry.rounds[0].tournament_round_id == round_id
holes = await leaderboard_participant_round_holes(
tournament_id, tp_id, round_id, organization_id=org_id,
)
assert len(holes) == 18
assert all(h.gross_strokes == 5 for h in holes)
with pytest.raises(HTTPException) as exc_info:
await leaderboard_participant_round_holes(
tournament_id, tp_id, "00000000-0000-0000-0000-000000000000", organization_id=org_id,
)
assert exc_info.value.status_code == 404