Update Todos

Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)

Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)

Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)

Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)

Scratch-verifiser backend for alle fire (27/27 sjekker gjort)

Browserverifiser alle fire i nettleser mot scratch-backend

Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse

Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:

PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
This commit is contained in:
Erol Haagenrud 2026-07-28 22:34:16 +02:00
parent d6c1a38318
commit 19a21073ba
27 changed files with 1838 additions and 75 deletions

View file

@ -0,0 +1,19 @@
-- =====================================================================
-- TeeCup — flere flighter i én frittstående runde: løs gruppering
-- (migrasjon 035, FEATURE_BACKLOG.md "Frittstående runder: flere
-- flighter i én 'vanlig' runde", retning 1 -- separate `round`-rader
-- bundet sammen av et tynt, klient-generert gruppe-id, avklart/presisert
-- 2026-07-25/26).
--
-- Bevisst INGEN egen tabell -- gruppen er kun EN DELT UUID-VERDI på tvers
-- av N `round`-rader, ikke en FK til noe. Leaderboardets omfang blir da
-- automatisk PRESIST det som ble satt opp sammen (samme grupperingsverdi),
-- aldri "alle som spilte samme bane samme dag" (se presiseringen i
-- FEATURE_BACKLOG.md).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round ADD COLUMN flight_group_id uuid;
CREATE INDEX round_flight_group_id_idx ON round (flight_group_id)
WHERE flight_group_id IS NOT NULL;

View file

@ -0,0 +1,17 @@
-- =====================================================================
-- TeeCup — scramble/greensome: hvilken spillers utslag ble valgt
-- (migrasjon 036, FEATURE_BACKLOG.md "Scramble: statistikk over utslag
-- brukt per spiller", NOTERT 2026-07-25 -- avgrenset til frittstående
-- runder i denne runden (ADR-039 sitt delt-ball-format, ikke org-scopede
-- turneringer, se .md-status for begrunnelse).
--
-- Kun meningsfullt på en DELT-BALL-hull-rad (round_hole.round_side_id
-- IS NOT NULL) -- håndhevet i app-laget (update_side_hole), ikke en
-- CHECK, siden en tverr-kolonne-betinget FK-constraint ikke er praktisk
-- i ren SQL her. Nullable -- helt valgfritt å registrere, samme som
-- resten av "full"-statistikknivået.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round_hole ADD COLUMN selected_participant_id uuid
REFERENCES round_participant(id) ON DELETE SET NULL;

View file

@ -0,0 +1,25 @@
-- =====================================================================
-- TeeCup — push-varsler til telefonens OS (Web Push/VAPID), migrasjon 037.
-- FEATURE_BACKLOG.md "Varsler: push til telefon" -- siste gjenstående
-- del av det opprinnelige varsel-forslaget (in-app-senteret + e-post-
-- fallback per type var allerede ferdig).
--
-- Samme `plain_connection()`-mønster som notification/round/friendship --
-- bruker-eid, ingen RLS. Én bruker kan ha FLERE abonnementer (flere
-- enheter/nettlesere) -- `endpoint` er unik per abonnement (selve
-- push-tjenestens URL, ikke brukerens id).
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE push_subscription (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
endpoint text NOT NULL UNIQUE,
p256dh text NOT NULL,
auth text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX push_subscription_user_id_idx ON push_subscription (user_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON push_subscription TO teecup_app;

View file

@ -3615,12 +3615,17 @@ Disse må avklares før eller under de relevante fasene:
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt
UI-løsning spesifiseres senere. **Utvidet 2026-07-25:** brukeren ba om
statistikk over hvor mange utslag hver spiller har hatt (dvs. hvor
mange ganger spillerens drive ble valgt) — se egen seksjon i
UI-løsning spesifiseres senere. **Utvidet 2026-07-25, DELVIS AVKLART
OG BYGGET 2026-07-28:** brukeren ba om statistikk over hvor mange
utslag hver spiller har hatt (dvs. hvor mange ganger spillerens drive
ble valgt). Bygget for FRITTSTÅENDE RUNDER (ADR-039 sitt delt-ball-
format, `round_hole.selected_participant_id`, migrasjon 036) — se
FEATURE_BACKLOG.md ("Scramble: statistikk over utslag brukt per
spiller") for full detalj og åpne spørsmål. Sannsynligvis samme
problemstilling for greensome.
spiller") for full detalj. **Fortsatt åpent:** samme funksjon for
org-scopede turnering-scramble (`hole_score`/`match_hole_result`) —
bevisst utenfor omfang i 2026-07-28-runden, samme underliggende
problem som punkt 5 under. Sannsynligvis samme problemstilling for
greensome i begge domener.
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
på at f.eks. et foursome ikke får per-spiller-scorer.

119
CLAUDE.md
View file

@ -4843,6 +4843,100 @@ Ferdig og verifisert:
begge var fullført i tidligere økter denne uken) — rettet til å
stemme med den faktiske, allerede leverte tilstanden.
- **Fire gjenstående forslag fra en tidligere "hva nå?"-runde, ALLE FIRE
BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28, samme dag:**
brukeren ba eksplisitt om å ferdigstille alle fire samtidig
(PWA-installasjon, flere flighter i frittstående runder, scramble/
greensome-statistikk, push-varsler til telefonens OS) — se
FEATURE_BACKLOG.md for full detalj per punkt, kort oppsummert her.
Tre nye migrasjoner (`035_round_flight_group.sql`,
`036_round_hole_selected_participant.sql`, `037_push_subscriptions.sql`),
alle rent additive.
1. **PWA-installasjonsoppfordring**`lib/pwa-install.ts` (globalt
fanget `beforeinstallprompt`, mountet via `sw-register.tsx` på alle
sider siden eventet kun fyres én gang per side-liv) + ny
`components/install-prompt.tsx`, vist på dashbordet rett under
hilsenen. Android/Chrome-familien får en ekte "Installer"-knapp,
iOS Safari et instruksjonsbanner (ingen programmatisk vei finnes
der), andre nettlesere uten reell installasjonsvei viser ingenting.
2. **Flere flighter i én frittstående runde** (retning 1, løs
gruppering av separate `round`-rader bundet sammen av en klient-
generert delt UUID, `round.flight_group_id`) — ny
`GET /rounds/{id}/flight-group` + `GET .../flight-group/leaderboard`
(slår sammen hver tilgjengelig søsken-flights EGET leaderboard til
én rangert liste, `score_to_par` allerede normalisert og dermed
sammenlignbart på tvers av baner). Ny `FlightGroupPanel` i
`round-detail.tsx` ("+ Legg til en flight til" gjenbruker HELE
opprett-runde-flyten, forhåndsutfylt via URL-parametre — banen
velges på nytt per flight, bevisst), ny side
`/my-rounds/[id]/flights`.
3. **Scramble/greensome: valgt utslag per spiller** — avgrenset til
frittstående runder (ADR-039 sitt delt-ball-format), IKKE
org-scopede turneringer i denne runden (bevisst scope-kutt, se
FEATURE_BACKLOG.md). Ny `round_hole.selected_participant_id`,
`PATCH .../sides/{id}/holes/{n}` fikk et nytt valgfritt felt (samme
"full overwrite hvert kall"-kontrakt som `played`/`score`).
`SideScoreWizard` fikk en ny valgfri "Hvem sitt utslag ble brukt?"-
seksjon (auto-hopp bevisst slått av her, ellers ville brukeren blitt
revet videre før valget kunne gjøres); `round-stats.tsx` fikk en ny
"Utslag brukt"-oppsummering per side.
4. **Push-varsler til telefonens OS** (Web Push/VAPID) — ny
`push_subscription`-tabell, nytt `app/push.py`
(`send_push_to_user`, `pywebpush`), kalt fra `create_notification()`
for ALLE fire varseltyper. Bevisst INGEN egen per-type opt-in
(ulikt e-post-fallbacken) — å abonnere ER samtykket. VAPID-nøkler
valgfrie (`PUSH_CONFIGURED`), samme grasiøs-degraderings-mønster som
SMTP. `public/sw.js` fikk `push`/`notificationclick`-håndtering, ny
`lib/push-subscribe.ts` + seksjon i `/account`
(`PushNotificationSection`).
**VAPID-nøkkelpar generert lokalt** (Python `cryptography`, EC P-256,
rå base64url-kodet privat/offentlig nøkkel — samme format `pywebpush`
og nettleserens `applicationServerKey` forventer), ALDRI vist i
klartekst i chatten (skrevet direkte til en midlertidig fil med 600-
rettigheter, lest inn i ekte `.env` ved utrulling, slettet fra
scratchpad etterpå) — samme regel som alle andre hemmeligheter i
prosjektet.
**Scratch-verifisert grundig, 27/27 sjekker i ett delt testløp**
(isolert `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
API-container med `pywebpush` installert, alle 37 migrasjoner kjørt
friskt): flight-group happy-path + ugyldig UUID avvist + kryss-bruker-
isolasjon (403/404, ingen lekkasje) + slått-sammen leaderboard rangerer
korrekt på tvers av to flighter; scramble-valg PATCH lykkes + feil side
avvist (400) + eksplisitt null nullstiller; VAPID-nøkkel eksponert +
abonnement lagret + anonymt abonnement avvist (401) + en EKTE
`webpush()`-utsendelse forsøkt mot en syntaktisk ugyldig test-nøkkel
(`WebPushException` fanget og logget, in-app-varselet ble uansett
opprettet normalt — beviser feil-isolasjonen fungerer i praksis, ikke
bare i teorien). `test_isolation.sql` 12/12 uendret.
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
isolert scratch-backend, ekte innlogging): PWA-knappen rendret og var
klikkbar (Chrome fyrte faktisk `beforeinstallprompt` i testøkten), ingen
konsollfeil; hele "+ Legg til en flight til"-flyten klikket gjennom FRA
KNAPPETRYKK TIL FERDIG RUNDE (riktig forhåndsutfylt skjema, korrekt
`flightGroupId` i URL-en, DIREKTE DATABASE-bekreftelse at begge rundene
delte samme gruppe-id etterpå), kombinert leaderboard-siden bekreftet
visuelt med korrekt rangering; scramble-valget satt i veiviseren,
bekreftet DIREKTE I DATABASEN, OG "Utslag brukt"-oppsummeringen
bekreftet visuelt på statistikksiden; push-UI-et rendret korrekt og
håndterte avslått tillatelse med en forklarende tekst uten konsollfeil
(denne automatiserte nettleserøkten hadde `Notification.permission`
forhåndssatt til "denied" av selve miljøet — en ekte innvilget
tillatelse → ekte levert OS-varsel er derfor IKKE bevist, ærlig
begrensning, bruker bør selv teste dette på en ekte enhet før full
tillit). Ekte typesjekket produksjonsbuild (`docker build --target
builder`) kompilerte rent, alle 27 ruter listet inkl. den nye
`/my-rounds/[id]/flights`.
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt (plan vist
FØR kjøring, per CLAUDE.md sin ufravikelige regel): migrasjon 035-037
kjørt mot ekte `teecup_db` (alle tre bekreftet med direkte spørring,
`test_isolation.sql` fortsatt 12/12), VAPID-nøkler lagt inn i ekte
`.env`, `docker compose up -d --build teecup_api teecup_frontend`.
Begge containere boot-et rent, `/health`/`/dashboard` → 200,
`GET /push/vapid-public-key` over ekte https ga korrekt offentlig
nøkkel, `GET /rounds/{ukjent-id}/flight-group` ga korrekt
`401 NOT_AUTHENTICATED` (ikke en rå 404 — bekrefter ruten faktisk når
FastAPI), `teeoff.no` upåvirket.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
@ -5054,12 +5148,14 @@ Neste steg:
(c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
faser); (d) flere flighter i én frittstående runde
fortsatt kun drøftet (se
FEATURE_BACKLOG.md); (e) ~~varsler koblet til venneforespørsler, ikke
til rundehendelser ennå~~ — ferdig, scratch-verifisert og live
2026-07-28, se status over (medspiller lagt til/venn ser synlig
runde/tilkoblet runde fullført).
faser); (d) ~~flere flighter i én frittstående runde fortsatt kun
drøftet~~ — ferdig, retning 1 (løs gruppering) bygget, scratch-/
browserverifisert og live 2026-07-28, se status over; (e) ~~varsler
koblet til venneforespørsler, ikke til rundehendelser ennå~~ —
ferdig, scratch-verifisert og live 2026-07-28, se status over
(medspiller lagt til/venn ser synlig runde/tilkoblet runde fullført).
Eneste reelt gjenstående punkt i denne listen nå: (b), Stableford for
frittstående runder.
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
foursome/greensome/scramble) for frittstående runder — backend
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,
@ -5074,3 +5170,14 @@ Neste steg:
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.
13. **Ferdig, kun for historikk:** PWA-installasjonsoppfordring, flere
flighter i én frittstående runde (retning 1), scramble/greensome-
statistikk over valgt utslag (frittstående runder), og push-varsler
til telefonens OS (Web Push/VAPID) — alle fire bygget, scratch-/
browserverifisert og live 2026-07-28, se status over for full detalj.
Gjenstående, IKKE bygget: samme scramble-utslagsstatistikk for
org-scopede turneringer (bevisst utenfor omfang, se
FEATURE_BACKLOG.md), ekte OS-nivå push-levering ikke bevist med en
reell innvilget tillatelse (automatisert testmiljø hadde
`Notification.permission` forhåndssatt til "denied") — bruker bør
selv teste push på en ekte enhet.

View file

@ -671,31 +671,77 @@ migrasjonsutkast til gjennomgang.
---
## Varsler: push til telefon + in-app varslingssenter — 📋 DESIGNET 2026-07-25, IKKE bygget
## Varsler: push til telefon + in-app varslingssenter — ✅ HELT FERDIG 2026-07-28 (begge deler)
**In-app varslingssenteret er nå komplett** (bygget 2026-07-25/26, utvidet
2026-07-28): bjelle-ikon i dashbord-header, `/my-notifications`, fire
triggerpunkter (venneforespørsel sendt/akseptert, medspiller lagt til på
en runde, en venn ser en synlig runde, en tilkoblet runde fullført) OG en
e-post-fallback PER TYPE (`user_notification_email_pref`, trygg standard
= ingen e-post). Se CLAUDE.md-status for full detalj. Kun push til
telefonens eget OS-varslingssystem (under) gjenstår av det opprinnelige
forslaget.
Brukeren spurte om dagens PWA-oppsett kan varsle telefonens eget
varslingssystem (f.eks. ved en ny venneforespørsel), og ba om at det uansett
finnes en in-app-fallback på dashbordet (varslingsindikator + en side med
uleste varsler) — med et V0-prompt klart "i tilfelle".
**Push-varsler til telefonens OS — teknisk mulig, men reell ny
infrastruktur, ikke en liten utvidelse:**
- Fundamentet finnes allerede (ADR-028: service worker, installerbar PWA).
Web Push (Push API + Notification API) fungerer UTEN en native app.
- **Viktig plattformbegrensning:** på iOS Safari fungerer Web Push KUN når
PWA-en er lagt til på hjemskjermen (iOS 16.4+) — en vanlig Safari-fane
kan ALDRI motta push, uansett tillatelse gitt. Android/desktop er langt
mer tilgivende.
- Krever: et VAPID-nøkkelpar, en ny `push_subscription`-tabell (én bruker
kan ha flere enheter/abonnementer), en backend-sende-funksjon (f.eks.
`pywebpush`), og et eksplisitt tillatelsesspørsmål fra nettleseren (kan
ikke sendes stille, brukeren kan avslå).
- **Vurdering:** reell verdi, men et eget, moderat-til-stort byggeløft med
en hard plattformbegrensning å designe rundt — IKKE anbefalt som første
steg.
**Push-varsler til telefonens OS — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT
OG LIVE 2026-07-28 (Web Push/VAPID):**
- Ny migrasjon `037_push_subscriptions.sql` -- `push_subscription`
(`user_id`, `endpoint` unik, `p256dh`/`auth`), samme `plain_connection()`-
mønster som `notification`/`round`/`friendship` (ingen RLS, bruker-eid).
Én bruker kan ha flere abonnement (flere enheter/nettlesere).
- Nytt `app/push.py` (`send_push_to_user`) -- `pywebpush`, kalt fra
`create_notification()` (den eneste skrivevegen inn, se
`app/routers/notifications.py`) for ALLE fire varseltyper. **Bevisst
ingen egen per-type opt-in** (ulikt e-post-fallbacken) -- selve det å
abonnere ER samtykket, en abonnert enhet får push for alt. VAPID-nøkler
er valgfrie (`settings.PUSH_CONFIGURED`, `app/config.py`) -- samme
grasiøs-degraderings-mønster som SMTP, push forsøkes rett og slett ikke
uten nøkler satt. En utløpt/tilbakekalt abonnement (404/410 fra
push-tjenesten) ryddes automatisk bort, andre feil isoleres med
`traceback.print_exc()` og lekker aldri til kalleren (samme mønster som
e-post-utsendingen).
Nye endepunkter: `GET /push/vapid-public-key` (offentlig), `POST/DELETE
/push/subscribe` (krever sesjon).
- Frontend: `public/sw.js` fikk `push`/`notificationclick`-håndtering
(viser native OS-varsel selv når ingen fane er åpen, klikk fokuserer
eller åpner appen på riktig `link_path`). Ny `lib/push-subscribe.ts`
(feature-deteksjon inkl. iOS-hjemskjerm-kravet, abonner/avmeld). Ny
seksjon "Push-varsler på denne enheten" i `/account`
(`PushNotificationSection`), enkel av/på-bryter, håndterer avslått
tillatelse med en forklarende tekst i stedet for en generisk feil.
- **Viktig plattformbegrensning, uendret:** på iOS Safari fungerer Web
Push KUN når PWA-en er lagt til på hjemskjermen -- dekket eksplisitt av
`isPushSupported()`.
- **Scratch-verifisert (27/27 sjekker, delt testløp med de tre andre
punktene under samme dag):** VAPID-nøkkel korrekt eksponert, abonnement
lagret, anonymt abonnement avvist (401), en EKTE `webpush()`-utsendelse
forsøkt (mot en syntaktisk ugyldig test-nøkkel -- `WebPushException`
fanget og logget, in-app-varselet ble uansett opprettet normalt, ingen
krasj). **Browserverifisert:** UI-et rendrer korrekt, klikk kaller
`Notification.requestPermission()` -- i denne automatiserte nettleser-
økten var tillatelsen forhåndssatt til "denied" av miljøet selv (ingen
ekte permission-dialog kan trigges av testverktøyet), og avslag ble vist
med riktig forklarende tekst uten konsollfeil. **Ærlig begrensning:**
en ekte innvilget tillatelse → ekte abonnement → ekte levert OS-varsel
er IKKE bevist i denne runden -- krever en ekte enhet/nettleserøkt
utenfor automatisert testing. Bruker bør selv teste dette før full
tillit.
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: migrasjon
037 kjørt mot ekte `teecup_db`, VAPID-nøkkelpar generert lokalt
(`cryptography`, EC P-256) og lagt inn i ekte `.env` uten å noensinne
vises i klartekst i chatten (samme regel som alle andre hemmeligheter i
prosjektet), `docker compose up -d --build teecup_api teecup_frontend`.
Verifisert: `/health`/`/dashboard` → 200, `GET /push/vapid-public-key`
over ekte https ga korrekt offentlig nøkkel, `teeoff.no` upåvirket.
**In-app varslingssenter — anbefalt første steg, fungerer overalt, ingen
tillatelse kreves:**
**In-app varslingssenter — ✅ BYGGET, i hovedsak akkurat som beskrevet
under (skjema/endepunkter/rute-navn stemmer med forslaget), pluss
per-type e-post-fallback som ikke var en del av det opprinnelige
forslaget:**
- Ny tabell `notification` (arbeidsnavn): `id`, `user_id` (mottaker),
`type`, `message` (ferdig norsk tekst, samme snapshot-prinsipp som
`author_display_name` i meldinger — unngår å måtte slå opp relaterte
@ -821,7 +867,7 @@ altså bygges som et nytt, eget felt, ikke gjenbrukes.
---
## PWA-installasjon: hvordan få brukere til å installere raskt — 📋 VURDERT 2026-07-25, IKKE designet i detalj
## PWA-installasjon: hvordan få brukere til å installere raskt — ✅ BYGGET OG LIVE 2026-07-28
Brukeren reiste dette som en oppfølging av varsel-diskusjonen: hvordan få
brukere til "nærmest umiddelbart" å installere TeeCup som app på
@ -876,12 +922,26 @@ kaldt overfall på innloggingssiden.
`event.prompt()`; iOS: instruksjonsbanner) — INGEN visning for andre
nettlesere uten en reell installasjonsvei.
**Status: kun vurdert/skissert i denne runden, ikke designet i detalj
eller bygget** — ingen V0-prompt skrevet ennå for dette (i motsetning
til varslingssenteret over). Naturlig neste steg om bruker vil gå videre:
en egen designrunde for selve UI-teksten/visuelt (særlig iOS-
instruksjonsbanneret, som må vise konkrete steg) — trolig verdt et eget
V0-prompt da, siden det er en egen, synlig UI-flate.
**Status: BYGGET akkurat som foreslått over, håndkodet (ikke V0), 2026-07-28:**
ny `lib/pwa-install.ts` (globalt fanget `beforeinstallprompt`-event via
`components/sw-register.tsx`, som allerede mountes på alle sider -- eventet
fyres kun én gang per side-liv og må derfor fanges tidligst mulig, ikke
først når selve dashbord-komponenten mountes). Ny `components/install-
prompt.tsx` (`InstallPrompt`) -- viser ALDRI noe hvis allerede installert
(`display-mode: standalone`) eller nylig avvist (`localStorage`-cooldown,
14 dager). To varianter: Android/Chrome-familien får en ekte
"Installer"-knapp (`event.prompt()`), iOS Safari får et instruksjonsbanner
(Del-ikon → "Legg til på Hjem-skjerm", INGEN programmatisk vei finnes der).
Andre nettlesere uten en reell installasjonsvei viser ingenting. Lagt inn i
`dashboard.tsx` rett under hilsenen -- akkurat sjekkpunktet foreslått over
(første/hver gang brukeren når dashbordet med komplett profil).
**Browserverifisert:** Chrome fyrte faktisk `beforeinstallprompt` i
testøkten, "Installer"-knappen rendret og var klikkbar, `event.prompt()`
ble kalt uten konsollfeil (selve den native installasjonsdialogen kan ikke
utløses i en automatisert nettleserøkt -- ærlig, forventet begrensning,
samme klasse som all annen "ekte OS-installasjon"-testing i prosjektet).
**Rullet ut live 2026-07-28** sammen med de tre andre punktene samme dag,
se CLAUDE.md-status for felles utrullingsdetalj.
---
@ -1448,7 +1508,7 @@ blant minst to fullførte runder). Full detalj i CLAUDE.md sin statuslogg
---
## Scramble: statistikk over utslag brukt per spiller — 📋 NOTERT 2026-07-25, IKKE bygget
## Scramble: statistikk over utslag brukt per spiller — ✅ BYGGET OG LIVE 2026-07-28 (frittstående runder)
Brukeren ba om at det i scramble-turneringer skal føres statistikk over
hvor mange utslag hver spiller har hatt (dvs. hvor mange ganger den
@ -1470,19 +1530,52 @@ nevnt av bruker, men samme spilleregel-mekanikk: begge partnere slår
egen ball fra tee, ett velges) — verdt å vurdere sammen når dette
designes, ikke som to separate ting.
**Åpne spørsmål, ikke besluttet:**
- Hvem registrerer dette — kapteinen/den som fører score for laget
(samme autorisasjonsmodell som resten av match-scoring, ADR-023), og
på hvilket tidspunkt (samtidig med selve hull-scoren, eller separat)?
- Hvor vises statistikken — per match, aggregert for hele turneringen,
eller begge deler?
- Teller uspilte/ikke-registrerte hull annerledes enn et hull der ingen
eksplisitt valgte utslag ble registrert (skal det være mulig å hoppe
over)?
**Åpne spørsmål, avklart pragmatisk ved bygging (2026-07-28):** registreres
av den som fører score for siden (samme autorisasjon som selve hull-
scoringen, ingen ny sjekk), samtidig med slagtallet men som en HELT
UAVHENGIG, valgfri handling (kan settes/endres uten å røre selve
slagtallet). Vist per side (match) OG aggregert for hele runden
(`round-stats.tsx`). Uregistrerte hull telles rett og slett ikke med --
"registrert på N av M spilte hull" vises alltid ved siden av.
**Ingen datamodell eller UI designet ennå** — rent notat, fanget opp slik
at det ikke går i glemmeboken til scramble/greensome-scoring tas fatt på
som egen runde.
**Status: ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 --
avgrenset til frittstående runder (ADR-039 sitt delt-ball-format), IKKE
org-scopede turneringer i denne runden** (samme underliggende problem,
men bevisst utsatt der -- se "bevisst utenfor omfang" under).
Ny migrasjon `036_round_hole_selected_participant.sql` --
`round_hole.selected_participant_id` (nullable, FK til
`round_participant`, `ON DELETE SET NULL`), kun meningsfull på en
delt-ball-hull-rad (`round_side_id IS NOT NULL`), håndhevet i app-laget.
`PATCH /rounds/{id}/sides/{side_id}/holes/{n}` (`SideHoleUpdate`) fikk et
nytt valgfritt felt, validert til å tilhøre NØYAKTIG denne siden (400
ellers). Samme "full overwrite hvert kall"-kontrakt som `played`/`score`
alltid har hatt -- frontend sender alltid gjeldende verdi for begge felt
sammen (`patchSideHole()` i `round-detail.tsx`), ellers ville en
score-oppdatering stille nullstilt et allerede registrert valg.
**Frontend:** `SideScoreWizard` fikk en ny "Hvem sitt utslag ble brukt?"-
seksjon (knapperad, ett trykk per spiller på siden, valgfritt) --
auto-hopp-mekanismen (2026-07-26-runden) ble bevisst SLÅTT AV for
delt-ball-formater med spillere å velge mellom (ville revet brukeren
videre før valget kunne gjøres), beholdt uendret for slagspill/skins/
individuell-ball-formater. Ny "Utslag brukt"-seksjon i `round-stats.tsx`
(`SelectedDriverSummary`) -- ren klientside-telling fra allerede hentet
`round_hole`-data, per side, med "registrert på N av M spilte hull".
**Scratch-verifisert** (del av samme 27-punkts testløp som de tre andre
punktene denne dagen): PATCH med valgt spiller lykkes, feil side avvist
(400), eksplisitt null nullstiller valget. **Browserverifisert:** valgte
"Gjest Partner" i veiviseren, bekreftet lagret DIREKTE i databasen
(`selected_participant_id` pekte riktig), OG bekreftet "Utslag brukt"-
oppsummeringen viste "Gjest Partner: 1 utslag" korrekt på statistikksiden,
ingen konsollfeil.
**Bevisst utenfor omfang:** org-scopede turnering-scramble/greensome
(`hole_score`/`match_hole_result`, delt mellom lag i en Ryder Cup-
tourney) har samme strukturelle hull, men ble IKKE bygget i denne
runden -- appens hovedfokus er frittstående runder (ADR-033/035/036), og
å bygge samme funksjon i to atskilte datamodeller i samme økt som tre
andre store leveranser ble vurdert som unødvendig risiko. Notert her,
ikke glemt, hvis/når turnering-scramble tas fatt på som egen sak.
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
utrullingsdetalj med de tre andre punktene samme dag.
---
@ -2264,7 +2357,14 @@ og vil derfor begge se profil-fullførings-skjemaet ved neste innlogging.
---
## Dashboard: tom-tilstand ved første innlogging — 📋 KONKRET FORSLAG LAGT FREM (2026-07-25), IKKE bygget
## Dashboard: tom-tilstand ved første innlogging — ✅ HELT FERDIG (bygget som ADR-035, live 2026-07-25)
**Bygget som beskrevet i "2026-07-25"-forslaget under** (de sju blokkene:
Hurtighandlinger/Kommende runder/Kommende turneringer/Statistikk/Spilte
baner/Venner/Organisasjoner) — se ADR-035 i ARCHITECTURE_DECISIONS.md og
CLAUDE.md-status 2026-07-25 for full bygge-/verifiseringsdetalj.
Organisasjon er ikke lenger første/eneste synlige vei inn; opprettes nå
usynlig i bakgrunnen når en bruker trykker "Ny turnering".
Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen
organisasjon ennå — opprett en") er organisator-vridd og ikke stemmer med
@ -2629,7 +2729,7 @@ umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CLAUDE.md-status
---
## Frittstående runder: flere flighter i én "vanlig" runde — 📋 NOTERT OG PRESISERT 2026-07-25/26, IKKE besluttet eller bygget
## Frittstående runder: flere flighter i én "vanlig" runde — ✅ BYGGET OG LIVE 2026-07-28 (retning 1, løs gruppering)
Reist av brukeren samme runde: eksempel gitt — "jeg går i den første
flighten sammen med tre andre, mens fire venner går i flighten bak."
@ -2707,6 +2807,53 @@ bevisst ADSKILT (to separate, mindre systemer), ikke forent til ett.
Denne seksjonens egen retning (løs gruppering av separate `round`-rader,
se over) står fortsatt som anbefaling, uendret av presiseringen.
**Status: ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 --
retning 1 bygget nøyaktig som anbefalt.** Ny migrasjon
`035_round_flight_group.sql` -- `round.flight_group_id uuid` (nullable,
INGEN egen tabell/FK-mål -- bevisst kun en delt UUID-VERDI på tvers av
N `round`-rader, det tynneste mulige "satt opp sammen"-konseptet, se
moduldoc for begrunnelsen: leaderboardets omfang blir da AUTOMATISK
presist det som deler samme verdi, aldri "alle som spilte samme bane
samme dag"). Klient-generert (ikke server-generert) -- eieren genererer
`crypto.randomUUID()` FØRST gang en andre flight legges til (via en ny
`PATCH /rounds/{id}` med `flight_group_id`), gjenbruker samme verdi for
en tredje+ flight.
Nye endepunkter: `GET /rounds/{id}/flight-group` (søsken-flighter
brukeren selv har tilgang til -- rader man IKKE har tilgang til
avsløres aldri, verken navn eller eksistens) og
`GET /rounds/{id}/flight-group/leaderboard` (slår sammen hver
tilgjengelig søsken-flights EGET leaderboard, `_build_leaderboard()`
uendret, til én rangert liste tagget med `flight_label` -- `score_to_par`
er allerede normalisert mot par og dermed sammenlignbart på tvers av
ulike baner/hullantall).
**Frontend:** ny `FlightGroupPanel` i `round-detail.tsx` (sibling-liste +
"+ Legg til en flight til"-knapp, som PATCHer gruppe-id på anker-runden
ved behov og navigerer til `/my-rounds/new` med dato/starthull/
hullantall/spilleform forhåndsutfylt via URL-parametre -- banen velges på
nytt for hver flight, bevisst, samme lave friksjon som vanlig bane-søk).
Ny side `/my-rounds/[id]/flights` (`flight-group-leaderboard.tsx`) --
egen, lettere lokal variant av `round-leaderboard.tsx` sitt rangerings-/
mode-toggle-mønster (brutto/netto/poeng), rader lenker videre til hver
flights EGET fulle leaderboard for hull-for-hull-detalj.
**Scratch-verifisert** (del av et 27-punkts testløp samme dag som de tre
andre punktene): anker uten gruppe viser kun seg selv, PATCH setter
gruppe-id, ugyldig UUID avvist (400), begge flighter vises etter
gruppering, fremmed bruker nektes BÅDE anker-runden og det slåtte
leaderboardet (403/404, ingen lekkasje), slått-sammen leaderboard
rangerer korrekt på tvers av to flighter (1 foran +2), `flight_label`
satt for alle rader.
**Browserverifisert, inkludert hele "+ Legg til en flight til"-flyten
FRA KNAPPETRYKK TIL FERDIG RUNDE** (ikke bare API-kall): klikket
knappen på en gruppeløs runde, bekreftet riktig `flightGroupId`/
`playedAt`/`startHole`/`holes`/`playFormat` i URL-en, bekreftet
forhåndsutfylt-men-redigerbart skjema (inkl. synlig forklarende banner),
fullførte oppretting, bekreftet DIREKTE I DATABASEN at begge rundene nå
delte samme `flight_group_id`. Kombinert leaderboard-siden bekreftet
visuelt (skjermbilde) med korrekt rangering/farger, ingen konsollfeil
noe sted i hele flyten.
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
utrullingsdetalj med de tre andre punktene samme dag.
---
## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-28 (se ADR-033/ADR-038 for full detalj per dag)

View file

@ -88,5 +88,17 @@ class Settings:
# facilities-API er offentlig uten auth, så ingen hemmelighet trengs her.
TEEOFF_API_BASE_URL: str = os.getenv("TEECUP_TEEOFF_API_BASE_URL", "http://teeoff_api:8000")
# Push-varsler til telefonens OS (Web Push/VAPID, FEATURE_BACKLOG.md
# "Varsler: push til telefon"). Valgfritt (ikke _required) -- samme
# grasiøs-degraderings-mønster som SMTP: push forsøkes rett og slett
# ikke uten nøkler satt, resten av varslingssystemet (in-app + e-post)
# fungerer helt uendret. Privatnøkkelen genereres og legges KUN i
# .env på serveren, aldri i klartekst i chatten (samme regel som alle
# andre hemmeligheter i prosjektet).
VAPID_PRIVATE_KEY: str | None = os.environ.get("TEECUP_VAPID_PRIVATE_KEY", "").strip() or None
VAPID_PUBLIC_KEY: str | None = os.environ.get("TEECUP_VAPID_PUBLIC_KEY", "").strip() or None
VAPID_SUBJECT: str = os.getenv("TEECUP_VAPID_SUBJECT", "https://teecup.teeoff.no")
PUSH_CONFIGURED: bool = bool(VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY)
settings = Settings()

63
app/push.py Normal file
View file

@ -0,0 +1,63 @@
"""
Push-varsler til telefonens OS (Web Push/VAPID), FEATURE_BACKLOG.md
"Varsler: push til telefon". Bevisst helt frittstående fra
app/realtime.py (WebSocket-sanntid for åpne faner) -- push når appen ikke
er åpen i det hele tatt, helt andre leveringsmekanisme og bibliotek.
Valgfritt (PUSH_CONFIGURED, app/config.py) -- ingen nøkler satt betyr
push rett og slett ikke forsøkes, resten av varslingssystemet (in-app +
e-post) fungerer helt uendret. Samme grasiøs-degraderings-/feil-
isolasjons-mønster som app/email.py: en driftsfeil i selve utsendingen
lekker ALDRI til kalleren (create_notification skal alltid lykkes med å
skrive selve in-app-varselet, uansett hva som skjer her).
"""
from __future__ import annotations
import asyncio
import json
import traceback
from pywebpush import WebPushException, webpush
from .config import settings
def _send_sync(subscription_info: dict, payload: dict) -> None:
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": settings.VAPID_SUBJECT},
)
async def send_push_to_user(conn, user_id: str, title: str, body: str, link_path: str) -> None:
"""Sender til ALLE enheter brukeren har abonnert (flere nettlesere/
telefoner er normalt). Et utløpt/tilbakekalt abonnement (404/410 fra
push-tjenesten) ryddes bort her -- ikke behandlet som en feil, bare
forventet livssyklus for et push-abonnement."""
if not settings.PUSH_CONFIGURED:
return
rows = await conn.fetch(
"SELECT id::text AS id, endpoint, p256dh, auth FROM push_subscription WHERE user_id = $1",
user_id,
)
if not rows:
return
payload = {"title": title, "body": body, "link_path": link_path}
for row in rows:
subscription_info = {
"endpoint": row["endpoint"],
"keys": {"p256dh": row["p256dh"], "auth": row["auth"]},
}
try:
await asyncio.to_thread(_send_sync, subscription_info, payload)
except WebPushException as exc:
status = exc.response.status_code if exc.response is not None else None
if status in (404, 410):
await conn.execute("DELETE FROM push_subscription WHERE id = $1", row["id"])
else:
traceback.print_exc()
except Exception:
traceback.print_exc()

View file

@ -11,3 +11,4 @@ httpx>=0.27
argon2-cffi>=23.1
pyotp>=2.9
qrcode>=7.4
pywebpush>=2.0

View file

@ -1,6 +1,10 @@
"""
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) +
e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033).
e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033) + push til
telefonens OS (2026-07-28, migrasjon 037, se app/push.py). Push har -- ulikt
e-post -- INGEN egen per-type opt-in: selve det å abonnere (gi
nettleser-tillatelse) ER samtykket, en abonnert enhet får push for ALLE
typer create_notification() skriver.
Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
mønster som personlig profil/HCP-historikk/venner. `create_notification()`
@ -30,18 +34,27 @@ from ..auth import CurrentUser, get_current_user
from ..config import settings
from ..db import plain_connection
from ..email import send_notification_email
from ..errors import app_error
from ..push import send_push_to_user
router = APIRouter(tags=["notifications"])
NotificationType = Literal["friend", "tournament", "round", "result"]
_NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("friend", "tournament", "round", "result")
_PUSH_TITLES: dict[str, str] = {
"friend": "Venner",
"round": "Runde",
"result": "Resultat",
"tournament": "Turnering",
}
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en
e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for
akkurat DENNE typen."""
akkurat DENNE typen, OG et push-varsel til enhver enhet mottakeren har
abonnert (uavhengig av e-post-valget -- se _push_for_notification)."""
await conn.execute(
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
user_id,
@ -50,6 +63,8 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
link_path,
)
await _push_for_notification(conn, user_id=user_id, type=type, message=message, link_path=link_path)
wants_email = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
user_id,
@ -74,6 +89,16 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
print(f"[DEV] Varsel-e-post til {recipient['email']} ({type}): {message}", flush=True)
async def _push_for_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
"""Kalt fra create_notification() -- egen liten funksjon KUN for at
push (uavhengig av e-post-preferansen over) alltid forsøkes for enhver
abonnert enhet. Feiler aldri synlig for kalleren, se app/push.py."""
try:
await send_push_to_user(conn, user_id, _PUSH_TITLES.get(type, "TeeCup"), message, link_path)
except Exception:
traceback.print_exc()
class NotificationEmailPrefsOut(BaseModel):
types: list[str]
@ -173,3 +198,73 @@ async def mark_all_read(user: CurrentUser = Depends(get_current_user)) -> dict[s
user.user_id,
)
return {"ok": True}
# ---------------------------------------------------------------------------
# Push-varsler til telefonens OS (Web Push/VAPID, migrasjon 037). Offentlig
# nøkkel er nettopp det -- offentlig, ingen auth nødvendig for GET-en (samme
# begrunnelse som ethvert annet "her er min offentlige nøkkel"-endepunkt).
# Abonner/avbryt krever derimot en ekte sesjon -- abonnementet er alltid
# knyttet til AKKURAT den innloggede brukeren.
# ---------------------------------------------------------------------------
class VapidPublicKeyOut(BaseModel):
public_key: str | None
configured: bool
@router.get("/push/vapid-public-key", response_model=VapidPublicKeyOut)
async def get_vapid_public_key() -> VapidPublicKeyOut:
return VapidPublicKeyOut(public_key=settings.VAPID_PUBLIC_KEY, configured=settings.PUSH_CONFIGURED)
class PushSubscriptionKeys(BaseModel):
p256dh: str
auth: str
class PushSubscriptionCreate(BaseModel):
endpoint: str
keys: PushSubscriptionKeys
@router.post("/push/subscribe")
async def subscribe_push(
body: PushSubscriptionCreate, user: CurrentUser = Depends(get_current_user)
) -> dict[str, bool]:
if not settings.PUSH_CONFIGURED:
raise app_error(400, "VALIDATION_FAILED", "Push er ikke konfigurert på serveren.")
async with plain_connection() as conn:
# Samme endpoint kan i prinsippet re-abonneres (f.eks. nøklene
# rotert av nettleseren) -- oppdater i stedet for å feile på
# unik-constrainten.
await conn.execute(
"""
INSERT INTO push_subscription (user_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, p256dh = $3, auth = $4
""",
user.user_id,
body.endpoint,
body.keys.p256dh,
body.keys.auth,
)
return {"ok": True}
class PushUnsubscribe(BaseModel):
endpoint: str
@router.delete("/push/subscribe")
async def unsubscribe_push(
body: PushUnsubscribe, user: CurrentUser = Depends(get_current_user)
) -> dict[str, bool]:
async with plain_connection() as conn:
await conn.execute(
"DELETE FROM push_subscription WHERE endpoint = $1 AND user_id = $2",
body.endpoint,
user.user_id,
)
return {"ok": True}

View file

@ -33,6 +33,7 @@ fremtidig turnering-kilde, ADR-038 Beslutning E).
from __future__ import annotations
import math
import uuid
from datetime import date, datetime, timedelta
from typing import Literal
@ -634,6 +635,14 @@ class RoundCreate(BaseModel):
# -- hvilke av EGNE private kategorier (se friends.py sin Category) som
# får se runden. Tomt/utelatt for public/private.
visible_categories: list[_FriendCategory] = []
# Flere flighter i én "vanlig" runde (migrasjon 035) -- klient-generert
# UUID som binder flere `round`-rader sammen. Utelatt/null for en vanlig
# solo-/enkeltflight-runde. Klienten genererer denne selv (kun ETT sted:
# round-detail.tsx sin "Legg til en flight til"), enten en FERSK verdi
# (første gang en andre flight legges til en tidligere gruppeløs runde,
# satt på DEN runden via PATCH FØRST) eller en allerede eksisterende
# gruppes id (tredje+ flight). Se GET .../flight-group.
flight_group_id: str | None = None
class RoundParticipantOut(BaseModel):
@ -714,6 +723,8 @@ class RoundOut(BaseModel):
# medspiller-endepunktet (GET /rounds/{id}), ikke tredjepartsvisningen.
visibility_mode: str
visible_categories: list[str]
# Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
flight_group_id: str | None
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
@ -721,7 +732,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
"""
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
started_at, completed_at, visibility_mode
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id
FROM round WHERE id = $1
""",
round_id,
@ -794,6 +805,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
my_score_to_par=my_score_to_par,
visibility_mode=round_row["visibility_mode"],
visible_categories=[r["category"] for r in visible_category_rows],
flight_group_id=round_row["flight_group_id"],
)
@ -890,6 +902,12 @@ async def create_round(
elif body.skins_scoring is not None or body.skins_tie_handling is not None:
raise app_error(400, "VALIDATION_FAILED", "skins_scoring/skins_tie_handling gir kun mening for play_format='skins'.")
if body.flight_group_id is not None:
try:
uuid.UUID(body.flight_group_id)
except ValueError:
raise app_error(400, "VALIDATION_FAILED", "flight_group_id er ikke en gyldig UUID.")
async with plain_connection() as conn:
owner_row = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
@ -911,8 +929,8 @@ async def create_round(
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name, play_format,
skins_scoring, skins_tie_handling, visibility_mode)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id::text AS id
""",
user.user_id,
@ -931,6 +949,7 @@ async def create_round(
body.skins_scoring,
body.skins_tie_handling,
body.visibility_mode,
body.flight_group_id,
)
round_id = round_row["id"]
@ -1513,6 +1532,12 @@ class RoundUpdate(BaseModel):
# dem.
visibility_mode: Literal["public", "private", "friends"] | None = None
visible_categories: list[_FriendCategory] | None = None
# Flere flighter (migrasjon 035) -- SETTER gruppe-id (None = ikke rør,
# samme konvensjon som resten av denne modellen). Kun brukt til å binde
# en tidligere gruppeløs runde til en fersk gruppe idet en andre flight
# legges til (round-detail.tsx). Ingen vei til å FJERNE en runde fra en
# gruppe igjen i denne runden -- ikke etterspurt.
flight_group_id: str | None = None
@router.patch("/rounds/{round_id}", response_model=RoundOut)
@ -1536,6 +1561,12 @@ async def update_round(
ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
uansett fullført-status.
"""
if body.flight_group_id is not None:
try:
uuid.UUID(body.flight_group_id)
except ValueError:
raise app_error(400, "VALIDATION_FAILED", "flight_group_id er ikke en gyldig UUID.")
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
@ -1612,6 +1643,8 @@ async def update_round(
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
if body.visibility_mode is not None:
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
if body.flight_group_id is not None:
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
if body.visible_categories is not None:
await conn.execute("DELETE FROM round_visible_category WHERE round_id = $1", round_id)
for category in sorted(set(body.visible_categories)):
@ -2257,6 +2290,11 @@ class RoundSideHoleOut(BaseModel):
# strokes_received. None hvis siden ikke har en beregnet playing
# handicap ennå (f.eks. kun én av to partnere tildelt).
strokes_received: int | None = None
# Hvilken av sidens partnere sitt utslag ble valgt (migrasjon 036) --
# helt valgfritt, null = ikke registrert. Kun meningsfullt her (delt-
# ball-hull) -- individuell ball spiller alltid egen ball, spørsmålet
# gir ikke mening for RoundHoleOut.
selected_participant_id: str | None = None
async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSideHoleOut]:
@ -2266,8 +2304,8 @@ async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSide
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole "
"WHERE round_side_id = $1 ORDER BY hole_number",
"SELECT hole_number, par, stroke_index, played, score, selected_participant_id::text AS selected_participant_id "
"FROM round_hole WHERE round_side_id = $1 ORDER BY hole_number",
side_id,
)
playing_handicap = await conn.fetchval(
@ -2302,6 +2340,11 @@ async def list_side_holes(
class SideHoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)
# Hvem sitt utslag ble valgt (migrasjon 036) -- helt valgfritt, samme
# "full overwrite hver gang"-kontrakt som played/score over (klienten
# sender alltid gjeldende verdi, null = ikke registrert -- IKKE en
# delvis PATCH). Validert til å tilhøre nøyaktig DENNE siden under.
selected_participant_id: str | None = None
@router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut)
@ -2319,13 +2362,23 @@ async def update_side_hole(
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
if body.selected_participant_id is not None:
belongs = await conn.fetchval(
"SELECT 1 FROM round_participant WHERE id = $1 AND round_side_id = $2",
body.selected_participant_id, side_id,
)
if not belongs:
raise app_error(
400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
)
row = await conn.fetchrow(
"""
UPDATE round_hole SET played = $3, score = $4
UPDATE round_hole SET played = $3, score = $4, selected_participant_id = $5
WHERE round_side_id = $1 AND hole_number = $2
RETURNING hole_number, par, stroke_index, played, score
RETURNING hole_number, par, stroke_index, played, score,
selected_participant_id::text AS selected_participant_id
""",
side_id, hole_number, body.played, body.score,
side_id, hole_number, body.played, body.score, body.selected_participant_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
@ -2814,6 +2867,146 @@ async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current
return await _build_leaderboard(conn, round_id)
# ---------------------------------------------------------------------------
# Flere flighter i én "vanlig" runde (migrasjon 035) -- løs gruppering av
# separate `round`-rader, se moduldoc/FEATURE_BACKLOG.md. Bevisst egen,
# smal autorisasjon: kun eier-eller-deltaker (samme regel som resten av
# denne filens autentiserte lese-endepunkter) på DEN ANKER-runden anropet
# gjelder -- søsken-runder man selv ikke har tilgang til inkluderes ALDRI
# i svaret (verken i listen eller det slåtte leaderboardet), samme
# "trygg standard: se ingenting" som resten av rundevisibilitet.
# ---------------------------------------------------------------------------
class FlightSummaryOut(BaseModel):
round_id: str
name: str | None
course_name_snapshot: str
played_at: date
started_at: str | None
completed_at: str | None
participant_count: int
is_anchor: bool
class FlightGroupOut(BaseModel):
flight_group_id: str | None
flights: list[FlightSummaryOut]
async def _accessible_flight_rows(conn, round_id: str, user_id: str):
"""Anker-runden (tilgang allerede sjekket av kalleren) + ALLE søsken i
samme flight_group_id som brukeren selv eier eller er lenket deltaker
-- rader brukeren ikke har tilgang til utelates helt, avsløres
aldri (verken navn eller at de finnes)."""
anchor = await conn.fetchrow(
"SELECT id::text AS id, flight_group_id::text AS flight_group_id FROM round WHERE id = $1",
round_id,
)
group_id = anchor["flight_group_id"]
if group_id is None:
sibling_ids = [round_id]
else:
sibling_rows = await conn.fetch(
"SELECT id::text AS id FROM round WHERE flight_group_id = $1 ORDER BY created_at", group_id
)
sibling_ids = [r["id"] for r in sibling_rows]
rows = []
for sibling_id in sibling_ids:
row = await conn.fetchrow(
"""
SELECT r.id::text AS id, r.name, r.course_name_snapshot, r.played_at,
r.started_at, r.completed_at, r.owner_user_id::text AS owner_user_id,
EXISTS(SELECT 1 FROM round_participant WHERE round_id = r.id AND user_id = $2) AS is_linked_participant
FROM round r WHERE r.id = $1
""",
sibling_id,
user_id,
)
if row is None:
continue
if row["owner_user_id"] != user_id and not row["is_linked_participant"]:
continue
rows.append(row)
return group_id, rows
@router.get("/rounds/{round_id}/flight-group", response_model=FlightGroupOut)
async def get_flight_group(round_id: str, user: CurrentUser = Depends(get_current_user)) -> FlightGroupOut:
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
group_id, rows = await _accessible_flight_rows(conn, round_id, user.user_id)
flights = []
for row in rows:
participant_count = await conn.fetchval(
"SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
)
flights.append(
FlightSummaryOut(
round_id=row["id"],
name=row["name"],
course_name_snapshot=row["course_name_snapshot"],
played_at=row["played_at"],
started_at=row["started_at"].isoformat() if row["started_at"] else None,
completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
participant_count=participant_count,
is_anchor=row["id"] == round_id,
)
)
return FlightGroupOut(flight_group_id=group_id, flights=flights)
class FlightGroupLeaderboardEntryOut(LeaderboardEntryOut):
round_id: str
flight_label: str
class FlightGroupLeaderboardOut(BaseModel):
flight_group_id: str | None
flights: list[FlightSummaryOut]
entries: list[FlightGroupLeaderboardEntryOut]
@router.get("/rounds/{round_id}/flight-group/leaderboard", response_model=FlightGroupLeaderboardOut)
async def get_flight_group_leaderboard(
round_id: str, user: CurrentUser = Depends(get_current_user)
) -> FlightGroupLeaderboardOut:
"""Slår sammen hver tilgjengelig søsken-flights EGET leaderboard
(_build_leaderboard, uendret formel) til ÉN rangert liste, tagget med
hvilken flight hver rad kom fra -- score_to_par er allerede normalisert
mot par og dermed sammenlignbart tvers av ulike baner/hullantall."""
async with plain_connection() as conn:
await _get_accessible_round_or_404(conn, round_id, user.user_id)
group_id, rows = await _accessible_flight_rows(conn, round_id, user.user_id)
flights: list[FlightSummaryOut] = []
entries: list[FlightGroupLeaderboardEntryOut] = []
for idx, row in enumerate(rows):
participant_count = await conn.fetchval(
"SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
)
label = row["name"] or f"Flight {idx + 1}"
flights.append(
FlightSummaryOut(
round_id=row["id"],
name=row["name"],
course_name_snapshot=row["course_name_snapshot"],
played_at=row["played_at"],
started_at=row["started_at"].isoformat() if row["started_at"] else None,
completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
participant_count=participant_count,
is_anchor=row["id"] == round_id,
)
)
sub_board = await _build_leaderboard(conn, row["id"])
for entry in sub_board.entries:
entries.append(
FlightGroupLeaderboardEntryOut(round_id=row["id"], flight_label=label, **entry.model_dump())
)
entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par))
return FlightGroupLeaderboardOut(flight_group_id=group_id, flights=flights, entries=entries)
class HoleUpdate(BaseModel):
played: bool = True
score: int | None = Field(default=None, ge=1, le=20)

View file

@ -33,6 +33,8 @@ services:
TEECUP_MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET}
TEECUP_VAPID_PRIVATE_KEY: ${TEECUP_VAPID_PRIVATE_KEY}
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
networks:
- teeoff_default
restart: unless-stopped

View file

@ -0,0 +1,6 @@
import { FlightGroupLeaderboard } from "@/components/flight-group-leaderboard"
export default async function FlightGroupLeaderboardPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <FlightGroupLeaderboard roundId={id} />
}

View file

@ -1,5 +1,19 @@
import { Suspense } from "react"
import { NewRound } from "@/components/new-round"
export default function NewRoundPage() {
return <NewRound />
return (
<Suspense
fallback={
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
}
>
<NewRound />
</Suspense>
)
}

View file

@ -12,6 +12,7 @@ import { useRouter } from "next/navigation"
import {
ArrowLeft,
Bell,
BellRing,
Camera,
KeyRound,
Lock,
@ -25,6 +26,7 @@ import {
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { getExistingSubscription, isPushSupported, subscribeToPush, unsubscribeFromPush } from "@/lib/push-subscribe"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
@ -280,6 +282,8 @@ export function AccountSettings() {
<NotificationEmailPrefsSection />
<PushNotificationSection />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
@ -1330,6 +1334,97 @@ const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: st
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
]
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Egen seksjon
// ved siden av e-post-varianten over -- selve abonnementet ER samtykket her
// (ulikt e-post, som trenger en egen per-type opt-in), så dette er kun en
// enkel av/på-bryter for DENNE enheten/nettleseren.
function PushNotificationSection() {
const [supported, setSupported] = useState<boolean | null>(null)
const [subscribed, setSubscribed] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
setSupported(isPushSupported())
setIsIOS(/iphone|ipad|ipod/i.test(window.navigator.userAgent))
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches)
void getExistingSubscription().then((sub) => setSubscribed(sub !== null))
}, [])
async function toggle() {
setBusy(true)
setMessage(null)
try {
if (subscribed) {
await unsubscribeFromPush()
setSubscribed(false)
setMessage("Push-varsler er slått av på denne enheten.")
} else {
const result = await subscribeToPush()
if (result.ok) {
setSubscribed(true)
setMessage("Push-varsler er slått på for denne enheten.")
} else if (result.reason === "denied") {
setMessage("Nettleseren din blokkerer varsler -- gi TeeCup tillatelse i nettleserinnstillingene.")
} else if (result.reason === "not-configured") {
setMessage("Push er ikke satt opp på serveren ennå.")
} else {
setMessage("Klarte ikke å slå på push-varsler. Prøv igjen.")
}
}
} finally {
setBusy(false)
}
}
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<BellRing aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Push-varsler denne enheten</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
varsler direkte fra telefonen/nettleseren din, selv når TeeCup ikke er åpen -- for eksempel
når noen sender deg en venneforespørsel eller en runde du følger blir ferdig.
</p>
{supported === false && isIOS && !isStandalone && (
<p className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground">
iPhone/iPad TeeCup først legges til hjemskjermen (Del-ikonet i Safari «Legg til
Hjem-skjerm») før push-varsler kan slås .
</p>
)}
{supported === false && !isIOS && (
<p className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground">
Nettleseren din støtter ikke push-varsler.
</p>
)}
{supported && (
<button
type="button"
onClick={() => void toggle()}
disabled={busy}
className={`inline-flex min-h-11 w-fit items-center gap-2 rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60 ${
subscribed
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50"
}`}
>
<BellRing aria-hidden="true" className="size-4" />
{busy ? "Vent…" : subscribed ? "Push-varsler er PÅ -- slå av" : "Slå på push-varsler"}
</button>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
</section>
)
}
function NotificationEmailPrefsSection() {
const [types, setTypes] = useState<string[]>([])
const [loaded, setLoaded] = useState(false)

View file

@ -39,6 +39,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Wordmark } from "@/components/wordmark"
import { InstallPrompt } from "@/components/install-prompt"
import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
@ -407,6 +408,10 @@ export function Dashboard() {
</p>
)}
<div className="mt-6">
<InstallPrompt />
</div>
<div className="mt-6 flex flex-col gap-8">
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />

View file

@ -0,0 +1,276 @@
"use client"
// Samlet leaderboard på tvers av flere flighter satt opp SAMMEN i én
// handling (migrasjon 035, retning 1 -- se FEATURE_BACKLOG.md/
// ARCHITECTURE_DECISIONS.md "Frittstående runder: flere flighter i én
// 'vanlig' runde"). Egen, lettere lokal variant av round-leaderboard.tsx
// sitt rangerings-/mode-toggle-mønster (samme "liten lokal duplisering
// fremfor cross-file-import"-konvensjon som resten av den filen) -- denne
// siden slår sammen FLERE flighters egne leaderboard til én rangert liste,
// tagget med hvilken flight hver rad kom fra. Ingen egen hull-for-hull-
// detalj her (den finnes allerede på hver enkelt flights EGET leaderboard,
// lenket til fra hver rad) -- denne siden er en oversikt, ikke en duplikat.
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
type ApiFlightSummary = {
round_id: string
name: string | null
course_name_snapshot: string
played_at: string
started_at: string | null
completed_at: string | null
participant_count: number
is_anchor: boolean
}
type ApiEntry = {
round_id: string
flight_label: string
participant_id: string
display_name: string
is_owner: boolean
holes_played: number
total_score: number | null
score_to_par: number | null
net_score_to_par: number | null
total_points: number | null
}
type ApiFlightGroupLeaderboard = {
flight_group_id: string | null
flights: ApiFlightSummary[]
entries: ApiEntry[]
}
type Mode = "gross" | "net" | "points"
function valueFor(entry: ApiEntry, mode: Mode): number | null {
if (mode === "gross") return entry.score_to_par
if (mode === "net") return entry.net_score_to_par
return entry.total_points
}
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
const direction = mode === "points" ? -1 : 1
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
const ranked: RankedEntry[] = []
let rank = 0
let prevValue: number | null = null
for (let i = 0; i < withValue.length; i++) {
const e = withValue[i]
const v = valueFor(e, mode) as number
if (v !== prevValue) {
rank = i + 1
prevValue = v
}
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
ranked.push({ ...e, rank, tied })
}
for (const e of withoutValue) ranked.push({ ...e, rank: null, tied: false })
return ranked
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
if (mode === "points") {
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
)}
>
{value}
</span>
)
}
if (value === 0) {
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
}
const under = value < 0
return (
<span
className={cn(
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
under ? "rounded-full" : "rounded-[6px]",
emphasize
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary bg-primary/10 text-primary"
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
)}
>
{formatToPar(value)}
</span>
)
}
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
return (
<span
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
aria-hidden="true"
>
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : ""}
</span>
)
}
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const options: { value: Mode; label: string }[] = [
{ value: "gross", label: "Brutto" },
{ value: "net", label: "Netto" },
{ value: "points", label: "Poeng" },
]
return (
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
{options.map((opt) => (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={mode === opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
)
}
export function FlightGroupLeaderboard({ roundId }: { roundId: string }) {
const [data, setData] = useState<ApiFlightGroupLeaderboard | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<Mode>("gross")
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/flight-group/leaderboard`, { credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error(String(res.status))
return res.json()
})
.then((json: ApiFlightGroupLeaderboard) => {
if (!cancelled) setData(json)
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente det samlede leaderboardet.")
})
return () => {
cancelled = true
}
}, [roundId])
if (error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href={`/my-rounds/${roundId}`} className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til runden
</Link>
</div>
)
}
if (!data) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
const ranked = rankEntries(data.entries, mode)
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
Samlet leaderboard
</Link>
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
<div className="flex flex-col gap-2 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">
{data.flights.length} flighter satt opp sammen
</span>
<ul className="flex flex-col gap-1">
{data.flights.map((f) => (
<li key={f.round_id} className="text-sm text-muted-foreground">
{f.name || f.course_name_snapshot} · {f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
{f.completed_at ? " · Ferdig" : ""}
</li>
))}
</ul>
</div>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-foreground">Stilling</h2>
<ModeToggle mode={mode} onChange={setMode} />
</div>
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry) => {
const value = valueFor(entry, mode)
const isLeader = entry.rank === 1
return (
<li key={`${entry.round_id}-${entry.participant_id}`}>
<Link
href={`/my-rounds/${entry.round_id}/leaderboard`}
className="flex w-full items-center gap-3 p-3 text-left transition-colors hover:bg-accent/30 sm:p-4"
>
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
<span className="min-w-0 flex-1">
<span className="block truncate text-base font-bold text-foreground">{entry.display_name}</span>
<span className="block truncate text-xs font-semibold text-muted-foreground">{entry.flight_label}</span>
</span>
{value !== null ? (
<ValueMark mode={mode} value={value} emphasize={isLeader} />
) : (
<span className="shrink-0 text-sm font-semibold text-muted-foreground"></span>
)}
<ArrowRight aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</Link>
</li>
)
})}
</ul>
{ranked.length === 0 && (
<p className="text-sm text-muted-foreground">Ingen registrerte spillere i noen av flightene ennå.</p>
)}
</main>
</div>
)
}

View file

@ -0,0 +1,150 @@
"use client"
import { useEffect, useState } from "react"
import { Download, Share, SquarePlus, X } from "lucide-react"
import {
clearDeferredInstallPrompt,
getDeferredInstallPrompt,
subscribeInstallPrompt,
type BeforeInstallPromptEvent,
} from "@/lib/pwa-install"
const DISMISS_KEY = "teecup-install-dismissed-until"
const DISMISS_COOLDOWN_DAYS = 14
function isStandalone() {
if (typeof window === "undefined") return false
const nav = window.navigator as Navigator & { standalone?: boolean }
return window.matchMedia("(display-mode: standalone)").matches || nav.standalone === true
}
function isIOS() {
if (typeof window === "undefined") return false
return /iphone|ipad|ipod/i.test(window.navigator.userAgent)
}
function isDismissedForNow() {
if (typeof window === "undefined") return true
const raw = window.localStorage.getItem(DISMISS_KEY)
if (!raw) return false
const until = Number(raw)
return Number.isFinite(until) && Date.now() < until
}
function dismissForNow() {
const until = Date.now() + DISMISS_COOLDOWN_DAYS * 24 * 60 * 60 * 1000
window.localStorage.setItem(DISMISS_KEY, String(until))
}
// PWA-installasjonsoppfordring (FEATURE_BACKLOG.md "PWA-installasjon").
// To distinkte varianter -- INGEN visning for andre nettlesere uten en
// reell installasjonsvei:
// - Android/Chrome-familien: ekte "Installer"-knapp via det fangede
// beforeinstallprompt-eventet (lib/pwa-install.ts).
// - iOS Safari: Apple har ALDRI implementert beforeinstallprompt -- kun et
// instruksjonsbanner (Del-ikon -> "Legg til på Hjemskjerm") er mulig.
export function InstallPrompt() {
const [dismissed, setDismissed] = useState(true)
const [platform, setPlatform] = useState<"android" | "ios" | "none">("none")
const [deferredEvent, setDeferredEvent] = useState<BeforeInstallPromptEvent | null>(null)
useEffect(() => {
if (isStandalone() || isDismissedForNow()) return
if (isIOS()) {
setPlatform("ios")
setDismissed(false)
return
}
// Android/Chrome-familien: vis kun når/hvis eventet faktisk fyres --
// andre nettlesere uten en reell installasjonsvei skal ikke vise noe.
const existing = getDeferredInstallPrompt()
if (existing) {
setDeferredEvent(existing)
setPlatform("android")
setDismissed(false)
}
const unsubscribe = subscribeInstallPrompt((event) => {
if (event) {
setDeferredEvent(event)
setPlatform("android")
setDismissed(false)
}
})
return unsubscribe
}, [])
if (dismissed || platform === "none") return null
async function handleInstall() {
if (!deferredEvent) return
await deferredEvent.prompt()
await deferredEvent.userChoice
clearDeferredInstallPrompt()
setDismissed(true)
}
function handleDismiss() {
dismissForNow()
setDismissed(true)
}
return (
<div
role="region"
aria-label="Installer TeeCup"
className="flex items-start gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm"
>
<div className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary">
{platform === "ios" ? (
<Share aria-hidden="true" className="size-5" />
) : (
<Download aria-hidden="true" className="size-5" />
)}
</div>
<div className="flex-1">
<p className="text-sm font-bold text-foreground">Legg TeeCup til hjemskjermen</p>
{platform === "ios" ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i Safari,
velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
«Legg til Hjem-skjerm».
</p>
) : (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Rask tilgang, fungerer som en egen app, og virker delvis uten nett.
</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
{platform === "android" && (
<button
type="button"
onClick={handleInstall}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-bold text-primary-foreground transition-colors hover:bg-primary/90"
>
<Download aria-hidden="true" className="size-4" />
Installer
</button>
)}
<button
type="button"
onClick={handleDismiss}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-3 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
Ikke
</button>
</div>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Lukk installasjonsoppfordringen"
className="flex size-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground"
>
<X aria-hidden="true" className="size-4" />
</button>
</div>
)
}

View file

@ -8,7 +8,7 @@
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import {
ArrowLeft,
@ -138,6 +138,16 @@ function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
export function NewRound() {
const router = useRouter()
const searchParams = useSearchParams()
// Flere flighter i én "vanlig" runde (migrasjon 035) -- kommer fra
// round-detail.tsx sin "Legg til en flight til"-handling. Rene primitiver
// i URL-en (ikke et bane-oppslag) -- brukeren velger selv bane på nytt
// for hver flight, men slipper å taste dato/starthull/spilleform igjen.
const flightGroupId = searchParams.get("flightGroupId")
const prefillPlayedAt = searchParams.get("playedAt") ?? undefined
const prefillStartHole = searchParams.get("startHole")
const prefillHoles = searchParams.get("holes")
const prefillPlayFormat = searchParams.get("playFormat") as PlayFormat | null
const [step, setStep] = useState<Step>("source")
const [ownGender, setOwnGender] = useState<Gender | null>(null)
@ -260,6 +270,7 @@ export function NewRound() {
skins_tie_handling: payload.skinsTieHandling,
visibility_mode: payload.visibilityMode,
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
flight_group_id: flightGroupId || undefined,
}
const body =
courseMeta.source === "teeoff"
@ -335,6 +346,11 @@ export function NewRound() {
ownGender={ownGender}
onSubmit={submitRound}
onCreated={(id) => router.replace(`/my-rounds/${id}`)}
isAdditionalFlight={Boolean(flightGroupId)}
defaultDate={prefillPlayedAt}
defaultStartHole={prefillStartHole ?? undefined}
defaultHoles={prefillHoles === "9" ? 9 : prefillHoles === "18" ? 18 : undefined}
defaultPlayFormat={prefillPlayFormat ?? undefined}
/>
)}
</>
@ -1148,6 +1164,11 @@ function ConfirmStep({
ownGender,
onSubmit,
onCreated,
isAdditionalFlight = false,
defaultDate,
defaultStartHole,
defaultHoles,
defaultPlayFormat,
}: {
course: Course
ownGender: Gender
@ -1167,16 +1188,23 @@ function ConfirmStep({
visibleCategories: string[]
}) => Promise<{ id: string }>
onCreated: (roundId: string) => void
// Flere flighter (migrasjon 035) -- satt av round-detail.tsx sin
// "Legg til en flight til" via URL-parametre, se NewRound() over.
isAdditionalFlight?: boolean
defaultDate?: string
defaultStartHole?: string
defaultHoles?: 9 | 18
defaultPlayFormat?: PlayFormat
}) {
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
const [name, setName] = useState("")
const [date, setDate] = useState(todayIso)
const [date, setDate] = useState(defaultDate ?? todayIso)
const [teeTime, setTeeTime] = useState("")
const [startHole, setStartHole] = useState("1")
const [holes, setHoles] = useState<9 | 18>(18)
const [startHole, setStartHole] = useState(defaultStartHole ?? "1")
const [holes, setHoles] = useState<9 | 18>(defaultHoles ?? 18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke")
const [playFormat, setPlayFormat] = useState<PlayFormat>(defaultPlayFormat ?? "stroke")
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
// ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
@ -1238,6 +1266,13 @@ function ConfirmStep({
<div className="flex flex-col gap-8">
<StepHeader title="Bekreft og fullfør" description="Sjekk detaljene før du starter runden." />
{isAdditionalFlight && (
<p className="rounded-2xl border border-dashed border-primary/40 bg-primary/5 px-4 py-3 text-sm font-semibold text-foreground">
Du setter opp enda en flight i samme oppsett -- dato, starthull, hullantall og spilleform er
forhåndsutfylt fra den første flighten, men kan endres.
</p>
)}
{/* Course */}
<div className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm shadow-black/5">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">

View file

@ -33,6 +33,7 @@ import {
Settings2,
Target,
Trophy,
Users,
WifiOff,
X,
} from "lucide-react"
@ -222,6 +223,8 @@ type ApiRound = {
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
visibility_mode: string
visible_categories: string[]
// Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
flight_group_id: string | null
}
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
@ -312,6 +315,9 @@ type ApiSideHole = {
stroke_index: number
played: boolean
score: number | null
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
// (migrasjon 036) -- helt valgfritt, null = ikke registrert.
selected_participant_id: string | null
}
function apiHoleToStat(h: ApiHole): HoleStat {
@ -542,7 +548,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
}))
}
async function queueSideHoleWrite(sideId: string, holeNumber: number, body: { played: boolean; score: number | null }) {
async function queueSideHoleWrite(
sideId: string,
holeNumber: number,
body: { played: boolean; score: number | null; selected_participant_id: string | null },
) {
const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
@ -799,9 +809,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
return null
})()
async function updateWizardSideScore(score: number | null) {
// Begge feltene (score OG selected_participant_id) sendes ALLTID sammen
// (samme "full overwrite hvert kall"-kontrakt som played/score alltid har
// hatt) -- ellers ville en score-oppdatering stille nullstilt en allerede
// registrert utslags-valg (migrasjon 036), siden backend ikke gjør en
// delvis PATCH her.
async function patchSideHole(score: number | null, selectedParticipantId: string | null) {
if (readOnly || !wizardSideId) return
const body = { played: true, score }
const body = { played: true, score, selected_participant_id: selectedParticipantId }
if (!navigator.onLine) {
await queueSideHoleWrite(wizardSideId, activeHole, body)
return
@ -827,6 +842,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
setFormatResultRefreshTick((n) => n + 1)
}
function updateWizardSideScore(score: number | null) {
void patchSideHole(score, wizardSideHole?.selected_participant_id ?? null)
}
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036) -- helt
// valgfritt, egen handling fra selve slagregistreringen slik at man kan
// sette/endre valget uten å røre et allerede registrert slagtall.
function updateWizardSideSelection(participantId: string | null) {
void patchSideHole(wizardSideHole?.score ?? null, participantId)
}
function advanceWizardSide() {
if (!round) return
const idx = round.sides.findIndex((s) => s.id === wizardSideId)
@ -1110,6 +1136,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
(ADR-039) -- avledet ved lesing, aldri lagret. */}
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} />
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
Eieren tar ansvar for å sette opp ALLE flightene i én
handling ("+ Legg til en flight til" -> gjenbruker hele
opprett-runde-flyten, forhåndsutfylt fra denne runden). */}
<FlightGroupPanel roundId={roundId} round={round} isOwnerViewer={isOwnerViewer} />
<div className="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
{isOwnerViewer && (
@ -1325,9 +1358,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<SideScoreWizard
sides={round.sides}
side={wizardSide}
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
hole={sharedHole}
score={wizardSideHole?.played ? wizardSideHole.score : null}
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
onSave={updateWizardSideScore}
onSelectParticipant={updateWizardSideSelection}
onClose={() => setWizardSideId(null)}
onAdvance={advanceWizardSide}
readOnly={readOnly}
@ -2231,6 +2267,143 @@ function substituteSideLabels(text: string, sides: ApiSide[]): string {
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
}
type ApiFlightSummary = {
round_id: string
name: string | null
course_name_snapshot: string
played_at: string
started_at: string | null
completed_at: string | null
participant_count: number
is_anchor: boolean
}
// Flere flighter i én "vanlig" runde (migrasjon 035, retning 1 -- løs
// gruppering av separate `round`-rader, se FEATURE_BACKLOG.md). "+ Legg
// til en flight til" gjenbruker HELE eksisterende opprett-runde-flyten
// (/my-rounds/new) i stedet for et eget skjema -- forhåndsutfylt med denne
// flightens dato/starthull/hullantall/spilleform via URL-parametre, men
// banen velges på nytt (samme lave friksjon som normal bane-søk ellers).
function FlightGroupPanel({
roundId,
round,
isOwnerViewer,
}: {
roundId: string
round: ApiRound
isOwnerViewer: boolean
}) {
const router = useRouter()
const [flights, setFlights] = useState<ApiFlightSummary[] | null>(null)
const [adding, setAdding] = useState(false)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null) => {
if (!cancelled) setFlights(data?.flights ?? null)
})
.catch(() => {
if (!cancelled) setFlights(null)
})
return () => {
cancelled = true
}
}, [roundId, round.flight_group_id])
async function addFlight() {
if (!isOwnerViewer) return
setAdding(true)
try {
let groupId = round.flight_group_id
if (!groupId) {
groupId = crypto.randomUUID()
await fetch(`/rounds/${roundId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ flight_group_id: groupId }),
})
}
const params = new URLSearchParams({
flightGroupId: groupId,
playedAt: round.played_at,
startHole: String(round.start_hole),
holes: String(round.holes_planned),
playFormat: round.play_format,
})
router.push(`/my-rounds/new?${params.toString()}`)
} finally {
setAdding(false)
}
}
const others = (flights ?? []).filter((f) => f.round_id !== roundId)
return (
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
<Users aria-hidden="true" className="size-4 text-primary" />
Flighter
</span>
{isOwnerViewer && (
<button
type="button"
onClick={addFlight}
disabled={adding}
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
>
<Plus aria-hidden="true" className="size-4" />
{adding ? "Setter opp…" : "Legg til en flight til"}
</button>
)}
</div>
{others.length === 0 ? (
<p className="text-sm text-muted-foreground">
Kun denne flighten er satt opp. Legg til en flight til hvis flere spiller sammen med dere samme dag --
de deler da leaderboard med denne flighten.
</p>
) : (
<>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{(flights ?? []).map((f) => (
<li key={f.round_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
{f.round_id === roundId ? (
<span className="text-sm font-semibold text-foreground">
{f.name || f.course_name_snapshot}{" "}
<span className="font-normal text-muted-foreground">(denne)</span>
</span>
) : (
<Link
href={`/my-rounds/${f.round_id}`}
className="text-sm font-semibold text-primary hover:underline"
>
{f.name || f.course_name_snapshot}
</Link>
)}
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
{f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
{f.completed_at ? " · Ferdig" : ""}
</span>
</li>
))}
</ul>
<Link
href={`/my-rounds/${roundId}/flights`}
className="inline-flex min-h-11 items-center gap-1.5 self-start text-sm font-bold text-primary hover:underline"
>
Se samlet leaderboard for flightene
<ArrowRight aria-hidden="true" className="size-4" />
</Link>
</>
)}
</div>
)
}
function FormatResultPanel({
roundId,
round,
@ -4068,9 +4241,12 @@ function SideScorecardGrid({
function SideScoreWizard({
sides,
side,
sideParticipants,
hole,
score,
selectedParticipantId,
onSave,
onSelectParticipant,
onClose,
onAdvance,
readOnly,
@ -4078,9 +4254,13 @@ function SideScoreWizard({
}: {
sides: ApiSide[]
side: ApiSide
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036).
sideParticipants: Player[]
hole: Hole
score: number | null
selectedParticipantId: string | null
onSave: (score: number | null) => void
onSelectParticipant: (participantId: string | null) => void
onClose: () => void
onAdvance: () => void
readOnly: boolean
@ -4097,8 +4277,11 @@ function SideScoreWizard({
}
// Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer
// slagtall for siden, hopp umiddelbart videre til neste side/hull. Kun
// ETT felt her, så ingen splitt-i-flere-steg-problematikk.
// slagtall for siden, hopp umiddelbart videre til neste side/hull. KUN
// når det ikke finnes noe utslags-valg å ta stilling til også (migrasjon
// 036) -- ellers ville auto-hoppet revet brukeren videre FØR de rakk å
// velge hvem sitt utslag som ble brukt (det valgfrie feltet under).
const autoAdvance = sideParticipants.length === 0
const enteredWithValueRef = useRef(score !== null)
const firedRef = useRef(false)
useEffect(() => {
@ -4107,13 +4290,13 @@ function SideScoreWizard({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [side.id, hole.holeNumber])
useEffect(() => {
if (firedRef.current || enteredWithValueRef.current) return
if (!autoAdvance || firedRef.current || enteredWithValueRef.current) return
if (score !== null) {
firedRef.current = true
onAdvance()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [score])
}, [score, autoAdvance])
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
@ -4170,6 +4353,37 @@ function SideScoreWizard({
onChange={(v) => onSave(v)}
readOnly={readOnly}
/>
{/* Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036,
FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per
spiller"). Helt valgfritt -- egen handling, rører aldri
slagtallet. */}
{sideParticipants.length > 0 && (
<fieldset className="flex flex-col gap-2">
<legend className="mb-1 text-sm font-bold text-foreground">
Hvem sitt utslag ble brukt? <span className="font-normal text-muted-foreground">(valgfritt)</span>
</legend>
<div className="flex flex-wrap gap-2">
{sideParticipants.map((p) => (
<button
key={p.id}
type="button"
disabled={readOnly}
onClick={() => onSelectParticipant(selectedParticipantId === p.id ? null : p.id)}
aria-pressed={selectedParticipantId === p.id}
className={cn(
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60",
selectedParticipantId === p.id
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{p.name}
</button>
))}
</div>
</fieldset>
)}
</div>
</main>

View file

@ -361,6 +361,13 @@ type ApiParticipant = {
guest_name: string | null
display_name: string
is_owner: boolean
// ADR-039 -- hvilken side deltakeren er på, null for slagspill/skins.
round_side_id: string | null
}
type ApiSide = {
id: string
label: string | null
}
type ApiRound = {
@ -373,6 +380,17 @@ type ApiRound = {
holes_planned: number
play_format: string
participants: ApiParticipant[]
sides: ApiSide[]
}
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
// (migrasjon 036, FEATURE_BACKLOG.md "Scramble: statistikk over utslag
// brukt per spiller"). Samme rad-form som round-detail.tsx sin ApiSideHole.
type ApiSideHole = {
hole_number: number
played: boolean
score: number | null
selected_participant_id: string | null
}
// Delt-ball-formater (foursome/greensome/scramble, ADR-039 Beslutning C)
@ -583,6 +601,69 @@ function participantLabel(p: ApiParticipant, viewerId: string | null): string {
// --- Screen ----------------------------------------------------------------
// Scramble/greensome: "utslag brukt" per spiller (migrasjon 036,
// FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per spiller").
// Ren klientside-telling fra allerede hentet round_hole-data (samme
// "regn ut ved lesing, ikke lagre et eget tall"-prinsipp som resten av
// denne siden) -- vises kun de hullene som faktisk har et registrert valg,
// ingen antakelse om at ALLE hull må ha ett (helt valgfritt å registrere).
function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHoles: Record<string, ApiSideHole[]> }) {
const rows = round.sides.map((side) => {
const holes = sideHoles[side.id] ?? []
const holesWithSelection = holes.filter((h) => h.selected_participant_id !== null)
const counts = new Map<string, number>()
for (const h of holesWithSelection) {
const pid = h.selected_participant_id as string
counts.set(pid, (counts.get(pid) ?? 0) + 1)
}
const sideParticipants = round.participants.filter((p) => p.round_side_id === side.id)
const playedCount = holes.filter((h) => h.played && h.score !== null).length
return {
side,
playedCount,
registeredCount: holesWithSelection.length,
entries: sideParticipants
.map((p) => ({ name: p.display_name, count: counts.get(p.id) ?? 0 }))
.sort((a, b) => b.count - a.count),
}
})
if (rows.every((r) => r.registeredCount === 0)) {
return (
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen har registrert hvem sitt utslag som ble brukt noe hull ennå -- valgfritt felt i
scoreregistreringen for {FORMAT_LABELS[round.play_format] ?? round.play_format}.
</div>
)
}
return (
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<h2 className="text-base font-bold text-foreground">Utslag brukt</h2>
{rows.map(({ side, playedCount, registeredCount, entries }, idx) => (
<div key={side.id} className="flex flex-col gap-2">
{rows.length > 1 && (
<span className="text-sm font-semibold text-muted-foreground">
{side.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`}
</span>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{entries.map((e) => (
<li key={e.name} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{e.name}</span>
<span className="text-base font-extrabold tabular-nums text-foreground">{e.count} utslag</span>
</li>
))}
</ul>
<span className="text-xs text-muted-foreground">
Registrert {registeredCount} av {playedCount} spilte hull.
</span>
</div>
))}
</div>
)
}
export function RoundStats({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [participantId, setParticipantId] = useState<string | null>(null)
@ -645,6 +726,29 @@ export function RoundStats({ roundId }: { roundId: string }) {
}
}, [roundId, viewerId, refreshKey])
// Scramble/greensome: hent hver sides hull for "Utslag brukt"-sammendraget
// under (migrasjon 036) -- samme endepunkt round-detail.tsx allerede bruker
// for selve registreringen.
const [sideHoles, setSideHoles] = useState<Record<string, ApiSideHole[]>>({})
useEffect(() => {
if (!round || !SHARED_BALL_FORMATS.includes(round.play_format)) return
let cancelled = false
async function loadSideHoles() {
const entries = await Promise.all(
round!.sides.map(async (s) => {
const res = await fetch(`/rounds/${roundId}/sides/${s.id}/holes`, { credentials: "include" })
const data: ApiSideHole[] = res.ok ? await res.json() : []
return [s.id, data] as const
}),
)
if (!cancelled) setSideHoles(Object.fromEntries(entries))
}
void loadSideHoles()
return () => {
cancelled = true
}
}, [round, roundId, refreshKey])
useEffect(() => {
// Delt-ball-formater har ingen individuell round_hole-rad å hente --
// se guarden under, som viser en forklarende melding i stedet.
@ -696,6 +800,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
</div>
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Detaljert statistikk (fairwaytreff, GIR, putt-fordeling m.m.) er ikke tilgjengelig for{" "}
{FORMAT_LABELS[round.play_format] ?? round.play_format} -- disse formatene registrerer kun ETT felles

View file

@ -1,12 +1,19 @@
"use client"
import { useEffect } from "react"
import { initInstallPromptCapture } from "@/lib/pwa-install"
// Registrerer service workeren (ADR-028). Stille no-op i nettlesere uten
// støtte -- appen skal fungere helt uendret uten SW, bare uten
// installasjon/offline-cache.
//
// Fanger i tillegg "beforeinstallprompt" så tidlig som mulig (samme
// mountpunkt på ALLE sider, se lib/pwa-install.ts) -- eventet fyres kun én
// gang per side-liv, og InstallPrompt-komponenten på dashbordet kan mountes
// for sent til å fange det selv.
export function SwRegister() {
useEffect(() => {
initInstallPromptCapture()
if (!("serviceWorker" in navigator)) return
navigator.serviceWorker.register("/sw.js").catch(() => {})
}, [])

View file

@ -0,0 +1,80 @@
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Ren
// klientkode -- selve abonnementet lagres server-side via /push/subscribe.
// VIKTIG plattformbegrensning (se FEATURE_BACKLOG.md): iOS Safari støtter
// Web Push KUN når PWA-en er lagt til på hjemskjermen (installert som
// "standalone") -- en vanlig Safari-fane kan aldri abonnere, uansett
// tillatelse. isPushSupported() dekker dette.
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4)
const base64Safe = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/")
const raw = atob(base64Safe)
const output = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i)
return output
}
export function isPushSupported(): boolean {
if (typeof window === "undefined") return false
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) return false
const nav = window.navigator as Navigator & { standalone?: boolean }
const isIOS = /iphone|ipad|ipod/i.test(window.navigator.userAgent)
const isStandalone = window.matchMedia("(display-mode: standalone)").matches || nav.standalone === true
// iOS krever installasjon til hjemskjermen -- alle andre støttede
// nettlesere (Chrome/Edge/Firefox-familien) trenger det ikke.
return !isIOS || isStandalone
}
export async function getExistingSubscription(): Promise<PushSubscription | null> {
if (!("serviceWorker" in navigator)) return null
const registration = await navigator.serviceWorker.ready
return registration.pushManager.getSubscription()
}
export async function subscribeToPush(): Promise<{ ok: boolean; reason?: string }> {
if (!isPushSupported()) {
return { ok: false, reason: "unsupported" }
}
const permission = await Notification.requestPermission()
if (permission !== "granted") {
return { ok: false, reason: "denied" }
}
const keyRes = await fetch("/push/vapid-public-key", { credentials: "include" })
if (!keyRes.ok) return { ok: false, reason: "server-error" }
const { public_key, configured }: { public_key: string | null; configured: boolean } = await keyRes.json()
if (!configured || !public_key) return { ok: false, reason: "not-configured" }
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(public_key),
})
const json = subscription.toJSON()
const subscribeRes = await fetch("/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
endpoint: json.endpoint,
keys: { p256dh: json.keys?.p256dh, auth: json.keys?.auth },
}),
})
if (!subscribeRes.ok) {
return { ok: false, reason: "server-error" }
}
return { ok: true }
}
export async function unsubscribeFromPush(): Promise<void> {
const subscription = await getExistingSubscription()
if (!subscription) return
const endpoint = subscription.endpoint
await subscription.unsubscribe()
await fetch("/push/subscribe", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ endpoint }),
}).catch(() => {})
}

View file

@ -0,0 +1,48 @@
// Deler ett globalt "beforeinstallprompt"-event mellom root layout (som
// fanger det så tidlig som mulig via SwRegister, ADR-028) og
// InstallPrompt-komponenten på dashbordet (som faktisk viser UI-et).
// Nettleseren fyrer eventet KUN én gang per side-liv og kun hvis ingen har
// kalt preventDefault() før -- må derfor fanges globalt, ikke lokalt i en
// komponent som kan mountes for sent.
export type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>
}
type Listener = (event: BeforeInstallPromptEvent | null) => void
let deferredEvent: BeforeInstallPromptEvent | null = null
const listeners = new Set<Listener>()
export function initInstallPromptCapture() {
if (typeof window === "undefined") return
const w = window as typeof window & { __teecupInstallCaptureInitialized?: boolean }
if (w.__teecupInstallCaptureInitialized) return
w.__teecupInstallCaptureInitialized = true
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault()
deferredEvent = event as BeforeInstallPromptEvent
listeners.forEach((l) => l(deferredEvent))
})
window.addEventListener("appinstalled", () => {
deferredEvent = null
listeners.forEach((l) => l(null))
})
}
export function getDeferredInstallPrompt() {
return deferredEvent
}
export function clearDeferredInstallPrompt() {
deferredEvent = null
}
export function subscribeInstallPrompt(listener: Listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}

View file

@ -51,6 +51,10 @@ const nextConfig = {
// /my-notifications, ALDRI /notifications.
{ source: "/notifications/:path*", destination: `${API_ORIGIN}/notifications/:path*` },
{ source: "/notifications", destination: `${API_ORIGIN}/notifications` },
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28) -- ingen
// frontend-side bruker dette prefikset, kun fetch() fra /account.
{ source: "/push/:path*", destination: `${API_ORIGIN}/push/:path*` },
{ source: "/push", destination: `${API_ORIGIN}/push` },
{ source: "/health", destination: `${API_ORIGIN}/health` },
]
},

View file

@ -74,3 +74,40 @@ self.addEventListener("fetch", (event) => {
)
}
})
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Payloaden er
// alltid JSON {title, body, link_path} (se app/push.py) -- viser en native
// systemvarsling selv når ingen fane er åpen. notificationclick fokuserer en
// allerede åpen fane på samme origin hvis én finnes, ellers åpner en ny.
self.addEventListener("push", (event) => {
let data = { title: "TeeCup", body: "Du har et nytt varsel.", link_path: "/my-notifications" }
try {
if (event.data) data = { ...data, ...event.data.json() }
} catch {
// Behold standardteksten hvis payloaden mot formodning ikke er JSON.
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: { link_path: data.link_path },
}),
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const linkPath = event.notification.data?.link_path || "/my-notifications"
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url.includes(self.location.origin) && "focus" in client) {
client.navigate(linkPath)
return client.focus()
}
}
return self.clients.openWindow(linkPath)
}),
)
})

File diff suppressed because one or more lines are too long