teecup/frontend/components/flight-group-leaderboard.tsx

276 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Samlet leaderboard på tvers av flere flighter satt opp SAMMEN i én
// handling (migrasjon 035, retning 1 -- se FEATURE_BACKLOG.md/
// ARCHITECTURE_DECISIONS.md "Frittstående runder: flere flighter i én
// 'vanlig' runde"). Egen, lettere lokal variant av round-leaderboard.tsx
// sitt rangerings-/mode-toggle-mønster (samme "liten lokal duplisering
// fremfor cross-file-import"-konvensjon som resten av den filen) -- denne
// siden slår sammen FLERE flighters egne leaderboard til én rangert liste,
// tagget med hvilken flight hver rad kom fra. Ingen egen hull-for-hull-
// detalj her (den finnes allerede på hver enkelt flights EGET leaderboard,
// lenket til fra hver rad) -- denne siden er en oversikt, ikke en duplikat.
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
type ApiFlightSummary = {
round_id: string
name: string | null
course_name_snapshot: string
played_at: string
started_at: string | null
completed_at: string | null
participant_count: number
is_anchor: boolean
}
type ApiEntry = {
round_id: string
flight_label: string
participant_id: string
display_name: string
is_owner: boolean
holes_played: number
total_score: number | null
score_to_par: number | null
net_score_to_par: number | null
total_points: number | null
}
type ApiFlightGroupLeaderboard = {
flight_group_id: string | null
flights: ApiFlightSummary[]
entries: ApiEntry[]
}
type Mode = "gross" | "net" | "points"
function valueFor(entry: ApiEntry, mode: Mode): number | null {
if (mode === "gross") return entry.score_to_par
if (mode === "net") return entry.net_score_to_par
return entry.total_points
}
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied })
}
for (const e of withoutValue) ranked.push({ ...e, rank: null, tied: false })
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
if (mode === "points") {
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
)}
>
{value}
</span>
)
}
if (value === 0) {
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
}
const under = value < 0
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
under ? "rounded-full" : "rounded-[6px]",
emphasize
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary bg-primary/10 text-primary"
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{formatToPar(value)}
</span>
)
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
aria-hidden="true"
>
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : ""}
</span>
)
}
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const options: { value: Mode; label: string }[] = [
{ value: "gross", label: "Brutto" },
{ value: "net", label: "Netto" },
{ value: "points", label: "Poeng" },
]
return (
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={mode === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
)
}
export function FlightGroupLeaderboard({ roundId }: { roundId: string }) {
const [data, setData] = useState<ApiFlightGroupLeaderboard | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>("gross")
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/flight-group/leaderboard`, { credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error(String(res.status))
return res.json()
})
.then((json: ApiFlightGroupLeaderboard) => {
if (!cancelled) setData(json)
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente det samlede leaderboardet.")
})
return () => {
cancelled = true
}
}, [roundId])
if (error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href={`/my-rounds/${roundId}`} className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til runden
</Link>
</div>
)
}
if (!data) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
const ranked = rankEntries(data.entries, mode)
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Samlet leaderboard
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-2 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{data.flights.length} flighter satt opp sammen
</span>
<ul className="flex flex-col gap-1">
{data.flights.map((f) => (
<li key={f.round_id} className="text-sm text-muted-foreground">
{f.name || f.course_name_snapshot} · {f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
{f.completed_at ? " · Ferdig" : ""}
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry) => {
const value = valueFor(entry, mode)
const isLeader = entry.rank === 1
return (
<li key={`${entry.round_id}-${entry.participant_id}`}>
<Link
href={`/my-rounds/${entry.round_id}/leaderboard`}
className="flex w-full items-center gap-3 p-3 text-left transition-colors hover:bg-accent/30 sm:p-4"
>
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span className="min-w-0 flex-1">
<span className="block truncate text-base font-bold text-foreground">{entry.display_name}</span>
<span className="block truncate text-xs font-semibold text-muted-foreground">{entry.flight_label}</span>
</span>
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
<ArrowRight aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</Link>
</li>
)
})}
</ul>
{ranked.length === 0 && (
<p className="text-sm text-muted-foreground">Ingen registrerte spillere i noen av flightene ennå.</p>
)}
</main>
</div>
)
}