Leaderboard — ikke bygget frontend ennå, kun backend. V0-prompten er skrevet og ligger i FEATURE_BACKLOG.md, klar til å limes inn i v0.app.
Mottatte slag i hull-headeren — live nå, viser f.eks. "Hull 7 · Par 4 · Hcp 5 · −1" når aktiv spiller mottar slag på hullet. Gjeste-e-post/kjønn/navn — backend er live (kan nå lagres og redigeres for midlertidige spillere), men jeg har ikke bygget selve grensesnittet for det ennå — det hører sammen med spillerliste-redesignet du ba om. Spillerliste-redesign — skrev et fullstendig V0-prompt (i FEATURE_BACKLOG.md) som dekker: vertikal liste, utslag/HCP/Rediger inne i hvert spillerkort, og for midlertidige spillere også Navn/Kjønn/E-post i redigeringsflyten. Klar for deg å kjøre i v0.app — send meg zip-en når den er klar, så integrerer jeg den (og erstatter min midlertidige EditParticipantPanel med den nye designen).
This commit is contained in:
parent
9d5e9965db
commit
5d319f1b68
6 changed files with 268 additions and 13 deletions
|
|
@ -48,7 +48,8 @@
|
||||||
"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(grep -n \"participants/\\\\${\" /opt/teecup/frontend/components/round-detail.tsx)",
|
||||||
"Bash(python3 -)",
|
"Bash(python3 -)",
|
||||||
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)"
|
"Bash(curl -s -o /dev/null -w \"teeoff.no: %{http_code}\\\\n\" https://teeoff.no/)",
|
||||||
|
"Bash(python3 -c \"import ast; ast.parse\\(open\\('app/routers/rounds.py'\\).read\\(\\)\\)\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
029_round_participant_guest_email.sql
Normal file
15
029_round_participant_guest_email.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — valgfri e-post for gjestedeltakere (migrasjon 029)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Reist av bruker 2026-07-26 sammen med et ønske om å kunne redigere
|
||||||
|
-- navn/kjønn/e-post for en "midlertidig spiller" (gjest uten konto) fra
|
||||||
|
-- samme sted som utslag/HCP redigeres. Rent kontaktfelt -- ingen
|
||||||
|
-- kontosammenkobling/invitasjon bygget her, kun lagring+redigering.
|
||||||
|
-- Kun meningsfullt for gjester (user_id IS NULL) -- håndheves i app-laget,
|
||||||
|
-- ikke en CHECK, siden det speiler de andre gjeste-kun-feltene (guest_name)
|
||||||
|
-- som heller ikke er CHECK-håndhevet mot user_id her.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE round_participant ADD COLUMN guest_email text;
|
||||||
50
CLAUDE.md
50
CLAUDE.md
|
|
@ -3162,9 +3162,57 @@ Ferdig og verifisert:
|
||||||
uavhengige merker: "Eier" (fra API-ets `is_owner`) og "Deg" (fra en
|
uavhengige merker: "Eier" (fra API-ets `is_owner`) og "Deg" (fra en
|
||||||
`participant_id`-prop komponenten mottar utenfra, ikke fra selve
|
`participant_id`-prop komponenten mottar utenfra, ikke fra selve
|
||||||
leaderboard-dataen). Prompten er ikke sendt til v0.app ennå.
|
leaderboard-dataen). Prompten er ikke sendt til v0.app ennå.
|
||||||
|
- **Mottatte slag i hull-headeren + gjeste-e-post/kjønn/navn redigerbart,
|
||||||
|
BYGGET OG SCRATCH-VERIFISERT (2026-07-26), IKKE ENNÅ RULLET UT:** brukeren
|
||||||
|
viste et skjermbilde av det nettopp rullede "Rediger for denne runden"-
|
||||||
|
tillegget og ba om to ting: (1) hvor mange slag aktiv spiller MOTTAR på
|
||||||
|
det aktive hullet vist i selve hull-headeren (eksempel "Hull 7 - Par 4 -
|
||||||
|
Hcp 5 - -1"), og (2) for en midlertidig spiller (gjest) skal Navn/Kjønn/
|
||||||
|
E-post også være redigerbart, ikke bare utslag/HCP. Ba samtidig om et
|
||||||
|
V0-prompt for å designe om selve spillerlisten (utslag/HCP/rediger inn i
|
||||||
|
selve spillerknappen, listet vertikalt i stedet for dagens horisontale
|
||||||
|
scroll-rad).
|
||||||
|
**Punkt 1 bygget direkte** (triviell, ren frontend, ingen backend-endring
|
||||||
|
-- `strokes_received` var allerede hentet fra `GET .../holes` fra før,
|
||||||
|
bare aldri vist FØR scoring): `round-detail.tsx` sin hull-header viser nå
|
||||||
|
`· −N` (ekte minustegn, golfvis fortegn) når aktiv spiller mottar minst
|
||||||
|
ett slag på hullet, ellers uendret.
|
||||||
|
**Punkt 2 krevde ny backend:** ny migrasjon `029_round_participant_
|
||||||
|
guest_email.sql` (`round_participant.guest_email`, nullable). `Participant
|
||||||
|
Create` fikk et valgfritt `guest_email: EmailStr | None` (avvist sammen
|
||||||
|
med `user_id`). `ParticipantUpdate` fikk `guest_name`/`gender`/
|
||||||
|
`guest_email` -- KUN gyldig for en gjest (`user_id IS NULL`), avvist
|
||||||
|
(400) på en lenket deltaker. `gender`-endring inngår nå i samme "rating_
|
||||||
|
changed"-bunt som utslag/HCP (påvirker gyldig rating), avvist etter
|
||||||
|
fullføring (409, samme presedens); `guest_name` alene har ingen rating-
|
||||||
|
implikasjon og forblir redigerbar selv etter fullføring (ren metadata).
|
||||||
|
**Frontend for punkt 2 IKKE bygget ennå** -- overlatt til V0-prompten
|
||||||
|
(se FEATURE_BACKLOG.md), siden brukeren eksplisitt ba om det og den
|
||||||
|
eksisterende hånd-bygde `EditParticipantPanel` uansett skal erstattes av
|
||||||
|
den nye vertikale spillerlisten.
|
||||||
|
**Scratch-verifisert, 22/22 nye sjekker** (gjest med e-post ved
|
||||||
|
opprettelse, avvist sammen med user_id, navn+kjønn+e-post-redigering
|
||||||
|
lykkes og rating regnes om for nytt kjønn, eksplisitt null fjerner
|
||||||
|
e-post, kjønnsendring til urepresentert rating avvist med full rollback,
|
||||||
|
alle tre gjeste-feltene avvist på en LENKET deltaker mens utslag/HCP
|
||||||
|
fortsatt fungerer uendret der, kjønn/utslag/HCP-endring blokkert etter
|
||||||
|
fullføring mens navneendring fortsatt tillates). Full regresjonskjøring
|
||||||
|
av samme dags 27+35-punkts testsuiter mot samme friske scratch-database,
|
||||||
|
alle 62 fortsatt grønne. `test_isolation.sql` 12/12. Ekte typesjekket
|
||||||
|
produksjonsbuild kompilerte rent (inkl. punkt 1).
|
||||||
|
**Rullet ut mot ekte systemer 2026-07-26**, bruker bekreftet eksplisitt:
|
||||||
|
migrasjon 029 kjørt mot ekte `teecup_db` (kolonne bekreftet,
|
||||||
|
`test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d
|
||||||
|
--build teecup_api teecup_frontend`. Begge containere boot-et rent,
|
||||||
|
`/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0. **Rundeleaderboard — venter på V0-zip (2026-07-26):** backenden
|
0a. **Spillerliste-redesign — venter på V0-zip (2026-07-26):** utslag/HCP/
|
||||||
|
Rediger flyttet inn i selve spillerkortet, vertikal liste, gjeste-navn/
|
||||||
|
kjønn/e-post redigerbart. Backenden er live (migrasjon 029), prompten
|
||||||
|
er skrevet og sendt til bruker (se FEATURE_BACKLOG.md), ikke kjørt i
|
||||||
|
v0.app ennå.
|
||||||
|
0b. **Rundeleaderboard — venter på V0-zip (2026-07-26):** backenden
|
||||||
(`GET /rounds/{id}/leaderboard`) er live siden tidligere samme dag.
|
(`GET /rounds/{id}/leaderboard`) er live siden tidligere samme dag.
|
||||||
V0-prompten for selve VISNINGEN er skrevet, sendt til bruker, og
|
V0-prompten for selve VISNINGEN er skrevet, sendt til bruker, og
|
||||||
rettet ("Deg"/"Eier"-merke, se status over) — men ikke kjørt i
|
rettet ("Deg"/"Eier"-merke, se status over) — men ikke kjørt i
|
||||||
|
|
|
||||||
|
|
@ -1053,6 +1053,154 @@ over. Fortsatt ikke kjørt i v0.app.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Spillerliste-redesign (vertikal, utslag/HCP/rediger inline) — backend BYGGET, V0-prompt sendt 2026-07-26
|
||||||
|
|
||||||
|
Samme dag som HCP-i-søk/rediger-utslag-per-deltaker (se ADR-033-loggen i
|
||||||
|
CLAUDE.md), rett etter at den runden var rullet ut live. Brukeren viste et
|
||||||
|
skjermbilde av "+ Medspiller"-skjermen og pekte på to ting: (1) utslag+HCP
|
||||||
|
(nettopp bygget samme dag) bør flyttes OPP i selve spillerknappen i stedet
|
||||||
|
for å ligge som en egen rad under statistikknivå-velgeren, med Navn/
|
||||||
|
Utslag/Hcp/Rediger inni knappen, og spillerne listet VERTIKALT i stedet for
|
||||||
|
dagens horisontale scroll-rad. For en "midlertidig spiller" (gjest uten
|
||||||
|
konto) skal Rediger-flyten også dekke Navn/Kjønn/E-post. (2) Under selve
|
||||||
|
hull-registreringen bør det vises hvor mange mottatte slag aktiv spiller
|
||||||
|
har på det hullet, eksempel "Hull 7 - Par 4 - Hcp 5 - -1".
|
||||||
|
|
||||||
|
**Punkt 2 er ALLEREDE bygget og live** — ren frontend-tilføyelse i
|
||||||
|
`round-detail.tsx` sin hull-header, bruker data (`strokes_received`) som
|
||||||
|
allerede var hentet fra `GET .../holes` fra før (samme felt `ScoreSoFar`/
|
||||||
|
leaderboardet bruker til netto), bare aldri vist FØR scoring. Golfvis
|
||||||
|
fortegn (ekte minustegn), vist kun når spilleren faktisk mottar minst ett
|
||||||
|
slag på hullet (`Hull 7 · Par 4 · Hcp 5 · −1`).
|
||||||
|
|
||||||
|
**Punkt 1 krevde ny backend, bygget og scratch-verifisert (22/22 sjekker +
|
||||||
|
full regresjon av samme dags 27+35-punkts testsuiter) samme dag:** ny
|
||||||
|
migrasjon `029_round_participant_guest_email.sql` (`round_participant.
|
||||||
|
guest_email`, nullable). `ParticipantCreate` (`POST .../participants`)
|
||||||
|
fikk et valgfritt `guest_email: EmailStr | None` (avvist sammen med
|
||||||
|
`user_id` — en lenket bruker har allerede sin egen konto-e-post).
|
||||||
|
`ParticipantUpdate` (`PATCH .../participants/{id}`) fikk `guest_name`/
|
||||||
|
`gender`/`guest_email` — men KUN gyldig for en gjest (`user_id IS NULL`);
|
||||||
|
forsøk på en lenket deltaker avvises tydelig (400). `gender`-endring er nå
|
||||||
|
en del av samme "rating_changed"-bunt som utslag/HCP (påvirker hvilken
|
||||||
|
utslags-rating som er gyldig) — regnes om, avvist (400) hvis den nye
|
||||||
|
kombinasjonen (nytt/uendret utslag × nytt kjønn) mangler rating, og
|
||||||
|
bevisst avvist etter fullføring (409, samme presedens som utslag/HCP).
|
||||||
|
`guest_name`-endring alene har INGEN rating-implikasjon og forblir derfor
|
||||||
|
tillatt selv etter fullføring (ren metadata, samme som `RoundUpdate` sitt
|
||||||
|
navnefelt).
|
||||||
|
|
||||||
|
**V0-prompt sendt til bruker 2026-07-26, ikke kjørt i v0.app ennå:**
|
||||||
|
|
||||||
|
> Design om spillerlisten på en frittstående golfrundes hull-for-hull-
|
||||||
|
> registreringsside i TeeCup (Next.js + Tailwind + shadcn/ui, mobil-først,
|
||||||
|
> eksisterende merkevarefarger — grønn primær, oransje sekundær). I dag er
|
||||||
|
> spillerne en horisontal scroll-rad av små faner øverst på siden; dette
|
||||||
|
> skal bli en VERTIKAL liste av spillerkort i stedet. Runden kan ha alt fra
|
||||||
|
> kun eieren alene til mange spillere samtidig, så listen må skalere pent
|
||||||
|
> til 10+ rader uten å bli en endeløs, monoton vegg.
|
||||||
|
>
|
||||||
|
> **Hvert spillerkort viser:** navn, utslagssted, HCP (eller "ikke satt"
|
||||||
|
> hvis fraværende), og en tydelig "Rediger"-knapp/lenke. Kortet er OGSÅ
|
||||||
|
> selve trykkflaten for å VELGE spilleren som aktiv for hull-registrering
|
||||||
|
> (samme funksjon som dagens fane-klikk) — velg en tydelig, men ikke
|
||||||
|
> overveldende, måte å vise "dette kortet betyr både 'velg meg' og
|
||||||
|
> 'rediger meg'" på (f.eks. hele kortet velger, en distinkt undersone/
|
||||||
|
> knapp for rediger) uten at de to handlingene blandes sammen ved et
|
||||||
|
> uhell.
|
||||||
|
>
|
||||||
|
> **To distinkte merker på kortet**, kan begge gjelde samme kort: "Deg"
|
||||||
|
> (spilleren SOM SER PÅ siden akkurat nå — komponenten mottar egen
|
||||||
|
> deltaker-id som prop, siden både eieren og en lenket medspiller kan se
|
||||||
|
> og bruke siden) og "Eier" (rundens eier, fra et eget boolsk felt på
|
||||||
|
> spilleren).
|
||||||
|
>
|
||||||
|
> **Rediger-flyten** (åpnes fra kortet, f.eks. en utvidbar seksjon eller et
|
||||||
|
> ark/modal — din vurdering) inneholder:
|
||||||
|
> - Utslagssted: en nedtrekksliste hentet fra et eget endepunkt (banens
|
||||||
|
> faktiske utslag på DENNE runden), filtrert til utslag som har en
|
||||||
|
> rating for spillerens (gjeldende, eller nylig valgte — se under)
|
||||||
|
> kjønn.
|
||||||
|
> - HCP: et tallfelt, valgfritt (kan stå tomt/fjernes).
|
||||||
|
> - Statistikknivå for spilleren: tre valg ("Kun slag" / "Slag og putter" /
|
||||||
|
> "All statistikk") — samme tre-valgs-mønster som resten av appen bruker
|
||||||
|
> for dette (segmentert knapperad).
|
||||||
|
> - **KUN hvis spilleren er en "midlertidig spiller" (gjest uten TeeCup-
|
||||||
|
> konto — komponenten vet dette fra at spilleren mangler en konto-id)**:
|
||||||
|
> TRE EKSTRA felt — Navn (fritekst), Kjønn (mann/kvinne/annet), E-post
|
||||||
|
> (valgfritt, e-postformat). Endring av kjønn her skal oppdatere hvilke
|
||||||
|
> utslag som er valgbare i utslag-nedtrekkslisten (samme filter som
|
||||||
|
> over, reaktivt).
|
||||||
|
> - En tydelig "gjelder kun denne runden, endrer ikke [spillerens]
|
||||||
|
> profil"-forklaring for utslag/HCP-feltene (gjelder IKKE navn/kjønn/
|
||||||
|
> e-post for en gjest, siden en gjest ikke har noen egen profil å
|
||||||
|
> bevare uendret).
|
||||||
|
>
|
||||||
|
> **For en spiller MED egen TeeCup-konto** (ikke en gjest) skal Rediger-
|
||||||
|
> flyten KUN vise utslag/HCP/statistikknivå — ALDRI navn/kjønn/e-post-
|
||||||
|
> feltene (gir ingen mening, personen har sin egen konto).
|
||||||
|
>
|
||||||
|
> **"Legg til medspiller"-knappen/flyten** (søk-som-du-skriver mot ekte
|
||||||
|
> brukere, med et "legg til uten konto"-alternativ for gjester) beholdes
|
||||||
|
> konseptuelt som i dag, men tilpasses visuelt til den nye vertikale
|
||||||
|
> listen. Gjesteskjemaet i "legg til uten konto" bør få det samme
|
||||||
|
> valgfrie e-post-feltet som Rediger-flyten nå støtter (kan sette e-post
|
||||||
|
> allerede ved opprettelse, ikke bare i etterkant).
|
||||||
|
>
|
||||||
|
> **Skjul redigering/tilføyelse/fjerning helt** når komponenten får beskjed
|
||||||
|
> om at brukeren IKKE har lov til å forvalte runden (en prop, f.eks.
|
||||||
|
> `canManage`) — en medspiller uten forvaltningsrett skal fortsatt kunne
|
||||||
|
> VELGE et kort for å registrere score, bare ikke redigere/legge til/
|
||||||
|
> fjerne noen. Skjul ALL redigering (også for eieren) når runden er
|
||||||
|
> fullført (en `readOnly`-prop) — vis da utslag/HCP som ren tekst, ingen
|
||||||
|
> Rediger-knapp.
|
||||||
|
>
|
||||||
|
> Tilgjengelighet er et ufravikelig krav: god kontrast, stor nok skrift,
|
||||||
|
> store trykkflater (min. 44px), aldri kun farge for å formidle status
|
||||||
|
> (Deg/Eier-merkene trenger tekst, ikke bare farge), lesbar uten briller.
|
||||||
|
|
||||||
|
**Data komponenten skal jobbe mot (allerede live i produksjon):**
|
||||||
|
```
|
||||||
|
type Participant = {
|
||||||
|
id: string
|
||||||
|
user_id: string | null // null = gjest ("midlertidig spiller")
|
||||||
|
guest_name: string | null
|
||||||
|
guest_email: string | null // kun meningsfullt når user_id er null
|
||||||
|
display_name: string // alltid utfylt, bruk denne til visning
|
||||||
|
is_owner: boolean
|
||||||
|
gender: "m" | "f" | "x"
|
||||||
|
tee_name_snapshot: string
|
||||||
|
handicap_index_snapshot: number | null
|
||||||
|
course_handicap_snapshot: number | null
|
||||||
|
stat_level: "strokes_only" | "strokes_and_putts" | "full"
|
||||||
|
}
|
||||||
|
|
||||||
|
GET /rounds/{round_id}/tee-options
|
||||||
|
→ [{ name: string, genders: ("m"|"f")[] }]
|
||||||
|
|
||||||
|
PATCH /rounds/{round_id}/participants/{id}
|
||||||
|
body (alle valgfrie, kun de som faktisk endres sendes):
|
||||||
|
stat_level, tee_name, handicap_index (kan settes til null),
|
||||||
|
guest_name, gender, guest_email (kan settes til null)
|
||||||
|
-- guest_name/gender/guest_email avvises (400) hvis participant.user_id
|
||||||
|
er satt (ikke en gjest)
|
||||||
|
-- avvist (409) hvis runden er fullført OG tee_name/handicap_index/
|
||||||
|
gender er blant feltene som sendes
|
||||||
|
|
||||||
|
POST /rounds/{round_id}/participants
|
||||||
|
body: ENTEN { user_id, tee_name?, stat_level? }
|
||||||
|
ELLER { guest_name, gender, handicap_index?, guest_email?, tee_name?, stat_level? }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ikke avgjort ennå, avklares når zip-en er klar til integrering:** nøyaktig
|
||||||
|
plassering av "+ Medspiller"-knappen i den nye vertikale listen (nederst
|
||||||
|
som et eget kort, eller en fast knapp over/under listen — din vurdering
|
||||||
|
når du ser V0s forslag), og om Rediger-flyten skal være en inline-
|
||||||
|
utvidelse av kortet eller et eget ark/modal (V0 fikk begge alternativer
|
||||||
|
åpne i prompten over, bevisst).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
Brukeren spurte om det ville vært en idé å introdusere én (eller kanskje
|
Brukeren spurte om det ville vært en idé å introdusere én (eller kanskje
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from datetime import date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
from .. import teeoff_client
|
from .. import teeoff_client
|
||||||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket
|
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket
|
||||||
|
|
@ -422,6 +422,9 @@ class RoundParticipantOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
user_id: str | None
|
user_id: str | None
|
||||||
guest_name: str | None
|
guest_name: str | None
|
||||||
|
# Valgfritt kontaktfelt, KUN for gjester (2026-07-26) -- ingen betydning
|
||||||
|
# for en lenket bruker, som allerede har sin egen kontos e-post.
|
||||||
|
guest_email: str | None
|
||||||
# Alltid utfylt -- guest_name for gjester, ellers en LEVENDE oppslått
|
# Alltid utfylt -- guest_name for gjester, ellers en LEVENDE oppslått
|
||||||
# app_user.display_name for enhver lenket bruker (eier ELLER medspiller,
|
# app_user.display_name for enhver lenket bruker (eier ELLER medspiller,
|
||||||
# ADR-036 fase 3-utvidelsen 2026-07-26) -- ikke en snapshot, samme
|
# ADR-036 fase 3-utvidelsen 2026-07-26) -- ikke en snapshot, samme
|
||||||
|
|
@ -474,7 +477,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
)
|
)
|
||||||
participant_rows = await conn.fetch(
|
participant_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
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, rp.guest_email,
|
||||||
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.tee_name_snapshot,
|
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,
|
||||||
|
|
@ -538,6 +541,7 @@ async def _create_participant(
|
||||||
gender: str,
|
gender: str,
|
||||||
handicap_index: float | None,
|
handicap_index: float | None,
|
||||||
stat_level: StatLevel = "strokes_only",
|
stat_level: StatLevel = "strokes_only",
|
||||||
|
guest_email: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
rating = resolved.rating_for(tee_name, gender)
|
rating = resolved.rating_for(tee_name, gender)
|
||||||
course_rating = slope_rating = tee_par = None
|
course_rating = slope_rating = tee_par = None
|
||||||
|
|
@ -550,15 +554,16 @@ 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, tee_name_snapshot, handicap_index_snapshot,
|
(round_id, user_id, guest_name, guest_email, 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, $12)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
RETURNING id::text AS id
|
RETURNING id::text AS id
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
user_id,
|
user_id,
|
||||||
guest_name,
|
guest_name,
|
||||||
|
guest_email,
|
||||||
is_owner,
|
is_owner,
|
||||||
gender,
|
gender,
|
||||||
tee_name,
|
tee_name,
|
||||||
|
|
@ -899,6 +904,9 @@ 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)
|
||||||
|
# Valgfritt kontaktfelt for en gjest (2026-07-26) -- avvist hvis oppgitt
|
||||||
|
# sammen med user_id (en lenket bruker har allerede sin egen e-post).
|
||||||
|
guest_email: EmailStr | None = None
|
||||||
# Utslagssted for DENNE deltakeren (2026-07-26) -- valgfritt, faller
|
# Utslagssted for DENNE deltakeren (2026-07-26) -- valgfritt, faller
|
||||||
# tilbake til rundens eget utslag (samme som før) hvis utelatt. Kan
|
# tilbake til rundens eget utslag (samme som før) hvis utelatt. Kan
|
||||||
# avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
|
# avvike fra de andre deltakerne, f.eks. et kjønnsdelt utslag.
|
||||||
|
|
@ -914,6 +922,8 @@ async def add_participant(
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
if (body.user_id is not None) == (body.guest_name is not None):
|
if (body.user_id is not None) == (body.guest_name is not None):
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.")
|
raise app_error(400, "VALIDATION_FAILED", "Oppgi enten user_id (funnet via søk) eller guest_name, ikke begge/ingen.")
|
||||||
|
if body.user_id is not None and body.guest_email is not None:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "guest_email gir ikke mening sammen med user_id -- en lenket bruker har allerede sin egen konto-e-post.")
|
||||||
|
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
round_row = await conn.fetchrow(
|
round_row = await conn.fetchrow(
|
||||||
|
|
@ -970,10 +980,11 @@ async def add_participant(
|
||||||
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,
|
||||||
|
guest_email=body.guest_email,
|
||||||
)
|
)
|
||||||
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, rp.guest_email,
|
||||||
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.tee_name_snapshot,
|
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,
|
||||||
|
|
@ -1021,6 +1032,13 @@ class ParticipantUpdate(BaseModel):
|
||||||
# (differensialen er da allerede beregnet fra det gamle grunnlaget).
|
# (differensialen er da allerede beregnet fra det gamle grunnlaget).
|
||||||
tee_name: str | None = None
|
tee_name: str | None = None
|
||||||
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
|
# KUN gyldig for en gjest (user_id IS NULL) -- gir ingen mening for en
|
||||||
|
# lenket bruker, som allerede har sin egen kontos navn/kjønn/e-post.
|
||||||
|
# `gender` er en del av rating-bunten under (påvirker hvilken utslags-
|
||||||
|
# rating som er gyldig), `guest_name`/`guest_email` er rene metadata.
|
||||||
|
guest_name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
gender: Literal["m", "f", "x"] | None = None
|
||||||
|
guest_email: EmailStr | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
|
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
|
||||||
|
|
@ -1038,7 +1056,7 @@ async def update_participant(
|
||||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
current = await conn.fetchrow(
|
current = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT rp.gender, rp.tee_name_snapshot,
|
SELECT rp.user_id::text AS user_id, rp.gender, rp.tee_name_snapshot,
|
||||||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
||||||
r.teeoff_course_id, r.personal_course_id
|
r.teeoff_course_id, r.personal_course_id
|
||||||
|
|
@ -1051,11 +1069,18 @@ async def update_participant(
|
||||||
if current 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
|
guest_only_fields = {"guest_name", "gender", "guest_email"}
|
||||||
|
if current["user_id"] is not None and guest_only_fields & updates.keys():
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED",
|
||||||
|
"Navn/kjønn/e-post kan ikke endres her for en spiller med egen konto -- kun for en gjest.",
|
||||||
|
)
|
||||||
|
|
||||||
|
rating_changed = "tee_name" in updates or "handicap_index" in updates or "gender" in updates
|
||||||
if rating_changed and current["completed_at"] is not None:
|
if rating_changed and current["completed_at"] is not None:
|
||||||
raise app_error(
|
raise app_error(
|
||||||
409, "ALREADY_COMPLETED",
|
409, "ALREADY_COMPLETED",
|
||||||
"Runden er allerede fullført -- kan ikke endre utslag eller HCP i etterkant.",
|
"Runden er allerede fullført -- kan ikke endre utslag, HCP eller kjønn i etterkant.",
|
||||||
)
|
)
|
||||||
|
|
||||||
set_clauses: list[str] = []
|
set_clauses: list[str] = []
|
||||||
|
|
@ -1064,6 +1089,12 @@ async def update_participant(
|
||||||
if "stat_level" in updates:
|
if "stat_level" in updates:
|
||||||
values.append(updates["stat_level"])
|
values.append(updates["stat_level"])
|
||||||
set_clauses.append(f"stat_level = ${len(values)}")
|
set_clauses.append(f"stat_level = ${len(values)}")
|
||||||
|
if "guest_name" in updates:
|
||||||
|
values.append(updates["guest_name"].strip())
|
||||||
|
set_clauses.append(f"guest_name = ${len(values)}")
|
||||||
|
if "guest_email" in updates:
|
||||||
|
values.append(updates["guest_email"])
|
||||||
|
set_clauses.append(f"guest_email = ${len(values)}")
|
||||||
|
|
||||||
if rating_changed:
|
if rating_changed:
|
||||||
if current["course_source"] == "teeoff":
|
if current["course_source"] == "teeoff":
|
||||||
|
|
@ -1074,14 +1105,15 @@ async def update_participant(
|
||||||
resolved = await _resolve_personal_course(current["personal_course_id"])
|
resolved = await _resolve_personal_course(current["personal_course_id"])
|
||||||
|
|
||||||
new_tee_name = updates.get("tee_name") or current["tee_name_snapshot"]
|
new_tee_name = updates.get("tee_name") or current["tee_name_snapshot"]
|
||||||
|
new_gender = updates.get("gender") or current["gender"]
|
||||||
new_handicap_index = (
|
new_handicap_index = (
|
||||||
updates["handicap_index"] if "handicap_index" in updates else current["handicap_index_snapshot"]
|
updates["handicap_index"] if "handicap_index" in updates else current["handicap_index_snapshot"]
|
||||||
)
|
)
|
||||||
rating = resolved.rating_for(new_tee_name, current["gender"])
|
rating = resolved.rating_for(new_tee_name, new_gender)
|
||||||
if rating is None:
|
if rating is None:
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED",
|
400, "VALIDATION_FAILED",
|
||||||
"Valgt utslag har ingen rating for spillerens registrerte kjønn.",
|
"Valgt utslag har ingen rating for spillerens (nye) kjønn.",
|
||||||
)
|
)
|
||||||
course_rating, slope_rating, tee_par = rating
|
course_rating, slope_rating, tee_par = rating
|
||||||
course_handicap_snapshot = (
|
course_handicap_snapshot = (
|
||||||
|
|
@ -1101,6 +1133,9 @@ async def update_participant(
|
||||||
set_clauses.append(f"tee_par_snapshot = ${len(values)}")
|
set_clauses.append(f"tee_par_snapshot = ${len(values)}")
|
||||||
values.append(course_handicap_snapshot)
|
values.append(course_handicap_snapshot)
|
||||||
set_clauses.append(f"course_handicap_snapshot = ${len(values)}")
|
set_clauses.append(f"course_handicap_snapshot = ${len(values)}")
|
||||||
|
if "gender" in updates:
|
||||||
|
values.append(new_gender)
|
||||||
|
set_clauses.append(f"gender = ${len(values)}")
|
||||||
|
|
||||||
values.append(participant_id)
|
values.append(participant_id)
|
||||||
updated_id = await conn.fetchval(
|
updated_id = await conn.fetchval(
|
||||||
|
|
@ -1109,7 +1144,7 @@ async def update_participant(
|
||||||
)
|
)
|
||||||
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, rp.guest_email,
|
||||||
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.tee_name_snapshot,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -813,6 +813,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}
|
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}
|
||||||
|
{/* Mottatte slag for AKTIV spiller på dette hullet (2026-07-26)
|
||||||
|
-- samme allerede hentede strokes_received som ScoreSoFar/
|
||||||
|
leaderboardet bruker til netto, bare aldri vist FØR
|
||||||
|
scoring her. Golfvis fortegn (ekte minustegn), kun vist
|
||||||
|
når spilleren faktisk mottar minst ett slag her. */}
|
||||||
|
{currentApiHole?.strokes_received != null && currentApiHole.strokes_received > 0 && (
|
||||||
|
<> · −{currentApiHole.strokes_received}</>
|
||||||
|
)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||||
{showGir && (
|
{showGir && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue