diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 294a856..20e7d37 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -6193,6 +6193,28 @@ ganger denne dagen -- brukeren stanset bevisst mellom første og andre
gang for å laste opp V0-eksporten). Se CHANGELOG.md punkt 77 for full
verifiseringsdetalj.
+**Tillegg 2026-08-13 — den primære "Ny runde"-veiviseren manglet
+GolfAPI-søket, og en reell ruting-bug ble funnet og fikset.** Brukeren
+spurte hvor, nøyaktig, funksjonen kunne sees — det viste seg at
+2026-08-12-rundens søk-UI kun var koblet inn i "endre bane"-skjemaet
+(`round-detail.tsx`), ikke i den faktiske `/my-rounds/new`-veiviseren
+(`components/ny-runde/`, en helt separat komponenttre). Lagt til der.
+Under verifisering av DENNE tilkoblingen (ikke forrige runde, som kun
+reelt HTTP-testet POST-import, aldri GET-søket) ble en ekte 500-feil
+funnet: `GET /personal-courses/{personal_course_id}` (eksisterende,
+generisk) var registrert FØR den nye, mer spesifikke
+`GET /personal-courses/international-search` — FastAPI matcher ruter i
+registreringsrekkefølge, så den generiske ruten fanget grådig opp
+"international-search" som en ugyldig UUID. Lærdom: en ny literal-path-
+rute som deler prefiks med en eksisterende parameterisert rute MÅ
+registreres FØR den, ikke etter — nå kommentert direkte i koden
+(`rounds.py`) for å unngå gjentakelse. Org-siden (`courses.py`) hadde
+aldri dette problemet (ingen bar `/courses/{course_id}`-rute der).
+Full scratch-verifisert på nytt (ekte klikk-gjennom i selve "Ny
+runde"-veiviseren, ingen konsollfeil, søket bekreftet returnere reelle
+treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg)
+for full detalj.
+
Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84365c5..06d91ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10756,3 +10756,50 @@ Neste steg:
etter). Rene containerlogger begge ganger, ingen konsollfeil på
`https://teecup.golf/logg-inn` etter siste omstart. ~15,5 av 20
GolfAPI-kall gjenstår.
+
+ **Tillegg 2026-08-13 -- den PRIMÆRE "Ny runde"-veiviseren manglet
+ GolfAPI-søket, og en reell ruting-bug ble funnet og fikset.**
+ Brukeren spurte "hvor, nøyaktig, kan jeg se dette implementert?" --
+ undersøkelsen avdekket at søk-og-koble-UI-en fra 2026-08-12 KUN var
+ koblet inn i "endre bane på en allerede opprettet runde"-skjemaet
+ (`ChangeCourseForm`, round-detail.tsx), IKKE i den faktiske
+ `/my-rounds/new`-veiviseren (`components/ny-runde/step1-course-
+ time.tsx` + `lib/ny-runde/api.ts`) -- en helt separat komponenttre
+ brukeren faktisk møter FØRST. Lagt til der også: ny
+ `"international-search"`-delstilstand (`Sub1`-typen),
+ `InternationalSearch`-komponent (samme klubb->baner-todelte søk som
+ de to andre stedene, i `--nr-*`-designtokens), `searchInternationalClubs`/
+ `importInternationalCourse` i `lib/ny-runde/api.ts`.
+
+ **Ekte bug fanget under nettleserverifisering av DENNE
+ integrasjonen** (ikke funnet i forrige runde fordi kun POST
+ `/personal-courses/international-import` ble reelt HTTP-testet der,
+ aldri GET `/personal-courses/international-search`): 500-feil,
+ `asyncpg.exceptions.DataError: invalid input for query argument $1:
+ 'international-search' (invalid UUID...)`. Årsak: FastAPI matcher
+ ruter i REGISTRERINGSREKKEFØLGE -- `GET /personal-courses/
+ {personal_course_id}` (allerede eksisterende, generisk) var
+ registrert FØR den nye, mer spesifikke `GET /personal-courses/
+ international-search`, så `{personal_course_id}` fanget grådig opp
+ "international-search" som en (ugyldig) UUID. Samme fellgruve
+ fantes IKKE på org-siden (courses.py) fordi det der ikke finnes noen
+ bar `/courses/{course_id}`-rute å kollidere med (kun `/{course_id}/
+ tees` og `/{course_id}/holes`, annen path-form). Fikset ved å flytte
+ de to nye endepunktene til FØR `get_personal_course` i
+ `rounds.py` -- lærdom kommentert direkte i koden for å unngå samme
+ feil neste gang en literal-path-rute legges til ved siden av en
+ eksisterende parameterisert rute.
+
+ **Verifisert på nytt, fullt ut, etter fiksen:** frisk scratch-stack,
+ ekte innlogging, full klikk-gjennom-flyt i den FAKTISKE "Ny
+ runde"-veiviseren (Egen bane -> "Fant ikke banen? Søk
+ internasjonalt" -> søk «Tjøme» -> Tjøme Golfklubb -> importer ->
+ landet korrekt på "Bane & tid"-steget med "Tjøme Golfklubb – Bane",
+ 4 utslag), ingen konsollfeil, bekreftet via nettverksfane at søket
+ nå returnerer 200 med reelt klubbtreff (ikke lenger 401/500).
+ `tsc --noEmit` rent, 45/45 vitest, 31/31 backend-tester. Scratch-
+ ressurser ryddet opp, `teecup_db` bekreftet uendret.
+
+ **Rullet ut 2026-08-13**, samme økt. `docker compose build
+ teecup_api teecup_frontend && up -d`. Rene containerlogger, ingen
+ konsollfeil på `https://teecup.golf/logg-inn` etter omstart.
diff --git a/app/routers/rounds.py b/app/routers/rounds.py
index 5acfeaf..95adc74 100644
--- a/app/routers/rounds.py
+++ b/app/routers/rounds.py
@@ -203,6 +203,163 @@ async def search_personal_courses(
return [PersonalCourseOut(**dict(r)) for r in rows]
+# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
+#
+# Speiler international-search/-import i courses.py (org-scopet variant),
+# men skriver inn i den globale personal_course-katalogen i stedet for
+# course/hole/tee. Samme cache-chokepoint (golfapi_cache), samme "fail
+# loudly"-prinsipp. round.course_source trenger INGEN endring -- en
+# GolfAPI-importert bane blir en helt vanlig personal_course-rad
+# (course_source='custom'), kun med external_golfapi_course_id satt for
+# å (a) hindre dupliserte importer, (b) la rangefinder-oppslaget finne
+# koordinatene senere.
+#
+# MERK plasseringen: MÅ stå FØR GET /personal-courses/{personal_course_id}
+# under -- FastAPI matcher ruter i registreringsrekkefølge, og en generisk
+# {personal_course_id}-parameter ville ellers grådig fanget opp
+# "international-search" som en (ugyldig) UUID FØR denne mer spesifikke
+# ruten noensinne nås. Fanget som en ekte 500 under nettleserverifisering
+# 2026-08-13 -- se CHANGELOG.md.
+
+
+class InternationalCourseOption(BaseModel):
+ course_id: str
+ course_name: str
+ num_holes: int
+ has_gps: bool
+
+
+class InternationalClub(BaseModel):
+ club_id: str
+ club_name: str
+ city: str | None = None
+ country: str | None = None
+ courses: list[InternationalCourseOption]
+
+
+class InternationalCourseImport(BaseModel):
+ golfapi_course_id: str
+
+
+@router.get("/personal-courses/international-search", response_model=list[InternationalClub])
+async def search_international_personal_courses(
+ q: str = Query(default=""),
+ user: CurrentUser = Depends(get_current_user),
+) -> list[InternationalClub]:
+ if not q.strip():
+ return []
+ try:
+ clubs = await golfapi_client.search_clubs(name=q)
+ except golfapi_client.GolfApiNotConfiguredError:
+ raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
+ except golfapi_client.GolfApiUnavailableError:
+ raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
+ return [
+ InternationalClub(
+ club_id=c["clubID"],
+ club_name=c["clubName"],
+ city=c.get("city"),
+ country=c.get("country"),
+ courses=[
+ InternationalCourseOption(
+ course_id=course["courseID"],
+ course_name=course.get("courseName") or "Bane",
+ num_holes=int(course["numHoles"]),
+ has_gps=bool(int(course.get("hasGPS") or 0)),
+ )
+ for course in c.get("courses", [])
+ ],
+ )
+ for c in clubs
+ ]
+
+
+@router.post("/personal-courses/international-import", response_model=PersonalCourseOut, status_code=201)
+async def import_international_personal_course(
+ body: InternationalCourseImport,
+ user: CurrentUser = Depends(get_current_user),
+) -> PersonalCourseOut:
+ if not settings.GOLFAPI_TOKEN:
+ raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
+
+ async with plain_connection() as conn:
+ # Idempotent, samme begrunnelse som TeeOff-importen: gi tilbake den
+ # EKSISTERENDE (globale -- kan være importert av en ANNEN bruker)
+ # personal_course-raden fremfor å avvise med 409.
+ existing = await conn.fetchrow(
+ """
+ SELECT pc.id::text AS id, pc.name, (pc.created_by_user_id::text = $2) AS is_mine, au.display_name AS created_by_display_name
+ FROM personal_course pc JOIN app_user au ON au.id = pc.created_by_user_id
+ WHERE pc.external_golfapi_course_id = $1
+ """,
+ body.golfapi_course_id,
+ user.user_id,
+ )
+ if existing is not None:
+ return PersonalCourseOut(**dict(existing))
+
+ async with plain_connection() as conn, translate_db_errors():
+ try:
+ await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
+ except golfapi_client.GolfApiNotFoundError:
+ raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
+ except golfapi_client.GolfApiUnavailableError:
+ raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
+
+ gc = await conn.fetchrow(
+ "SELECT club_name, course_name FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id
+ )
+ holes = await conn.fetch(
+ "SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
+ body.golfapi_course_id,
+ )
+ tees = await conn.fetch(
+ "SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
+ body.golfapi_course_id,
+ )
+ if len(holes) != 18:
+ raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
+ if not tees:
+ raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
+
+ par_total = sum(h["par_men"] for h in holes)
+ course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"]
+ own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
+
+ async with conn.transaction():
+ course_row = await conn.fetchrow(
+ """
+ INSERT INTO personal_course (name, created_by_user_id, external_golfapi_course_id)
+ VALUES ($1, $2, $3) RETURNING id::text AS id, name
+ """,
+ course_name,
+ user.user_id,
+ body.golfapi_course_id,
+ )
+ for h in holes:
+ await conn.execute(
+ "INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
+ course_row["id"], h["hole_number"], h["par_men"], h["index_men"],
+ )
+ for t in tees:
+ tee_row = await conn.fetchrow(
+ "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
+ course_row["id"], t["name"],
+ )
+ ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)]
+ if t["course_rating_women"] is not None and t["slope_women"] is not None:
+ ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
+ for gender, course_rating, slope_rating, par in ratings:
+ await conn.execute(
+ """
+ INSERT INTO personal_course_tee_rating (personal_course_tee_id, gender, course_rating, slope_rating, par)
+ VALUES ($1, $2, $3, $4, $5)
+ """,
+ tee_row["id"], gender, course_rating, slope_rating, par,
+ )
+ return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
+
+
@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail)
async def get_personal_course(
personal_course_id: str,
@@ -353,156 +510,6 @@ async def create_personal_course(
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
-# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
-#
-# Speiler international-search/-import i courses.py (org-scopet variant),
-# men skriver inn i den globale personal_course-katalogen i stedet for
-# course/hole/tee. Samme cache-chokepoint (golfapi_cache), samme "fail
-# loudly"-prinsipp. round.course_source trenger INGEN endring -- en
-# GolfAPI-importert bane blir en helt vanlig personal_course-rad
-# (course_source='custom'), kun med external_golfapi_course_id satt for
-# å (a) hindre dupliserte importer, (b) la rangefinder-oppslaget finne
-# koordinatene senere.
-
-
-class InternationalCourseOption(BaseModel):
- course_id: str
- course_name: str
- num_holes: int
- has_gps: bool
-
-
-class InternationalClub(BaseModel):
- club_id: str
- club_name: str
- city: str | None = None
- country: str | None = None
- courses: list[InternationalCourseOption]
-
-
-class InternationalCourseImport(BaseModel):
- golfapi_course_id: str
-
-
-@router.get("/personal-courses/international-search", response_model=list[InternationalClub])
-async def search_international_personal_courses(
- q: str = Query(default=""),
- user: CurrentUser = Depends(get_current_user),
-) -> list[InternationalClub]:
- if not q.strip():
- return []
- try:
- clubs = await golfapi_client.search_clubs(name=q)
- except golfapi_client.GolfApiNotConfiguredError:
- raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
- except golfapi_client.GolfApiUnavailableError:
- raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
- return [
- InternationalClub(
- club_id=c["clubID"],
- club_name=c["clubName"],
- city=c.get("city"),
- country=c.get("country"),
- courses=[
- InternationalCourseOption(
- course_id=course["courseID"],
- course_name=course.get("courseName") or "Bane",
- num_holes=int(course["numHoles"]),
- has_gps=bool(int(course.get("hasGPS") or 0)),
- )
- for course in c.get("courses", [])
- ],
- )
- for c in clubs
- ]
-
-
-@router.post("/personal-courses/international-import", response_model=PersonalCourseOut, status_code=201)
-async def import_international_personal_course(
- body: InternationalCourseImport,
- user: CurrentUser = Depends(get_current_user),
-) -> PersonalCourseOut:
- if not settings.GOLFAPI_TOKEN:
- raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
-
- async with plain_connection() as conn:
- # Idempotent, samme begrunnelse som TeeOff-importen: gi tilbake den
- # EKSISTERENDE (globale -- kan være importert av en ANNEN bruker)
- # personal_course-raden fremfor å avvise med 409.
- existing = await conn.fetchrow(
- """
- SELECT pc.id::text AS id, pc.name, (pc.created_by_user_id::text = $2) AS is_mine, au.display_name AS created_by_display_name
- FROM personal_course pc JOIN app_user au ON au.id = pc.created_by_user_id
- WHERE pc.external_golfapi_course_id = $1
- """,
- body.golfapi_course_id,
- user.user_id,
- )
- if existing is not None:
- return PersonalCourseOut(**dict(existing))
-
- async with plain_connection() as conn, translate_db_errors():
- try:
- await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
- except golfapi_client.GolfApiNotFoundError:
- raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
- except golfapi_client.GolfApiUnavailableError:
- raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
-
- gc = await conn.fetchrow(
- "SELECT club_name, course_name FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id
- )
- holes = await conn.fetch(
- "SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
- body.golfapi_course_id,
- )
- tees = await conn.fetch(
- "SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
- body.golfapi_course_id,
- )
- if len(holes) != 18:
- raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
- if not tees:
- raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
-
- par_total = sum(h["par_men"] for h in holes)
- course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"]
- own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
-
- async with conn.transaction():
- course_row = await conn.fetchrow(
- """
- INSERT INTO personal_course (name, created_by_user_id, external_golfapi_course_id)
- VALUES ($1, $2, $3) RETURNING id::text AS id, name
- """,
- course_name,
- user.user_id,
- body.golfapi_course_id,
- )
- for h in holes:
- await conn.execute(
- "INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
- course_row["id"], h["hole_number"], h["par_men"], h["index_men"],
- )
- for t in tees:
- tee_row = await conn.fetchrow(
- "INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
- course_row["id"], t["name"],
- )
- ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)]
- if t["course_rating_women"] is not None and t["slope_women"] is not None:
- ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
- for gender, course_rating, slope_rating, par in ratings:
- await conn.execute(
- """
- INSERT INTO personal_course_tee_rating (personal_course_tee_id, gender, course_rating, slope_rating, par)
- VALUES ($1, $2, $3, $4, $5)
- """,
- tee_row["id"], gender, course_rating, slope_rating, par,
- )
- return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
-
-
@router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut)
async def update_personal_course(
personal_course_id: str,
diff --git a/frontend/components/ny-runde/step1-course-time.tsx b/frontend/components/ny-runde/step1-course-time.tsx
index baf4f1f..329c44d 100644
--- a/frontend/components/ny-runde/step1-course-time.tsx
+++ b/frontend/components/ny-runde/step1-course-time.tsx
@@ -5,10 +5,13 @@ import { useEffect, useMemo, useRef, useState } from "react"
import {
fetchFacilityCourses,
fetchOwnCourse,
+ importInternationalCourse,
nearbyOfficialFacilities,
+ searchInternationalClubs,
searchOfficialFacilities,
searchOwnCourses,
} from "@/lib/ny-runde/api"
+import type { ApiInternationalClub, ApiInternationalCourseOption } from "@/lib/ny-runde/api"
import type { Course, Facility } from "@/lib/ny-runde/types"
import { CreateCourseForm } from "./create-course-form"
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
@@ -86,6 +89,14 @@ export function Step1CourseTime() {
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
+ onInternational={() => patch({ s1Sub: "international-search" })}
+ />
+ )
+ case "international-search":
+ return (
+
+ {error} +
+ ) : null} + + {!selectedClub ? ( + <> ++ Begynn å skrive for å søke etter en klubb. +
+ ) : clubs.length > 0 ? ( ++ Ingen klubber matcher «{query}». +
+ )} +