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:
parent
21196c9cfd
commit
d51c0153bb
8 changed files with 531 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
49
CLAUDE.md
49
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])],
|
||||
)
|
||||
|
|
|
|||
29
frontend/app/tournaments/[id]/leaderboard/page.tsx
Normal file
29
frontend/app/tournaments/[id]/leaderboard/page.tsx
Normal 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. Gå tilbake til dashbordet og prøv igjen.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TournamentLeaderboard organizationId={org} tournamentId={id} tournamentName={tournamentName} />
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
312
frontend/components/tournament-leaderboard.tsx
Normal file
312
frontend/components/tournament-leaderboard.tsx
Normal 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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue