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() {