Update Todos

Diffe zip 8 mot live-treet

Integrere tournament-leaderboard.tsx mot ekte API

Legge til Leaderboard-fane i program- og roster-skjermen

Ekte typesjekket frontend-build

Rulle ut live

Oppdatere .md-filer + slette zip 8
Leaderboard-skjermen er live, og hele "bygg i rekkefølgen ting brukes"-serien er komplett: oppsett (lag/roster) → program → blind draw → scorekort → leaderboard, alle live og verifisert. Leaderboard-fanen er nå med i alle turnering-skjermenes fanerad. .md-filene er oppdatert, zip 8 slettet.

Gjenstående i backloggen, ingen haster spesielt:

PWA (manifest, service worker, offline-cache) — ikke startet
Kommunikasjon (lag-chat/offentlig feed) — ikke startet
Noen åpne beslutninger (walkover/konsesjon, sanntid vs. polling, tilskuer-rolle) som venter på egne avklaringsrunder
This commit is contained in:
Erol Haagenrud 2026-07-18 18:22:10 +02:00
parent 21196c9cfd
commit d51c0153bb
8 changed files with 531 additions and 5 deletions

View file

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

View file

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

View file

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

View file

@ -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"])],
)

View file

@ -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 (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<TournamentLeaderboard organizationId={org} tournamentId={id} tournamentName={tournamentName} />
)
}

View file

@ -258,6 +258,12 @@ export function TournamentDetail({
>
Program
</Link>
<Link
href={`/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Leaderboard
</Link>
</nav>
</header>

View file

@ -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<string, number>
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<ApiLeaderboard | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
<Link
href={detailHref}
aria-label="Tilbake til turnering"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
<Link
href={detailHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Lag og spillere
</Link>
<Link
href={programHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Program
</Link>
<span
aria-current="page"
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
>
Leaderboard
</span>
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Totalt antall poeng summert tvers av alle økter, oppdatert etter hvert som matcher
avgjøres.
</p>
</div>
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{loading || !board ? (
<div className="flex justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : (
<>
{(() => {
const [teamA, teamB] = board.teams
const leaderId =
teamA.points === teamB.points ? null : teamA.points > teamB.points ? teamA.team_id : teamB.team_id
return (
<>
<Scoreboard teamA={teamA} teamB={teamB} leaderId={leaderId} />
<p className="mt-3 text-center text-sm font-medium text-muted-foreground">
{board.matches_decided} av {board.matches_total} matcher avgjort
</p>
<div className="mt-8 flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Poeng per økt</h3>
{board.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen økter opprettet ennå.</p>
) : (
board.sessions.map((s) => (
<SessionRow key={s.session_id} session={s} teamA={teamA} teamB={teamB} />
))
)}
</div>
</>
)
})()}
</>
)}
</main>
</div>
)
}
// --- Scoreboard ------------------------------------------------------------
function Scoreboard({
teamA,
teamB,
leaderId,
}: {
teamA: ApiTeamStanding
teamB: ApiTeamStanding
leaderId: string | null
}) {
return (
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5">
<TeamScore team={teamA} isLeader={leaderId === teamA.team_id} align="left" />
<div className="flex items-center justify-center bg-border/40 px-2">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">vs</span>
</div>
<TeamScore team={teamB} isLeader={leaderId === teamB.team_id} align="right" />
</div>
)
}
function TeamScore({
team,
isLeader,
align,
}: {
team: ApiTeamStanding
isLeader: boolean
align: "left" | "right"
}) {
const color = team.color ?? "#64748b"
return (
<div
className={cn(
"relative flex flex-col gap-2 p-5 sm:p-6",
align === "right" ? "items-end text-right" : "items-start text-left",
)}
style={{
borderTop: `4px solid ${color}`,
backgroundColor: isLeader ? `${color}14` : undefined,
}}
>
<div className={cn("flex items-center gap-2", align === "right" && "flex-row-reverse")}>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="truncate text-sm font-bold text-foreground sm:text-base">{team.team_name}</span>
</div>
{isLeader && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-bold text-foreground">
<Trophy aria-hidden="true" className="size-3 fill-primary text-primary" />
Leder
</span>
)}
<span
className={cn(
"font-extrabold tabular-nums leading-none tracking-tight",
isLeader ? "text-6xl sm:text-7xl" : "text-5xl sm:text-6xl text-foreground/80",
)}
style={isLeader ? { color } : undefined}
>
{formatPoints(team.points)}
</span>
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">poeng</span>
</div>
)
}
// --- 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 (
<div className="rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
<div className="flex items-center justify-between gap-3">
<h4 className="text-base font-bold text-foreground">{sessionTitle(session)}</h4>
{complete ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2.5 py-1 text-xs font-semibold text-foreground">
<Check aria-hidden="true" className="size-3.5 text-primary" />
Fullført
</span>
) : notStarted ? (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-1 text-xs font-medium text-muted-foreground">
<Circle aria-hidden="true" className="size-3" />
Ikke startet ennå
</span>
) : (
<span className="text-xs font-medium text-muted-foreground">
{session.matches_decided} av {session.matches_total} matcher avgjort
</span>
)}
</div>
<div className="mt-3 flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: colorA }} />
<span className="truncate text-sm font-medium text-foreground">{teamA.team_name}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5 tabular-nums">
<span className={cn("text-lg font-extrabold", notStarted ? "text-muted-foreground" : "text-foreground")}>
{formatPoints(aPts)}
</span>
<span className="text-sm font-semibold text-muted-foreground"></span>
<span className={cn("text-lg font-extrabold", notStarted ? "text-muted-foreground" : "text-foreground")}>
{formatPoints(bPts)}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<span className="truncate text-right text-sm font-medium text-foreground">{teamB.team_name}</span>
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: colorB }} />
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="flex min-h-[100dvh] flex-col bg-background">
@ -234,6 +235,12 @@ export function TournamentProgram({
>
Program
</Link>
<Link
href={leaderboardHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Leaderboard
</Link>
</nav>
</header>