Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py
Build /rounds list page + component
Build /rounds/new creation flow (course search/create, tee, date, holes)
Build /rounds/[id] detail page (participants, hole-by-hole stats, complete)
Link new feature from dashboard
Scratch-verify backend additions + typecheck production build
Deploy live after user confirmation
Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status
Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]).
What's new, in summary:
Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched.
Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card.
One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
162 lines
6.5 KiB
TypeScript
162 lines
6.5 KiB
TypeScript
"use client"
|
|
|
|
// Frittstående rundeføring (ADR-033) -- runder eid direkte av en BRUKER
|
|
// (app_user), ikke en organisasjon. Bevisst adskilt navn fra dashbordets
|
|
// "Mine runder" (turnering-deltakelse, se MyToursSection i dashboard.tsx)
|
|
// for å unngå forveksling -- denne funksjonen kalles "Egne runder" overalt
|
|
// i UI-et.
|
|
|
|
import { useEffect, useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter } from "next/navigation"
|
|
import { ArrowLeft, Calendar, ChevronRight, Flag, Plus } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Wordmark } from "@/components/wordmark"
|
|
|
|
type ApiRoundParticipant = {
|
|
id: string
|
|
is_owner: boolean
|
|
guest_name: string | null
|
|
}
|
|
|
|
type ApiRound = {
|
|
id: string
|
|
course_name_snapshot: string
|
|
tee_name_snapshot: string
|
|
played_at: string
|
|
holes_planned: number
|
|
completed_at: string | null
|
|
participants: ApiRoundParticipant[]
|
|
}
|
|
|
|
export function PersonalRounds() {
|
|
const router = useRouter()
|
|
const [rounds, setRounds] = useState<ApiRound[] | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
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()
|
|
if (!cancelled) setRounds(data)
|
|
} catch {
|
|
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
|
}
|
|
}
|
|
void load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [router])
|
|
|
|
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 items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
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="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-2xl font-extrabold tracking-tight text-foreground">Egne runder</h1>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Registrer en runde på egen hånd, med eller uten turnering, og få detaljert statistikk.
|
|
</p>
|
|
</div>
|
|
<Link href="/rounds/new">
|
|
<Button className="h-12 rounded-2xl text-base font-bold shadow-sm">
|
|
<Plus aria-hidden="true" className="size-5" />
|
|
Ny runde
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{rounds === null ? (
|
|
<div className="flex justify-center py-12">
|
|
<div
|
|
aria-hidden="true"
|
|
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
/>
|
|
</div>
|
|
) : rounds.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
<Flag aria-hidden="true" className="size-7 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="text-base font-bold text-foreground text-balance">Ingen runder registrert ennå</h2>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Trykk på «Ny runde» for å registrere den første.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-3">
|
|
{rounds.map((r) => (
|
|
<li key={r.id}>
|
|
<Link
|
|
href={`/rounds/${r.id}`}
|
|
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 sm:p-5"
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<h3 className="truncate text-base font-bold text-foreground">{r.course_name_snapshot}</h3>
|
|
{r.completed_at ? (
|
|
<span className="inline-flex items-center rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-bold text-primary">
|
|
Fullført
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs font-bold text-muted-foreground">
|
|
Pågår
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="truncate text-sm text-muted-foreground">
|
|
{r.tee_name_snapshot} · {r.holes_planned} hull · {formatDate(r.played_at)}
|
|
</span>
|
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
|
{r.participants.length} {r.participants.length === 1 ? "spiller" : "spillere"}
|
|
</div>
|
|
</div>
|
|
<ChevronRight
|
|
aria-hidden="true"
|
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
|
/>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
const date = new Date(iso)
|
|
if (Number.isNaN(date.getTime())) return iso
|
|
return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date)
|
|
}
|