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.
215 lines
7.6 KiB
TypeScript
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 på{" "}
|
|
<span className="font-semibold text-foreground">«Ny runde»</span> øverst for å komme i
|
|
gang.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|