diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 170aaa0..89e3dd0 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -350,7 +350,12 @@
"Bash(sudo -n apt-get install -y poppler-utils)",
"Bash(python3 -m pytest test_handicap_engine.py -q)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")",
- "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)"
+ "Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)",
+ "Bash(docker run -d --name teecup-minio-scratch --network teeoff_default \\\\ *)",
+ "Bash(docker run -d --name teecup_api_scratch --network teeoff_default \\\\ *)",
+ "Bash(docker rm -f teecup_api_scratch >/dev/null 2>&1 *)",
+ "Bash(python3 test_rounds_frontend_endpoints.py)",
+ "Bash(TEECUP_API_ORIGIN=http://localhost:8000 npx next build)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",
diff --git a/app/routers/rounds.py b/app/routers/rounds.py
index f5087ff..1658db3 100644
--- a/app/routers/rounds.py
+++ b/app/routers/rounds.py
@@ -80,6 +80,17 @@ class PersonalCourseOut(BaseModel):
name: str
+class TeeOption(BaseModel):
+ name: str
+ genders: list[Literal["m", "f"]]
+
+
+class PersonalCourseDetail(BaseModel):
+ id: str
+ name: str
+ tees: list[TeeOption]
+
+
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
async def search_personal_courses(
q: str = "",
@@ -93,6 +104,35 @@ async def search_personal_courses(
return [PersonalCourseOut(**dict(r)) for r in rows]
+@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail)
+async def get_personal_course(
+ personal_course_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> PersonalCourseDetail:
+ async with plain_connection() as conn:
+ course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id)
+ if course_row is None:
+ raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
+ tee_rows = await conn.fetch(
+ """
+ SELECT t.name AS tee_name, r.gender
+ FROM personal_course_tee t
+ JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
+ WHERE t.personal_course_id = $1
+ ORDER BY t.name
+ """,
+ personal_course_id,
+ )
+ tees: dict[str, list[str]] = {}
+ for r in tee_rows:
+ tees.setdefault(r["tee_name"], []).append(r["gender"])
+ return PersonalCourseDetail(
+ id=course_row["id"],
+ name=course_row["name"],
+ tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
+ )
+
+
@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
async def create_personal_course(
body: PersonalCourseCreate,
@@ -140,6 +180,83 @@ async def create_personal_course(
return PersonalCourseOut(**dict(course_row))
+# ---------------------------------------------------------------------------
+# Offisiell bane fra teeoff (ADR-019/ADR-033 Beslutning C) -- IKKE org-scopet,
+# ulikt courses.py sine tilsvarende endepunkter (frittstående runder har
+# ingen organisasjon). Rent lese-søk, ingen import/persistering av banen.
+# ---------------------------------------------------------------------------
+
+class OfficialFacility(BaseModel):
+ slug: str
+ name: str
+ city: str | None
+ county: str | None
+
+
+class OfficialCourseOption(BaseModel):
+ teeoff_course_id: int
+ name: str
+ is_main_course: bool
+ tees: list[TeeOption]
+
+
+class OfficialFacilityDetail(BaseModel):
+ slug: str
+ name: str
+ courses: list[OfficialCourseOption]
+
+
+@router.get("/rounds/official-search", response_model=list[OfficialFacility])
+async def search_official_courses_for_round(
+ q: str = "",
+ user: CurrentUser = Depends(get_current_user),
+) -> list[OfficialFacility]:
+ try:
+ facilities = await teeoff_client.search_facilities(q)
+ except teeoff_client.TeeoffUnavailableError:
+ raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra teeoff akkurat nå.")
+ return [
+ OfficialFacility(slug=f["slug"], name=f["name"], city=f.get("city"), county=f.get("county"))
+ for f in facilities
+ ]
+
+
+@router.get("/rounds/official-search/{slug}", response_model=OfficialFacilityDetail)
+async def get_official_facility_for_round(
+ slug: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> OfficialFacilityDetail:
+ try:
+ facility = await teeoff_client.get_facility(slug)
+ except teeoff_client.TeeoffNotFoundError:
+ raise app_error(404, "NOT_FOUND", "Anlegget finnes ikke i teeoff.")
+ except teeoff_client.TeeoffUnavailableError:
+ raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra teeoff akkurat nå.")
+
+ courses: list[OfficialCourseOption] = []
+ for c in facility.get("courses", []):
+ if len(c.get("holes") or []) != 18:
+ continue
+ tees: list[TeeOption] = []
+ for t in c.get("tees") or []:
+ genders: list[str] = []
+ if t.get("cr_men") is not None and t.get("slope_men") is not None:
+ genders.append("m")
+ if t.get("cr_women") is not None and t.get("slope_women") is not None:
+ genders.append("f")
+ if genders:
+ tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
+ courses.append(
+ OfficialCourseOption(
+ teeoff_course_id=c["id"],
+ name=c["name"],
+ is_main_course=bool(c.get("is_main_course")),
+ tees=tees,
+ )
+ )
+ return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)
+
+
# ---------------------------------------------------------------------------
# Banedata-oppslag (delt mellom opprett-runde og legg-til-deltaker)
# ---------------------------------------------------------------------------
@@ -512,6 +629,51 @@ async def remove_guest_participant(
# Hull-for-hull-registrering
# ---------------------------------------------------------------------------
+class RoundHoleOut(BaseModel):
+ hole_number: int
+ par: int
+ stroke_index: int
+ played: bool
+ score: int | None
+ putts: int | None
+ club_off_tee: str | None
+ tee_shot_result: str | None
+ approach_result: str | None
+ chip_count: int | None
+ bunker_shot_count: int | None
+ penalty_strokes: int | None
+ first_putt_distance_m: float | None
+
+
+@router.get(
+ "/rounds/{round_id}/participants/{participant_id}/holes",
+ response_model=list[RoundHoleOut],
+)
+async def list_holes(
+ round_id: str,
+ participant_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[RoundHoleOut]:
+ async with plain_connection() as conn:
+ await _get_owned_round_or_404(conn, round_id, user.user_id)
+ owner_check = await conn.fetchval(
+ "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2",
+ participant_id, round_id,
+ )
+ if owner_check is None:
+ raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
+ rows = await conn.fetch(
+ """
+ SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee,
+ tee_shot_result, approach_result, chip_count, bunker_shot_count,
+ penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m
+ FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
+ """,
+ participant_id,
+ )
+ return [RoundHoleOut(**dict(r)) for r in rows]
+
+
class HoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)
@@ -525,14 +687,17 @@ class HoleUpdate(BaseModel):
first_putt_distance_m: float | None = Field(default=None, ge=0)
-@router.patch("/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}")
+@router.patch(
+ "/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}",
+ response_model=RoundHoleOut,
+)
async def update_hole(
round_id: str,
participant_id: str,
hole_number: int,
body: HoleUpdate,
user: CurrentUser = Depends(get_current_user),
-) -> dict:
+) -> RoundHoleOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
owner_check = await conn.fetchval(
@@ -550,7 +715,9 @@ async def update_hole(
tee_shot_result = $7, approach_result = $8, chip_count = $9,
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12
WHERE round_participant_id = $1 AND hole_number = $2
- RETURNING id
+ RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee,
+ tee_shot_result, approach_result, chip_count, bunker_shot_count,
+ penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m
""",
participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee,
@@ -559,7 +726,7 @@ async def update_hole(
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
- return {"ok": True}
+ return RoundHoleOut(**dict(row))
# ---------------------------------------------------------------------------
diff --git a/frontend/app/rounds/[id]/page.tsx b/frontend/app/rounds/[id]/page.tsx
new file mode 100644
index 0000000..bf9ef85
--- /dev/null
+++ b/frontend/app/rounds/[id]/page.tsx
@@ -0,0 +1,6 @@
+import { RoundDetail } from "@/components/round-detail"
+
+export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ return
+}
diff --git a/frontend/app/rounds/new/page.tsx b/frontend/app/rounds/new/page.tsx
new file mode 100644
index 0000000..8c8dccf
--- /dev/null
+++ b/frontend/app/rounds/new/page.tsx
@@ -0,0 +1,5 @@
+import { NewRound } from "@/components/round-new"
+
+export default function NewRoundPage() {
+ return
+}
diff --git a/frontend/app/rounds/page.tsx b/frontend/app/rounds/page.tsx
new file mode 100644
index 0000000..9d7ba8b
--- /dev/null
+++ b/frontend/app/rounds/page.tsx
@@ -0,0 +1,5 @@
+import { PersonalRounds } from "@/components/personal-rounds"
+
+export default function RoundsPage() {
+ return
+}
diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx
index b98eb85..4229c99 100644
--- a/frontend/components/dashboard.tsx
+++ b/frontend/components/dashboard.tsx
@@ -10,6 +10,7 @@ import {
ChevronRight,
ChevronsUpDown,
ClipboardList,
+ Flag,
LogOut,
MessageCircle,
Plus,
@@ -231,6 +232,13 @@ export function Dashboard() {
+
+
+ Egne runder
+
+
+
{hasMyTournaments &&
}
{hasOrg && activeOrg ? (
@@ -279,6 +289,35 @@ export function Dashboard() {
)
}
+// --- "Egne runder" (ADR-033) -------------------------------------------------
+// Frittstående rundeføring med detaljert statistikk -- eid direkte av
+// brukeren (app_user), uavhengig av organisasjon/turnering. Bevisst atskilt
+// navn fra "Mine runder" under (turnering-deltakelse) for å unngå
+// forveksling mellom de to konseptene.
+
+function PersonalRoundsEntry() {
+ return (
+
+
+
+
+
+
Egne runder
+
+ Registrer en runde på egen hånd, med detaljert statistikk -- uavhengig av turnering.
+
+
+
+
+ )
+}
+
// --- "Mine runder" (ADR-031) -----------------------------------------------
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
// Kortets hoveddel lenker til den offentlige turnering-siden. Fra
diff --git a/frontend/components/personal-rounds.tsx b/frontend/components/personal-rounds.tsx
new file mode 100644
index 0000000..c55b087
--- /dev/null
+++ b/frontend/components/personal-rounds.tsx
@@ -0,0 +1,162 @@
+"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
(null)
+ const [error, setError] = useState(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 (
+
+
+
+
+
+
+
Egne runder
+
+ Registrer en runde på egen hånd, med eller uten turnering, og få detaljert statistikk.
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {rounds === null ? (
+
+ ) : rounds.length === 0 ? (
+
+
+
+
+
+
Ingen runder registrert ennå
+
+ Trykk på «Ny runde» for å registrere den første.
+
+
+
+ ) : (
+
+ {rounds.map((r) => (
+ -
+
+
+
+
{r.course_name_snapshot}
+ {r.completed_at ? (
+
+ Fullført
+
+ ) : (
+
+ Pågår
+
+ )}
+
+
+ {r.tee_name_snapshot} · {r.holes_planned} hull · {formatDate(r.played_at)}
+
+
+
+ {r.participants.length} {r.participants.length === 1 ? "spiller" : "spillere"}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+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)
+}
diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx
new file mode 100644
index 0000000..30c563c
--- /dev/null
+++ b/frontend/components/round-detail.tsx
@@ -0,0 +1,774 @@
+"use client"
+
+// Hull-for-hull-registrering for en frittstående runde (ADR-033). Én
+// deltaker om gangen velges via fanene øverst -- hver har sitt eget sett
+// med 18 round_hole-rader (opprettet ved runde-/deltaker-opprettelse).
+// PATCH-endepunktet erstatter ALLE felt på hullet ved hver kall (ikke et
+// ekte delvis-PATCH) -- derfor sendes alltid hele det gjeldende hullet,
+// kun ETT felt endret, aldri bare det isolerte feltet som ble trykket på.
+
+import type React from "react"
+import { useCallback, useEffect, useState } from "react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { ArrowLeft, Check, ChevronLeft, ChevronRight, Plus, Trophy, UserPlus, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Wordmark } from "@/components/wordmark"
+import { cn } from "@/lib/utils"
+
+type Gender = "m" | "f" | "x"
+
+type ApiParticipant = {
+ id: string
+ user_id: string | null
+ guest_name: string | null
+ is_owner: boolean
+ gender: Gender
+ handicap_index_snapshot: number | null
+ course_handicap_snapshot: number | null
+ counts_for_handicap: boolean
+ score_differential: number | null
+}
+
+type ApiRound = {
+ id: string
+ course_source: string
+ course_name_snapshot: string
+ tee_name_snapshot: string
+ played_at: string
+ start_hole: number
+ holes_planned: number
+ completed_at: string | null
+ participants: ApiParticipant[]
+}
+
+type TeeShotResult = "fairway" | "left" | "right"
+type ApproachResult = "hit" | "long" | "short" | "left" | "right"
+
+type ApiHole = {
+ hole_number: number
+ par: number
+ stroke_index: number
+ played: boolean
+ score: number | null
+ putts: number | null
+ club_off_tee: string | null
+ tee_shot_result: TeeShotResult | null
+ approach_result: ApproachResult | null
+ chip_count: number | null
+ bunker_shot_count: number | null
+ penalty_strokes: number | null
+ first_putt_distance_m: number | null
+}
+
+function holeOrder(startHole: number): number[] {
+ return Array.from({ length: 18 }, (_, i) => ((startHole - 1 + i) % 18) + 1)
+}
+
+function participantLabel(p: ApiParticipant): string {
+ return p.is_owner ? "Deg" : p.guest_name ?? "Gjest"
+}
+
+export function RoundDetail({ roundId }: { roundId: string }) {
+ const router = useRouter()
+ const [round, setRound] = useState(null)
+ const [error, setError] = useState(null)
+ const [activeParticipantId, setActiveParticipantId] = useState(null)
+ const [holesByParticipant, setHolesByParticipant] = useState>({})
+ const [currentHole, setCurrentHole] = useState(1)
+ const [showAddGuest, setShowAddGuest] = useState(false)
+ const [completing, setCompleting] = useState(false)
+
+ const loadRound = useCallback(async () => {
+ try {
+ const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
+ if (res.status === 401) {
+ router.replace("/")
+ return
+ }
+ if (res.status === 403 || res.status === 404) {
+ setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
+ return
+ }
+ if (!res.ok) throw new Error(`round: ${res.status}`)
+ const data: ApiRound = await res.json()
+ setRound(data)
+ setActiveParticipantId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
+ setCurrentHole((prev) => prev || data.start_hole)
+ } catch {
+ setError("Klarte ikke å hente runden. Prøv igjen om litt.")
+ }
+ }, [roundId, router])
+
+ useEffect(() => {
+ void loadRound()
+ }, [loadRound])
+
+ const loadHoles = useCallback(
+ async (participantId: string) => {
+ const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
+ if (!res.ok) return
+ const data: ApiHole[] = await res.json()
+ setHolesByParticipant((prev) => ({ ...prev, [participantId]: data }))
+ },
+ [roundId],
+ )
+
+ useEffect(() => {
+ if (activeParticipantId && !holesByParticipant[activeParticipantId]) {
+ void loadHoles(activeParticipantId)
+ }
+ }, [activeParticipantId, holesByParticipant, loadHoles])
+
+ async function handleUpdateHole(patch: Partial) {
+ if (!activeParticipantId || !round) return
+ const holes = holesByParticipant[activeParticipantId]
+ const existing = holes?.find((h) => h.hole_number === currentHole)
+ if (!existing) return
+ const merged: ApiHole = { ...existing, ...patch }
+ const res = await fetch(`/rounds/${roundId}/participants/${activeParticipantId}/holes/${currentHole}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ played: merged.played,
+ score: merged.score,
+ putts: merged.putts,
+ club_off_tee: merged.club_off_tee,
+ tee_shot_result: merged.tee_shot_result,
+ approach_result: merged.approach_result,
+ chip_count: merged.chip_count,
+ bunker_shot_count: merged.bunker_shot_count,
+ penalty_strokes: merged.penalty_strokes,
+ first_putt_distance_m: merged.first_putt_distance_m,
+ }),
+ })
+ if (!res.ok) return
+ const updated: ApiHole = await res.json()
+ setHolesByParticipant((prev) => ({
+ ...prev,
+ [activeParticipantId]: (prev[activeParticipantId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
+ }))
+ }
+
+ async function handleAddGuest(name: string, gender: Gender, hcp: number | null) {
+ const res = await fetch(`/rounds/${roundId}/participants`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ guest_name: name, gender, handicap_index: hcp }),
+ })
+ if (!res.ok) {
+ setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
+ return
+ }
+ setShowAddGuest(false)
+ await loadRound()
+ }
+
+ async function handleRemoveGuest(participantId: string) {
+ if (!confirm("Fjerne denne spilleren fra runden?")) return
+ const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { method: "DELETE", credentials: "include" })
+ if (!res.ok) return
+ if (activeParticipantId === participantId) setActiveParticipantId(round?.participants.find((p) => p.is_owner)?.id ?? null)
+ await loadRound()
+ }
+
+ async function handleComplete() {
+ if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
+ setCompleting(true)
+ try {
+ const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
+ if (!res.ok) throw new Error()
+ setRound(await res.json())
+ } catch {
+ setError("Klarte ikke å fullføre runden. Prøv igjen.")
+ } finally {
+ setCompleting(false)
+ }
+ }
+
+ if (error) {
+ return (
+
+
{error}
+
+ Tilbake til egne runder
+
+
+ )
+ }
+
+ if (!round) {
+ return (
+
+ )
+ }
+
+ const activeParticipant = round.participants.find((p) => p.id === activeParticipantId) ?? null
+ const holes = activeParticipantId ? holesByParticipant[activeParticipantId] : undefined
+ const currentHoleData = holes?.find((h) => h.hole_number === currentHole) ?? null
+ const isCompleted = round.completed_at !== null
+ const order = holeOrder(round.start_hole)
+
+ return (
+
+
+
+
+
+
+ {round.course_name_snapshot}
+
+
+ {round.tee_name_snapshot} · {round.holes_planned} hull · {formatDate(round.played_at)}
+
+
+
+ {isCompleted && }
+
+
+ {round.participants.map((p) => (
+
+ ))}
+ {!isCompleted && !showAddGuest && (
+
+ )}
+
+
+ {showAddGuest && setShowAddGuest(false)} onAdd={handleAddGuest} />}
+
+ {activeParticipant && (
+ <>
+
+
+ {currentHoleData ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+ {!isCompleted && (
+
+ )}
+
+
+ )
+}
+
+// --- Fullført-sammendrag -----------------------------------------------------
+
+function CompletedSummary({ round }: { round: ApiRound }) {
+ return (
+
+
+
+ Runde fullført
+
+
+ {round.participants.map((p) => (
+
+ {participantLabel(p)}
+
+ {p.counts_for_handicap && p.score_differential !== null
+ ? `Differensial: ${p.score_differential.toFixed(1)}`
+ : "Telte ikke mot HCP"}
+
+
+ ))}
+
+
+ )
+}
+
+// --- Legg til gjest -----------------------------------------------------------
+
+function AddGuestForm({
+ onCancel,
+ onAdd,
+}: {
+ onCancel: () => void
+ onAdd: (name: string, gender: Gender, hcp: number | null) => void
+}) {
+ const [name, setName] = useState("")
+ const [gender, setGender] = useState("m")
+ const [hcp, setHcp] = useState("")
+ const valid = name.trim().length > 0
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (!valid) return
+ onAdd(name.trim(), gender, hcp.trim() ? Number(hcp) : null)
+ setName("")
+ setHcp("")
+ }
+
+ return (
+
+ )
+}
+
+// --- Hull-navigasjon ----------------------------------------------------------
+
+function HoleStrip({
+ order,
+ holes,
+ currentHole,
+ onSelect,
+}: {
+ order: number[]
+ holes: ApiHole[] | undefined
+ currentHole: number
+ onSelect: (n: number) => void
+}) {
+ return (
+
+ {order.map((n) => {
+ const h = holes?.find((x) => x.hole_number === n)
+ return (
+
+ )
+ })}
+
+ )
+}
+
+// --- Hull-panel -----------------------------------------------------------
+
+function HolePanel({
+ hole,
+ readOnly,
+ onChange,
+}: {
+ hole: ApiHole
+ readOnly: boolean
+ onChange: (patch: Partial) => void
+}) {
+ const [showDetails, setShowDetails] = useState(false)
+ const gir =
+ hole.approach_result === "hit" && hole.score !== null && hole.putts !== null
+ ? hole.score - hole.putts <= hole.par - 2
+ : null
+
+ return (
+
+
+
+
Hull {hole.hole_number}
+
+ · Par {hole.par} · Idx {hole.stroke_index}
+
+
+ {gir !== null && (
+
+ {gir ? "GIR ✓" : "Ikke GIR"}
+
+ )}
+
+
+
+
+
+ Slag
+ onChange({ score: n, played: true })} />
+
+
+
+ Putter
+ onChange({ putts: n })} />
+
+
+
+
+ {showDetails && (
+
+
+
+ onChange({ club_off_tee: e.target.value || null })}
+ placeholder="F.eks. Driver"
+ className="h-11 rounded-xl text-base"
+ />
+
+
+ {hole.par >= 4 && (
+
+
Utslag
+
+ {(["left", "fairway", "right"] as const).map((v) => (
+ onChange({ tee_shot_result: v })}
+ >
+ {v === "fairway" ? "Fairway" : v === "left" ? "Venstre" : "Høyre"}
+
+ ))}
+
+
+ )}
+
+
+
Innspill
+
+ {(["left", "short", "hit", "long", "right"] as const).map((v) => (
+ onChange({ approach_result: v })}
+ >
+ {v === "hit" ? "Traff" : v === "long" ? "Langt" : v === "short" ? "Kort" : v === "left" ? "Venstre" : "Høyre"}
+
+ ))}
+
+
+
+
+ onChange({ chip_count: n })} />
+ onChange({ bunker_shot_count: n })} />
+ onChange({ penalty_strokes: n })} />
+
+
+
+
+ onChange({ first_putt_distance_m: e.target.value === "" ? null : Number(e.target.value) })}
+ className="h-11 rounded-xl text-base"
+ />
+
+
+ )}
+
+ )
+}
+
+function ChoiceButton({
+ active,
+ disabled,
+ onClick,
+ children,
+}: {
+ active: boolean
+ disabled: boolean
+ onClick: () => void
+ children: React.ReactNode
+}) {
+ return (
+
+ )
+}
+
+function SmallStepper({
+ label,
+ value,
+ disabled,
+ onChange,
+}: {
+ label: string
+ value: number | null
+ disabled: boolean
+ onChange: (n: number) => void
+}) {
+ const current = value ?? 0
+ return (
+
+
{label}
+
+
+ {current}
+
+
+
+ )
+}
+
+// Tallvelger, samme mønster som StrokePicker i session-scorecard.tsx: rask
+// direkte-trykk for de vanligste verdiene, med en "flere"-utvidelse for
+// resten -- ikke en +/- stepper (for mange klikk for typiske slagtall).
+function NumberPicker({
+ value,
+ min,
+ max,
+ disabled,
+ onSelect,
+}: {
+ value: number | null
+ min: number
+ max: number
+ disabled: boolean
+ onSelect: (n: number) => void
+}) {
+ const splitAt = Math.min(min + 8, max)
+ const [showHigh, setShowHigh] = useState(value !== null && value > splitAt)
+
+ useEffect(() => {
+ setShowHigh(value !== null && value > splitAt)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value])
+
+ const low = Array.from({ length: splitAt - min + 1 }, (_, i) => min + i)
+ const high = Array.from({ length: max - splitAt }, (_, i) => splitAt + 1 + i)
+ const numbers = showHigh ? high : low
+
+ return (
+
+
+ {numbers.map((n) => (
+
+ ))}
+ {!showHigh && high.length > 0 && (
+
+ )}
+
+ {showHigh && (
+
+ )}
+
+ )
+}
+
+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)
+}
diff --git a/frontend/components/round-new.tsx b/frontend/components/round-new.tsx
new file mode 100644
index 0000000..08ed076
--- /dev/null
+++ b/frontend/components/round-new.tsx
@@ -0,0 +1,834 @@
+"use client"
+
+// Opprett en frittstående runde (ADR-033). To bane-kilder: offisiell
+// teeoff-bane (live oppslag, Beslutning C -- ingen lokal kopi lagres) eller
+// en egendefinert bane (organisasjonsuavhengig katalog, søkbar på tvers av
+// alle brukere -- samme "søk før du oppretter"-idé som org-banene).
+
+import type React from "react"
+import { useEffect, useState } from "react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { ArrowLeft, Check, ChevronRight, Plus, Search, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Wordmark } from "@/components/wordmark"
+import { cn } from "@/lib/utils"
+
+type Gender = "m" | "f"
+type TeeOption = { name: string; genders: Gender[] }
+
+type SelectedCourse = {
+ source: "teeoff" | "custom"
+ teeoffFacilitySlug?: string
+ teeoffCourseId?: number
+ personalCourseId?: string
+ name: string
+ tees: TeeOption[]
+}
+
+type Step = "source" | "teeoff" | "custom-search" | "custom-create" | "confirm"
+
+export function NewRound() {
+ const router = useRouter()
+ const [step, setStep] = useState("source")
+ const [selected, setSelected] = useState(null)
+ const [ownGender, setOwnGender] = useState(null)
+ const [loadingMe, setLoadingMe] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ async function loadMe() {
+ try {
+ const res = await fetch("/auth/me", { credentials: "include" })
+ if (res.status === 401) {
+ router.replace("/")
+ return
+ }
+ const data: { gender: Gender | null } = await res.json()
+ if (!cancelled) setOwnGender(data.gender)
+ } finally {
+ if (!cancelled) setLoadingMe(false)
+ }
+ }
+ void loadMe()
+ return () => {
+ cancelled = true
+ }
+ }, [router])
+
+ function pickCourse(course: SelectedCourse) {
+ setSelected(course)
+ setStep("confirm")
+ }
+
+ return (
+
+
+
+
+ Ny runde
+
+ {loadingMe ? (
+
+ ) : !ownGender ? (
+
+ Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
+
+ Gå til kontoinnstillinger
+
+ .
+
+ ) : step === "source" ? (
+ setStep("teeoff")}
+ onPickCustom={() => setStep("custom-search")}
+ />
+ ) : step === "teeoff" ? (
+ setStep("source")} onPick={pickCourse} />
+ ) : step === "custom-search" ? (
+ setStep("source")}
+ onPick={pickCourse}
+ onCreateNew={() => setStep("custom-create")}
+ />
+ ) : step === "custom-create" ? (
+ setStep("custom-search")} onCreated={pickCourse} />
+ ) : selected ? (
+ setStep("source")} />
+ ) : null}
+
+
+ )
+}
+
+// --- Steg 1: velg kilde ------------------------------------------------------
+
+function SourceChoice({ onPickTeeoff, onPickCustom }: { onPickTeeoff: () => void; onPickCustom: () => void }) {
+ return (
+
+
+ Hvor spilte du runden?
+
+
+
+
+ )
+}
+
+// --- Steg 2a: teeoff-søk -----------------------------------------------------
+
+type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
+type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: TeeOption[] }
+
+function TeeoffCourseSearch({
+ onBack,
+ onPick,
+}: {
+ onBack: () => void
+ onPick: (course: SelectedCourse) => void
+}) {
+ const [query, setQuery] = useState("")
+ const [facilities, setFacilities] = useState(null)
+ const [selectedFacility, setSelectedFacility] = useState(null)
+ const [courses, setCourses] = useState(null)
+ const [searching, setSearching] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function runSearch() {
+ setSearching(true)
+ setError(null)
+ try {
+ const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, {
+ credentials: "include",
+ })
+ if (!res.ok) throw new Error(`search: ${res.status}`)
+ setFacilities(await res.json())
+ } catch {
+ setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
+ setFacilities([])
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ async function pickFacility(facility: ApiFacility) {
+ setSelectedFacility(facility)
+ setError(null)
+ setCourses(null)
+ try {
+ const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
+ if (!res.ok) throw new Error(`facility detail: ${res.status}`)
+ const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
+ setCourses(detail.courses)
+ } catch {
+ setError("Klarte ikke å hente baner for dette anlegget.")
+ setCourses([])
+ }
+ }
+
+ return (
+
+ {!selectedFacility ? (
+ <>
+
+ {error &&
{error}
}
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ runSearch()
+ }
+ }}
+ className="h-12 flex-1 rounded-xl text-base"
+ />
+
+
+ {facilities && (
+
+ {facilities.map((f) => (
+ -
+
+
+ ))}
+ {facilities.length === 0 && (
+ - Ingen treff.
+ )}
+
+ )}
+ >
+ ) : (
+ <>
+
setSelectedFacility(null)} label={selectedFacility.name} />
+ {error && {error}
}
+ {courses === null ? (
+ Laster baner…
+ ) : (
+
+ {courses.map((c) => (
+ -
+
+
+ ))}
+ {courses.length === 0 && (
+ -
+ Ingen 18-hulls baner registrert hos dette anlegget ennå.
+
+ )}
+
+ )}
+ >
+ )}
+
+ )
+}
+
+// --- Steg 2b: egen bane -- søk eksisterende ---------------------------------
+
+type ApiPersonalCourse = { id: string; name: string }
+
+function CustomCourseSearch({
+ onBack,
+ onPick,
+ onCreateNew,
+}: {
+ onBack: () => void
+ onPick: (course: SelectedCourse) => void
+ onCreateNew: () => void
+}) {
+ const [query, setQuery] = useState("")
+ const [results, setResults] = useState([])
+ const [error, setError] = useState(null)
+ const [resolving, setResolving] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ const timer = setTimeout(async () => {
+ try {
+ const res = await fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
+ if (!res.ok) throw new Error(`search: ${res.status}`)
+ const data: ApiPersonalCourse[] = await res.json()
+ if (!cancelled) setResults(data)
+ } catch {
+ if (!cancelled) setError("Klarte ikke å søke i egendefinerte baner akkurat nå.")
+ }
+ }, 250)
+ return () => {
+ cancelled = true
+ clearTimeout(timer)
+ }
+ }, [query])
+
+ async function pick(course: ApiPersonalCourse) {
+ setResolving(true)
+ setError(null)
+ try {
+ const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
+ if (!res.ok) throw new Error(`detail: ${res.status}`)
+ const detail: { tees: TeeOption[] } = await res.json()
+ onPick({ source: "custom", personalCourseId: course.id, name: course.name, tees: detail.tees })
+ } catch {
+ setError("Klarte ikke å hente banedetaljer. Prøv igjen.")
+ } finally {
+ setResolving(false)
+ }
+ }
+
+ return (
+
+
+ {error &&
{error}
}
+
setQuery(e.target.value)}
+ className="h-12 rounded-xl text-base"
+ />
+
+ {results.map((c) => (
+ -
+
+
+ ))}
+ {results.length === 0 && (
+ -
+ {query.trim() ? "Ingen treff." : "Skriv for å søke, eller opprett en ny bane under."}
+
+ )}
+
+
+
+ )
+}
+
+// --- Steg 2c: egen bane -- opprett ny ---------------------------------------
+
+const DEFAULT_PARS = [4, 4, 3, 5, 4, 4, 3, 5, 4, 4, 4, 3, 5, 4, 4, 3, 5, 4]
+
+type HoleDraft = { par: number; strokeIndex: number }
+type TeeRatingDraft = { courseRating: string; slopeRating: string; par: string }
+type TeeDraft = { name: string; m: TeeRatingDraft | null; f: TeeRatingDraft | null }
+
+function CustomCourseCreate({
+ onBack,
+ onCreated,
+}: {
+ onBack: () => void
+ onCreated: (course: SelectedCourse) => void
+}) {
+ const [name, setName] = useState("")
+ const [holes, setHoles] = useState(
+ DEFAULT_PARS.map((par, i) => ({ par, strokeIndex: i + 1 })),
+ )
+ const [tees, setTees] = useState([
+ { name: "Gul", m: { courseRating: "", slopeRating: "", par: "72" }, f: null },
+ ])
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ const parSum = holes.reduce((s, h) => s + h.par, 0)
+ const indexPermutationValid = new Set(holes.map((h) => h.strokeIndex)).size === 18
+
+ function updateHole(i: number, patch: Partial) {
+ setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
+ }
+
+ function updateTee(i: number, patch: Partial) {
+ setTees((prev) => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)))
+ }
+
+ const teesValid = tees.every(
+ (t) =>
+ t.name.trim().length > 0 &&
+ (t.m || t.f) &&
+ [t.m, t.f].every((r) => !r || (r.courseRating.trim() && r.slopeRating.trim() && r.par.trim())),
+ )
+ const canSubmit = name.trim().length >= 2 && indexPermutationValid && teesValid && !submitting
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (!canSubmit) return
+ setSubmitting(true)
+ setError(null)
+ try {
+ const body = {
+ name: name.trim(),
+ holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.strokeIndex })),
+ tees: tees.map((t) => ({
+ name: t.name.trim(),
+ ratings: [
+ ...(t.m
+ ? [{ gender: "m", course_rating: Number(t.m.courseRating), slope_rating: Number(t.m.slopeRating), par: Number(t.m.par) }]
+ : []),
+ ...(t.f
+ ? [{ gender: "f", course_rating: Number(t.f.courseRating), slope_rating: Number(t.f.slopeRating), par: Number(t.f.par) }]
+ : []),
+ ],
+ })),
+ }
+ const res = await fetch("/personal-courses", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify(body),
+ })
+ if (!res.ok) throw new Error(`create: ${res.status}`)
+ const created: { id: string; name: string } = await res.json()
+ onCreated({
+ source: "custom",
+ personalCourseId: created.id,
+ name: created.name,
+ tees: tees.map((t) => ({
+ name: t.name.trim(),
+ genders: [...(t.m ? (["m"] as const) : []), ...(t.f ? (["f"] as const) : [])],
+ })),
+ })
+ } catch {
+ setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
+ } finally {
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+function GenderRatingFields({
+ label,
+ value,
+ onChange,
+}: {
+ label: string
+ value: TeeRatingDraft | null
+ onChange: (v: TeeRatingDraft | null) => void
+}) {
+ const id = label.toLowerCase().replace(/\s+/g, "-")
+ return (
+
+
+ {value && (
+
+ )}
+
+ )
+}
+
+// --- Steg 3: bekreft + opprett runde -----------------------------------------
+
+function ConfirmRound({
+ course,
+ ownGender,
+ onBack,
+}: {
+ course: SelectedCourse
+ ownGender: Gender
+ onBack: () => void
+}) {
+ const router = useRouter()
+ const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender))
+ const [teeName, setTeeName] = useState(compatibleTees[0]?.name ?? "")
+ const [playedAt, setPlayedAt] = useState(() => new Date().toISOString().slice(0, 10))
+ const [startHole, setStartHole] = useState(1)
+ const [holesPlanned, setHolesPlanned] = useState<9 | 18>(18)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleSubmit() {
+ if (!teeName) return
+ setSubmitting(true)
+ setError(null)
+ try {
+ const res = await fetch("/rounds", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({
+ course_source: course.source,
+ teeoff_facility_slug: course.teeoffFacilitySlug,
+ teeoff_course_id: course.teeoffCourseId,
+ personal_course_id: course.personalCourseId,
+ tee_name: teeName,
+ played_at: playedAt,
+ start_hole: startHole,
+ holes_planned: holesPlanned,
+ }),
+ })
+ if (!res.ok) throw new Error(`create round: ${res.status}`)
+ const created: { id: string } = await res.json()
+ router.replace(`/rounds/${created.id}`)
+ } catch {
+ setError("Klarte ikke å opprette runden. Prøv igjen.")
+ setSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ {course.name}
+
+
+ {error &&
{error}
}
+
+ {compatibleTees.length === 0 ? (
+
+ Denne banen har ingen registrert rating for ditt kjønn på noe utslag -- HCP-sporing er ikke mulig for
+ denne runden. Velg en annen bane, eller fullfør profilen din på nytt.
+
+ ) : (
+
+
+
+ {compatibleTees.map((t) => (
+
+ ))}
+
+
+ )}
+
+
+
+ setPlayedAt(e.target.value)}
+ className="h-12 rounded-xl text-base"
+ />
+
+
+
+
+
+
+
+
+
+
+ {([9, 18] as const).map((n) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+// --- Delt --------------------------------------------------------------------
+
+function BackLink({ onClick, label }: { onClick: () => void; label: string }) {
+ return (
+
+ )
+}