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 3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell. isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt 4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt
UI-løsning spesifiseres senere. **Utvidet 2026-07-25:** brukeren ba om UI-løsning spesifiseres senere. **Utvidet 2026-07-25, DELVIS AVKLART
statistikk over hvor mange utslag hver spiller har hatt (dvs. hvor OG BYGGET 2026-07-28:** brukeren ba om statistikk over hvor mange
mange ganger spillerens drive ble valgt) — se egen seksjon i 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 FEATURE_BACKLOG.md ("Scramble: statistikk over utslag brukt per
spiller") for full detalj og åpne spørsmål. Sannsynligvis samme spiller") for full detalj. **Fortsatt åpent:** samme funksjon for
problemstilling for greensome. 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 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 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. 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 å begge var fullført i tidligere økter denne uken) — rettet til å
stemme med den faktiske, allerede leverte tilstanden. 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: Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet 0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen (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) (c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre 2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
faser); (d) flere flighter i én frittstående runde faser); (d) ~~flere flighter i én frittstående runde fortsatt kun
fortsatt kun drøftet (se drøftet~~ — ferdig, retning 1 (løs gruppering) bygget, scratch-/
FEATURE_BACKLOG.md); (e) ~~varsler koblet til venneforespørsler, ikke browserverifisert og live 2026-07-28, se status over; (e) ~~varsler
til rundehendelser ennå~~ — ferdig, scratch-verifisert og live koblet til venneforespørsler, ikke til rundehendelser ennå~~ —
2026-07-28, se status over (medspiller lagt til/venn ser synlig ferdig, scratch-verifisert og live 2026-07-28, se status over
runde/tilkoblet runde fullført). (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/ 11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
foursome/greensome/scramble) for frittstående runder — backend foursome/greensome/scramble) for frittstående runder — backend
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett, (ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,
@ -5074,3 +5170,14 @@ Neste steg:
spillere + etter-runde-invitasjon) — alle bygget, scratch-/ spillere + etter-runde-invitasjon) — alle bygget, scratch-/
browserverifisert og live 2026-07-28, se status over. Ingen nye åpne browserverifisert og live 2026-07-28, se status over. Ingen nye åpne
spørsmål igjen fra denne runden. 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 Brukeren spurte om dagens PWA-oppsett kan varsle telefonens eget
varslingssystem (f.eks. ved en ny venneforespørsel), og ba om at det uansett 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 finnes en in-app-fallback på dashbordet (varslingsindikator + en side med
uleste varsler) — med et V0-prompt klart "i tilfelle". uleste varsler) — med et V0-prompt klart "i tilfelle".
**Push-varsler til telefonens OS — teknisk mulig, men reell ny **Push-varsler til telefonens OS — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT
infrastruktur, ikke en liten utvidelse:** OG LIVE 2026-07-28 (Web Push/VAPID):**
- Fundamentet finnes allerede (ADR-028: service worker, installerbar PWA). - Ny migrasjon `037_push_subscriptions.sql` -- `push_subscription`
Web Push (Push API + Notification API) fungerer UTEN en native app. (`user_id`, `endpoint` unik, `p256dh`/`auth`), samme `plain_connection()`-
- **Viktig plattformbegrensning:** på iOS Safari fungerer Web Push KUN når mønster som `notification`/`round`/`friendship` (ingen RLS, bruker-eid).
PWA-en er lagt til på hjemskjermen (iOS 16.4+) — en vanlig Safari-fane Én bruker kan ha flere abonnement (flere enheter/nettlesere).
kan ALDRI motta push, uansett tillatelse gitt. Android/desktop er langt - Nytt `app/push.py` (`send_push_to_user`) -- `pywebpush`, kalt fra
mer tilgivende. `create_notification()` (den eneste skrivevegen inn, se
- Krever: et VAPID-nøkkelpar, en ny `push_subscription`-tabell (én bruker `app/routers/notifications.py`) for ALLE fire varseltyper. **Bevisst
kan ha flere enheter/abonnementer), en backend-sende-funksjon (f.eks. ingen egen per-type opt-in** (ulikt e-post-fallbacken) -- selve det å
`pywebpush`), og et eksplisitt tillatelsesspørsmål fra nettleseren (kan abonnere ER samtykket, en abonnert enhet får push for alt. VAPID-nøkler
ikke sendes stille, brukeren kan avslå). er valgfrie (`settings.PUSH_CONFIGURED`, `app/config.py`) -- samme
- **Vurdering:** reell verdi, men et eget, moderat-til-stort byggeløft med grasiøs-degraderings-mønster som SMTP, push forsøkes rett og slett ikke
en hard plattformbegrensning å designe rundt — IKKE anbefalt som første uten nøkler satt. En utløpt/tilbakekalt abonnement (404/410 fra
steg. 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 **In-app varslingssenter — ✅ BYGGET, i hovedsak akkurat som beskrevet
tillatelse kreves:** 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), - Ny tabell `notification` (arbeidsnavn): `id`, `user_id` (mottaker),
`type`, `message` (ferdig norsk tekst, samme snapshot-prinsipp som `type`, `message` (ferdig norsk tekst, samme snapshot-prinsipp som
`author_display_name` i meldinger — unngår å måtte slå opp relaterte `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å Brukeren reiste dette som en oppfølging av varsel-diskusjonen: hvordan få
brukere til "nærmest umiddelbart" å installere TeeCup som app på 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 `event.prompt()`; iOS: instruksjonsbanner) — INGEN visning for andre
nettlesere uten en reell installasjonsvei. nettlesere uten en reell installasjonsvei.
**Status: kun vurdert/skissert i denne runden, ikke designet i detalj **Status: BYGGET akkurat som foreslått over, håndkodet (ikke V0), 2026-07-28:**
eller bygget** — ingen V0-prompt skrevet ennå for dette (i motsetning ny `lib/pwa-install.ts` (globalt fanget `beforeinstallprompt`-event via
til varslingssenteret over). Naturlig neste steg om bruker vil gå videre: `components/sw-register.tsx`, som allerede mountes på alle sider -- eventet
en egen designrunde for selve UI-teksten/visuelt (særlig iOS- fyres kun én gang per side-liv og må derfor fanges tidligst mulig, ikke
instruksjonsbanneret, som må vise konkrete steg) — trolig verdt et eget først når selve dashbord-komponenten mountes). Ny `components/install-
V0-prompt da, siden det er en egen, synlig UI-flate. 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 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 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 egen ball fra tee, ett velges) — verdt å vurdere sammen når dette
designes, ikke som to separate ting. designes, ikke som to separate ting.
**Åpne spørsmål, ikke besluttet:** **Åpne spørsmål, avklart pragmatisk ved bygging (2026-07-28):** registreres
- Hvem registrerer dette — kapteinen/den som fører score for laget av den som fører score for siden (samme autorisasjon som selve hull-
(samme autorisasjonsmodell som resten av match-scoring, ADR-023), og scoringen, ingen ny sjekk), samtidig med slagtallet men som en HELT
på hvilket tidspunkt (samtidig med selve hull-scoren, eller separat)? UAVHENGIG, valgfri handling (kan settes/endres uten å røre selve
- Hvor vises statistikken — per match, aggregert for hele turneringen, slagtallet). Vist per side (match) OG aggregert for hele runden
eller begge deler? (`round-stats.tsx`). Uregistrerte hull telles rett og slett ikke med --
- Teller uspilte/ikke-registrerte hull annerledes enn et hull der ingen "registrert på N av M spilte hull" vises alltid ved siden av.
eksplisitt valgte utslag ble registrert (skal det være mulig å hoppe
over)?
**Ingen datamodell eller UI designet ennå** — rent notat, fanget opp slik **Status: ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 --
at det ikke går i glemmeboken til scramble/greensome-scoring tas fatt på avgrenset til frittstående runder (ADR-039 sitt delt-ball-format), IKKE
som egen runde. 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 Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen
organisasjon ennå — opprett en") er organisator-vridd og ikke stemmer med 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 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." 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, Denne seksjonens egen retning (løs gruppering av separate `round`-rader,
se over) står fortsatt som anbefaling, uendret av presiseringen. 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) ## 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. # 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") 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() 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 argon2-cffi>=23.1
pyotp>=2.9 pyotp>=2.9
qrcode>=7.4 qrcode>=7.4
pywebpush>=2.0

View file

@ -1,6 +1,10 @@
""" """
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) + 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 Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
mønster som personlig profil/HCP-historikk/venner. `create_notification()` 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 ..config import settings
from ..db import plain_connection from ..db import plain_connection
from ..email import send_notification_email from ..email import send_notification_email
from ..errors import app_error
from ..push import send_push_to_user
router = APIRouter(tags=["notifications"]) router = APIRouter(tags=["notifications"])
NotificationType = Literal["friend", "tournament", "round", "result"] NotificationType = Literal["friend", "tournament", "round", "result"]
_NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("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: 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 -- """Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en 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 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( await conn.execute(
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)", "INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
user_id, user_id,
@ -50,6 +63,8 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
link_path, link_path,
) )
await _push_for_notification(conn, user_id=user_id, type=type, message=message, link_path=link_path)
wants_email = await conn.fetchval( wants_email = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)", "SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
user_id, 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) 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): class NotificationEmailPrefsOut(BaseModel):
types: list[str] types: list[str]
@ -173,3 +198,73 @@ async def mark_all_read(user: CurrentUser = Depends(get_current_user)) -> dict[s
user.user_id, user.user_id,
) )
return {"ok": True} 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 from __future__ import annotations
import math import math
import uuid
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Literal from typing import Literal
@ -634,6 +635,14 @@ class RoundCreate(BaseModel):
# -- hvilke av EGNE private kategorier (se friends.py sin Category) som # -- hvilke av EGNE private kategorier (se friends.py sin Category) som
# får se runden. Tomt/utelatt for public/private. # får se runden. Tomt/utelatt for public/private.
visible_categories: list[_FriendCategory] = [] 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): class RoundParticipantOut(BaseModel):
@ -714,6 +723,8 @@ class RoundOut(BaseModel):
# medspiller-endepunktet (GET /rounds/{id}), ikke tredjepartsvisningen. # medspiller-endepunktet (GET /rounds/{id}), ikke tredjepartsvisningen.
visibility_mode: str visibility_mode: str
visible_categories: list[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: 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, 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, 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 FROM round WHERE id = $1
""", """,
round_id, 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, my_score_to_par=my_score_to_par,
visibility_mode=round_row["visibility_mode"], visibility_mode=round_row["visibility_mode"],
visible_categories=[r["category"] for r in visible_category_rows], 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: 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'.") 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: async with plain_connection() as conn:
owner_row = await conn.fetchrow( owner_row = await conn.fetchrow(
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1", "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, (owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at, personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
start_hole, holes_planned, started_at, name, play_format, start_hole, holes_planned, started_at, name, play_format,
skins_scoring, skins_tie_handling, visibility_mode) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING id::text AS id RETURNING id::text AS id
""", """,
user.user_id, user.user_id,
@ -931,6 +949,7 @@ async def create_round(
body.skins_scoring, body.skins_scoring,
body.skins_tie_handling, body.skins_tie_handling,
body.visibility_mode, body.visibility_mode,
body.flight_group_id,
) )
round_id = round_row["id"] round_id = round_row["id"]
@ -1513,6 +1532,12 @@ class RoundUpdate(BaseModel):
# dem. # dem.
visibility_mode: Literal["public", "private", "friends"] | None = None visibility_mode: Literal["public", "private", "friends"] | None = None
visible_categories: list[_FriendCategory] | 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) @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 ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
uansett fullført-status. 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: async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id) 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) 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) await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
if body.visibility_mode is not None: if body.visibility_mode is not None:
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode) 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: if body.visible_categories is not None:
await conn.execute("DELETE FROM round_visible_category WHERE round_id = $1", round_id) await conn.execute("DELETE FROM round_visible_category WHERE round_id = $1", round_id)
for category in sorted(set(body.visible_categories)): 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 # strokes_received. None hvis siden ikke har en beregnet playing
# handicap ennå (f.eks. kun én av to partnere tildelt). # handicap ennå (f.eks. kun én av to partnere tildelt).
strokes_received: int | None = None 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]: 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: if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.") raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
rows = await conn.fetch( rows = await conn.fetch(
"SELECT hole_number, par, stroke_index, played, score FROM round_hole " "SELECT hole_number, par, stroke_index, played, score, selected_participant_id::text AS selected_participant_id "
"WHERE round_side_id = $1 ORDER BY hole_number", "FROM round_hole WHERE round_side_id = $1 ORDER BY hole_number",
side_id, side_id,
) )
playing_handicap = await conn.fetchval( playing_handicap = await conn.fetchval(
@ -2302,6 +2340,11 @@ async def list_side_holes(
class SideHoleUpdate(BaseModel): class SideHoleUpdate(BaseModel):
played: bool = True played: bool = True
score: int | None = Field(default=None, ge=1, le=20) 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) @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) 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: if not side_exists:
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.") 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( 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 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: if row is None:
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.") 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) 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): class HoleUpdate(BaseModel):
played: bool = True played: bool = True
score: int | None = Field(default=None, ge=1, le=20) 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_USER: ${TEECUP_MINIO_ROOT_USER}
TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD} TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET} TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET}
TEECUP_VAPID_PRIVATE_KEY: ${TEECUP_VAPID_PRIVATE_KEY}
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
networks: networks:
- teeoff_default - teeoff_default
restart: unless-stopped 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" import { NewRound } from "@/components/new-round"
export default function NewRoundPage() { 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 { import {
ArrowLeft, ArrowLeft,
Bell, Bell,
BellRing,
Camera, Camera,
KeyRound, KeyRound,
Lock, Lock,
@ -25,6 +26,7 @@ import {
X, X,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getExistingSubscription, isPushSupported, subscribeToPush, unsubscribeFromPush } from "@/lib/push-subscribe"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark" import { Wordmark } from "@/components/wordmark"
@ -280,6 +282,8 @@ export function AccountSettings() {
<NotificationEmailPrefsSection /> <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"> <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 items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15"> <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." }, { 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() { function NotificationEmailPrefsSection() {
const [types, setTypes] = useState<string[]>([]) const [types, setTypes] = useState<string[]>([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)

View file

@ -39,6 +39,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Wordmark } from "@/components/wordmark" import { Wordmark } from "@/components/wordmark"
import { InstallPrompt } from "@/components/install-prompt"
import { RoundCard, type Round } from "@/components/round-card" import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card" import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge" import { type TournamentStatus } from "@/components/tournament-status-badge"
@ -407,6 +408,10 @@ export function Dashboard() {
</p> </p>
)} )}
<div className="mt-6">
<InstallPrompt />
</div>
<div className="mt-6 flex flex-col gap-8"> <div className="mt-6 flex flex-col gap-8">
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} /> <QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} /> <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 type React from "react"
import { useEffect, useMemo, useState } 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 Link from "next/link"
import { import {
ArrowLeft, ArrowLeft,
@ -138,6 +138,16 @@ function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
export function NewRound() { export function NewRound() {
const router = useRouter() 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 [step, setStep] = useState<Step>("source")
const [ownGender, setOwnGender] = useState<Gender | null>(null) const [ownGender, setOwnGender] = useState<Gender | null>(null)
@ -260,6 +270,7 @@ export function NewRound() {
skins_tie_handling: payload.skinsTieHandling, skins_tie_handling: payload.skinsTieHandling,
visibility_mode: payload.visibilityMode, visibility_mode: payload.visibilityMode,
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [], visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
flight_group_id: flightGroupId || undefined,
} }
const body = const body =
courseMeta.source === "teeoff" courseMeta.source === "teeoff"
@ -335,6 +346,11 @@ export function NewRound() {
ownGender={ownGender} ownGender={ownGender}
onSubmit={submitRound} onSubmit={submitRound}
onCreated={(id) => router.replace(`/my-rounds/${id}`)} 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, ownGender,
onSubmit, onSubmit,
onCreated, onCreated,
isAdditionalFlight = false,
defaultDate,
defaultStartHole,
defaultHoles,
defaultPlayFormat,
}: { }: {
course: Course course: Course
ownGender: Gender ownGender: Gender
@ -1167,16 +1188,23 @@ function ConfirmStep({
visibleCategories: string[] visibleCategories: string[]
}) => Promise<{ id: string }> }) => Promise<{ id: string }>
onCreated: (roundId: string) => void 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 compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "") const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
const [name, setName] = useState("") const [name, setName] = useState("")
const [date, setDate] = useState(todayIso) const [date, setDate] = useState(defaultDate ?? todayIso)
const [teeTime, setTeeTime] = useState("") const [teeTime, setTeeTime] = useState("")
const [startHole, setStartHole] = useState("1") const [startHole, setStartHole] = useState(defaultStartHole ?? "1")
const [holes, setHoles] = useState<9 | 18>(18) const [holes, setHoles] = useState<9 | 18>(defaultHoles ?? 18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only") 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) const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
// ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for // ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare. // play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
@ -1238,6 +1266,13 @@ function ConfirmStep({
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<StepHeader title="Bekreft og fullfør" description="Sjekk detaljene før du starter runden." /> <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 */} {/* 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 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"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">

View file

@ -33,6 +33,7 @@ import {
Settings2, Settings2,
Target, Target,
Trophy, Trophy,
Users,
WifiOff, WifiOff,
X, X,
} from "lucide-react" } from "lucide-react"
@ -222,6 +223,8 @@ type ApiRound = {
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller. // innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
visibility_mode: string visibility_mode: string
visible_categories: 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 // Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
@ -312,6 +315,9 @@ type ApiSideHole = {
stroke_index: number stroke_index: number
played: boolean played: boolean
score: number | null 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 { 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}` const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId }) await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`)) setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
@ -799,9 +809,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
return null 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 if (readOnly || !wizardSideId) return
const body = { played: true, score } const body = { played: true, score, selected_participant_id: selectedParticipantId }
if (!navigator.onLine) { if (!navigator.onLine) {
await queueSideHoleWrite(wizardSideId, activeHole, body) await queueSideHoleWrite(wizardSideId, activeHole, body)
return return
@ -827,6 +842,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
setFormatResultRefreshTick((n) => n + 1) 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() { function advanceWizardSide() {
if (!round) return if (!round) return
const idx = round.sides.findIndex((s) => s.id === wizardSideId) 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. */} (ADR-039) -- avledet ved lesing, aldri lagret. */}
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} /> <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="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{isOwnerViewer && ( {isOwnerViewer && (
@ -1325,9 +1358,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
<SideScoreWizard <SideScoreWizard
sides={round.sides} sides={round.sides}
side={wizardSide} side={wizardSide}
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
hole={sharedHole} hole={sharedHole}
score={wizardSideHole?.played ? wizardSideHole.score : null} score={wizardSideHole?.played ? wizardSideHole.score : null}
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
onSave={updateWizardSideScore} onSave={updateWizardSideScore}
onSelectParticipant={updateWizardSideSelection}
onClose={() => setWizardSideId(null)} onClose={() => setWizardSideId(null)}
onAdvance={advanceWizardSide} onAdvance={advanceWizardSide}
readOnly={readOnly} readOnly={readOnly}
@ -2231,6 +2267,143 @@ function substituteSideLabels(text: string, sides: ApiSide[]): string {
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`) 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({ function FormatResultPanel({
roundId, roundId,
round, round,
@ -4068,9 +4241,12 @@ function SideScorecardGrid({
function SideScoreWizard({ function SideScoreWizard({
sides, sides,
side, side,
sideParticipants,
hole, hole,
score, score,
selectedParticipantId,
onSave, onSave,
onSelectParticipant,
onClose, onClose,
onAdvance, onAdvance,
readOnly, readOnly,
@ -4078,9 +4254,13 @@ function SideScoreWizard({
}: { }: {
sides: ApiSide[] sides: ApiSide[]
side: ApiSide side: ApiSide
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036).
sideParticipants: Player[]
hole: Hole hole: Hole
score: number | null score: number | null
selectedParticipantId: string | null
onSave: (score: number | null) => void onSave: (score: number | null) => void
onSelectParticipant: (participantId: string | null) => void
onClose: () => void onClose: () => void
onAdvance: () => void onAdvance: () => void
readOnly: boolean readOnly: boolean
@ -4097,8 +4277,11 @@ function SideScoreWizard({
} }
// Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer // Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer
// slagtall for siden, hopp umiddelbart videre til neste side/hull. Kun // slagtall for siden, hopp umiddelbart videre til neste side/hull. KUN
// ETT felt her, så ingen splitt-i-flere-steg-problematikk. // 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 enteredWithValueRef = useRef(score !== null)
const firedRef = useRef(false) const firedRef = useRef(false)
useEffect(() => { useEffect(() => {
@ -4107,13 +4290,13 @@ function SideScoreWizard({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [side.id, hole.holeNumber]) }, [side.id, hole.holeNumber])
useEffect(() => { useEffect(() => {
if (firedRef.current || enteredWithValueRef.current) return if (!autoAdvance || firedRef.current || enteredWithValueRef.current) return
if (score !== null) { if (score !== null) {
firedRef.current = true firedRef.current = true
onAdvance() onAdvance()
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [score]) }, [score, autoAdvance])
return ( return (
<div className="fixed inset-0 z-50 flex flex-col bg-background"> <div className="fixed inset-0 z-50 flex flex-col bg-background">
@ -4170,6 +4353,37 @@ function SideScoreWizard({
onChange={(v) => onSave(v)} onChange={(v) => onSave(v)}
readOnly={readOnly} 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> </div>
</main> </main>

View file

@ -361,6 +361,13 @@ type ApiParticipant = {
guest_name: string | null guest_name: string | null
display_name: string display_name: string
is_owner: boolean 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 = { type ApiRound = {
@ -373,6 +380,17 @@ type ApiRound = {
holes_planned: number holes_planned: number
play_format: string play_format: string
participants: ApiParticipant[] 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) // Delt-ball-formater (foursome/greensome/scramble, ADR-039 Beslutning C)
@ -583,6 +601,69 @@ function participantLabel(p: ApiParticipant, viewerId: string | null): string {
// --- Screen ---------------------------------------------------------------- // --- 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 }) { export function RoundStats({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null) const [round, setRound] = useState<ApiRound | null>(null)
const [participantId, setParticipantId] = useState<string | null>(null) const [participantId, setParticipantId] = useState<string | null>(null)
@ -645,6 +726,29 @@ export function RoundStats({ roundId }: { roundId: string }) {
} }
}, [roundId, viewerId, refreshKey]) }, [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(() => { useEffect(() => {
// Delt-ball-formater har ingen individuell round_hole-rad å hente -- // Delt-ball-formater har ingen individuell round_hole-rad å hente --
// se guarden under, som viser en forklarende melding i stedet. // se guarden under, som viser en forklarende melding i stedet.
@ -696,6 +800,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
</div> </div>
</header> </header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5"> <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"> <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{" "} 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 {FORMAT_LABELS[round.play_format] ?? round.play_format} -- disse formatene registrerer kun ETT felles

View file

@ -1,12 +1,19 @@
"use client" "use client"
import { useEffect } from "react" import { useEffect } from "react"
import { initInstallPromptCapture } from "@/lib/pwa-install"
// Registrerer service workeren (ADR-028). Stille no-op i nettlesere uten // Registrerer service workeren (ADR-028). Stille no-op i nettlesere uten
// støtte -- appen skal fungere helt uendret uten SW, bare uten // støtte -- appen skal fungere helt uendret uten SW, bare uten
// installasjon/offline-cache. // 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() { export function SwRegister() {
useEffect(() => { useEffect(() => {
initInstallPromptCapture()
if (!("serviceWorker" in navigator)) return if (!("serviceWorker" in navigator)) return
navigator.serviceWorker.register("/sw.js").catch(() => {}) 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. // /my-notifications, ALDRI /notifications.
{ source: "/notifications/:path*", destination: `${API_ORIGIN}/notifications/:path*` }, { source: "/notifications/:path*", destination: `${API_ORIGIN}/notifications/:path*` },
{ source: "/notifications", destination: `${API_ORIGIN}/notifications` }, { 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` }, { 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