276 lines
9.9 KiB
TypeScript
276 lines
9.9 KiB
TypeScript
"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>
|
||
)
|
||
}
|