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:
parent
d6c1a38318
commit
19a21073ba
27 changed files with 1838 additions and 75 deletions
19
035_round_flight_group.sql
Normal file
19
035_round_flight_group.sql
Normal 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;
|
||||
17
036_round_hole_selected_participant.sql
Normal file
17
036_round_hole_selected_participant.sql
Normal 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;
|
||||
25
037_push_subscriptions.sql
Normal file
25
037_push_subscriptions.sql
Normal 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;
|
||||
|
|
@ -3615,12 +3615,17 @@ Disse må avklares før eller under de relevante fasene:
|
|||
3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke
|
||||
isolasjonsmodellen, men påvirker fakturerings-/kvotemodell.
|
||||
4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt
|
||||
UI-løsning spesifiseres senere. **Utvidet 2026-07-25:** brukeren ba om
|
||||
statistikk over hvor mange utslag hver spiller har hatt (dvs. hvor
|
||||
mange ganger spillerens drive ble valgt) — se egen seksjon i
|
||||
UI-løsning spesifiseres senere. **Utvidet 2026-07-25, DELVIS AVKLART
|
||||
OG BYGGET 2026-07-28:** brukeren ba om statistikk over hvor mange
|
||||
utslag hver spiller har hatt (dvs. hvor mange ganger spillerens drive
|
||||
ble valgt). Bygget for FRITTSTÅENDE RUNDER (ADR-039 sitt delt-ball-
|
||||
format, `round_hole.selected_participant_id`, migrasjon 036) — se
|
||||
FEATURE_BACKLOG.md ("Scramble: statistikk over utslag brukt per
|
||||
spiller") for full detalj og åpne spørsmål. Sannsynligvis samme
|
||||
problemstilling for greensome.
|
||||
spiller") for full detalj. **Fortsatt åpent:** samme funksjon for
|
||||
org-scopede turnering-scramble (`hole_score`/`match_hole_result`) —
|
||||
bevisst utenfor omfang i 2026-07-28-runden, samme underliggende
|
||||
problem som punkt 5 under. Sannsynligvis samme problemstilling for
|
||||
greensome i begge domener.
|
||||
5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av
|
||||
databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe
|
||||
på at f.eks. et foursome ikke får per-spiller-scorer.
|
||||
|
|
|
|||
119
CLAUDE.md
119
CLAUDE.md
|
|
@ -4843,6 +4843,100 @@ Ferdig og verifisert:
|
|||
begge var fullført i tidligere økter denne uken) — rettet til å
|
||||
stemme med den faktiske, allerede leverte tilstanden.
|
||||
|
||||
- **Fire gjenstående forslag fra en tidligere "hva nå?"-runde, ALLE FIRE
|
||||
BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28, samme dag:**
|
||||
brukeren ba eksplisitt om å ferdigstille alle fire samtidig
|
||||
(PWA-installasjon, flere flighter i frittstående runder, scramble/
|
||||
greensome-statistikk, push-varsler til telefonens OS) — se
|
||||
FEATURE_BACKLOG.md for full detalj per punkt, kort oppsummert her.
|
||||
Tre nye migrasjoner (`035_round_flight_group.sql`,
|
||||
`036_round_hole_selected_participant.sql`, `037_push_subscriptions.sql`),
|
||||
alle rent additive.
|
||||
1. **PWA-installasjonsoppfordring** — `lib/pwa-install.ts` (globalt
|
||||
fanget `beforeinstallprompt`, mountet via `sw-register.tsx` på alle
|
||||
sider siden eventet kun fyres én gang per side-liv) + ny
|
||||
`components/install-prompt.tsx`, vist på dashbordet rett under
|
||||
hilsenen. Android/Chrome-familien får en ekte "Installer"-knapp,
|
||||
iOS Safari et instruksjonsbanner (ingen programmatisk vei finnes
|
||||
der), andre nettlesere uten reell installasjonsvei viser ingenting.
|
||||
2. **Flere flighter i én frittstående runde** (retning 1, løs
|
||||
gruppering av separate `round`-rader bundet sammen av en klient-
|
||||
generert delt UUID, `round.flight_group_id`) — ny
|
||||
`GET /rounds/{id}/flight-group` + `GET .../flight-group/leaderboard`
|
||||
(slår sammen hver tilgjengelig søsken-flights EGET leaderboard til
|
||||
én rangert liste, `score_to_par` allerede normalisert og dermed
|
||||
sammenlignbart på tvers av baner). Ny `FlightGroupPanel` i
|
||||
`round-detail.tsx` ("+ Legg til en flight til" gjenbruker HELE
|
||||
opprett-runde-flyten, forhåndsutfylt via URL-parametre — banen
|
||||
velges på nytt per flight, bevisst), ny side
|
||||
`/my-rounds/[id]/flights`.
|
||||
3. **Scramble/greensome: valgt utslag per spiller** — avgrenset til
|
||||
frittstående runder (ADR-039 sitt delt-ball-format), IKKE
|
||||
org-scopede turneringer i denne runden (bevisst scope-kutt, se
|
||||
FEATURE_BACKLOG.md). Ny `round_hole.selected_participant_id`,
|
||||
`PATCH .../sides/{id}/holes/{n}` fikk et nytt valgfritt felt (samme
|
||||
"full overwrite hvert kall"-kontrakt som `played`/`score`).
|
||||
`SideScoreWizard` fikk en ny valgfri "Hvem sitt utslag ble brukt?"-
|
||||
seksjon (auto-hopp bevisst slått av her, ellers ville brukeren blitt
|
||||
revet videre før valget kunne gjøres); `round-stats.tsx` fikk en ny
|
||||
"Utslag brukt"-oppsummering per side.
|
||||
4. **Push-varsler til telefonens OS** (Web Push/VAPID) — ny
|
||||
`push_subscription`-tabell, nytt `app/push.py`
|
||||
(`send_push_to_user`, `pywebpush`), kalt fra `create_notification()`
|
||||
for ALLE fire varseltyper. Bevisst INGEN egen per-type opt-in
|
||||
(ulikt e-post-fallbacken) — å abonnere ER samtykket. VAPID-nøkler
|
||||
valgfrie (`PUSH_CONFIGURED`), samme grasiøs-degraderings-mønster som
|
||||
SMTP. `public/sw.js` fikk `push`/`notificationclick`-håndtering, ny
|
||||
`lib/push-subscribe.ts` + seksjon i `/account`
|
||||
(`PushNotificationSection`).
|
||||
**VAPID-nøkkelpar generert lokalt** (Python `cryptography`, EC P-256,
|
||||
rå base64url-kodet privat/offentlig nøkkel — samme format `pywebpush`
|
||||
og nettleserens `applicationServerKey` forventer), ALDRI vist i
|
||||
klartekst i chatten (skrevet direkte til en midlertidig fil med 600-
|
||||
rettigheter, lest inn i ekte `.env` ved utrulling, slettet fra
|
||||
scratchpad etterpå) — samme regel som alle andre hemmeligheter i
|
||||
prosjektet.
|
||||
**Scratch-verifisert grundig, 27/27 sjekker i ett delt testløp**
|
||||
(isolert `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
|
||||
API-container med `pywebpush` installert, alle 37 migrasjoner kjørt
|
||||
friskt): flight-group happy-path + ugyldig UUID avvist + kryss-bruker-
|
||||
isolasjon (403/404, ingen lekkasje) + slått-sammen leaderboard rangerer
|
||||
korrekt på tvers av to flighter; scramble-valg PATCH lykkes + feil side
|
||||
avvist (400) + eksplisitt null nullstiller; VAPID-nøkkel eksponert +
|
||||
abonnement lagret + anonymt abonnement avvist (401) + en EKTE
|
||||
`webpush()`-utsendelse forsøkt mot en syntaktisk ugyldig test-nøkkel
|
||||
(`WebPushException` fanget og logget, in-app-varselet ble uansett
|
||||
opprettet normalt — beviser feil-isolasjonen fungerer i praksis, ikke
|
||||
bare i teorien). `test_isolation.sql` 12/12 uendret.
|
||||
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
|
||||
isolert scratch-backend, ekte innlogging): PWA-knappen rendret og var
|
||||
klikkbar (Chrome fyrte faktisk `beforeinstallprompt` i testøkten), ingen
|
||||
konsollfeil; hele "+ Legg til en flight til"-flyten klikket gjennom FRA
|
||||
KNAPPETRYKK TIL FERDIG RUNDE (riktig forhåndsutfylt skjema, korrekt
|
||||
`flightGroupId` i URL-en, DIREKTE DATABASE-bekreftelse at begge rundene
|
||||
delte samme gruppe-id etterpå), kombinert leaderboard-siden bekreftet
|
||||
visuelt med korrekt rangering; scramble-valget satt i veiviseren,
|
||||
bekreftet DIREKTE I DATABASEN, OG "Utslag brukt"-oppsummeringen
|
||||
bekreftet visuelt på statistikksiden; push-UI-et rendret korrekt og
|
||||
håndterte avslått tillatelse med en forklarende tekst uten konsollfeil
|
||||
(denne automatiserte nettleserøkten hadde `Notification.permission`
|
||||
forhåndssatt til "denied" av selve miljøet — en ekte innvilget
|
||||
tillatelse → ekte levert OS-varsel er derfor IKKE bevist, ærlig
|
||||
begrensning, bruker bør selv teste dette på en ekte enhet før full
|
||||
tillit). Ekte typesjekket produksjonsbuild (`docker build --target
|
||||
builder`) kompilerte rent, alle 27 ruter listet inkl. den nye
|
||||
`/my-rounds/[id]/flights`.
|
||||
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt (plan vist
|
||||
FØR kjøring, per CLAUDE.md sin ufravikelige regel): migrasjon 035-037
|
||||
kjørt mot ekte `teecup_db` (alle tre bekreftet med direkte spørring,
|
||||
`test_isolation.sql` fortsatt 12/12), VAPID-nøkler lagt inn i ekte
|
||||
`.env`, `docker compose up -d --build teecup_api teecup_frontend`.
|
||||
Begge containere boot-et rent, `/health`/`/dashboard` → 200,
|
||||
`GET /push/vapid-public-key` over ekte https ga korrekt offentlig
|
||||
nøkkel, `GET /rounds/{ukjent-id}/flight-group` ga korrekt
|
||||
`401 NOT_AUTHENTICATED` (ikke en rå 404 — bekrefter ruten faktisk når
|
||||
FastAPI), `teeoff.no` upåvirket.
|
||||
|
||||
Neste steg:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||
|
|
@ -5054,12 +5148,14 @@ Neste steg:
|
|||
(c) ~~rundedeling/visibility (ADR-036 fase 2, public/private/friends)
|
||||
fortsatt ikke bygget~~ — ferdig, browser-/scratch-verifisert og live
|
||||
2026-07-28, se status over (ADR-036 er dermed HELT ferdig, alle tre
|
||||
faser); (d) flere flighter i én frittstående runde
|
||||
fortsatt kun drøftet (se
|
||||
FEATURE_BACKLOG.md); (e) ~~varsler koblet til venneforespørsler, ikke
|
||||
til rundehendelser ennå~~ — ferdig, scratch-verifisert og live
|
||||
2026-07-28, se status over (medspiller lagt til/venn ser synlig
|
||||
runde/tilkoblet runde fullført).
|
||||
faser); (d) ~~flere flighter i én frittstående runde fortsatt kun
|
||||
drøftet~~ — ferdig, retning 1 (løs gruppering) bygget, scratch-/
|
||||
browserverifisert og live 2026-07-28, se status over; (e) ~~varsler
|
||||
koblet til venneforespørsler, ikke til rundehendelser ennå~~ —
|
||||
ferdig, scratch-verifisert og live 2026-07-28, se status over
|
||||
(medspiller lagt til/venn ser synlig runde/tilkoblet runde fullført).
|
||||
Eneste reelt gjenstående punkt i denne listen nå: (b), Stableford for
|
||||
frittstående runder.
|
||||
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
|
||||
foursome/greensome/scramble) for frittstående runder — backend
|
||||
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,
|
||||
|
|
@ -5074,3 +5170,14 @@ Neste steg:
|
|||
spillere + etter-runde-invitasjon) — alle bygget, scratch-/
|
||||
browserverifisert og live 2026-07-28, se status over. Ingen nye åpne
|
||||
spørsmål igjen fra denne runden.
|
||||
13. **Ferdig, kun for historikk:** PWA-installasjonsoppfordring, flere
|
||||
flighter i én frittstående runde (retning 1), scramble/greensome-
|
||||
statistikk over valgt utslag (frittstående runder), og push-varsler
|
||||
til telefonens OS (Web Push/VAPID) — alle fire bygget, scratch-/
|
||||
browserverifisert og live 2026-07-28, se status over for full detalj.
|
||||
Gjenstående, IKKE bygget: samme scramble-utslagsstatistikk for
|
||||
org-scopede turneringer (bevisst utenfor omfang, se
|
||||
FEATURE_BACKLOG.md), ekte OS-nivå push-levering ikke bevist med en
|
||||
reell innvilget tillatelse (automatisert testmiljø hadde
|
||||
`Notification.permission` forhåndssatt til "denied") — bruker bør
|
||||
selv teste push på en ekte enhet.
|
||||
|
|
|
|||
|
|
@ -671,31 +671,77 @@ migrasjonsutkast til gjennomgang.
|
|||
|
||||
---
|
||||
|
||||
## Varsler: push til telefon + in-app varslingssenter — 📋 DESIGNET 2026-07-25, IKKE bygget
|
||||
## Varsler: push til telefon + in-app varslingssenter — ✅ HELT FERDIG 2026-07-28 (begge deler)
|
||||
|
||||
**In-app varslingssenteret er nå komplett** (bygget 2026-07-25/26, utvidet
|
||||
2026-07-28): bjelle-ikon i dashbord-header, `/my-notifications`, fire
|
||||
triggerpunkter (venneforespørsel sendt/akseptert, medspiller lagt til på
|
||||
en runde, en venn ser en synlig runde, en tilkoblet runde fullført) OG en
|
||||
e-post-fallback PER TYPE (`user_notification_email_pref`, trygg standard
|
||||
= ingen e-post). Se CLAUDE.md-status for full detalj. Kun push til
|
||||
telefonens eget OS-varslingssystem (under) gjenstår av det opprinnelige
|
||||
forslaget.
|
||||
|
||||
Brukeren spurte om dagens PWA-oppsett kan varsle telefonens eget
|
||||
varslingssystem (f.eks. ved en ny venneforespørsel), og ba om at det uansett
|
||||
finnes en in-app-fallback på dashbordet (varslingsindikator + en side med
|
||||
uleste varsler) — med et V0-prompt klart "i tilfelle".
|
||||
|
||||
**Push-varsler til telefonens OS — teknisk mulig, men reell ny
|
||||
infrastruktur, ikke en liten utvidelse:**
|
||||
- Fundamentet finnes allerede (ADR-028: service worker, installerbar PWA).
|
||||
Web Push (Push API + Notification API) fungerer UTEN en native app.
|
||||
- **Viktig plattformbegrensning:** på iOS Safari fungerer Web Push KUN når
|
||||
PWA-en er lagt til på hjemskjermen (iOS 16.4+) — en vanlig Safari-fane
|
||||
kan ALDRI motta push, uansett tillatelse gitt. Android/desktop er langt
|
||||
mer tilgivende.
|
||||
- Krever: et VAPID-nøkkelpar, en ny `push_subscription`-tabell (én bruker
|
||||
kan ha flere enheter/abonnementer), en backend-sende-funksjon (f.eks.
|
||||
`pywebpush`), og et eksplisitt tillatelsesspørsmål fra nettleseren (kan
|
||||
ikke sendes stille, brukeren kan avslå).
|
||||
- **Vurdering:** reell verdi, men et eget, moderat-til-stort byggeløft med
|
||||
en hard plattformbegrensning å designe rundt — IKKE anbefalt som første
|
||||
steg.
|
||||
**Push-varsler til telefonens OS — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT
|
||||
OG LIVE 2026-07-28 (Web Push/VAPID):**
|
||||
- Ny migrasjon `037_push_subscriptions.sql` -- `push_subscription`
|
||||
(`user_id`, `endpoint` unik, `p256dh`/`auth`), samme `plain_connection()`-
|
||||
mønster som `notification`/`round`/`friendship` (ingen RLS, bruker-eid).
|
||||
Én bruker kan ha flere abonnement (flere enheter/nettlesere).
|
||||
- Nytt `app/push.py` (`send_push_to_user`) -- `pywebpush`, kalt fra
|
||||
`create_notification()` (den eneste skrivevegen inn, se
|
||||
`app/routers/notifications.py`) for ALLE fire varseltyper. **Bevisst
|
||||
ingen egen per-type opt-in** (ulikt e-post-fallbacken) -- selve det å
|
||||
abonnere ER samtykket, en abonnert enhet får push for alt. VAPID-nøkler
|
||||
er valgfrie (`settings.PUSH_CONFIGURED`, `app/config.py`) -- samme
|
||||
grasiøs-degraderings-mønster som SMTP, push forsøkes rett og slett ikke
|
||||
uten nøkler satt. En utløpt/tilbakekalt abonnement (404/410 fra
|
||||
push-tjenesten) ryddes automatisk bort, andre feil isoleres med
|
||||
`traceback.print_exc()` og lekker aldri til kalleren (samme mønster som
|
||||
e-post-utsendingen).
|
||||
Nye endepunkter: `GET /push/vapid-public-key` (offentlig), `POST/DELETE
|
||||
/push/subscribe` (krever sesjon).
|
||||
- Frontend: `public/sw.js` fikk `push`/`notificationclick`-håndtering
|
||||
(viser native OS-varsel selv når ingen fane er åpen, klikk fokuserer
|
||||
eller åpner appen på riktig `link_path`). Ny `lib/push-subscribe.ts`
|
||||
(feature-deteksjon inkl. iOS-hjemskjerm-kravet, abonner/avmeld). Ny
|
||||
seksjon "Push-varsler på denne enheten" i `/account`
|
||||
(`PushNotificationSection`), enkel av/på-bryter, håndterer avslått
|
||||
tillatelse med en forklarende tekst i stedet for en generisk feil.
|
||||
- **Viktig plattformbegrensning, uendret:** på iOS Safari fungerer Web
|
||||
Push KUN når PWA-en er lagt til på hjemskjermen -- dekket eksplisitt av
|
||||
`isPushSupported()`.
|
||||
- **Scratch-verifisert (27/27 sjekker, delt testløp med de tre andre
|
||||
punktene under samme dag):** VAPID-nøkkel korrekt eksponert, abonnement
|
||||
lagret, anonymt abonnement avvist (401), en EKTE `webpush()`-utsendelse
|
||||
forsøkt (mot en syntaktisk ugyldig test-nøkkel -- `WebPushException`
|
||||
fanget og logget, in-app-varselet ble uansett opprettet normalt, ingen
|
||||
krasj). **Browserverifisert:** UI-et rendrer korrekt, klikk kaller
|
||||
`Notification.requestPermission()` -- i denne automatiserte nettleser-
|
||||
økten var tillatelsen forhåndssatt til "denied" av miljøet selv (ingen
|
||||
ekte permission-dialog kan trigges av testverktøyet), og avslag ble vist
|
||||
med riktig forklarende tekst uten konsollfeil. **Ærlig begrensning:**
|
||||
en ekte innvilget tillatelse → ekte abonnement → ekte levert OS-varsel
|
||||
er IKKE bevist i denne runden -- krever en ekte enhet/nettleserøkt
|
||||
utenfor automatisert testing. Bruker bør selv teste dette før full
|
||||
tillit.
|
||||
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: migrasjon
|
||||
037 kjørt mot ekte `teecup_db`, VAPID-nøkkelpar generert lokalt
|
||||
(`cryptography`, EC P-256) og lagt inn i ekte `.env` uten å noensinne
|
||||
vises i klartekst i chatten (samme regel som alle andre hemmeligheter i
|
||||
prosjektet), `docker compose up -d --build teecup_api teecup_frontend`.
|
||||
Verifisert: `/health`/`/dashboard` → 200, `GET /push/vapid-public-key`
|
||||
over ekte https ga korrekt offentlig nøkkel, `teeoff.no` upåvirket.
|
||||
|
||||
**In-app varslingssenter — anbefalt første steg, fungerer overalt, ingen
|
||||
tillatelse kreves:**
|
||||
**In-app varslingssenter — ✅ BYGGET, i hovedsak akkurat som beskrevet
|
||||
under (skjema/endepunkter/rute-navn stemmer med forslaget), pluss
|
||||
per-type e-post-fallback som ikke var en del av det opprinnelige
|
||||
forslaget:**
|
||||
- Ny tabell `notification` (arbeidsnavn): `id`, `user_id` (mottaker),
|
||||
`type`, `message` (ferdig norsk tekst, samme snapshot-prinsipp som
|
||||
`author_display_name` i meldinger — unngår å måtte slå opp relaterte
|
||||
|
|
@ -821,7 +867,7 @@ altså bygges som et nytt, eget felt, ikke gjenbrukes.
|
|||
|
||||
---
|
||||
|
||||
## PWA-installasjon: hvordan få brukere til å installere raskt — 📋 VURDERT 2026-07-25, IKKE designet i detalj
|
||||
## PWA-installasjon: hvordan få brukere til å installere raskt — ✅ BYGGET OG LIVE 2026-07-28
|
||||
|
||||
Brukeren reiste dette som en oppfølging av varsel-diskusjonen: hvordan få
|
||||
brukere til "nærmest umiddelbart" å installere TeeCup som app på
|
||||
|
|
@ -876,12 +922,26 @@ kaldt overfall på innloggingssiden.
|
|||
`event.prompt()`; iOS: instruksjonsbanner) — INGEN visning for andre
|
||||
nettlesere uten en reell installasjonsvei.
|
||||
|
||||
**Status: kun vurdert/skissert i denne runden, ikke designet i detalj
|
||||
eller bygget** — ingen V0-prompt skrevet ennå for dette (i motsetning
|
||||
til varslingssenteret over). Naturlig neste steg om bruker vil gå videre:
|
||||
en egen designrunde for selve UI-teksten/visuelt (særlig iOS-
|
||||
instruksjonsbanneret, som må vise konkrete steg) — trolig verdt et eget
|
||||
V0-prompt da, siden det er en egen, synlig UI-flate.
|
||||
**Status: BYGGET akkurat som foreslått over, håndkodet (ikke V0), 2026-07-28:**
|
||||
ny `lib/pwa-install.ts` (globalt fanget `beforeinstallprompt`-event via
|
||||
`components/sw-register.tsx`, som allerede mountes på alle sider -- eventet
|
||||
fyres kun én gang per side-liv og må derfor fanges tidligst mulig, ikke
|
||||
først når selve dashbord-komponenten mountes). Ny `components/install-
|
||||
prompt.tsx` (`InstallPrompt`) -- viser ALDRI noe hvis allerede installert
|
||||
(`display-mode: standalone`) eller nylig avvist (`localStorage`-cooldown,
|
||||
14 dager). To varianter: Android/Chrome-familien får en ekte
|
||||
"Installer"-knapp (`event.prompt()`), iOS Safari får et instruksjonsbanner
|
||||
(Del-ikon → "Legg til på Hjem-skjerm", INGEN programmatisk vei finnes der).
|
||||
Andre nettlesere uten en reell installasjonsvei viser ingenting. Lagt inn i
|
||||
`dashboard.tsx` rett under hilsenen -- akkurat sjekkpunktet foreslått over
|
||||
(første/hver gang brukeren når dashbordet med komplett profil).
|
||||
**Browserverifisert:** Chrome fyrte faktisk `beforeinstallprompt` i
|
||||
testøkten, "Installer"-knappen rendret og var klikkbar, `event.prompt()`
|
||||
ble kalt uten konsollfeil (selve den native installasjonsdialogen kan ikke
|
||||
utløses i en automatisert nettleserøkt -- ærlig, forventet begrensning,
|
||||
samme klasse som all annen "ekte OS-installasjon"-testing i prosjektet).
|
||||
**Rullet ut live 2026-07-28** sammen med de tre andre punktene samme dag,
|
||||
se CLAUDE.md-status for felles utrullingsdetalj.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1448,7 +1508,7 @@ blant minst to fullførte runder). Full detalj i CLAUDE.md sin statuslogg
|
|||
|
||||
---
|
||||
|
||||
## Scramble: statistikk over utslag brukt per spiller — 📋 NOTERT 2026-07-25, IKKE bygget
|
||||
## Scramble: statistikk over utslag brukt per spiller — ✅ BYGGET OG LIVE 2026-07-28 (frittstående runder)
|
||||
|
||||
Brukeren ba om at det i scramble-turneringer skal føres statistikk over
|
||||
hvor mange utslag hver spiller har hatt (dvs. hvor mange ganger den
|
||||
|
|
@ -1470,19 +1530,52 @@ nevnt av bruker, men samme spilleregel-mekanikk: begge partnere slår
|
|||
egen ball fra tee, ett velges) — verdt å vurdere sammen når dette
|
||||
designes, ikke som to separate ting.
|
||||
|
||||
**Åpne spørsmål, ikke besluttet:**
|
||||
- Hvem registrerer dette — kapteinen/den som fører score for laget
|
||||
(samme autorisasjonsmodell som resten av match-scoring, ADR-023), og
|
||||
på hvilket tidspunkt (samtidig med selve hull-scoren, eller separat)?
|
||||
- Hvor vises statistikken — per match, aggregert for hele turneringen,
|
||||
eller begge deler?
|
||||
- Teller uspilte/ikke-registrerte hull annerledes enn et hull der ingen
|
||||
eksplisitt valgte utslag ble registrert (skal det være mulig å hoppe
|
||||
over)?
|
||||
**Åpne spørsmål, avklart pragmatisk ved bygging (2026-07-28):** registreres
|
||||
av den som fører score for siden (samme autorisasjon som selve hull-
|
||||
scoringen, ingen ny sjekk), samtidig med slagtallet men som en HELT
|
||||
UAVHENGIG, valgfri handling (kan settes/endres uten å røre selve
|
||||
slagtallet). Vist per side (match) OG aggregert for hele runden
|
||||
(`round-stats.tsx`). Uregistrerte hull telles rett og slett ikke med --
|
||||
"registrert på N av M spilte hull" vises alltid ved siden av.
|
||||
|
||||
**Ingen datamodell eller UI designet ennå** — rent notat, fanget opp slik
|
||||
at det ikke går i glemmeboken til scramble/greensome-scoring tas fatt på
|
||||
som egen runde.
|
||||
**Status: ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 --
|
||||
avgrenset til frittstående runder (ADR-039 sitt delt-ball-format), IKKE
|
||||
org-scopede turneringer i denne runden** (samme underliggende problem,
|
||||
men bevisst utsatt der -- se "bevisst utenfor omfang" under).
|
||||
Ny migrasjon `036_round_hole_selected_participant.sql` --
|
||||
`round_hole.selected_participant_id` (nullable, FK til
|
||||
`round_participant`, `ON DELETE SET NULL`), kun meningsfull på en
|
||||
delt-ball-hull-rad (`round_side_id IS NOT NULL`), håndhevet i app-laget.
|
||||
`PATCH /rounds/{id}/sides/{side_id}/holes/{n}` (`SideHoleUpdate`) fikk et
|
||||
nytt valgfritt felt, validert til å tilhøre NØYAKTIG denne siden (400
|
||||
ellers). Samme "full overwrite hvert kall"-kontrakt som `played`/`score`
|
||||
alltid har hatt -- frontend sender alltid gjeldende verdi for begge felt
|
||||
sammen (`patchSideHole()` i `round-detail.tsx`), ellers ville en
|
||||
score-oppdatering stille nullstilt et allerede registrert valg.
|
||||
**Frontend:** `SideScoreWizard` fikk en ny "Hvem sitt utslag ble brukt?"-
|
||||
seksjon (knapperad, ett trykk per spiller på siden, valgfritt) --
|
||||
auto-hopp-mekanismen (2026-07-26-runden) ble bevisst SLÅTT AV for
|
||||
delt-ball-formater med spillere å velge mellom (ville revet brukeren
|
||||
videre før valget kunne gjøres), beholdt uendret for slagspill/skins/
|
||||
individuell-ball-formater. Ny "Utslag brukt"-seksjon i `round-stats.tsx`
|
||||
(`SelectedDriverSummary`) -- ren klientside-telling fra allerede hentet
|
||||
`round_hole`-data, per side, med "registrert på N av M spilte hull".
|
||||
**Scratch-verifisert** (del av samme 27-punkts testløp som de tre andre
|
||||
punktene denne dagen): PATCH med valgt spiller lykkes, feil side avvist
|
||||
(400), eksplisitt null nullstiller valget. **Browserverifisert:** valgte
|
||||
"Gjest Partner" i veiviseren, bekreftet lagret DIREKTE i databasen
|
||||
(`selected_participant_id` pekte riktig), OG bekreftet "Utslag brukt"-
|
||||
oppsummeringen viste "Gjest Partner: 1 utslag" korrekt på statistikksiden,
|
||||
ingen konsollfeil.
|
||||
**Bevisst utenfor omfang:** org-scopede turnering-scramble/greensome
|
||||
(`hole_score`/`match_hole_result`, delt mellom lag i en Ryder Cup-
|
||||
tourney) har samme strukturelle hull, men ble IKKE bygget i denne
|
||||
runden -- appens hovedfokus er frittstående runder (ADR-033/035/036), og
|
||||
å bygge samme funksjon i to atskilte datamodeller i samme økt som tre
|
||||
andre store leveranser ble vurdert som unødvendig risiko. Notert her,
|
||||
ikke glemt, hvis/når turnering-scramble tas fatt på som egen sak.
|
||||
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
|
||||
utrullingsdetalj med de tre andre punktene samme dag.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -2264,7 +2357,14 @@ og vil derfor begge se profil-fullførings-skjemaet ved neste innlogging.
|
|||
|
||||
---
|
||||
|
||||
## Dashboard: tom-tilstand ved første innlogging — 📋 KONKRET FORSLAG LAGT FREM (2026-07-25), IKKE bygget
|
||||
## Dashboard: tom-tilstand ved første innlogging — ✅ HELT FERDIG (bygget som ADR-035, live 2026-07-25)
|
||||
|
||||
**Bygget som beskrevet i "2026-07-25"-forslaget under** (de sju blokkene:
|
||||
Hurtighandlinger/Kommende runder/Kommende turneringer/Statistikk/Spilte
|
||||
baner/Venner/Organisasjoner) — se ADR-035 i ARCHITECTURE_DECISIONS.md og
|
||||
CLAUDE.md-status 2026-07-25 for full bygge-/verifiseringsdetalj.
|
||||
Organisasjon er ikke lenger første/eneste synlige vei inn; opprettes nå
|
||||
usynlig i bakgrunnen når en bruker trykker "Ny turnering".
|
||||
|
||||
Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen
|
||||
organisasjon ennå — opprett en") er organisator-vridd og ikke stemmer med
|
||||
|
|
@ -2629,7 +2729,7 @@ umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CLAUDE.md-status
|
|||
|
||||
---
|
||||
|
||||
## Frittstående runder: flere flighter i én "vanlig" runde — 📋 NOTERT OG PRESISERT 2026-07-25/26, IKKE besluttet eller bygget
|
||||
## Frittstående runder: flere flighter i én "vanlig" runde — ✅ BYGGET OG LIVE 2026-07-28 (retning 1, løs gruppering)
|
||||
|
||||
Reist av brukeren samme runde: eksempel gitt — "jeg går i den første
|
||||
flighten sammen med tre andre, mens fire venner går i flighten bak."
|
||||
|
|
@ -2707,6 +2807,53 @@ bevisst ADSKILT (to separate, mindre systemer), ikke forent til ett.
|
|||
Denne seksjonens egen retning (løs gruppering av separate `round`-rader,
|
||||
se over) står fortsatt som anbefaling, uendret av presiseringen.
|
||||
|
||||
**Status: ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 --
|
||||
retning 1 bygget nøyaktig som anbefalt.** Ny migrasjon
|
||||
`035_round_flight_group.sql` -- `round.flight_group_id uuid` (nullable,
|
||||
INGEN egen tabell/FK-mål -- bevisst kun en delt UUID-VERDI på tvers av
|
||||
N `round`-rader, det tynneste mulige "satt opp sammen"-konseptet, se
|
||||
moduldoc for begrunnelsen: leaderboardets omfang blir da AUTOMATISK
|
||||
presist det som deler samme verdi, aldri "alle som spilte samme bane
|
||||
samme dag"). Klient-generert (ikke server-generert) -- eieren genererer
|
||||
`crypto.randomUUID()` FØRST gang en andre flight legges til (via en ny
|
||||
`PATCH /rounds/{id}` med `flight_group_id`), gjenbruker samme verdi for
|
||||
en tredje+ flight.
|
||||
Nye endepunkter: `GET /rounds/{id}/flight-group` (søsken-flighter
|
||||
brukeren selv har tilgang til -- rader man IKKE har tilgang til
|
||||
avsløres aldri, verken navn eller eksistens) og
|
||||
`GET /rounds/{id}/flight-group/leaderboard` (slår sammen hver
|
||||
tilgjengelig søsken-flights EGET leaderboard, `_build_leaderboard()`
|
||||
uendret, til én rangert liste tagget med `flight_label` -- `score_to_par`
|
||||
er allerede normalisert mot par og dermed sammenlignbart på tvers av
|
||||
ulike baner/hullantall).
|
||||
**Frontend:** ny `FlightGroupPanel` i `round-detail.tsx` (sibling-liste +
|
||||
"+ Legg til en flight til"-knapp, som PATCHer gruppe-id på anker-runden
|
||||
ved behov og navigerer til `/my-rounds/new` med dato/starthull/
|
||||
hullantall/spilleform forhåndsutfylt via URL-parametre -- banen velges på
|
||||
nytt for hver flight, bevisst, samme lave friksjon som vanlig bane-søk).
|
||||
Ny side `/my-rounds/[id]/flights` (`flight-group-leaderboard.tsx`) --
|
||||
egen, lettere lokal variant av `round-leaderboard.tsx` sitt rangerings-/
|
||||
mode-toggle-mønster (brutto/netto/poeng), rader lenker videre til hver
|
||||
flights EGET fulle leaderboard for hull-for-hull-detalj.
|
||||
**Scratch-verifisert** (del av et 27-punkts testløp samme dag som de tre
|
||||
andre punktene): anker uten gruppe viser kun seg selv, PATCH setter
|
||||
gruppe-id, ugyldig UUID avvist (400), begge flighter vises etter
|
||||
gruppering, fremmed bruker nektes BÅDE anker-runden og det slåtte
|
||||
leaderboardet (403/404, ingen lekkasje), slått-sammen leaderboard
|
||||
rangerer korrekt på tvers av to flighter (−1 foran +2), `flight_label`
|
||||
satt for alle rader.
|
||||
**Browserverifisert, inkludert hele "+ Legg til en flight til"-flyten
|
||||
FRA KNAPPETRYKK TIL FERDIG RUNDE** (ikke bare API-kall): klikket
|
||||
knappen på en gruppeløs runde, bekreftet riktig `flightGroupId`/
|
||||
`playedAt`/`startHole`/`holes`/`playFormat` i URL-en, bekreftet
|
||||
forhåndsutfylt-men-redigerbart skjema (inkl. synlig forklarende banner),
|
||||
fullførte oppretting, bekreftet DIREKTE I DATABASEN at begge rundene nå
|
||||
delte samme `flight_group_id`. Kombinert leaderboard-siden bekreftet
|
||||
visuelt (skjermbilde) med korrekt rangering/farger, ingen konsollfeil
|
||||
noe sted i hele flyten.
|
||||
**Rullet ut live 2026-07-28**, se CLAUDE.md-status for felles
|
||||
utrullingsdetalj med de tre andre punktene samme dag.
|
||||
|
||||
---
|
||||
|
||||
## Frittstående rundeføring + detaljert statistikk (uten turnering/organisasjon) — ✅ BACKEND + FRONTEND LIVE, løpende oppfølging t.o.m. 2026-07-28 (se ADR-033/ADR-038 for full detalj per dag)
|
||||
|
|
|
|||
|
|
@ -88,5 +88,17 @@ class Settings:
|
|||
# facilities-API er offentlig uten auth, så ingen hemmelighet trengs her.
|
||||
TEEOFF_API_BASE_URL: str = os.getenv("TEECUP_TEEOFF_API_BASE_URL", "http://teeoff_api:8000")
|
||||
|
||||
# Push-varsler til telefonens OS (Web Push/VAPID, FEATURE_BACKLOG.md
|
||||
# "Varsler: push til telefon"). Valgfritt (ikke _required) -- samme
|
||||
# grasiøs-degraderings-mønster som SMTP: push forsøkes rett og slett
|
||||
# ikke uten nøkler satt, resten av varslingssystemet (in-app + e-post)
|
||||
# fungerer helt uendret. Privatnøkkelen genereres og legges KUN i
|
||||
# .env på serveren, aldri i klartekst i chatten (samme regel som alle
|
||||
# andre hemmeligheter i prosjektet).
|
||||
VAPID_PRIVATE_KEY: str | None = os.environ.get("TEECUP_VAPID_PRIVATE_KEY", "").strip() or None
|
||||
VAPID_PUBLIC_KEY: str | None = os.environ.get("TEECUP_VAPID_PUBLIC_KEY", "").strip() or None
|
||||
VAPID_SUBJECT: str = os.getenv("TEECUP_VAPID_SUBJECT", "https://teecup.teeoff.no")
|
||||
PUSH_CONFIGURED: bool = bool(VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
63
app/push.py
Normal file
63
app/push.py
Normal 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()
|
||||
|
|
@ -11,3 +11,4 @@ httpx>=0.27
|
|||
argon2-cffi>=23.1
|
||||
pyotp>=2.9
|
||||
qrcode>=7.4
|
||||
pywebpush>=2.0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
"""
|
||||
In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) +
|
||||
e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033).
|
||||
e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033) + push til
|
||||
telefonens OS (2026-07-28, migrasjon 037, se app/push.py). Push har -- ulikt
|
||||
e-post -- INGEN egen per-type opt-in: selve det å abonnere (gi
|
||||
nettleser-tillatelse) ER samtykket, en abonnert enhet får push for ALLE
|
||||
typer create_notification() skriver.
|
||||
|
||||
Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
|
||||
mønster som personlig profil/HCP-historikk/venner. `create_notification()`
|
||||
|
|
@ -30,18 +34,27 @@ from ..auth import CurrentUser, get_current_user
|
|||
from ..config import settings
|
||||
from ..db import plain_connection
|
||||
from ..email import send_notification_email
|
||||
from ..errors import app_error
|
||||
from ..push import send_push_to_user
|
||||
|
||||
router = APIRouter(tags=["notifications"])
|
||||
|
||||
NotificationType = Literal["friend", "tournament", "round", "result"]
|
||||
_NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("friend", "tournament", "round", "result")
|
||||
_PUSH_TITLES: dict[str, str] = {
|
||||
"friend": "Venner",
|
||||
"round": "Runde",
|
||||
"result": "Resultat",
|
||||
"tournament": "Turnering",
|
||||
}
|
||||
|
||||
|
||||
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
|
||||
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
|
||||
tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en
|
||||
e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for
|
||||
akkurat DENNE typen."""
|
||||
akkurat DENNE typen, OG et push-varsel til enhver enhet mottakeren har
|
||||
abonnert (uavhengig av e-post-valget -- se _push_for_notification)."""
|
||||
await conn.execute(
|
||||
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
|
||||
user_id,
|
||||
|
|
@ -50,6 +63,8 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
|
|||
link_path,
|
||||
)
|
||||
|
||||
await _push_for_notification(conn, user_id=user_id, type=type, message=message, link_path=link_path)
|
||||
|
||||
wants_email = await conn.fetchval(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
|
||||
user_id,
|
||||
|
|
@ -74,6 +89,16 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
|
|||
print(f"[DEV] Varsel-e-post til {recipient['email']} ({type}): {message}", flush=True)
|
||||
|
||||
|
||||
async def _push_for_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
|
||||
"""Kalt fra create_notification() -- egen liten funksjon KUN for at
|
||||
push (uavhengig av e-post-preferansen over) alltid forsøkes for enhver
|
||||
abonnert enhet. Feiler aldri synlig for kalleren, se app/push.py."""
|
||||
try:
|
||||
await send_push_to_user(conn, user_id, _PUSH_TITLES.get(type, "TeeCup"), message, link_path)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class NotificationEmailPrefsOut(BaseModel):
|
||||
types: list[str]
|
||||
|
||||
|
|
@ -173,3 +198,73 @@ async def mark_all_read(user: CurrentUser = Depends(get_current_user)) -> dict[s
|
|||
user.user_id,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Push-varsler til telefonens OS (Web Push/VAPID, migrasjon 037). Offentlig
|
||||
# nøkkel er nettopp det -- offentlig, ingen auth nødvendig for GET-en (samme
|
||||
# begrunnelse som ethvert annet "her er min offentlige nøkkel"-endepunkt).
|
||||
# Abonner/avbryt krever derimot en ekte sesjon -- abonnementet er alltid
|
||||
# knyttet til AKKURAT den innloggede brukeren.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VapidPublicKeyOut(BaseModel):
|
||||
public_key: str | None
|
||||
configured: bool
|
||||
|
||||
|
||||
@router.get("/push/vapid-public-key", response_model=VapidPublicKeyOut)
|
||||
async def get_vapid_public_key() -> VapidPublicKeyOut:
|
||||
return VapidPublicKeyOut(public_key=settings.VAPID_PUBLIC_KEY, configured=settings.PUSH_CONFIGURED)
|
||||
|
||||
|
||||
class PushSubscriptionKeys(BaseModel):
|
||||
p256dh: str
|
||||
auth: str
|
||||
|
||||
|
||||
class PushSubscriptionCreate(BaseModel):
|
||||
endpoint: str
|
||||
keys: PushSubscriptionKeys
|
||||
|
||||
|
||||
@router.post("/push/subscribe")
|
||||
async def subscribe_push(
|
||||
body: PushSubscriptionCreate, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
if not settings.PUSH_CONFIGURED:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Push er ikke konfigurert på serveren.")
|
||||
async with plain_connection() as conn:
|
||||
# Samme endpoint kan i prinsippet re-abonneres (f.eks. nøklene
|
||||
# rotert av nettleseren) -- oppdater i stedet for å feile på
|
||||
# unik-constrainten.
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO push_subscription (user_id, endpoint, p256dh, auth)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, p256dh = $3, auth = $4
|
||||
""",
|
||||
user.user_id,
|
||||
body.endpoint,
|
||||
body.keys.p256dh,
|
||||
body.keys.auth,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class PushUnsubscribe(BaseModel):
|
||||
endpoint: str
|
||||
|
||||
|
||||
@router.delete("/push/subscribe")
|
||||
async def unsubscribe_push(
|
||||
body: PushUnsubscribe, user: CurrentUser = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
async with plain_connection() as conn:
|
||||
await conn.execute(
|
||||
"DELETE FROM push_subscription WHERE endpoint = $1 AND user_id = $2",
|
||||
body.endpoint,
|
||||
user.user_id,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ fremtidig turnering-kilde, ADR-038 Beslutning E).
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Literal
|
||||
|
||||
|
|
@ -634,6 +635,14 @@ class RoundCreate(BaseModel):
|
|||
# -- hvilke av EGNE private kategorier (se friends.py sin Category) som
|
||||
# får se runden. Tomt/utelatt for public/private.
|
||||
visible_categories: list[_FriendCategory] = []
|
||||
# Flere flighter i én "vanlig" runde (migrasjon 035) -- klient-generert
|
||||
# UUID som binder flere `round`-rader sammen. Utelatt/null for en vanlig
|
||||
# solo-/enkeltflight-runde. Klienten genererer denne selv (kun ETT sted:
|
||||
# round-detail.tsx sin "Legg til en flight til"), enten en FERSK verdi
|
||||
# (første gang en andre flight legges til en tidligere gruppeløs runde,
|
||||
# satt på DEN runden via PATCH FØRST) eller en allerede eksisterende
|
||||
# gruppes id (tredje+ flight). Se GET .../flight-group.
|
||||
flight_group_id: str | None = None
|
||||
|
||||
|
||||
class RoundParticipantOut(BaseModel):
|
||||
|
|
@ -714,6 +723,8 @@ class RoundOut(BaseModel):
|
|||
# medspiller-endepunktet (GET /rounds/{id}), ikke tredjepartsvisningen.
|
||||
visibility_mode: str
|
||||
visible_categories: list[str]
|
||||
# Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
|
||||
flight_group_id: str | None
|
||||
|
||||
|
||||
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||
|
|
@ -721,7 +732,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
"""
|
||||
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
|
||||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||
started_at, completed_at, visibility_mode
|
||||
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -794,6 +805,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
my_score_to_par=my_score_to_par,
|
||||
visibility_mode=round_row["visibility_mode"],
|
||||
visible_categories=[r["category"] for r in visible_category_rows],
|
||||
flight_group_id=round_row["flight_group_id"],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -890,6 +902,12 @@ async def create_round(
|
|||
elif body.skins_scoring is not None or body.skins_tie_handling is not None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "skins_scoring/skins_tie_handling gir kun mening for play_format='skins'.")
|
||||
|
||||
if body.flight_group_id is not None:
|
||||
try:
|
||||
uuid.UUID(body.flight_group_id)
|
||||
except ValueError:
|
||||
raise app_error(400, "VALIDATION_FAILED", "flight_group_id er ikke en gyldig UUID.")
|
||||
|
||||
async with plain_connection() as conn:
|
||||
owner_row = await conn.fetchrow(
|
||||
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
|
||||
|
|
@ -911,8 +929,8 @@ async def create_round(
|
|||
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||
start_hole, holes_planned, started_at, name, play_format,
|
||||
skins_scoring, skins_tie_handling, visibility_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
skins_scoring, skins_tie_handling, visibility_mode, flight_group_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
user.user_id,
|
||||
|
|
@ -931,6 +949,7 @@ async def create_round(
|
|||
body.skins_scoring,
|
||||
body.skins_tie_handling,
|
||||
body.visibility_mode,
|
||||
body.flight_group_id,
|
||||
)
|
||||
round_id = round_row["id"]
|
||||
|
||||
|
|
@ -1513,6 +1532,12 @@ class RoundUpdate(BaseModel):
|
|||
# dem.
|
||||
visibility_mode: Literal["public", "private", "friends"] | None = None
|
||||
visible_categories: list[_FriendCategory] | None = None
|
||||
# Flere flighter (migrasjon 035) -- SETTER gruppe-id (None = ikke rør,
|
||||
# samme konvensjon som resten av denne modellen). Kun brukt til å binde
|
||||
# en tidligere gruppeløs runde til en fersk gruppe idet en andre flight
|
||||
# legges til (round-detail.tsx). Ingen vei til å FJERNE en runde fra en
|
||||
# gruppe igjen i denne runden -- ikke etterspurt.
|
||||
flight_group_id: str | None = None
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||
|
|
@ -1536,6 +1561,12 @@ async def update_round(
|
|||
ren metadata som ALDRI påvirker HCP-beregningen, og kan derfor justeres
|
||||
uansett fullført-status.
|
||||
"""
|
||||
if body.flight_group_id is not None:
|
||||
try:
|
||||
uuid.UUID(body.flight_group_id)
|
||||
except ValueError:
|
||||
raise app_error(400, "VALIDATION_FAILED", "flight_group_id er ikke en gyldig UUID.")
|
||||
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow("SELECT started_at, completed_at FROM round WHERE id = $1", round_id)
|
||||
|
|
@ -1612,6 +1643,8 @@ async def update_round(
|
|||
await conn.execute("UPDATE round SET completed_at = $2 WHERE id = $1", round_id, body.completed_at)
|
||||
if body.visibility_mode is not None:
|
||||
await conn.execute("UPDATE round SET visibility_mode = $2 WHERE id = $1", round_id, body.visibility_mode)
|
||||
if body.flight_group_id is not None:
|
||||
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
|
||||
if body.visible_categories is not None:
|
||||
await conn.execute("DELETE FROM round_visible_category WHERE round_id = $1", round_id)
|
||||
for category in sorted(set(body.visible_categories)):
|
||||
|
|
@ -2257,6 +2290,11 @@ class RoundSideHoleOut(BaseModel):
|
|||
# strokes_received. None hvis siden ikke har en beregnet playing
|
||||
# handicap ennå (f.eks. kun én av to partnere tildelt).
|
||||
strokes_received: int | None = None
|
||||
# Hvilken av sidens partnere sitt utslag ble valgt (migrasjon 036) --
|
||||
# helt valgfritt, null = ikke registrert. Kun meningsfullt her (delt-
|
||||
# ball-hull) -- individuell ball spiller alltid egen ball, spørsmålet
|
||||
# gir ikke mening for RoundHoleOut.
|
||||
selected_participant_id: str | None = None
|
||||
|
||||
|
||||
async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSideHoleOut]:
|
||||
|
|
@ -2266,8 +2304,8 @@ async def _build_side_holes(conn, round_id: str, side_id: str) -> list[RoundSide
|
|||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
rows = await conn.fetch(
|
||||
"SELECT hole_number, par, stroke_index, played, score FROM round_hole "
|
||||
"WHERE round_side_id = $1 ORDER BY hole_number",
|
||||
"SELECT hole_number, par, stroke_index, played, score, selected_participant_id::text AS selected_participant_id "
|
||||
"FROM round_hole WHERE round_side_id = $1 ORDER BY hole_number",
|
||||
side_id,
|
||||
)
|
||||
playing_handicap = await conn.fetchval(
|
||||
|
|
@ -2302,6 +2340,11 @@ async def list_side_holes(
|
|||
class SideHoleUpdate(BaseModel):
|
||||
played: bool = True
|
||||
score: int | None = Field(default=None, ge=1, le=20)
|
||||
# Hvem sitt utslag ble valgt (migrasjon 036) -- helt valgfritt, samme
|
||||
# "full overwrite hver gang"-kontrakt som played/score over (klienten
|
||||
# sender alltid gjeldende verdi, null = ikke registrert -- IKKE en
|
||||
# delvis PATCH). Validert til å tilhøre nøyaktig DENNE siden under.
|
||||
selected_participant_id: str | None = None
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut)
|
||||
|
|
@ -2319,13 +2362,23 @@ async def update_side_hole(
|
|||
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
if body.selected_participant_id is not None:
|
||||
belongs = await conn.fetchval(
|
||||
"SELECT 1 FROM round_participant WHERE id = $1 AND round_side_id = $2",
|
||||
body.selected_participant_id, side_id,
|
||||
)
|
||||
if not belongs:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne siden."
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE round_hole SET played = $3, score = $4
|
||||
UPDATE round_hole SET played = $3, score = $4, selected_participant_id = $5
|
||||
WHERE round_side_id = $1 AND hole_number = $2
|
||||
RETURNING hole_number, par, stroke_index, played, score
|
||||
RETURNING hole_number, par, stroke_index, played, score,
|
||||
selected_participant_id::text AS selected_participant_id
|
||||
""",
|
||||
side_id, hole_number, body.played, body.score,
|
||||
side_id, hole_number, body.played, body.score, body.selected_participant_id,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
|
||||
|
|
@ -2814,6 +2867,146 @@ async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current
|
|||
return await _build_leaderboard(conn, round_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flere flighter i én "vanlig" runde (migrasjon 035) -- løs gruppering av
|
||||
# separate `round`-rader, se moduldoc/FEATURE_BACKLOG.md. Bevisst egen,
|
||||
# smal autorisasjon: kun eier-eller-deltaker (samme regel som resten av
|
||||
# denne filens autentiserte lese-endepunkter) på DEN ANKER-runden anropet
|
||||
# gjelder -- søsken-runder man selv ikke har tilgang til inkluderes ALDRI
|
||||
# i svaret (verken i listen eller det slåtte leaderboardet), samme
|
||||
# "trygg standard: se ingenting" som resten av rundevisibilitet.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FlightSummaryOut(BaseModel):
|
||||
round_id: str
|
||||
name: str | None
|
||||
course_name_snapshot: str
|
||||
played_at: date
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
participant_count: int
|
||||
is_anchor: bool
|
||||
|
||||
|
||||
class FlightGroupOut(BaseModel):
|
||||
flight_group_id: str | None
|
||||
flights: list[FlightSummaryOut]
|
||||
|
||||
|
||||
async def _accessible_flight_rows(conn, round_id: str, user_id: str):
|
||||
"""Anker-runden (tilgang allerede sjekket av kalleren) + ALLE søsken i
|
||||
samme flight_group_id som brukeren selv eier eller er lenket deltaker
|
||||
på -- 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 på tvers av ulike baner/hullantall."""
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
group_id, rows = await _accessible_flight_rows(conn, round_id, user.user_id)
|
||||
flights: list[FlightSummaryOut] = []
|
||||
entries: list[FlightGroupLeaderboardEntryOut] = []
|
||||
for idx, row in enumerate(rows):
|
||||
participant_count = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
|
||||
)
|
||||
label = row["name"] or f"Flight {idx + 1}"
|
||||
flights.append(
|
||||
FlightSummaryOut(
|
||||
round_id=row["id"],
|
||||
name=row["name"],
|
||||
course_name_snapshot=row["course_name_snapshot"],
|
||||
played_at=row["played_at"],
|
||||
started_at=row["started_at"].isoformat() if row["started_at"] else None,
|
||||
completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
|
||||
participant_count=participant_count,
|
||||
is_anchor=row["id"] == round_id,
|
||||
)
|
||||
)
|
||||
sub_board = await _build_leaderboard(conn, row["id"])
|
||||
for entry in sub_board.entries:
|
||||
entries.append(
|
||||
FlightGroupLeaderboardEntryOut(round_id=row["id"], flight_label=label, **entry.model_dump())
|
||||
)
|
||||
|
||||
entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par))
|
||||
return FlightGroupLeaderboardOut(flight_group_id=group_id, flights=flights, entries=entries)
|
||||
|
||||
|
||||
class HoleUpdate(BaseModel):
|
||||
played: bool = True
|
||||
score: int | None = Field(default=None, ge=1, le=20)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ services:
|
|||
TEECUP_MINIO_ROOT_USER: ${TEECUP_MINIO_ROOT_USER}
|
||||
TEECUP_MINIO_ROOT_PASSWORD: ${TEECUP_MINIO_ROOT_PASSWORD}
|
||||
TEECUP_MINIO_BUCKET: ${TEECUP_MINIO_BUCKET}
|
||||
TEECUP_VAPID_PRIVATE_KEY: ${TEECUP_VAPID_PRIVATE_KEY}
|
||||
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
|
||||
networks:
|
||||
- teeoff_default
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
6
frontend/app/my-rounds/[id]/flights/page.tsx
Normal file
6
frontend/app/my-rounds/[id]/flights/page.tsx
Normal 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} />
|
||||
}
|
||||
|
|
@ -1,5 +1,19 @@
|
|||
import { Suspense } from "react"
|
||||
import { NewRound } from "@/components/new-round"
|
||||
|
||||
export default function NewRoundPage() {
|
||||
return <NewRound />
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NewRound />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useRouter } from "next/navigation"
|
|||
import {
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
BellRing,
|
||||
Camera,
|
||||
KeyRound,
|
||||
Lock,
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
X,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getExistingSubscription, isPushSupported, subscribeToPush, unsubscribeFromPush } from "@/lib/push-subscribe"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
|
|
@ -280,6 +282,8 @@ export function AccountSettings() {
|
|||
|
||||
<NotificationEmailPrefsSection />
|
||||
|
||||
<PushNotificationSection />
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
|
|
@ -1330,6 +1334,97 @@ const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: st
|
|||
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
|
||||
]
|
||||
|
||||
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Egen seksjon
|
||||
// ved siden av e-post-varianten over -- selve abonnementet ER samtykket her
|
||||
// (ulikt e-post, som trenger en egen per-type opt-in), så dette er kun en
|
||||
// enkel av/på-bryter for DENNE enheten/nettleseren.
|
||||
function PushNotificationSection() {
|
||||
const [supported, setSupported] = useState<boolean | null>(null)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
const [isIOS, setIsIOS] = useState(false)
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(isPushSupported())
|
||||
setIsIOS(/iphone|ipad|ipod/i.test(window.navigator.userAgent))
|
||||
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches)
|
||||
void getExistingSubscription().then((sub) => setSubscribed(sub !== null))
|
||||
}, [])
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
if (subscribed) {
|
||||
await unsubscribeFromPush()
|
||||
setSubscribed(false)
|
||||
setMessage("Push-varsler er slått av på denne enheten.")
|
||||
} else {
|
||||
const result = await subscribeToPush()
|
||||
if (result.ok) {
|
||||
setSubscribed(true)
|
||||
setMessage("Push-varsler er slått på for denne enheten.")
|
||||
} else if (result.reason === "denied") {
|
||||
setMessage("Nettleseren din blokkerer varsler -- gi TeeCup tillatelse i nettleserinnstillingene.")
|
||||
} else if (result.reason === "not-configured") {
|
||||
setMessage("Push er ikke satt opp på serveren ennå.")
|
||||
} else {
|
||||
setMessage("Klarte ikke å slå på push-varsler. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<BellRing aria-hidden="true" className="size-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Push-varsler på denne enheten</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Få 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">
|
||||
På iPhone/iPad må TeeCup først legges til på hjemskjermen (Del-ikonet i Safari → «Legg til på
|
||||
Hjem-skjerm») før push-varsler kan slås på.
|
||||
</p>
|
||||
)}
|
||||
{supported === false && !isIOS && (
|
||||
<p className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
Nettleseren din støtter ikke push-varsler.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supported && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
disabled={busy}
|
||||
className={`inline-flex min-h-11 w-fit items-center gap-2 rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60 ${
|
||||
subscribed
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<BellRing aria-hidden="true" className="size-4" />
|
||||
{busy ? "Vent…" : subscribed ? "Push-varsler er PÅ -- slå av" : "Slå på push-varsler"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{message && <p className="text-sm text-muted-foreground">{message}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationEmailPrefsSection() {
|
||||
const [types, setTypes] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Wordmark } from "@/components/wordmark"
|
||||
import { InstallPrompt } from "@/components/install-prompt"
|
||||
import { RoundCard, type Round } from "@/components/round-card"
|
||||
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
||||
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
||||
|
|
@ -407,6 +408,10 @@ export function Dashboard() {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<InstallPrompt />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-8">
|
||||
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
|
||||
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
|
||||
|
|
|
|||
276
frontend/components/flight-group-leaderboard.tsx
Normal file
276
frontend/components/flight-group-leaderboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
frontend/components/install-prompt.tsx
Normal file
150
frontend/components/install-prompt.tsx
Normal 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 på 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 på 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 nå
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import type React from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowLeft,
|
||||
|
|
@ -138,6 +138,16 @@ function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
|
|||
|
||||
export function NewRound() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Flere flighter i én "vanlig" runde (migrasjon 035) -- kommer fra
|
||||
// round-detail.tsx sin "Legg til en flight til"-handling. Rene primitiver
|
||||
// i URL-en (ikke et bane-oppslag) -- brukeren velger selv bane på nytt
|
||||
// for hver flight, men slipper å taste dato/starthull/spilleform igjen.
|
||||
const flightGroupId = searchParams.get("flightGroupId")
|
||||
const prefillPlayedAt = searchParams.get("playedAt") ?? undefined
|
||||
const prefillStartHole = searchParams.get("startHole")
|
||||
const prefillHoles = searchParams.get("holes")
|
||||
const prefillPlayFormat = searchParams.get("playFormat") as PlayFormat | null
|
||||
const [step, setStep] = useState<Step>("source")
|
||||
|
||||
const [ownGender, setOwnGender] = useState<Gender | null>(null)
|
||||
|
|
@ -260,6 +270,7 @@ export function NewRound() {
|
|||
skins_tie_handling: payload.skinsTieHandling,
|
||||
visibility_mode: payload.visibilityMode,
|
||||
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
|
||||
flight_group_id: flightGroupId || undefined,
|
||||
}
|
||||
const body =
|
||||
courseMeta.source === "teeoff"
|
||||
|
|
@ -335,6 +346,11 @@ export function NewRound() {
|
|||
ownGender={ownGender}
|
||||
onSubmit={submitRound}
|
||||
onCreated={(id) => router.replace(`/my-rounds/${id}`)}
|
||||
isAdditionalFlight={Boolean(flightGroupId)}
|
||||
defaultDate={prefillPlayedAt}
|
||||
defaultStartHole={prefillStartHole ?? undefined}
|
||||
defaultHoles={prefillHoles === "9" ? 9 : prefillHoles === "18" ? 18 : undefined}
|
||||
defaultPlayFormat={prefillPlayFormat ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -1148,6 +1164,11 @@ function ConfirmStep({
|
|||
ownGender,
|
||||
onSubmit,
|
||||
onCreated,
|
||||
isAdditionalFlight = false,
|
||||
defaultDate,
|
||||
defaultStartHole,
|
||||
defaultHoles,
|
||||
defaultPlayFormat,
|
||||
}: {
|
||||
course: Course
|
||||
ownGender: Gender
|
||||
|
|
@ -1167,16 +1188,23 @@ function ConfirmStep({
|
|||
visibleCategories: string[]
|
||||
}) => Promise<{ id: string }>
|
||||
onCreated: (roundId: string) => void
|
||||
// Flere flighter (migrasjon 035) -- satt av round-detail.tsx sin
|
||||
// "Legg til en flight til" via URL-parametre, se NewRound() over.
|
||||
isAdditionalFlight?: boolean
|
||||
defaultDate?: string
|
||||
defaultStartHole?: string
|
||||
defaultHoles?: 9 | 18
|
||||
defaultPlayFormat?: PlayFormat
|
||||
}) {
|
||||
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
|
||||
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
|
||||
const [name, setName] = useState("")
|
||||
const [date, setDate] = useState(todayIso)
|
||||
const [date, setDate] = useState(defaultDate ?? todayIso)
|
||||
const [teeTime, setTeeTime] = useState("")
|
||||
const [startHole, setStartHole] = useState("1")
|
||||
const [holes, setHoles] = useState<9 | 18>(18)
|
||||
const [startHole, setStartHole] = useState(defaultStartHole ?? "1")
|
||||
const [holes, setHoles] = useState<9 | 18>(defaultHoles ?? 18)
|
||||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||||
const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke")
|
||||
const [playFormat, setPlayFormat] = useState<PlayFormat>(defaultPlayFormat ?? "stroke")
|
||||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
|
||||
// ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for
|
||||
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
||||
|
|
@ -1238,6 +1266,13 @@ function ConfirmStep({
|
|||
<div className="flex flex-col gap-8">
|
||||
<StepHeader title="Bekreft og fullfør" description="Sjekk detaljene før du starter runden." />
|
||||
|
||||
{isAdditionalFlight && (
|
||||
<p className="rounded-2xl border border-dashed border-primary/40 bg-primary/5 px-4 py-3 text-sm font-semibold text-foreground">
|
||||
Du setter opp enda en flight i samme oppsett -- dato, starthull, hullantall og spilleform er
|
||||
forhåndsutfylt fra den første flighten, men kan endres.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Course */}
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm shadow-black/5">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
Settings2,
|
||||
Target,
|
||||
Trophy,
|
||||
Users,
|
||||
WifiOff,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
|
@ -222,6 +223,8 @@ type ApiRound = {
|
|||
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
|
||||
visibility_mode: string
|
||||
visible_categories: string[]
|
||||
// Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
|
||||
flight_group_id: string | null
|
||||
}
|
||||
|
||||
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
|
||||
|
|
@ -312,6 +315,9 @@ type ApiSideHole = {
|
|||
stroke_index: number
|
||||
played: boolean
|
||||
score: number | null
|
||||
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
|
||||
// (migrasjon 036) -- helt valgfritt, null = ikke registrert.
|
||||
selected_participant_id: string | null
|
||||
}
|
||||
|
||||
function apiHoleToStat(h: ApiHole): HoleStat {
|
||||
|
|
@ -542,7 +548,11 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
}))
|
||||
}
|
||||
|
||||
async function queueSideHoleWrite(sideId: string, holeNumber: number, body: { played: boolean; score: number | null }) {
|
||||
async function queueSideHoleWrite(
|
||||
sideId: string,
|
||||
holeNumber: number,
|
||||
body: { played: boolean; score: number | null; selected_participant_id: string | null },
|
||||
) {
|
||||
const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
|
||||
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
|
||||
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
|
||||
|
|
@ -799,9 +809,14 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
return null
|
||||
})()
|
||||
|
||||
async function updateWizardSideScore(score: number | null) {
|
||||
// Begge feltene (score OG selected_participant_id) sendes ALLTID sammen
|
||||
// (samme "full overwrite hvert kall"-kontrakt som played/score alltid har
|
||||
// hatt) -- ellers ville en score-oppdatering stille nullstilt en allerede
|
||||
// registrert utslags-valg (migrasjon 036), siden backend ikke gjør en
|
||||
// delvis PATCH her.
|
||||
async function patchSideHole(score: number | null, selectedParticipantId: string | null) {
|
||||
if (readOnly || !wizardSideId) return
|
||||
const body = { played: true, score }
|
||||
const body = { played: true, score, selected_participant_id: selectedParticipantId }
|
||||
if (!navigator.onLine) {
|
||||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||||
return
|
||||
|
|
@ -827,6 +842,17 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
setFormatResultRefreshTick((n) => n + 1)
|
||||
}
|
||||
|
||||
function updateWizardSideScore(score: number | null) {
|
||||
void patchSideHole(score, wizardSideHole?.selected_participant_id ?? null)
|
||||
}
|
||||
|
||||
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036) -- helt
|
||||
// valgfritt, egen handling fra selve slagregistreringen slik at man kan
|
||||
// sette/endre valget uten å røre et allerede registrert slagtall.
|
||||
function updateWizardSideSelection(participantId: string | null) {
|
||||
void patchSideHole(wizardSideHole?.score ?? null, participantId)
|
||||
}
|
||||
|
||||
function advanceWizardSide() {
|
||||
if (!round) return
|
||||
const idx = round.sides.findIndex((s) => s.id === wizardSideId)
|
||||
|
|
@ -1110,6 +1136,13 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
(ADR-039) -- avledet ved lesing, aldri lagret. */}
|
||||
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} />
|
||||
|
||||
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
|
||||
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
|
||||
Eieren tar ansvar for å sette opp ALLE flightene i én
|
||||
handling ("+ Legg til en flight til" -> gjenbruker hele
|
||||
opprett-runde-flyten, forhåndsutfylt fra denne runden). */}
|
||||
<FlightGroupPanel roundId={roundId} round={round} isOwnerViewer={isOwnerViewer} />
|
||||
|
||||
<div className="mb-5 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isOwnerViewer && (
|
||||
|
|
@ -1325,9 +1358,12 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
|||
<SideScoreWizard
|
||||
sides={round.sides}
|
||||
side={wizardSide}
|
||||
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
|
||||
hole={sharedHole}
|
||||
score={wizardSideHole?.played ? wizardSideHole.score : null}
|
||||
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
|
||||
onSave={updateWizardSideScore}
|
||||
onSelectParticipant={updateWizardSideSelection}
|
||||
onClose={() => setWizardSideId(null)}
|
||||
onAdvance={advanceWizardSide}
|
||||
readOnly={readOnly}
|
||||
|
|
@ -2231,6 +2267,143 @@ function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
|||
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
||||
}
|
||||
|
||||
type ApiFlightSummary = {
|
||||
round_id: string
|
||||
name: string | null
|
||||
course_name_snapshot: string
|
||||
played_at: string
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
participant_count: number
|
||||
is_anchor: boolean
|
||||
}
|
||||
|
||||
// Flere flighter i én "vanlig" runde (migrasjon 035, retning 1 -- løs
|
||||
// gruppering av separate `round`-rader, se FEATURE_BACKLOG.md). "+ Legg
|
||||
// til en flight til" gjenbruker HELE eksisterende opprett-runde-flyten
|
||||
// (/my-rounds/new) i stedet for et eget skjema -- forhåndsutfylt med denne
|
||||
// flightens dato/starthull/hullantall/spilleform via URL-parametre, men
|
||||
// banen velges på nytt (samme lave friksjon som normal bane-søk ellers).
|
||||
function FlightGroupPanel({
|
||||
roundId,
|
||||
round,
|
||||
isOwnerViewer,
|
||||
}: {
|
||||
roundId: string
|
||||
round: ApiRound
|
||||
isOwnerViewer: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [flights, setFlights] = useState<ApiFlightSummary[] | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null) => {
|
||||
if (!cancelled) setFlights(data?.flights ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFlights(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [roundId, round.flight_group_id])
|
||||
|
||||
async function addFlight() {
|
||||
if (!isOwnerViewer) return
|
||||
setAdding(true)
|
||||
try {
|
||||
let groupId = round.flight_group_id
|
||||
if (!groupId) {
|
||||
groupId = crypto.randomUUID()
|
||||
await fetch(`/rounds/${roundId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ flight_group_id: groupId }),
|
||||
})
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
flightGroupId: groupId,
|
||||
playedAt: round.played_at,
|
||||
startHole: String(round.start_hole),
|
||||
holes: String(round.holes_planned),
|
||||
playFormat: round.play_format,
|
||||
})
|
||||
router.push(`/my-rounds/new?${params.toString()}`)
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const others = (flights ?? []).filter((f) => f.round_id !== roundId)
|
||||
|
||||
return (
|
||||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
|
||||
<Users aria-hidden="true" className="size-4 text-primary" />
|
||||
Flighter
|
||||
</span>
|
||||
{isOwnerViewer && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFlight}
|
||||
disabled={adding}
|
||||
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
{adding ? "Setter opp…" : "Legg til en flight til"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{others.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kun denne flighten er satt opp. Legg til en flight til hvis flere spiller sammen med dere samme dag --
|
||||
de deler da leaderboard med denne flighten.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{(flights ?? []).map((f) => (
|
||||
<li key={f.round_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||||
{f.round_id === roundId ? (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{f.name || f.course_name_snapshot}{" "}
|
||||
<span className="font-normal text-muted-foreground">(denne)</span>
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={`/my-rounds/${f.round_id}`}
|
||||
className="text-sm font-semibold text-primary hover:underline"
|
||||
>
|
||||
{f.name || f.course_name_snapshot}
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||
{f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
|
||||
{f.completed_at ? " · Ferdig" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={`/my-rounds/${roundId}/flights`}
|
||||
className="inline-flex min-h-11 items-center gap-1.5 self-start text-sm font-bold text-primary hover:underline"
|
||||
>
|
||||
Se samlet leaderboard for flightene
|
||||
<ArrowRight aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormatResultPanel({
|
||||
roundId,
|
||||
round,
|
||||
|
|
@ -4068,9 +4241,12 @@ function SideScorecardGrid({
|
|||
function SideScoreWizard({
|
||||
sides,
|
||||
side,
|
||||
sideParticipants,
|
||||
hole,
|
||||
score,
|
||||
selectedParticipantId,
|
||||
onSave,
|
||||
onSelectParticipant,
|
||||
onClose,
|
||||
onAdvance,
|
||||
readOnly,
|
||||
|
|
@ -4078,9 +4254,13 @@ function SideScoreWizard({
|
|||
}: {
|
||||
sides: ApiSide[]
|
||||
side: ApiSide
|
||||
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036).
|
||||
sideParticipants: Player[]
|
||||
hole: Hole
|
||||
score: number | null
|
||||
selectedParticipantId: string | null
|
||||
onSave: (score: number | null) => void
|
||||
onSelectParticipant: (participantId: string | null) => void
|
||||
onClose: () => void
|
||||
onAdvance: () => void
|
||||
readOnly: boolean
|
||||
|
|
@ -4097,8 +4277,11 @@ function SideScoreWizard({
|
|||
}
|
||||
|
||||
// Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer
|
||||
// slagtall for siden, hopp umiddelbart videre til neste side/hull. Kun
|
||||
// ETT felt her, så ingen splitt-i-flere-steg-problematikk.
|
||||
// slagtall for siden, hopp umiddelbart videre til neste side/hull. KUN
|
||||
// når det ikke finnes noe utslags-valg å ta stilling til også (migrasjon
|
||||
// 036) -- ellers ville auto-hoppet revet brukeren videre FØR de rakk å
|
||||
// velge hvem sitt utslag som ble brukt (det valgfrie feltet under).
|
||||
const autoAdvance = sideParticipants.length === 0
|
||||
const enteredWithValueRef = useRef(score !== null)
|
||||
const firedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
|
|
@ -4107,13 +4290,13 @@ function SideScoreWizard({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [side.id, hole.holeNumber])
|
||||
useEffect(() => {
|
||||
if (firedRef.current || enteredWithValueRef.current) return
|
||||
if (!autoAdvance || firedRef.current || enteredWithValueRef.current) return
|
||||
if (score !== null) {
|
||||
firedRef.current = true
|
||||
onAdvance()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [score])
|
||||
}, [score, autoAdvance])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
|
|
@ -4170,6 +4353,37 @@ function SideScoreWizard({
|
|||
onChange={(v) => onSave(v)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036,
|
||||
FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per
|
||||
spiller"). Helt valgfritt -- egen handling, rører aldri
|
||||
slagtallet. */}
|
||||
{sideParticipants.length > 0 && (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-1 text-sm font-bold text-foreground">
|
||||
Hvem sitt utslag ble brukt? <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sideParticipants.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
disabled={readOnly}
|
||||
onClick={() => onSelectParticipant(selectedParticipantId === p.id ? null : p.id)}
|
||||
aria-pressed={selectedParticipantId === p.id}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60",
|
||||
selectedParticipantId === p.id
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -361,6 +361,13 @@ type ApiParticipant = {
|
|||
guest_name: string | null
|
||||
display_name: string
|
||||
is_owner: boolean
|
||||
// ADR-039 -- hvilken side deltakeren er på, null for slagspill/skins.
|
||||
round_side_id: string | null
|
||||
}
|
||||
|
||||
type ApiSide = {
|
||||
id: string
|
||||
label: string | null
|
||||
}
|
||||
|
||||
type ApiRound = {
|
||||
|
|
@ -373,6 +380,17 @@ type ApiRound = {
|
|||
holes_planned: number
|
||||
play_format: string
|
||||
participants: ApiParticipant[]
|
||||
sides: ApiSide[]
|
||||
}
|
||||
|
||||
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
|
||||
// (migrasjon 036, FEATURE_BACKLOG.md "Scramble: statistikk over utslag
|
||||
// brukt per spiller"). Samme rad-form som round-detail.tsx sin ApiSideHole.
|
||||
type ApiSideHole = {
|
||||
hole_number: number
|
||||
played: boolean
|
||||
score: number | null
|
||||
selected_participant_id: string | null
|
||||
}
|
||||
|
||||
// Delt-ball-formater (foursome/greensome/scramble, ADR-039 Beslutning C)
|
||||
|
|
@ -583,6 +601,69 @@ function participantLabel(p: ApiParticipant, viewerId: string | null): string {
|
|||
|
||||
// --- Screen ----------------------------------------------------------------
|
||||
|
||||
// Scramble/greensome: "utslag brukt" per spiller (migrasjon 036,
|
||||
// FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per spiller").
|
||||
// Ren klientside-telling fra allerede hentet round_hole-data (samme
|
||||
// "regn ut ved lesing, ikke lagre et eget tall"-prinsipp som resten av
|
||||
// denne siden) -- vises kun de hullene som faktisk har et registrert valg,
|
||||
// ingen antakelse om at ALLE hull må ha ett (helt valgfritt å registrere).
|
||||
function SelectedDriverSummary({ round, sideHoles }: { round: ApiRound; sideHoles: Record<string, ApiSideHole[]> }) {
|
||||
const rows = round.sides.map((side) => {
|
||||
const holes = sideHoles[side.id] ?? []
|
||||
const holesWithSelection = holes.filter((h) => h.selected_participant_id !== null)
|
||||
const counts = new Map<string, number>()
|
||||
for (const h of holesWithSelection) {
|
||||
const pid = h.selected_participant_id as string
|
||||
counts.set(pid, (counts.get(pid) ?? 0) + 1)
|
||||
}
|
||||
const sideParticipants = round.participants.filter((p) => p.round_side_id === side.id)
|
||||
const playedCount = holes.filter((h) => h.played && h.score !== null).length
|
||||
return {
|
||||
side,
|
||||
playedCount,
|
||||
registeredCount: holesWithSelection.length,
|
||||
entries: sideParticipants
|
||||
.map((p) => ({ name: p.display_name, count: counts.get(p.id) ?? 0 }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
}
|
||||
})
|
||||
|
||||
if (rows.every((r) => r.registeredCount === 0)) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
Ingen har registrert hvem sitt utslag som ble brukt på 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 på {registeredCount} av {playedCount} spilte hull.
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RoundStats({ roundId }: { roundId: string }) {
|
||||
const [round, setRound] = useState<ApiRound | null>(null)
|
||||
const [participantId, setParticipantId] = useState<string | null>(null)
|
||||
|
|
@ -645,6 +726,29 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
}
|
||||
}, [roundId, viewerId, refreshKey])
|
||||
|
||||
// Scramble/greensome: hent hver sides hull for "Utslag brukt"-sammendraget
|
||||
// under (migrasjon 036) -- samme endepunkt round-detail.tsx allerede bruker
|
||||
// for selve registreringen.
|
||||
const [sideHoles, setSideHoles] = useState<Record<string, ApiSideHole[]>>({})
|
||||
useEffect(() => {
|
||||
if (!round || !SHARED_BALL_FORMATS.includes(round.play_format)) return
|
||||
let cancelled = false
|
||||
async function loadSideHoles() {
|
||||
const entries = await Promise.all(
|
||||
round!.sides.map(async (s) => {
|
||||
const res = await fetch(`/rounds/${roundId}/sides/${s.id}/holes`, { credentials: "include" })
|
||||
const data: ApiSideHole[] = res.ok ? await res.json() : []
|
||||
return [s.id, data] as const
|
||||
}),
|
||||
)
|
||||
if (!cancelled) setSideHoles(Object.fromEntries(entries))
|
||||
}
|
||||
void loadSideHoles()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [round, roundId, refreshKey])
|
||||
|
||||
useEffect(() => {
|
||||
// Delt-ball-formater har ingen individuell round_hole-rad å hente --
|
||||
// se guarden under, som viser en forklarende melding i stedet.
|
||||
|
|
@ -696,6 +800,8 @@ export function RoundStats({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</header>
|
||||
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5">
|
||||
<SelectedDriverSummary round={round} sideHoles={sideHoles} />
|
||||
|
||||
<div className="rounded-3xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||
Detaljert statistikk (fairwaytreff, GIR, putt-fordeling m.m.) er ikke tilgjengelig for{" "}
|
||||
{FORMAT_LABELS[round.play_format] ?? round.play_format} -- disse formatene registrerer kun ETT felles
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { initInstallPromptCapture } from "@/lib/pwa-install"
|
||||
|
||||
// Registrerer service workeren (ADR-028). Stille no-op i nettlesere uten
|
||||
// støtte -- appen skal fungere helt uendret uten SW, bare uten
|
||||
// installasjon/offline-cache.
|
||||
//
|
||||
// Fanger i tillegg "beforeinstallprompt" så tidlig som mulig (samme
|
||||
// mountpunkt på ALLE sider, se lib/pwa-install.ts) -- eventet fyres kun én
|
||||
// gang per side-liv, og InstallPrompt-komponenten på dashbordet kan mountes
|
||||
// for sent til å fange det selv.
|
||||
export function SwRegister() {
|
||||
useEffect(() => {
|
||||
initInstallPromptCapture()
|
||||
if (!("serviceWorker" in navigator)) return
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => {})
|
||||
}, [])
|
||||
|
|
|
|||
80
frontend/lib/push-subscribe.ts
Normal file
80
frontend/lib/push-subscribe.ts
Normal 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(() => {})
|
||||
}
|
||||
48
frontend/lib/pwa-install.ts
Normal file
48
frontend/lib/pwa-install.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,10 @@ const nextConfig = {
|
|||
// /my-notifications, ALDRI /notifications.
|
||||
{ source: "/notifications/:path*", destination: `${API_ORIGIN}/notifications/:path*` },
|
||||
{ source: "/notifications", destination: `${API_ORIGIN}/notifications` },
|
||||
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28) -- ingen
|
||||
// frontend-side bruker dette prefikset, kun fetch() fra /account.
|
||||
{ source: "/push/:path*", destination: `${API_ORIGIN}/push/:path*` },
|
||||
{ source: "/push", destination: `${API_ORIGIN}/push` },
|
||||
{ source: "/health", destination: `${API_ORIGIN}/health` },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue