From 926a1a5275625b8dc0a1fd22a313bb3276d77560 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 25 Jul 2026 06:29:09 +0200 Subject: [PATCH] All three changes are implemented and scratch-verified (22/22 checks passed, test_isolation.sql still 12/12, frontend typechecks cleanly). Summary of what's ready to ship: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enkeltbane-anlegg (Tjøme m.fl.): banenavnet droppes nå når anlegget bare har én bane — "Tjøme Golfklubb" i stedet for "Tjøme Golfklubb – Hovedbanen". Anlegg med flere baner (f.eks. Ålesund) beholder fortsatt kombinert navn. Gjelder både turnering-import og frittstående runder. Navngi runder: nytt valgfritt name-felt på round (migrasjon 024_round_name.sql), settbart ved opprettelse og redigerbart/fjernbart senere via "Rediger runde". Vises på tvers av rundeliste, rundeside, scorekort og statistikk (faller tilbake til banenavn når ikke satt). Land før hjemmeklubb: "Land" er nå en nedtrekksliste (kun "Norge" foreløpig, klargjort for flere), og "Hjemmeklubb" er en søkbar liste mot teeoffs ekte klubbregister (gjenbruker det eksisterende /rounds/official-search-endepunktet — ingen ny backend-kode). Gjelder både profil-fullføring og kontoinnstillinger. --- 024_round_name.sql | 6 + app/routers/courses.py | 8 +- app/routers/rounds.py | 31 +++- frontend/components/account-settings.tsx | 172 +++++++++++++++++------ frontend/components/new-round.tsx | 21 +++ frontend/components/own-rounds.tsx | 2 + frontend/components/round-card.tsx | 7 +- frontend/components/round-detail.tsx | 28 +++- frontend/components/round-scorecard.tsx | 4 +- frontend/components/round-stats.tsx | 4 +- 10 files changed, 227 insertions(+), 56 deletions(-) create mode 100644 024_round_name.sql diff --git a/024_round_name.sql b/024_round_name.sql new file mode 100644 index 0000000..5e0960d --- /dev/null +++ b/024_round_name.sql @@ -0,0 +1,6 @@ +-- Frittstående rundeføring (ADR-033), oppfølging 2026-07-25: brukeren skal +-- kunne navngi rundene sine ("Man må kunne navngi rundene man spiller."). +-- Valgfritt -- mangler et navn, faller visningen tilbake til +-- course_name_snapshot slik den alltid har gjort. + +ALTER TABLE round ADD COLUMN name text; diff --git a/app/routers/courses.py b/app/routers/courses.py index 11983ee..7a5bf77 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -432,7 +432,13 @@ async def import_official_course( # Navn kombinerer anlegg + bane -- "Hovedbanen" alene er tvetydig (mange # klubber navngir hovedbanen sin nøyaktig likt). Fant og fikset samme # runde som idempotent-fiksen over, samme bakenforliggende brukerrapport. - course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}" + # UNNTAK (2026-07-25, brukerrapport om Tjøme Golfklubb): har anlegget + # bare ÉN bane totalt, er banenavnet ("Hovedbanen") overflødig + # informasjon -- kun anleggsnavnet brukes da. + if len(facility.get("courses", [])) == 1: + course_name = facility.get("name") or "Bane" + else: + course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}" async with org_connection(organization_id) as conn, translate_db_errors(): course_row = await conn.fetchrow( diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 4ce1b26..3bf3ae7 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -337,7 +337,12 @@ async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ if h.get("par") is None or h.get("hcp_index") is None: raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull.") - course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}" + # Samme "kun ett banenavn -- hopp over duplikat anleggsnavn"-regel som + # `courses.py` sin `import_official_course` (2026-07-25). + if len(facility.get("courses", [])) == 1: + course_name = facility.get("name") or "Bane" + else: + course_name = f"{facility.get('name')} – {course_data.get('name') or 'Bane'}" resolved = _ResolvedCourse( holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes), course_name=course_name, @@ -394,6 +399,9 @@ class RoundCreate(BaseModel): teeoff_course_id: int | None = None personal_course_id: str | None = None tee_name: str + # Valgfritt eget navn på runden (2026-07-25) -- mangler det, faller + # visningen tilbake til course_name_snapshot, som alltid er satt. + name: str | None = Field(default=None, max_length=200) played_at: date start_hole: int = Field(default=1, ge=1, le=18) holes_planned: Literal[9, 18] = 18 @@ -419,6 +427,7 @@ class RoundParticipantOut(BaseModel): class RoundOut(BaseModel): id: str course_source: str + name: str | None course_name_snapshot: str tee_name_snapshot: str played_at: date @@ -440,7 +449,7 @@ class RoundOut(BaseModel): async def _load_round_out(conn, round_id: str) -> RoundOut: round_row = await conn.fetchrow( """ - SELECT id::text AS id, course_source, course_name_snapshot, tee_name_snapshot, + SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot, played_at, start_hole, holes_planned, started_at, completed_at FROM round WHERE id = $1 """, @@ -474,6 +483,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut: return RoundOut( id=round_row["id"], course_source=round_row["course_source"], + name=round_row["name"], course_name_snapshot=round_row["course_name_snapshot"], tee_name_snapshot=round_row["tee_name_snapshot"], played_at=round_row["played_at"], @@ -577,8 +587,8 @@ async def create_round( INSERT INTO round (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, - start_hole, holes_planned, started_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + start_hole, holes_planned, started_at, name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id::text AS id """, user.user_id, @@ -592,6 +602,7 @@ async def create_round( body.start_hole, body.holes_planned, body.started_at, + body.name.strip() if body.name and body.name.strip() else None, ) round_id = round_row["id"] @@ -640,6 +651,12 @@ class RoundUpdate(BaseModel): teeoff_course_id: int | None = None personal_course_id: str | None = None tee_name: str | None = None + # Rent metadata-felt -- kan endres/fjernes uansett fullført-status, + # samme begrunnelse som start_hole/started_at/completed_at under. Sendes + # feltet med tom streng, tolkes det som "fjern navnet" (NULL); mangler + # feltet i kallet, røres eksisterende navn ikke (samme mønster som de + # andre feltene her -- kun `None`/utelatt betyr "ikke rør"). + name: str | None = Field(default=None, max_length=200) holes_planned: Literal[9, 18] | None = None start_hole: int | None = Field(default=None, ge=1, le=18) # Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i @@ -735,6 +752,12 @@ async def update_round( await conn.execute("UPDATE round SET holes_planned = $2 WHERE id = $1", round_id, body.holes_planned) if body.start_hole is not None: await conn.execute("UPDATE round SET start_hole = $2 WHERE id = $1", round_id, body.start_hole) + if body.name is not None: + await conn.execute( + "UPDATE round SET name = $2 WHERE id = $1", + round_id, + body.name.strip() if body.name.strip() else None, + ) if body.started_at is not None: await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at) if body.completed_at is not None: diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index 18e0880..bc95ef0 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -42,6 +42,123 @@ const BAG_CLUBS = [ ] as const const MAX_BAG_CLUBS = 14 +// Land+hjemmeklubb (2026-07-25): "Land" er klargjøring for fremtidig +// flerspråklighet -- kun norske klubber finnes i teeoff ennå, så listen har +// bevisst kun ett valg foreløpig. Flere land legges til her når teeoff (eller +// en fremtidig tilsvarende kilde) faktisk har data for dem -- INGEN +// backend-endring trengs da, kun denne listen utvides. +const COUNTRIES = ["Norge"] as const + +function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) { + return ( +
+ + +
+ ) +} + +// Hjemmeklubb (2026-07-25): søkbar liste mot teeoffs klubbregister -- +// gjenbruker `/rounds/official-search` (allerede org-uavhengig, tilgjengelig +// for enhver innlogget bruker, ADR-033), samme mønster som bane-søket ved ny +// runde. Teeoff filtrerer allerede bort upubliserte/nedlagte anlegg +// server-side (`is_published`), så ingen egen filtrering trengs her. +type HomeClubOption = { slug: string; name: string; city: string | null; county: string | null } + +function HomeClubField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) { + const [query, setQuery] = useState(value) + const [results, setResults] = useState([]) + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + if (!open) return + let cancelled = false + const timer = setTimeout(async () => { + try { + const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { + credentials: "include", + }) + if (res.ok && !cancelled) setResults(await res.json()) + } catch { + // Stille -- listen blir bare uendret, ingen kritisk feil å vise her. + } + }, 250) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [query, open]) + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + function selectClub(option: HomeClubOption) { + onChange(option.name) + setQuery(option.name) + setOpen(false) + } + + return ( +
+ + { + setQuery(e.target.value) + onChange(e.target.value) + setOpen(true) + }} + onFocus={() => setOpen(true)} + placeholder="Søk etter klubb …" + autoComplete="off" + className="h-11 rounded-xl" + /> + {open && results.length > 0 && ( +
    + {results.map((option) => ( +
  • + +
  • + ))} +
+ )} +
+ ) +} + type Me = { id: string email: string @@ -221,8 +338,11 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void // WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell // HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske. const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index)) + // Landfeltet er nå en nedtrekksliste (2026-07-25) -- default til det + // eneste tilgjengelige valget, samme "forhåndsutfylt fremfor tomt"-prinsipp + // som HCP-standarden 54 over. + const [country, setCountry] = useState(me.country ?? COUNTRIES[0]) const [homeClub, setHomeClub] = useState(me.home_club ?? "") - const [country, setCountry] = useState(me.country ?? "") const [bio, setBio] = useState(me.bio ?? "") const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -378,28 +498,8 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.

-
- - setHomeClub(e.target.value)} - className="h-11 rounded-xl" - /> -
-
- - setCountry(e.target.value)} - className="h-11 rounded-xl" - /> -
+ +
-
- - setHomeClub(e.target.value)} - className="h-11 rounded-xl" - /> -
-
- - setCountry(e.target.value)} - className="h-11 rounded-xl" - /> -
+ +
+ {round.name?.trim() && {round.courseName}}