Varsler (fra zip 20): in-app varslingssenter er live — bjelle med uleste-tall i dashbord-headeren, /my-notifications-side. Trigges i dag ved venneforespørsel sendt/akseptert; flere hendelser kan kobles på senere.

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.
This commit is contained in:
Erol Haagenrud 2026-07-26 06:53:58 +02:00
parent 0e17a257dc
commit 9987c93e18
12 changed files with 993 additions and 14 deletions

View file

@ -15,7 +15,19 @@
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip19)", "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(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(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)"
] ]
} }
} }

23
026_notifications.sql Normal file
View file

@ -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;

112
CLAUDE.md
View file

@ -2767,16 +2767,110 @@ Ferdig og verifisert:
i FEATURE_BACKLOG.md, ikke sendt ennå. Ingen kode skrevet — ren i FEATURE_BACKLOG.md, ikke sendt ennå. Ingen kode skrevet — ren
design-/dokumentasjonsrunde. 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: Neste steg:
1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign 0. **Venter på brukerens bekreftelse for utrulling (2026-07-26):**
(ADR-035) + venner/kategorisert deling (ADR-036), begge designet varsler (in-app varslingssenter) + leaderboard-BACKENDEN for
2026-07-25 (se status over og FEATURE_BACKLOG.md). Retningen er frittstående runder er begge bygget og scratch-verifisert i samme
avklart (organisasjon blir usynlig/automatisk, dashbordet får syv runde (39/39 sjekker) — se status over. Migrasjon `026_
konkrete blokker, venner bygges i tre uavhengige faser) — men INGEN notifications.sql` er IKKE kjørt mot ekte `teecup_db` ennå, og
av delene er bekreftet klar til bygging ennå. Naturlig neste steg: `teecup_api`/`teecup_frontend` er ikke redeployet med disse
send V0-prompten for dashbordet (se FEATURE_BACKLOG.md) til v0.app, endringene. V0-prompten for selve leaderboard-VISNINGEN er skrevet og
ELLER start på venner-kjernen (fase 1 av ADR-036), etter brukerens sendt til bruker (se FEATURE_BACKLOG.md), ikke kjørt i v0.app ennå —
valg. 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 3. **Frittstående rundeføring + detaljert statistikk — ADR-033 skrevet
2026-07-22, IKKE bygget.** Brukeren avklarte 2026-07-22 at dette skal 2026-07-22, IKKE bygget.** Brukeren avklarte 2026-07-22 at dette skal
bli appens HOVEDFOKUS (turneringsoppsett skal bli ekstremt enkelt bli appens HOVEDFOKUS (turneringsoppsett skal bli ekstremt enkelt

View file

@ -643,10 +643,351 @@ tillatelse kreves:**
> universelt gjenkjent symbol, MEN skal ha en beskrivende > universelt gjenkjent symbol, MEN skal ha en beskrivende
> `aria-label` som inkluderer antall uleste). > `aria-label` som inkluderer antall uleste).
**Status: ren design-/dokumentasjonsrunde, ingen kode skrevet** — venter på **Status: In-app varslingssenter BYGGET OG SCRATCH-VERIFISERT 2026-07-26,
brukerens beslutning om (a) bygge in-app-senteret først, (b) sende IKKE ENNÅ RULLET UT.** V0 kjørte prompten (zip 20), backend bygget for å
V0-prompten, eller (c) også gå videre med ekte push-varsler som en egen, matche eksakt: migrasjon `026_notifications.sql` (tabell `notification`,
senere runde. `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.
--- ---

View file

@ -21,6 +21,7 @@ from .routers import (
friends, friends,
matches, matches,
messaging, messaging,
notifications,
organizations, organizations,
players, players,
registration, registration,
@ -56,6 +57,7 @@ app.include_router(messaging.router)
app.include_router(messaging.public_router) app.include_router(messaging.public_router)
app.include_router(rounds.router) app.include_router(rounds.router)
app.include_router(friends.router) app.include_router(friends.router)
app.include_router(notifications.router)
@app.get("/health") @app.get("/health")

View file

@ -25,6 +25,7 @@ from .. import storage
from ..auth import CurrentUser, get_current_user from ..auth import CurrentUser, get_current_user
from ..db import plain_connection from ..db import plain_connection
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
from .notifications import create_notification
router = APIRouter(tags=["friends"]) router = APIRouter(tags=["friends"])
@ -168,6 +169,14 @@ async def send_friend_request(body: FriendRequestCreate, user: CurrentUser = Dep
user.user_id, user.user_id,
body.addressee_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"]} return {"id": row["id"]}
@ -185,6 +194,15 @@ async def accept_friend_request(friendship_id: str, user: CurrentUser = Depends(
if row["status"] == "accepted": if row["status"] == "accepted":
raise app_error(409, "ALREADY_ACCEPTED", "Forespørselen er allerede akseptert.") 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) 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} return {"ok": True}

View file

@ -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}

View file

@ -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): class HoleUpdate(BaseModel):
played: bool = True played: bool = True
score: int | None = Field(default=None, ge=1, le=20) score: int | None = Field(default=None, ge=1, le=20)

View file

@ -0,0 +1,5 @@
import { Notifications } from "@/components/notifications"
export default function MyNotificationsPage() {
return <Notifications />
}

View file

@ -14,6 +14,7 @@ import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { import {
ArrowRight, ArrowRight,
Bell,
Building2, Building2,
ChevronRight, ChevronRight,
Flag, Flag,
@ -193,6 +194,7 @@ export function Dashboard() {
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([]) const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([]) const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null) const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null)
const [unreadNotifications, setUnreadNotifications] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const loadMe = useCallback(async () => { const loadMe = useCallback(async () => {
@ -232,6 +234,10 @@ export function Dashboard() {
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((data: ApiFriendsSummary | null) => setFriendsSummary(data)) .then((data: ApiFriendsSummary | null) => setFriendsSummary(data))
.catch(() => {}) .catch(() => {})
fetch("/notifications/unread-count", { credentials: "include" })
.then((res) => (res.ok ? res.json() : { count: 0 }))
.then((data: { count: number }) => setUnreadNotifications(data.count))
.catch(() => {})
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -345,6 +351,7 @@ export function Dashboard() {
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4"> <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Wordmark compact /> <Wordmark compact />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NotificationBell unreadCount={unreadNotifications} />
<Link <Link
href="/account" href="/account"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground" className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
@ -394,6 +401,34 @@ export function Dashboard() {
) )
} }
// V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications,
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som
// /rounds->/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 (
<Link
href="/my-notifications"
aria-label={label}
className="relative inline-flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Bell aria-hidden="true" className="size-5" />
{hasUnread && (
<span
aria-hidden="true"
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full bg-brand-orange px-1 text-[11px] font-bold leading-none text-brand-orange-foreground ring-2 ring-background"
style={{ height: 18 }}
>
{badgeText}
</span>
)}
</Link>
)
}
// --- 1. Hurtighandlinger ----------------------------------------------------- // --- 1. Hurtighandlinger -----------------------------------------------------
function QuickActions({ function QuickActions({

View file

@ -0,0 +1,264 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Bell,
BellOff,
CheckCheck,
ChevronRight,
Flag,
Trophy,
UserPlus,
Users,
} from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
// (app/routers/notifications.py). Scenario-veksleren fra V0-eksporten fjernet.
type NotificationKind = "friend" | "tournament" | "round" | "result"
type ApiNotification = {
id: string
type: NotificationKind
message: string
link_path: string
created_at: string
read_at: string | null
}
const KIND_ICON: Record<NotificationKind, typeof Bell> = {
friend: UserPlus,
tournament: Trophy,
round: Flag,
result: Users,
}
const KIND_LABEL: Record<NotificationKind, string> = {
friend: "Venneforespørsel",
tournament: "Turnering",
round: "Runde",
result: "Resultat",
}
function formatRelativeTime(iso: string): string {
const minutesAgo = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000)
if (minutesAgo < 1) return "akkurat nå"
if (minutesAgo < 60) {
const m = Math.round(minutesAgo)
return `for ${m} ${m === 1 ? "minutt" : "minutter"} siden`
}
const hours = Math.floor(minutesAgo / 60)
if (hours < 24) {
return `for ${hours} ${hours === 1 ? "time" : "timer"} siden`
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `for ${days} ${days === 1 ? "dag" : "dager"} siden`
}
const weeks = Math.floor(days / 7)
return `for ${weeks} ${weeks === 1 ? "uke" : "uker"} siden`
}
export function Notifications() {
const router = useRouter()
const [items, setItems] = useState<ApiNotification[] | null>(null)
useEffect(() => {
fetch("/notifications", { credentials: "include" })
.then((res) => {
if (res.status === 401) {
router.replace("/")
return null
}
return res.ok ? res.json() : []
})
.then((data: ApiNotification[] | null) => {
if (data) setItems(data)
})
.catch(() => setItems([]))
}, [router])
const sorted = useMemo(
() => [...(items ?? [])].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)),
[items],
)
const unreadCount = sorted.filter((n) => n.read_at === null).length
async function markAllRead() {
setItems((prev) => (prev ? prev.map((n) => ({ ...n, read_at: n.read_at ?? new Date().toISOString() })) : prev))
await fetch("/notifications/read-all", { method: "POST", credentials: "include" })
}
async function markRead(id: string) {
setItems((prev) => (prev ? prev.map((n) => (n.id === id ? { ...n, read_at: n.read_at ?? new Date().toISOString() } : n)) : prev))
await fetch(`/notifications/${id}/read`, { method: "POST", credentials: "include" })
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Link
href="/dashboard"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Til dashbord
</Link>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-28 pt-6 sm:pt-8">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">Varsler</h1>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
{items === null
? "Henter varsler …"
: unreadCount > 0
? unreadCount === 1
? "Du har 1 ulest varsel."
: `Du har ${unreadCount} uleste varsler.`
: "Alt er lest. Her dukker nye varsler opp."}
</p>
</div>
{sorted.length > 0 && (
<div className="flex">
<button
type="button"
onClick={markAllRead}
disabled={unreadCount === 0}
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl border border-border bg-card px-4 py-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<CheckCheck aria-hidden="true" className="size-4" />
Merk alle som lest
</button>
</div>
)}
</div>
{items !== null && sorted.length === 0 ? (
<EmptyState />
) : (
<ul className="mt-4 flex flex-col gap-2.5">
{sorted.map((n) => (
<li key={n.id}>
<NotificationRow notification={n} onActivate={() => markRead(n.id)} />
</li>
))}
</ul>
)}
</main>
</div>
)
}
function NotificationRow({
notification,
onActivate,
}: {
notification: ApiNotification
onActivate: () => void
}) {
const Icon = KIND_ICON[notification.type]
const unread = notification.read_at === null
const readState = unread ? "Ulest" : "Lest"
return (
<Link
href={notification.link_path}
onClick={onActivate}
aria-label={`${readState}. ${KIND_LABEL[notification.type]}: ${notification.message} ${formatRelativeTime(
notification.created_at,
)}`}
className={cn(
"group flex items-center gap-3 rounded-2xl border p-4 text-left shadow-sm shadow-black/5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
unread
? "border-brand-orange/40 bg-brand-orange/5 hover:bg-brand-orange/10"
: "border-border bg-card hover:bg-accent/50",
)}
>
{/* Ulest-indikator: en prikk i en reservert plass, slik at leste rader forblir på linje. */}
<span className="flex w-3 shrink-0 justify-center" aria-hidden="true">
{unread && <span className="size-3 rounded-full bg-brand-orange" />}
</span>
<span
aria-hidden="true"
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl",
unread ? "bg-brand-orange/15 text-foreground" : "bg-muted text-muted-foreground",
)}
>
<Icon className="size-5" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center gap-2">
<span
className={cn(
"text-xs uppercase tracking-wide",
unread ? "font-bold text-foreground" : "font-semibold text-muted-foreground",
)}
>
{KIND_LABEL[notification.type]}
</span>
{unread && (
<span className="rounded-full bg-brand-orange px-2 py-0.5 text-[11px] font-bold leading-none text-brand-orange-foreground">
Ny
</span>
)}
</span>
<span
className={cn(
"text-base leading-snug text-foreground text-pretty",
unread ? "font-bold" : "font-normal",
)}
>
{notification.message}
</span>
<span className="text-sm font-medium text-muted-foreground tabular-nums">
{formatRelativeTime(notification.created_at)}
</span>
</div>
<ChevronRight
aria-hidden="true"
className="size-5 shrink-0 self-center text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</Link>
)
}
function EmptyState() {
return (
<div className="mt-6 flex flex-col items-center gap-4 rounded-3xl border border-dashed border-border bg-card px-6 py-14 text-center">
<span
aria-hidden="true"
className="flex size-16 items-center justify-center rounded-2xl bg-muted text-muted-foreground"
>
<BellOff className="size-8" />
</span>
<div className="flex flex-col gap-2">
<h2 className="text-lg font-extrabold tracking-tight text-foreground">Ingen varsler ennå</h2>
<p className="mx-auto max-w-xs text-base leading-relaxed text-muted-foreground text-pretty">
Når noe skjer med rundene, turneringene eller vennene dine, dukker det opp her.
</p>
</div>
<Link
href="/dashboard"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Tilbake til dashbord
</Link>
</div>
)
}

View file

@ -46,6 +46,11 @@ const nextConfig = {
{ source: "/people/:path*", destination: `${API_ORIGIN}/people/:path*` }, { source: "/people/:path*", destination: `${API_ORIGIN}/people/:path*` },
{ source: "/friends/:path*", destination: `${API_ORIGIN}/friends/:path*` }, { source: "/friends/:path*", destination: `${API_ORIGIN}/friends/:path*` },
{ source: "/friends", destination: `${API_ORIGIN}/friends` }, { source: "/friends", destination: `${API_ORIGIN}/friends` },
// Merk (samme kollisjonsklasse som /rounds->/my-rounds og
// /friends->/my-friends): frontend-siden for dette heter derfor
// /my-notifications, ALDRI /notifications.
{ source: "/notifications/:path*", destination: `${API_ORIGIN}/notifications/:path*` },
{ source: "/notifications", destination: `${API_ORIGIN}/notifications` },
{ source: "/health", destination: `${API_ORIGIN}/health` }, { source: "/health", destination: `${API_ORIGIN}/health` },
] ]
}, },