teecup/frontend/components/course-rounds.tsx

204 lines
7.3 KiB
TypeScript
Raw Normal View History

"use client"
// Bane-detaljvisning (2026-07-28): "Spilte baner" på dashbordet lenker hit --
// alle egne runder spilt på én gitt bane. Ingen nytt backend-endepunkt --
// samme GET /rounds som resten av "Egne runder"-flyten, filtrert client-side
// på course_name_snapshot (samme nøkkel dashbordets "Spilte baner"-gruppering
// allerede bruker, se dashboard.tsx). "Personlig rekord"-merket regnes likevel
// ALLTID over hele rundelisten (ikke bare denne banen), for at merket skal
// bety det samme uansett hvor et rundekort vises.
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Loader2, MapPin, ArrowLeft } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { RoundCard, type Round } from "@/components/round-card"
type ApiRoundParticipant = {
id: string
user_id: string | null
is_owner: boolean
guest_name: string | null
counts_for_handicap: boolean
score_differential: number | null
}
type ApiRound = {
id: string
name: string | null
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
holes_planned: number
completed_at: string | null
participants: ApiRoundParticipant[]
my_holes_played: number
my_total_score: number | null
my_score_to_par: number | null
my_net_score_to_par: number | null
play_format: string
my_match_status: string | null
my_match_lead: number | null
}
function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round {
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
return {
id: r.id,
name: r.name,
courseName: r.course_name_snapshot,
status: r.completed_at ? "completed" : "active",
teeName: r.tee_name_snapshot,
holes: r.holes_planned === 9 ? 9 : 18,
date: r.played_at,
playerCount: r.participants.length,
holesPlayed: r.my_holes_played,
totalScore: r.my_total_score ?? undefined,
toPar: r.my_score_to_par ?? undefined,
netToPar: r.my_net_score_to_par ?? undefined,
playFormat: r.play_format,
matchStatus: r.my_match_status,
matchLead: r.my_match_lead,
differential: me?.counts_for_handicap ? me.score_differential : null,
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
}
}
function findPersonalBestRoundId(rounds: ApiRound[]): string | null {
const completed = rounds.filter((r) => r.completed_at !== null && r.my_score_to_par !== null)
if (completed.length < 2) return null
let best: { id: string; toPar: number } | null = null
for (const r of completed) {
const toPar = r.my_score_to_par as number
if (!best || toPar < best.toPar) best = { id: r.id, toPar }
}
return best?.id ?? null
}
export function CourseRounds({ courseName }: { courseName: string }) {
const router = useRouter()
const [rounds, setRounds] = useState<Round[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch("/rounds", { credentials: "include" })
if (res.status === 401) {
router.replace("/logg-inn")
return
}
if (!res.ok) throw new Error(`rounds: ${res.status}`)
const data: ApiRound[] = await res.json()
const personalBestRoundId = findPersonalBestRoundId(data)
const forCourse = data
.filter((r) => r.course_name_snapshot === courseName)
.map((r) => toRound(r, viewerId, personalBestRoundId))
if (!cancelled) setRounds(forCourse)
} catch {
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
}
}
void load()
return () => {
cancelled = true
}
}, [router, viewerId, courseName])
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-3xl items-center justify-between gap-4 px-5 py-4">
<Link
href="/dashboard"
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl px-2 py-2 text-base font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ArrowLeft aria-hidden="true" className="size-5" />
Til dashbord
</Link>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
<div className="flex flex-col gap-2">
<h1 className="flex items-center gap-2 text-3xl font-extrabold tracking-tight text-foreground text-balance">
<MapPin aria-hidden="true" className="size-7 shrink-0 text-primary" />
<span className="truncate">{courseName}</span>
</h1>
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
{rounds === null ? "Runder spilt på denne banen." : `${rounds.length} ${rounds.length === 1 ? "runde" : "runder"} spilt på denne banen.`}
</p>
</div>
{error && (
<p role="alert" className="mt-4 text-base font-medium text-destructive">
{error}
</p>
)}
<div className="mt-8">
{rounds === null ? (
<LoadingState />
) : rounds.length === 0 ? (
<EmptyState />
) : (
<ul className="flex flex-col gap-4">
{rounds.map((round) => (
<li key={round.id}>
<RoundCard round={round} />
</li>
))}
</ul>
)}
</div>
</main>
</div>
)
}
function LoadingState() {
return (
<div
role="status"
aria-live="polite"
className="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<Loader2 aria-hidden="true" className="size-9 animate-spin text-primary" />
<span className="text-base font-medium text-muted-foreground">Laster runder</span>
</div>
)
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
2026-08-02 16:34:54 +02:00
<div className="flex size-16 items-center justify-center rounded-2xl bg-muted">
<MapPin aria-hidden="true" className="size-8 text-muted-foreground" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="text-xl font-bold text-foreground">Ingen runder funnet</h2>
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
Fant ingen runder registrert denne banen.
</p>
</div>
</div>
)
}