+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +
+diff --git a/034_session_scorecard_invitations.sql b/034_session_scorecard_invitations.sql
new file mode 100644
index 0000000..dee1240
--- /dev/null
+++ b/034_session_scorecard_invitations.sql
@@ -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;
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 55401e0..37bf81b 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -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).
---
diff --git a/CLAUDE.md b/CLAUDE.md
index 905e50c..506caf4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index 39ea85d..f987eae 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -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:
diff --git a/app/email.py b/app/email.py
index e53ac4e..abca99b 100644
--- a/app/email.py
+++ b/app/email.py
@@ -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)
diff --git a/app/routers/scoring.py b/app/routers/scoring.py
index 9d1ae66..10a304f 100644
--- a/app/routers/scoring.py
+++ b/app/routers/scoring.py
@@ -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)
]
diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py
index a993625..a222a14 100644
--- a/app/routers/tournaments.py
+++ b/app/routers/tournaments.py
@@ -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)
diff --git a/frontend/app/tournaments/[id]/sessions/[sessionId]/individual-leaderboard/page.tsx b/frontend/app/tournaments/[id]/sessions/[sessionId]/individual-leaderboard/page.tsx
new file mode 100644
index 0000000..26d79a4
--- /dev/null
+++ b/frontend/app/tournaments/[id]/sessions/[sessionId]/individual-leaderboard/page.tsx
@@ -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 (
+
+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
+
{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
+ {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`}.
+ {error} {error}
+ Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
+
+ Spillere uten {mode === "net" ? "netto-tall" : "poengsum"} (ingen handicap beregnet ennå) vises uten
+ rangering.
+ Stilling
+
+ {ranked.map((entry) => {
+ const value = valueFor(entry, mode)
+ const isLeader = entry.rank === 1
+ return (
+
+
+ {mode !== "gross" && ranked.some((e) => e.rank === null) && (
+