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:
parent
0e17a257dc
commit
9987c93e18
12 changed files with 993 additions and 14 deletions
|
|
@ -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
23
026_notifications.sql
Normal 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
112
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
99
app/routers/notifications.py
Normal file
99
app/routers/notifications.py
Normal 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}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
5
frontend/app/my-notifications/page.tsx
Normal file
5
frontend/app/my-notifications/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { Notifications } from "@/components/notifications"
|
||||||
|
|
||||||
|
export default function MyNotificationsPage() {
|
||||||
|
return <Notifications />
|
||||||
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
264
frontend/components/notifications.tsx
Normal file
264
frontend/components/notifications.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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` },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue