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