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:

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.
This commit is contained in:
Erol Haagenrud 2026-07-25 06:29:09 +02:00
parent 714c64e82e
commit 926a1a5275
10 changed files with 227 additions and 56 deletions

6
024_round_name.sql Normal file
View file

@ -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;

View file

@ -432,7 +432,13 @@ async def import_official_course(
# Navn kombinerer anlegg + bane -- "Hovedbanen" alene er tvetydig (mange # Navn kombinerer anlegg + bane -- "Hovedbanen" alene er tvetydig (mange
# klubber navngir hovedbanen sin nøyaktig likt). Fant og fikset samme # klubber navngir hovedbanen sin nøyaktig likt). Fant og fikset samme
# runde som idempotent-fiksen over, samme bakenforliggende brukerrapport. # 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(): async with org_connection(organization_id) as conn, translate_db_errors():
course_row = await conn.fetchrow( course_row = await conn.fetchrow(

View file

@ -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: 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.") 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( resolved = _ResolvedCourse(
holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes), holes=sorted((h["hole_number"], h["par"], h["hcp_index"]) for h in raw_holes),
course_name=course_name, course_name=course_name,
@ -394,6 +399,9 @@ class RoundCreate(BaseModel):
teeoff_course_id: int | None = None teeoff_course_id: int | None = None
personal_course_id: str | None = None personal_course_id: str | None = None
tee_name: str 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 played_at: date
start_hole: int = Field(default=1, ge=1, le=18) start_hole: int = Field(default=1, ge=1, le=18)
holes_planned: Literal[9, 18] = 18 holes_planned: Literal[9, 18] = 18
@ -419,6 +427,7 @@ class RoundParticipantOut(BaseModel):
class RoundOut(BaseModel): class RoundOut(BaseModel):
id: str id: str
course_source: str course_source: str
name: str | None
course_name_snapshot: str course_name_snapshot: str
tee_name_snapshot: str tee_name_snapshot: str
played_at: date played_at: date
@ -440,7 +449,7 @@ class RoundOut(BaseModel):
async def _load_round_out(conn, round_id: str) -> RoundOut: async def _load_round_out(conn, round_id: str) -> RoundOut:
round_row = await conn.fetchrow( 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 played_at, start_hole, holes_planned, started_at, completed_at
FROM round WHERE id = $1 FROM round WHERE id = $1
""", """,
@ -474,6 +483,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
return RoundOut( return RoundOut(
id=round_row["id"], id=round_row["id"],
course_source=round_row["course_source"], course_source=round_row["course_source"],
name=round_row["name"],
course_name_snapshot=round_row["course_name_snapshot"], course_name_snapshot=round_row["course_name_snapshot"],
tee_name_snapshot=round_row["tee_name_snapshot"], tee_name_snapshot=round_row["tee_name_snapshot"],
played_at=round_row["played_at"], played_at=round_row["played_at"],
@ -577,8 +587,8 @@ async def create_round(
INSERT INTO round INSERT INTO round
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id, (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at) start_hole, holes_planned, started_at, name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
user.user_id, user.user_id,
@ -592,6 +602,7 @@ async def create_round(
body.start_hole, body.start_hole,
body.holes_planned, body.holes_planned,
body.started_at, body.started_at,
body.name.strip() if body.name and body.name.strip() else None,
) )
round_id = round_row["id"] round_id = round_row["id"]
@ -640,6 +651,12 @@ class RoundUpdate(BaseModel):
teeoff_course_id: int | None = None teeoff_course_id: int | None = None
personal_course_id: str | None = None personal_course_id: str | None = None
tee_name: 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 holes_planned: Literal[9, 18] | None = None
start_hole: int | None = Field(default=None, ge=1, le=18) start_hole: int | None = Field(default=None, ge=1, le=18)
# Utslagstidspunkt og "Ferdig"-tidspunkt -- begge kan justeres i # 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) await conn.execute("UPDATE round SET holes_planned = $2 WHERE id = $1", round_id, body.holes_planned)
if body.start_hole is not None: if body.start_hole is not None:
await conn.execute("UPDATE round SET start_hole = $2 WHERE id = $1", round_id, body.start_hole) 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: if body.started_at is not None:
await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at) await conn.execute("UPDATE round SET started_at = $2 WHERE id = $1", round_id, body.started_at)
if body.completed_at is not None: if body.completed_at is not None:

View file

@ -42,6 +42,123 @@ const BAG_CLUBS = [
] as const ] as const
const MAX_BAG_CLUBS = 14 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 (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id} className="text-sm font-semibold">
Land
</Label>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
{COUNTRIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
)
}
// 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<HomeClubOption[]>([])
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative flex flex-col gap-1.5">
<Label htmlFor={id} className="text-sm font-semibold">
Hjemmeklubb
</Label>
<Input
id={id}
value={query}
onChange={(e) => {
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 && (
<ul className="absolute top-full z-20 mt-1 max-h-64 w-full overflow-auto rounded-xl border border-border bg-card shadow-md shadow-black/10">
{results.map((option) => (
<li key={option.slug}>
<button
type="button"
onClick={() => selectClub(option)}
className="flex min-h-11 w-full flex-col items-start justify-center px-3 py-1.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{option.name}</span>
{(option.city || option.county) && (
<span className="text-xs text-muted-foreground">
{[option.city, option.county].filter(Boolean).join(", ")}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
)
}
type Me = { type Me = {
id: string id: string
email: 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 // WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell
// HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske. // HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske.
const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index)) 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 [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [country, setCountry] = useState(me.country ?? "")
const [bio, setBio] = useState(me.bio ?? "") const [bio, setBio] = useState(me.bio ?? "")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(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å. Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.
</p> </p>
</div> </div>
<div className="flex flex-col gap-1.5"> <CountryField id="ob-country" value={country} onChange={setCountry} />
<Label htmlFor="ob-home-club" className="text-sm font-semibold"> <HomeClubField id="ob-home-club" value={homeClub} onChange={setHomeClub} />
Hjemmeklubb
</Label>
<Input
id="ob-home-club"
value={homeClub}
onChange={(e) => setHomeClub(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-country" className="text-sm font-semibold">
Land
</Label>
<Input
id="ob-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2"> <div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="ob-bio" className="text-sm font-semibold"> <Label htmlFor="ob-bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span> Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
@ -434,8 +534,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [birthDate, setBirthDate] = useState(me.birth_date ?? "") const [birthDate, setBirthDate] = useState(me.birth_date ?? "")
const [gender, setGender] = useState(me.gender ?? "") const [gender, setGender] = useState(me.gender ?? "")
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index)) const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
const [country, setCountry] = useState(me.country ?? COUNTRIES[0])
const [homeClub, setHomeClub] = useState(me.home_club ?? "") const [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [country, setCountry] = useState(me.country ?? "")
const [bio, setBio] = useState(me.bio ?? "") const [bio, setBio] = useState(me.bio ?? "")
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47") const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "") const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
@ -631,28 +731,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
className="h-11 rounded-xl" className="h-11 rounded-xl"
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <CountryField id="country" value={country} onChange={setCountry} />
<Label htmlFor="home-club" className="text-sm font-semibold"> <HomeClubField id="home-club" value={homeClub} onChange={setHomeClub} />
Hjemmeklubb
</Label>
<Input
id="home-club"
value={homeClub}
onChange={(e) => setHomeClub(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="country" className="text-sm font-semibold">
Land
</Label>
<Input
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2"> <div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="bio" className="text-sm font-semibold"> <Label htmlFor="bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span> Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>

View file

@ -191,6 +191,7 @@ export function NewRound() {
} }
async function submitRound(payload: { async function submitRound(payload: {
name: string
teeName: string teeName: string
date: string date: string
startedAt: string | null startedAt: string | null
@ -206,6 +207,7 @@ export function NewRound() {
teeoff_facility_slug: courseMeta.facilitySlug, teeoff_facility_slug: courseMeta.facilitySlug,
teeoff_course_id: courseMeta.teeoffCourseId, teeoff_course_id: courseMeta.teeoffCourseId,
tee_name: payload.teeName, tee_name: payload.teeName,
name: payload.name || null,
played_at: payload.date, played_at: payload.date,
started_at: payload.startedAt, started_at: payload.startedAt,
start_hole: payload.startHole, start_hole: payload.startHole,
@ -216,6 +218,7 @@ export function NewRound() {
course_source: "custom", course_source: "custom",
personal_course_id: courseMeta.personalCourseId, personal_course_id: courseMeta.personalCourseId,
tee_name: payload.teeName, tee_name: payload.teeName,
name: payload.name || null,
played_at: payload.date, played_at: payload.date,
started_at: payload.startedAt, started_at: payload.startedAt,
start_hole: payload.startHole, start_hole: payload.startHole,
@ -1100,6 +1103,7 @@ function ConfirmStep({
course: Course course: Course
ownGender: Gender ownGender: Gender
onSubmit: (payload: { onSubmit: (payload: {
name: string
teeName: string teeName: string
date: string date: string
startedAt: string | null startedAt: string | null
@ -1111,6 +1115,7 @@ function ConfirmStep({
}) { }) {
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women)) const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "") const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
const [name, setName] = useState("")
const [date, setDate] = useState(todayIso) const [date, setDate] = useState(todayIso)
const [teeTime, setTeeTime] = useState("") const [teeTime, setTeeTime] = useState("")
const [startHole, setStartHole] = useState("1") const [startHole, setStartHole] = useState("1")
@ -1131,6 +1136,7 @@ function ConfirmStep({
// konvertert til UTC av toISOString()) kun når klokkeslett er satt. // konvertert til UTC av toISOString()) kun når klokkeslett er satt.
const startedAt = teeTime ? new Date(`${date}T${teeTime}`).toISOString() : null const startedAt = teeTime ? new Date(`${date}T${teeTime}`).toISOString() : null
const created = await onSubmit({ const created = await onSubmit({
name: name.trim(),
teeName: selectedTee.name, teeName: selectedTee.name,
date, date,
startedAt, startedAt,
@ -1162,6 +1168,21 @@ function ConfirmStep({
{error && <p className="text-base font-medium text-destructive">{error}</p>} {error && <p className="text-base font-medium text-destructive">{error}</p>}
{/* Round name */}
<div className="flex flex-col gap-2">
<Label htmlFor="round-name" className="text-base font-semibold">
Navn runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="round-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={course.name}
maxLength={200}
className="h-14 rounded-2xl text-lg"
/>
</div>
{/* Tee */} {/* Tee */}
{compatibleTees.length === 0 ? ( {compatibleTees.length === 0 ? (
<p role="alert" className="text-base font-medium text-destructive"> <p role="alert" className="text-base font-medium text-destructive">

View file

@ -23,6 +23,7 @@ type ApiRoundParticipant = {
type ApiRound = { type ApiRound = {
id: string id: string
name: string | null
course_name_snapshot: string course_name_snapshot: string
tee_name_snapshot: string tee_name_snapshot: string
played_at: string played_at: string
@ -39,6 +40,7 @@ function toRound(r: ApiRound): Round {
const differential = owner?.counts_for_handicap ? owner.score_differential : null const differential = owner?.counts_for_handicap ? owner.score_differential : null
return { return {
id: r.id, id: r.id,
name: r.name,
courseName: r.course_name_snapshot, courseName: r.course_name_snapshot,
status: r.completed_at ? "completed" : "active", status: r.completed_at ? "completed" : "active",
teeName: r.tee_name_snapshot, teeName: r.tee_name_snapshot,

View file

@ -6,6 +6,7 @@ export type RoundStatus = "active" | "completed"
export type Round = { export type Round = {
id: string id: string
name?: string | null
courseName: string courseName: string
status: RoundStatus status: RoundStatus
teeName: string teeName: string
@ -146,7 +147,8 @@ export function RoundCard({ round }: { round: Round }) {
round.status === "completed" && hasScore round.status === "completed" && hasScore
? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.` ? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.`
: `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.` : `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.`
const ariaLabel = `${round.courseName}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}` const displayTitle = round.name?.trim() || round.courseName
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
return ( return (
<Link <Link
@ -158,12 +160,13 @@ export function RoundCard({ round }: { round: Round }) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<h3 className="flex min-w-0 items-center gap-2 text-lg font-bold text-foreground"> <h3 className="flex min-w-0 items-center gap-2 text-lg font-bold text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" /> <MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{round.courseName}</span> <span className="truncate">{displayTitle}</span>
</h3> </h3>
<RoundStatusBadge status={round.status} /> <RoundStatusBadge status={round.status} />
</div> </div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground">
{round.name?.trim() && <span className="truncate">{round.courseName}</span>}
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" /> <Flag aria-hidden="true" className="size-4 shrink-0" />
<span> <span>

View file

@ -130,6 +130,7 @@ type ApiParticipant = {
type ApiRound = { type ApiRound = {
id: string id: string
name: string | null
course_name_snapshot: string course_name_snapshot: string
tee_name_snapshot: string tee_name_snapshot: string
played_at: string played_at: string
@ -506,9 +507,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground"> <h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" /> <MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{round.course_name_snapshot}</span> <span className="truncate">{round.name?.trim() || round.course_name_snapshot}</span>
</h1> </h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
{round.name?.trim() && (
<span className="flex items-center gap-1.5 truncate">
<MapPin aria-hidden="true" className="size-4 shrink-0" />
{round.course_name_snapshot}
</span>
)}
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" /> <Flag aria-hidden="true" className="size-4 shrink-0" />
{round.tee_name_snapshot} {round.tee_name_snapshot}
@ -1354,6 +1361,7 @@ function EditRoundPanel({
onClose: () => void onClose: () => void
}) { }) {
const isCompleted = round.completed_at !== null const isCompleted = round.completed_at !== null
const [name, setName] = useState(round.name ?? "")
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18) const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18)
const [startHole, setStartHole] = useState(String(round.start_hole)) const [startHole, setStartHole] = useState(String(round.start_hole))
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at)) const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
@ -1363,6 +1371,7 @@ function EditRoundPanel({
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const hasMetadataChanges = const hasMetadataChanges =
name.trim() !== (round.name ?? "") ||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) || (!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
toDatetimeLocalValue(round.started_at) !== startedAt || toDatetimeLocalValue(round.started_at) !== startedAt ||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt)
@ -1371,6 +1380,9 @@ function EditRoundPanel({
setSaving(true) setSaving(true)
setError(null) setError(null)
const body: Record<string, unknown> = {} const body: Record<string, unknown> = {}
// Tom streng betyr "fjern navnet" (backend-kontrakt) -- send den derfor
// alltid med når feltet faktisk er endret, aldri utelatt for å tømme.
if (name.trim() !== (round.name ?? "")) body.name = name.trim()
if (!isCompleted) { if (!isCompleted) {
if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole) if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole)
@ -1395,6 +1407,20 @@ function EditRoundPanel({
{error && <p className="text-sm font-medium text-destructive">{error}</p>} {error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
<Label htmlFor="edit-round-name" className="text-sm font-semibold">
Navn runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="edit-round-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={round.course_name_snapshot}
maxLength={200}
className="h-11 rounded-xl text-base"
/>
</div>
{!isCompleted && ( {!isCompleted && (
<> <>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View file

@ -26,6 +26,7 @@ type ApiParticipant = {
} }
type ApiRound = { type ApiRound = {
name: string | null
id: string id: string
course_name_snapshot: string course_name_snapshot: string
tee_name_snapshot: string tee_name_snapshot: string
@ -412,9 +413,10 @@ export function RoundScorecard({ roundId }: { roundId: string }) {
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.course_name_snapshot} {round.name?.trim() || round.course_name_snapshot}
</span> </span>
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "} {round.tee_name_snapshot} {"·"} {round.holes_planned} hull {"·"}{" "}
{dateFormatter.format(new Date(round.played_at))} {dateFormatter.format(new Date(round.played_at))}
</span> </span>

View file

@ -363,6 +363,7 @@ type ApiParticipant = {
type ApiRound = { type ApiRound = {
id: string id: string
name: string | null
course_name_snapshot: string course_name_snapshot: string
tee_name_snapshot: string tee_name_snapshot: string
played_at: string played_at: string
@ -648,9 +649,10 @@ export function RoundStats({ roundId }: { roundId: string }) {
<div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5"> <div className="flex flex-wrap items-baseline justify-between gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance"> <span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{round.course_name_snapshot} {round.name?.trim() || round.course_name_snapshot}
</span> </span>
<span className="text-sm font-semibold text-muted-foreground"> <span className="text-sm font-semibold text-muted-foreground">
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
{round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))} {round.tee_name_snapshot} · {round.holes_planned} hull · {dateFormatter.format(new Date(round.played_at))}
</span> </span>
</div> </div>