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