Alt er live. Kort oppsummert:
HCP i søket — søkeresultatene under "+ Medspiller" viser nå HCP ved siden av hjemmeklubb (der du sirklet inn i skjermbildet). Rediger utslag/HCP per deltaker — hver spiller viser nå sitt utslag og HCP under statistikknivå-velgeren, med en "Rediger for denne runden"-lenke (kun eier ser den, kun før runden er fullført). Der kan du velge et annet utslagssted for akkurat den spilleren (filtrert på deres kjønn) og justere HCP — begge deler gjelder kun denne runden, spillerens egen profil røres aldri. Fant og fikset samtidig et reelt hull: alle deltakere delte tidligere samme utslag uansett, det fantes ingen vei til å velge et annet. Rundeleaderboard — backenden var allerede live. Fant at V0-prompten (skrevet og sendt til deg tidligere) hadde en utdatert antakelse om "Deg"-merket siden medspillere nå også kan se runden — rettet den til to uavhengige merker (Eier/Deg) før du kjører den i v0.app. Alt scratch-verifisert (27/27 nye sjekker + full regresjon av forrige rundes 35 sjekker), migrasjon 028 kjørt mot ekte teecup_db, begge containere redeployet og bekreftet friske, teeoff.no upåvirket.
This commit is contained in:
parent
3fc0c7460c
commit
9d5e9965db
7 changed files with 471 additions and 33 deletions
|
|
@ -45,7 +45,10 @@
|
||||||
"Bash(python3 -m ensurepip --user)",
|
"Bash(python3 -m ensurepip --user)",
|
||||||
"Bash(curl -sS https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py)",
|
"Bash(curl -sS https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py)",
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" -H \"Connection: Upgrade\" -H \"Upgrade: websocket\" -H \"Sec-WebSocket-Version: 13\" -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" https://teecup.teeoff.no/ws/rounds/00000000-0000-0000-0000-000000000000/live)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" -H \"Connection: Upgrade\" -H \"Upgrade: websocket\" -H \"Sec-WebSocket-Version: 13\" -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" https://teecup.teeoff.no/ws/rounds/00000000-0000-0000-0000-000000000000/live)",
|
||||||
"Bash(curl -s -H \"Connection: Upgrade\" -H \"Upgrade: websocket\" -H \"Sec-WebSocket-Version: 13\" -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" https://teecup.teeoff.no/ws/rounds/00000000-0000-0000-0000-000000000000/live)"
|
"Bash(curl -s -H \"Connection: Upgrade\" -H \"Upgrade: websocket\" -H \"Sec-WebSocket-Version: 13\" -H \"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\" https://teecup.teeoff.no/ws/rounds/00000000-0000-0000-0000-000000000000/live)",
|
||||||
|
"Bash(grep -n \"participants/\\\\${\" /opt/teecup/frontend/components/round-detail.tsx)",
|
||||||
|
"Bash(python3 -)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
028_round_participant_tee.sql
Normal file
25
028_round_participant_tee.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — utslagssted PER DELTAKER på en frittstående runde (migrasjon 028)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Reelt hull rapportert av bruker 2026-07-26: round_participant har i
|
||||||
|
-- dag INGEN egen tee -- alle deltakere spiller fra samme utslag som
|
||||||
|
-- round.tee_name_snapshot, uansett kjønn/faktisk valg. Rating-tallene
|
||||||
|
-- (course_rating_snapshot/slope_rating_snapshot/tee_par_snapshot) har
|
||||||
|
-- vært PER DELTAKER siden migrasjon 021 (nødvendig pga. kjønnsspesifikk
|
||||||
|
-- rating, ADR-029) -- selve NAVNET på utslaget manglet likevel helt.
|
||||||
|
--
|
||||||
|
-- Backfylles fra rundens eksisterende tee (samme fysiske utslag alle
|
||||||
|
-- deltakere allerede regnes ut fra i praksis) -- ingen data endres, kun
|
||||||
|
-- navnet gjøres eksplisitt per rad slik at det kan avvike fremover.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round_participant ADD COLUMN tee_name_snapshot text;
|
||||||
|
|
||||||
|
UPDATE round_participant rp
|
||||||
|
SET tee_name_snapshot = r.tee_name_snapshot
|
||||||
|
FROM round r
|
||||||
|
WHERE r.id = rp.round_id;
|
||||||
|
|
||||||
|
ALTER TABLE round_participant ALTER COLUMN tee_name_snapshot SET NOT NULL;
|
||||||
94
CLAUDE.md
94
CLAUDE.md
|
|
@ -3084,18 +3084,92 @@ Ferdig og verifisert:
|
||||||
produksjons-https ga FastAPI sin egen JSON-`{"detail":"Not Found"}`,
|
produksjons-https ga FastAPI sin egen JSON-`{"detail":"Not Found"}`,
|
||||||
ikke Next.js sin HTML-404 — samme verifiseringsmønster som ADR-027s
|
ikke Next.js sin HTML-404 — samme verifiseringsmønster som ADR-027s
|
||||||
tournament-live-rute.
|
tournament-live-rute.
|
||||||
|
- **HCP i medspiller-søk + rediger utslag/HCP per deltaker, BYGGET OG
|
||||||
|
SCRATCH-VERIFISERT (2026-07-26), IKKE ENNÅ RULLET UT:** brukeren viste et
|
||||||
|
skjermbilde av "+ Medspiller"-søket og påpekte at spillerens HCP burde
|
||||||
|
vises der (kun navn+klubb vistes), og ba samtidig om å kunne endre
|
||||||
|
utslagssted og HCP for medspillere -- "det er ikke sikkert de spiller fra
|
||||||
|
samme utslagssted som meg, og det er ikke sikkert HCP er riktig", eksplisitt
|
||||||
|
presisert som en endring KUN for denne runden/turneringen, ikke spillerens
|
||||||
|
faktiske profil.
|
||||||
|
**Reelt hull bekreftet ved kodegjennomgang FØR bygging:** ALLE deltakere
|
||||||
|
på en runde har i dag brukt SAMME `round.tee_name_snapshot` -- ingen
|
||||||
|
per-deltaker-tee har noensinne eksistert (rating-tallene har vært per
|
||||||
|
deltaker siden migrasjon 021, men selve utslags-NAVNET var aldri
|
||||||
|
eksplisitt per rad).
|
||||||
|
**Bygget:** `PersonMatch` (`app/routers/friends.py`, delt av vennesøk OG
|
||||||
|
medspiller-søk) fikk `handicap_index: float | None`. Ny migrasjon
|
||||||
|
`028_round_participant_tee.sql` -- `round_participant.tee_name_snapshot`,
|
||||||
|
backfylt fra rundens eksisterende tee for alle eksisterende rader. `_create_
|
||||||
|
participant` skriver nå tee-navnet eksplisitt; `ParticipantCreate` (add_
|
||||||
|
participant) fikk et valgfritt `tee_name` som faller tilbake til rundens
|
||||||
|
tee hvis utelatt. Ny `GET /rounds/{id}/tee-options` (gjenbruker
|
||||||
|
`_ResolvedCourse` sin rating-dict via en ny `tee_options()`-metode) --
|
||||||
|
brukt av "rediger spiller"-panelet til å liste banens faktiske utslag.
|
||||||
|
`ParticipantUpdate` skrevet om til vanlig `exclude_unset`-PATCH-semantikk
|
||||||
|
(var tidligere kun `stat_level`, alltid påkrevd) -- nye valgfrie
|
||||||
|
`tee_name`/`handicap_index`-felt trigger en full re-beregning av
|
||||||
|
rating-snapshottene + `course_handicap_snapshot` for AKKURAT den
|
||||||
|
deltakeren, uten å røre spillerens egen `app_user.handicap_index`.
|
||||||
|
Eksplisitt `null` på `handicap_index` fjerner HCP-sporing for denne
|
||||||
|
deltakeren i denne runden (samme mønster som profil-PATCH). **Bevisst
|
||||||
|
avvist etter at runden er fullført** (409 `ALREADY_COMPLETED`, samme
|
||||||
|
presedens som `RoundUpdate` sitt bane-bytte -- differensialen er da
|
||||||
|
allerede beregnet fra det gamle grunnlaget); `stat_level` alene er
|
||||||
|
fortsatt tillatt uansett fullført-status. `RoundUpdate` sitt eksisterende
|
||||||
|
bane-bytte (ADR-033-oppfølging 2026-07-24) nullstiller nå eksplisitt ALLE
|
||||||
|
deltakeres `tee_name_snapshot` til rundens nye utslag (et bane-bytte gjør
|
||||||
|
individuelle tee-valg fra den gamle banen meningsløse).
|
||||||
|
**Frontend (`round-detail.tsx`):** søkeresultatene i "+ Medspiller" viser
|
||||||
|
nå HCP ved siden av hjemmeklubb. Ny "Utslag: X · HCP: Y"-rad under
|
||||||
|
statistikknivå-velgeren for aktiv spiller, med en "Rediger for denne
|
||||||
|
runden"-lenke (kun eier, kun før fullført) som åpner en ny
|
||||||
|
`EditParticipantPanel` -- utslagssted som en `<select>` fylt fra det nye
|
||||||
|
tee-options-endepunktet (filtrert på spillerens kjønn), HCP som fritekst,
|
||||||
|
forklarende "endrer ikke profilen"-tekst. Gjelder likt for eierens egen
|
||||||
|
rad som for medspillere (ingen spesialtilfelle).
|
||||||
|
**Scratch-verifisert, 27/27 sjekker** (isolert `teecup_app_scratch`-
|
||||||
|
rolle + isolert scratch-MinIO + engangs API-container): HCP i søk, legg
|
||||||
|
til medspiller med eksplisitt AVVIKENDE utslag fra eieren, gjest uten
|
||||||
|
eksplisitt tee faller korrekt tilbake til rundens tee, utslag uten rating
|
||||||
|
for kjønn avvist (400) både ved tilføyelse og redigering, tee-options
|
||||||
|
lister riktige utslag, rediger tee+HCP for medspiller lykkes og
|
||||||
|
course_handicap regnes om, **medspillerens egen profil-HCP forblir
|
||||||
|
UENDRET** (den kritiske sjekken -- bekrefter runde-scoping), delvis PATCH
|
||||||
|
(kun stat_level) lar tee/HCP stå urørt, eksplisitt `null` fjerner HCP
|
||||||
|
round-scoped, lenket medspiller (ikke eier) nektes å redigere deltakere
|
||||||
|
(403, forvaltning fortsatt eier-only), tom PATCH avvist (400), redigering
|
||||||
|
blokkert etter fullføring (409) mens stat_level fortsatt tillates. Full
|
||||||
|
regresjonskjøring av forrige rundes 35-punkts medspiller-søk-testsuite
|
||||||
|
(`test_round_coplayer.py`) mot samme friske scratch-database, alle 35
|
||||||
|
fortsatt grønne. `test_isolation.sql` 12/12. Ekte typesjekket
|
||||||
|
produksjonsbuild kompilerte rent.
|
||||||
|
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
|
||||||
|
migrasjon 028 kjørt mot ekte `teecup_db` (kolonne bekreftet `NOT NULL`,
|
||||||
|
2 eksisterende rader backfylt korrekt, `test_isolation.sql` fortsatt
|
||||||
|
12/12), deretter `docker compose up -d --build teecup_api
|
||||||
|
teecup_frontend`. Begge containere boot-et rent (`Application startup
|
||||||
|
complete`, Next.js `Ready`), `/health`/`/dashboard` → 200, `teeoff.no`
|
||||||
|
upåvirket.
|
||||||
|
- **V0-prompt for rundeleaderboardet rettet (2026-07-26), FØR den ble
|
||||||
|
kjørt i v0.app:** samme runde som over, brukeren ba eksplisitt om å få
|
||||||
|
leaderboardet for frittstående runder på plass. Backend
|
||||||
|
(`GET /rounds/{id}/leaderboard`) var allerede live siden tidligere samme
|
||||||
|
dag; V0-prompten (skrevet samme dag, se FEATURE_BACKLOG.md) hadde
|
||||||
|
derimot en utdatert antakelse -- den ba om et "Deg"-merke basert på at
|
||||||
|
kun eieren noensinne ser sin egen runde, som ikke lenger stemmer etter
|
||||||
|
ADR-036 fase 3 (medspillere kan nå også se runden). Rettet til to
|
||||||
|
uavhengige merker: "Eier" (fra API-ets `is_owner`) og "Deg" (fra en
|
||||||
|
`participant_id`-prop komponenten mottar utenfra, ikke fra selve
|
||||||
|
leaderboard-dataen). Prompten er ikke sendt til v0.app ennå.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):**
|
0. **Rundeleaderboard — venter på V0-zip (2026-07-26):** backenden
|
||||||
varsler (in-app varslingssenter) + leaderboard-BACKENDEN for
|
(`GET /rounds/{id}/leaderboard`) er live siden tidligere samme dag.
|
||||||
frittstående runder er begge bygget og scratch-verifisert i samme
|
V0-prompten for selve VISNINGEN er skrevet, sendt til bruker, og
|
||||||
runde (39/39 sjekker) — se status over. Migrasjon `026_
|
rettet ("Deg"/"Eier"-merke, se status over) — men ikke kjørt i
|
||||||
notifications.sql` er IKKE kjørt mot ekte `teecup_db` ennå, og
|
v0.app ennå. Leaderboardets frontend kommer i en senere runde når den
|
||||||
`teecup_api`/`teecup_frontend` er ikke redeployet med disse
|
zip-en er klar.
|
||||||
endringene. V0-prompten for selve leaderboard-VISNINGEN er skrevet og
|
|
||||||
sendt til bruker (se FEATURE_BACKLOG.md), ikke kjørt i v0.app ennå —
|
|
||||||
leaderboardets frontend kommer i en senere runde når den zip-en er
|
|
||||||
klar.
|
|
||||||
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
|
1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og
|
||||||
venner/kategorisert deling fase 1 (ADR-036) — begge designet
|
venner/kategorisert deling fase 1 (ADR-036) — begge designet
|
||||||
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
|
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE
|
||||||
|
|
|
||||||
|
|
@ -1018,9 +1018,14 @@ stemte eksakt).
|
||||||
> HCP registrert) skal vises tydelig nederst/uten rangering i
|
> HCP registrert) skal vises tydelig nederst/uten rangering i
|
||||||
> netto-visningen, ikke skjules eller krasje.
|
> netto-visningen, ikke skjules eller krasje.
|
||||||
>
|
>
|
||||||
> **Rundens egen eier** bør være visuelt gjenkjennelig i listen (f.eks.
|
> **To distinkte merker, ikke ett:** et "Eier"-merke for rundens eier
|
||||||
> et lite "Deg"-merke ved siden av navnet), siden det alltid er
|
> (kommer direkte fra API-et sitt `is_owner`-felt), OG uavhengig av det et
|
||||||
> eieren som ser sin egen runde.
|
> "Deg"-merke for raden som tilhører DEN som ser på leaderboardet akkurat
|
||||||
|
> nå (siden runden også kan sees av lenkede medspillere, ikke bare eieren
|
||||||
|
> -- komponenten mottar hvilken `participant_id` som er "meg" som en egen
|
||||||
|
> prop utenfra, ikke fra selve leaderboard-dataen). De to kan gjelde samme
|
||||||
|
> rad (eieren ser sin egen runde) eller ulike rader (en medspiller ser
|
||||||
|
> både sin egen "Deg"-rad og eierens "Eier"-rad) -- design for begge.
|
||||||
>
|
>
|
||||||
> Design også en kompakt "mini-leaderboard"-variant (topp 3 + evt. "og
|
> Design også en kompakt "mini-leaderboard"-variant (topp 3 + evt. "og
|
||||||
> N til") egnet til å vises øverst på selve rundens detaljside — et
|
> N til") egnet til å vises øverst på selve rundens detaljside — et
|
||||||
|
|
@ -1038,6 +1043,14 @@ mye stablet oppå hverandre"-lærdom som tidligere runder denne uken),
|
||||||
og ruten (foreslått `/my-rounds/[id]/leaderboard`, samme mønster som
|
og ruten (foreslått `/my-rounds/[id]/leaderboard`, samme mønster som
|
||||||
`/my-rounds/[id]/stats`/`/scorecard`).
|
`/my-rounds/[id]/stats`/`/scorecard`).
|
||||||
|
|
||||||
|
**Rettet i selve prompten 2026-07-26 (samme dag, fanget FØR den ble kjørt i
|
||||||
|
v0.app):** den opprinnelige "Deg"-merke-instruksen antok feilaktig at kun
|
||||||
|
eieren ser sin egen runde -- ADR-036 fase 3 (bygget samme dag, se over) gjør
|
||||||
|
nå at en lenket medspiller også kan se leaderboardet. Byttet til to
|
||||||
|
uavhengige merker ("Eier" fra `is_owner`, "Deg" fra en egen
|
||||||
|
`participant_id`-prop komponenten mottar utenfra) -- se oppdatert prompt
|
||||||
|
over. Fortsatt ikke kjørt i v0.app.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## En tredje (informasjons-)farge til designet — 🧠 DRØFTET 2026-07-25, IKKE besluttet
|
## En tredje (informasjons-)farge til designet — 🧠 DRØFTET 2026-07-25, IKKE besluttet
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ class PersonMatch(BaseModel):
|
||||||
last_name: str
|
last_name: str
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
home_club: str | None
|
home_club: str | None
|
||||||
|
# HCP-indeks (2026-07-26) -- brukt av "legg til medspiller"-søket på en
|
||||||
|
# frittstående runde, slik at organisatoren kan se HCP FØR de velger en
|
||||||
|
# spiller, ikke bare etterpå. None hvis personen ikke har registrert HCP.
|
||||||
|
handicap_index: float | None
|
||||||
|
|
||||||
|
|
||||||
def _avatar_url(avatar_key: str | None) -> str | None:
|
def _avatar_url(avatar_key: str | None) -> str | None:
|
||||||
|
|
@ -96,6 +100,7 @@ async def search_people(
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
f"""
|
f"""
|
||||||
SELECT u.id::text AS id, u.first_name, u.last_name, u.avatar_key, u.home_club,
|
SELECT u.id::text AS id, u.first_name, u.last_name, u.avatar_key, u.home_club,
|
||||||
|
u.handicap_index::float AS handicap_index,
|
||||||
CASE
|
CASE
|
||||||
WHEN f.id IS NOT NULL THEN 0
|
WHEN f.id IS NOT NULL THEN 0
|
||||||
WHEN u.home_club = ${home_club_idx} THEN 1
|
WHEN u.home_club = ${home_club_idx} THEN 1
|
||||||
|
|
@ -117,7 +122,11 @@ async def search_people(
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
PersonMatch(id=r["id"], first_name=r["first_name"], last_name=r["last_name"], avatar_url=_avatar_url(r["avatar_key"]), home_club=r["home_club"])
|
PersonMatch(
|
||||||
|
id=r["id"], first_name=r["first_name"], last_name=r["last_name"],
|
||||||
|
avatar_url=_avatar_url(r["avatar_key"]), home_club=r["home_club"],
|
||||||
|
handicap_index=r["handicap_index"],
|
||||||
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -318,6 +318,12 @@ class _ResolvedCourse:
|
||||||
def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
|
def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
|
||||||
return self._ratings.get((tee_name, gender))
|
return self._ratings.get((tee_name, gender))
|
||||||
|
|
||||||
|
def tee_options(self) -> list["TeeOption"]:
|
||||||
|
names: dict[str, list[Literal["m", "f"]]] = {}
|
||||||
|
for tee_name, gender in self._ratings:
|
||||||
|
names.setdefault(tee_name, []).append(gender) # type: ignore[arg-type]
|
||||||
|
return [TeeOption(name=n, genders=sorted(g)) for n, g in names.items()]
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse:
|
async def _resolve_teeoff_course(facility_slug: str, teeoff_course_id: int) -> _ResolvedCourse:
|
||||||
try:
|
try:
|
||||||
|
|
@ -423,6 +429,11 @@ class RoundParticipantOut(BaseModel):
|
||||||
display_name: str
|
display_name: str
|
||||||
is_owner: bool
|
is_owner: bool
|
||||||
gender: str
|
gender: str
|
||||||
|
# Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
|
||||||
|
# deltakers, f.eks. et kjønnsdelt utslag eller et bevisst enklere valg.
|
||||||
|
# Redigerbar av eieren via PATCH .../participants/{id}, kun for DENNE
|
||||||
|
# runden (endrer aldri spillerens profil).
|
||||||
|
tee_name_snapshot: str
|
||||||
handicap_index_snapshot: float | None
|
handicap_index_snapshot: float | None
|
||||||
course_handicap_snapshot: int | None
|
course_handicap_snapshot: int | None
|
||||||
counts_for_handicap: bool
|
counts_for_handicap: bool
|
||||||
|
|
@ -465,7 +476,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
"""
|
"""
|
||||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
rp.is_owner, rp.gender,
|
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||||
rp.score_differential::float AS score_differential, rp.stat_level
|
rp.score_differential::float AS score_differential, rp.stat_level
|
||||||
|
|
@ -539,10 +550,10 @@ async def _create_participant(
|
||||||
participant_row = await conn.fetchrow(
|
participant_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
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, tee_name_snapshot, 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,
|
||||||
stat_level)
|
stat_level)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING id::text AS id
|
RETURNING id::text AS id
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -550,6 +561,7 @@ async def _create_participant(
|
||||||
guest_name,
|
guest_name,
|
||||||
is_owner,
|
is_owner,
|
||||||
gender,
|
gender,
|
||||||
|
tee_name,
|
||||||
handicap_index,
|
handicap_index,
|
||||||
course_rating,
|
course_rating,
|
||||||
slope_rating,
|
slope_rating,
|
||||||
|
|
@ -841,11 +853,11 @@ async def update_round(
|
||||||
)
|
)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE round_participant SET course_rating_snapshot = $2, slope_rating_snapshot = $3,
|
UPDATE round_participant SET tee_name_snapshot = $2, course_rating_snapshot = $3,
|
||||||
tee_par_snapshot = $4, course_handicap_snapshot = $5
|
slope_rating_snapshot = $4, tee_par_snapshot = $5, course_handicap_snapshot = $6
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
""",
|
""",
|
||||||
p["id"], course_rating, slope_rating, tee_par, course_handicap_snapshot,
|
p["id"], body.tee_name, course_rating, slope_rating, tee_par, course_handicap_snapshot,
|
||||||
)
|
)
|
||||||
for hole_number in range(1, 19):
|
for hole_number in range(1, 19):
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|
@ -887,6 +899,10 @@ class ParticipantCreate(BaseModel):
|
||||||
# personens egen profil (samme kilde som eierens egen deltaker-rad).
|
# personens egen profil (samme kilde som eierens egen deltaker-rad).
|
||||||
gender: Literal["m", "f", "x"] | None = None
|
gender: Literal["m", "f", "x"] | None = None
|
||||||
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
|
# Utslagssted for DENNE deltakeren (2026-07-26) -- valgfritt, faller
|
||||||
|
# tilbake til rundens eget utslag (samme som før) hvis utelatt. Kan
|
||||||
|
# avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
|
||||||
|
tee_name: str | None = None
|
||||||
stat_level: StatLevel = "strokes_only"
|
stat_level: StatLevel = "strokes_only"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -941,15 +957,16 @@ async def add_participant(
|
||||||
if gender is None:
|
if gender is None:
|
||||||
raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")
|
raise app_error(400, "VALIDATION_FAILED", "gender er påkrevd for en gjest uten konto.")
|
||||||
|
|
||||||
if resolved.rating_for(round_row["tee_name_snapshot"], gender) is None and handicap_index is not None:
|
tee_name = body.tee_name or round_row["tee_name_snapshot"]
|
||||||
|
if resolved.rating_for(tee_name, gender) is None and handicap_index is not None:
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED",
|
400, "VALIDATION_FAILED",
|
||||||
"Rundens utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
|
"Valgt utslag har ingen rating for dette kjønnet -- HCP-sporing er ikke mulig for denne deltakeren.",
|
||||||
)
|
)
|
||||||
|
|
||||||
async with conn.transaction(), translate_db_errors():
|
async with conn.transaction(), translate_db_errors():
|
||||||
participant_id = await _create_participant(
|
participant_id = await _create_participant(
|
||||||
conn, round_id, resolved, round_row["tee_name_snapshot"],
|
conn, round_id, resolved, tee_name,
|
||||||
user_id=linked_user_id, guest_name=guest_name, is_owner=False,
|
user_id=linked_user_id, guest_name=guest_name, is_owner=False,
|
||||||
gender=gender, handicap_index=handicap_index,
|
gender=gender, handicap_index=handicap_index,
|
||||||
stat_level=body.stat_level,
|
stat_level=body.stat_level,
|
||||||
|
|
@ -958,7 +975,7 @@ async def add_participant(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
rp.is_owner, rp.gender,
|
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||||
rp.score_differential::float AS score_differential, rp.stat_level
|
rp.score_differential::float AS score_differential, rp.stat_level
|
||||||
|
|
@ -972,8 +989,38 @@ async def add_participant(
|
||||||
return RoundParticipantOut(**dict(row))
|
return RoundParticipantOut(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rounds/{round_id}/tee-options", response_model=list[TeeOption])
|
||||||
|
async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_current_user)) -> list[TeeOption]:
|
||||||
|
"""Utslagene tilgjengelig på RUNDENS bane -- brukt av 'rediger spiller'-
|
||||||
|
panelet til å la eieren velge et annet utslag enn rundens default for en
|
||||||
|
enkelt deltaker (f.eks. et kjønnsdelt utslag). Tilgjengelig for eier
|
||||||
|
ELLER en lenket medspiller (samme lese-tilgang som resten av runden)."""
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||||
|
round_row = await conn.fetchrow(
|
||||||
|
"SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id FROM round WHERE id = $1",
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
if round_row["course_source"] == "teeoff":
|
||||||
|
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
|
||||||
|
else:
|
||||||
|
resolved = await _resolve_personal_course(round_row["personal_course_id"])
|
||||||
|
return resolved.tee_options()
|
||||||
|
|
||||||
|
|
||||||
class ParticipantUpdate(BaseModel):
|
class ParticipantUpdate(BaseModel):
|
||||||
stat_level: StatLevel
|
# Vanlig exclude_unset-PATCH-semantikk, som resten av appen -- et
|
||||||
|
# utelatt felt endres ikke. `handicap_index` kan sendes eksplisitt som
|
||||||
|
# `null` for å fjerne HCP-sporing for denne deltakeren (samme mønster
|
||||||
|
# som profil-PATCH-en).
|
||||||
|
stat_level: StatLevel | None = None
|
||||||
|
# Utslagssted OG HCP er begge KUN et snapshot for DENNE runden (2026-07-26)
|
||||||
|
# -- endrer aldri spillerens faktiske profil, samme reproduserbarhets-
|
||||||
|
# prinsipp som resten av round_participant. Bevisst avvist etter at
|
||||||
|
# runden er fullført, samme presedens som bane-bytte i RoundUpdate
|
||||||
|
# (differensialen er da allerede beregnet fra det gamle grunnlaget).
|
||||||
|
tee_name: str | None = None
|
||||||
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
|
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
|
||||||
|
|
@ -983,19 +1030,88 @@ async def update_participant(
|
||||||
body: ParticipantUpdate,
|
body: ParticipantUpdate,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
updated_id = await conn.fetchval(
|
current = await conn.fetchrow(
|
||||||
"UPDATE round_participant SET stat_level = $3 WHERE id = $1 AND round_id = $2 RETURNING id",
|
"""
|
||||||
participant_id, round_id, body.stat_level,
|
SELECT rp.gender, rp.tee_name_snapshot,
|
||||||
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
|
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
||||||
|
r.teeoff_course_id, r.personal_course_id
|
||||||
|
FROM round_participant rp
|
||||||
|
JOIN round r ON r.id = rp.round_id
|
||||||
|
WHERE rp.id = $1 AND rp.round_id = $2
|
||||||
|
""",
|
||||||
|
participant_id, round_id,
|
||||||
)
|
)
|
||||||
if updated_id is None:
|
if current is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
|
||||||
|
|
||||||
|
rating_changed = "tee_name" in updates or "handicap_index" in updates
|
||||||
|
if rating_changed and current["completed_at"] is not None:
|
||||||
|
raise app_error(
|
||||||
|
409, "ALREADY_COMPLETED",
|
||||||
|
"Runden er allerede fullført -- kan ikke endre utslag eller HCP i etterkant.",
|
||||||
|
)
|
||||||
|
|
||||||
|
set_clauses: list[str] = []
|
||||||
|
values: list[object] = []
|
||||||
|
|
||||||
|
if "stat_level" in updates:
|
||||||
|
values.append(updates["stat_level"])
|
||||||
|
set_clauses.append(f"stat_level = ${len(values)}")
|
||||||
|
|
||||||
|
if rating_changed:
|
||||||
|
if current["course_source"] == "teeoff":
|
||||||
|
resolved = await _resolve_teeoff_course(
|
||||||
|
current["teeoff_facility_slug"], int(current["teeoff_course_id"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
resolved = await _resolve_personal_course(current["personal_course_id"])
|
||||||
|
|
||||||
|
new_tee_name = updates.get("tee_name") or current["tee_name_snapshot"]
|
||||||
|
new_handicap_index = (
|
||||||
|
updates["handicap_index"] if "handicap_index" in updates else current["handicap_index_snapshot"]
|
||||||
|
)
|
||||||
|
rating = resolved.rating_for(new_tee_name, current["gender"])
|
||||||
|
if rating is None:
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Valgt utslag har ingen rating for spillerens registrerte kjønn.",
|
||||||
|
)
|
||||||
|
course_rating, slope_rating, tee_par = rating
|
||||||
|
course_handicap_snapshot = (
|
||||||
|
course_handicap(new_handicap_index, slope_rating, course_rating, tee_par)
|
||||||
|
if new_handicap_index is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
values.append(new_tee_name)
|
||||||
|
set_clauses.append(f"tee_name_snapshot = ${len(values)}")
|
||||||
|
values.append(new_handicap_index)
|
||||||
|
set_clauses.append(f"handicap_index_snapshot = ${len(values)}")
|
||||||
|
values.append(course_rating)
|
||||||
|
set_clauses.append(f"course_rating_snapshot = ${len(values)}")
|
||||||
|
values.append(slope_rating)
|
||||||
|
set_clauses.append(f"slope_rating_snapshot = ${len(values)}")
|
||||||
|
values.append(tee_par)
|
||||||
|
set_clauses.append(f"tee_par_snapshot = ${len(values)}")
|
||||||
|
values.append(course_handicap_snapshot)
|
||||||
|
set_clauses.append(f"course_handicap_snapshot = ${len(values)}")
|
||||||
|
|
||||||
|
values.append(participant_id)
|
||||||
|
updated_id = await conn.fetchval(
|
||||||
|
f"UPDATE round_participant SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING id",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name,
|
||||||
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS display_name,
|
||||||
rp.is_owner, rp.gender,
|
rp.is_owner, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||||
rp.score_differential::float AS score_differential, rp.stat_level
|
rp.score_differential::float AS score_differential, rp.stat_level
|
||||||
|
|
@ -1005,7 +1121,8 @@ async def update_participant(
|
||||||
""",
|
""",
|
||||||
updated_id,
|
updated_id,
|
||||||
)
|
)
|
||||||
return RoundParticipantOut(**dict(row))
|
await broadcast_round_update(round_id)
|
||||||
|
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)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ type Player = {
|
||||||
name: string
|
name: string
|
||||||
gender: Gender
|
gender: Gender
|
||||||
hcp: number | null
|
hcp: number | null
|
||||||
|
teeName: string
|
||||||
isSelf: boolean
|
isSelf: boolean
|
||||||
countsForHandicap: boolean
|
countsForHandicap: boolean
|
||||||
scoreDifferential: number | null
|
scoreDifferential: number | null
|
||||||
|
|
@ -124,6 +125,9 @@ type ApiParticipant = {
|
||||||
display_name: string
|
display_name: string
|
||||||
is_owner: boolean
|
is_owner: boolean
|
||||||
gender: ApiGender
|
gender: ApiGender
|
||||||
|
// Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
|
||||||
|
// deltakers, redigerbart av eieren kun for DENNE runden.
|
||||||
|
tee_name_snapshot: string
|
||||||
handicap_index_snapshot: number | null
|
handicap_index_snapshot: number | null
|
||||||
course_handicap_snapshot: number | null
|
course_handicap_snapshot: number | null
|
||||||
counts_for_handicap: boolean
|
counts_for_handicap: boolean
|
||||||
|
|
@ -137,6 +141,12 @@ type PersonMatch = {
|
||||||
last_name: string
|
last_name: string
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
home_club: string | null
|
home_club: string | null
|
||||||
|
handicap_index: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiTeeOption = {
|
||||||
|
name: string
|
||||||
|
genders: ApiGender[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiRound = {
|
type ApiRound = {
|
||||||
|
|
@ -255,6 +265,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
const [completing, setCompleting] = useState(false)
|
const [completing, setCompleting] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [showEditRound, setShowEditRound] = useState(false)
|
const [showEditRound, setShowEditRound] = useState(false)
|
||||||
|
const [showEditParticipant, setShowEditParticipant] = useState(false)
|
||||||
// "Flere detaljer" er kollapset som default (etterspurt av bruker
|
// "Flere detaljer" er kollapset som default (etterspurt av bruker
|
||||||
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
|
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
|
||||||
// stablet oppå hverandre for et enkelt slag-registrering) slik at en
|
// stablet oppå hverandre for et enkelt slag-registrering) slik at en
|
||||||
|
|
@ -359,6 +370,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
name: playerLabel(p, viewerId),
|
name: playerLabel(p, viewerId),
|
||||||
gender: apiGenderToUi(p.gender),
|
gender: apiGenderToUi(p.gender),
|
||||||
hcp: p.handicap_index_snapshot,
|
hcp: p.handicap_index_snapshot,
|
||||||
|
teeName: p.tee_name_snapshot,
|
||||||
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
|
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
|
||||||
countsForHandicap: p.counts_for_handicap,
|
countsForHandicap: p.counts_for_handicap,
|
||||||
scoreDifferential: p.score_differential,
|
scoreDifferential: p.score_differential,
|
||||||
|
|
@ -493,6 +505,31 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utslag+HCP for en enkelt deltaker (2026-07-26) -- gjelder KUN denne
|
||||||
|
// runden, rører aldri spillerens egen profil. Bygget for å dekke at en
|
||||||
|
// medspiller kan spille fra et annet utslag enn eieren, og at en foreslått
|
||||||
|
// HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen.
|
||||||
|
async function patchParticipant(
|
||||||
|
participantId: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json().catch(() => null)
|
||||||
|
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere spilleren. Prøv igjen." }
|
||||||
|
}
|
||||||
|
const updated: ApiParticipant = await res.json()
|
||||||
|
setRound((prev) =>
|
||||||
|
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
|
||||||
|
)
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -694,6 +731,42 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Utslag+HCP for DENNE runden (2026-07-26) -- ikke alle deltakere
|
||||||
|
spiller nødvendigvis fra samme utslag som eieren, og en
|
||||||
|
auto-foreslått HCP (fra profilen ved søk) kan være feil for
|
||||||
|
akkurat denne dagen. Endrer aldri spillerens egen profil. */}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Utslag: <span className="font-semibold text-foreground">{activePlayer.teeName}</span>
|
||||||
|
{" · "}
|
||||||
|
HCP:{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{activePlayer.hcp !== null ? activePlayer.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isOwnerViewer && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditParticipant((v) => !v)}
|
||||||
|
aria-expanded={showEditParticipant}
|
||||||
|
className="text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{showEditParticipant ? "Lukk" : "Rediger for denne runden"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditParticipant && isOwnerViewer && !readOnly && (
|
||||||
|
<EditParticipantPanel
|
||||||
|
roundId={roundId}
|
||||||
|
player={activePlayer}
|
||||||
|
onPatch={patchParticipant}
|
||||||
|
onClose={() => setShowEditParticipant(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Skjules når runden er fullført (2026-07-25, rapportert av bruker
|
{/* Skjules når runden er fullført (2026-07-25, rapportert av bruker
|
||||||
som "veldig mye dobbel informasjon") -- CompletedBanner sin
|
som "veldig mye dobbel informasjon") -- CompletedBanner sin
|
||||||
"Se full rundestatistikk"-lenke dekker nå akkurat det samme,
|
"Se full rundestatistikk"-lenke dekker nå akkurat det samme,
|
||||||
|
|
@ -1430,7 +1503,11 @@ function AddParticipantForm({
|
||||||
<span className="truncate text-base font-bold text-foreground">
|
<span className="truncate text-base font-bold text-foreground">
|
||||||
{person.first_name} {person.last_name}
|
{person.first_name} {person.last_name}
|
||||||
</span>
|
</span>
|
||||||
{person.home_club && <span className="truncate text-sm text-muted-foreground">{person.home_club}</span>}
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{person.home_club}
|
||||||
|
{person.home_club && person.handicap_index !== null && " · "}
|
||||||
|
{person.handicap_index !== null && `HCP ${person.handicap_index.toFixed(1).replace(".", ",")}`}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -1746,6 +1823,126 @@ function EditRoundPanel({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rediger utslag+HCP for én deltaker, KUN for denne runden (2026-07-26) -
|
||||||
|
|
||||||
|
function EditParticipantPanel({
|
||||||
|
roundId,
|
||||||
|
player,
|
||||||
|
onPatch,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
roundId: string
|
||||||
|
player: Player
|
||||||
|
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [teeOptions, setTeeOptions] = useState<ApiTeeOption[] | null>(null)
|
||||||
|
const [teeName, setTeeName] = useState(player.teeName)
|
||||||
|
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiTeeOption[]) => {
|
||||||
|
if (!cancelled) setTeeOptions(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTeeOptions([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [roundId])
|
||||||
|
|
||||||
|
const apiGender = uiGenderToApi(player.gender)
|
||||||
|
const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender))
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
const body: Record<string, unknown> = {}
|
||||||
|
if (teeName !== player.teeName) body.tee_name = teeName
|
||||||
|
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||||||
|
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
|
||||||
|
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
|
||||||
|
if (Object.keys(body).length === 0) {
|
||||||
|
setSaving(false)
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await onPatch(player.id, body)
|
||||||
|
setSaving(false)
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Rediger {player.name} for denne runden</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="edit-participant-tee" className="text-sm font-semibold">
|
||||||
|
Utslagssted
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="edit-participant-tee"
|
||||||
|
value={teeName}
|
||||||
|
onChange={(e) => setTeeName(e.target.value)}
|
||||||
|
disabled={teeOptions === null}
|
||||||
|
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{!availableTees.some((t) => t.name === teeName) && <option value={teeName}>{teeName}</option>}
|
||||||
|
{availableTees.map((t) => (
|
||||||
|
<option key={t.name} value={t.name}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{teeOptions !== null && availableTees.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Fant ingen andre utslag med rating for dette kjønnet på denne banen.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="edit-participant-hcp" className="text-sm font-semibold">
|
||||||
|
HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-participant-hcp"
|
||||||
|
inputMode="decimal"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={hcp}
|
||||||
|
onChange={(e) => setHcp(e.target.value)}
|
||||||
|
placeholder="F.eks. 18"
|
||||||
|
className="h-12 rounded-2xl text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" disabled={saving} onClick={save} className="h-11 rounded-xl text-sm font-bold">
|
||||||
|
{saving ? "Lagrer…" : "Lagre endringer"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ChangeCourseForm({
|
function ChangeCourseForm({
|
||||||
onPatch,
|
onPatch,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue