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(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/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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"}`,
|
||||
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` 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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +1121,7 @@ async def update_participant(
|
|||
""",
|
||||
updated_id,
|
||||
)
|
||||
await broadcast_round_update(round_id)
|
||||
return RoundParticipantOut(**dict(row))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 nå 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 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({
|
||||
onPatch,
|
||||
onCancel,
|
||||
|
|
|
|||
Loading…
Reference in a new issue