teecup/frontend/components/own-rounds.tsx
Erol Haagenrud 30c114a2de Rundeboksen på dashboardet (og på "Egne runder"/"Spilte baner"):
For match-formater viser den nå spillerens eget resultat, viewer-relativt — "1 UP"/"AS"/"Vunnet 2&1" osv. (aldri en generisk side A/B-tekst), farget grønt ved ledelse og oransje ved etterslep. Vises nå både mens runden pågår og etter fullføring, ikke bare etterpå som før.
For vanlig slagspill viser den fortsatt brutto "Til par", men har nå fått et eget "Netto"-felt ved siden av når HCP faktisk spores.
Scorekortet (både match- og slagspill-formater, individuell- og delt-ball-grid): en liten blå "−N" vises nå inni den tomme ruten når spilleren/siden faktisk mottar et slag på det hullet — synlig før du fyller ut noe, ikke bare som netto-tall etterpå.

Verifisert grundig mot en isolert scratch-backend (både API-svar og full nettleser-gjennomgang i lys/mørk modus, match- og foursome-runder), ingen konsollfeil, ingen migrasjon nødvendig.
2026-07-29 15:46:49 +02:00

215 lines
7.6 KiB
TypeScript

"use client"
// Frittstående rundeføring (ADR-033) -- runder eid direkte av en BRUKER
// (app_user), ikke en organisasjon. Presentasjon fra V0, datalag skrevet om
// fra mock til ekte fetch mot /rounds (samme mønster som resten av appen).
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Loader2, Plus, ClipboardList } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Wordmark } from "@/components/wordmark"
import { RoundCard, type Round } from "@/components/round-card"
import { ArrowLeft } from "lucide-react"
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
}
// viewerId er null helt til /auth/me har svart -- faller da tilbake til
// eierens rad (samme oppførsel som før ADR-036 fase 3-utvidelsen).
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)
const differential = me?.counts_for_handicap ? me.score_differential : null
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,
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
}
}
// Samme regel som dashboardet (2026-07-28): laveste til-par blant minst to
// fullførte runder -- én enkelt fullført runde er ikke ennå en "rekord".
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 OwnRounds() {
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("/")
return
}
if (!res.ok) throw new Error(`rounds: ${res.status}`)
const data: ApiRound[] = await res.json()
const personalBestRoundId = findPersonalBestRoundId(data)
if (!cancelled) setRounds(data.map((r) => toRound(r, viewerId, personalBestRoundId)))
} catch {
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
}
}
void load()
return () => {
cancelled = true
}
}, [router, viewerId])
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 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
Egne runder
</h1>
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
Frittstående golfrunder du har registrert selv, uavhengig av turnering og klubb.
</p>
</div>
<Button
render={<Link href="/my-rounds/new" />}
size="lg"
className="h-12 shrink-0 gap-2 rounded-2xl px-6 text-base font-bold shadow-sm"
>
<Plus aria-hidden="true" className="size-5" />
Ny runde
</Button>
</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">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<ClipboardList aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex max-w-md flex-col gap-2">
<h2 className="text-xl font-bold text-foreground">Ingen runder ennå</h2>
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
Her dukker rundene dine opp etter hvert som du registrerer dem. Trykk {" "}
<span className="font-semibold text-foreground">«Ny runde»</span> øverst for å komme i
gang.
</p>
</div>
</div>
)
}