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:
parent
714c64e82e
commit
926a1a5275
10 changed files with 227 additions and 56 deletions
6
024_round_name.sql
Normal file
6
024_round_name.sql
Normal 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;
|
||||
|
|
@ -432,6 +432,12 @@ 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.
|
||||
# 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():
|
||||
|
|
|
|||
|
|
@ -337,6 +337,11 @@ 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.")
|
||||
|
||||
# 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),
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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 = {
|
||||
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<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å.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ob-home-club" className="text-sm font-semibold">
|
||||
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>
|
||||
<CountryField id="ob-country" value={country} onChange={setCountry} />
|
||||
<HomeClubField id="ob-home-club" value={homeClub} onChange={setHomeClub} />
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="ob-bio" className="text-sm font-semibold">
|
||||
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 [gender, setGender] = useState(me.gender ?? "")
|
||||
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 [country, setCountry] = useState(me.country ?? "")
|
||||
const [bio, setBio] = useState(me.bio ?? "")
|
||||
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
|
||||
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
|
||||
|
|
@ -631,28 +731,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
className="h-11 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="home-club" className="text-sm font-semibold">
|
||||
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>
|
||||
<CountryField id="country" value={country} onChange={setCountry} />
|
||||
<HomeClubField id="home-club" value={homeClub} onChange={setHomeClub} />
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<Label htmlFor="bio" className="text-sm font-semibold">
|
||||
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export function NewRound() {
|
|||
}
|
||||
|
||||
async function submitRound(payload: {
|
||||
name: string
|
||||
teeName: string
|
||||
date: string
|
||||
startedAt: string | null
|
||||
|
|
@ -206,6 +207,7 @@ export function NewRound() {
|
|||
teeoff_facility_slug: courseMeta.facilitySlug,
|
||||
teeoff_course_id: courseMeta.teeoffCourseId,
|
||||
tee_name: payload.teeName,
|
||||
name: payload.name || null,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
|
|
@ -216,6 +218,7 @@ export function NewRound() {
|
|||
course_source: "custom",
|
||||
personal_course_id: courseMeta.personalCourseId,
|
||||
tee_name: payload.teeName,
|
||||
name: payload.name || null,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
|
|
@ -1100,6 +1103,7 @@ function ConfirmStep({
|
|||
course: Course
|
||||
ownGender: Gender
|
||||
onSubmit: (payload: {
|
||||
name: string
|
||||
teeName: string
|
||||
date: string
|
||||
startedAt: string | null
|
||||
|
|
@ -1111,6 +1115,7 @@ function ConfirmStep({
|
|||
}) {
|
||||
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
|
||||
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
|
||||
const [name, setName] = useState("")
|
||||
const [date, setDate] = useState(todayIso)
|
||||
const [teeTime, setTeeTime] = useState("")
|
||||
const [startHole, setStartHole] = useState("1")
|
||||
|
|
@ -1131,6 +1136,7 @@ function ConfirmStep({
|
|||
// konvertert til UTC av toISOString()) kun når klokkeslett er satt.
|
||||
const startedAt = teeTime ? new Date(`${date}T${teeTime}`).toISOString() : null
|
||||
const created = await onSubmit({
|
||||
name: name.trim(),
|
||||
teeName: selectedTee.name,
|
||||
date,
|
||||
startedAt,
|
||||
|
|
@ -1162,6 +1168,21 @@ function ConfirmStep({
|
|||
|
||||
{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 på 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 */}
|
||||
{compatibleTees.length === 0 ? (
|
||||
<p role="alert" className="text-base font-medium text-destructive">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type ApiRoundParticipant = {
|
|||
|
||||
type ApiRound = {
|
||||
id: string
|
||||
name: string | null
|
||||
course_name_snapshot: string
|
||||
tee_name_snapshot: string
|
||||
played_at: string
|
||||
|
|
@ -39,6 +40,7 @@ function toRound(r: ApiRound): Round {
|
|||
const differential = owner?.counts_for_handicap ? owner.score_differential : null
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
courseName: r.course_name_snapshot,
|
||||
status: r.completed_at ? "completed" : "active",
|
||||
teeName: r.tee_name_snapshot,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type RoundStatus = "active" | "completed"
|
|||
|
||||
export type Round = {
|
||||
id: string
|
||||
name?: string | null
|
||||
courseName: string
|
||||
status: RoundStatus
|
||||
teeName: string
|
||||
|
|
@ -146,7 +147,8 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
round.status === "completed" && hasScore
|
||||
? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.`
|
||||
: `${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 (
|
||||
<Link
|
||||
|
|
@ -158,12 +160,13 @@ export function RoundCard({ round }: { round: Round }) {
|
|||
<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">
|
||||
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
|
||||
<span className="truncate">{round.courseName}</span>
|
||||
<span className="truncate">{displayTitle}</span>
|
||||
</h3>
|
||||
<RoundStatusBadge status={round.status} />
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ type ApiParticipant = {
|
|||
|
||||
type ApiRound = {
|
||||
id: string
|
||||
name: string | null
|
||||
course_name_snapshot: string
|
||||
tee_name_snapshot: 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">
|
||||
<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" />
|
||||
<span className="truncate">{round.course_name_snapshot}</span>
|
||||
<span className="truncate">{round.name?.trim() || round.course_name_snapshot}</span>
|
||||
</h1>
|
||||
<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">
|
||||
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||
{round.tee_name_snapshot}
|
||||
|
|
@ -1354,6 +1361,7 @@ function EditRoundPanel({
|
|||
onClose: () => void
|
||||
}) {
|
||||
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 [startHole, setStartHole] = useState(String(round.start_hole))
|
||||
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
|
||||
|
|
@ -1363,6 +1371,7 @@ function EditRoundPanel({
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const hasMetadataChanges =
|
||||
name.trim() !== (round.name ?? "") ||
|
||||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
||||
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
||||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt)
|
||||
|
|
@ -1371,6 +1380,9 @@ function EditRoundPanel({
|
|||
setSaving(true)
|
||||
setError(null)
|
||||
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 (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
|
||||
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>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="edit-round-name" className="text-sm font-semibold">
|
||||
Navn på 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 && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type ApiParticipant = {
|
|||
}
|
||||
|
||||
type ApiRound = {
|
||||
name: string | null
|
||||
id: string
|
||||
course_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-col">
|
||||
<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 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))}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ type ApiParticipant = {
|
|||
|
||||
type ApiRound = {
|
||||
id: string
|
||||
name: string | null
|
||||
course_name_snapshot: string
|
||||
tee_name_snapshot: 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-col">
|
||||
<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 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))}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue