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:
Erol Haagenrud 2026-07-26 09:18:52 +02:00
parent 3fc0c7460c
commit 9d5e9965db
7 changed files with 471 additions and 33 deletions

View file

@ -45,7 +45,10 @@
"Bash(python3 -m ensurepip --user)",
"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 -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/)"
]
}
}

View 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;

View file

@ -3084,18 +3084,92 @@ Ferdig og verifisert:
produksjons-https ga FastAPI sin egen JSON-`{"detail":"Not Found"}`,
ikke Next.js sin HTML-404 — samme verifiseringsmønster som ADR-027s
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``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:
0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):**
varsler (in-app varslingssenter) + leaderboard-BACKENDEN for
frittstående runder er begge bygget og scratch-verifisert i samme
runde (39/39 sjekker) — se status over. Migrasjon `026_
notifications.sql` er IKKE kjørt mot ekte `teecup_db` ennå, og
`teecup_api`/`teecup_frontend` er ikke redeployet med disse
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.
0. **Rundeleaderboard — venter på V0-zip (2026-07-26):** backenden
(`GET /rounds/{id}/leaderboard`) er live siden tidligere samme dag.
V0-prompten for selve VISNINGEN er skrevet, sendt til bruker, og
rettet ("Deg"/"Eier"-merke, se status over) — men 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
venner/kategorisert deling fase 1 (ADR-036) — begge designet
2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE

View file

@ -1018,9 +1018,14 @@ stemte eksakt).
> HCP registrert) skal vises tydelig nederst/uten rangering i
> netto-visningen, ikke skjules eller krasje.
>
> **Rundens egen eier** bør være visuelt gjenkjennelig i listen (f.eks.
> et lite "Deg"-merke ved siden av navnet), siden det alltid er
> eieren som ser sin egen runde.
> **To distinkte merker, ikke ett:** et "Eier"-merke for rundens eier
> (kommer direkte fra API-et sitt `is_owner`-felt), OG uavhengig av det et
> "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
> 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
`/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

View file

@ -53,6 +53,10 @@ class PersonMatch(BaseModel):
last_name: str
avatar_url: 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:
@ -96,6 +100,7 @@ async def search_people(
rows = await conn.fetch(
f"""
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
WHEN f.id IS NOT NULL THEN 0
WHEN u.home_club = ${home_club_idx} THEN 1
@ -117,7 +122,11 @@ async def search_people(
)
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
]

View file

@ -318,6 +318,12 @@ class _ResolvedCourse:
def rating_for(self, tee_name: str, gender: str) -> tuple[float, int, int] | None:
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:
try:
@ -423,6 +429,11 @@ class RoundParticipantOut(BaseModel):
display_name: str
is_owner: bool
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
course_handicap_snapshot: int | None
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,
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.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level
@ -539,10 +550,10 @@ async def _create_participant(
participant_row = await conn.fetchrow(
"""
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,
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
""",
round_id,
@ -550,6 +561,7 @@ async def _create_participant(
guest_name,
is_owner,
gender,
tee_name,
handicap_index,
course_rating,
slope_rating,
@ -841,11 +853,11 @@ async def update_round(
)
await conn.execute(
"""
UPDATE round_participant SET course_rating_snapshot = $2, slope_rating_snapshot = $3,
tee_par_snapshot = $4, course_handicap_snapshot = $5
UPDATE round_participant SET tee_name_snapshot = $2, course_rating_snapshot = $3,
slope_rating_snapshot = $4, tee_par_snapshot = $5, course_handicap_snapshot = $6
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):
await conn.execute(
@ -887,6 +899,10 @@ class ParticipantCreate(BaseModel):
# personens egen profil (samme kilde som eierens egen deltaker-rad).
gender: Literal["m", "f", "x"] | None = None
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"
@ -941,15 +957,16 @@ async def add_participant(
if gender is None:
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(
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():
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,
gender=gender, handicap_index=handicap_index,
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,
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.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level
@ -972,8 +989,38 @@ async def add_participant(
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):
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)
@ -983,19 +1030,88 @@ async def update_participant(
body: ParticipantUpdate,
user: CurrentUser = Depends(get_current_user),
) -> 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:
await _get_owned_round_or_404(conn, round_id, user.user_id)
updated_id = await conn.fetchval(
"UPDATE round_participant SET stat_level = $3 WHERE id = $1 AND round_id = $2 RETURNING id",
participant_id, round_id, body.stat_level,
current = await conn.fetchrow(
"""
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.")
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(
"""
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,
rp.is_owner, rp.gender,
rp.is_owner, rp.gender, rp.tee_name_snapshot,
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
rp.course_handicap_snapshot, rp.counts_for_handicap,
rp.score_differential::float AS score_differential, rp.stat_level
@ -1005,7 +1121,8 @@ async def update_participant(
""",
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)

View file

@ -53,6 +53,7 @@ type Player = {
name: string
gender: Gender
hcp: number | null
teeName: string
isSelf: boolean
countsForHandicap: boolean
scoreDifferential: number | null
@ -124,6 +125,9 @@ type ApiParticipant = {
display_name: string
is_owner: boolean
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
course_handicap_snapshot: number | null
counts_for_handicap: boolean
@ -137,6 +141,12 @@ type PersonMatch = {
last_name: string
avatar_url: string | null
home_club: string | null
handicap_index: number | null
}
type ApiTeeOption = {
name: string
genders: ApiGender[]
}
type ApiRound = {
@ -255,6 +265,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false)
const [showEditParticipant, setShowEditParticipant] = useState(false)
// "Flere detaljer" er kollapset som default (etterspurt av bruker
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
// 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),
gender: apiGenderToUi(p.gender),
hcp: p.handicap_index_snapshot,
teeName: p.tee_name_snapshot,
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
countsForHandicap: p.counts_for_handicap,
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() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
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
som "veldig mye dobbel informasjon") -- CompletedBanner sin
"Se full rundestatistikk"-lenke dekker akkurat det samme,
@ -1430,7 +1503,11 @@ function AddParticipantForm({
<span className="truncate text-base font-bold text-foreground">
{person.first_name} {person.last_name}
</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>
</button>
</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 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({
onPatch,
onCancel,