Oppsummering av det som er fikset/bygget i denne runden:

Starthull-bugen (og trolig GIR-avviket den forårsaket) — fikset
Kølle-bag i profilen (28 faste kølletyper, maks 14) — bygget
Anywayslag-statistikkfelt — bygget
Valgfritt statistikknivå per deltaker (Kun slag / Slag og putter / All statistikk, default Kun slag) — bygget
Putt-avstand som faste bøtter i stedet for fritekst — bygget
«Hullet er spilt»-avkrysningen fjernet — gjort
Bekreft, så kjører jeg migrasjonen og ruller ut.
This commit is contained in:
Erol Haagenrud 2026-07-24 06:27:35 +02:00
parent 29a6b90127
commit 65f876e6dc
8 changed files with 504 additions and 139 deletions

View file

@ -370,7 +370,10 @@
"Bash(grep -n '\"/rounds\\\\|`/rounds\\\\|href=\"/rounds\\\\|href={`/rounds\\\\|router\\\\.\\\\\\(push\\\\|replace\\\\\\)\\(.*rounds' components/own-rounds.tsx components/new-round.tsx components/round-detail.tsx components/round-card.tsx components/dashboard.tsx)",
"Bash(curl -s -o /dev/null -w \"my-rounds: %{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds)",
"Bash(curl -s -o /dev/null -w \"my-rounds/new: %{http_code}\\\\n\" https://teecup.teeoff.no/my-rounds/new)",
"Bash(curl -s https://teecup.teeoff.no/rounds)"
"Bash(curl -s https://teecup.teeoff.no/rounds)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/auth.py'\\).read\\(\\)\\)\")",
"Bash(grep -n \"PlayedToggle\\\\|AddGuestForm onAdd\\\\|Putter\\\\\"\\\\|Flere detaljer\\\\|Kølle brukt\\\\|Avstand første\\\\|goPrev} className\\\\|PlayerTabs$\\\\|players={players}\" /opt/teecup/frontend/components/round-detail.tsx)",
"Bash(python3 test_round_stats_and_bag.py)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -0,0 +1,48 @@
-- Frittstående rundeføring (ADR-033), oppfølging 2026-07-24: kølle-bag,
-- valgfritt statistikknivå per deltaker, anywayslag, og bøtte-inndelt
-- lengde på første putt (erstatter det opprinnelige frie tallfeltet).
-- ---------------------------------------------------------------------
-- Kølle-bag (personlig profil, ADR-031-mønster -- ETT sett per konto)
-- ---------------------------------------------------------------------
-- Fast, ikke-utvidbar liste (28 kølletyper) -- app-laget (Pydantic Literal)
-- er den primære validatoren, denne CHECK-en er en bakstopper i samme
-- ånd som gender-CHECK-en andre steder i skjemaet.
ALTER TABLE app_user ADD COLUMN bag_clubs text[];
ALTER TABLE app_user ADD CONSTRAINT app_user_bag_clubs_max_14
CHECK (bag_clubs IS NULL OR array_length(bag_clubs, 1) <= 14);
ALTER TABLE app_user ADD CONSTRAINT app_user_bag_clubs_known_values
CHECK (bag_clubs IS NULL OR bag_clubs <@ ARRAY[
'Driver', 'Mini-driver', 'Driving iron', '3-wood', '4-wood', '5-wood',
'7-wood', '9-wood', '2-hybrid', '3-hybrid', '4-hybrid', '5-hybrid',
'1-iron', '2-iron', '3-iron', '4-iron', '5-iron', '6-iron', '7-iron',
'8-iron', '9-iron', 'Pitching wedge', 'Gap wedge', 'Sand wedge',
'Lob wedge', 'Ultra-lob wedge', 'Chipper', 'Putter'
]::text[]);
-- ---------------------------------------------------------------------
-- Valgfritt statistikknivå per deltaker (default: kun slag -- alt annet
-- er strengt tatt ikke nødvendig for å beregne resultat/HCP)
-- ---------------------------------------------------------------------
ALTER TABLE round_participant ADD COLUMN stat_level text NOT NULL DEFAULT 'strokes_only'
CHECK (stat_level IN ('strokes_only', 'strokes_and_putts', 'full'));
-- ---------------------------------------------------------------------
-- round_hole: anywayslag (nytt) + bøtte-inndelt førsteputt-lengde
-- (erstatter first_putt_distance_m -- ingen produksjonsdata å bevare,
-- kun 0 rader hadde verdi satt da dette ble besluttet)
-- ---------------------------------------------------------------------
ALTER TABLE round_hole ADD COLUMN anyway_strokes smallint
CHECK (anyway_strokes IS NULL OR anyway_strokes >= 0);
ALTER TABLE round_hole DROP COLUMN first_putt_distance_m;
ALTER TABLE round_hole ADD COLUMN first_putt_distance_bucket text
CHECK (first_putt_distance_bucket IS NULL OR first_putt_distance_bucket IN (
'<1m', '<2m', '<3m', '<5m', '<8m', '8m+'
));

View file

@ -1405,6 +1405,38 @@ backend, og rundedetalj-siden var helt uoppnåelig). Fikset ved å flytte
frontend til `/my-rounds/*`, API uendret. Se ADR-033 for full detalj,
inkl. `curl`-bevis før/etter.
**Sju punkter rapportert av bruker 2026-07-24 etter faktisk bruk, BYGGET
OG LIVE samme dag** (unntatt punkt 6, se eget notat under): starthull-bug
(currentHole respekterte aldri `round.start_hole`, forklarte trolig også
det rapporterte GIR-avviket — feil hull ga feil par inn i en ellers
korrekt formel), kølle-bag i profilen (28 faste kølletyper, maks 14 --
den ekte golfregelen -- brukt som knapp-utvalg for "kølle brukt ved
utslaget", kun for eieren selv siden gjester ikke har profil), nytt
statistikkfelt "Anywayslag" (siste punkt i "Flere detaljer", samme
tallvelger-stil som slag/putter), valgfritt statistikknivå per deltaker
(`strokes_only`/`strokes_and_putts`/`full`, default `strokes_only` --
kun slag er strengt tatt nødvendig for resultat/HCP, resten er valgfritt
og skjules helt til det slås på), putt-avstand endret fra fritekst-tall
til seks faste bøtter (`<1m``8m+`), og "Hullet er spilt"-avkrysningen
fjernet helt (var reelt overflødig -- spilt settes allerede automatisk
når et slagtall velges). Ny migrasjon `022_round_stats_and_bag.sql`.
**Punkt 6 (numpad-layout for tallvelgerne + retningskors-ikoner for
utslag/innspill + vurdering av sveip vs. scroll) er BEVISST IKKE bygget
selv** — brukeren ba eksplisitt om at dette prompres til V0 for en egen
vurdering, se egen V0-prompt utarbeidet samme dag (ikke kjørt av
brukeren ennå ved denne loggens skriving).
**Fanget, IKKE bygget (brukerens egen kommentar mens punkt 7 ble
avklart):** "plukket opp"-mulighet for Stableford-format -- i Stableford
er det vanlig å plukke opp ballen uten å fullføre hullet når det er
klart 0 poeng uansett. Frittstående runder har i dag INGEN
Stableford-poengberegning i det hele tatt (kun rå slagtall for HCP-
differensial) -- dette er et helt eget, udesignet format-spørsmål, ikke
løst av at "spilt"-avkrysningen ble fjernet. Trenger egen designrunde
(scoring-format-valg per runde, poengberegning, og en "plukket opp"-
tilstand som sannsynligvis bør lagres som en cap på nettoscore, samme
prinsipp som WHS sin Net Double Bogey) den dagen Stableford faktisk
bygges.
**Notat fra bruker, IKKE designet/bygget ennå (fanget 2026-07-22):**
brukeren har tenkt å ha med (a) måling av lengde på slag, og (b) å kunne
få opplyst avstand til forskjellige steder på banen (typisk pin/hazard/

View file

@ -615,6 +615,20 @@ class SecondaryEmailOut(BaseModel):
email: str
# Kølle-bag (2026-07-24, ADR-031-mønster): fast, ikke-utvidbar liste --
# samme liste brukes i migrasjon 022 sin CHECK-constraint (bakstopper) og
# frontend sin avkrysningsliste. Maks 14 (den ekte golfregelen), håndhevet
# under i ProfileUpdate.
BAG_CLUBS: list[str] = [
"Driver", "Mini-driver", "Driving iron", "3-wood", "4-wood", "5-wood",
"7-wood", "9-wood", "2-hybrid", "3-hybrid", "4-hybrid", "5-hybrid",
"1-iron", "2-iron", "3-iron", "4-iron", "5-iron", "6-iron", "7-iron",
"8-iron", "9-iron", "Pitching wedge", "Gap wedge", "Sand wedge",
"Lob wedge", "Ultra-lob wedge", "Chipper", "Putter",
]
BagClub = Literal[tuple(BAG_CLUBS)] # type: ignore[valid-type]
class Me(BaseModel):
id: str
email: str
@ -636,6 +650,7 @@ class Me(BaseModel):
bio: str | None
mobile_country_code: str | None
mobile_number: str | None
bag_clubs: list[str]
avatar_url: str | None
# Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske
# profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/
@ -671,7 +686,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
(password_hash IS NOT NULL) AS has_password, two_factor_method,
first_name, last_name, birth_date, gender,
handicap_index::float AS handicap_index, home_club, country, bio,
mobile_country_code, mobile_number, avatar_key
mobile_country_code, mobile_number, bag_clubs, avatar_key
FROM app_user WHERE id = $1
""",
user.user_id,
@ -755,6 +770,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
bio=user_row["bio"],
mobile_country_code=user_row["mobile_country_code"],
mobile_number=user_row["mobile_number"],
bag_clubs=list(user_row["bag_clubs"]) if user_row["bag_clubs"] else [],
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
profile_complete=profile_complete,
my_tournaments=my_tournaments,
@ -786,6 +802,9 @@ class ProfileUpdate(BaseModel):
# måtte parse/splitte en fritekststreng i etterkant.
mobile_country_code: str | None = Field(default=None, max_length=5)
mobile_number: str | None = Field(default=None, max_length=30)
# Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke
# en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen.
bag_clubs: list[BagClub] | None = Field(default=None, max_length=14)
@router.patch("/profile", response_model=Me)

View file

@ -343,6 +343,12 @@ async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
# Runder
# ---------------------------------------------------------------------------
# Statistikknivå per deltaker (2026-07-24): kun slag er strengt tatt
# nødvendig for resultat/HCP -- putter og "flere detaljer" er valgfritt,
# av som default. GIR krever putts og vises derfor ikke i strokes_only.
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
class RoundCreate(BaseModel):
course_source: Literal["teeoff", "custom"]
teeoff_facility_slug: str | None = None
@ -352,6 +358,7 @@ class RoundCreate(BaseModel):
played_at: date
start_hole: int = Field(default=1, ge=1, le=18)
holes_planned: Literal[9, 18] = 18
stat_level: StatLevel = "strokes_only"
class RoundParticipantOut(BaseModel):
@ -364,6 +371,7 @@ class RoundParticipantOut(BaseModel):
course_handicap_snapshot: int | None
counts_for_handicap: bool
score_differential: float | None
stat_level: StatLevel
class RoundOut(BaseModel):
@ -392,7 +400,7 @@ async def _load_round_out(conn, round_id: str) -> RoundOut:
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential
score_differential::float AS score_differential, stat_level
FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
""",
round_id,
@ -421,6 +429,7 @@ async def _create_participant(
is_owner: bool,
gender: str,
handicap_index: float | None,
stat_level: StatLevel = "strokes_only",
) -> str:
rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None
@ -434,8 +443,9 @@ async def _create_participant(
"""
INSERT INTO round_participant
(round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
stat_level)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id::text AS id
""",
round_id,
@ -448,6 +458,7 @@ async def _create_participant(
slope_rating,
tee_par,
course_handicap_snapshot,
stat_level,
)
participant_id = participant_row["id"]
@ -517,6 +528,7 @@ async def create_round(
conn, round_id, resolved, body.tee_name,
user_id=user.user_id, guest_name=None, is_owner=True,
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level,
)
return await _load_round_out(conn, round_id)
@ -562,6 +574,7 @@ class GuestParticipantCreate(BaseModel):
guest_name: str = Field(min_length=1, max_length=100)
gender: Literal["m", "f", "x"]
handicap_index: float | None = Field(default=None, ge=-10, le=54)
stat_level: StatLevel = "strokes_only"
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
@ -592,13 +605,14 @@ async def add_guest_participant(
conn, round_id, resolved, round_row["tee_name_snapshot"],
user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
gender=body.gender, handicap_index=body.handicap_index,
stat_level=body.stat_level,
)
row = await conn.fetchrow(
"""
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential
score_differential::float AS score_differential, stat_level
FROM round_participant WHERE id = $1
""",
participant_id,
@ -606,6 +620,35 @@ async def add_guest_participant(
return RoundParticipantOut(**dict(row))
class ParticipantUpdate(BaseModel):
stat_level: StatLevel
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
async def update_participant(
round_id: str,
participant_id: str,
body: ParticipantUpdate,
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
row = await conn.fetchrow(
"""
UPDATE round_participant SET stat_level = $3
WHERE id = $1 AND round_id = $2
RETURNING id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap,
score_differential::float AS score_differential, stat_level
""",
participant_id, round_id, body.stat_level,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
return RoundParticipantOut(**dict(row))
@router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204)
async def remove_guest_participant(
round_id: str,
@ -642,7 +685,8 @@ class RoundHoleOut(BaseModel):
chip_count: int | None
bunker_shot_count: int | None
penalty_strokes: int | None
first_putt_distance_m: float | None
first_putt_distance_bucket: str | None
anyway_strokes: int | None
@router.get(
@ -666,7 +710,7 @@ async def list_holes(
"""
SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m
penalty_strokes, first_putt_distance_bucket, anyway_strokes
FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""",
participant_id,
@ -684,7 +728,8 @@ class HoleUpdate(BaseModel):
chip_count: int | None = Field(default=None, ge=0)
bunker_shot_count: int | None = Field(default=None, ge=0)
penalty_strokes: int | None = Field(default=None, ge=0)
first_putt_distance_m: float | None = Field(default=None, ge=0)
first_putt_distance_bucket: Literal["<1m", "<2m", "<3m", "<5m", "<8m", "8m+"] | None = None
anyway_strokes: int | None = Field(default=None, ge=0)
@router.patch(
@ -713,16 +758,18 @@ async def update_hole(
UPDATE round_hole SET
played = $3, score = $4, putts = $5, club_off_tee = $6,
tee_shot_result = $7, approach_result = $8, chip_count = $9,
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_m = $12
bunker_shot_count = $10, penalty_strokes = $11, first_putt_distance_bucket = $12,
anyway_strokes = $13
WHERE round_participant_id = $1 AND hole_number = $2
RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count,
penalty_strokes, first_putt_distance_m::float AS first_putt_distance_m
penalty_strokes, first_putt_distance_bucket, anyway_strokes
""",
participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_m,
body.bunker_shot_count, body.penalty_strokes, body.first_putt_distance_bucket,
body.anyway_strokes,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")

View file

@ -31,6 +31,17 @@ import { TwoFactorSetupForm } from "@/components/two-factor-flow"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer.
// Kølle-bag (2026-07-24): fast, ikke-utvidbar liste -- MÅ matche
// BAG_CLUBS i app/routers/auth.py nøyaktig (backend validerer strengt).
const BAG_CLUBS = [
"Driver", "Mini-driver", "Driving iron", "3-wood", "4-wood", "5-wood",
"7-wood", "9-wood", "2-hybrid", "3-hybrid", "4-hybrid", "5-hybrid",
"1-iron", "2-iron", "3-iron", "4-iron", "5-iron", "6-iron", "7-iron",
"8-iron", "9-iron", "Pitching wedge", "Gap wedge", "Sand wedge",
"Lob wedge", "Ultra-lob wedge", "Chipper", "Putter",
] as const
const MAX_BAG_CLUBS = 14
type Me = {
id: string
email: string
@ -47,6 +58,7 @@ type Me = {
bio: string | null
mobile_country_code: string | null
mobile_number: string | null
bag_clubs: string[]
avatar_url: string | null
// 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse
// teller ikke). Se AccountSettings sin bruk under.
@ -427,6 +439,7 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [bio, setBio] = useState(me.bio ?? "")
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
const [bagClubs, setBagClubs] = useState<string[]>(me.bag_clubs)
const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -454,6 +467,7 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
bio: bio.trim() === "" ? null : bio.trim(),
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
bag_clubs: bagClubs,
}),
})
if (!res.ok) {
@ -677,6 +691,46 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<Label className="text-sm font-semibold">Køllebag</Label>
<span className="text-xs font-medium text-muted-foreground">
{bagClubs.length}/{MAX_BAG_CLUBS} valgt
</span>
</div>
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
Velg køllene du spiller med (maks {MAX_BAG_CLUBS}). Disse blir tilgjengelige som
valg når du registrerer hvilken kølle du slo ut med i en runde.
</p>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{BAG_CLUBS.map((club) => {
const checked = bagClubs.includes(club)
const disabled = !checked && bagClubs.length >= MAX_BAG_CLUBS
return (
<label
key={club}
className={`flex min-h-11 items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium ${
checked ? "border-primary bg-primary/10 text-foreground" : "border-border text-foreground"
} ${disabled ? "opacity-50" : "cursor-pointer"}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) =>
setBagClubs((prev) =>
e.target.checked ? [...prev, club] : prev.filter((c) => c !== club),
)
}
className="size-4 shrink-0 rounded border-border"
/>
{club}
</label>
)
})}
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}

View file

@ -70,6 +70,7 @@ type CourseMeta =
| { source: "custom"; personalCourseId: string }
type Gender = "m" | "f"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
// --- API-typer ---------------------------------------------------------------
@ -194,6 +195,7 @@ export function NewRound() {
date: string
startHole: number
holes: 9 | 18
statLevel: StatLevel
}): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane")
const body =
@ -206,6 +208,7 @@ export function NewRound() {
played_at: payload.date,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
}
: {
course_source: "custom",
@ -214,6 +217,7 @@ export function NewRound() {
played_at: payload.date,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
}
const res = await fetch("/rounds", {
method: "POST",
@ -1004,7 +1008,13 @@ function ConfirmStep({
}: {
course: Course
ownGender: Gender
onSubmit: (payload: { teeName: string; date: string; startHole: number; holes: 9 | 18 }) => Promise<{ id: string }>
onSubmit: (payload: {
teeName: string
date: string
startHole: number
holes: 9 | 18
statLevel: StatLevel
}) => Promise<{ id: string }>
onCreated: (roundId: string) => void
}) {
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
@ -1012,6 +1022,7 @@ function ConfirmStep({
const [date, setDate] = useState(todayIso)
const [startHole, setStartHole] = useState("1")
const [holes, setHoles] = useState<9 | 18>(18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -1027,6 +1038,7 @@ function ConfirmStep({
date,
startHole: Number(startHole),
holes,
statLevel,
})
onCreated(created.id)
} catch {
@ -1116,6 +1128,36 @@ function ConfirmStep({
</div>
</div>
{/* Statistikknivå -- kun slag er strengt tatt nødvendig for resultat/HCP */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Statistikk for deg selv</legend>
<div className="flex flex-col gap-2">
{(
[
{ value: "strokes_only", label: "Kun slag", desc: "Raskest -- bare det som trengs for resultat og HCP." },
{ value: "strokes_and_putts", label: "Slag og putter", desc: "Legger til putt-telling per hull." },
{ value: "full", label: "All statistikk", desc: "Kølle, retning, chip, bunker, straffeslag med mer." },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setStatLevel(opt.value)}
aria-pressed={statLevel === opt.value}
className={cn(
"flex min-h-[44px] flex-col items-start gap-0.5 rounded-2xl border px-5 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
statLevel === opt.value
? "border-primary bg-primary/10"
: "border-border bg-card hover:bg-accent/50",
)}
>
<span className="text-base font-bold text-foreground">{opt.label}</span>
<span className="text-sm text-muted-foreground">{opt.desc}</span>
</button>
))}
</div>
</fieldset>
{/* Hole count */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Antall hull</legend>

View file

@ -36,6 +36,8 @@ type Gender = "male" | "female" | "other"
type ApiGender = "m" | "f" | "x"
type TeeShot = "left" | "fairway" | "right"
type Approach = "left" | "short" | "hit" | "long" | "right"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"
type Player = {
id: string
@ -45,6 +47,7 @@ type Player = {
isSelf: boolean
countsForHandicap: boolean
scoreDifferential: number | null
statLevel: StatLevel
}
type HoleStat = {
@ -57,7 +60,8 @@ type HoleStat = {
chip: number
bunker: number
penalty: number
firstPuttDistance: string
firstPuttBucket: PuttBucket | null
anywayStrokes: number | null
}
type Hole = {
@ -77,7 +81,8 @@ function emptyStat(): HoleStat {
chip: 0,
bunker: 0,
penalty: 0,
firstPuttDistance: "",
firstPuttBucket: null,
anywayStrokes: null,
}
}
@ -111,6 +116,7 @@ type ApiParticipant = {
course_handicap_snapshot: number | null
counts_for_handicap: boolean
score_differential: number | null
stat_level: StatLevel
}
type ApiRound = {
@ -137,7 +143,8 @@ type ApiHole = {
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_m: number | null
first_putt_distance_bucket: PuttBucket | null
anyway_strokes: number | null
}
function apiHoleToStat(h: ApiHole): HoleStat {
@ -151,7 +158,8 @@ function apiHoleToStat(h: ApiHole): HoleStat {
chip: h.chip_count ?? 0,
bunker: h.bunker_shot_count ?? 0,
penalty: h.penalty_strokes ?? 0,
firstPuttDistance: h.first_putt_distance_m !== null ? String(h.first_putt_distance_m) : "",
firstPuttBucket: h.first_putt_distance_bucket,
anywayStrokes: h.anyway_strokes,
}
}
@ -166,7 +174,8 @@ function statToPatchBody(s: HoleStat) {
chip_count: s.chip,
bunker_shot_count: s.bunker,
penalty_strokes: s.penalty,
first_putt_distance_m: s.firstPuttDistance.trim() === "" ? null : Number(s.firstPuttDistance.replace(",", ".")),
first_putt_distance_bucket: s.firstPuttBucket,
anyway_strokes: s.anywayStrokes,
}
}
@ -181,7 +190,15 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const [error, setError] = useState<string | null>(null)
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
const [currentHole, setCurrentHole] = useState<number>(1)
// Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et
// knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
// for eieren selv (gjester har ingen profil å hente dette fra).
const [ownBagClubs, setOwnBagClubs] = useState<string[]>([])
// `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1
// er en gyldig, truthy hullverdi og ville gjort `prev || start_hole`
// lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
// hull 1 uansett faktisk starthull).
const [currentHole, setCurrentHole] = useState<number | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false)
@ -197,7 +214,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const data: ApiRound = await res.json()
setRound(data)
setActivePlayerId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
setCurrentHole((prev) => prev || data.start_hole)
setCurrentHole((prev) => prev ?? data.start_hole)
} catch {
setError("Klarte ikke å hente runden. Prøv igjen om litt.")
}
@ -207,6 +224,19 @@ export function RoundDetail({ roundId }: { roundId: string }) {
void loadRound()
}, [loadRound])
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { bag_clubs: string[] } | null) => {
if (!cancelled && data) setOwnBagClubs(data.bag_clubs)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const loadHoles = useCallback(
async (participantId: string) => {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
@ -235,15 +265,24 @@ export function RoundDetail({ roundId }: { roundId: string }) {
isSelf: p.is_owner,
countsForHandicap: p.counts_for_handicap,
scoreDifferential: p.score_differential,
statLevel: p.stat_level,
})) ?? []
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
const hole = holes.find((h) => h.holeNumber === currentHole) ?? null
const totalHoles = 18
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
// loadRound() har satt currentHole fra round.start_hole.
const activeHole = currentHole ?? round?.start_hole ?? 1
const hole = holes.find((h) => h.holeNumber === activeHole) ?? null
const currentApiHole = apiHoles?.find((h) => h.hole_number === currentHole) ?? null
// Navigasjonsrekkefølge starter på øktens starthull og går rundt (18
// hull, sirkulært) -- ikke bare stigende 1..18, som ville vist feil
// hull i fokus for en runde som starter et annet sted enn hull 1.
const holeOrder = round ? Array.from({ length: 18 }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1) : []
const orderedHoles = holeOrder.map((n) => holes.find((h) => h.holeNumber === n)).filter((h): h is Hole => h !== undefined)
const currentApiHole = apiHoles?.find((h) => h.hole_number === activeHole) ?? null
const currentStat = currentApiHole ? apiHoleToStat(currentApiHole) : emptyStat()
const showGir =
@ -256,7 +295,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
async function updateStat(patch: Partial<HoleStat>) {
if (readOnly || !activePlayerId || !currentApiHole) return
const merged: HoleStat = { ...currentStat, ...patch }
const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${currentHole}`, {
const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -275,20 +314,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}
function goPrev() {
setCurrentHole((h) => Math.max(1, h - 1))
const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i - 1 + 18) % 18])
setDetailsOpen(false)
}
function goNext() {
setCurrentHole((h) => Math.min(totalHoles, h + 1))
const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % 18])
setDetailsOpen(false)
}
async function addGuest(name: string, gender: Gender, hcp: number | null) {
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ guest_name: name, gender: uiGenderToApi(gender), handicap_index: hcp }),
body: JSON.stringify({
guest_name: name,
gender: uiGenderToApi(gender),
handicap_index: hcp,
stat_level: statLevel,
}),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
@ -308,6 +354,20 @@ export function RoundDetail({ roundId }: { roundId: string }) {
await loadRound()
}
async function updateStatLevel(participantId: string, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stat_level: statLevel }),
})
if (!res.ok) return
const updated: ApiParticipant = await res.json()
setRound((prev) =>
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
)
}
async function finishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
setCompleting(true)
@ -393,6 +453,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
readOnly={readOnly}
/>
{!readOnly && (
<StatLevelPicker
value={activePlayer.statLevel}
onChange={(v) => updateStatLevel(activePlayer.id, v)}
/>
)}
{showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />}
{/* Hole navigation */}
@ -403,8 +470,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
) : (
<>
<HoleNav
holes={holes}
currentHole={currentHole}
holes={orderedHoles}
currentHole={activeHole}
isPlayed={holeIsPlayed}
onSelect={(n) => {
setCurrentHole(n)
@ -426,8 +493,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)}
</div>
<PlayedToggle checked={currentStat.played} onChange={(v) => updateStat({ played: v })} readOnly={readOnly} />
<NumberPicker
label="Slag"
value={currentStat.strokes}
@ -438,6 +503,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
readOnly={readOnly}
/>
{activePlayer.statLevel !== "strokes_only" && (
<NumberPicker
label="Putter"
value={currentStat.putts}
@ -447,7 +513,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
onChange={(v) => updateStat({ putts: v })}
readOnly={readOnly}
/>
)}
{activePlayer.statLevel === "full" && (
<div className="rounded-2xl border border-border">
<button
type="button"
@ -464,9 +532,31 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{detailsOpen && (
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
<div className="flex flex-col gap-2">
<Label htmlFor="club" className="text-base font-semibold">
Kølle brukt ved utslaget
</Label>
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = currentStat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => updateStat({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
id="club"
value={currentStat.club}
@ -475,6 +565,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && (
@ -511,33 +602,41 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="putt-dist" className="text-base font-semibold">
Avstand første putt (m)
</Label>
<Input
id="putt-dist"
inputMode="decimal"
type="number"
min="0"
step="0.5"
value={currentStat.firstPuttDistance}
onChange={(e) => updateStat({ firstPuttDistance: e.target.value })}
placeholder="F.eks. 3.5"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={currentStat.firstPuttBucket}
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
<NumberPicker
label="Anywayslag"
value={currentStat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
onChange={(v) => updateStat({ anywayStrokes: v })}
readOnly={readOnly}
/>
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} disabled={currentHole <= 1} className="h-14 rounded-2xl text-base font-bold">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige
</Button>
<Button type="button" variant="outline" onClick={goNext} disabled={currentHole >= totalHoles} className="h-14 rounded-2xl text-base font-bold">
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
Neste hull
<ChevronRight aria-hidden="true" className="size-5" />
</Button>
@ -672,19 +771,20 @@ function AddGuestForm({
onAdd,
onCancel,
}: {
onAdd: (name: string, gender: Gender, hcp: number | null) => void
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
const [gender, setGender] = useState<Gender>("male")
const [hcp, setHcp] = useState("")
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null)
onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null, statLevel)
}
return (
@ -724,6 +824,18 @@ function AddGuestForm({
/>
</div>
<ChoiceRow
label="Statistikk for denne spilleren"
options={[
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]}
value={statLevel}
onChange={(v) => setStatLevel(v as StatLevel)}
readOnly={false}
/>
<div className="flex gap-3">
<Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
Legg til
@ -780,32 +892,40 @@ function HoleNav({
)
}
// --- Played toggle ---------------------------------------------------------
// --- Statistikknivå-velger ---------------------------------------------------
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
// overflødig -- score settes allerede automatisk til "spilt" idet et
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
// 2026-07-24 på brukerens eksplisitte bekreftelse.
function PlayedToggle({ checked, onChange, readOnly }: { checked: boolean; onChange: (value: boolean) => void; readOnly: boolean }) {
function StatLevelPicker({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
const options: { value: StatLevel; label: string }[] = [
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Statistikk</span>
<div className="flex gap-1.5">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="checkbox"
aria-checked={checked}
disabled={readOnly}
onClick={() => onChange(!checked)}
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"flex min-h-12 items-center gap-3 rounded-2xl border px-4 py-3 text-left transition-colors disabled:opacity-100",
checked ? "border-primary bg-primary/10" : "border-border bg-card hover:bg-accent/50",
"rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
>
<span
aria-hidden="true"
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-lg border-2 transition-colors",
checked ? "border-primary bg-primary text-primary-foreground" : "border-muted-foreground/50",
)}
>
{checked && <Check className="size-5" strokeWidth={3} />}
</span>
<span className="text-base font-bold text-foreground">Hullet er spilt</span>
{opt.label}
</button>
))}
</div>
</div>
)
}