Nøytral "ikke startet"-rad + tee-tid/starthull i leaderboardet

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.
This commit is contained in:
Erol Haagenrud 2026-08-18 14:23:03 +02:00
parent 985dada178
commit 54795065ef
5 changed files with 309 additions and 14 deletions

View file

@ -12488,4 +12488,73 @@ Neste steg:
manuelt for at scratch-dataen skulle vise seg riktig -- verdt å manuelt for at scratch-dataen skulle vise seg riktig -- verdt å
huske for fremtidig scratch-seeding av individuelle turneringer. 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. **Rullet ut:** venter på bekreftelse.

View file

@ -1230,6 +1230,13 @@ class LeaderboardEntry(BaseModel):
thru_label: str | None = None thru_label: str | None = None
total_label: str | None = None total_label: str | None = None
rounds: list[RoundCellOut] = [] 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 # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
# per hull på tvers av ALLE turneringens runder (samme bane, håndhevet # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
# ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
@ -1356,7 +1363,7 @@ async def _attach_stroke_play_columns(
""" """
rounds_meta = await conn.fetch( 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 FROM tournament_round
WHERE tournament_id = $1 WHERE tournament_id = $1
ORDER BY sequence ORDER BY sequence
@ -1366,6 +1373,8 @@ async def _attach_stroke_play_columns(
hole_count_by_seq = { hole_count_by_seq = {
r["sequence"]: _HOLE_COUNT_BY_CONFIG[r["hole_config"]] for r in rounds_meta 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). # É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 # 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.thru_label = "-"
e.today_label = None 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/ # TOTAL -- gjenbruker den allerede korrekt summerte gross_total/
# net_total/stableford_total (uendret av denne funksjonen), trekker # 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": 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] = ( 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 -(e.stableford_total - 2 * total_holes_played)
) )
else: else:
total_val = e.net_total if method == "stroke_net" else e.gross_total 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] = ( 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 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 # POS -- vanlig konkurranse-rangering (uavgjort deler plass, neste
# rangering hopper over like mange plasser som antall uavgjorte). # 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) i, n = 0, len(entries)
while i < n: while i < n:
j = i j = i
@ -1493,9 +1523,10 @@ async def _attach_stroke_play_columns(
j += 1 j += 1
rank = i + 1 rank = i + 1
tied = (j - i) > 1 tied = (j - i) > 1
not_started_group = key_i == NO_SCORE
for k in range(i, j): for k in range(i, j):
entries[k].position = f"T{rank}" if tied else str(rank) entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank))
entries[k].is_leader = rank == 1 entries[k].is_leader = rank == 1 and not not_started_group
i = j i = j

View file

@ -216,6 +216,11 @@ type ApiLeaderboardEntry = {
today_label: string | null today_label: string | null
thru_label: string | null thru_label: string | null
total_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[] rounds: ApiRoundCell[]
// Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for // Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for
// scoring_method eclectic_gross/eclectic_net/eclectic_stableford. // scoring_method eclectic_gross/eclectic_net/eclectic_stableford.
@ -3979,9 +3984,15 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
todayLabel: entry.today_label, todayLabel: entry.today_label,
todayIsUnderPar: null, todayIsUnderPar: null,
thruLabel: entry.thru_label ?? "-", 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, totalIsUnderPar: false,
isLeader: entry.is_leader, 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 })), rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
} }
} }

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" 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" import { cn } from "@/lib/utils"
// --- Types (exact contract — do not change) -------------------------------- // --- 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 todayLabel: string | null // "-2", "+4", "E", "38 p" — null = today's round not started yet
todayIsUnderPar: boolean | null todayIsUnderPar: boolean | null
thruLabel: string // "F" (finished), "14" (holes played), "-" (not started) 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 totalIsUnderPar: boolean
isLeader: boolean // true for every row sharing the current lead position (ties possible) 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) 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: "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: "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: "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 ---------------------------------------------------------- // --- Tone helpers ----------------------------------------------------------
@ -371,6 +378,10 @@ function Legend() {
</span> </span>
Par Par
</li> </li>
<li className="flex items-center gap-1.5">
<Clock aria-hidden="true" className="size-4 xl:size-6" />
Ikke startet
</li>
</ul> </ul>
) )
} }
@ -461,17 +472,23 @@ function FieldRow({
roundNumbers: number[] roundNumbers: number[]
striped: boolean 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 bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
const border = "border-b border-clubhouse-border" const border = "border-b border-clubhouse-border"
return ( return (
<tr className={cn(bg, status && "opacity-70")}> <tr className={cn(bg, (status || notStarted) && "opacity-70")}>
<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={cn("block truncate text-base font-semibold xl:text-2xl", status ? "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>
</td> </td>
@ -494,7 +511,11 @@ function FieldRow({
<> <>
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" /> <ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
<ThruTd bg={bg} border={border} thru={row.thruLabel} /> <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" /> <ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
)}
{roundNumbers.map((n) => { {roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n) const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} bg={bg} border={border} cell={cell} /> return <RoundTd key={n} bg={bg} border={border} cell={cell} />
@ -668,3 +689,45 @@ function DashTd({ bg, border }: { bg: string; border: string }) {
</td> </td>
) )
} }
// --- 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 (
<td className={cn(cellPad, bg, border, "text-right")}>
{parts.length > 0 ? (
<span className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md bg-muted px-2.5 py-1 text-sm font-bold text-clubhouse-muted xl:text-lg">
<Clock aria-hidden="true" className="size-3.5 shrink-0 xl:size-5" />
{parts.join(" · ")}
<span className="sr-only"> (ikke startet)</span>
</span>
) : (
<span className="text-base text-clubhouse-muted xl:text-2xl" aria-label="ikke startet">
</span>
)}
</td>
)
}

View file

@ -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