diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 02fd08b..b97e827 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -261,7 +261,11 @@ "Bash(python3 -m py_compile /opt/teecup/app/routers/courses.py /opt/teecup/app/routers/scoring.py)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/score_cookies.txt)", "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-7)", - "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/sc_cookies.txt)" + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/sc_cookies.txt)", + "Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_scratch -c ' *)", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/lb_cookies.txt)", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-8)", + "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/lb2_cookies.txt)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 79e8007..5a0e792 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -924,9 +924,54 @@ Ferdig og verifisert: typesjekket build. **Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket. +- **Leaderboard-backend LIVE (2026-07-18):** ny `GET /orgs/{id}/ + tournaments/{id}/leaderboard` i `app/routers/tournaments.py` — summerer + poeng på tvers av ALLE økter/matcher i turneringen, både total og + per-økt-delsum. + **Reelt korrekthetshull funnet OG designet rundt FØR koden ble skrevet, + ikke oppdaget i ettertid:** `match.team_a_id`/`team_b_id` settes PER + MATCH ved opprettelse, ikke garantert konsistent på tvers av matcher — + en organisator kunne i prinsippet opprettet match 1 med team_a=Rød og + match 2 med team_a=Blå. En naiv summering av `points_side_a`/`points_ + side_b` ville da blandet sammen poeng fra to ULIKE fysiske lag. Løst ved + å ALDRI summere på "a"/"b"-labelen — kun på det ekte lag-id-et + (`points_by_team: dict[team_id, float]`, både i totalen og per økt). + **Verifisert presist, ikke bare "kjørte uten feil":** bygget et scratch- + scenario med 3 matcher over 2 økter der match 2 sin team_a/team_b + BEVISST var byttet om i forhold til de to andre — satte poeng direkte + (Rød vinner alle tre, ett delt) og bekreftet at leaderboardet likevel ga + riktig total (Rød 2.5, Blå 0.5) og riktig per-økt-delsum, til tross for + swap-en. `test_isolation.sql` 12/12. Ingen migrasjon, kun `teecup_api` + redeployet, `teeoff.no` upåvirket. Frontend-skjermen gjenstår. + +- **Leaderboard-skjermen LIVE (2026-07-18), samme dag:** niende og siste + V0-skjerm i "bygg i rekkefølgen ting brukes"-serien for kamp-play-flyten. + `components/tournament-leaderboard.tsx`, ny rute `/tournaments/[id]/ + leaderboard`. Stort scoreboard-kort med de to lagenes totalpoeng + (lederen fremhevet), pluss en per-økt poeng-fordelingsliste med + fullført/ikke-startet-status. V0 la selv til et tredje faneelement + ("Leaderboard") i BÅDE program- og roster-skjermens fanerad denne + runden — portert inn i de LIVE versjonene av begge (med riktig `org`- + parameter, som V0s eksport som vanlig manglet). + **Ingen nye backend-hull** — forrige rundes leaderboard-endepunkt dekket + akkurat det skjermen trengte. + **Verifisert i scratch:** full datamodell-runde (samme felt-for-felt + som backend-verifiseringen dagen før) OG et eget tomtilstand-scenario + (fersk turnering med 2 lag, 0 økter — `sessions: []`, `matches_total: 0`) + for å bekrefte at "ingen økter opprettet ennå"-meldingen vises riktig + i stedet for å krasje på et tomt array. `test_isolation.sql` 12/12, ekte + typesjekket build. + **Rullet ut live**, ren frontend-endring, `teeoff.no` upåvirket. + **Hele "bygg i rekkefølgen ting brukes"-serien for match-play-flyten er + dermed komplett:** oppsett (lag/roster) → program → blind draw → + scorekort → leaderboard. + Neste steg: -1. Leaderboard-skjermen (siste i "bygg i rekkefølgen ting brukes"-serien - for match-play-flyten). Samme mønster: +1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. +2. Kommunikasjon (chat/feed) — ikke startet. +3. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md (walkover/konsesjon, + sanntid/WebSockets vs. polling, tilskuer-rolle) — bør avklares i egne, + dedikerte runder når det er naturlig. design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver gang — diff mot live-treet i et scratch-område før noe pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 9fd5d37..079b6f7 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -425,14 +425,28 @@ - Datamodellen tillater dette fordi spillere lever på org-nivå og gjenbrukes på tvers av turneringer, og handicap fryses per turnering (reproduserbart). +### Leaderboard (turnering-total) +- **Status:** ✅ HELT FERDIG 2026-07-18 — backend + frontend LIVE + (`components/tournament-leaderboard.tsx`, `/tournaments/[id]/ + leaderboard`). Siste skjerm i "bygg i rekkefølgen ting brukes"-serien + for match-play-flyten (oppsett → program → blind draw → scorekort → + leaderboard, alle live). +- Summerer poeng på tvers av ALLE økter/matcher, total + per-økt-delsum. + **Reelt korrekthetshull designet rundt før bygging:** `match.team_a_id`/ + `team_b_id` er ikke garantert konsistent på tvers av matcher (settes per + match) — summering skjer derfor KUN på ekte lag-id, aldri på "a"/"b"- + labelen. Verifisert med et scratch-scenario med en bevisst byttet om + team_a/team_b i én match — riktig total og per-økt-delsum likevel. + ### Push-varsler - **Status:** 📋 planlagt (infrastruktur) - «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit. ### Sanntid (WebSockets) - **Status:** ❓ trenger beslutning -- Live leaderboard og chat som oppdateres uten refresh. Vi har leaderboard-viewet, - men ikke sanntidsleveringen. Valg: WebSockets vs. polling. +- Live leaderboard og chat som oppdateres uten refresh. Vi har leaderboard- + backend-endepunktet (se egen seksjon over), men ikke sanntidsleveringen + eller frontend-skjermen ennå. Valg: WebSockets vs. polling. ### Knockout / cup-turnering (egen turneringstype) - **Status:** 💤 utsatt (egen fremtidig type, ADR-011) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index db843a8..28e5f41 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -701,3 +701,112 @@ async def delete_sponsor( ) if deleted is None: raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") + + +# --------------------------------------------------------------------------- +# Leaderboard (turnering-total, summert på tvers av alle økter/matcher) +# +# `match.team_a_id`/`team_b_id` settes PER MATCH ved opprettelse (matches.py), +# ikke garantert konsistent på tvers av matcher -- en organisator kunne i +# prinsippet opprettet match 1 med team_a=Rød og match 3 med team_a=Blå. Derfor +# summeres ALT (både total og per-økt) på det EKTE lag-id-et, aldri på +# "a"/"b"-labelen -- se points_by_team under. +# --------------------------------------------------------------------------- + +class TeamStanding(BaseModel): + team_id: str + team_name: str + color: str | None + points: float + + +class SessionStanding(BaseModel): + session_id: str + sequence: int + name: str | None + points_by_team: dict[str, float] + matches_total: int + matches_decided: int + + +class Leaderboard(BaseModel): + teams: list[TeamStanding] + matches_total: int + matches_decided: int + sessions: list[SessionStanding] + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/leaderboard", + response_model=Leaderboard, +) +async def get_leaderboard( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> Leaderboard: + async with org_connection(organization_id) as conn: + teams = await conn.fetch( + "SELECT id::text, name, color FROM team WHERE tournament_id = $1 ORDER BY created_at", + tournament_id, + ) + if len(teams) != 2: + raise app_error(400, "VALIDATION_FAILED", "Turneringen har ikke nøyaktig to lag ennå.") + + rows = await conn.fetch( + """ + SELECT s.id::text AS session_id, s.sequence, s.name, + m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id, + m.points_side_a::float AS points_side_a, m.points_side_b::float AS points_side_b + FROM match m + JOIN session s ON s.id = m.session_id + WHERE s.tournament_id = $1 + ORDER BY s.sequence, m.sequence + """, + tournament_id, + ) + + team_totals: dict[str, float] = {t["id"]: 0.0 for t in teams} + session_order: list[str] = [] + sessions: dict[str, dict] = {} + matches_total = 0 + matches_decided = 0 + + for r in rows: + matches_total += 1 + sid = r["session_id"] + if sid not in sessions: + session_order.append(sid) + sessions[sid] = { + "session_id": sid, + "sequence": r["sequence"], + "name": r["name"], + "points_by_team": {t["id"]: 0.0 for t in teams}, + "matches_total": 0, + "matches_decided": 0, + } + sessions[sid]["matches_total"] += 1 + + if r["points_side_a"] is None: + continue + matches_decided += 1 + sessions[sid]["matches_decided"] += 1 + team_totals[r["team_a_id"]] = team_totals.get(r["team_a_id"], 0.0) + r["points_side_a"] + team_totals[r["team_b_id"]] = team_totals.get(r["team_b_id"], 0.0) + r["points_side_b"] + sessions[sid]["points_by_team"][r["team_a_id"]] = ( + sessions[sid]["points_by_team"].get(r["team_a_id"], 0.0) + r["points_side_a"] + ) + sessions[sid]["points_by_team"][r["team_b_id"]] = ( + sessions[sid]["points_by_team"].get(r["team_b_id"], 0.0) + r["points_side_b"] + ) + + team_standings = [ + TeamStanding(team_id=t["id"], team_name=t["name"], color=t["color"], points=team_totals[t["id"]]) + for t in teams + ] + + return Leaderboard( + teams=team_standings, + matches_total=matches_total, + matches_decided=matches_decided, + sessions=[SessionStanding(**sessions[sid]) for sid in sorted(session_order, key=lambda s: sessions[s]["sequence"])], + ) diff --git a/frontend/app/tournaments/[id]/leaderboard/page.tsx b/frontend/app/tournaments/[id]/leaderboard/page.tsx new file mode 100644 index 0000000..0092620 --- /dev/null +++ b/frontend/app/tournaments/[id]/leaderboard/page.tsx @@ -0,0 +1,29 @@ +import { TournamentLeaderboard } from "@/components/tournament-leaderboard" + +export default async function TournamentLeaderboardPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; name?: string }> +}) { + const { id } = await params + const { org, name } = await searchParams + const tournamentName = name?.trim() || "Turnering" + + // Samme grunn som de andre turnering-undersidene: API-et krever + // organization_id på alle turnering-endepunkter (RLS, se ADR-001/003). + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index 2448fff..24c0979 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -258,6 +258,12 @@ export function TournamentDetail({ > Program + + Leaderboard + diff --git a/frontend/components/tournament-leaderboard.tsx b/frontend/components/tournament-leaderboard.tsx new file mode 100644 index 0000000..fee8744 --- /dev/null +++ b/frontend/components/tournament-leaderboard.tsx @@ -0,0 +1,312 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { ArrowLeft, Check, Circle, Trophy } from "lucide-react" +import { cn } from "@/lib/utils" + +// --- Types (matcher API-kontrakten i app/routers/tournaments.py sin leaderboard-endepunkt) + +type ApiTeamStanding = { + team_id: string + team_name: string + color: string | null + points: number +} + +type ApiSessionStanding = { + session_id: string + sequence: number + name: string | null + points_by_team: Record + matches_total: number + matches_decided: number +} + +type ApiLeaderboard = { + teams: [ApiTeamStanding, ApiTeamStanding] + matches_total: number + matches_decided: number + sessions: ApiSessionStanding[] +} + +// --- Formatting helpers ---------------------------------------------------- + +function formatPoints(value: number): string { + return value.toLocaleString("nb-NO", { maximumFractionDigits: 1 }) +} + +function sessionTitle(s: ApiSessionStanding): string { + return s.name?.trim() || `Økt ${s.sequence}` +} + +// --- Component ------------------------------------------------------------- + +export function TournamentLeaderboard({ + organizationId, + tournamentId, + tournamentName, +}: { + organizationId: string + tournamentId: string + tournamentName: string +}) { + const [board, setBoard] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/leaderboard`, { + credentials: "include", + }) + if (!res.ok) throw new Error(`load failed: ${res.status}`) + const data: ApiLeaderboard = await res.json() + if (cancelled) return + setBoard({ ...data, sessions: [...data.sessions].sort((a, b) => a.sequence - b.sequence) }) + } catch { + if (!cancelled) setError("Klarte ikke å laste leaderboardet. Prøv å laste siden på nytt.") + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { + cancelled = true + } + }, [organizationId, tournamentId]) + + const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + + return ( +
+
+
+ +
+ + +
+ +
+
+

Stilling

+

+ Totalt antall poeng summert på tvers av alle økter, oppdatert etter hvert som matcher + avgjøres. +

+
+ + {error && ( +

+ {error} +

+ )} + + {loading || !board ? ( +
+ + ) : ( + <> + {(() => { + const [teamA, teamB] = board.teams + const leaderId = + teamA.points === teamB.points ? null : teamA.points > teamB.points ? teamA.team_id : teamB.team_id + return ( + <> + +

+ {board.matches_decided} av {board.matches_total} matcher avgjort +

+
+

Poeng per økt

+ {board.sessions.length === 0 ? ( +

Ingen økter opprettet ennå.

+ ) : ( + board.sessions.map((s) => ( + + )) + )} +
+ + ) + })()} + + )} +
+
+ ) +} + +// --- Scoreboard ------------------------------------------------------------ + +function Scoreboard({ + teamA, + teamB, + leaderId, +}: { + teamA: ApiTeamStanding + teamB: ApiTeamStanding + leaderId: string | null +}) { + return ( +
+ +
+ vs +
+ +
+ ) +} + +function TeamScore({ + team, + isLeader, + align, +}: { + team: ApiTeamStanding + isLeader: boolean + align: "left" | "right" +}) { + const color = team.color ?? "#64748b" + return ( +
+
+
+ + {isLeader && ( + + + )} + + + {formatPoints(team.points)} + + poeng +
+ ) +} + +// --- Session row ----------------------------------------------------------- + +function SessionRow({ + session, + teamA, + teamB, +}: { + session: ApiSessionStanding + teamA: ApiTeamStanding + teamB: ApiTeamStanding +}) { + const notStarted = session.matches_decided === 0 + const complete = session.matches_decided === session.matches_total + const aPts = session.points_by_team[teamA.team_id] ?? 0 + const bPts = session.points_by_team[teamB.team_id] ?? 0 + const colorA = teamA.color ?? "#64748b" + const colorB = teamB.color ?? "#64748b" + + return ( +
+
+

{sessionTitle(session)}

+ {complete ? ( + + + ) : notStarted ? ( + + + ) : ( + + {session.matches_decided} av {session.matches_total} matcher avgjort + + )} +
+ +
+
+
+ +
+ + {formatPoints(aPts)} + + + + {formatPoints(bPts)} + +
+ +
+ {teamB.team_name} +
+
+
+ ) +} diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index 0652699..2788f27 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -198,6 +198,7 @@ export function TournamentProgram({ const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` + const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` return (
@@ -234,6 +235,12 @@ export function TournamentProgram({ > Program + + Leaderboard +