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:
parent
29a6b90127
commit
65f876e6dc
8 changed files with 504 additions and 139 deletions
|
|
@ -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",
|
||||
|
|
|
|||
48
022_round_stats_and_bag.sql
Normal file
48
022_round_stats_and_bag.sql
Normal 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+'
|
||||
));
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue