From 65f876e6dc935f3d199690697a5d7d4acc869736 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Fri, 24 Jul 2026 06:27:35 +0200 Subject: [PATCH] Oppsummering av det som er fikset/bygget i denne runden: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/settings.local.json | 5 +- 022_round_stats_and_bag.sql | 48 +++ FEATURE_BACKLOG.md | 32 ++ app/routers/auth.py | 21 +- app/routers/rounds.py | 67 +++- frontend/components/account-settings.tsx | 54 ++++ frontend/components/new-round.tsx | 44 ++- frontend/components/round-detail.tsx | 372 +++++++++++++++-------- 8 files changed, 504 insertions(+), 139 deletions(-) create mode 100644 022_round_stats_and_bag.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index be82464..6c3b2a7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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", diff --git a/022_round_stats_and_bag.sql b/022_round_stats_and_bag.sql new file mode 100644 index 0000000..fab3050 --- /dev/null +++ b/022_round_stats_and_bag.sql @@ -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+' + )); diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 5393ba6..18f4a43 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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/ diff --git a/app/routers/auth.py b/app/routers/auth.py index 3751776..83fab00 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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) diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 1658db3..1c6b7e3 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -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.") diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index f10c184..18e0880 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -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(me.bag_clubs) const [saving, setSaving] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const [error, setError] = useState(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 }) { +
+
+ + + {bagClubs.length}/{MAX_BAG_CLUBS} valgt + +
+

+ 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. +

+
+ {BAG_CLUBS.map((club) => { + const checked = bagClubs.includes(club) + const disabled = !checked && bagClubs.length >= MAX_BAG_CLUBS + return ( + + ) + })} +
+
+ {error &&

{error}

} {success &&

Profilen er oppdatert.

} diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx index 9fc802d..423cc2b 100644 --- a/frontend/components/new-round.tsx +++ b/frontend/components/new-round.tsx @@ -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("strokes_only") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) @@ -1027,6 +1038,7 @@ function ConfirmStep({ date, startHole: Number(startHole), holes, + statLevel, }) onCreated(created.id) } catch { @@ -1116,6 +1128,36 @@ function ConfirmStep({ + {/* Statistikknivå -- kun slag er strengt tatt nødvendig for resultat/HCP */} +
+ Statistikk for deg selv +
+ {( + [ + { 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) => ( + + ))} +
+
+ {/* Hole count */}
Antall hull diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 0ccaeb2..7d57210 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -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(null) const [activePlayerId, setActivePlayerId] = useState(null) const [holesByParticipant, setHolesByParticipant] = useState>({}) - const [currentHole, setCurrentHole] = useState(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([]) + // `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(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) { 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 && ( + updateStatLevel(activePlayer.id, v)} + /> + )} + {showAddGuest && !readOnly && setShowAddGuest(false)} />} {/* Hole navigation */} @@ -403,8 +470,8 @@ export function RoundDetail({ roundId }: { roundId: string }) { ) : ( <> { setCurrentHole(n) @@ -426,8 +493,6 @@ export function RoundDetail({ roundId }: { roundId: string }) { )} - updateStat({ played: v })} readOnly={readOnly} /> - - updateStat({ putts: v })} - readOnly={readOnly} - /> + {activePlayer.statLevel !== "strokes_only" && ( + updateStat({ putts: v })} + readOnly={readOnly} + /> + )} -
- - {detailsOpen && ( -
-
- - updateStat({ club: e.target.value })} - placeholder="F.eks. Driver, 3-jern" - disabled={readOnly} - className="h-12 rounded-2xl text-base" - /> -
+ {activePlayer.statLevel === "full" && ( +
+ + {detailsOpen && ( +
+
+ + {activePlayer.isSelf && ownBagClubs.length > 0 ? ( +
+ {ownBagClubs.map((club) => { + const selected = currentStat.club === club + return ( + + ) + })} +
+ ) : ( + updateStat({ club: e.target.value })} + placeholder="F.eks. Driver, 3-jern" + disabled={readOnly} + className="h-12 rounded-2xl text-base" + /> + )} +
+ + {hole.par !== 3 && ( + updateStat({ teeShot: v as TeeShot })} + readOnly={readOnly} + /> + )} - {hole.par !== 3 && ( updateStat({ teeShot: v as TeeShot })} + value={currentStat.approach} + onChange={(v) => updateStat({ approach: v as Approach })} readOnly={readOnly} /> - )} - updateStat({ approach: v as Approach })} - readOnly={readOnly} - /> +
+ updateStat({ chip: v })} readOnly={readOnly} /> + updateStat({ bunker: v })} readOnly={readOnly} /> + updateStat({ penalty: v })} readOnly={readOnly} /> +
-
- updateStat({ chip: v })} readOnly={readOnly} /> - updateStat({ bunker: v })} readOnly={readOnly} /> - updateStat({ penalty: v })} readOnly={readOnly} /> -
+ updateStat({ firstPuttBucket: v as PuttBucket })} + readOnly={readOnly} + /> -
- - updateStat({ firstPuttDistance: e.target.value })} - placeholder="F.eks. 3.5" - disabled={readOnly} - className="h-12 rounded-2xl text-base" + updateStat({ anywayStrokes: v })} + readOnly={readOnly} />
-
- )} -
+ )} +
+ )}
- - @@ -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("male") const [hcp, setHcp] = useState("") + const [statLevel, setStatLevel] = useState("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({ />
+ setStatLevel(v as StatLevel)} + readOnly={false} + /> +
+
+ Statistikk +
+ {options.map((opt) => ( + + ))} +
+
) }