From 54795065ef67245826ac3fc08643f803a219631c Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 18 Aug 2026 14:23:03 +0200 Subject: [PATCH] =?UTF-8?q?N=C3=B8ytral=20"ikke=20startet"-rad=20+=20tee-t?= =?UTF-8?q?id/starthull=20i=20leaderboardet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deltakere som ikke har spilt noe ennå fikk feilaktig totalLabel "E"/"0 p" (til forveksling likt en ekte, jevn score) og en tallfestet sisteplass. Vises nå nøytralt: total/posisjon null/None, og runde-nivåets tee-tid/ starthull (hvis satt) i stedet for score-cellen. --- CHANGELOG.md | 69 ++++++++++ app/routers/individual_tournaments.py | 45 ++++++- .../individual-tournament-detail.tsx | 13 +- .../components/stroke-play-leaderboard.tsx | 75 ++++++++++- tests/test_individual_leaderboard.py | 121 ++++++++++++++++++ 5 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 tests/test_individual_leaderboard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ce33e06..40872c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12488,4 +12488,73 @@ Neste steg: manuelt for at scratch-dataen skulle vise seg riktig -- verdt å huske for fremtidig scratch-seeding av individuelle turneringer. + **Rullet ut 2026-08-18** -- bruker bekreftet ("Ja, bygg og rull ut"). + `docker compose build teecup_frontend && docker compose up -d + teecup_frontend`, rene logger ("Ready in 0ms"). `curl -I + https://teecup.golf/logg-inn` -> 200 etter utrulling. + +113. **Nøytral "ikke startet"-rad + tee-tid/starthull i leaderboardet — + 2026-08-18.** Bruker: "De som ikke har begynt å spille ennå må være + nøytrale på et vis, i leaderboardet. Det bør kanskje komme frem + teetime og starthull på de, dersom det er angitt?" Rot funnet i + `_attach_stroke_play_columns` (`individual_tournaments.py`): en + deltaker som ikke hadde spilt ETT eneste hull ennå (`total_par_played + == 0`) fikk likevel `total_label = "E"` (brutto/netto) eller `"0 p"` + (stableford) -- til forveksling likt en ekte, jevn/poengløs SCORE, + ikke "ingen data ennå". Samme deltaker fikk også en tallfestet + plassering (nederst i feltet, siden NO_SCORE-nøkkelen sorterer sist) + -- så de så ut som en reelt rangert, dårlig presterende spiller i + stedet for en som rett og slett ikke har startet. + + **Backend (`app/routers/individual_tournaments.py`):** ny + `LeaderboardEntry.next_tee_time`/`next_start_hole` (kun individuelle + turneringer har en `tournament_round`-tabell å hente disse fra -- + ETT klokkeslett/starthull PER RUNDE, delt av hele feltet, ikke per + deltaker -- ingen per-deltaker utslettidspunkt finnes noe sted i + denne turneringsformen). Populeres når `thru_label == "-"` (samme + betingelse som already brukes for "ikke startet gjeldende runde"): + referanserunden er `today_sequence` (runden som er i gang) hvis + turneringen er underveis, ellers runde 1 (ingen har startet noe + ennå). `next_start_hole` vises alltid når referanserunden finnes + (kolonnen har alltid en verdi, default 1); `next_tee_time` vises kun + når runden faktisk har et satt `scheduled_at` (nullable -- ikke alle + turneringer setter det). `total_label` er nå `None` (ikke "E"/"0 p") + når deltakeren ikke har spilt noe -- **kun** når `total_par_played + == 0`, IKKE når de bare venter på neste runde etter å ha fullført en + tidligere (den deltakeren beholder sin ekte total, kun THRU viser + "-", eksisterende oppførsel uendret). `position`/`is_leader` settes + til `None`/`False` for hele NO_SCORE-gruppen i stedet for en + tallfestet sisteplass -- inkl. kant-tilfellet der INGEN i feltet har + spilt noe ennå (da ville rank 1 ellers blitt feilaktig kåret til + "leder"). + + **Frontend (`stroke-play-leaderboard.tsx`, liten, veloverveid + håndkodet utvidelse av en eksisterende V0-komponent -- IKKE en ny + UI-flate, se [[feedback_frontend_via_v0]]):** `totalLabel` er nå + `string | null`; når `null` (og raden ikke allerede er en WD/DQ- + statusrad) rendres en ny `TeeInfoTd` i TOTALT-kolonnen i stedet for + en `ValueTd` -- en nøytral pille (klokke-ikon, `bg-muted`) med + `"{tee-tid} · Hull {starthull}"`, eller bare det ene hvis kun det er + satt, eller en ren dash hvis ingen av delene er satt. Raden får + samme dempede `opacity-70`-behandling som WD/DQ-rader (bevisst + gjenbruk -- samme "dette er ikke et konkurransedyktig resultat ennå"- + signal). Ny "Ikke startet"-forklaring lagt til i toppens Legend + (klokke-ikon), siden pillen introduserer en ny visuell betydning + Legend ikke dekket fra før. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: + to nye pytest-tester i `tests/test_individual_leaderboard.py` (én + med en reell deltaker som HAR spilt runde 1 ved siden av én som + ikke har startet noe -- bekrefter begge veier samtidig -- og én for + alle-ikke-startet-kant-tilfellet), kjørt via + `scripts/run_backend_tests.sh` (egen scratch-database, rører aldri + ekte `teecup_db`) -- 117/117 grønt (opp fra 115). Frontend: en + midlertidig lokal `/leaderboardpreviewtmp`-side (kun i en `next dev`- + prosess, ALDRI committet) rendret `StrokePlayLeaderboard` med + `MOCK_ROWS` utvidet med to nye rader (én med både tee-tid og + starthull, én med KUN starthull) -- skjermbilde + a11y-snapshot + bekreftet riktig pille-tekst, riktig dempet radstil, og at + posisjonen viser "-" i stedet for et tall. Midlertidig side slettet + igjen etter verifisering. + **Rullet ut:** venter på bekreftelse. diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 511d4be..f3e03d8 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -1230,6 +1230,13 @@ class LeaderboardEntry(BaseModel): thru_label: str | None = None total_label: str | None = None rounds: list[RoundCellOut] = [] + # Nøytral visning for spillere som ikke har startet gjeldende/neste runde + # ennå (2026-08-18). next_start_hole settes alltid når det finnes en + # referanse-runde (start_hole har alltid en verdi -- default 1). + # next_tee_time settes kun når runden faktisk har et oppgitt klokkeslett + # (scheduled_at er nullable -- ikke alle turneringer setter det). + next_tee_time: str | None = None + next_start_hole: int | None = None # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ @@ -1356,7 +1363,7 @@ async def _attach_stroke_play_columns( """ rounds_meta = await conn.fetch( """ - SELECT sequence, hole_config::text AS hole_config + SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence @@ -1366,6 +1373,8 @@ async def _attach_stroke_play_columns( hole_count_by_seq = { r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta } + round_meta_by_seq = {r["sequence"]: r for r in rounds_meta} + first_sequence = rounds_meta[0]["sequence"] if rounds_meta else None # Én rad per (runde, deltaker) som faktisk er PÅBEGYNT (holes_played > 0). # par_played = summen av par for KUN de hullene deltakeren faktisk har @@ -1462,18 +1471,36 @@ async def _attach_stroke_play_columns( e.thru_label = "-" e.today_label = None + # Ikke-startet-info (2026-08-18): så lenge deltakeren ikke har + # startet den gjeldende/neste runden ("-" over), vis tee-tid/ + # starthull for DEN runden -- referanserunden er selve gjeldende + # runden (today_sequence) hvis turneringen er i gang, ellers + # runde 1 (ingen har startet noe ennå). + if e.thru_label == "-": + reference_sequence = today_sequence if today_sequence is not None else first_sequence + meta = round_meta_by_seq.get(reference_sequence) if reference_sequence is not None else None + if meta is not None: + e.next_tee_time = meta["scheduled_at"] + e.next_start_hole = meta["start_hole"] + # TOTAL -- gjenbruker den allerede korrekt summerte gross_total/ # net_total/stableford_total (uendret av denne funksjonen), trekker - # kun fra summert par-for-spilte-hull for til-par-formatet. + # kun fra summert par-for-spilte-hull for til-par-formatet. Ingen + # runde spilt ennå (total_holes_played == 0) -> total_label forblir + # None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte, + # jevn/poengløs score i stedet for "ikke startet"). + started_any = total_holes_played > 0 if method == "stableford": - e.total_label = f"{e.stableford_total or 0} p" + e.total_label = f"{e.stableford_total or 0} p" if started_any else None rank_key_by_id[e.tournament_participant_id] = ( - NO_SCORE if e.stableford_total is None or total_holes_played == 0 + NO_SCORE if e.stableford_total is None or not started_any else -(e.stableford_total - 2 * total_holes_played) ) else: total_val = e.net_total if method == "stroke_net" else e.gross_total - e.total_label = _to_par_label(total_val - total_par_played) if total_val is not None else "E" + e.total_label = ( + _to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None + ) rank_key_by_id[e.tournament_participant_id] = ( NO_SCORE if total_val is None or total_par_played == 0 else total_val - total_par_played ) @@ -1485,6 +1512,9 @@ async def _attach_stroke_play_columns( # POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste # rangering hopper over like mange plasser som antall uavgjorte). + # Deltakere som ikke har spilt ETT eneste hull ennå (NO_SCORE) får INGEN + # posisjon -- de er ikke "sist rangert", de er ennå ikke en del av + # rangeringen (nøytral, se next_tee_time/next_start_hole over). i, n = 0, len(entries) while i < n: j = i @@ -1493,9 +1523,10 @@ async def _attach_stroke_play_columns( j += 1 rank = i + 1 tied = (j - i) > 1 + not_started_group = key_i == NO_SCORE for k in range(i, j): - entries[k].position = f"T{rank}" if tied else str(rank) - entries[k].is_leader = rank == 1 + entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) + entries[k].is_leader = rank == 1 and not not_started_group i = j diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 822fc72..943f4d7 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -216,6 +216,11 @@ type ApiLeaderboardEntry = { today_label: string | null thru_label: string | null total_label: string | null + // Nøytral "ikke startet"-info (2026-08-18) -- kun satt når deltakeren + // ikke har startet gjeldende/neste runde (thru_label "-"), se + // stroke-play-leaderboard.tsx. + next_tee_time: string | null + next_start_hole: number | null rounds: ApiRoundCell[] // Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for // scoring_method eclectic_gross/eclectic_net/eclectic_stableford. @@ -3979,9 +3984,15 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou todayLabel: entry.today_label, todayIsUnderPar: null, thruLabel: entry.thru_label ?? "-", - totalLabel: entry.total_label ?? "E", + // total_label er null når deltakeren ikke har spilt ett eneste hull + // ennå -- nøytral tilstand, IKKE til forveksling med "E" (jevnt med + // par, en ekte score). stroke-play-leaderboard.tsx viser tee-tid/ + // starthull i stedet når totalLabel er null. + totalLabel: entry.total_label, totalIsUnderPar: false, isLeader: entry.is_leader, + nextTeeTime: entry.next_tee_time, + nextStartHole: entry.next_start_hole, rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })), } } diff --git a/frontend/components/stroke-play-leaderboard.tsx b/frontend/components/stroke-play-leaderboard.tsx index 5075b2a..636fbf9 100644 --- a/frontend/components/stroke-play-leaderboard.tsx +++ b/frontend/components/stroke-play-leaderboard.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" -import { Flag, Pause, Play, Trophy } from "lucide-react" +import { Clock, Flag, Pause, Play, Trophy } from "lucide-react" import { cn } from "@/lib/utils" // --- Types (exact contract — do not change) -------------------------------- @@ -18,9 +18,14 @@ export type LeaderboardRow = { todayLabel: string | null // "-2", "+4", "E", "38 p" — null = today's round not started yet todayIsUnderPar: boolean | null thruLabel: string // "F" (finished), "14" (holes played), "-" (not started) - totalLabel: string // "-13", "+7", "146 p" + totalLabel: string | null // "-13", "+7", "146 p" — null = hasn't played a single hole yet (neutral, not "E") totalIsUnderPar: boolean isLeader: boolean // true for every row sharing the current lead position (ties possible) + // When totalLabel is null, the row is rendered neutrally and shows this + // instead of a score: the tee time / start hole of the round they're + // about to play, if the organizer set one. Either can be null/undefined. + nextTeeTime?: string | null // ISO datetime, or null if no tee time was set + nextStartHole?: number | null rounds: RoundCell[] // one entry per round played so far (1-4 entries, not always 4) } @@ -54,6 +59,8 @@ export const MOCK_ROWS: LeaderboardRow[] = [ { position: "14", playerName: "Maja Lund", todayLabel: "+2", todayIsUnderPar: false, thruLabel: "F", totalLabel: "+9", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 79), rc(2, 74)] }, { position: "15", playerName: "Frida Ness", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "+11", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 83)] }, { position: "–", playerName: "Kaia Strand", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: "WD", totalIsUnderPar: false, isLeader: false, rounds: [rc(1, 76)] }, + { position: "-", playerName: "Julie Berget", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: "2026-08-19T11:10:00", nextStartHole: 1, rounds: [] }, + { position: "-", playerName: "Selma Vang", todayLabel: null, todayIsUnderPar: null, thruLabel: "-", totalLabel: null, totalIsUnderPar: false, isLeader: false, nextTeeTime: null, nextStartHole: 10, rounds: [] }, ] // --- Tone helpers ---------------------------------------------------------- @@ -371,6 +378,10 @@ function Legend() { Par +
  • +
  • ) } @@ -461,17 +472,23 @@ function FieldRow({ roundNumbers: number[] striped: boolean }) { - const status = isStatus(row.totalLabel) + const status = row.totalLabel != null && isStatus(row.totalLabel) + // Hasn't played a single hole yet anywhere in the tournament (backend + // sends totalLabel: null for this, never "E" — "E" is a real even-par + // score). Rendered neutrally, same muted treatment as a WD/DQ row, with + // the upcoming tee time / start hole shown in place of a score if the + // organizer set one. + const notStarted = !status && row.totalLabel == null const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card" const border = "border-b border-clubhouse-border" return ( - + {row.position} - + {row.playerName} @@ -494,7 +511,11 @@ function FieldRow({ <> - + {notStarted ? ( + + ) : ( + + )} {roundNumbers.map((n) => { const cell = row.rounds.find((c) => c.roundNumber === n) return @@ -668,3 +689,45 @@ function DashTd({ bg, border }: { bg: string; border: string }) { ) } + +// --- Tee-info cell (not-started-yet rows) ----------------------------------- +// Replaces the TOTAL cell for a player who hasn't played a single hole yet: +// their upcoming tee time / start hole instead of a score, so the row reads +// as "not started" rather than as a real (if unlucky) result. + +function formatTeeTime(iso: string) { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return null + return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) +} + +function TeeInfoTd({ + teeTime, + startHole, + bg, + border, +}: { + teeTime: string | null + startHole: number | null + bg: string + border: string +}) { + const time = teeTime ? formatTeeTime(teeTime) : null + const parts = [time, startHole != null ? `Hull ${startHole}` : null].filter((v): v is string => v != null) + + return ( + + {parts.length > 0 ? ( + + + ) : ( + + – + + )} + + ) +} diff --git a/tests/test_individual_leaderboard.py b/tests/test_individual_leaderboard.py new file mode 100644 index 0000000..bbda3ee --- /dev/null +++ b/tests/test_individual_leaderboard.py @@ -0,0 +1,121 @@ +""" +Nøytral "ikke startet"-visning i det individuelle leaderboardet (2026-08-18): +en deltaker som ikke har spilt ett eneste hull ennå skal IKKE vises med en +falsk "E"/"0 p"-total eller en rangert plassering -- og skal, hvis runden +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 app.auth import CurrentUser +from app.routers.individual_tournaments import ( + HoleUpdate as TournamentHoleUpdate, + individual_leaderboard, + update_hole as tournament_update_hole, +) + +from tests.conftest import ( + add_membership, + create_org, + create_org_hole, + create_course, + create_player, + create_tee, + create_tournament, + create_tournament_participant, + create_tournament_round, + create_tournament_round_participant, + create_user, +) + +import app.db as app_db + + +async def _score_all_holes(tournament_id, round_id, rp_id, org_id, user_id, score): + user = CurrentUser(user_id=user_id) + for n in range(1, 19): + await tournament_update_hole( + tournament_id, round_id, rp_id, n, + TournamentHoleUpdate(gross_strokes=score, expected_version=None), + organization_id=org_id, user=user, + ) + + +async def test_not_started_player_is_neutral_and_shows_tee_info(pool): + 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="Slagspill-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, + ) + + round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE tournament_round SET scheduled_at = '2026-08-19 10:20:00+02', start_hole = 5 WHERE id = $1", + round1_id, + ) + + started_player_id = await create_player(org_id, display_name="Har startet") + started_tp_id = await create_tournament_participant(org_id, tournament_id, started_player_id) + started_rp_id = await create_tournament_round_participant(org_id, round1_id, started_tp_id, tee_id) + await _score_all_holes(tournament_id, round1_id, started_rp_id, org_id, owner_id, score=4) + + not_started_player_id = await create_player(org_id, display_name="Ikke startet ennå") + not_started_tp_id = await create_tournament_participant(org_id, tournament_id, not_started_player_id) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + by_id = {e.tournament_participant_id: e for e in entries} + + started = by_id[started_tp_id] + assert started.total_label == "E" + assert started.position == "1" + assert started.is_leader is True + + not_started = by_id[not_started_tp_id] + assert not_started.total_label is None + assert not_started.position is None + assert not_started.is_leader is False + assert not_started.next_start_hole == 5 + # ::text-castet fra Postgres kommer ut i DB-sesjonens tidssone (ikke + # nødvendigvis Europe/Oslo -- frontend gjør selve lokal-formateringen), + # så testen sjekker kun at DATOEN er riktig, ikke et eksakt klokkeslett. + assert not_started.next_tee_time is not None + assert "2026-08-19" in not_started.next_tee_time + + +async def test_all_not_started_has_no_leader(pool): + """Kant-tilfelle: INGEN har spilt noe ennå -- da skal ingen markeres som + leder (alle havner i samme NO_SCORE-gruppe, som ellers ville blitt + "rank 1" og dermed feilaktig kåret til leder).""" + 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="Turnering uten spill ennå") + 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) + 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, + ) + round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + + player_id = await create_player(org_id, display_name="Spiller") + tp_id = await create_tournament_participant(org_id, tournament_id, player_id) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + assert len(entries) == 1 + assert entries[0].tournament_participant_id == tp_id + assert entries[0].total_label is None + assert entries[0].position is None + assert entries[0].is_leader is False