From d6c1a383186979b48a25675b507ff1ebc6d78036 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 28 Jul 2026 20:56:08 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- 034_session_scorecard_invitations.sql | 13 + ARCHITECTURE_DECISIONS.md | 19 +- CLAUDE.md | 122 ++++++ FEATURE_BACKLOG.md | 154 ++++--- app/email.py | 57 +++ app/routers/scoring.py | 12 +- app/routers/tournaments.py | 378 +++++++++++++++++- .../individual-leaderboard/page.tsx | 34 ++ frontend/components/session-blind-draw.tsx | 80 +++- .../session-individual-leaderboard.tsx | 306 ++++++++++++++ frontend/components/tournament-detail.tsx | 44 ++ 11 files changed, 1154 insertions(+), 65 deletions(-) create mode 100644 034_session_scorecard_invitations.sql create mode 100644 frontend/app/tournaments/[id]/sessions/[sessionId]/individual-leaderboard/page.tsx create mode 100644 frontend/components/session-individual-leaderboard.tsx 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. +

+
+ ) + } + + return ( + + ) +} diff --git a/frontend/components/session-blind-draw.tsx b/frontend/components/session-blind-draw.tsx index b0b72c7..bcd3725 100644 --- a/frontend/components/session-blind-draw.tsx +++ b/frontend/components/session-blind-draw.tsx @@ -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({ {formatPoints(session.points_per_match)} poeng + {(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke" && ( + +