From 9987c93e18ca34ac7aaa9ac252838062f7c382d8 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 26 Jul 2026 06:53:58 +0200 Subject: [PATCH] =?UTF-8?q?Varsler=20(fra=20zip=2020):=20in-app=20varsling?= =?UTF-8?q?ssenter=20er=20live=20=E2=80=94=20bjelle=20med=20uleste-tall=20?= =?UTF-8?q?i=20dashbord-headeren,=20/my-notifications-side.=20Trigges=20i?= =?UTF-8?q?=20dag=20ved=20venneforesp=C3=B8rsel=20sendt/akseptert;=20flere?= =?UTF-8?q?=20hendelser=20kan=20kobles=20p=C3=A5=20senere.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rundeleaderboard: ny GET /rounds/{id}/leaderboard-backend er live (rangering, thru-tall, brutto+netto til par, håndterer 1 til 15+ deltakere). V0-prompten for selve visningen ligger i FEATURE_BACKLOG.md, klar til å limes inn i v0.app — send meg zip-en når du har den, så kobler jeg den på (foreslått rute /my-rounds/[id]/leaderboard, lenket fra rundesiden). Begge deler scratch-verifisert (39/39 sjekker, inkl. en uavhengig kryssjekk av netto-beregningen mot handicap_engine direkte), rullet ut mot ekte teecup_db/containere, teeoff.no upåvirket. --- .claude/settings.json | 14 +- 026_notifications.sql | 23 ++ CLAUDE.md | 112 +++++++- FEATURE_BACKLOG.md | 349 ++++++++++++++++++++++++- app/main.py | 2 + app/routers/friends.py | 18 ++ app/routers/notifications.py | 99 +++++++ app/routers/rounds.py | 81 ++++++ frontend/app/my-notifications/page.tsx | 5 + frontend/components/dashboard.tsx | 35 +++ frontend/components/notifications.tsx | 264 +++++++++++++++++++ frontend/next.config.mjs | 5 + 12 files changed, 993 insertions(+), 14 deletions(-) create mode 100644 026_notifications.sql create mode 100644 app/routers/notifications.py create mode 100644 frontend/app/my-notifications/page.tsx create mode 100644 frontend/components/notifications.tsx diff --git a/.claude/settings.json b/.claude/settings.json index 7cf6d0b..036e1e8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,7 +15,19 @@ "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip19)", "Bash(python3 -m zipfile -e \"/opt/teecup/tee-cup-login-screen \\(19\\).zip\" .)", "Bash(curl -s -o /dev/null -w \"my-friends: %{http_code}\\\\n\" https://teecup.teeoff.no/my-friends)", - "Bash(python3 test_display_name.py)" + "Bash(python3 test_display_name.py)", + "Bash(xargs -I{} ls -la {})", + "Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip20)", + "Bash(unzip -o \"/opt/teecup/tee-cup-login-screen \\(20\\).zip\")", + "Bash(python3 -m zipfile -e \"/opt/teecup/tee-cup-login-screen \\(20\\).zip\" .)", + "Bash(sort -t/ -k1)", + "Bash(python3 test_notifications_leaderboard.py)", + "Bash(python3 test_leaderboard_net.py)", + "Bash(curl -s -o /dev/null -w \"https://teecup.teeoff.no/health -> %{http_code}\\\\n\" https://teecup.teeoff.no/health)", + "Bash(curl -s -o /dev/null -w \"https://teecup.teeoff.no/dashboard -> %{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)", + "Bash(curl -s -o /dev/null -w \"https://teecup.teeoff.no/my-notifications -> %{http_code}\\\\n\" https://teecup.teeoff.no/my-notifications)", + "Bash(curl -s -o /dev/null -w \"https://teeoff.no/ -> %{http_code}\\\\n\" https://teeoff.no/)", + "Bash(curl -s https://teecup.teeoff.no/notifications)" ] } } diff --git a/026_notifications.sql b/026_notifications.sql new file mode 100644 index 0000000..9085f69 --- /dev/null +++ b/026_notifications.sql @@ -0,0 +1,23 @@ +-- In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25). +-- Eid av BRUKER (mottaker), ikke organisasjon -- INGEN RLS, samme +-- plain_connection()-mønster som personlig profil/HCP-historikk/venner +-- (ADR-033 Beslutning A / ADR-036 Beslutning A). `message` er en FERDIG +-- norsk tekst, snapshot-prinsipp (samme som author_display_name i +-- meldinger, ADR-025) -- unngår å måtte slå opp relaterte data på nytt ved +-- hver lesing, og overlever at den relaterte raden (f.eks. en venneforespørsel) +-- senere slettes. + +CREATE TABLE notification ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + type text NOT NULL CHECK (type IN ('friend', 'tournament', 'round', 'result')), + message text NOT NULL, + link_path text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + read_at timestamptz +); + +CREATE INDEX notification_user_created_idx ON notification (user_id, created_at DESC); +CREATE INDEX notification_user_unread_idx ON notification (user_id) WHERE read_at IS NULL; + +GRANT SELECT, INSERT, UPDATE, DELETE ON notification TO teecup_app; diff --git a/CLAUDE.md b/CLAUDE.md index 0a64d66..81cae85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2767,16 +2767,110 @@ Ferdig og verifisert: i FEATURE_BACKLOG.md, ikke sendt ennå. Ingen kode skrevet — ren design-/dokumentasjonsrunde. +- **Oppfølging samme dag: e-post-fallback for varsler + PWA- + installasjon vurdert, IKKE bygget.** Varsel-V0-prompten sendt til + bruker. Bruker foreslo en betinget e-post-fallback ved venneforespørsel + (kun hvis mottaker har samtykket til e-post fra TeeCup) — sjekket at + INGEN generell kommunikasjons-samtykke-flagg finnes i dag (kun + ADR-017s turnering-registrerings-samtykke, noe annet), foreslo nytt + opt-in `app_user.notification_emails_enabled` + en sjette + `send_friend_request_email()`-funksjon i `app/email.py` (samme mønster + som de fem eksisterende). Bruker spurte også hvordan få brukere til å + installere PWA-en "nærmest umiddelbart" — vurdert grundig: Android kan + fange `beforeinstallprompt` og vise egen timing, iOS Safari har INGEN + programmatisk installasjonsvei (kun instruksjonsoverlegg mulig, hard + Apple-begrensning) — anbefalte å vise oppfordringen rett etter + obligatorisk profil-fullføring (universelt sjekkpunkt alle nye brukere + allerede går gjennom) fremfor bokstavelig "umiddelbart". Begge kun + vurdert/skissert i FEATURE_BACKLOG.md, ingen kode skrevet, intet + V0-prompt for PWA-delen ennå. + +- **To nye drøftingspunkter, IKKE besluttet eller bygget (2026-07-25):** + bruker lastet opp to skjermbilder av Golf GameBooks leaderboard-løsning + (egen "Leaderboards"-fane, rangert liste, trykk-ut til fullt + scorekort) og spurte om (1) et tilsvarende leaderboard for + runder/turneringer, usikker på plassering, og (2) om TeeCup burde få + 1-2 nye designfarger. Drøftet grundig i FEATURE_BACKLOG.md, ikke + konkludert: fant at "runder" kan få dette NÅ (flere-deltakere-støtte + finnes allerede, ingen ADR-036-avhengighet) mens "turneringer" sitt + EKSISTERENDE leaderboard er lag-poeng (Ryder Cup-matchplay), strukturelt + noe annet enn Golf GameBooks individuelle rangering — åpent + avklaringsspørsmål. For fargespørsmålet: fant at en blåtone ALLEREDE + finnes i `--chart-3` (bare ikke løftet til en kjerne-designtoken) — + anbefalte å gjenbruke DEN i stedet for å finne på en helt ny, og + anbefalte mot flere enn én ekstra farge. Ingen kode skrevet, ingen + V0-prompt — ren refleksjonsrunde på brukerens eksplisitte instruks. + +- **In-app varslingssenter + rundeleaderboard-backend BYGGET OG SCRATCH- + VERIFISERT (2026-07-26), IKKE ENNÅ RULLET UT:** to ting i samme runde. + (1) Brukeren lastet opp zip 20 — resultatet av varsel-V0-prompten fra + 2026-07-25 (bjelle-ikon i dashbord-header, egen varslingsside). Bygget + matchende backend: migrasjon `026_notifications.sql` (tabell + `notification`, `plain_connection()`-mønster som resten av bruker-eid + data), `app/routers/notifications.py` (fire endepunkter + delt + `create_notification()`-hjelpefunksjon), to trigger-punkter i + `friends.py` (venneforespørsel sendt/akseptert — de eneste hendelsene + som faktisk finnes i dag). Frontend integrert kirurgisk (kun de nye + filene + et uttrekk fra V0s dashbord-eksport, ikke en full revert): + `components/notifications.tsx` skrevet om fra V0s mock-scenario til + ekte fetch, ny rute `/my-notifications` (IKKE `/notifications` — samme + kollisjonsklasse unngått fra start som `/rounds`/`/friends`), + bjelle-komponenten portert inn i den LIVE `dashboard.tsx` sin header, + koblet til et ekte `GET /notifications/unread-count`-kall. + (2) Brukeren ba samtidig om et leaderboard for frittstående runder + (opptil 13+ spillere mulig, flere flighter). Bygget ny + `GET /rounds/{round_id}/leaderboard` i `app/routers/rounds.py` — brutto + OG netto score-til-par + "thru"-antall PER deltaker, sortert stigende + på brutto. Netto bruker samme `allocate_strokes_by_index`-algoritme som + resten av appen (`strokes_received` i `list_holes`), denne gangen + summert over KUN de faktisk spilte hullene. + **Scratch-verifisert grundig, 39/39 sjekker i to testløp** (isolert + `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API- + container, samme mønster som hele prosjektet): full varsel-syklus begge + retninger + `mark-all-read` + kryss-bruker-isolasjon (bruker B kan ikke + markere bruker A sitt varsel som lest via id-gjetting), full + leaderboard-runde (3 deltakere, ulik fremdrift/score, riktig rangering/ + thru/to-par for alle), kryss-bruker-autorisasjon (403), ukjent + runde-id (404) — OG en dedikert netto-kryssjekk der resultatet ble + sammenlignet mot en HELT UAVHENGIG beregning via + `handicap_engine.allocate_strokes_by_index` kalt direkte fra + testskriptet (ikke bare "endepunktet svarte 200") — stemte eksakt, + inkl. et bevisst vekslende stroke-index-mønster og kun 9 av 18 hull + spilt, for å teste allokeringen over et REELT delvis spilt sett, ikke + et trivielt sammenfallende tilfelle. `test_isolation.sql` fortsatt + 12/12. Ekte typesjekket produksjonsbuild av frontend kompilerte rent, + `/my-notifications` listet blant rutene. + **V0-prompt for selve leaderboard-VISNINGEN skrevet og sendt til + bruker** (se FEATURE_BACKLOG.md for hele prompten) — rangering med + delt plassering, brutto/netto-veksling, "thru X"/"Ferdig"-tilstander, + kompakt mini-variant til rundens detaljside, alltid form+farge for + over/under par. Ikke kjørt i v0.app ennå — leaderboardets FRONTEND + kommer i en senere runde. + **Rullet ut live 2026-07-26**, bruker bekreftet eksplisitt: migrasjon + 026 kjørt mot ekte `teecup_db` (tabell `notification` bekreftet, + `test_isolation.sql` fortsatt 12/12), deretter `docker compose up -d + --build teecup_api teecup_frontend`. Begge containere boot-et rent, + `/health`/`/dashboard`/`/my-notifications` → 200, `teeoff.no` + upåvirket. Verifisert presist at `/notifications`-ruten faktisk når + FastAPI (ikke bare at Next.js svarte): anonymt `GET /notifications` + over ekte https ga korrekt `401 NOT_AUTHENTICATED`, ikke en rå 404. + Neste steg: -1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign - (ADR-035) + venner/kategorisert deling (ADR-036), begge designet - 2026-07-25 (se status over og FEATURE_BACKLOG.md). Retningen er - avklart (organisasjon blir usynlig/automatisk, dashbordet får syv - konkrete blokker, venner bygges i tre uavhengige faser) — men INGEN - av delene er bekreftet klar til bygging ennå. Naturlig neste steg: - send V0-prompten for dashbordet (se FEATURE_BACKLOG.md) til v0.app, - ELLER start på venner-kjernen (fase 1 av ADR-036), etter brukerens - valg. +0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):** + varsler (in-app varslingssenter) + leaderboard-BACKENDEN for + frittstående runder er begge bygget og scratch-verifisert i samme + runde (39/39 sjekker) — se status over. Migrasjon `026_ + notifications.sql` er IKKE kjørt mot ekte `teecup_db` ennå, og + `teecup_api`/`teecup_frontend` er ikke redeployet med disse + endringene. V0-prompten for selve leaderboard-VISNINGEN er skrevet og + sendt til bruker (se FEATURE_BACKLOG.md), ikke kjørt i v0.app ennå — + leaderboardets frontend kommer i en senere runde når den zip-en er + klar. +1. **Ferdig, kun for historikk:** dashbord-redesign (ADR-035) og + venner/kategorisert deling fase 1 (ADR-036) — begge designet + 2026-07-25 og siden BYGGET, SCRATCH-VERIFISERT OG RULLET UT LIVE + samme dag (se status over). Venner fase 2 (rundevisibilitet) og + fase 3 (ekte medspillere) er fortsatt ikke bygget. 3. **Frittstående rundeføring + detaljert statistikk — ADR-033 skrevet 2026-07-22, IKKE bygget.** Brukeren avklarte 2026-07-22 at dette skal bli appens HOVEDFOKUS (turneringsoppsett skal bli ekstremt enkelt diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index fd1e485..2b8b283 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -643,10 +643,351 @@ tillatelse kreves:** > universelt gjenkjent symbol, MEN skal ha en beskrivende > `aria-label` som inkluderer antall uleste). -**Status: ren design-/dokumentasjonsrunde, ingen kode skrevet** — venter på -brukerens beslutning om (a) bygge in-app-senteret først, (b) sende -V0-prompten, eller (c) også gå videre med ekte push-varsler som en egen, -senere runde. +**Status: In-app varslingssenter BYGGET OG SCRATCH-VERIFISERT 2026-07-26, +IKKE ENNÅ RULLET UT.** V0 kjørte prompten (zip 20), backend bygget for å +matche eksakt: migrasjon `026_notifications.sql` (tabell `notification`, +`plain_connection()`-mønster, ingen RLS — samme som personlig profil/ +runder/venner), ny `app/routers/notifications.py` (`create_notification()` +delt hjelpefunksjon + fire endepunkter: `GET /notifications`, +`GET /notifications/unread-count`, `POST /notifications/{id}/read`, +`POST /notifications/read-all`). To trigger-punkter koblet inn i +`friends.py` (kun venneforespørsel-hendelsene som faktisk finnes i dag, +ADR-036 fase 1): `POST /friends` varsler mottakeren, `POST /friends/{id}/ +accept` varsler den opprinnelige forespørreren. +`components/notifications.tsx` + ny rute `/my-notifications` (IKKE +`/notifications` — samme kollisjonsklasse unngått fra start som +`/rounds`/`/friends`). Bjelle-ikonet fra V0s dashbord-eksport portert inn +i den LIVE `dashboard.tsx` sin header (ikke en full revert av filen — +samme kirurgiske uttrekk-mønster som alltid), koblet til et ekte +`GET /notifications/unread-count`-kall ved mount. +**Scratch-verifisert i samme testløp som rundeleaderboardet under** (se +den seksjonen for detaljer om selve scratch-infrastrukturen, totalt +39/39 sjekker på tvers av begge funksjonene): full +forespørsel→varsel→lest-syklus begge retninger, `mark-all-read`, og +eksplisitt kryss-bruker-isolasjon (bruker B kan ikke markere bruker A sitt +varsel som lest via id-gjetting — stille no-op, ikke en feilmelding som +ville lekket at id-en fantes). Ekte typesjekket produksjonsbuild kompilerte +rent, `/my-notifications` listet blant rutene. +**Ikke bygget i denne runden, bevisst utenfor omfang:** ekte push til +telefonens OS (egen, større runde, se vurderingen over), e-post-fallback +(se tillegget under, fortsatt kun foreslått). +**Venter på brukerens bekreftelse før migrasjon 026 kjøres mot ekte +`teecup_db` og containerne redeployes** — se CLAUDE.md for den samlede +utrullingsplanen (denne runden + rundeleaderboardet under ble bygget +sammen). + +### Tillegg 2026-07-25: e-post som fallback-kanal, betinget av samtykke + +Brukeren foreslo at TeeCup i tillegg sender en e-post til mottakeren av en +venneforespørsel ("du har fått en forespørsel, åpne appen for å se den") +— MEN kun hvis brukeren har akseptert e-post som kommunikasjonskanal fra +TeeCup. + +**Sjekket eksisterende kode:** det finnes I DAG ingen generell +kommunikasjons-/varslings-samtykke-flagg på `app_user` — det eneste +samtykket i skjemaet er `tournament_registration.consent_given_at` +(ADR-017), som er noe HELT ANNET (samtykke til selve +turneringspåmeldingen, org-scopet, ikke en kontoinnstilling). Dette må +altså bygges som et nytt, eget felt, ikke gjenbrukes. + +**Foreslått, ikke bekreftet:** +- Nytt `app_user.notification_emails_enabled boolean NOT NULL DEFAULT + false` — OPT-IN, ikke opt-out (samme "trygg standard"-filosofi som + resten av appen, f.eks. rundevisibilitet default `private`). Satt via + en ny bryter i kontoinnstillinger (`/account`), IKKE en del av den + obligatoriske profil-fullføringen (dette er valgfritt, ikke påkrevd). +- Ny `send_friend_request_email()` i `app/email.py` — følger EKSAKT + samme mønster som de fem eksisterende utsendingsfunksjonene der + (nb/en-maler, `_send_sync` via `asyncio.to_thread`, driftsfeil lekker + aldri til klientresponsen). Sendes fra `POST /friends`, KUN hvis + mottakeren har `notification_emails_enabled = true`. +- **Fremtidig presisering, ikke et problem nå:** med kun ÉN varseltype + (venneforespørsel) holder én global boolean. Den dagen appen får flere + varseltyper (ADR-036 fase 2s rundevisibilitet, fremtidige + turnering-hendelser, osv.) bør dette trolig bli et SETT av brytere per + type, ikke én global av/på — notert her for å ikke bli glemt, ikke + løst nå. + +--- + +## PWA-installasjon: hvordan få brukere til å installere raskt — 📋 VURDERT 2026-07-25, IKKE designet i detalj + +Brukeren reiste dette som en oppfølging av varsel-diskusjonen: hvordan få +brukere til "nærmest umiddelbart" å installere TeeCup som app på +telefonen, gitt at ADR-028 allerede har bygget selve PWA-fundamentet +(manifest, service worker, ikoner, "Legg til på Hjemskjerm"-metadata) — +men ingenting proaktivt OPPFORDRER til installasjon i dag. + +**Plattformvirkeligheten, avgjør hele designet:** +- **Android/Chrome-familien:** nettleseren fyrer selv av et + `beforeinstallprompt`-event når siden kvalifiserer (manifest+service + worker+https — alt allerede på plass). Fanges opp med + `event.preventDefault()` + lagres, og kan trigges SENERE fra en egen + knapp via `event.prompt()` — full kontroll på NÅR spørsmålet stilles, + ikke bare nettleserens egen timing. +- **iOS Safari: INGEN programmatisk vei finnes i det hele tatt.** Apple + har aldri implementert `beforeinstallprompt`. Eneste vei er den + manuelle Del-ikon → "Legg til på Hjemskjerm"-flyten — appen kan KUN + vise en instruksjonsoverlegg (f.eks. med skjermbilde/animasjon av + hvilken knapp som skal trykkes), aldri utløse selve installasjonen. + Dette er en hard Apple-begrensning, ikke noe TeeCup kan designe seg + rundt. +- **Deteksjon nødvendig for begge retninger:** `matchMedia( + "(display-mode: standalone)")` (evt. `navigator.standalone` på eldre + iOS) avslører om brukeren ALLEREDE kjører den installerte PWA-en — vis + ALDRI noe installasjons-UI da. iOS-vs-Android/Chrome avgjøres med + UA-sniffing (upresist, men standard og nødvendig her siden det ikke + finnes noen bedre feature-deteksjon) for å velge riktig av de to + variantene (ekte knapp vs. instruksjonsbanner) — andre nettlesere uten + noen reell installasjonsvei bør ikke vise noe i det hele tatt. + +**Om "nærmest umiddelbart" — mild uenighet, med begrunnelse:** et +prompt FØR brukeren har vist noen interesse (f.eks. på selve +innloggingsskjermen) treffer typisk dårlig og føles påtrengende, OG +`beforeinstallprompt` har ikke alltid rukket å fyres av så tidlig uansett. +Appen har derimot ALLEREDE et universelt, høy-intensjons sjekkpunkt HVER +ny bruker går gjennom: den obligatoriske profil-fullføringen (ADR-031/ +"obligatorisk profil-fullføring ved innlogging"-runden). Å vise +installasjons-oppfordringen RETT ETTER det steget — første gang brukeren +faktisk når `/dashboard` med en komplett profil — er trolig det beste +"nærmest umiddelbart"-tidspunktet som finnes: reelt tidlig, men etter at +brukeren allerede har investert litt og vist ekte intensjon, ikke et +kaldt overfall på innloggingssiden. + +**Foreslått, ikke besluttet:** +- Vis KUN når `display-mode` ikke allerede er `standalone`. +- Første visning: rett etter fullført profil, første gang `/dashboard` + nås. +- "Ikke nå"-avvisning lagres i `localStorage` med en avkjølingsperiode + (f.eks. ikke vis på nytt før om N dager) — ingen server-side felt + nødvendig, dette er et rent klient-signal. +- To distinkte UI-varianter (Android: ekte "Installer"-knapp som kaller + `event.prompt()`; iOS: instruksjonsbanner) — INGEN visning for andre + nettlesere uten en reell installasjonsvei. + +**Status: kun vurdert/skissert i denne runden, ikke designet i detalj +eller bygget** — ingen V0-prompt skrevet ennå for dette (i motsetning +til varslingssenteret over). Naturlig neste steg om bruker vil gå videre: +en egen designrunde for selve UI-teksten/visuelt (særlig iOS- +instruksjonsbanneret, som må vise konkrete steg) — trolig verdt et eget +V0-prompt da, siden det er en egen, synlig UI-flate. + +--- + +## Leaderboard for runder og turneringer — 🧠 DRØFTET 2026-07-25, IKKE besluttet + +Brukeren ba om et leaderboard for pågående og ferdige runder OG +turneringer, usikker på om det bør ligge der man allerede ser rundens +detaljer så langt (`round-stats.tsx`) eller integreres i selve +detaljsiden (`round-detail.tsx`) — og lastet opp to skjermbilder av +hvordan Golf GameBook har løst akkurat dette, som referanse. Bevisst +drøftet her, ikke besluttet — brukeren ba selv om å "drodle", ikke bygge. + +**Referansen (Golf GameBook), oppsummert — IKKE noe TeeCup skal +kopiere rett av, kun inspireres av struktur/konsept:** en egen, +dedikert "Leaderboards"-fane nederst (sidestilt med "Rundeinfo" og +"Spill-feed", ikke en del av noen av dem), med faner ØVERST for ulike +scoringsmetoder ("Slagspill NET"/"Stableford NET"), en rangert liste +(#, navn, HCP, score, til par, "F" for ferdig), en blå "HCP-RUNDE"- +merkelapp, og at hver rad kan TRYKKES UT til å vise spillerens fulle +horisontale scorekort inline (samme hull-for-hull-tabellformat TeeCup +allerede har bygget i `round-scorecard.tsx`) pluss sosiale handlinger +(Lik/Kommentar/Statistikk). + +**To reelle presiseringer funnet ved å faktisk sjekke koden, ikke antatt:** +1. **"Runder" kan få dette NÅ, ingen avhengighet til ADR-036 fase 3.** + En frittstående runde støtter allerede flere deltakere i dag (eier + + gjester, `round-detail.tsx` sine spiller-faner) med uavhengig + hull-for-hull-score hver — et rangert leaderboard PÅ TVERS av disse + deltakerne er fullt buildbart nå. Fase 3 (ekte medspillere med egen + konto) endrer ikke dette, det utvider bare HVEM som kan være en + deltaker. +2. **"Turneringer" har allerede et leaderboard** (`tournament- + leaderboard.tsx`, live) — men det er et LAG-POENG-leaderboard for + Ryder Cup-matchplay (ADR-011, to lag), strukturelt noe helt annet enn + Golf GameBooks individuelle slagspill-rangering. **Åpent spørsmål, + ikke avklart:** mener brukeren en individuell rangering INNAD i en + turnering (f.eks. rangere spillere etter brutto/netto score i en + økt, ved siden av det eksisterende lag-poeng-leaderboardet), eller + var "turneringer" ment mer løst/generelt? Bør avklares før noe + designes for turnering-siden av dette. + +**Plassering — min foreløpige vurdering, ikke en konklusjon:** verken +`round-detail.tsx` (allerede tett under selve spillingen, samme +"for mye stablet oppå hverandre"-fare som tidligere runder denne uken +allerede ryddet opp i) eller `round-stats.tsx` (dedikert til DYP +enkelt-spiller-statistikk, ikke tvers-sammenligning) er et perfekt +hjem alene. To ideer, ikke gjensidig utelukkende: +- En KOMPAKT leaderboard-oppsummering (topp/posisjon, ikke full tabell) + øverst på `round-detail.tsx` — det man faktisk vil sjekke RASKT mens + man spiller ("hvem leder nå"). +- Gjenbruk EKSISTERENDE infrastruktur for full detalj i stedet for å + duplisere Golf GameBooks "trykk ut for fullt scorekort inline": + `round-stats.tsx` har ALLEREDE en spillervelger (pill-rad) bygget for + flere-deltakere-runder — en leaderboard-rad kan trolig bare LENKE + dit/bytte valgt spiller, i stedet for å bygge en helt ny inline- + scorekort-mekanisme på nytt. +- Flere scoringsmetode-visninger (Slagspill NET vs. Stableford NET) — + TeeCup regner allerede Stableford klientside i `round-stats.tsx`, men + har ingen tilsvarende "netto slagspill"-rangeringsvisning i dag. Verdt + å designe eksplisitt om dette ønskes, ikke noe som følger gratis av + det som allerede finnes. + +**Status: ingen kode, ingen V0-prompt ennå** — venter på at retning (og +særlig turnering-spørsmålet over) avklares før noe designes ferdig. + +### Oppdatering 2026-07-26: backend for RUNDE-leaderboardet BYGGET, V0-prompt sendt + +Brukeren ba eksplisitt om å få leaderboardet for frittstående runder på +plass (kun runder, ikke turneringer — turnering-spørsmålet over fortsatt +ikke avklart). Bygget som ny `GET /rounds/{round_id}/leaderboard` +(`app/routers/rounds.py`), samme `plain_connection()`/eier-only- +autorisasjon som resten av rundene (`_get_owned_round_or_404`). + +**Datakontrakt:** +``` +GET /rounds/{round_id}/leaderboard +{ + "holes_planned": 9 | 18, + "completed": boolean, + "entries": [ + { + "participant_id": string, + "display_name": string, + "is_owner": boolean, + "holes_played": number, // "thru" + "total_score": number | null, // null = ingen hull registrert ennå + "score_to_par": number | null, + "net_score_to_par": number | null // null hvis ingen course handicap + // (f.eks. gjest uten HCP oppgitt) + }, + ... + ] +} +``` +Sortert server-side stigende på `score_to_par` (lavest/best først, ingen +registrerte hull sist). Samme allokeringsalgoritme +(`allocate_strokes_by_index`) som resten av appen for netto — uavhengig +kryssjekket mot `handicap_engine.py` direkte i scratch, ikke bare "kjørte +uten feil". + +**Scratch-verifisert (39/39 sjekker, to separate testløp):** varsel- +trigger-punktene fra samme runde (se "Varsler"-seksjonen), pluss full +leaderboard-runde (3 deltakere — eier ferdig 9 hull til par, gjest 9 hull ++1/hull, gjest kun 3 hull -1/hull — riktig rangering/thru/to-par for alle +tre), kryss-bruker-autorisasjon (403 for en annen bruker), ukjent +runde-id (404), OG en dedikert netto-kryssjekk (avvikende SI-rekkefølge, +kun 9 av 18 hull spilt, resultatet sammenlignet mot en UAVHENGIG +beregning via `handicap_engine.allocate_strokes_by_index` direkte — +stemte eksakt). + +**V0-prompt sendt til bruker 2026-07-26, ikke kjørt i v0.app ennå:** + +> Design et "Leaderboard"-visning for en enkelt frittstående golfrunde i +> TeeCup (Next.js + Tailwind + shadcn/ui, mobil-først, eksisterende +> merkevarefarger — grønn primær, oransje sekundær). Runden kan ha +> ALLE typer deltakerantall — fra kun eieren alene til flere flighter +> samtidig (opptil 13+ spillere er reelt mulig), så designet må skalere +> pent fra 1 til 15+ rader UTEN å bli en endeløs, monoton liste. +> +> Data kommer fra et allerede bygget API-endepunkt som returnerer, for +> runden: om den er fullført eller pågår, planlagt hullantall (9/18), og +> en liste med én rad per deltaker: navn, om det er rundens eier, antall +> hull spilt ("thru"), total score, score til par (brutto), og score til +> par netto (kan være fraværende — vises da ikke for den spilleren, +> IKKE som "0" eller en feil). +> +> **Ranger deltakerne** etter brutto score til par (lavest/best først). +> Gi et tydelig, men ikke overveldende, rangeringstall (#1, #2, ...) — +> delt plassering (likt resultat) skal vises tydelig som delt (f.eks. +> "T-2"), ikke to forskjellige tall for samme resultat. +> +> **Topp-plassering fortjener litt ekstra visuell vekt** (f.eks. en +> diskret kant/bakgrunnstone eller et lite ikon) — men ALDRI kun farge +> for å skille ledere fra resten (tilgjengelighetskrav, se under). +> +> **For en pågående runde:** vis "thru X" (f.eks. "thru 5") for spillere +> som ikke har fullført alle planlagte hull ennå, i stedet for en +> ferdig-markering. For en FULLFØRT runde: vis heller en tydelig +> "Ferdig"-markering per spiller i stedet for "thru X av X". +> +> **Score-til-par-tall** skal formateres på golfvis: "E" for jevnt med +> par (0), "+N" over, "−N" (ekte minustegn) under — ALDRI bare "0"/"-3" +> uten fortegn. Bruk FORM i tillegg til farge der du fremhever over/ +> under par (f.eks. en liten sirkel/firkant-indikator, ikke bare +> tekstfarge) — samme "aldri kun farge"-prinsipp som resten av TeeCup. +> +> **Gi brukeren en brutto/netto-veksling** (to faner eller en enkel +> switch øverst) som bytter både HVILKET tall som vises OG selve +> rangeringsrekkefølgen mellom de to. Spillere uten et netto-tall (ingen +> HCP registrert) skal vises tydelig nederst/uten rangering i +> netto-visningen, ikke skjules eller krasje. +> +> **Rundens egen eier** bør være visuelt gjenkjennelig i listen (f.eks. +> et lite "Deg"-merke ved siden av navnet), siden det alltid er +> eieren som ser sin egen runde. +> +> Design også en kompakt "mini-leaderboard"-variant (topp 3 + evt. "og +> N til") egnet til å vises øverst på selve rundens detaljside — et +> raskt "hvem leder nå"-blikk uten å måtte navigere til hele +> leaderboardet. +> +> Tilgjengelighet er et ufravikelig krav: god kontrast, stor nok skrift, +> store trykkflater (min. 44px), aldri kun farge for å formidle +> informasjon (rangering/over-under par), lesbar uten briller. + +**Ikke avgjort ennå, avklares når zip-en er klar til integrering:** +nøyaktig plassering av lenke til full leaderboard-side (trolig +`round-detail.tsx`, som en lenke/knapp — ikke inline, samme "unngå for +mye stablet oppå hverandre"-lærdom som tidligere runder denne uken), +og ruten (foreslått `/my-rounds/[id]/leaderboard`, samme mønster som +`/my-rounds/[id]/stats`/`/scorecard`). + +--- + +## En tredje (informasjons-)farge til designet — 🧠 DRØFTET 2026-07-25, IKKE besluttet + +Brukeren spurte om det ville vært en idé å introdusere én (eller kanskje +to) nye farger til TeeCups design — trolig utløst av Golf GameBook- +skjermbildene over, som bruker flere fargenyanser for score-mot-par- +indikasjon og en egen blå "HCP-RUNDE"-merkelapp. + +**Sjekket faktisk palett i `globals.css` FØR noe ble foreslått:** +TeeCup har i dag `--primary` (grønn, hue ~130, ADR-016 — bevisst avledet +fra Teeoffs egen logo, se ADR-009/016 sin begrunnelse for hvorfor dette +IKKE er en tilfeldig fargevalg), `--brand-orange` (hue ~36.5, samme +opprinnelse), og `--destructive` (rød, hue ~27, reservert for slette-/ +feil-handlinger). I TILLEGG finnes allerede en `--chart-1…6`- +datavisualiseringsskala (lagt til under rundestatistikk-arbeidet) — +`--chart-3` er ALLEREDE en blåtone (hue 210), med egne, ferdig avstemte +verdier for BÅDE lyst og mørkt tema. + +**Anbefaling, forankret i dataviz-prinsippet om at statusfarger skal +være RESERVERTE (god/advarsel/alvorlig/kritisk) og aldri gjenbrukt som +"serie 4":** i stedet for å finne på en helt ny fargetone, LØFT den +allerede eksisterende `--chart-3`-blåtonen til en egen, navngitt +kjerne-designtoken (f.eks. `--info`/`--info-foreground`, samme mønster +som `--brand-orange`/`--destructive` allerede er egne tokens utover +selve chart-skalaen) — gjenbruker allerede validerte OKLCH-verdier for +begge temaer, i stedet for å øke det totale fargeantallet i appen. Denne +"informasjons"-fargen kunne dekke akkurat den typen behov Golf GameBook +løser med blått: en nøytral status mellom "bra" (grønt) og "trenger +oppmerksomhet" (oransje) — f.eks. en "teller for HCP"-merkelapp, eller +en mellomste score-til-par-kategori (par ↔ bogey ↔ dobbel bogey, hvis +TeeCup noen gang vil fargekode scorekortceller mer finmasket enn i dag). + +**Anbefaler IKKE en fjerde/femte helt ny nyanse i tillegg** — grønn +(positiv/merkevare), oransje (merkevare/oppmerksomhet), en løftet blå +(nøytral/informasjon), og rød (destruktiv, reservert) dekker allerede de +fire klassiske statuskategoriene godt (god/informasjon/advarsel/ +kritisk-destruktiv). Flere farger enn det risikerer å utvanne betydningen +uten en konkret, begrunnet bruk å vise til ennå. + +**Status: ingen kode endret** — dette er en anbefaling, ikke en +beslutning. Hvis bekreftet: en liten, lav-risiko endring +(`globals.css`, to nye CSS-variabler + Tailwind-token-kobling, samme +mønster som `--brand-orange`), ingen migrasjon, ingen bakoverkompatibi- +litetsbekymring siden det kun er et TILLEGG til paletten. --- diff --git a/app/main.py b/app/main.py index c02ce9a..ba485fa 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from .routers import ( friends, matches, messaging, + notifications, organizations, players, registration, @@ -56,6 +57,7 @@ app.include_router(messaging.router) app.include_router(messaging.public_router) app.include_router(rounds.router) app.include_router(friends.router) +app.include_router(notifications.router) @app.get("/health") diff --git a/app/routers/friends.py b/app/routers/friends.py index d1adba5..fc6a2d4 100644 --- a/app/routers/friends.py +++ b/app/routers/friends.py @@ -25,6 +25,7 @@ from .. import storage from ..auth import CurrentUser, get_current_user from ..db import plain_connection from ..errors import app_error, translate_db_errors +from .notifications import create_notification router = APIRouter(tags=["friends"]) @@ -168,6 +169,14 @@ async def send_friend_request(body: FriendRequestCreate, user: CurrentUser = Dep user.user_id, body.addressee_user_id, ) + requester_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) + await create_notification( + conn, + user_id=body.addressee_user_id, + type="friend", + message=f"{requester_name} har sendt deg en venneforespørsel.", + link_path="/my-friends", + ) return {"id": row["id"]} @@ -185,6 +194,15 @@ async def accept_friend_request(friendship_id: str, user: CurrentUser = Depends( if row["status"] == "accepted": raise app_error(409, "ALREADY_ACCEPTED", "Forespørselen er allerede akseptert.") await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", friendship_id) + addressee_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id) + requester_id = await conn.fetchval("SELECT requester_user_id::text FROM friendship WHERE id = $1", friendship_id) + await create_notification( + conn, + user_id=requester_id, + type="friend", + message=f"{addressee_name} godtok venneforespørselen din.", + link_path="/my-friends", + ) return {"ok": True} diff --git a/app/routers/notifications.py b/app/routers/notifications.py new file mode 100644 index 0000000..cb89b18 --- /dev/null +++ b/app/routers/notifications.py @@ -0,0 +1,99 @@ +""" +In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25). + +Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme +mønster som personlig profil/HCP-historikk/venner. `create_notification()` +er den eneste skrivevegen inn -- kalt fra andre routere (i dag kun +friends.py) ved konkrete hendelser, ikke noe generisk event-system. +""" + +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from ..auth import CurrentUser, get_current_user +from ..db import plain_connection + +router = APIRouter(tags=["notifications"]) + +NotificationType = Literal["friend", "tournament", "round", "result"] + + +async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None: + """Kalles fra andre routere sin egen `plain_connection()`/transaksjon -- + tar en allerede-åpen `conn`, åpner ikke en egen.""" + await conn.execute( + "INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)", + user_id, + type, + message, + link_path, + ) + + +class NotificationOut(BaseModel): + id: str + type: str + message: str + link_path: str + created_at: str + read_at: str | None + + +@router.get("/notifications", response_model=list[NotificationOut]) +async def list_notifications(user: CurrentUser = Depends(get_current_user)) -> list[NotificationOut]: + async with plain_connection() as conn: + rows = await conn.fetch( + """ + SELECT id::text AS id, type, message, link_path, created_at, read_at + FROM notification WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 100 + """, + user.user_id, + ) + return [ + NotificationOut( + id=r["id"], + type=r["type"], + message=r["message"], + link_path=r["link_path"], + created_at=r["created_at"].isoformat(), + read_at=r["read_at"].isoformat() if r["read_at"] else None, + ) + for r in rows + ] + + +@router.get("/notifications/unread-count") +async def unread_count(user: CurrentUser = Depends(get_current_user)) -> dict[str, int]: + async with plain_connection() as conn: + count = await conn.fetchval( + "SELECT COUNT(*) FROM notification WHERE user_id = $1 AND read_at IS NULL", + user.user_id, + ) + return {"count": count} + + +@router.post("/notifications/{notification_id}/read") +async def mark_read(notification_id: str, user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]: + async with plain_connection() as conn: + await conn.execute( + "UPDATE notification SET read_at = now() WHERE id = $1 AND user_id = $2 AND read_at IS NULL", + notification_id, + user.user_id, + ) + return {"ok": True} + + +@router.post("/notifications/read-all") +async def mark_all_read(user: CurrentUser = Depends(get_current_user)) -> dict[str, bool]: + async with plain_connection() as conn: + await conn.execute( + "UPDATE notification SET read_at = now() WHERE user_id = $1 AND read_at IS NULL", + user.user_id, + ) + return {"ok": True} diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 3bf3ae7..5b4eb4f 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -986,6 +986,87 @@ async def list_holes( ] +class LeaderboardEntryOut(BaseModel): + participant_id: str + display_name: str + is_owner: bool + holes_played: int + total_score: int | None + score_to_par: int | None + # None når deltakeren ikke har noen beregnet course handicap (f.eks. + # gjest uten registrert HCP) -- samme begrensning som strokes_received + # i RoundHoleOut/list_holes, samme algoritme (allocate_strokes_by_index). + net_score_to_par: int | None + + +class LeaderboardOut(BaseModel): + holes_planned: int + completed: bool + entries: list[LeaderboardEntryOut] + + +@router.get("/rounds/{round_id}/leaderboard", response_model=LeaderboardOut) +async def get_leaderboard(round_id: str, user: CurrentUser = Depends(get_current_user)) -> LeaderboardOut: + async with plain_connection() as conn: + await _get_owned_round_or_404(conn, round_id, user.user_id) + round_row = await conn.fetchrow( + "SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id + ) + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.is_owner, rp.guest_name, rp.course_handicap_snapshot, + au.display_name AS owner_display_name + FROM round_participant rp + LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + ORDER BY rp.is_owner DESC, rp.created_at + """, + round_id, + ) + + entries: list[LeaderboardEntryOut] = [] + for p in participant_rows: + hole_rows = await conn.fetch( + "SELECT hole_number, par, stroke_index, played, score FROM round_hole WHERE round_participant_id = $1 ORDER BY hole_number", + p["id"], + ) + played_holes = [h for h in hole_rows if h["played"] and h["score"] is not None] + holes_played = len(played_holes) + total_score = sum(h["score"] for h in played_holes) if holes_played else None + total_par = sum(h["par"] for h in played_holes) if holes_played else None + score_to_par = total_score - total_par if total_score is not None else None + + net_score_to_par = None + if total_score is not None and p["course_handicap_snapshot"] is not None: + allocation = allocate_strokes_by_index( + p["course_handicap_snapshot"], [h["stroke_index"] for h in hole_rows] + ) + strokes_received_by_hole = {h["hole_number"]: a for h, a in zip(hole_rows, allocation)} + strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes) + net_score_to_par = score_to_par - strokes_received_total + + display_name = p["guest_name"] if not p["is_owner"] else (p["owner_display_name"] or "Deg") + entries.append( + LeaderboardEntryOut( + participant_id=p["id"], + display_name=display_name, + is_owner=p["is_owner"], + holes_played=holes_played, + total_score=total_score, + score_to_par=score_to_par, + net_score_to_par=net_score_to_par, + ) + ) + + entries.sort(key=lambda e: (e.score_to_par is None, e.score_to_par)) + + return LeaderboardOut( + holes_planned=round_row["holes_planned"], + completed=round_row["completed_at"] is not None, + entries=entries, + ) + + class HoleUpdate(BaseModel): played: bool = True score: int | None = Field(default=None, ge=1, le=20) diff --git a/frontend/app/my-notifications/page.tsx b/frontend/app/my-notifications/page.tsx new file mode 100644 index 0000000..eeb1e07 --- /dev/null +++ b/frontend/app/my-notifications/page.tsx @@ -0,0 +1,5 @@ +import { Notifications } from "@/components/notifications" + +export default function MyNotificationsPage() { + return +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 9e9c5ad..6a235ed 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -14,6 +14,7 @@ import { useRouter } from "next/navigation" import Link from "next/link" import { ArrowRight, + Bell, Building2, ChevronRight, Flag, @@ -193,6 +194,7 @@ export function Dashboard() { const [combinedTournaments, setCombinedTournaments] = useState([]) const [hcpHistory, setHcpHistory] = useState([]) const [friendsSummary, setFriendsSummary] = useState(null) + const [unreadNotifications, setUnreadNotifications] = useState(0) const [error, setError] = useState(null) const loadMe = useCallback(async () => { @@ -232,6 +234,10 @@ export function Dashboard() { .then((res) => (res.ok ? res.json() : null)) .then((data: ApiFriendsSummary | null) => setFriendsSummary(data)) .catch(() => {}) + fetch("/notifications/unread-count", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : { count: 0 })) + .then((data: { count: number }) => setUnreadNotifications(data.count)) + .catch(() => {}) }, []) useEffect(() => { @@ -345,6 +351,7 @@ export function Dashboard() {
+ /my-rounds og /friends->/my-friends). +function NotificationBell({ unreadCount }: { unreadCount: number }) { + const hasUnread = unreadCount > 0 + const label = hasUnread ? `Varsler, ${unreadCount} uleste` : "Varsler, ingen uleste" + const badgeText = unreadCount > 9 ? "9+" : String(unreadCount) + + return ( + +