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
|
# 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.
|
||||||
|
# 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'}"
|
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():
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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.")
|
||||||
|
|
||||||
|
# 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'}"
|
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),
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 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 */}
|
{/* 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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 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 && (
|
{!isCompleted && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue