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(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: %{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 -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": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/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, frontend til `/my-rounds/*`, API uendret. Se ADR-033 for full detalj,
inkl. `curl`-bevis før/etter. 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):** **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 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/ få opplyst avstand til forskjellige steder på banen (typisk pin/hazard/

View file

@ -615,6 +615,20 @@ class SecondaryEmailOut(BaseModel):
email: str 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): class Me(BaseModel):
id: str id: str
email: str email: str
@ -636,6 +650,7 @@ class Me(BaseModel):
bio: str | None bio: str | None
mobile_country_code: str | None mobile_country_code: str | None
mobile_number: str | None mobile_number: str | None
bag_clubs: list[str]
avatar_url: str | None avatar_url: str | None
# Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske # Obligatorisk-felt-sjekk (2026-07-22): true når alle obligatoriske
# profilfelt er utfylt (fornavn/etternavn/fødselsdato/kjønn/hcp/ # 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, (password_hash IS NOT NULL) AS has_password, two_factor_method,
first_name, last_name, birth_date, gender, first_name, last_name, birth_date, gender,
handicap_index::float AS handicap_index, home_club, country, bio, 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 FROM app_user WHERE id = $1
""", """,
user.user_id, user.user_id,
@ -755,6 +770,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
bio=user_row["bio"], bio=user_row["bio"],
mobile_country_code=user_row["mobile_country_code"], mobile_country_code=user_row["mobile_country_code"],
mobile_number=user_row["mobile_number"], 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, avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
profile_complete=profile_complete, profile_complete=profile_complete,
my_tournaments=my_tournaments, my_tournaments=my_tournaments,
@ -786,6 +802,9 @@ class ProfileUpdate(BaseModel):
# måtte parse/splitte en fritekststreng i etterkant. # måtte parse/splitte en fritekststreng i etterkant.
mobile_country_code: str | None = Field(default=None, max_length=5) mobile_country_code: str | None = Field(default=None, max_length=5)
mobile_number: str | None = Field(default=None, max_length=30) 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) @router.patch("/profile", response_model=Me)

View file

@ -343,6 +343,12 @@ async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
# Runder # 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): class RoundCreate(BaseModel):
course_source: Literal["teeoff", "custom"] course_source: Literal["teeoff", "custom"]
teeoff_facility_slug: str | None = None teeoff_facility_slug: str | None = None
@ -352,6 +358,7 @@ class RoundCreate(BaseModel):
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
stat_level: StatLevel = "strokes_only"
class RoundParticipantOut(BaseModel): class RoundParticipantOut(BaseModel):
@ -364,6 +371,7 @@ class RoundParticipantOut(BaseModel):
course_handicap_snapshot: int | None course_handicap_snapshot: int | None
counts_for_handicap: bool counts_for_handicap: bool
score_differential: float | None score_differential: float | None
stat_level: StatLevel
class RoundOut(BaseModel): 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, SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot, handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap, 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 FROM round_participant WHERE round_id = $1 ORDER BY is_owner DESC, created_at
""", """,
round_id, round_id,
@ -421,6 +429,7 @@ async def _create_participant(
is_owner: bool, is_owner: bool,
gender: str, gender: str,
handicap_index: float | None, handicap_index: float | None,
stat_level: StatLevel = "strokes_only",
) -> str: ) -> str:
rating = resolved.rating_for(tee_name, gender) rating = resolved.rating_for(tee_name, gender)
course_rating = slope_rating = tee_par = None course_rating = slope_rating = tee_par = None
@ -434,8 +443,9 @@ async def _create_participant(
""" """
INSERT INTO round_participant INSERT INTO round_participant
(round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot, (round_id, user_id, guest_name, is_owner, gender, handicap_index_snapshot,
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot) course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) stat_level)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
round_id, round_id,
@ -448,6 +458,7 @@ async def _create_participant(
slope_rating, slope_rating,
tee_par, tee_par,
course_handicap_snapshot, course_handicap_snapshot,
stat_level,
) )
participant_id = participant_row["id"] participant_id = participant_row["id"]
@ -517,6 +528,7 @@ async def create_round(
conn, round_id, resolved, body.tee_name, conn, round_id, resolved, body.tee_name,
user_id=user.user_id, guest_name=None, is_owner=True, user_id=user.user_id, guest_name=None, is_owner=True,
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"], gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
stat_level=body.stat_level,
) )
return await _load_round_out(conn, round_id) 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) guest_name: str = Field(min_length=1, max_length=100)
gender: Literal["m", "f", "x"] gender: Literal["m", "f", "x"]
handicap_index: float | None = Field(default=None, ge=-10, le=54) 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) @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"], conn, round_id, resolved, round_row["tee_name_snapshot"],
user_id=None, guest_name=body.guest_name.strip(), is_owner=False, user_id=None, guest_name=body.guest_name.strip(), is_owner=False,
gender=body.gender, handicap_index=body.handicap_index, gender=body.gender, handicap_index=body.handicap_index,
stat_level=body.stat_level,
) )
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender, SELECT id::text AS id, user_id::text AS user_id, guest_name, is_owner, gender,
handicap_index_snapshot::float AS handicap_index_snapshot, handicap_index_snapshot::float AS handicap_index_snapshot,
course_handicap_snapshot, counts_for_handicap, 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 FROM round_participant WHERE id = $1
""", """,
participant_id, participant_id,
@ -606,6 +620,35 @@ async def add_guest_participant(
return RoundParticipantOut(**dict(row)) 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) @router.delete("/rounds/{round_id}/participants/{participant_id}", status_code=204)
async def remove_guest_participant( async def remove_guest_participant(
round_id: str, round_id: str,
@ -642,7 +685,8 @@ class RoundHoleOut(BaseModel):
chip_count: int | None chip_count: int | None
bunker_shot_count: int | None bunker_shot_count: int | None
penalty_strokes: int | None penalty_strokes: int | None
first_putt_distance_m: float | None first_putt_distance_bucket: str | None
anyway_strokes: int | None
@router.get( @router.get(
@ -666,7 +710,7 @@ async def list_holes(
""" """
SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee, SELECT hole_number, par, stroke_index, played, score, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count, 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 FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number
""", """,
participant_id, participant_id,
@ -684,7 +728,8 @@ class HoleUpdate(BaseModel):
chip_count: int | None = Field(default=None, ge=0) chip_count: int | None = Field(default=None, ge=0)
bunker_shot_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) 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( @router.patch(
@ -713,16 +758,18 @@ async def update_hole(
UPDATE round_hole SET UPDATE round_hole SET
played = $3, score = $4, putts = $5, club_off_tee = $6, played = $3, score = $4, putts = $5, club_off_tee = $6,
tee_shot_result = $7, approach_result = $8, chip_count = $9, 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 WHERE round_participant_id = $1 AND hole_number = $2
RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee, RETURNING hole_number, par, stroke_index, played, score, putts, club_off_tee,
tee_shot_result, approach_result, chip_count, bunker_shot_count, 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, participant_id, hole_number,
body.played, body.score, body.putts, body.club_off_tee, body.played, body.score, body.putts, body.club_off_tee,
body.tee_shot_result, body.approach_result, body.chip_count, 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: if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.") 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 // ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer. // 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 = { type Me = {
id: string id: string
email: string email: string
@ -47,6 +58,7 @@ type Me = {
bio: string | null bio: string | null
mobile_country_code: string | null mobile_country_code: string | null
mobile_number: string | null mobile_number: string | null
bag_clubs: string[]
avatar_url: string | null avatar_url: string | null
// 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse // 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse
// teller ikke). Se AccountSettings sin bruk under. // 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 [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 ?? "")
const [bagClubs, setBagClubs] = useState<string[]>(me.bag_clubs)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [error, setError] = useState<string | null>(null) 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(), bio: bio.trim() === "" ? null : bio.trim(),
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(), mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(), mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
bag_clubs: bagClubs,
}), }),
}) })
if (!res.ok) { if (!res.ok) {
@ -677,6 +691,46 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
</div> </div>
</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>} {error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</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 } | { source: "custom"; personalCourseId: string }
type Gender = "m" | "f" type Gender = "m" | "f"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
// --- API-typer --------------------------------------------------------------- // --- API-typer ---------------------------------------------------------------
@ -194,6 +195,7 @@ export function NewRound() {
date: string date: string
startHole: number startHole: number
holes: 9 | 18 holes: 9 | 18
statLevel: StatLevel
}): Promise<{ id: string }> { }): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane") if (!courseMeta) throw new Error("Mangler valgt bane")
const body = const body =
@ -206,6 +208,7 @@ export function NewRound() {
played_at: payload.date, played_at: payload.date,
start_hole: payload.startHole, start_hole: payload.startHole,
holes_planned: payload.holes, holes_planned: payload.holes,
stat_level: payload.statLevel,
} }
: { : {
course_source: "custom", course_source: "custom",
@ -214,6 +217,7 @@ export function NewRound() {
played_at: payload.date, played_at: payload.date,
start_hole: payload.startHole, start_hole: payload.startHole,
holes_planned: payload.holes, holes_planned: payload.holes,
stat_level: payload.statLevel,
} }
const res = await fetch("/rounds", { const res = await fetch("/rounds", {
method: "POST", method: "POST",
@ -1004,7 +1008,13 @@ function ConfirmStep({
}: { }: {
course: Course course: Course
ownGender: Gender 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 onCreated: (roundId: string) => void
}) { }) {
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women)) const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
@ -1012,6 +1022,7 @@ function ConfirmStep({
const [date, setDate] = useState(todayIso) const [date, setDate] = useState(todayIso)
const [startHole, setStartHole] = useState("1") const [startHole, setStartHole] = useState("1")
const [holes, setHoles] = useState<9 | 18>(18) const [holes, setHoles] = useState<9 | 18>(18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -1027,6 +1038,7 @@ function ConfirmStep({
date, date,
startHole: Number(startHole), startHole: Number(startHole),
holes, holes,
statLevel,
}) })
onCreated(created.id) onCreated(created.id)
} catch { } catch {
@ -1116,6 +1128,36 @@ function ConfirmStep({
</div> </div>
</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 */} {/* Hole count */}
<fieldset className="flex flex-col gap-3"> <fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Antall hull</legend> <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 ApiGender = "m" | "f" | "x"
type TeeShot = "left" | "fairway" | "right" type TeeShot = "left" | "fairway" | "right"
type Approach = "left" | "short" | "hit" | "long" | "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 = { type Player = {
id: string id: string
@ -45,6 +47,7 @@ type Player = {
isSelf: boolean isSelf: boolean
countsForHandicap: boolean countsForHandicap: boolean
scoreDifferential: number | null scoreDifferential: number | null
statLevel: StatLevel
} }
type HoleStat = { type HoleStat = {
@ -57,7 +60,8 @@ type HoleStat = {
chip: number chip: number
bunker: number bunker: number
penalty: number penalty: number
firstPuttDistance: string firstPuttBucket: PuttBucket | null
anywayStrokes: number | null
} }
type Hole = { type Hole = {
@ -77,7 +81,8 @@ function emptyStat(): HoleStat {
chip: 0, chip: 0,
bunker: 0, bunker: 0,
penalty: 0, penalty: 0,
firstPuttDistance: "", firstPuttBucket: null,
anywayStrokes: null,
} }
} }
@ -111,6 +116,7 @@ type ApiParticipant = {
course_handicap_snapshot: number | null course_handicap_snapshot: number | null
counts_for_handicap: boolean counts_for_handicap: boolean
score_differential: number | null score_differential: number | null
stat_level: StatLevel
} }
type ApiRound = { type ApiRound = {
@ -137,7 +143,8 @@ type ApiHole = {
chip_count: number | null chip_count: number | null
bunker_shot_count: number | null bunker_shot_count: number | null
penalty_strokes: 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 { function apiHoleToStat(h: ApiHole): HoleStat {
@ -151,7 +158,8 @@ function apiHoleToStat(h: ApiHole): HoleStat {
chip: h.chip_count ?? 0, chip: h.chip_count ?? 0,
bunker: h.bunker_shot_count ?? 0, bunker: h.bunker_shot_count ?? 0,
penalty: h.penalty_strokes ?? 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, chip_count: s.chip,
bunker_shot_count: s.bunker, bunker_shot_count: s.bunker,
penalty_strokes: s.penalty, 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 [error, setError] = useState<string | null>(null)
const [activePlayerId, setActivePlayerId] = useState<string | null>(null) const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({}) 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 [detailsOpen, setDetailsOpen] = useState(false)
const [showAddGuest, setShowAddGuest] = useState(false) const [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false) const [completing, setCompleting] = useState(false)
@ -197,7 +214,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const data: ApiRound = await res.json() const data: ApiRound = await res.json()
setRound(data) setRound(data)
setActivePlayerId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null) 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 { } catch {
setError("Klarte ikke å hente runden. Prøv igjen om litt.") setError("Klarte ikke å hente runden. Prøv igjen om litt.")
} }
@ -207,6 +224,19 @@ export function RoundDetail({ roundId }: { roundId: string }) {
void loadRound() void loadRound()
}, [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( const loadHoles = useCallback(
async (participantId: string) => { async (participantId: string) => {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" }) 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, isSelf: p.is_owner,
countsForHandicap: p.counts_for_handicap, countsForHandicap: p.counts_for_handicap,
scoreDifferential: p.score_differential, scoreDifferential: p.score_differential,
statLevel: p.stat_level,
})) ?? [] })) ?? []
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index })) 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 // Alltid en tallverdi å regne videre på selv rett etter mount, FØR
const totalHoles = 18 // 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 currentStat = currentApiHole ? apiHoleToStat(currentApiHole) : emptyStat()
const showGir = const showGir =
@ -256,7 +295,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
async function updateStat(patch: Partial<HoleStat>) { async function updateStat(patch: Partial<HoleStat>) {
if (readOnly || !activePlayerId || !currentApiHole) return if (readOnly || !activePlayerId || !currentApiHole) return
const merged: HoleStat = { ...currentStat, ...patch } 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", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", credentials: "include",
@ -275,20 +314,27 @@ export function RoundDetail({ roundId }: { roundId: string }) {
} }
function goPrev() { function goPrev() {
setCurrentHole((h) => Math.max(1, h - 1)) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i - 1 + 18) % 18])
setDetailsOpen(false) setDetailsOpen(false)
} }
function goNext() { function goNext() {
setCurrentHole((h) => Math.min(totalHoles, h + 1)) const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % 18])
setDetailsOpen(false) 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`, { const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: "include", 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) { if (!res.ok) {
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.") 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() 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() { async function finishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
setCompleting(true) setCompleting(true)
@ -393,6 +453,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
readOnly={readOnly} readOnly={readOnly}
/> />
{!readOnly && (
<StatLevelPicker
value={activePlayer.statLevel}
onChange={(v) => updateStatLevel(activePlayer.id, v)}
/>
)}
{showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />} {showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />}
{/* Hole navigation */} {/* Hole navigation */}
@ -403,8 +470,8 @@ export function RoundDetail({ roundId }: { roundId: string }) {
) : ( ) : (
<> <>
<HoleNav <HoleNav
holes={holes} holes={orderedHoles}
currentHole={currentHole} currentHole={activeHole}
isPlayed={holeIsPlayed} isPlayed={holeIsPlayed}
onSelect={(n) => { onSelect={(n) => {
setCurrentHole(n) setCurrentHole(n)
@ -426,8 +493,6 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)} )}
</div> </div>
<PlayedToggle checked={currentStat.played} onChange={(v) => updateStat({ played: v })} readOnly={readOnly} />
<NumberPicker <NumberPicker
label="Slag" label="Slag"
value={currentStat.strokes} value={currentStat.strokes}
@ -438,106 +503,140 @@ export function RoundDetail({ roundId }: { roundId: string }) {
readOnly={readOnly} readOnly={readOnly}
/> />
<NumberPicker {activePlayer.statLevel !== "strokes_only" && (
label="Putter" <NumberPicker
value={currentStat.putts} label="Putter"
directValues={[0, 1, 2, 3, 4, 5, 6]} value={currentStat.putts}
expandValues={[7, 8, 9, 10]} directValues={[0, 1, 2, 3, 4, 5, 6]}
expandLabel="7+" expandValues={[7, 8, 9, 10]}
onChange={(v) => updateStat({ putts: v })} expandLabel="7+"
readOnly={readOnly} onChange={(v) => updateStat({ putts: v })}
/> readOnly={readOnly}
/>
)}
<div className="rounded-2xl border border-border"> {activePlayer.statLevel === "full" && (
<button <div className="rounded-2xl border border-border">
type="button" <button
onClick={() => setDetailsOpen((v) => !v)} type="button"
aria-expanded={detailsOpen} onClick={() => setDetailsOpen((v) => !v)}
className="flex min-h-12 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-semibold text-foreground" aria-expanded={detailsOpen}
> className="flex min-h-12 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-semibold text-foreground"
Flere detaljer >
<ChevronDown Flere detaljer
aria-hidden="true" <ChevronDown
className={cn("size-5 text-muted-foreground transition-transform", detailsOpen && "rotate-180")} aria-hidden="true"
/> className={cn("size-5 text-muted-foreground transition-transform", detailsOpen && "rotate-180")}
</button> />
{detailsOpen && ( </button>
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5"> {detailsOpen && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
<Label htmlFor="club" className="text-base font-semibold"> <div className="flex flex-col gap-2">
Kølle brukt ved utslaget <Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
</Label> {activePlayer.isSelf && ownBagClubs.length > 0 ? (
<Input <div className="flex flex-wrap gap-2">
id="club" {ownBagClubs.map((club) => {
value={currentStat.club} const selected = currentStat.club === club
onChange={(e) => updateStat({ club: e.target.value })} return (
placeholder="F.eks. Driver, 3-jern" <button
disabled={readOnly} key={club}
className="h-12 rounded-2xl text-base" type="button"
/> disabled={readOnly}
</div> 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}
onChange={(e) => updateStat({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && (
<ChoiceRow
label="Utslag"
options={[
{ value: "left", label: "Venstre" },
{ value: "fairway", label: "Fairway" },
{ value: "right", label: "Høyre" },
]}
value={currentStat.teeShot}
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
{hole.par !== 3 && (
<ChoiceRow <ChoiceRow
label="Utslag" label="Innspill"
options={[ options={[
{ value: "left", label: "Venstre" }, { value: "left", label: "Venstre" },
{ value: "fairway", label: "Fairway" }, { value: "short", label: "Kort" },
{ value: "hit", label: "Traff" },
{ value: "long", label: "Langt" },
{ value: "right", label: "Høyre" }, { value: "right", label: "Høyre" },
]} ]}
value={currentStat.teeShot} value={currentStat.approach}
onChange={(v) => updateStat({ teeShot: v as TeeShot })} onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly} readOnly={readOnly}
/> />
)}
<ChoiceRow <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
label="Innspill" <Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} />
options={[ <Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} />
{ value: "left", label: "Venstre" }, <Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} />
{ value: "short", label: "Kort" }, </div>
{ value: "hit", label: "Traff" },
{ value: "long", label: "Langt" },
{ value: "right", label: "Høyre" },
]}
value={currentStat.approach}
onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3"> <ChoiceRow
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} /> label="Avstand første putt"
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} /> options={[
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} /> { value: "<1m", label: "<1m" },
</div> { 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}
/>
<div className="flex flex-col gap-2"> <NumberPicker
<Label htmlFor="putt-dist" className="text-base font-semibold"> label="Anywayslag"
Avstand første putt (m) value={currentStat.anywayStrokes}
</Label> directValues={[0, 1, 2, 3, 4, 5, 6]}
<Input expandValues={[7, 8, 9, 10]}
id="putt-dist" expandLabel="7+"
inputMode="decimal" onChange={(v) => updateStat({ anywayStrokes: v })}
type="number" readOnly={readOnly}
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"
/> />
</div> </div>
</div> )}
)} </div>
</div> )}
<div className="grid grid-cols-2 gap-3"> <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" /> <ChevronLeft aria-hidden="true" className="size-5" />
Forrige Forrige
</Button> </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 Neste hull
<ChevronRight aria-hidden="true" className="size-5" /> <ChevronRight aria-hidden="true" className="size-5" />
</Button> </Button>
@ -672,19 +771,20 @@ function AddGuestForm({
onAdd, onAdd,
onCancel, onCancel,
}: { }: {
onAdd: (name: string, gender: Gender, hcp: number | null) => void onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onCancel: () => void onCancel: () => void
}) { }) {
const [name, setName] = useState("") const [name, setName] = useState("")
const [gender, setGender] = useState<Gender>("male") const [gender, setGender] = useState<Gender>("male")
const [hcp, setHcp] = useState("") const [hcp, setHcp] = useState("")
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return if (!trimmed) return
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) 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 ( return (
@ -724,6 +824,18 @@ function AddGuestForm({
/> />
</div> </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"> <div className="flex gap-3">
<Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold"> <Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
Legg til 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 ( return (
<button <div className="mt-3 flex flex-wrap items-center gap-2">
type="button" <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Statistikk</span>
role="checkbox" <div className="flex gap-1.5">
aria-checked={checked} {options.map((opt) => (
disabled={readOnly} <button
onClick={() => onChange(!checked)} key={opt.value}
className={cn( type="button"
"flex min-h-12 items-center gap-3 rounded-2xl border px-4 py-3 text-left transition-colors disabled:opacity-100", onClick={() => onChange(opt.value)}
checked ? "border-primary bg-primary/10" : "border-border bg-card hover:bg-accent/50", aria-pressed={value === opt.value}
)} className={cn(
> "rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
<span value === opt.value
aria-hidden="true" ? "bg-primary text-primary-foreground"
className={cn( : "bg-muted text-muted-foreground hover:bg-accent",
"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", >
)} {opt.label}
> </button>
{checked && <Check className="size-5" strokeWidth={3} />} ))}
</span> </div>
<span className="text-base font-bold text-foreground">Hullet er spilt</span> </div>
</button>
) )
} }