Update Todos

Undersøk kode-kontekst for alle tre byggeoppgavene

Avklar åpne designspørsmål med bruker

Bygg: flytte spiller mellom lag (backend + frontend)

Bygg: individuell rangering i turnering (backend + frontend)

Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)

Typesjekket produksjonsbuild av frontend

Scratch-verifiser alt tre + full regresjon

Browser-verifiser alt tre (Chrome DevTools)

Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md

Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:

Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
This commit is contained in:
Erol Haagenrud 2026-07-28 20:56:08 +02:00
parent 58a1242a9b
commit d6c1a38318
11 changed files with 1154 additions and 65 deletions

View file

@ -0,0 +1,13 @@
-- =====================================================================
-- TeeCup — midlertidige spillere: etter-runde scorekort + innloggings-
-- invitasjon, økt-nivå (migrasjon 034, FEATURE_BACKLOG.md "Midlertidige
-- spillere + automatisk etter-runde-invitasjon", avklart 2026-07-28)
-- =====================================================================
-- Dobbel-utsending-sperre: en rad her ER allerede "denne deltakeren i
-- denne matchen har fått sin scorekort-e-post" -- samme "tilstedeværelse/
-- tidsstempel = skjedd"-mønster som resten av skjemaet (round.started_at
-- m.fl.), ikke en egen tabell siden match_participant allerede er nøyaktig
-- (økt, spiller)-koblingen vi trenger å spore mot.
\set ON_ERROR_STOP on
ALTER TABLE match_participant ADD COLUMN invitation_sent_at timestamptz;

View file

@ -3636,15 +3636,16 @@ Disse må avklares før eller under de relevante fasene:
beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
skrevet — ADR-037 er ren struktur-beslutning.
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
par-lag-konkurranse, reist 2026-07-28) — **AVKLART OG BACKEND BYGGET
SAMME DAG, se ADR-039.** `round.play_format` styrer nå faktisk både
HCP-beregning (via en portert versjon av turnering-motoren) og et
nytt `format-result`-lese-endepunkt for scorekort-presentasjon.
Fortsatt IKKE utrullet mot ekte systemer, og frontend (sideoppsett/
skins-konfig/matchstatus-visning) fortsatt ikke bygget — se
FEATURE_BACKLOG.md. "Flere flighter i én frittstående runde" (egen
seksjon i FEATURE_BACKLOG.md) forblir et adskilt, ikke-relatert
spørsmål (grupperer RUNDER, ikke deltakere innad i én runde).
par-lag-konkurranse, reist 2026-07-28) — **AVKLART, BYGGET (backend +
frontend) OG LIVE SAMME DAG, se ADR-039.** `round.play_format` styrer
nå faktisk både HCP-beregning (via en portert versjon av turnering-
motoren) og et `format-result`-lese-endepunkt for scorekort-
presentasjon, med tilhørende UI for sideoppsett/skins-konfig/
matchstatus-visning — se FEATURE_BACKLOG.md/CLAUDE.md for full detalj
og etterfølgende samme-dags oppfølgingsrunder. "Flere flighter i én
frittstående runde" (egen seksjon i FEATURE_BACKLOG.md) forblir et
adskilt, ikke-relatert spørsmål (grupperer RUNDER, ikke deltakere
innad i én runde).
---

122
CLAUDE.md
View file

@ -4726,6 +4726,123 @@ Ferdig og verifisert:
produksjonsbuild (samme `Dockerfile` som deployes) kompilerte rent,
alle 24 ruter listet uendret.
- **Tre organisator-oppfølgingspunkter fra FEATURE_BACKLOG.md, ALLE
BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE (2026-07-28), samme dag:**
brukeren ba om at alle tre gjenstående, godt avgrensede oppfølgings-
punkter fra en tidligere gjennomgang av `.md`-filene tas i én runde,
inkludert oppdatering av de relevante backlog-/beslutningsfilene.
1. **Flytte spiller mellom lag:** ny
`POST /orgs/{id}/teams/{team_id}/roster/{roster_id}/move`
(`{"target_team_id": ...}`, `app/routers/tournaments.py`) —
atomisk `UPDATE team_roster SET team_id = ...`, avviser 409
`ALREADY_IN_MATCH` hvis spilleren allerede er lagt til i en match
(roster-raden er referert av `match_participant.team_roster_id`
med `ON DELETE RESTRICT` -- en flytting ville da gjort matchens
`team_side` inkonsistent med spillerens faktiske lag), 400 ved
flytting til samme lag, 404 ved ukjent mållag/roster-id.
Kapteinmerket nullstilles eksplisitt ved flytting (følger ikke med
til det nye laget). Frontend: ny "Flytt til {annet lag}"-handling i
`TeamPanel` sin per-spiller-meny (`tournament-detail.tsx`) — v1s
to-lags-grense (ADR-011) gjør målet entydig, ingen dropdown
nødvendig. Ingen migrasjon.
2. **Individuell rangering PER ØKT i en org-turnering:** ny
`GET /orgs/{id}/sessions/{id}/individual-leaderboard` — bevisst
AVGRENSET til én økt (ikke summert på tvers av turneringen, samme
avklaring som rundeleaderboardets omfang tidligere), og KUN
meningsfull for `scoring_mode='stroke'`-økter i individuell-ball-
format (singles/fourball; delt-ball-formater og hole_result-modus
avvist med 400, ikke krasj, siden de ikke har noen individuell
brutto-score å rangere fra). Gjenbruker `mp.playing_handicap`
(allerede beregnet, allowance-justert) + `allocate_over_played_
holes` (samme mønster som eksisterende match-play-scoring i
`scoring.py`, som fikk sin `_played_hole_numbers` omdøpt til
`played_hole_numbers` -- "fjern understrek når et andre bruksted
dukker opp"-mønsteret, samme som `SIDE_IS_UNIT` tidligere) — ingen
ny regnelogikk. Respekterer samme reveal-gating (`locked_team_ids`/
`own_team_ids`, ADR-013/026) som den eksisterende matchlisten.
Frontend: ny side `/tournaments/[id]/sessions/[sessionId]/
individual-leaderboard` (`session-individual-leaderboard.tsx`,
egen enklere lokal variant av `round-leaderboard.tsx` sitt
rangerings-/mode-toggle-mønster -- brutto/netto/poeng, delt
plassering "T-N"), lenket fra blind draw-skjermen for
kvalifiserende økter. Ingen migrasjon.
3. **Midlertidige spillere + automatisk etter-runde-invitasjon
(økt-nivå):** de tre tidligere åpne spørsmålene avklart eksplisitt
(AskUserQuestion) -- nivå ØKT (ikke turnering), dobbel-utsending-
sperre JA, locale bevisst alltid `nb`. Ny migrasjon
`034_session_scorecard_invitations.sql`
(`match_participant.invitation_sent_at`, samme "tidsstempel =
skjedd"-mønster som `round.started_at` m.fl.). Ny
`POST /orgs/{id}/sessions/{id}/send-scorecard-invitations`
sender KUN til spillere uten konto (`player.user_id IS NULL`) OG
med registrert e-post OG uten en tidligere sendt invitasjon for
akkurat denne (økt, deltaker)-kombinasjonen; responsen skiller
`sent`/`skipped_has_account`/`skipped_no_email`/
`skipped_already_sent` for full gjennomsiktighet. Gjenbruker
SAMME magic-link-token-mekanisme som vanlig innlogging (ikke bare
en "logg inn senere"-henvisning) -- ny `send_session_result_email()`
i `app/email.py` (begge nb/en-maler klare, kun `nb` faktisk brukt).
E-postens innhold: matchresultat (`status_text`) + individuelt
slagtotal når tilgjengelig (individuell-ball-formater -- `SUM
gross_strokes` er naturlig `NULL` for delt-ball-formater uten noen
egen format-sjekk, siden `match_participant_id` aldri settes i
`hole_score` der). Frontend: ny "Send scorekort til alle med
e-post"-knapp i blind draw-skjermen (`session-blind-draw.tsx`),
synlig når økten har minst én match.
**Liten, men reell ryddejobb funnet FØR e-post-laget ble lagt på**
(delt med forrige rundes rundevarsel-mønster): dev-log-grenen for den
nye invitasjons-e-posten skrev først KUN en oppsummeringstekst, ikke
selve token-en -- umulig å teste innloggingslenken i et scratch-/dev-
miljø uten SMTP. Rettet til å skrive to linjer: én i SAMME format som
`request_magic_link` sin etablerte `[DEV] Magic link for ...`-linje
(så eksisterende dev-verktøy som harvester token derfra fungerer
uendret), pluss en egen lesbar oppsummeringslinje.
**Scratch-verifisert grundig, 54/54 nye sjekker** (isolert
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
container, alle 34 migrasjoner kjørt friskt, full org-turnering-
scaffold bygget fra bunnen via API -- org/bane/18 hull/tee/turnering/
to lag/seks spillere/roster/singles-stroke-økt/to matcher): flytting
happy-path + kaptein-nullstilling + alle tre feilveier (409/400/404)
bekreftet, individuell rangering bekreftet tom→fylt→korrekt brutto/
netto/poeng for et hånd-utregnet 4-spiller-scenario (kryssjekket at
netto-til-par alltid ≤ brutto-til-par for alle fire), begge ikke-
kvalifiserende økt-typer (hole_result, foursome) avvist rent,
invitasjons-utsending bekreftet presist (1 sendt/2 manglet e-post/1
hadde allerede konto via en EKTE innlogget "linket" bruker), andre
kall bekreftet idempotent (0 nye, riktig `skipped_already_sent`), OG
den utstedte lenken bekreftet FAKTISK brukbar (spilleren logget inn
med den, kontoen ble koblet til spiller-profilen, samme ADR-017-
mekanisme uendret). PLUSS regresjon av scoring.py-omdøpingen
(`test_holeless_course_crash.py`, 5/5) og to notifikasjons-/e-post-
testsuiter fra forrige runde (28/28, 19/19) — alle fortsatt grønne.
`test_isolation.sql` 12/12. Ekte typesjekket produksjonsbuild
kompilerte rent, ny rute `/tournaments/[id]/sessions/[sessionId]/
individual-leaderboard` listet.
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
isolert scratch-backend, ekte innlogging inkl. tvungen 2FA-oppsett for
en fersk organisasjonseier): flyttet en spiller mellom lag direkte i
UI-et og bekreftet begge lags roster-lister/-antall oppdatert
umiddelbart; åpnet individuell rangering og bekreftet alle tre
visningsmodiene (Brutto/Netto/Poeng) viste korrekte, ulike tall for de
samme to spillerne; trykket "Send scorekort til alle med e-post" og
bekreftet resultatteksten "1 invitasjon sendt, 1 mangler registrert
e-post" -- trykket samme knapp igjen og bekreftet "0 invitasjoner
sendt, 1 allerede sendt tidligere, …" (dobbel-sperren synlig direkte i
UI-et, ikke bare i et API-svar). Ingen konsollfeil i noen av de tre
rundene.
**Rullet ut mot ekte systemer 2026-07-28**, bruker bekreftet
eksplisitt: migrasjon 034 kjørt mot ekte `teecup_db`
(`invitation_sent_at`-kolonnen 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.
**Samtidig, ren dokumentasjonshygiene:** et par steder i
`FEATURE_BACKLOG.md`/`ARCHITECTURE_DECISIONS.md` hadde blitt hengende
etter `CLAUDE.md` (ADR-039-utrulling/frontend og PWA-offline-
browsertesting fremstod fortsatt som "ikke gjort" til tross for at
begge var fullført i tidligere økter denne uken) — rettet til å
stemme med den faktiske, allerede leverte tilstanden.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
@ -4952,3 +5069,8 @@ Neste steg:
fremtidig finpuss (ikke bedt om ennå): redigere et sidenavn i
etterkant (kun opprett/slett finnes i dag), en tydeligere
skins-poeng-forklaring i UI-et.
12. **Ferdig, kun for historikk:** de tre organisator-oppfølgingspunktene
(flytte spiller mellom lag, individuell rangering per økt, midlertidige
spillere + etter-runde-invitasjon) — alle bygget, scratch-/
browserverifisert og live 2026-07-28, se status over. Ingen nye åpne
spørsmål igjen fra denne runden.

View file

@ -774,12 +774,25 @@ telefonens OS (egen, større runde, se vurderingen over), e-post-fallback
utrullingsplanen (denne runden + rundeleaderboardet under ble bygget
sammen).
### Tillegg 2026-07-25: e-post som fallback-kanal, betinget av samtykke
### Tillegg 2026-07-25: e-post som fallback-kanal, betinget av samtykke — ✅ BYGGET OG LIVE 2026-07-28
Brukeren foreslo at TeeCup i tillegg sender en e-post til mottakeren av en
venneforespørsel ("du har fått en forespørsel, åpne appen for å se den")
— MEN kun hvis brukeren har akseptert e-post som kommunikasjonskanal fra
TeeCup.
**Bygget rett til den "fremtidig presisering"-varianten under, ikke det
enklere forslaget:** brukeren ba eksplisitt om at mottakeren skal kunne
velge HVILKE varseltyper som skal gi e-post, ikke én global av/på-bryter
— ny `user_notification_email_pref`-tabell (migrasjon 033, samme
"tilstedeværelse = valgt"-mønster som `friend_categorization`/
`round_visible_category`, trygg standard = ingen typer valgt). E-post-
utsendingen ligger ETT sted (inni `create_notification()` selv,
`app/routers/notifications.py`), så den dekker automatisk alle nåværende
OG fremtidige varseltyper (`friend`/`round`/`result`/`tournament`) uten
at hvert kallsted må huske det selv. Ny `send_notification_email()` i
`app/email.py`, ny seksjon "Varsler på e-post" i `/account`. Se
CLAUDE.md-status 2026-07-28 for full detalj (19/19 scratch-sjekker).
Opprinnelig forslag, for historikkens skyld: brukeren foreslo at TeeCup i
tillegg sender en e-post til mottakeren av en venneforespørsel ("du har
fått en forespørsel, åpne appen for å se den") — MEN kun hvis brukeren
har akseptert e-post som kommunikasjonskanal fra TeeCup.
**Sjekket eksisterende kode:** det finnes I DAG ingen generell
kommunikasjons-/varslings-samtykke-flagg på `app_user` — det eneste
@ -872,7 +885,7 @@ V0-prompt da, siden det er en egen, synlig UI-flate.
---
## Leaderboard for runder og turneringer — 🧠 DRØFTET 2026-07-25, IKKE besluttet
## Leaderboard for runder og turneringer — ✅ HELT FERDIG (runder 2026-07-26, turnering-individuell-rangering 2026-07-28)
Brukeren ba om et leaderboard for pågående og ferdige runder OG
turneringer, usikker på om det bør ligge der man allerede ser rundens
@ -1072,6 +1085,29 @@ fargevalg/layout).
Full verifiserings- og utrullingsdetalj i CLAUDE.md sin statuslogg
(2026-07-26) -- ikke gjentatt her for å unngå duplisering.
### Oppdatering 2026-07-28: turnering-halvparten av spørsmålet AVKLART OG BYGGET
Det tidligere åpne spørsmålet ("mener brukeren en individuell rangering
INNAD i en turnering, ved siden av det eksisterende lag-poeng-
leaderboardet?") ble avklart eksplisitt (AskUserQuestion) — svar: JA, en
egen rangering PER ØKT (ikke summert på tvers av turneringen), ved siden
av lag-poeng-leaderboardet, ikke i stedet for det. Bygget: ny
`GET /orgs/{id}/sessions/{id}/individual-leaderboard`
(`app/routers/tournaments.py`) — KUN meningsfull for
`scoring_mode='stroke'`-økter i individuell-ball-format (singles/
fourball; delt-ball-formater og hole_result-modus avvises tydelig med
400, ikke krasj, siden de ikke har noen individuell brutto-score å
rangere fra). Gjenbruker `mp.playing_handicap` (allerede beregnet,
allowance-justert per format) + `allocate_over_played_holes` (samme
mønster som eksisterende match-play-scoring) for netto/poeng — ingen ny
regnelogikk. Respekterer samme reveal-gating (blind draw, ADR-013) som
matchlisten. Frontend: ny side
`/tournaments/[id]/sessions/[sessionId]/individual-leaderboard`
(`session-individual-leaderboard.tsx`, gjenbruker rangerings-/
mode-toggle-mønsteret fra `round-leaderboard.tsx` i en enklere,
sesjonsscopet variant), lenket fra blind draw-skjermen for kvalifiserende
økter. Scratch- og browserverifisert 2026-07-28, se CLAUDE.md-status.
---
## Spillerliste-redesign (vertikal, utslag/HCP/rediger inline) — ✅ HELT FERDIG, håndkodet (V0 tom for credits), live 2026-07-26
@ -1990,7 +2026,7 @@ kjørt mot ekte `teecup_db`, begge containere redeployet, `/health`/
---
## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå
## Midlertidige spillere + automatisk etter-runde-invitasjon — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 (økt-nivå)
Reist samme runde som punktet over, uttalt som punkt 2 (ikke like prioritert
som "Mine runder"-dashbordet, men skal likevel dokumenteres grundig nå).
@ -2027,16 +2063,34 @@ koblingen skal skje).
magic-link, ingen ny auth-mekanisme nødvendig siden `link_player_by_
email()` allerede kobler kontoen automatisk ved første innlogging).
**Åpne spørsmål, trengs FØR bygging:**
- Skal utsendingsknappen ligge på ØKT-nivå (send til alle i denne ene
runden) eller TURNERING-nivå (send til alle på tvers av alle økter, når
hele turneringen er ferdig)? Økt-nivå virker riktigst — en spiller kan
ha spilt kun én av flere økter.
- Skal systemet spore "allerede sendt til denne spilleren for denne økten"
for å hindre dobbel utsending ved et nytt klikk (sannsynligvis ja — én
liten ny tabell/kolonne)?
- Skal e-posten sendes på spillerens/organisasjonens foretrukne språk
(samme `locale`-mønster som magic-link-e-posten, ADR-015 Beslutning C)?
**Åpne spørsmål — alle avklart 2026-07-28 (AskUserQuestion), deretter bygget:**
- **Nivå: ØKT** (bekreftet, den anbefalte retningen). Ny
`POST /orgs/{id}/sessions/{id}/send-scorecard-invitations`
(`app/routers/tournaments.py`).
- **Dobbel-utsending-sperre: JA.** Ny `match_participant.
invitation_sent_at timestamptz` (migrasjon `034`) — samme
"tidsstempel = skjedd"-mønster som `round.started_at` m.fl. Kun
spillere UTEN konto (`player.user_id IS NULL`) OG med registrert
e-post OG uten en tidligere sendt invitasjon for akkurat denne
(økt, deltaker)-kombinasjonen kvalifiserer — responsen skiller
eksplisitt mellom `sent`/`skipped_has_account`/`skipped_no_email`/
`skipped_already_sent` for full gjennomsiktighet i UI-et.
- **Locale: bevisst alltid `nb`** (ikke bygget som et valg) — en spiller
uten konto har ingen lagret språkpreferanse å lese fra, og dette er en
norsk klubb-app. Ny `send_session_result_email()` i `app/email.py`
har likevel BEGGE nb/en-maler klare (samme fil-konvensjon som ellers),
kun `nb` faktisk brukt i dag.
E-posten inneholder et EKTE, ferdig innloggingslenke (samme
magic-link-mekanisme som vanlig innlogging, ikke bare en «logg inn
senere»-henvisning) + matchresultat (status_text) + individuelt
slagtotal når tilgjengelig (individuell-ball-formater).
Scratch-verifisert grundig (54/54 sjekker, inkl. at den utstedte
lenken faktisk logger spilleren inn og kobler kontoen automatisk til
spiller-profilen, ADR-017-mekanismen uendret) OG bekreftet direkte i
en ekte innlogget nettleser (Chrome DevTools) — knappen viste korrekt
«1 invitasjon sendt, 1 mangler registrert e-post» første gang, «0
invitasjoner sendt, 1 allerede sendt tidligere, …» andre gang. Se
CLAUDE.md-status 2026-07-28 for full detalj.
---
@ -2550,23 +2604,28 @@ kringkasting bekreftet begge veier, ikke bare REST-svar.
---
## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget
## Turneringsoppsett: flytte spillere mellom lag — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28
Reist av brukeren samme runde som dashbord-integreringen. I dag finnes
`DELETE .../roster/{roster_id}` (fjerner en spiller fra ETT lag) og
`PATCH .../roster/{roster_id}` (kun `is_captain`, ADR-023) — men INGEN vei
til å FLYTTE en allerede rostret spiller til et ANNET lag i samme
turnering i én operasjon. I dag må organisatoren gjøre det som to separate
kall (fjern fra lag A, legg til på lag B), og det finnes ingen slik
UI-handling i `tournament-detail.tsx` sin `TeamPanel` i det hele tatt —
kun fjerning.
**Ikke designet i detalj ennå** — rent notert som et reelt hull. Naturlig
retning ved bygging: enten et eget `POST .../roster/{roster_id}/move`
(target-team-id) som gjør begge operasjonene atomisk (unngår en
mellomtilstand der spilleren midlertidig ikke er rostret noe sted), eller
en UI-snarvei som bare kjører de to eksisterende kallene i sekvens — det
første er tryggere (ingen delvis fullført tilstand ved feil midtveis).
Reist av brukeren 2026-07-25 samme runde som dashbord-integreringen. Løst
akkurat slik forrige runde antok tryggest: ny
`POST /orgs/{id}/teams/{team_id}/roster/{roster_id}/move`
(`{"target_team_id": ...}`, `app/routers/tournaments.py`) — atomisk
`UPDATE team_roster SET team_id = ...` i én operasjon, ingen
mellomtilstand. Avviser med 409 `ALREADY_IN_MATCH` hvis spilleren
allerede er lagt til i en match (roster-raden er referert av
`match_participant.team_roster_id` med `ON DELETE RESTRICT` — å flytte
den ville gjort matchens `team_side` inkonsistent med spillerens
faktiske lag), avviser 400 ved flytting til samme lag, 404 ved ukjent
mållag/roster-id. Kapteinmerket følger IKKE med til det nye laget
(nullstilles eksplisitt ved flytting). Frontend: ny "Flytt til {annet
lag}"-handling i `TeamPanel` sin per-spiller-meny
(`tournament-detail.tsx`) — v1s to-lags-grense (ADR-011) gjør målet
entydig, ingen dropdown nødvendig.
Scratch-verifisert (happy path, kaptein-nullstilling, 409/400/404 alle
bekreftet) OG bekreftet direkte i en ekte innlogget nettleser (Chrome
DevTools): flytting oppdaterte begge lags roster-lister og -antall
umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CLAUDE.md-status
2026-07-28.
---
@ -2916,19 +2975,22 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden.
---
## Frittstående runder: ekte spillformer (slagspill/match/skins/par-lag) — ✅ BACKEND BYGGET OG SCRATCH-VERIFISERT (ADR-039), IKKE ENNÅ rullet ut mot ekte systemer, frontend fortsatt ikke bygget
## Frittstående runder: ekte spillformer (slagspill/match/skins/par-lag) — ✅ HELT FERDIG, backend + frontend BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE (ADR-039)
**Oppdatering 2026-07-28 (ADR-039):** design + backend er ferdig samme
dag som punktet ble reist. Se CLAUDE.md-status 2026-07-28 for full
detalj (migrasjon `031`, nye endepunkter, 96/96 scratch-sjekker). Kort:
alle fire load-bærende spørsmål (sider/gruppering, omfang av par-/lag-
underformater, skins-regler, HCP-tellestatus for delt-ball) avklart med
bruker, hele match-play-motoren fra org-turneringer PORTERT uendret
(ingen ny regnelogikk for match/fourball/foursome/greensome/scramble),
kun skins fikk ekte ny motorkode. **Gjenstår:** frontend (ingen skjerm
for sideoppsett/skins-konfig/matchstatus-visning ennå) og selve
utrullingen mot ekte `teecup_db`/`teecup_api` — begge egne, separate
neste steg.
**Oppdatering 2026-07-28 (ADR-039):** design + backend bygget samme dag
som punktet ble reist, frontend (sideoppsett/skins-konfig/matchstatus-
visning/delt-ball-scorekort) og selve utrullingen mot ekte `teecup_db`/
`teecup_api` fulgte rett etter, samme dag — se CLAUDE.md-status
2026-07-28 for full detalj (migrasjon `031`, nye endepunkter, 96+26
backend-scratch-sjekker, full nettleser-verifisering av alle seks
formater). Kort: alle fire load-bærende spørsmål (sider/gruppering,
omfang av par-/lag-underformater, skins-regler, HCP-tellestatus for
delt-ball) avklart med bruker, hele match-play-motoren fra
org-turneringer PORTERT uendret (ingen ny regnelogikk for match/
fourball/foursome/greensome/scramble), kun skins fikk ekte ny
motorkode. Etterfølgende samme-dags oppfølgingsrunder (minimums-
spiller-håndhevelse, offline-kø utvidet til frittstående runder,
scorekort-bugfikser for spillformater) også alle live — se CLAUDE.md.
Opprinnelig reist av brukeren rett etter at ADR-038 (faktisk HCP) ble
rullet ut:
@ -3164,7 +3226,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort:
| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. |
| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). |
| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. |
| Faktisk browser-testet (DevTools Offline-modus) | **FORTSATT IKKE GJORT — OPPFØLGINGSPUNKT** | Kun verifisert med typesjekket build + container-boot/curl, aldri i en ekte nettleser. Ingen nettleserverktøy tilgjengelig i byggeøkten. **Brukeren bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter, registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk synkes** — først da er offline-flyten reelt bevist, ikke bare kodegjennomgått. |
| Faktisk browser-testet (DevTools Offline-modus) | **FERDIG 2026-07-28** | Både turnering-scorekortet (den opprinnelige ADR-028-flyten) og den senere utvidelsen til frittstående runder er nå bevist med ekte Chrome DevTools-nettverksemulering (offline → registrer slag → tilbake online → automatisk synk bekreftet server-side), ikke bare kodegjennomgang. Se CLAUDE.md-status 2026-07-28. |
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: `docker compose
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:

View file

@ -263,3 +263,60 @@ async def send_notification_email(to_email: str, message: str, link_path: str, l
link = f"{settings.PUBLIC_BASE_URL}{link_path}"
body = template["body"].format(message=message, link=link)
await to_thread(_send_sync, to_email, template["subject"], body)
_SESSION_INVITATION_TEMPLATES = {
"nb": {
"subject": "Din runde i {tournament} er registrert i TeeCup",
"body": (
"Hei {name},\n\n"
"{summary}\n\n"
"Åpne lenken under innen {minutes} minutter for å logge deg rett inn og se mer, "
"eller følge med videre:\n\n"
"{link}\n\n"
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
"{token}\n\n"
"Fikk du denne uventet? Arrangøren av turneringen har registrert deg som spiller "
"i TeeCup -- se bort fra e-posten hvis dette ikke stemmer.\n"
),
},
"en": {
"subject": "Your round in {tournament} has been recorded in TeeCup",
"body": (
"Hi {name},\n\n"
"{summary}\n\n"
"Open the link below within {minutes} minutes to log straight in and see more, "
"or keep following along:\n\n"
"{link}\n\n"
"Link not working? Paste this code in manually instead:\n"
"{token}\n\n"
"Got this unexpectedly? The tournament organizer registered you as a player in "
"TeeCup -- ignore this email if that's not right.\n"
),
},
}
async def send_session_result_email(
to_email: str, player_name: str, tournament_name: str, summary: str, raw_token: str, locale: str = "nb"
) -> None:
"""FEATURE_BACKLOG.md "Midlertidige spillere + automatisk etter-runde-
invitasjon" (avklart 2026-07-28, økt-nivå) -- en organisator sender
dette til spillere UTEN egen konto ennå (player.user_id IS NULL) etter
en økt. `summary` er en allerede ferdig, norsk tekst (samme snapshot-
prinsipp som varsel-meldinger) som beskriver matchresultatet -- bygget
av kalleren (app/routers/tournaments.py), ikke her. Samme ekte
klikkbare magic-link-mønster som send_magic_link_email (ikke bare en
"logg inn senere"-henvisning som organisasjonsinvitasjonen, siden
dette er en engangs, direkte innloggingslenke)."""
template = _SESSION_INVITATION_TEMPLATES.get(locale, _SESSION_INVITATION_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = template["subject"].format(tournament=tournament_name)
body = template["body"].format(
name=player_name,
summary=summary,
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
link=link,
token=raw_token,
)
await to_thread(_send_sync, to_email, subject, body)

View file

@ -63,7 +63,7 @@ router = APIRouter()
_INDIVIDUAL_FORMATS = {"singles", "fourball"}
def _played_hole_numbers(hole_config: str) -> list[int]:
def played_hole_numbers(hole_config: str) -> list[int]:
if hole_config == "front_9":
return list(range(1, 10))
if hole_config == "back_9":
@ -98,7 +98,7 @@ def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
played = _played_hole_numbers(match["hole_config"])
played = played_hole_numbers(match["hole_config"])
if match["scoring_mode"] == "hole_result":
rows = await conn.fetch(
@ -188,7 +188,7 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
match_id,
)
results = await _compute_hole_results(conn, match_id, match)
played = _played_hole_numbers(match["hole_config"])
played = played_hole_numbers(match["hole_config"])
state = compute_match_state(results, total_holes=len(played))
# ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
@ -382,7 +382,7 @@ async def submit_hole_score(
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
)
played = _played_hole_numbers(match["hole_config"])
played = played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
@ -507,7 +507,7 @@ async def submit_hole_result(
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
)
played = _played_hole_numbers(match["hole_config"])
played = played_hole_numbers(match["hole_config"])
if body.hole_number not in played:
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
@ -604,7 +604,7 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
)
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
played = _played_hole_numbers(match["hole_config"])
played = played_hole_numbers(match["hole_config"])
holes = [
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
]

View file

@ -9,19 +9,25 @@ rostret på et lag som ikke finnes ennå).
import json
import secrets
from datetime import date, datetime
import traceback
from datetime import date, datetime, timedelta, timezone
import asyncpg
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel, Field
from handicap_engine import allocate_over_played_holes
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..blind_draw import locked_team_ids
from ..blind_draw import locked_team_ids, own_team_ids
from ..config import settings
from ..db import org_connection
from ..email import send_session_result_email
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from .auth import _hash_secret
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
from .scoring import apply_concession, recompute_and_cache_match_state
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
from .. import storage
router = APIRouter()
@ -547,6 +553,85 @@ async def update_roster_entry(
return RosterEntry(**dict(row))
class RosterMove(BaseModel):
target_team_id: str
@router.post(
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}/move",
response_model=RosterEntry,
)
async def move_roster_entry(
team_id: str,
roster_id: str,
body: RosterMove,
organization_id: str = Depends(get_authorized_org),
) -> RosterEntry:
"""FEATURE_BACKLOG.md "Turneringsoppsett: flytte/slette spillere mellom
lag" (notert 2026-07-25) -- atomisk flytting i én operasjon i stedet for
to separate DELETE+POST-kall (unngår en mellomtilstand der spilleren
midlertidig ikke er rostret noe sted)."""
async with org_connection(organization_id) as conn, translate_db_errors():
current = await conn.fetchrow(
"SELECT tr.id, t.tournament_id::text AS tournament_id "
"FROM team_roster tr JOIN team t ON t.id = tr.team_id "
"WHERE tr.id = $1 AND tr.team_id = $2",
roster_id,
team_id,
)
if current is None:
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
if body.target_team_id == team_id:
raise app_error(400, "VALIDATION_FAILED", "Spilleren er allerede på dette laget.")
target_team = await conn.fetchrow(
"SELECT id FROM team WHERE id = $1 AND tournament_id = $2",
body.target_team_id,
current["tournament_id"],
)
if target_team is None:
raise app_error(404, "NOT_FOUND", "Mållaget finnes ikke i denne turneringen.")
# Roster-raden er referert av match_participant.team_roster_id (ON
# DELETE RESTRICT) -- en spiller som allerede er satt opp i minst én
# match kan ikke flyttes trygt (matchens team_side ville da ikke
# lenger stemme med spillerens faktiske lag). Krev at spilleren
# fjernes fra matchen(e) FØRST, i stedet for å tillate en
# inkonsistent mellomtilstand.
already_in_match = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM match_participant WHERE team_roster_id = $1)",
roster_id,
)
if already_in_match:
raise app_error(
409,
"ALREADY_IN_MATCH",
"Spilleren er allerede lagt til i en match og kan ikke flyttes -- "
"fjern spilleren fra matchen(e) først.",
)
# Kaptein-merket følger IKKE med til det nye laget -- "kun én
# kaptein per lag" (ADR-023) håndheves allerede ELLERS via
# update_roster_entry, men en flyttet spiller skal aldri bli
# kaptein på et nytt lag uten et eksplisitt valg der.
row = await conn.fetchrow(
"""
WITH moved AS (
UPDATE team_roster
SET team_id = $1, is_captain = false
WHERE id = $2
RETURNING id, player_id, handicap_index_snapshot, is_captain
)
SELECT moved.id::text, moved.player_id::text,
moved.handicap_index_snapshot::float AS handicap_index_snapshot,
moved.is_captain, p.display_name
FROM moved JOIN player p ON p.id = moved.player_id
""",
body.target_team_id,
roster_id,
)
return RosterEntry(**dict(row))
@router.delete(
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
status_code=204,
@ -905,6 +990,141 @@ async def delete_session(
await conn.execute("DELETE FROM session WHERE id = $1", session_id)
# ---------------------------------------------------------------------------
# Midlertidige spillere: etter-runde scorekort + innloggingsinvitasjon,
# økt-nivå (FEATURE_BACKLOG.md, avklart 2026-07-28, migrasjon 034).
#
# Eksplisitt organisator-handling (ikke en automatisk bakgrunnsjobb, se
# begrunnelse i backloggen) -- sender KUN til spillere UTEN egen konto ennå
# (player.user_id IS NULL) og med en registrert e-post, og KUN én gang per
# (økt, deltaker) via match_participant.invitation_sent_at. Locale er
# bevisst alltid 'nb' -- en spiller uten konto har ingen lagret
# språkpreferanse å lese.
# ---------------------------------------------------------------------------
class SendInvitationsOut(BaseModel):
sent: int
skipped_has_account: int
skipped_no_email: int
skipped_already_sent: int
@router.post(
"/orgs/{organization_id}/sessions/{session_id}/send-scorecard-invitations",
response_model=SendInvitationsOut,
)
async def send_scorecard_invitations(
session_id: str,
organization_id: str = Depends(get_authorized_org),
) -> SendInvitationsOut:
async with org_connection(organization_id) as conn:
session = await conn.fetchrow(
"SELECT tournament_id::text AS tournament_id, name FROM session WHERE id = $1",
session_id,
)
if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
tournament_name = await conn.fetchval(
"SELECT name FROM tournament WHERE id = $1", session["tournament_id"]
)
session_label = (session["name"] or "").strip() or "en økt"
rows = await conn.fetch(
"""
SELECT mp.id::text AS participant_id, mp.invitation_sent_at,
p.display_name, p.email, p.user_id::text AS user_id,
m.status_text
FROM match_participant mp
JOIN match m ON m.id = mp.match_id
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
WHERE m.session_id = $1
""",
session_id,
)
sent = 0
skipped_has_account = 0
skipped_no_email = 0
skipped_already_sent = 0
for r in rows:
if r["user_id"] is not None:
skipped_has_account += 1
continue
if not r["email"]:
skipped_no_email += 1
continue
if r["invitation_sent_at"] is not None:
skipped_already_sent += 1
continue
# Kun meningsfull for individuell-ball-formater (singles/
# fourball) -- for delt-ball-formater er match_participant_id
# ALDRI satt i hole_score (ADR/scoring.py), så SUM blir naturlig
# NULL der uten noen egen format-sjekk her.
gross = await conn.fetchval(
"SELECT SUM(gross_strokes) FROM hole_score WHERE match_participant_id = $1",
r["participant_id"],
)
if r["status_text"]:
summary = f"Du spilte i {session_label} i {tournament_name} -- resultatet ble {r['status_text']}."
else:
summary = f"Du var satt opp for {session_label} i {tournament_name}."
if gross is not None:
summary += f" Du hadde {gross} slag totalt."
email = r["email"].strip().lower()
now = datetime.now(timezone.utc)
raw_token = secrets.token_urlsafe(32)
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
# request_magic_link (routers/auth.py) -- begrenser vinduet en
# eldre, lekket lenke kan misbrukes i.
await conn.execute(
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
email,
)
await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
email,
_hash_secret(raw_token),
expires_at,
)
if settings.SMTP_CONFIGURED:
try:
await send_session_result_email(
email, r["display_name"], tournament_name, summary, raw_token, "nb"
)
except Exception:
# Se app/email.py sitt mønster -- driftsfeil i
# utsendingen skal aldri hindre resten av utsendingen
# (token-raden består uansett, lenken virker fortsatt
# hvis spilleren ber om en ny normalt).
traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
# Samme "[DEV] Magic link for ..."-linjeformat som
# request_magic_link (routers/auth.py) FØRST -- slik at
# eksisterende dev-/test-verktøy som harvester token derfra
# fungerer uendret her også -- pluss en egen, lesbar
# oppsummeringslinje.
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
print(f"[DEV] Scorekort-invitasjon til {email}: {summary}", flush=True)
await conn.execute(
"UPDATE match_participant SET invitation_sent_at = now() WHERE id = $1",
r["participant_id"],
)
sent += 1
return SendInvitationsOut(
sent=sent,
skipped_has_account=skipped_has_account,
skipped_no_email=skipped_no_email,
skipped_already_sent=skipped_already_sent,
)
# ---------------------------------------------------------------------------
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
# ---------------------------------------------------------------------------
@ -1221,3 +1441,155 @@ async def get_leaderboard(
) -> Leaderboard:
async with org_connection(organization_id) as conn:
return await fetch_leaderboard(conn, tournament_id)
# ---------------------------------------------------------------------------
# Individuell rangering PER ØKT (FEATURE_BACKLOG.md "Leaderboard for runder
# og turneringer", 2026-07-25/28 oppfølging) -- ved siden av, ikke i stedet
# for, lag-poeng-leaderboardet over. Bevisst AVGRENSET til én økt (ikke
# summert på tvers av turneringen, se avklaring i CLAUDE.md) -- og KUN
# meningsfullt for scoring_mode='stroke'-økter i individuell-ball-format
# (singles/fourball, se scoring.py sin _INDIVIDUAL_FORMATS) siden delt-ball-
# formater og hole_result-modus ikke har noen individuell brutto-score.
# ---------------------------------------------------------------------------
class IndividualLeaderboardEntry(BaseModel):
participant_id: str
display_name: str
team_id: str
team_name: str
team_color: str | None
thru: int
gross_score: int | None
gross_to_par: int | None
net_score: int | None
net_to_par: int | None
total_points: int | None
class IndividualLeaderboard(BaseModel):
session_id: str
format: str
entries: list[IndividualLeaderboardEntry]
async def fetch_individual_leaderboard(
conn, organization_id: str, session_id: str, viewer_user_id: str | None
) -> IndividualLeaderboard:
session = await conn.fetchrow(
"""
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
hole_config::text AS hole_config, course_id::text AS course_id
FROM session WHERE id = $1
""",
session_id,
)
if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
if session["scoring_mode"] != "stroke" or session["format"] not in ("singles", "fourball"):
raise app_error(
400,
"VALIDATION_FAILED",
"Individuell rangering er kun tilgjengelig for slagspill-økter i format singles eller fourball.",
)
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
session["course_id"],
)
if len(hole_rows) != 18:
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
all_18_si = [r["stroke_index"] for r in hole_rows]
played = played_hole_numbers(session["hole_config"])
# Samme reveal-respekterende synlighet som fetch_matches (ADR-013/026):
# egen side FØR reveal, hele feltet ETTER.
locked = await locked_team_ids(conn, session_id)
revealed = len(locked) >= 2
own = (
set()
if revealed
else await own_team_ids(conn, organization_id, session["tournament_id"], viewer_user_id)
)
participant_rows = await conn.fetch(
"""
SELECT mp.id::text AS participant_id, mp.playing_handicap,
tr.team_id::text AS team_id, t.name AS team_name, t.color AS team_color,
p.display_name
FROM match_participant mp
JOIN match m ON m.id = mp.match_id
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN team t ON t.id = tr.team_id
JOIN player p ON p.id = tr.player_id
WHERE m.session_id = $1 AND ($2 OR tr.team_id::text = ANY($3::text[]))
ORDER BY p.display_name
""",
session_id,
revealed,
list(own),
)
entries: list[IndividualLeaderboardEntry] = []
for r in participant_rows:
score_rows = await conn.fetch(
"SELECT hole_number, gross_strokes FROM hole_score "
"WHERE match_participant_id = $1 ORDER BY hole_number",
r["participant_id"],
)
gross_score = gross_to_par = net_score = net_to_par = total_points = None
if score_rows:
gross_score = sum(s["gross_strokes"] for s in score_rows)
par_total = sum(par_by_hole[s["hole_number"]] for s in score_rows)
gross_to_par = gross_score - par_total
if r["playing_handicap"] is not None:
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
strokes_by_hole = dict(zip(played, strokes_list))
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
net_score = gross_score - strokes_received_total
net_to_par = gross_to_par - strokes_received_total
# Samme stableford-formel som round-leaderboardets
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
# per slag, gulvet på 0.
total_points = sum(
max(
0,
par_by_hole[s["hole_number"]]
- (s["gross_strokes"] - strokes_by_hole[s["hole_number"]])
+ 2,
)
for s in score_rows
)
entries.append(
IndividualLeaderboardEntry(
participant_id=r["participant_id"],
display_name=r["display_name"],
team_id=r["team_id"],
team_name=r["team_name"],
team_color=r["team_color"],
thru=len(score_rows),
gross_score=gross_score,
gross_to_par=gross_to_par,
net_score=net_score,
net_to_par=net_to_par,
total_points=total_points,
)
)
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
@router.get(
"/orgs/{organization_id}/sessions/{session_id}/individual-leaderboard",
response_model=IndividualLeaderboard,
)
async def get_individual_leaderboard(
session_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> IndividualLeaderboard:
async with org_connection(organization_id) as conn:
return await fetch_individual_leaderboard(conn, organization_id, session_id, user.user_id)

View file

@ -0,0 +1,34 @@
import { SessionIndividualLeaderboard } from "@/components/session-individual-leaderboard"
export default async function SessionIndividualLeaderboardPage({
params,
searchParams,
}: {
params: Promise<{ id: string; sessionId: string }>
searchParams: Promise<{ org?: string; name?: string }>
}) {
const { id, sessionId } = await params
const { org, name } = await searchParams
const tournamentName = name?.trim() || "Turnering"
// Samme grunn som de andre turnering-undersidene: API-et krever
// organization_id på alle økt-/scoring-endepunkter (RLS, se ADR-001/003).
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<SessionIndividualLeaderboard
organizationId={org}
tournamentId={id}
sessionId={sessionId}
tournamentName={tournamentName}
/>
)
}

View file

@ -3,7 +3,7 @@
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Clock, EyeOff, Lock, PartyPopper, Plus, Users, X } from "lucide-react"
import { ArrowLeft, Clock, EyeOff, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@ -337,6 +337,18 @@ export function SessionBlindDraw({
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
</div>
{(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke" && (
<Link
href={`/tournaments/${tournamentId}/sessions/${sessionId}/individual-leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
className="mb-4 flex w-fit items-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
>
<Trophy aria-hidden="true" className="size-4 text-primary" />
Individuell rangering
</Link>
)}
{matches.length > 0 && <SendInvitationsButton organizationId={organizationId} sessionId={sessionId} />}
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
@ -802,6 +814,72 @@ function RevealSide({
)
}
// --- Midlertidige spillere: etter-runde-invitasjon (migrasjon 034) --------
// Eksplisitt organisator-knapp, økt-nivå (FEATURE_BACKLOG.md, avklart
// 2026-07-28) -- sender scorekort + innloggingslenke KUN til spillere uten
// egen konto ennå, KUN én gang per (økt, deltaker), håndtert av backend.
type SendInvitationsResult = {
sent: number
skipped_has_account: number
skipped_no_email: number
skipped_already_sent: number
}
function SendInvitationsButton({ organizationId, sessionId }: { organizationId: string; sessionId: string }) {
const [sending, setSending] = useState(false)
const [result, setResult] = useState<SendInvitationsResult | null>(null)
const [error, setError] = useState<string | null>(null)
async function handleSend() {
if (
!confirm(
"Send scorekort og innloggingslenke til alle spillere med registrert e-post i denne økten som ikke har konto ennå?",
)
)
return
setSending(true)
setError(null)
setResult(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/send-scorecard-invitations`, {
method: "POST",
credentials: "include",
})
if (!res.ok) throw new Error(`send: ${res.status}`)
setResult(await res.json())
} catch {
setError("Klarte ikke å sende invitasjoner. Prøv igjen.")
} finally {
setSending(false)
}
}
return (
<div className="mb-4 flex flex-col gap-2">
<Button
type="button"
variant="outline"
onClick={handleSend}
disabled={sending}
className="h-10 w-fit rounded-xl font-semibold"
>
<Mail aria-hidden="true" className="size-4" />
{sending ? "Sender …" : "Send scorekort til alle med e-post"}
</Button>
{result && (
<p className="text-sm text-muted-foreground">
{result.sent} {result.sent === 1 ? "invitasjon sendt" : "invitasjoner sendt"}
{result.skipped_already_sent > 0 && `, ${result.skipped_already_sent} allerede sendt tidligere`}
{result.skipped_has_account > 0 && `, ${result.skipped_has_account} har allerede konto`}
{result.skipped_no_email > 0 && `, ${result.skipped_no_email} mangler registrert e-post`}.
</p>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
)
}
// --- Small helpers ---------------------------------------------------------
function MetaBadge({ children }: { children: React.ReactNode }) {

View file

@ -0,0 +1,306 @@
"use client"
// Individuell rangering PER ØKT i en org-turnering (FEATURE_BACKLOG.md
// "Leaderboard for runder og turneringer", avklart 2026-07-28 -- omfang
// bevisst AVGRENSET til én økt, ikke summert på tvers av turneringen, se
// CLAUDE.md for begrunnelse). Ved siden av, ikke i stedet for, det
// eksisterende lag-poeng-leaderboardet (tournament-leaderboard.tsx) -- KUN
// meningsfullt for scoring_mode='stroke'-økter i format singles/fourball,
// backend avviser (400) ellers.
//
// Gjenbruker samme rangering/mode-toggle/verdi-merke-mønster som
// round-leaderboard.tsx (brutto/netto/poeng, "form + farge"-språket, delt
// plassering "T-N") -- egen, enklere lokal versjon her siden denne
// leaderboardet ikke har noen hull-for-hull-data å utvide til (backend
// returnerer kun aggregerte tall per deltaker, ikke per hull).
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
type ApiEntry = {
participant_id: string
display_name: string
team_id: string
team_name: string
team_color: string | null
thru: number
gross_score: number | null
gross_to_par: number | null
net_score: number | null
net_to_par: number | null
total_points: number | null
}
type ApiLeaderboard = {
session_id: string
format: string
entries: ApiEntry[]
}
const FORMAT_LABELS: Record<string, string> = {
singles: "Singles",
fourball: "Fourball",
}
type Mode = "gross" | "net" | "points"
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
function valueFor(entry: ApiEntry, mode: Mode): number | null {
if (mode === "gross") return entry.gross_to_par
if (mode === "net") return entry.net_to_par
return entry.total_points
}
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied })
}
for (const e of withoutValue) {
ranked.push({ ...e, rank: null, tied: false })
}
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
if (value === 0) {
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
}
const under = value < 0
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
under ? "rounded-full" : "rounded-[6px]",
emphasize
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary bg-primary/10 text-primary"
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{formatToPar(value)}
</span>
)
}
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
)}
>
{value}
</span>
)
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
aria-hidden="true"
>
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : ""}
</span>
)
}
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const options: { value: Mode; label: string }[] = [
{ value: "gross", label: "Brutto" },
{ value: "net", label: "Netto" },
{ value: "points", label: "Poeng" },
]
return (
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={mode === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
)
}
export function SessionIndividualLeaderboard({
organizationId,
tournamentId,
sessionId,
tournamentName,
}: {
organizationId: string
tournamentId: string
sessionId: string
tournamentName: string
}) {
const [data, setData] = useState<ApiLeaderboard | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>("gross")
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/individual-leaderboard`, {
credentials: "include",
})
if (res.status === 400) {
if (!cancelled)
setError("Denne økten støtter ikke individuell rangering -- kun slagspill i format singles eller fourball.")
return
}
if (res.status === 404) {
if (!cancelled) setError("Økten finnes ikke.")
return
}
if (!res.ok) {
if (!cancelled) setError("Klarte ikke å hente rangeringen. Prøv igjen om litt.")
return
}
const parsed: ApiLeaderboard = await res.json()
if (!cancelled) setData(parsed)
}
void load()
return () => {
cancelled = true
}
}, [organizationId, sessionId])
const backHref = `/tournaments/${tournamentId}/sessions/${sessionId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
if (error) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href={backHref} className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til økten
</Link>
</div>
)
}
if (!data) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
const ranked = rankEntries(data.entries, mode)
return (
<div className="min-h-[100dvh] bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={backHref}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Individuell rangering
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span>
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
</div>
{data.entries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
</p>
) : (
<>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry) => {
const value = valueFor(entry, mode)
const isLeader = entry.rank === 1
return (
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: entry.team_color ?? "var(--muted-foreground)" }}
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{entry.display_name}</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{entry.team_name} · {entry.thru > 0 ? `${entry.thru} hull spilt` : "Ikke startet"}
</span>
</span>
{isLeader && entry.rank === 1 && <Trophy aria-hidden="true" className="size-4 shrink-0 text-primary" />}
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
</li>
)
})}
</ul>
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
<p className="text-sm text-muted-foreground">
Spillere uten {mode === "net" ? "netto-tall" : "poengsum"} (ingen handicap beregnet ennå) vises uten
rangering.
</p>
)}
</>
)}
</main>
</div>
)
}

View file

@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
ArrowLeftRight,
Check,
Copy,
Flag,
@ -262,6 +263,37 @@ export function TournamentDetail({
}
}
async function movePlayer(teamId: string, rosterId: string, targetTeamId: string) {
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}/move`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ target_team_id: targetTeamId }),
},
)
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? `move roster: ${res.status}`)
}
const moved: ApiRosterEntry = await res.json()
setTeams(
(prev) =>
prev.map((t) => {
if (!t) return t
if (t.id === teamId) return { ...t, roster: t.roster.filter((r) => r.id !== rosterId) }
if (t.id === targetTeamId) return { ...t, roster: [...t.roster, moved] }
return t
}) as [Team | null, Team | null],
)
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å flytte spilleren. Prøv igjen.")
}
}
async function updateStatus(newStatus: TournamentStatus) {
setError(null)
const previous = status
@ -398,6 +430,7 @@ export function TournamentDetail({
findPlayerTeam={findPlayerTeam}
onToggleCaptain={toggleCaptain}
onRemovePlayer={removePlayer}
onMovePlayer={movePlayer}
onAddExisting={addExistingPlayer}
onAddNew={addNewPlayer}
onUpdatePlayer={updatePlayer}
@ -548,6 +581,7 @@ function TeamPanel({
findPlayerTeam,
onToggleCaptain,
onRemovePlayer,
onMovePlayer,
onAddExisting,
onAddNew,
onUpdatePlayer,
@ -560,6 +594,7 @@ function TeamPanel({
findPlayerTeam: (playerId: string) => Team | null
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
onRemovePlayer: (teamId: string, rosterId: string) => void
onMovePlayer: (teamId: string, rosterId: string, targetTeamId: string) => void
onAddExisting: (teamId: string, playerId: string) => void
onAddNew: (teamId: string, name: string, handicap?: number) => void
onUpdatePlayer: (
@ -719,6 +754,15 @@ function TeamPanel({
<Star aria-hidden="true" className="size-4" />
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
</DropdownMenuItem>
{otherTeam && (
<DropdownMenuItem
onClick={() => onMovePlayer(team.id, entry.id, otherTeam.id)}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
<ArrowLeftRight aria-hidden="true" className="size-4" />
Flytt til {otherTeam.name}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setConfirmingId(entry.id)}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"