2026-07-18 06:46:09 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// Dashbord-redesign (ADR-035, 2026-07-25): organisasjon er ikke lenger det
|
|
|
|
|
|
// første/viktigste en bruker møter -- en bruker er først og fremst en
|
|
|
|
|
|
// GOLFSPILLER. Presentasjon fra V0 (zip 18), datalag skrevet om fra mock
|
|
|
|
|
|
// til ekte fetch. Organisasjon-opprettelse er nå USYNLIG/automatisk ("Ny
|
|
|
|
|
|
// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget
|
|
|
|
|
|
// "opprett organisasjon"-steg for det vanlige tilfellet) -- se
|
|
|
|
|
|
// ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A.
|
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
|
import type React from "react"
|
2026-07-25 07:51:32 +02:00
|
|
|
|
import { useCallback, useEffect, useState } from "react"
|
2026-07-18 06:46:09 +02:00
|
|
|
|
import { useRouter } from "next/navigation"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
import Link from "next/link"
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
import {
|
2026-07-25 08:37:07 +02:00
|
|
|
|
ArrowRight,
|
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.
2026-07-26 06:53:58 +02:00
|
|
|
|
Bell,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
Building2,
|
|
|
|
|
|
ChevronRight,
|
Update Todos
Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py
Build /rounds list page + component
Build /rounds/new creation flow (course search/create, tee, date, holes)
Build /rounds/[id] detail page (participants, hole-by-hole stats, complete)
Link new feature from dashboard
Scratch-verify backend additions + typecheck production build
Deploy live after user confirmation
Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status
Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]).
What's new, in summary:
Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched.
Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card.
One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
|
|
|
|
Flag,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
KeyRound,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
LogOut,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
MapPin,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
Plus,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
TrendingDown,
|
|
|
|
|
|
TrendingUp,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
Trophy,
|
|
|
|
|
|
UserCircle,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
UserPlus,
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
X,
|
|
|
|
|
|
} from "lucide-react"
|
2026-07-18 06:46:09 +02:00
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
|
import { Label } from "@/components/ui/label"
|
|
|
|
|
|
import {
|
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
|
} from "@/components/ui/dropdown-menu"
|
|
|
|
|
|
import { Wordmark } from "@/components/wordmark"
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
|
import { InstallPrompt } from "@/components/install-prompt"
|
2026-07-25 07:51:32 +02:00
|
|
|
|
import { RoundCard, type Round } from "@/components/round-card"
|
2026-07-18 06:46:09 +02:00
|
|
|
|
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
2026-07-25 07:51:32 +02:00
|
|
|
|
import { type TournamentStatus } from "@/components/tournament-status-badge"
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
import { cn } from "@/lib/utils"
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- API-typer ---------------------------------------------------------------
|
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
|
type MyOrg = { organization_id: string; name: string; role: string }
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// ADR-031: turneringer brukeren er SPILLER i (rostret på et lag), på tvers
|
|
|
|
|
|
// av organisasjoner, uavhengig av organisasjonsmedlemskap.
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
type MyTournament = {
|
|
|
|
|
|
organization_id: string
|
|
|
|
|
|
organization_name: string
|
|
|
|
|
|
tournament_id: string
|
|
|
|
|
|
tournament_name: string
|
|
|
|
|
|
status: TournamentStatus
|
|
|
|
|
|
team_id: string
|
|
|
|
|
|
team_name: string
|
|
|
|
|
|
team_color: string | null
|
|
|
|
|
|
next_session_at: string | null
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
|
my_session_id: string | null
|
|
|
|
|
|
my_match_id: string | null
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
|
type Me = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
email: string
|
|
|
|
|
|
display_name: string
|
2026-07-26 07:54:00 +02:00
|
|
|
|
first_name: string | null
|
2026-07-18 06:46:09 +02:00
|
|
|
|
preferred_locale: string
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
|
profile_complete: boolean
|
2026-07-25 07:51:32 +02:00
|
|
|
|
handicap_index: number | null
|
2026-07-18 06:46:09 +02:00
|
|
|
|
organizations: MyOrg[]
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
my_tournaments: MyTournament[]
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiTournament = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
status: TournamentStatus
|
|
|
|
|
|
start_date: string | null
|
|
|
|
|
|
end_date: string | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-26 08:22:41 +02:00
|
|
|
|
type ApiRoundParticipant = {
|
|
|
|
|
|
user_id: string | null
|
|
|
|
|
|
is_owner: boolean
|
|
|
|
|
|
counts_for_handicap: boolean
|
|
|
|
|
|
score_differential: number | null
|
|
|
|
|
|
}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
type ApiRound = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string | null
|
|
|
|
|
|
course_name_snapshot: string
|
|
|
|
|
|
tee_name_snapshot: string
|
|
|
|
|
|
played_at: string
|
|
|
|
|
|
holes_planned: number
|
|
|
|
|
|
completed_at: string | null
|
|
|
|
|
|
participants: ApiRoundParticipant[]
|
2026-07-26 08:22:41 +02:00
|
|
|
|
my_holes_played: number
|
|
|
|
|
|
my_total_score: number | null
|
|
|
|
|
|
my_score_to_par: number | null
|
2026-07-25 07:51:32 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiHandicapPoint = { handicap_index: number; recorded_at: string }
|
|
|
|
|
|
|
2026-07-25 08:37:07 +02:00
|
|
|
|
// ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele
|
|
|
|
|
|
// GET /friends-responsen, som også bærer utgående forespørsler/kategorier).
|
|
|
|
|
|
type ApiFriendsSummary = {
|
|
|
|
|
|
friends: { first_name: string | null; last_name: string | null }[]
|
|
|
|
|
|
incoming_requests: unknown[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
|
function toTournament(t: ApiTournament): Tournament {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round {
|
2026-07-26 08:22:41 +02:00
|
|
|
|
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
return {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
id: r.id,
|
|
|
|
|
|
name: r.name,
|
|
|
|
|
|
courseName: r.course_name_snapshot,
|
|
|
|
|
|
status: r.completed_at ? "completed" : "active",
|
|
|
|
|
|
teeName: r.tee_name_snapshot,
|
|
|
|
|
|
holes: r.holes_planned === 9 ? 9 : 18,
|
|
|
|
|
|
date: r.played_at,
|
|
|
|
|
|
playerCount: r.participants.length,
|
2026-07-26 08:22:41 +02:00
|
|
|
|
holesPlayed: r.my_holes_played,
|
|
|
|
|
|
totalScore: r.my_total_score ?? undefined,
|
|
|
|
|
|
toPar: r.my_score_to_par ?? undefined,
|
|
|
|
|
|
differential: me?.counts_for_handicap ? me.score_differential : null,
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
// Laveste egne til-par blant minst to fullførte runder -- én eneste
|
|
|
|
|
|
// fullført runde er ikke ennå meningsfullt en "rekord".
|
|
|
|
|
|
function findPersonalBestRoundId(completedRounds: ApiRound[]): string | null {
|
|
|
|
|
|
if (completedRounds.length < 2) return null
|
|
|
|
|
|
let best: { id: string; toPar: number } | null = null
|
|
|
|
|
|
for (const r of completedRounds) {
|
|
|
|
|
|
if (r.my_score_to_par === null) continue
|
|
|
|
|
|
if (!best || r.my_score_to_par < best.toPar) best = { id: r.id, toPar: r.my_score_to_par }
|
|
|
|
|
|
}
|
|
|
|
|
|
return best?.id ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------
|
|
|
|
|
|
|
|
|
|
|
|
type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null }
|
|
|
|
|
|
|
|
|
|
|
|
function combineTournaments(myTournaments: MyTournament[], organizerLists: { orgId: string; tournaments: ApiTournament[] }[]): CombinedTournament[] {
|
|
|
|
|
|
const byId = new Map<string, CombinedTournament>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const t of myTournaments) {
|
|
|
|
|
|
byId.set(t.tournament_id, {
|
|
|
|
|
|
tournament: { id: t.tournament_id, name: t.tournament_name, status: t.status, startDate: t.next_session_at ?? undefined },
|
|
|
|
|
|
organizer: false,
|
|
|
|
|
|
orgId: t.organization_id,
|
|
|
|
|
|
sortKey: t.next_session_at,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const { orgId, tournaments } of organizerLists) {
|
|
|
|
|
|
for (const t of tournaments) {
|
|
|
|
|
|
const existing = byId.get(t.id)
|
|
|
|
|
|
byId.set(t.id, {
|
|
|
|
|
|
tournament: existing?.tournament ?? toTournament(t),
|
|
|
|
|
|
organizer: true,
|
|
|
|
|
|
orgId: existing?.orgId ?? orgId,
|
|
|
|
|
|
sortKey: existing?.sortKey ?? t.start_date,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Kun "kommende" -- fullførte/arkiverte turneringer skal ikke fylle opp
|
|
|
|
|
|
// en "kommende"-liste, uansett dato.
|
|
|
|
|
|
const upcoming = [...byId.values()].filter((c) => c.tournament.status === "draft" || c.tournament.status === "active")
|
|
|
|
|
|
upcoming.sort((a, b) => {
|
|
|
|
|
|
if (a.sortKey && b.sortKey) return a.sortKey < b.sortKey ? -1 : 1
|
|
|
|
|
|
if (a.sortKey) return -1
|
|
|
|
|
|
if (b.sortKey) return 1
|
|
|
|
|
|
return a.tournament.name.localeCompare(b.tournament.name)
|
|
|
|
|
|
})
|
|
|
|
|
|
return upcoming
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Formattering ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function formatSigned(value: number, decimals = 1): string {
|
|
|
|
|
|
if (value === 0) return decimals === 0 ? "0" : `0,${"0".repeat(decimals)}`
|
|
|
|
|
|
const abs = Math.abs(value).toFixed(decimals).replace(".", ",")
|
|
|
|
|
|
return value > 0 ? `+${abs}` : `−${abs}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" })
|
|
|
|
|
|
function formatDate(value: string) {
|
|
|
|
|
|
const parsed = new Date(value)
|
|
|
|
|
|
if (Number.isNaN(parsed.getTime())) return value
|
|
|
|
|
|
return dateFormatter.format(parsed)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Root component ----------------------------------------------------------
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
|
|
|
|
|
export function Dashboard() {
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const [me, setMe] = useState<Me | null>(null)
|
|
|
|
|
|
const [loadingMe, setLoadingMe] = useState(true)
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const [rounds, setRounds] = useState<ApiRound[] | null>(null)
|
|
|
|
|
|
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
|
|
|
|
|
|
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
|
2026-07-25 08:37:07 +02:00
|
|
|
|
const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null)
|
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.
2026-07-26 06:53:58 +02:00
|
|
|
|
const [unreadNotifications, setUnreadNotifications] = useState(0)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const loadMe = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch("/auth/me", { credentials: "include" })
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
router.replace("/")
|
|
|
|
|
|
return
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const data: Me = await res.json()
|
|
|
|
|
|
if (!data.profile_complete) {
|
|
|
|
|
|
router.replace("/account")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setMe(data)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
router.replace("/")
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingMe(false)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
}, [router])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
void loadMe()
|
|
|
|
|
|
}, [loadMe])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetch("/rounds", { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
|
|
|
|
.then((data: ApiRound[]) => setRounds(data))
|
|
|
|
|
|
.catch(() => setRounds([]))
|
|
|
|
|
|
fetch("/auth/profile/handicap-history", { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
|
|
|
|
.then((data: ApiHandicapPoint[]) => setHcpHistory(data))
|
|
|
|
|
|
.catch(() => {})
|
2026-07-25 08:37:07 +02:00
|
|
|
|
fetch("/friends", { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
|
.then((data: ApiFriendsSummary | null) => setFriendsSummary(data))
|
|
|
|
|
|
.catch(() => {})
|
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.
2026-07-26 06:53:58 +02:00
|
|
|
|
fetch("/notifications/unread-count", { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : { count: 0 }))
|
|
|
|
|
|
.then((data: { count: number }) => setUnreadNotifications(data.count))
|
|
|
|
|
|
.catch(() => {})
|
2026-07-25 07:51:32 +02:00
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!me) return
|
2026-07-18 06:46:09 +02:00
|
|
|
|
let cancelled = false
|
2026-07-25 07:51:32 +02:00
|
|
|
|
async function loadOrganizerTournaments() {
|
2026-07-18 06:46:09 +02:00
|
|
|
|
try {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const lists = await Promise.all(
|
|
|
|
|
|
me!.organizations.map(async (o) => {
|
|
|
|
|
|
const res = await fetch(`/orgs/${o.organization_id}/tournaments`, { credentials: "include" })
|
|
|
|
|
|
const tournaments: ApiTournament[] = res.ok ? await res.json() : []
|
|
|
|
|
|
return { orgId: o.organization_id, tournaments }
|
|
|
|
|
|
}),
|
|
|
|
|
|
)
|
|
|
|
|
|
if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, lists))
|
2026-07-18 06:46:09 +02:00
|
|
|
|
} catch {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, []))
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
void loadOrganizerTournaments()
|
2026-07-18 06:46:09 +02:00
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
}, [me])
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
// Delt åpen/lukk-tilstand for hurtighandling-skjemaene (turnering/kode) --
|
|
|
|
|
|
// løftet til roten (2026-07-29, "alternativ designinstruks") slik at BÅDE
|
|
|
|
|
|
// Hurtighandlinger-kortene OG tomtilstands-knappene i "Kommende
|
|
|
|
|
|
// turneringer" kan åpne samme inline-skjema, i stedet for at sistnevnte
|
|
|
|
|
|
// var uten href/onClick og ikke gjorde noe ved trykk.
|
|
|
|
|
|
const [quickActionOpen, setQuickActionOpen] = useState<"tournament" | "code" | null>(null)
|
|
|
|
|
|
const openQuickAction = useCallback((which: "tournament" | "code") => {
|
|
|
|
|
|
setQuickActionOpen(which)
|
|
|
|
|
|
document.getElementById("hurtighandlinger")?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) --
|
|
|
|
|
|
// ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet.
|
|
|
|
|
|
// Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles
|
|
|
|
|
|
// FØRST ved selve innsendingen (ikke når skjemaet bare åpnes -- unngår en
|
|
|
|
|
|
// foreldreløs tom org hvis brukeren avbryter).
|
|
|
|
|
|
async function handleCreateTournament(name: string, chosenOrgId: string | null) {
|
|
|
|
|
|
if (!me) return
|
2026-07-18 06:46:09 +02:00
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
let orgId = chosenOrgId ?? (me.organizations.length === 1 ? me.organizations[0].organization_id : null)
|
|
|
|
|
|
if (!orgId) {
|
|
|
|
|
|
const orgName = me.display_name ? `${me.display_name}s turneringer` : "Mine turneringer"
|
|
|
|
|
|
const orgRes = await fetch("/orgs", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ name: orgName }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!orgRes.ok) throw new Error("orgs")
|
|
|
|
|
|
const org: { id: string; name: string; role: string } = await orgRes.json()
|
|
|
|
|
|
orgId = org.id
|
|
|
|
|
|
setMe((prev) => (prev ? { ...prev, organizations: [...prev.organizations, { organization_id: org.id, name: org.name, role: org.role }] } : prev))
|
|
|
|
|
|
}
|
|
|
|
|
|
const res = await fetch(`/orgs/${orgId}/tournaments`, {
|
2026-07-18 06:46:09 +02:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ name }),
|
|
|
|
|
|
})
|
2026-07-25 07:51:32 +02:00
|
|
|
|
if (!res.ok) throw new Error("create tournament")
|
2026-07-18 06:46:09 +02:00
|
|
|
|
const created: ApiTournament = await res.json()
|
2026-07-25 07:51:32 +02:00
|
|
|
|
router.push(`/tournaments/${created.id}?org=${orgId}&name=${encodeURIComponent(created.name)}`)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleLogout() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
router.replace("/")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (loadingMe) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!me) return null // router.replace("/") er allerede utløst
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const rows = rounds ?? []
|
|
|
|
|
|
const activeRounds = rows.filter((r) => !r.completed_at)
|
|
|
|
|
|
const completedRounds = rows.filter((r) => r.completed_at)
|
|
|
|
|
|
const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3)
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
const personalBestRoundId = findPersonalBestRoundId(completedRounds)
|
2026-07-25 07:51:32 +02:00
|
|
|
|
|
|
|
|
|
|
const avgToParSample = completedRounds
|
2026-07-26 08:22:41 +02:00
|
|
|
|
.filter((r) => r.my_score_to_par !== null)
|
2026-07-25 07:51:32 +02:00
|
|
|
|
.slice(0, 5)
|
2026-07-26 08:22:41 +02:00
|
|
|
|
.map((r) => r.my_score_to_par as number)
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—"
|
|
|
|
|
|
|
|
|
|
|
|
const hcpValues = hcpHistory.map((h) => h.handicap_index)
|
|
|
|
|
|
const hcpTrend = hcpValues.length >= 2 ? hcpValues[hcpValues.length - 1] - hcpValues[hcpValues.length - 2] : null
|
|
|
|
|
|
const hcpNow = me.handicap_index !== null ? me.handicap_index.toFixed(1).replace(".", ",") : "—"
|
|
|
|
|
|
|
|
|
|
|
|
const coursesMap = new Map<string, { name: string; visits: number; lastPlayed: string }>()
|
|
|
|
|
|
for (const r of rows) {
|
|
|
|
|
|
const existing = coursesMap.get(r.course_name_snapshot)
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
existing.visits += 1
|
|
|
|
|
|
if (r.played_at > existing.lastPlayed) existing.lastPlayed = r.played_at
|
|
|
|
|
|
} else {
|
|
|
|
|
|
coursesMap.set(r.course_name_snapshot, { name: r.course_name_snapshot, visits: 1, lastPlayed: r.played_at })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur">
|
2026-07-18 06:46:09 +02:00
|
|
|
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
|
|
|
|
|
<Wordmark compact />
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
<div className="flex items-center gap-1">
|
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.
2026-07-26 06:53:58 +02:00
|
|
|
|
<NotificationBell unreadCount={unreadNotifications} />
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
<Link
|
|
|
|
|
|
href="/account"
|
2026-07-29 12:44:51 +02:00
|
|
|
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
>
|
|
|
|
|
|
<UserCircle aria-hidden="true" className="size-4" />
|
|
|
|
|
|
Konto
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleLogout}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
>
|
|
|
|
|
|
<LogOut aria-hidden="true" className="size-4" />
|
|
|
|
|
|
Logg ut
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-[max(4rem,env(safe-area-inset-bottom))] pt-6 sm:pt-8">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
|
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
2026-07-26 07:54:00 +02:00
|
|
|
|
God dag, {me.first_name ?? me.display_name}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</h1>
|
|
|
|
|
|
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
|
Klar for en ny runde? Her er golfen din på ett sted.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
|
{error && (
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<p role="alert" className="mt-4 text-sm font-medium text-destructive">
|
2026-07-18 06:46:09 +02:00
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
|
<div className="mt-6">
|
|
|
|
|
|
<InstallPrompt />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="mt-6 flex flex-col gap-8">
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<QuickActions
|
|
|
|
|
|
organizations={me.organizations}
|
|
|
|
|
|
onCreateTournament={handleCreateTournament}
|
|
|
|
|
|
open={quickActionOpen}
|
|
|
|
|
|
onOpenChange={setQuickActionOpen}
|
|
|
|
|
|
/>
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<UpcomingTournaments
|
|
|
|
|
|
entries={combinedTournaments}
|
|
|
|
|
|
onNewTournament={() => openQuickAction("tournament")}
|
|
|
|
|
|
onJoinWithCode={() => openQuickAction("code")}
|
|
|
|
|
|
/>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
|
|
|
|
|
<PlayedCourses courses={playedCourses} />
|
2026-07-25 08:37:07 +02:00
|
|
|
|
<FriendsSection summary={friendsSummary} />
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<OrganizationsFooter organizations={me.organizations} />
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
</div>
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-07-26 06:53:58 +02:00
|
|
|
|
// 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}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
className="relative inline-flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
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.
2026-07-26 06:53:58 +02:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- 1. Hurtighandlinger -----------------------------------------------------
|
Update Todos
Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py
Build /rounds list page + component
Build /rounds/new creation flow (course search/create, tee, date, holes)
Build /rounds/[id] detail page (participants, hole-by-hole stats, complete)
Link new feature from dashboard
Scratch-verify backend additions + typecheck production build
Deploy live after user confirmation
Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status
Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]).
What's new, in summary:
Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched.
Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card.
One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function QuickActions({
|
|
|
|
|
|
organizations,
|
|
|
|
|
|
onCreateTournament,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
open,
|
|
|
|
|
|
onOpenChange,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
}: {
|
|
|
|
|
|
organizations: MyOrg[]
|
|
|
|
|
|
onCreateTournament: (name: string, orgId: string | null) => Promise<void>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
open: "tournament" | "code" | null
|
|
|
|
|
|
onOpenChange: (value: "tournament" | "code" | null) => void
|
2026-07-25 07:51:32 +02:00
|
|
|
|
}) {
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<section id="hurtighandlinger" aria-label="Hurtighandlinger" className="flex flex-col gap-3">
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" />
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => onOpenChange(open === "tournament" ? null : "tournament")} />
|
|
|
|
|
|
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => onOpenChange(open === "code" ? null : "code")} />
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
</div>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
{open === "tournament" && (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => onOpenChange(null)} />
|
2026-07-25 07:51:32 +02:00
|
|
|
|
)}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
{open === "code" && <JoinByCodeInline onClose={() => onOpenChange(null)} />}
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function QuickAction({
|
|
|
|
|
|
icon: Icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
href,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
icon: typeof Flag
|
|
|
|
|
|
label: string
|
|
|
|
|
|
href?: string
|
|
|
|
|
|
onClick?: () => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const className =
|
2026-07-29 12:44:51 +02:00
|
|
|
|
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl bg-primary px-2 py-3 text-center text-primary-foreground shadow-md shadow-primary/25 transition-all duration-200 ease-in-out hover:bg-primary/90 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const inner = (
|
|
|
|
|
|
<>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<Icon aria-hidden="true" className="size-5 sm:size-6" />
|
|
|
|
|
|
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
if (href) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Link href={href} className={className}>
|
|
|
|
|
|
{inner}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button type="button" onClick={onClick} className={className}>
|
|
|
|
|
|
{inner}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function NewTournamentInline({
|
|
|
|
|
|
organizations,
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
onCreate,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
onClose,
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
}: {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
organizations: MyOrg[]
|
|
|
|
|
|
onCreate: (name: string, orgId: string | null) => Promise<void>
|
|
|
|
|
|
onClose: () => void
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
}) {
|
2026-07-18 06:46:09 +02:00
|
|
|
|
const [name, setName] = useState("")
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const [orgId, setOrgId] = useState(organizations[0]?.organization_id ?? "")
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
const valid = name.trim().length >= 2
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
2026-07-18 06:46:09 +02:00
|
|
|
|
e.preventDefault()
|
2026-07-25 07:51:32 +02:00
|
|
|
|
if (!valid || submitting) return
|
|
|
|
|
|
setSubmitting(true)
|
|
|
|
|
|
await onCreate(name.trim(), organizations.length > 1 ? orgId : null)
|
|
|
|
|
|
setSubmitting(false)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
|
|
|
|
<Label htmlFor="new-tournament-name" className="text-sm font-semibold">
|
|
|
|
|
|
Navn på turnering
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="new-tournament-name"
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
placeholder="F.eks. Sommercup 2026"
|
|
|
|
|
|
value={name}
|
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
|
className="h-11 rounded-xl"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{organizations.length > 1 && (
|
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
|
<Label htmlFor="new-tournament-org" className="text-sm font-semibold">
|
|
|
|
|
|
Organisasjon
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
id="new-tournament-org"
|
|
|
|
|
|
value={orgId}
|
|
|
|
|
|
onChange={(e) => setOrgId(e.target.value)}
|
|
|
|
|
|
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none sm:w-48"
|
|
|
|
|
|
>
|
|
|
|
|
|
{organizations.map((o) => (
|
|
|
|
|
|
<option key={o.organization_id} value={o.organization_id}>
|
|
|
|
|
|
{o.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro
Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting
Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere
Frontend: profil-seksjon i /account
Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx
Scratch-verifisere alt (15 sjekker bestått)
Typesjekket frontend-build
ADR-031 + .md-oppdatering
Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering:
Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes.
"Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted.
Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før.
Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg.
Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button type="submit" disabled={!valid || submitting} className="h-11 rounded-xl font-bold">
|
|
|
|
|
|
{submitting ? "Oppretter…" : "Opprett"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
|
|
|
|
|
|
<X aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</Button>
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</form>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function JoinByCodeInline({ onClose }: { onClose: () => void }) {
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const [code, setCode] = useState("")
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
const trimmed = code.trim()
|
|
|
|
|
|
if (!trimmed || loading) return
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const body = await res.json().catch(() => null)
|
|
|
|
|
|
setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const data: { tournament_id: string } = await res.json()
|
|
|
|
|
|
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setError("Klarte ikke å slå opp koden. Prøv igjen.")
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
|
|
|
|
<Label htmlFor="join-code-inline" className="text-sm font-semibold">
|
|
|
|
|
|
Invitasjonskode
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="join-code-inline"
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
placeholder="F.eks. 7K2M9P"
|
|
|
|
|
|
value={code}
|
|
|
|
|
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
|
|
|
|
className="h-11 rounded-xl uppercase tracking-widest"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button type="submit" disabled={!code.trim() || loading} className="h-11 rounded-xl font-bold">
|
|
|
|
|
|
{loading ? "Søker…" : "Bli med"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
|
|
|
|
|
|
<X aria-hidden="true" className="size-5" />
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</Button>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Delt seksjon-"chrome" ----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
|
|
|
|
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h2>
|
|
|
|
|
|
{action}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function SeeAllLink({ href, label }: { href: string; label: string }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={href}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-primary transition-all duration-200 ease-in-out hover:bg-accent/50 active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
2026-07-25 07:51:32 +02:00
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
<ChevronRight aria-hidden="true" className="size-4" />
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function EmptyState({
|
|
|
|
|
|
icon: Icon,
|
|
|
|
|
|
title,
|
|
|
|
|
|
description,
|
|
|
|
|
|
children,
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}: {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
icon: typeof Flag
|
|
|
|
|
|
title: string
|
|
|
|
|
|
description: string
|
|
|
|
|
|
children?: React.ReactNode
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border bg-card/50 px-6 py-8 text-center">
|
|
|
|
|
|
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
|
<Icon aria-hidden="true" className="size-6 text-muted-foreground" />
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
<h3 className="text-base font-bold text-foreground text-balance">{title}</h3>
|
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{description}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function ShortcutButton({
|
|
|
|
|
|
icon: Icon,
|
|
|
|
|
|
label,
|
|
|
|
|
|
href,
|
2026-07-29 12:44:51 +02:00
|
|
|
|
onClick,
|
2026-07-25 07:51:32 +02:00
|
|
|
|
variant = "primary",
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}: {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
icon: typeof Flag
|
|
|
|
|
|
label: string
|
|
|
|
|
|
href?: string
|
2026-07-29 12:44:51 +02:00
|
|
|
|
onClick?: () => void
|
2026-07-25 07:51:32 +02:00
|
|
|
|
variant?: "primary" | "outline"
|
2026-07-18 06:46:09 +02:00
|
|
|
|
}) {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
const className = cn(
|
2026-07-29 12:44:51 +02:00
|
|
|
|
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
2026-07-25 07:51:32 +02:00
|
|
|
|
variant === "primary" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)
|
|
|
|
|
|
const inner = (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Icon aria-hidden="true" className="size-4" />
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
if (href) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Link href={href} className={className}>
|
|
|
|
|
|
{inner}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<button type="button" onClick={onClick} className={className}>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
{inner}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
// --- 2. Runder --------------------------------------------------------
|
2026-07-25 07:51:32 +02:00
|
|
|
|
|
|
|
|
|
|
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
|
|
|
|
|
|
const visible = rounds.slice(0, 3)
|
2026-07-18 06:46:09 +02:00
|
|
|
|
return (
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
<section aria-label="Runder">
|
|
|
|
|
|
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
|
2026-07-25 07:51:32 +02:00
|
|
|
|
{loading ? (
|
|
|
|
|
|
<div className="flex justify-center py-8">
|
|
|
|
|
|
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
) : visible.length > 0 ? (
|
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
|
{visible.map((round) => (
|
|
|
|
|
|
<RoundCard key={round.id} round={round} />
|
|
|
|
|
|
))}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<EmptyState icon={Flag} title="Ingen runder på gang" description="Start en runde når du er på banen, så dukker den opp her mens du spiller.">
|
|
|
|
|
|
<ShortcutButton icon={Plus} label="Ny runde" href="/my-rounds/new" />
|
|
|
|
|
|
</EmptyState>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
2026-07-18 06:46:09 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- 3. Kommende turneringer -----------------------------------------------
|
2026-07-21 09:18:07 +02:00
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
function UpcomingTournaments({
|
|
|
|
|
|
entries,
|
|
|
|
|
|
onNewTournament,
|
|
|
|
|
|
onJoinWithCode,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
entries: CombinedTournament[]
|
|
|
|
|
|
onNewTournament: () => void
|
|
|
|
|
|
onJoinWithCode: () => void
|
|
|
|
|
|
}) {
|
2026-07-25 07:51:32 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<section aria-label="Kommende turneringer">
|
|
|
|
|
|
<SectionHeader title="Kommende turneringer" />
|
|
|
|
|
|
{entries.length > 0 ? (
|
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
|
{entries.map((e) => (
|
|
|
|
|
|
<TournamentCard key={e.tournament.id} tournament={e.tournament} orgId={e.orgId} organizer={e.organizer} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<EmptyState icon={Trophy} title="Ingen turneringer ennå" description="Bli med i en turnering med en kode fra arrangøren, eller opprett din egen.">
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<ShortcutButton icon={Trophy} label="Ny turnering" onClick={onNewTournament} />
|
|
|
|
|
|
<ShortcutButton icon={KeyRound} label="Bli med med kode" variant="outline" onClick={onJoinWithCode} />
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</EmptyState>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-21 09:18:07 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- 4. Statistikk ---------------------------------------------------------
|
2026-07-21 09:18:07 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function Sparkline({ values }: { values: number[] }) {
|
|
|
|
|
|
if (values.length < 2) return null
|
|
|
|
|
|
const max = Math.max(...values)
|
|
|
|
|
|
const min = Math.min(...values)
|
|
|
|
|
|
const range = max - min || 1
|
2026-07-21 09:18:07 +02:00
|
|
|
|
return (
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div aria-hidden="true" className="flex h-5 items-end gap-0.5">
|
|
|
|
|
|
{values.map((v, i) => {
|
|
|
|
|
|
const height = 30 + ((v - min) / range) * 70
|
|
|
|
|
|
return <div key={i} className="w-1 rounded-full bg-primary/40" style={{ height: `${height}%` }} />
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
2026-07-21 09:18:07 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
|
|
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<div className="flex flex-col gap-1.5 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
|
|
|
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<span className="text-2xl font-extrabold tabular-nums text-foreground sm:text-3xl">{value}</span>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function StatsSection({
|
|
|
|
|
|
roundsCount,
|
|
|
|
|
|
hcpNow,
|
|
|
|
|
|
hcpTrend,
|
|
|
|
|
|
hcpHistory,
|
|
|
|
|
|
avgToPar,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
roundsCount: number
|
|
|
|
|
|
hcpNow: string
|
|
|
|
|
|
hcpTrend: number | null
|
|
|
|
|
|
hcpHistory: number[]
|
|
|
|
|
|
avgToPar: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const TrendIcon = hcpTrend !== null && hcpTrend < 0 ? TrendingDown : TrendingUp
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section aria-label="Statistikk">
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
<SectionHeader
|
|
|
|
|
|
title="Statistikk"
|
|
|
|
|
|
action={roundsCount > 0 ? <SeeAllLink href="/my-rounds/stats" label="Se full statistikk" /> : null}
|
|
|
|
|
|
/>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
|
|
<StatTile label="Runder" value={String(roundsCount)} />
|
|
|
|
|
|
<StatTile label="HCP nå" value={hcpNow}>
|
|
|
|
|
|
{hcpTrend !== null ? (
|
|
|
|
|
|
<div className="flex items-center justify-between gap-1">
|
|
|
|
|
|
<span className="inline-flex items-center gap-1 text-xs font-bold text-foreground">
|
|
|
|
|
|
<TrendIcon aria-hidden="true" className="size-4 text-primary" />
|
|
|
|
|
|
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
|
|
|
|
|
|
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Sparkline values={hcpHistory} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-xs font-medium text-muted-foreground">Ingen data</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</StatTile>
|
|
|
|
|
|
<StatTile label="Snitt til par" value={avgToPar} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- 5. Spilte baner ---------------------------------------------------------
|
2026-07-18 06:46:09 +02:00
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
function PlayedCourses({ courses }: { courses: { name: string; visits: number; lastPlayed: string }[] }) {
|
2026-07-18 06:46:09 +02:00
|
|
|
|
return (
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<section aria-label="Spilte baner">
|
|
|
|
|
|
<SectionHeader title="Spilte baner" />
|
|
|
|
|
|
{courses.length > 0 ? (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
{courses.map((course) => {
|
|
|
|
|
|
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
|
|
|
|
|
return (
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
<li key={course.name}>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out hover:bg-accent/50 active:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
>
|
|
|
|
|
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
|
|
|
|
|
<MapPin aria-hidden="true" className="size-5" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
|
|
|
|
<span className="truncate text-base font-bold text-foreground">{course.name}</span>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<span className="truncate text-sm font-medium text-muted-foreground">
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
|
|
|
|
|
</span>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</span>
|
All done and verified live in the browser. Summary of this round:
"Kommende runder" → "Runder" on the dashboard.
Tjøme course-name duplicate fixed in the real database (one round's stale course_name_snapshot corrected) — all three of your rounds now group under one "Tjøme Golfklubb" entry.
Two new colors, per your request for two: lifted the already-validated --chart-3 (blue → --info) and --chart-4 (gold → --gold) into core design tokens. First real uses: a blue "Hcp spilt til X" badge on round cards, and a gold "Personlig rekord" badge on your best completed round.
Clicking a course under "Spilte baner" now opens a new page listing every round played there (/my-rounds/course/[name]) — caught and fixed a real double-encoding bug here via an actual browser screenshot before shipping.
Aggregated statistics, clickable from the dashboard's "Statistikk" section (/my-rounds/stats): rounds completed, avg to-par, putts/18 holes (implementing your padding rule exactly — unplayed holes count as 2 putts, only for rounds where putt-tracking was on), fairway%, GIR%, one-putt%, scrambling%, sand save%, and chip/bunker/penalty/anywayslag averages per round.
Everything is typechecked, the putts-padding logic is verified against a hand-computed synthetic dataset, and all new screens were checked live in the browser with no console errors.
2026-07-28 07:04:56 +02:00
|
|
|
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|
|
|
|
|
</Link>
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<EmptyState icon={MapPin} title="Ingen baner registrert" description="Banene du spiller dukker opp her, med antall besøk og når du sist spilte." />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
2026-07-18 06:46:09 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 07:51:32 +02:00
|
|
|
|
// --- 6. Venner ---------------------------------------------------------------
|
2026-07-25 08:37:07 +02:00
|
|
|
|
// ADR-036 fase 1 (venner-kjernen) er live -- ekte data fra GET /friends.
|
|
|
|
|
|
|
|
|
|
|
|
function friendInitials(first: string | null, last: string | null): string {
|
|
|
|
|
|
const f = (first ?? "").trim()[0] ?? ""
|
|
|
|
|
|
const l = (last ?? "").trim()[0] ?? ""
|
|
|
|
|
|
return (f + l).toUpperCase() || "?"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
|
// Fargede avatar-initialer (i stedet for alle i samme tonede grønn) --
|
|
|
|
|
|
// deterministisk per person via en enkel navne-hash, gjenbruker de
|
|
|
|
|
|
// allerede validerte statistikk-fargetokenene (chart-1..6, DESIGN_SYSTEM.md)
|
|
|
|
|
|
// i stedet for å finne opp nye, hardkodede farger. Literal klasse-strenger
|
|
|
|
|
|
// (ikke sammensatt av en variabel) siden Tailwind krever det for at JIT-en
|
|
|
|
|
|
// skal fange dem opp.
|
|
|
|
|
|
const AVATAR_PALETTE = [
|
|
|
|
|
|
"bg-chart-1/20 text-chart-1",
|
|
|
|
|
|
"bg-chart-2/20 text-chart-2",
|
|
|
|
|
|
"bg-chart-3/20 text-chart-3",
|
|
|
|
|
|
"bg-chart-4/20 text-chart-4",
|
|
|
|
|
|
"bg-chart-5/20 text-chart-5",
|
|
|
|
|
|
"bg-chart-6/20 text-chart-6",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
function avatarColorClass(first: string | null, last: string | null): string {
|
|
|
|
|
|
const name = `${first ?? ""}${last ?? ""}`
|
|
|
|
|
|
let hash = 0
|
|
|
|
|
|
for (let i = 0; i < name.length; i++) hash = (hash + name.charCodeAt(i)) % AVATAR_PALETTE.length
|
|
|
|
|
|
return AVATAR_PALETTE[hash]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 08:37:07 +02:00
|
|
|
|
function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
|
|
|
|
|
|
const friends = summary?.friends ?? []
|
|
|
|
|
|
const pending = summary?.incoming_requests.length ?? 0
|
2026-07-25 07:51:32 +02:00
|
|
|
|
|
2026-07-18 06:46:09 +02:00
|
|
|
|
return (
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<section aria-label="Venner">
|
|
|
|
|
|
<SectionHeader title="Venner" />
|
2026-07-25 08:37:07 +02:00
|
|
|
|
{friends.length > 0 ? (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-center sm:justify-between">
|
2026-07-25 08:37:07 +02:00
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<div className="flex items-center">
|
|
|
|
|
|
{friends.slice(0, 4).map((friend, i) => (
|
|
|
|
|
|
<span
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
className={cn(
|
2026-07-29 12:44:51 +02:00
|
|
|
|
"flex size-11 items-center justify-center rounded-full border-2 border-card text-sm font-bold",
|
|
|
|
|
|
avatarColorClass(friend.first_name, friend.last_name),
|
2026-07-25 08:37:07 +02:00
|
|
|
|
i > 0 && "-ml-3",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{friendInitials(friend.first_name, friend.last_name)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
2026-07-29 12:44:51 +02:00
|
|
|
|
{friends.length > 4 && (
|
|
|
|
|
|
<span className="-ml-3 flex size-11 items-center justify-center rounded-full border-2 border-card bg-muted text-xs font-bold text-muted-foreground">
|
|
|
|
|
|
+{friends.length - 4}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-07-25 08:37:07 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
<span className="text-base font-bold text-foreground">
|
|
|
|
|
|
{friends.length} {friends.length === 1 ? "venn" : "venner"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{pending > 0 ? (
|
|
|
|
|
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-brand-orange/15 px-2.5 py-0.5 text-xs font-bold text-foreground">
|
|
|
|
|
|
<span aria-hidden="true" className="size-1.5 rounded-full bg-brand-orange" />
|
|
|
|
|
|
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<span className="text-sm font-medium text-muted-foreground">Ingen nye forespørsler</span>
|
2026-07-25 08:37:07 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ShortcutButton icon={ArrowRight} label="Se venner" href="/my-friends" variant="outline" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<EmptyState icon={UserPlus} title="Finn vennene dine" description="Søk opp folk du spiller med, så kan dere følge hverandres runder og resultater.">
|
|
|
|
|
|
<ShortcutButton icon={UserPlus} label="Søk etter venner" href="/my-friends" />
|
|
|
|
|
|
</EmptyState>
|
|
|
|
|
|
)}
|
2026-07-25 07:51:32 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- 7. Organisasjoner (bevisst nedtonet) -----------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
if (organizations.length === 0) return null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex justify-center pt-2">
|
|
|
|
|
|
<DropdownMenu>
|
2026-07-29 12:44:51 +02:00
|
|
|
|
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-all duration-200 ease-in-out hover:text-foreground active:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
2026-07-25 07:51:32 +02:00
|
|
|
|
<Building2 aria-hidden="true" className="size-4" />
|
|
|
|
|
|
Dine organisasjoner ({organizations.length})
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="center" className="w-64 rounded-2xl p-1.5">
|
|
|
|
|
|
{organizations.map((o) => (
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
key={o.organization_id}
|
|
|
|
|
|
onClick={() => router.push(`/organizations/${o.organization_id}/members?name=${encodeURIComponent(o.name)}`)}
|
|
|
|
|
|
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
|
>
|
|
|
|
|
|
{o.name}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
2026-07-18 06:46:09 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|