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:
parent
4a415ac377
commit
dcc2a9275c
6 changed files with 506 additions and 41 deletions
|
|
@ -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)
|
||||
|
||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -14343,3 +14343,36 @@ Neste steg:
|
|||
backend-endring). `docker compose build teecup_api teecup_frontend
|
||||
&& up -d`, begge containere friske, rene logger.
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -1690,6 +1690,11 @@ class RoundCellOut(BaseModel):
|
|||
round_number: int
|
||||
label: str
|
||||
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):
|
||||
|
|
@ -1904,6 +1909,70 @@ async def individual_leaderboard(
|
|||
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):
|
||||
cut_after_round: int
|
||||
cut_size: int
|
||||
|
|
@ -2266,7 +2335,7 @@ async def _attach_stroke_play_columns(
|
|||
# er ferdigspilt).
|
||||
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.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
||||
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
|
||||
expected = 2 * row["holes_played"]
|
||||
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:
|
||||
val = row[value_col]
|
||||
|
|
@ -2338,6 +2412,7 @@ async def _attach_stroke_play_columns(
|
|||
round_number=seq,
|
||||
label=str(val),
|
||||
tone=_to_par_tone(val - row["par_played"]),
|
||||
tournament_round_id=row["tournament_round_id"],
|
||||
)
|
||||
)
|
||||
e.rounds = rounds_out
|
||||
|
|
|
|||
|
|
@ -236,6 +236,8 @@ type ApiRoundCell = {
|
|||
round_number: number
|
||||
label: string
|
||||
tone: "under" | "even" | "over" | null
|
||||
// Hull-for-hull-utvidelse i leaderboardet (ADR-101).
|
||||
tournament_round_id: string
|
||||
}
|
||||
|
||||
type ApiEclecticHoleCell = {
|
||||
|
|
@ -4925,6 +4927,18 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
|||
// resten av appen, ikke alle utvidet samtidig).
|
||||
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(() => {
|
||||
let cancelled = false
|
||||
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)
|
||||
|
||||
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
|
||||
|
|
@ -5143,6 +5213,9 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
|||
<StrokePlayLeaderboard
|
||||
rows={group.entries.map(toStrokePlayRow)}
|
||||
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">
|
||||
|
|
@ -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:
|
||||
// hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra.
|
||||
// Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
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"
|
||||
|
||||
// --- 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({
|
||||
rows = MOCK_ROWS,
|
||||
caption = "Resultattavle",
|
||||
expandedId = null,
|
||||
onToggleExpand,
|
||||
renderExpanded,
|
||||
}: {
|
||||
rows?: LeaderboardRow[]
|
||||
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 [playing, setPlaying] = useState(true)
|
||||
|
|
@ -358,7 +375,15 @@ export function StrokePlayLeaderboard({
|
|||
return field.map((row, i) => (
|
||||
<Fragment key={`field-${row.playerName}`}>
|
||||
{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>
|
||||
))
|
||||
})()}
|
||||
|
|
@ -481,10 +506,18 @@ function FieldRow({
|
|||
row,
|
||||
roundNumbers,
|
||||
striped,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
renderExpanded,
|
||||
totalCols,
|
||||
}: {
|
||||
row: LeaderboardRow
|
||||
roundNumbers: number[]
|
||||
striped: boolean
|
||||
expanded: boolean
|
||||
onToggleExpand?: () => void
|
||||
renderExpanded?: (row: LeaderboardRow) => React.ReactNode
|
||||
totalCols: number
|
||||
}) {
|
||||
const status = row.totalLabel != null && isStatus(row.totalLabel)
|
||||
// Hasn't played a single hole yet anywhere in the tournament (backend
|
||||
|
|
@ -495,48 +528,87 @@ function FieldRow({
|
|||
const notStarted = !status && row.totalLabel == null
|
||||
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
|
||||
const border = "border-b border-clubhouse-border"
|
||||
const canExpand = Boolean(onToggleExpand && renderExpanded)
|
||||
const panelId = `stroke-play-holes-${row.playerName}`
|
||||
|
||||
return (
|
||||
<tr className={cn(bg, (status || notStarted) && "opacity-70")}>
|
||||
<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>
|
||||
</td>
|
||||
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
|
||||
<span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
|
||||
{row.playerName}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{status ? (
|
||||
<>
|
||||
<DashTd bg={bg} border={border} />
|
||||
<DashTd bg={bg} border={border} />
|
||||
<td className={cn(cellPad, bg, border, "text-right")}>
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-sm font-black uppercase tracking-wide text-clubhouse-muted xl:text-xl">
|
||||
{row.totalLabel}
|
||||
<>
|
||||
<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")}>
|
||||
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span>
|
||||
</td>
|
||||
<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")}>
|
||||
{row.playerName}
|
||||
</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>
|
||||
|
||||
{status ? (
|
||||
<>
|
||||
<DashTd bg={bg} border={border} />
|
||||
<DashTd bg={bg} border={border} />
|
||||
<td className={cn(cellPad, bg, border, "text-right")}>
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-sm font-black uppercase tracking-wide text-clubhouse-muted xl:text-xl">
|
||||
{row.totalLabel}
|
||||
</span>
|
||||
</td>
|
||||
{roundNumbers.map((n) => {
|
||||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
|
||||
<ThruTd bg={bg} border={border} thru={row.thruLabel} />
|
||||
{notStarted ? (
|
||||
<TeeInfoTd bg={bg} border={border} teeTime={row.nextTeeTime ?? null} startHole={row.nextStartHole ?? null} />
|
||||
) : (
|
||||
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
|
||||
)}
|
||||
{roundNumbers.map((n) => {
|
||||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
{canExpand && expanded && (
|
||||
<tr id={panelId} className={bg}>
|
||||
<td colSpan={totalCols} className={cn(bg, border, "p-0")}>
|
||||
{renderExpanded?.(row)}
|
||||
</td>
|
||||
{roundNumbers.map((n) => {
|
||||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
|
||||
<ThruTd bg={bg} border={border} thru={row.thruLabel} />
|
||||
{notStarted ? (
|
||||
<TeeInfoTd bg={bg} border={border} teeTime={row.nextTeeTime ?? null} startHole={row.nextStartHole ?? null} />
|
||||
) : (
|
||||
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
|
||||
)}
|
||||
{roundNumbers.map((n) => {
|
||||
const cell = row.rounds.find((c) => c.roundNumber === n)
|
||||
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
|
||||
})}
|
||||
</>
|
||||
</tr>
|
||||
)}
|
||||
</tr>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
from app.auth import CurrentUser
|
||||
from app.routers.individual_tournaments import (
|
||||
HoleUpdate as TournamentHoleUpdate,
|
||||
individual_leaderboard,
|
||||
leaderboard_participant_round_holes,
|
||||
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].position is None
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue