diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index c84c774..e9ed47c 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -404,7 +404,40 @@
"Bash(openssl rand *)",
"Bash(curl -s -X POST http://localhost:18100/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"scratch-official-e2e@testmailbox.golf\"}')",
"Bash(docker rm -f teecup_frontend_scratch6_run teecup_api_scratch6_run teecup-minio-scratch6 2>&1 *)",
- "Bash(grep -n \"^[0-9]\\\\{1,2\\\\}\\\\. \\\\*\\\\*\" /opt/teecup/CHANGELOG.md)"
+ "Bash(grep -n \"^[0-9]\\\\{1,2\\\\}\\\\. \\\\*\\\\*\" /opt/teecup/CHANGELOG.md)",
+ "Bash(grep -n \"my-rounds/\\\\${friend\" /opt/teecup/frontend/components/dashboard.tsx)",
+ "Bash(grep -n \"href=\\\\`/my-rounds\\\\|href={\\\\`/my-rounds\\\\|round.round_id\\\\|friend.round_id\" /opt/teecup/frontend/components/dashboard.tsx)",
+ "Bash(curl -s -o /dev/null -w \"dashboard: %{http_code}\\\\n\" https://teecup.golf/dashboard)",
+ "Bash(curl -s -o /dev/null -w \"teeoff.no upåvirket: %{http_code}\\\\n\" https://teeoff.no)",
+ "Bash(curl -s https://teecup.golf/health)",
+ "Bash(curl -s -o /dev/null -w \"icon.svg: %{http_code}\\\\n\" https://teecup.golf/icon.svg)",
+ "Bash(curl -s -o /dev/null -w \"apple-icon.png: %{http_code}\\\\n\" https://teecup.golf/apple-icon.png)",
+ "Bash(curl -s -o /dev/null -w \"manifest icon-512: %{http_code}\\\\n\" https://teecup.golf/icons/icon-512.png)",
+ "Bash(grep -n \"^function \\\\|^export function \\\\|fetch\\(\\\\`/rounds\\\\|fetch\\(\\\\\"/rounds\\\\|ws/rounds\\\\|href={\\\\`/my-rounds\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
+ "Bash(grep -n 'fetch\\(`/rounds\\\\|fetch\\(\"/rounds\\\\|ws/rounds\\\\|href={`/my-rounds\\\\|href=\"/my-rounds' /opt/teecup/frontend/components/round-scorecard.tsx)",
+ "Bash(grep -n \"const endpoint\\\\|/rounds/\\\\${roundId}/sides\\\\|/rounds/\\\\${roundId}/participants\" /opt/teecup/frontend/components/round-scorecard.tsx)",
+ "Bash(grep -n 'href=\"/my-rounds\"\\\\|href={`/my-rounds' /opt/teecup/frontend/components/round-scorecard.tsx)",
+ "Bash(grep -n 'fetch\\(`/rounds\\\\|fetch\\(\"/rounds\\\\|/ws/rounds\\\\|href={`/my-rounds\\\\|href=\"/my-rounds\\\\|Rediger\\\\|Slett\\\\|Fullfør\\\\|Medspiller' /opt/teecup/frontend/components/round-scorecard.tsx)",
+ "Bash(grep -n 'href={`/my-rounds' /opt/teecup/frontend/components/round-leaderboard.tsx)",
+ "Bash(grep -n 'fetch\\(`/rounds\\\\|fetch\\(\"/rounds\\\\|/ws/rounds\\\\|Rediger\\\\|Slett runde\\\\|Fullfør runde\\\\|Medspiller' /opt/teecup/frontend/components/round-leaderboard.tsx)",
+ "Bash(mkdir -p /opt/teecup/frontend/app/watch/\\\\[id\\\\]/scorecard /opt/teecup/frontend/app/watch/\\\\[id\\\\]/leaderboard)",
+ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18202/)",
+ "Bash(curl -s -o /dev/null -w \"API flight-group \\(random id\\): %{http_code}\\\\n\" https://teecup.golf/public/rounds/00000000-0000-0000-0000-000000000000/flight-group)",
+ "Bash(curl -s -o /dev/null -w \"watch page: %{http_code}\\\\n\" https://teecup.golf/watch/00000000-0000-0000-0000-000000000000)",
+ "Bash(curl -s -o /dev/null -w \"watch scorecard page: %{http_code}\\\\n\" https://teecup.golf/watch/00000000-0000-0000-0000-000000000000/scorecard)",
+ "Bash(curl -s -o /dev/null -w \"watch leaderboard page: %{http_code}\\\\n\" https://teecup.golf/watch/00000000-0000-0000-0000-000000000000/leaderboard)",
+ "Bash(curl -s -X POST http://localhost:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"mort.wo25@gmail.com\",\"locale\":\"nb\"}' --max-time 10 -o /dev/null -w 'status=%{http_code}\\\\n')",
+ "Bash(chmod 777 /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies)",
+ "Bash(rm -f /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies/*.txt /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies/body.txt)",
+ "Bash(mkdir -p v0-export-34)",
+ "Bash(unzip -o \"/opt/teecup/tee-cup-login-screen \\(34\\).zip\" -d v0-export-34)",
+ "Bash(npm run *)",
+ "Bash(awk '/^export function MatchScorecardGrid/{f=1} f{print NR\": \"$0} f && /^}/{c++; if\\(c==1\\) exit}' /opt/teecup/frontend/components/round-scorecard.tsx)",
+ "Bash(awk -F: '$1>=781')",
+ "Bash(grep -n \"return { round, leaderboard\" /opt/teecup/frontend/components/round-leaderboard.tsx)",
+ "WebFetch(domain:docs.mapbox.com)",
+ "Bash(python3 -m py_compile app/routers/rounds.py app/routers/round_messages.py app/config.py)",
+ "Bash(npm view *)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",
diff --git a/.gitignore b/.gitignore
index cff5543..aac7154 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.env
__pycache__/
*.pyc
+Temp-uploads/
diff --git a/059_post_reactions_and_comments.sql b/059_post_reactions_and_comments.sql
new file mode 100644
index 0000000..0faa9c3
--- /dev/null
+++ b/059_post_reactions_and_comments.sql
@@ -0,0 +1,107 @@
+-- =====================================================================
+-- TeeCup — Reaksjoner (emoji) + trådede kommentarer på innlegg,
+-- migrasjon 059 (ADR-046).
+--
+-- To tabellpar, ett per eksisterende "innlegg"-tabell -- IKKE én
+-- polymorf tabell på tvers av tenant-grensen (ADR-044 Beslutning C sin
+-- begrunnelse gjelder identisk her: round_message har ingen
+-- organization_id/RLS (ADR-033 Beslutning A), message har begge
+-- (ADR-025) -- CLAUDE.md sin invariant "organization_id på alle
+-- domenetabeller, håndhevet av RLS" krever egen RLS-håndtering for
+-- org-siden uansett).
+--
+-- Én reaksjon per bruker per innlegg (UPSERT ved bytte av emoji, ikke
+-- en stabel). Kommentarer lagres FLATT med parent_comment_id --
+-- frontend bygger tre-strukturen. Sletting av en kommentar kaskade-
+-- sletter undertråden (samme "kun slett, aldri rediger"-filosofi som
+-- round_message/message selv).
+-- =====================================================================
+\set ON_ERROR_STOP on
+
+-- ---------------------------------------------------------------------
+-- round_message -- ingen RLS, speiler round_message selv (ADR-033)
+-- ---------------------------------------------------------------------
+
+CREATE TABLE round_message_reaction (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ round_message_id uuid NOT NULL REFERENCES round_message(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES app_user(id),
+ emoji text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ UNIQUE (round_message_id, user_id)
+);
+
+CREATE INDEX round_message_reaction_message_idx ON round_message_reaction (round_message_id);
+
+-- INGEN RLS -- samme plain_connection()-mønster som round_message selv.
+GRANT SELECT, INSERT, UPDATE, DELETE ON round_message_reaction TO teecup_app;
+
+CREATE TABLE round_message_comment (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ round_message_id uuid NOT NULL REFERENCES round_message(id) ON DELETE CASCADE,
+ parent_comment_id uuid REFERENCES round_message_comment(id) ON DELETE CASCADE,
+ author_user_id uuid NOT NULL REFERENCES app_user(id),
+ -- Frosset ved skrivetidspunkt, samme prinsipp som round_message.author_display_name.
+ author_display_name text NOT NULL,
+ body text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ CHECK (length(btrim(body)) > 0)
+);
+
+CREATE INDEX round_message_comment_message_idx ON round_message_comment (round_message_id, created_at);
+CREATE INDEX round_message_comment_parent_idx ON round_message_comment (parent_comment_id);
+
+GRANT SELECT, INSERT, DELETE ON round_message_comment TO teecup_app;
+
+-- ---------------------------------------------------------------------
+-- message (lag-chat + org-oppslagstavle) -- RLS'et, org-tenant-invariant
+-- ---------------------------------------------------------------------
+
+CREATE TABLE message_reaction (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
+ message_id uuid NOT NULL REFERENCES message(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES app_user(id),
+ emoji text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ UNIQUE (message_id, user_id)
+);
+
+CREATE INDEX message_reaction_message_idx ON message_reaction (message_id);
+
+ALTER TABLE message_reaction ENABLE ROW LEVEL SECURITY;
+ALTER TABLE message_reaction FORCE ROW LEVEL SECURITY;
+
+CREATE POLICY org_isolation ON message_reaction
+ USING (organization_id = app_current_org())
+ WITH CHECK (organization_id = app_current_org());
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON message_reaction TO teecup_app;
+
+CREATE TABLE message_comment (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
+ message_id uuid NOT NULL REFERENCES message(id) ON DELETE CASCADE,
+ parent_comment_id uuid REFERENCES message_comment(id) ON DELETE CASCADE,
+ author_user_id uuid NOT NULL REFERENCES app_user(id),
+ author_display_name text NOT NULL,
+ body text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+
+ CHECK (length(btrim(body)) > 0)
+);
+
+CREATE INDEX message_comment_message_idx ON message_comment (message_id, created_at);
+CREATE INDEX message_comment_parent_idx ON message_comment (parent_comment_id);
+
+ALTER TABLE message_comment ENABLE ROW LEVEL SECURITY;
+ALTER TABLE message_comment FORCE ROW LEVEL SECURITY;
+
+CREATE POLICY org_isolation ON message_comment
+ USING (organization_id = app_current_org())
+ WITH CHECK (organization_id = app_current_org());
+
+GRANT SELECT, INSERT, DELETE ON message_comment TO teecup_app;
diff --git a/060_round_shot.sql b/060_round_shot.sql
new file mode 100644
index 0000000..ba4396a
--- /dev/null
+++ b/060_round_shot.sql
@@ -0,0 +1,75 @@
+-- =====================================================================
+-- TeeCup — Slag-for-slag GPS-avstandsmåling, migrasjon 060 (ADR-048).
+--
+-- IKKE en gjenåpning av ADR-033s avvisning av fri slag-for-slag-logging
+-- som DATAMODELL for hull-statistikk. Dette er en separat, valgfri
+-- måle-funksjon: spilleren kan måle avstanden på ett eller flere
+-- enkeltslag med GPS, uavhengig av det faste hull-statistikk-skjemaet.
+-- `round_hole` sine faste felt (club_off_tee, putts, osv.) er UBERØRT.
+--
+-- Eierskap: round_shot henger kun av round_hole.id -- IKKE direkte av
+-- round_participant_id/round_side_id. round_hole har allerede en XOR
+-- mellom disse to (round_hole_owner_xor, migrasjon 031), så round_shot
+-- arver eierskapet (individuell ELLER lagformat) transitivt uten å
+-- duplisere XOR-logikken selv. Dette dekker BEGGE hull-typer fra v1
+-- (ADR-048 Beslutning C).
+--
+-- Eier-mønster for øvrig: samme non-RLS plain_connection() + WHERE-
+-- filtrering som resten av frittstående-runde-subsystemet (ADR-033
+-- Beslutning A).
+--
+-- Koordinater: rene double precision lat/lng-par, IKKE PostGIS. Volum er
+-- maks noen titalls rader per runde, og eneste geometriske operasjon er
+-- punkt-til-punkt storsirkel-avstand (Haversine), regnet CLIENT-SIDE i
+-- JS -- PostGIS ville vært en ny, tung avhengighet uten reell gevinst
+-- her (ADR-048 Beslutning D).
+-- =====================================================================
+\set ON_ERROR_STOP on
+
+CREATE TABLE round_shot (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ round_hole_id uuid NOT NULL REFERENCES round_hole(id) ON DELETE CASCADE,
+ -- Hvem som MÅLTE slaget -- ikke nødvendigvis samme som round_hole sin
+ -- eier, en medspiller kan føre for flighten (samme skrive-autorisasjons-
+ -- modell som update_hole/side-PATCH allerede bruker).
+ recorded_by_user_id uuid NOT NULL REFERENCES app_user(id),
+
+ -- Eksplisitt, hull-tolerant rekkefølge (IKKE ORDER BY captured_at) --
+ -- lar et slag midt i sekvensen slettes uten omnummerering av resten,
+ -- og unngår at GPS-tidsstempel-drift/bakgrunnsthrottling noensinne
+ -- stille endrer rekkefølgen.
+ shot_number integer NOT NULL CHECK (shot_number > 0),
+
+ club text NOT NULL CHECK (char_length(club) <= 50),
+ -- Øvre grense 500m er en fornuftssjekk mot GPS-glitch/feiltrykk, ikke
+ -- en golf-regel (lengste realistiske driver ligger rundt 370m).
+ distance_meters numeric(6,1) NOT NULL CHECK (distance_meters > 0 AND distance_meters < 500),
+
+ start_method text NOT NULL CHECK (start_method IN ('gps', 'map_tap')),
+ start_lat double precision NOT NULL CHECK (start_lat BETWEEN -90 AND 90),
+ start_lng double precision NOT NULL CHECK (start_lng BETWEEN -180 AND 180),
+ end_lat double precision NOT NULL CHECK (end_lat BETWEEN -90 AND 90),
+ end_lng double precision NOT NULL CHECK (end_lng BETWEEN -180 AND 180),
+
+ -- Deling til rundens kommentarfelt (ADR-044) -- lenke, ikke bare et
+ -- bool-flagg, slik at "delt" alltid reflekterer om meldingen FAKTISK
+ -- finnes (ON DELETE SET NULL: slettes meldingen separat i feeden,
+ -- forblir slaget "målt, men ikke lenger delt", ikke ugyldig).
+ shared_round_message_id uuid REFERENCES round_message(id) ON DELETE SET NULL,
+
+ captured_at timestamptz NOT NULL DEFAULT now(),
+
+ UNIQUE (round_hole_id, shot_number)
+);
+
+CREATE INDEX round_shot_hole_idx ON round_shot (round_hole_id, shot_number);
+
+-- INGEN RLS -- samme plain_connection()-mønster som round_message/
+-- round_hole (ADR-033 Beslutning A). Autorisasjon i app-laget.
+GRANT SELECT, INSERT, DELETE ON round_shot TO teecup_app;
+-- Ingen generell UPDATE-grant -- samme "slett og opprett på nytt, aldri
+-- rediger"-prinsipp som round_message (et feilmålt slag slettes og måles
+-- på nytt). ETT eksplisitt, smalt unntak: å knytte et allerede opprettet
+-- slag til en round_message som opprettes i et eget, påfølgende
+-- delings-steg (se app/routers/rounds.py, share_shot).
+GRANT UPDATE (shared_round_message_id) ON round_shot TO teecup_app;
diff --git a/2026-07-29_124807.png b/2026-07-29_124807.png
deleted file mode 100644
index 7dcca58..0000000
Binary files a/2026-07-29_124807.png and /dev/null differ
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 10f0d40..9f22544 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -4181,7 +4181,480 @@ slett-knapp på `/my-feed` (kun fra runde-siden).
---
-## Åpne spørsmål (ikke besluttet ennå)
+## ADR-045: Tilskuer-visning (`/watch/[id]`) — tre faner + "Spillere og runde", full paritet med eiersiden
+
+Reist av brukeren 2026-08-06 (video-illustrasjon av bugen): en tilskuer som
+klikker seg inn på en venns pågående runde via "Venner på banen" endte på
+`/my-rounds/{id}` (eier-/medspiller-only, `_get_accessible_round_or_404`)
+i stedet for `/watch/{id}` (den faktiske tredjepartsvisningen, ADR-036
+fase 2) — rettet samme dag som en liten, isolert lenke-bug (se CHANGELOG
+2026-08-06, punkt 26). I samme runde ba brukeren om at `/watch/[id]`
+(fram til da ÉN enkelt side, kun kompakt status + leaderboard/matchstatus)
+skulle få samme tre faner som eiersiden (Score/Scorekort/Leaderboard) og
+vise "Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag,
+flight-gruppering), uten eier-kun-handlingene (Rediger/Fullfør/Slett
+runde, +Medspiller).
+
+**Beslutning A — Full formatparitet via delt komponent + `publicMode`-
+bryter, IKKE nye, forenklede tilskuer-komponenter.** `round-scorecard.tsx`
+(`RoundScorecard`) og `round-leaderboard.tsx` (`RoundLeaderboard`) sin
+faktiske rutenett-/tavle-rendring var ALLEREDE ren, skrivefri visning —
+redigering skjer et helt annet sted (Score-fanens `ScoringWizard` i
+`round-detail.tsx`). Det eneste som hindret gjenbruk for tilskuere var at
+alle interne data-hentinger (flere hooks: `useFormatResult`,
+`useFlightTeamStandings`, `useLeaderboardData`, pluss selve
+`RoundScorecard`/`RoundLeaderboard` sine egne fetch-effekter) hardkodet
+`/rounds/*`-prefikset og en autentisert WebSocket. Løsning: en ny,
+valgfri `publicMode`-prop (default `false`, INGEN endring i eiersidens
+oppførsel) som bytter alle interne URL-er til `/public/rounds/*` og
+WS-kanalen til `/ws/public/rounds/{id}/live`. Dette gir ekte full
+formatparitet (alle formater, ikke et utvalg) for null ekstra
+vedlikeholdshold -- samme kode, to datakilder -- fremfor å bygge og
+vedlikeholde en parallell, forenklet visning. Bekreftet eksplisitt med
+bruker (AskUserQuestion) at full detalj var ønsket, ikke en forenklet
+fellesvisning.
+
+**Beslutning B — Ingen gjenbruk av `RoundHeader`/`RoundPageShell` for
+selve fane-navigasjonen.** `RoundPageShell` gjør sitt eget autentiserte
+`fetch('/rounds/{id}')`-kall og hardkoder `/my-rounds/{id}`-hrefs.
+`RoundHeader` er i og for seg props-styrt, men "Administrer"-tannhjulet
+render ALLTID og åpner `ManageRoundDialog` (Rediger/Fullfør/Slett-runde)
+uansett om handlere er gitt inn — ingen eksisterende prop slår av selve
+admin-seksjonen. Bygget i stedet en egen, liten `WatchTabs`-komponent
+(`frontend/components/watch-tabs.tsx`) uten noen admin-vei i det hele
+tatt -- ingen risiko for at en eier-handling vises ved et uhell, siden
+koden rett og slett ikke inneholder den.
+
+**Beslutning C — Ny, egen "Spillere og runde"-komponent
+(`watch-players.tsx`), IKKE et forsøk på å gjøre `round-detail.tsx` sin
+lokale `PlayerList`-funksjon gjenbrukbar via `canManage=false`.**
+`PlayerList` er ikke eksportert, tett koblet til eier-autentiserte
+handlere, og selv `canManage`-proppen dekker ikke alt konsekvent i dag
+("Fullfør runde"-knappen der er kun gated på `!completed`, ikke
+`isOwnerViewer` -- en eksisterende, urelatert kvalitetsbrist, ikke
+arvet inn her). Den nye komponenten inneholder ingen +Medspiller-/
+Rediger-/Fullfør-/Slett-kode i det hele tatt. Viser deltakerkort
+(navn, "Eier"-merke -- bevisst INGEN "Deg"-merke, siden
+`PublicRoundParticipantOut` ikke eksponerer `user_id` og det uansett
+ikke gir mening i tilskuer-rammen), Utslag/HCP/Tildelte slag (allerede
+offentlig eksponert data), "Så langt"-progresjon (fra
+`/public/rounds/{id}/leaderboard`), og flight-gruppering.
+
+**Beslutning D — Nytt offentlig endepunkt `GET /public/rounds/{id}/
+flight-group`.** Speiler den eksisterende autentiserte `/rounds/{id}/
+flight-group`, men med en ny `_viewable_flight_rows`-variant av
+`_accessible_flight_rows` som bruker `_can_view_round` (eier/medspiller/
+`public`/riktig-kategorisert `friends`-venn) per SØSKEN-flight uavhengig
+-- en flight-gruppe kan ha søsken med ulik `visibility_mode`, og en
+tilskuer skal kun se de søsknene de faktisk har innsyn i, akkurat som
+selve anker-runden. Avklart eksplisitt med bruker (AskUserQuestion) at
+flight-gruppering skulle bygges nå, ikke utsettes.
+
+**Lukket, relatert gap:** den gamle `watch-round.tsx` sin lokale
+`TWO_SIDED_FORMATS`-liste dekket kun 8 av 17 formater (manglet
+`copenhagen/bbb/flag/shamble/money_ball/scramble_solo/scramble_solo_
+match`, som falt til "Ingen live-visning tilgjengelig ennå"). Løst
+automatisk av Beslutning A (samme `RoundLeaderboard`-komponent som
+eiersiden, som allerede dekker alle formater) -- ikke en egen,
+ekstra oppgave.
+
+**Verifisert grundig, isolert scratch-miljø** (DB/rolle/MinIO/API-/
+frontend-container, pluss en minimal scratch-Caddy for ekte WS-testing,
+samme mønster som ADR-044): 6/6 håndregnede sjekker for det nye
+flight-group-endepunktet (anonym ser kun offentlige søsken-flighter,
+en venn med riktig kategori ser i tillegg `friends`-synlige, eieren ser
+alle, en fremmed uten vennskap ser kun offentlige, en privat anker-runde
+avvises helt for anonym). `tsc --noEmit` rent på hele frontend-prosjektet
+etter refaktoren. Ekte nettleser: **regresjon FØRST** -- eiersidens
+`/my-rounds/{id}/scorecard` og `/leaderboard` bekreftet uendret (samme
+tall, samme layout) FØR noe nytt ble testet. Deretter tilskuer-sidene:
+alle tre faner, "Spillere og runde" uten eier-knapper, ETT allerede-
+dekket format (`stroke`) OG ETT tidligere udekket format (`flag`,
+Flaggturnering) begge bekreftet å rendre korrekt gjennom
+`/watch/{id}/leaderboard` -- beviser formatparitet-lukkingen direkte,
+ikke bare i teorien. En privat rundes tilskuer-sider (alle tre) bekreftet
+korrekt avvist for en anonym leser (egen isolert nettleser-kontekst,
+ikke bare et API-kall) med riktig feilmelding og "Tilbake til
+dashbordet"-lenke. Scratch-miljøet ryddet opp fullstendig etter bruk.
+
+**Bevisst utenfor omfang:** enhver endring i selve eiersidens
+`PlayerList`/`ManageRoundDialog`-logikk (den nevnte "Fullfør runde"-
+gating-kvaliteten er en egen, urelatert observasjon).
+
+---
+
+## ADR-046: Emoji-reaksjoner + trådede kommentarer på innlegg
+
+Reist av brukeren 2026-08-06, som del av samme melding som "nyeste først,
+over alt": "man skal kunne like (eller bruke andre emojier) og kommentere
+på innlegg." Avklart eksplisitt med bruker (AskUserQuestion, tre
+spørsmål): (1) "innlegg" = BÅDE de eksisterende meldingene (rundekommentar/
+lag-chat-melding/oppslagstavle-post) OG dashbord-feedens oppføringer, (2)
+kommentarer skal være TRÅDET (svar på svar, flere nivåer), (3) reaksjoner
+følger samme omfang som kommentarer.
+
+**Nøkkelfunn fra research:** dashbord-feeden (`/my-feed`, ADR-044
+Beslutning E) er IKKE en egen entitetstype ("runde fullført"-kort) — hver
+feed-oppføring ER ganske enkelt én `round_message`-rad, aggregert på tvers
+av runder. Å bygge reaksjoner/kommentarer på `round_message` dekker derfor
+AUTOMATISK både rundens egen kommentarseksjon og feeden — ingen egen
+tredje entitetstype trengtes. De reelle "innleggs"-typene var dermed kun
+to underliggende tabeller: `round_message` (ADR-044, INGEN
+`organization_id`/RLS) og `message` (ADR-025, `organization_id` NOT NULL
++ RLS, diskriminert på `scope` for lag-chat/oppslagstavle).
+
+**Beslutning A — To parallelle tabellpar, IKKE én polymorf tabell.**
+Migrasjon 059: `round_message_reaction`/`round_message_comment` (ingen
+RLS, speiler `round_message` selv) og `message_reaction`/`message_comment`
+(RLS'et via `app_current_org()`, speiler `message`). Samme presedens som
+ADR-044 Beslutning C — en delt tabell på tvers av RLS/ikke-RLS-grensen
+ville krevd enten en kunstig organisasjon eller et RLS-unntak, og
+CLAUDE.md sin invariant ("organization_id på alle domenetabeller,
+håndhevet av RLS") krever egen RLS-håndtering for org-siden uansett.
+
+**Beslutning B — Reaksjoner: fast kuratert sett, én per bruker per
+innlegg.** Seks emojier (👍 ❤️ 😂 😮 😢 🙏), validert i app-laget (400 ved
+ukjent emoji) — IKKE et fritt emoji-utvalg. Bytte av emoji ERSTATTER
+(UPSERT via `ON CONFLICT (post_id, user_id) DO UPDATE`), stables ikke —
+matcher "like (eller bruke andre emojier)"-fraseringen (velg ÉN).
+
+**Beslutning C — Kommentarer: flat lagring, kun tekst, kaskade-sletting.**
+`parent_comment_id` selv-referanse, lagret FLATT — frontend bygger
+tre-strukturen fra en kronologisk (eldst-først) liste (motsatt av resten
+av appens "nyeste først"-strømmer, bevisst: en samtaletråd leses
+top-til-bunn, ulikt en oppdateringsstrøm). Ingen bilde i selve
+kommentaren (innlegget har allerede sitt eget). Sletting av en kommentar
+med svar under KASKADE-SLETTER hele undertråden (FK `ON DELETE CASCADE`)
+— ikke myk-slett-med-plassholder, samme "kun slett, aldri rediger"-
+filosofi som `round_message`/`message` selv. Frontend viser en eksplisitt
+advarsel ("Sletter du denne, forsvinner også svarene under.") før
+bekreftet sletting av en kommentar med svar.
+
+**Beslutning D — Autorisasjon speiler eksisterende regler per
+innleggstype, ingen ny modell.** Hvem kan reagere/kommentere = hvem kan
+POSTE selve innlegget (rundekommentar: `_can_view_round`/"kan se = kan
+bidra"; lag-chat: rostret på laget; oppslagstavle: `_may_post_to_feed`).
+Hvem kan slette en kommentar = samme regel som innleggstypen allerede
+bruker for å slette selve innlegget (rundekommentar: forfatter ELLER
+rundeeier; lag-chat: KUN forfatter, ingen unntak; oppslagstavle: forfatter
+ELLER org-admin via `is_org_admin`). Egen reaksjon fjernes alltid fritt av
+den som satte den.
+
+**Beslutning E — Ingen ny WebSocket-payload-kanal.** Samme minimalt-
+fotavtrykk-valg som ADR-044 Beslutning D: klienten refetcher reaksjons-/
+kommentaroppsummeringen kun etter EGEN handling, ingen live-push til andre
+samtidige seere i v1. **Viktig rettelse funnet under nettleserverifisering:**
+de nye kommentar-endepunktene i `round_messages.py` kalte først
+`broadcast_round_update()` (kopiert inn ved en inkonsekvens mot egen
+plan) — dette trigget rundens EKSISTERENDE "noe endret seg"-WS-tick, som
+`RoundMessages` reagerer på med en full refetch av HELE meldingslisten,
+og med det kollapset enhver allerede-utvidet kommentartråd ANDRE STEDER
+på siden ved hver eneste kommentarhandling. Fjernet fra de nye
+kommentar-endepunktene (reaksjons-endepunktene hadde aldri kallet) —
+oppdaget og rettet FØR utrulling, takket være ekte nettleserverifisering
+i steget rett etterpå (ikke bare et API-testskript).
+
+**Backend:** `app/routers/round_messages.py` fikk
+`PUT/DELETE /rounds/{id}/messages/{id}/reaction` og
+`GET/POST /rounds/{id}/messages/{id}/comments` +
+`DELETE .../comments/{id}`, pluss `reactions`/`comment_count`-felt lagt
+til på `RoundMessageOut` OG `FeedEntryOut` (grupperte batch-spørringer,
+ingen N+1). `app/routers/messaging.py` fikk samme sett ×2 (lag-chat +
+oppslagstavle), importerer `ALLOWED_REACTION_EMOJIS`/`ReactionSummary`/
+`ReactionIn`/`CommentIn` fra `round_messages.py` for å unngå duplisert
+typedefinisjon (bevisst IKKE en delt SQL-hjelpemodul på tvers av filene,
+siden `plain_connection()` vs. `org_connection()` uansett gjør
+spørringene ulike nok at abstraksjon over filgrensen ville vært kunstig).
+
+**Frontend:** ny delt komponent `post-engagement.tsx` — reaksjonsrad
+(kuratert sett, egen valgt fremhevet grønt) + trådet kommentarfelt (lazy-
+lastet ved utvidelse, "Svar"-knapp per kommentar med innrykk per nivå,
+kaskade-advarsel ved sletting), bygget via ÉN Claude-skrevet V0-prompt
+(standard arbeidsfordeling) og integrert hånd-kodet i FIRE eksisterende
+steder: `round-messages.tsx`, `feed.tsx` (`FeedCard`), `team-chat.tsx`,
+`public-tournament.tsx` (`TournamentFeed`). To bevisste
+integrasjonsjusteringer utover ren copy-inn: (1) byttet V0-eksportens
+egendefinerte inline SVG-ikoner til `lucide-react` (`MessageSquare`/
+`Reply`/`Trash2`) for å følge DESIGN_SYSTEM.md sin "ingen annen ikonpakke
+blandet inn"-regel, (2) `feed.tsx` sin `FeedCard` var opprinnelig ÉN stor
+`` som pakket inn HELE kortet — `PostEngagement` måtte flyttes UT av
+selve lenken (til en søskenposisjon under) for å unngå ugyldig nøstet
+interaktivt innhold og utilsiktet navigering ved klikk på en
+reaksjonsknapp.
+
+**Bevisst forenklet, ikke en bug:** `public-tournament.tsx` sin
+`TournamentFeed` setter `canModerate={false}` alltid — den ekte "forfatter
+ELLER org-admin"-regelen håndheves korrekt av BACKEND uansett (bekreftet i
+autorisasjonsmatrisen under), men frontend mangler foreløpig et signal for
+"er denne innloggede brukeren org-admin i akkurat denne organisasjonen" å
+style et ekstra slett-ikon med for andres kommentarer. En org-admin som
+vil moderere andres kommentar kan det (API-et tillater det), men ser ikke
+en slett-knapp for det i UI-et ennå — en ren UI-fullstendighets-luke, ikke
+et sikkerhetshull. Kan lukkes senere ved å tilføye ett felt (f.eks. i
+tournament-info-oppslaget) uten skjemaendring.
+
+**Scratch-verifisert grundig** (isolert scratch-DB/rolle fra
+`teecup_db`-mal, egen API-container): full autorisasjonsmatrise for alle
+tre innleggstyper (reager/bytt-reaksjon-som-upsert/fjern-reaksjon,
+kommentér på toppnivå, svar på en kommentar, slett en kommentar med svar
+under og bekreft kaskade, riktig avvisning per type sin faktiske
+post-/slette-regel — lag-chat kun forfatter, oppslagstavle forfatter-
+eller-org-admin via ekte org-medlemskap, rundekommentar forfatter-eller-
+rundeeier). RLS-isolasjon for `message_reaction` bekreftet eksplisitt
+(annen orgs `app.current_org` ser 0 rader). `/feed`-aggregeringen bekreftet
+å vise samme reaksjons-/kommentardata som selve rundesiden (samme
+`round_message.id` gjenbrukt). `test_isolation.sql` 12/12 uendret.
+Ekte typesjekket produksjonsbuild av frontend kjørt og bekreftet (fanget
+INGEN nye feil, ren build). **Ekte nettleserverifisering** (egen scratch-
+Caddy for sesjon/WS): reagert, byttet reaksjon (upsert bekreftet visuelt),
+postet trådet samtale (rot + svar, riktig innrykk), slettet en kommentar
+med svar og bekreftet kaskaden fjernet begge fra UI-et, bekreftet
+`feed.tsx` sin `PostEngagement` IKKE trigger navigering ved reaksjonsklikk
+(Link-nøsting-fiksen virker), bekreftet `team-chat.tsx` og
+`public-tournament.tsx` sin Oppslagstavle begge rendrer komponenten
+korrekt uten konsollfeil. Fant og rettet WS-tick-regresjonen (Beslutning
+E) i dette steget. Scratch-miljøet (DB/rolle/API-/frontend-/Caddy-
+containere/images) ryddet opp fullstendig etter bruk.
+
+**Bevisst utenfor omfang:** push-varsler ved ny kommentar/reaksjon,
+sanntids-oppdatering hos ANDRE samtidige seere, fritt emoji-utvalg,
+redigering av kommentarer, myk-slett-med-plassholder.
+
+---
+
+## ADR-047: Leaderboard — lukk gapet mot en konkurrentapp-referanse, uten å bygge om fra bunnen
+
+Brukeren viste en video av GolfGameBook sitt leaderboard (trykk-for-å-
+utvide rad → fullt hull-for-hull-scorekort inline + sosiale handlinger
+per spiller) og ba om noe minst like bra i TeeCup — eksplisitt **IKKE**
+en kopi av selve utseendet.
+
+**Nøkkelfunn fra research, som endret omfanget vesentlig fra "bygg dette
+fra bunnen":** det meste av kjernemekanikken referansen viser var
+ALLEREDE bygget og live i `round-leaderboard.tsx`, fra en tidligere runde
+(FEATURE_BACKLOG.md "Leaderboard for runder og turneringer", 2026-07-26)
+som selv var inspirert av samme konkurrentapp — og allerede DA bevisst
+designet med egen form+farge-språk (sirkel/firkant, grønn/oransje) i
+stedet for å kopiere referansens fargebruk. `IndividualBoard` (dekker
+`stroke/stableford/skins/flag/bbb/copenhagen`) hadde allerede utvidbare
+rader (`IndividualRow`) med et fullt hull-for-hull-rutenett
+(`HoleByHole`). Det som faktisk manglet: (1) ingen sosial/kommentar-
+tilgang noe sted i leaderboardet, (2) ingen kompakt oppsummeringslinje
+under rutenettet, (3) `TwoSidedBoard`/`HighLowBoard` (to-sidede formater)
+hadde INGEN utvidelse i det hele tatt — kun en statisk hull-strip, (4)
+`TeamFlightBoard` hadde en flat, ikke-utvidbar råscore-liste.
+
+**Avklart med bruker (AskUserQuestion, to spørsmål) FØR bygging:**
+1. **Sosial-kobling:** `post-engagement.tsx` (ADR-046) reagerer/
+ kommenterer på ETT spesifikt `round_message`-innlegg, ikke runden som
+ helhet — det finnes intet "innlegg per deltaker" i skjemaet (og
+ GolfGameBooks egen "Kommentar til spillefeeden"-knapp åpner uansett
+ samme delte feed uansett hvilken rad man trykker på). **Svar: delt
+ tråd, lenket fra hver rad** — ingen ny per-deltaker-entitet, ingen ny
+ migrasjon, ingen backend-endring i det hele tatt denne runden.
+2. **To-sidede formater:** siden disse kun har 2 "sider" (ikke en
+ spillerliste), gir "én rad per spiller"-mønsteret ikke mening.
+ **Svar: ÉN utvidbar detaljseksjon for hele kampen**, ikke et
+ per-rad-mønster.
+
+**Bygget, alt i `round-leaderboard.tsx` (+ to eksisterende filer fikk en
+anker-id):**
+- **`HoleByHole`** (IndividualBoard) fikk en kompakt oppsummeringslinje
+ (par-sum/totalscore/plassering, egen ordlyd — IKKE referansens) + en
+ "💬 Kommentarer"-tekstlenke.
+- **`TeamFlightBoard`** sin flate råscore-liste konvertert til samme
+ utvidbare mønster via en ny `TeamMemberRow` (gjenbruker `HoleByHole`
+ uendret, uten rangeringskolonnen — et lags medlemmer rangeres ikke seg
+ imellom der).
+- **`TwoSidedBoard`/`HighLowBoard`** fikk en ny, delt
+ `MatchDetailSection` — ÉN "Vis hull-for-hull med score"-knapp som,
+ lazy, monterer den nå eksporterte `MatchScorecardGrid`
+ (`round-scorecard.tsx`, samme ADR-045-presedens: eksporter en
+ allerede-godkjent, modul-privat komponent uendret, ren
+ synlighetsendring) + samme kommentar-lenke.
+- **Type-utvidelse, ikke ny data:** `ApiRoundHeader`/`ApiRoundParticipant`
+ i round-leaderboard.tsx fikk flere felt lagt til (`tee_name_snapshot`,
+ `guest_name`, `display_name`, `is_owner`, `playing_handicap`) — alle
+ allerede returnert av samme `${apiPrefix}/{roundId}`-endepunkt
+ (bekreftet mot `RoundOut`/`PublicRoundOut` i `app/routers/rounds.py`),
+ bare ikke tidligere typet der. `guest_name` er valgfritt (fraværende i
+ `publicMode`, siden `PublicRoundParticipantOut` ikke har feltet) — en
+ ny `toGridRound()`-adapter defaulter det til `null` før
+ `MatchScorecardGrid` mates, trygt siden komponenten aldri leser feltet.
+- **Anker-scroll:** `round-detail.tsx`/`watch-round.tsx` sin
+ kommentarseksjon fikk `id="kommentarer"`, PLUSS en liten
+ `useEffect(() => { if (round && hash === "#kommentarer")
+ scrollIntoView() }, [round])` i begge — nettleserens EGEN anker-scroll
+ (som skjer FØR async data er lastet) rakk ikke frem til et element som
+ ennå ikke fantes i DOM-en; funnet og rettet under nettleserverifisering.
+
+**Ingen ny V0-runde, ingen backend-endring.** Alt er gjenbruk/eksport av
+allerede-eksisterende, allerede godkjente komponenter/mønstre til nye
+steder — "liten justering av eksisterende komponenter med allerede
+etablerte mønstre"-unntaket fra den stående V0-arbeidsfordelingen.
+
+**Bevisst forenklet, ikke en bug:** ingen kommentar-ANTALL-merke på selve
+leaderboard-lenken (unngår et ekstra API-kall for kun et tall på en
+lenke).
+
+**Scratch-verifisert** (isolert DB fra `teecup_db`-mal, egen API-/
+frontend-/Caddy-container): `tsc --noEmit` rent, ekte produksjonsbuild
+kjørt og bekreftet ren. Ekte nettleser: **regresjon FØRST** —
+`IndividualBoard`s eksisterende utvidelse bekreftet identisk for
+slagspill-formatet (samme tall, samme rad-innhold) FØR noe nytt ble
+testet. Deretter: ny oppsummeringslinje + kommentar-lenke bekreftet på
+slagspill (inkl. anker-scroll faktisk fungerende, ikke bare landet på
+riktig side), ny `MatchDetailSection`/`MatchScorecardGrid`-utvidelse
+bekreftet på et fourball-format med ekte 4-spiller-data (riktig
+hull-for-hull-score, riktig løpende matchstatus "1 UP"/"AS"/"Dormie 1"),
+samme fourball-format bekreftet identisk i `publicMode` (anonym
+tilskuer-kontekst, egen isolert nettleser-kontekst — kommentar-lenken
+riktig pekende til `/watch/{id}#kommentarer` i stedet for
+`/my-rounds/...`). Ingen konsollfeil utover forventet 401 på
+`/auth/me` for den anonyme leseren (allerede tolerert i koden).
+
+**Ikke direkte nettleser-testet: `TeamFlightBoard` (shamble/money_ball)
+og `HighLowBoard` (high_low_high)** — ingen eksisterende testdata for
+disse formatene i scratch-DB-malen, og å håndkonstruere gyldige
+runde+deltaker+hull-fixturer for dem fra bunnen ble vurdert som for
+tidkrevende/feilutsatt for denne verifiseringsrunden. Vurdert lav risiko
+likevel: `TeamFlightBoard`s nye `TeamMemberRow` gjenbruker `HoleByHole`
+**uendret** (samme funksjon som allerede ble browser-testet via
+`IndividualBoard`), og `HighLowBoard`s nye seksjon bruker **nøyaktig
+samme** `MatchDetailSection`/`toGridRound`/`MatchScorecardGrid`-kobling
+som allerede ble browser-testet via `TwoSidedBoard` — risikoflaten
+(prop-tredding, type-adapter, komponent-montering) er identisk, ikke en
+egen, utestet kodevei. Anbefales likevel spot-sjekket i ekte nettleser
+neste gang en runde med disse formatene finnes eller opprettes.
+
+**Merk, funnet under research, IKKE rettet i denne runden:** en
+skrivefeil i eksisterende kode — `useLeaderboardData`s returnerte
+variabel heter `viewerParticipantId` men inneholder faktisk den
+INNLOGGEDE BRUKERENS `user_id` (fra `/auth/me`), ikke en
+deltaker-id. Kun en forvirrende variabelnavngiving, ikke en funksjonell
+feil (verdien er korrekt for det den faktisk brukes til alle steder,
+inkludert det nye `MatchScorecardGrid`-kallet, som nettopp forventer
+bruker-id under navnet `viewerId`) — la stå urørt, utenfor omfanget av
+denne runden.
+
+**Bevisst utenfor omfang:** `RoundLeaderboardMini` (topp-3-widgeten,
+skal forbli et raskt overblikk), `ScrambleSoloResultView` (eget format,
+lite utbredt), skudd-nivå-detalj (fairway/putts/GIR) utover det
+`HoleByHole` allerede viser (den fulle scorekort-siden dekker det),
+kommentar-antall-merke på leaderboard-lenken, et separat ett-trykks
+"lik hele runden", "privat melding" (ingen DM-funksjon finnes).
+
+---
+
+## ADR-048: Slag-for-slag GPS-avstandsmåling
+
+Reist av brukeren 2026-08-06: "Måle lenge på slag ... Fra der man er
+ELLER fra et valgt punkt på et satellittfoto, til der man står ved siden
+av ballen. Man skal også kunne si hvilken kølle man slo med. Dette kan
+deles i feeden, eller beholdes i eget grensesnitt." Avklart eksplisitt med
+bruker (AskUserQuestion, tre spørsmål, 2026-08-07): (1) v1 skal dekke
+BÅDE individuelle runder OG lagformater (fourball/scramble/foursome m.fl.,
+der hull henger av `round_side_id`), ikke bare individuell, (2)
+delingsteksten skal være auto-generert MEN redigerbar, pluss et utsnitt
+av satellittfotoet med slaget tegnet inn, (3) måling skal være tilgjengelig
+både som en knapp i scoreførings-veiviseren OG som en alltid-synlig
+merkelapp på hull-kortet (retroaktiv måling).
+
+**Beslutning A — Kartleverandør: Mapbox GL JS, ikke Leaflet+Esri.** Esris
+gratis satellittlag (den vanlige gratis Leaflet-kilden) forbyr eksplisitt
+kommersiell bruk uten en separat, betalt ArcGIS-lisens — diskvalifiserende
+gitt TeeCups kommersielle retning. Mapbox: 50 000 gratis kartlastninger/
+mnd, ingen kortbinding for å starte, ~$5/1000 utover, eksplisitt tillatt
+for kommersiell bruk.
+
+**Beslutning B — Kostnadskontroll, tre tiltak.** (1) ETT kart-instans per
+måle-økt (montert når "velg punkt på kart" åpnes, aldri remontert per
+tap/pan — Mapbox fakturerer per initialisering, ikke per interaksjon).
+(2) Kartet lastes KUN ved eksplisitt "velg punkt på kart" — velger
+spilleren "min posisjon nå" for BEGGE punkt, initialiseres Mapbox GL JS
+aldri (må verifiseres eksplisitt via nettverksfane under scratch-testing,
+ikke bare visuelt, se verifiseringsseksjonen for ADR-048 i CHANGELOG.md
+når den skrives). (3) Selve avstandsberegningen er ren klient-side
+Haversine (`frontend/lib/geo.ts`), aldri et Mapbox-API-kall.
+
+**Beslutning C — Ny `round_shot`-tabell (migrasjon 060), IKKE en
+gjenåpning av ADR-033s avvisning av fri slag-for-slag-logging som
+hull-statistikk-DATAMODELL.** Dette er en separat, valgfri måle-funksjon;
+`round_hole` sine faste felt (`club_off_tee`, `putts` osv.) er uberørt.
+`round_shot` kjenner kun `round_hole_id` — IKKE `round_participant_id`/
+`round_side_id` direkte — og arver dermed eierskap (individuell ELLER
+lagformat) TRANSITIVT via `round_hole` sin eksisterende
+`round_participant_id`/`round_side_id`-XOR (migrasjon 031). Dette dekker
+begge hull-typer fra v1 uten egen XOR-logikk i den nye tabellen, via
+speilede API-endepunkter
+(`/participants/{id}/holes/{n}/shots` og `/sides/{id}/holes/{n}/shots`,
+samme dobbelte mønster som `RoundHoleOut`/`RoundSideHoleOut` allerede
+bruker for selve hull-scoringen). Samme non-RLS `plain_connection()`-
+mønster som resten av det frittstående-runde-subsystemet (ADR-033
+Beslutning A). `shot_number` er eksplisitt og hull-tolerant (ikke `ORDER
+BY captured_at`) — unngår at GPS-tidsstempel-drift stille endrer
+rekkefølgen; sletting midt i sekvensen etterlater et gap, ingen
+omnummerering. Kun `UPDATE (shared_round_message_id)` er grantet (ett
+smalt, eksplisitt unntak fra "slett og opprett på nytt, aldri rediger"-
+prinsippet, se Beslutning E) — ellers ingen UPDATE, samme filosofi som
+`round_message`.
+
+**Beslutning D — Koordinater: rene `double precision` lat/lng-par, ikke
+PostGIS.** Første funksjon i prosjektet som lagrer koordinater i det hele
+tatt (60 migrasjoner uten presedens frem til nå). Volumet (maks noen
+titalls rader per runde) og operasjonssettet (kun punkt-til-punkt
+storsirkel-avstand, regnet klient-side) rettferdiggjør ikke en ny, tung
+geo-avhengighet.
+
+**Beslutning E — Deling gjenbruker `round_message` (ADR-044) uendret, via
+en lenke.** Et nytt `POST /rounds/{id}/shots/{id}/share`-endepunkt
+genererer satellitt-utsnittet SERVER-SIDE (Mapbox Static Images API, egen
+HEMMELIG `TEECUP_MAPBOX_SECRET_TOKEN` — atskilt fra frontendens
+OFFENTLIGE, URL-restriktere `NEXT_PUBLIC_MAPBOX_TOKEN` som Mapbox GL JS
+bruker i nettleseren), laster bildet opp via eksisterende
+`storage.upload_image()`, og setter INN en vanlig `round_message`-rad —
+ingen ny feed-mekanisme. `shared_round_message_id` er en LENKE (ikke et
+bool-flagg) slik at "delt" alltid reflekterer om meldingen faktisk
+finnes (`ON DELETE SET NULL` hvis meldingen slettes separat i feeden).
+Rekkefølgen er bevisst: slaget opprettes ALLTID først (uten deling);
+deling er et frivillig, påfølgende steg — unngår at en feilet
+slag-opprettelse kan etterlate en foreldreløs feed-melding. Mapbox-kallet
+degraderer grasiøst til ren tekst uten satellittbilde ved feil/manglende
+token (samme mønster som SMTP/push i `config.py`), blokkerer ikke selve
+delingen.
+
+**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
+og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
+alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
+"eier ELLER lenket medspiller kan føre for hele flighten"-regel som
+`update_hole`/side-PATCH allerede bruker). `_resolve_round_message_author_name`
+flyttet fra `round_messages.py` til `rounds.py` (som allerede var den
+importerte, ikke-importerende parten av de to filene) for å unngå en
+sirkulær import da `share_shot` trengte å gjenbruke den uendret.
+
+**Frontend:** `frontend/lib/geo.ts` (ren Haversine-funksjon, ingen
+avhengigheter). Eksisterende kølle-plukker-mønster i `ScoringWizard`
+(round-detail.tsx) trukket ut til en delt `ClubPicker`-komponent (ren
+refaktor, bekreftet typesjekk uendret) slik at den nye måle-flyten kan
+gjenbruke nøyaktig samme pill-knapp-interaksjon. `mapbox-gl` lagt til
+(pakken har egne typedefinisjoner -- den separate `@types/mapbox-gl`-
+pakken er en utdatert stub, bevisst IKKE installert), `NEXT_PUBLIC_MAPBOX_TOKEN` tredd gjennom
+`frontend/Dockerfile` (build-tid, IKKE runtime — `NEXT_PUBLIC_`-variabler
+bakes inn i klient-bundlen ved `next build`, samme fallgruve-mønster som
+`TEECUP_API_ORIGIN` allerede dokumenterer i Dockerfile-kommentarene, men
+speilet KUN i builder-steget her siden bruken er ren klient-side) og
+`docker-compose.yml` sitt `teecup_frontend.build.args`. Selve
+`ShotMeasurementSheet`-komponenten (alle steg + kart-plassholder) går via
+én Claude-skrevet V0-prompt (standard arbeidsfordeling) — ekte Mapbox GL
+JS-integrasjon i kart-steget kobles på hånd-kodet ETTER V0-eksporten, ikke
+av V0 selv (V0-prompten ber eksplisitt om en visuell plassholder, ikke et
+ekte kartbibliotek, for å unngå at V0 genererer en selvstendig
+kart-komponent som remonterer per interaksjon og dermed bryter
+Beslutning B).
+
+**Bevisst utenfor omfang:** redigering av et allerede logget slag (slett
+og mål på nytt i stedet), en historisk "vis alle slag på kart etter
+runden"-visualisering.
Disse må avklares før eller under de relevante fasene:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9084b8..75c165a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8006,5 +8006,746 @@ Neste steg:
fra prosjektroten uten å bli spurt, samme rutine som alltid for merget
V0-eksport).
- **IKKE rullet ut mot ekte `teecup_db` i denne runden** -- venter på
- brukerens eksplisitte bekreftelse, samme rutine som alltid.
+ **Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt: migrasjon
+ 058 kjørt mot ekte `teecup_db` (`round_message`-tabellen bekreftet
+ tilstede etterpå), `docker compose up -d --build teecup_api
+ teecup_frontend`, begge containere boot-et rent. `/health`/`/my-rounds/
+ new`/`/my-feed` → 200 over https, `GET https://teecup.golf/feed`
+ bekreftet å proxye korrekt til backend (401 JSON `NOT_AUTHENTICATED`,
+ IKKE frontend-sidens HTML -- bekrefter rewrite-fiksen virker i
+ produksjon, ikke bare i scratch), `teeoff.no` upåvirket.
+
+26. **To bugfikser, 2026-08-06, rullet ut samme dag:**
+ - **"Venner på banen"-kortet lenket til feil rute.** Brukeren
+ rapporterte (video vedlagt) at et klikk på en venns pågående runde
+ på dashbordet ga "Denne runden finnes ikke, eller du har ikke
+ tilgang til den" -- selv om runden faktisk var synlig for
+ vedkommende (`GET /friends/on-course` viste den korrekt). Rotårsak:
+ `dashboard.tsx` sin `LiveFriends`-komponent lenket til
+ `/my-rounds/{id}` (eier/medspiller-only-visningen,
+ `_get_accessible_round_or_404`) i stedet for `/watch/{id}` (den
+ allerede eksisterende tredjeparts-visningen bygget for nettopp
+ dette, ADR-036 fase 2). Samme feilmønster funnet OG rettet i den
+ nybygde `/my-feed`-siden (punkt 25) -- `FeedCard` lenket likeens
+ alltid til `/my-rounds/{id}` uansett eierskap. Begge rettet til å
+ route riktig basert på om runden faktisk er brukerens egen.
+ - **Kamera lot seg ikke aktivere ved bildeopplasting på mobil**, seks
+ steder i appen (rundekommentarer, org-feed, avatar, lag-chat,
+ turnering-hero/sponsor-bilder). Rotårsak: `` -- flere
+ eksplisitte MIME-typer i `accept` gjør at mange mobilnettlesere kun
+ tilbyr galleri, ikke kamera, i filvelgeren. Rettet til
+ `accept="image/*"` alle seks steder (server-siden validerer uansett
+ strengt mot `storage.ALLOWED_INPUT_CONTENT_TYPES`, så dette svekker
+ ingen kontroll).
+
+ Begge ren frontend, ingen migrasjon. Ekte typesjekket produksjonsbuild
+ kjørt før utrulling. **Rullet ut live 2026-08-06** (kun
+ `teecup_frontend` bygget/restartet), `/dashboard` → 200, `teeoff.no`
+ upåvirket.
+
+27. **Ekte TeeCup-logo tatt i bruk som app-ikon, erstatter den midlertidige
+ grønne golfflagg-placeholderen — 2026-08-06.** Brukeren lastet opp
+ `TeeCup logo.svg` (ikon+ordmerke i én fil, Inkscape-eksport med
+ raster-baserte masker/filtre, ikke rene vektor-tekstobjekter) og
+ spurte om splitting av ikon fra tekst var mulig uten flere opplastede
+ varianter.
+
+ **Splitting løst programmatisk, ikke manuelt:** brukte nettleserens
+ `getBBox()` på hver topp-nivå-`` i den innlastede SVG-en for å
+ måle faktiske posisjoner -- avslørte at ikonet (ball/pokal/tee) består
+ av tre atskilte grupper (x: 0–169 av 656 totalt) mens "TeeCup"-
+ ordmerket er seks separate bokstav-grupper (x: 179–407) -- ingen
+ gjetning nødvendig. Beskåret til et eget ikon-SVG via et rent
+ `viewBox`-endring på en kopi av originalfilen (ingen gruppe fjernet
+ eller XML-struktur røsket i -- alle filter-/maske-/clipPath-
+ definisjoner ligger nestet INNI selve gruppene, bekreftet empirisk:
+ et forsøk på å trimme bort de ubrukte tekst-gruppene for å spare
+ filstørrelse brøt renderingen umiddelbart, samme "hele filen eller
+ ingenting"-begrensning som Inkscape sin raster-trace-eksport gir --
+ reverserte til den fullstendige, verifisert fungerende versjonen i
+ stedet for å risikere en ødelagt fil for en fil-størrelse-optimering).
+ `icon.svg` er dermed 416 KB (arves fra kildefilens embedded
+ raster-masker) -- fungerer korrekt, men en ekte vektor-reeksport fra
+ designer ville gitt en vesentlig lettere fil om ønskelig senere.
+
+ **Ekte nettleser brukt til rasterisering** (ingen lokal SVG->PNG-
+ rasterizer tilgjengelig i miljøet): Chrome DevTools sitt
+ skjermbilde-verktøy, med små HTML-wrapper-sider satt til eksakt
+ mål-pikselstørrelse per format, generert alle seks filene appen
+ faktisk bruker: `icon.svg` (transparent, favicon), `icon-light-
+ 32x32.png`/`icon-dark-32x32.png` (transparent, samme innhold --
+ fungerer i begge fargetema), `apple-icon.png` (180×180, opak hvit
+ bakgrunn -- iOS støtter ikke transparens), `icons/icon-192.png` og
+ `icons/icon-512.png` (PWA-manifest, opak hvit bakgrunn), `icons/
+ icon-maskable-512.png` (ikonet holdt innenfor sentrale ~63 % --
+ trygt innenfor W3C sin ~80 %-sikre-sone for maskable-ikoner som
+ OS-en klipper med egen maske). Hvert format visuelt bekreftet
+ (`Read` på hver genererte PNG) før plassering.
+
+ Hele ordmerket (ikon+"TeeCup"-tekst) lagret uendret som `public/
+ teecup-wordmark.svg` for senere bruk (f.eks. innloggingsside),
+ ikke koblet inn noe sted ennå -- kun selve app-ikon-oppgaven løst nå.
+ `app/manifest.ts` sin kommentar oppdatert til å reflektere at
+ placeholder-fasen er over.
+
+ Ren frontend, ingen migrasjon. Ekte typesjekket produksjonsbuild
+ kjørt. **Rullet ut live 2026-08-06** (kun `teecup_frontend`),
+ `/icon.svg`/`/apple-icon.png`/`/icons/icon-512.png` → 200 over https,
+ `teeoff.no` upåvirket.
+
+28. **Tilskuer-visning (`/watch/[id]`): tre faner + "Spillere og runde",
+ full paritet med eiersiden (ADR-045) — BYGGET OG SCRATCH-VERIFISERT
+ 2026-08-06, venter på utrulling.** Direkte oppfølging av punkt 26 sin
+ bug (feil lenke fra "Venner på banen") -- brukeren ba samtidig om at
+ selve tilskuer-siden (fram til da ÉN enkelt side, kun kompakt status)
+ skulle få samme tre faner som eiersiden (Score/Scorekort/Leaderboard)
+ og "Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag,
+ flight-gruppering), uten eier-kun-handlingene. Bekreftet med bruker
+ (AskUserQuestion, to spørsmål): full formatspesifikk detalj på
+ Scorekort-fanen (ikke en forenklet fellesvisning), og
+ flight-gruppering bygget nå (ikke utsatt).
+
+ **Nøkkelbeslutning, funnet under research (Explore-agent + egen
+ lesning av begge filene):** `round-scorecard.tsx` (`RoundScorecard`)
+ og `round-leaderboard.tsx` (`RoundLeaderboard`) sin faktiske rutenett-
+ /tavle-rendring var ALLEREDE ren, skrivefri visning -- redigering
+ skjer et helt annet sted. Det eneste som hindret gjenbruk for
+ tilskuere var at alle interne data-hentinger (fire hooks/effekter
+ per fil, pluss WS-tilkoblingen) hardkodet `/rounds/*`-prefikset. I
+ stedet for å bygge nye, forenklede tilskuer-komponenter (ville gitt
+ UFULLSTENDIG formatparitet eller duplisert ~1000+ linjer formatlogikk),
+ la til en valgfri `publicMode`-prop på begge de EKSISTERENDE
+ komponentene -- default `false` (INGEN endring i eiersidens
+ oppførsel), `true` bytter alle interne URL-er til `/public/rounds/*`
+ og WS-kanalen til den offentlige. Samme kode, to datakilder, ekte
+ full formatparitet (alle 17 formater) uten duplisert vedlikehold.
+
+ **Backend:** ett nytt endepunkt, `GET /public/rounds/{id}/
+ flight-group` (`app/routers/rounds.py`) -- speiler den eksisterende
+ autentiserte varianten, men med en ny `_viewable_flight_rows`
+ (bruker `_can_view_round` PER søsken-flight uavhengig, siden en
+ flight-gruppe kan ha søsken med ulik `visibility_mode`). Ingen
+ migrasjon.
+
+ **Frontend, ny fil-for-fil:** `watch-tabs.tsx` (ny, liten fane-stripe
+ -- BEVISST ikke en gjenbruk av `RoundHeader`, som alltid render et
+ "Administrer"-tannhjul/admin-dialog uansett hvilke handlere som gis
+ inn -- den nye komponenten inneholder rett og slett ingen slik kode).
+ `watch-players.tsx` (ny "Spillere og runde", IKKE et forsøk på å
+ gjøre `round-detail.tsx` sin lokale, ikke-eksporterte `PlayerList`
+ gjenbrukbar via `canManage=false` -- egen, read-only-only komponent,
+ ingen +Medspiller-/Rediger-/Fullfør-/Slett-kode i det hele tatt).
+ `watch-round.tsx` omskrevet: Score-fanen viser nå fane-stripen +
+ kompakt status + "Spillere og runde" -- selve matchstatus-/skins-/
+ slagspill-visningen FLYTTET til den nye Leaderboard-fanen (erstattet
+ av `RoundLeaderboard publicMode`, som dekker alle formater -- lukker
+ et eksisterende gap der den gamle `watch-round.tsx` sin lokale
+ `TWO_SIDED_FORMATS`-liste kun dekket 8 av 17 formater). To nye tynne
+ ruter, `/watch/[id]/scorecard` og `/watch/[id]/leaderboard`
+ (`watch-scorecard.tsx`/`watch-leaderboard.tsx`, hver bare fane-stripe
+ + `RoundScorecard`/`RoundLeaderboard` med `embedded publicMode`).
+
+ **Verifisering, samme scratch-Caddy-disiplin som ADR-044:** 6/6
+ håndregnede sjekker for det nye flight-group-endepunktet (anonym ser
+ kun offentlige søsken-flighter, venn med riktig kategori ser i
+ tillegg `friends`-synlige, eier ser alle, fremmed uten vennskap ser
+ kun offentlige, privat anker-runde avvist helt). `tsc --noEmit` rent
+ på hele frontend-prosjektet. Ekte nettleser: **regresjon FØRST** --
+ eiersidens `/my-rounds/{id}/scorecard`/`/leaderboard` bekreftet
+ PIKSEL-IDENTISK (samme tall, samme layout) FØR noe nytt ble testet.
+ Deretter tilskuer-sidene: alle tre faner, "Spillere og runde" uten
+ eier-knapper, ETT allerede-dekket format (`stroke`) OG ETT tidligere
+ udekket format (`flag`) begge bekreftet rendret korrekt gjennom
+ `/watch/{id}/leaderboard` -- beviser formatparitet-lukkingen direkte.
+ En privat rundes tilskuer-sider (alle tre) bekreftet korrekt avvist
+ for en ANONYM leser i en egen, isolert nettleser-kontekst (ikke bare
+ et API-kall) med riktig feilmelding og "Tilbake til dashbordet"-
+ lenke. `test_isolation.sql` uendret 12/12. Scratch-miljøet (DB/rolle/
+ MinIO/API-/frontend-/Caddy-containere og -images) ryddet opp
+ fullstendig etter bruk.
+
+ **Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt: `docker
+ compose up -d --build teecup_api teecup_frontend` (ingen migrasjon),
+ begge containere boot-et rent. `/health` → 200, `/dashboard` → 200,
+ nytt endepunkt bekreftet (`/public/rounds/{tilfeldig-id}/flight-group`
+ → 404 for en ikke-eksisterende runde), alle tre nye/endrede
+ `/watch/[id]`-ruter → 200, `teeoff.no` upåvirket.
+
+29. **Bugfiks: kommentarer/bilder (ADR-044) fantes ikke i tilskuer-
+ visningen — 2026-08-06.** Brukeren rapporterte at en person en runde
+ er delt med ikke kunne se selve samtalen. Rotårsak: `RoundMessages`
+ ble aldri montert på `/watch/[id]` -- kun på `round-detail.tsx` sin
+ egen Score-fane (eier/deltaker). Backend var allerede riktig
+ (`GET`/`POST /rounds/{id}/messages` bruker `_get_viewable_round_or_
+ 404`, synlighets-gatet, ikke eier/deltaker-only, siden ADR-044 ble
+ bygget) -- ren frontend-mangel.
+
+ **Én reell backend-mangel funnet underveis:** `PublicRoundOut`
+ eksponerte `owner_display_name` men ALDRI eierens faktiske
+ `user_id` -- `RoundMessages` sin "forfatter ELLER rundeeier kan
+ slette"-sjekk (`roundOwnerUserId === currentUserId`) hadde dermed
+ ingen korrekt verdi å sammenligne mot fra tilskuer-siden. Lagt til
+ `owner_user_id: str` på `PublicRoundOut` (samme personvernsnivå som
+ det allerede eksponerte visningsnavnet -- en ugjennomsiktig UUID
+ avslører ingenting nytt).
+
+ **Rettet:** `watch-round.tsx` monterer nå `` på
+ Score-fanen, med `roundOwnerUserId={round.owner_user_id}` og
+ `currentUserId` fra en ny `/auth/me`-henting (tolerant for anonym,
+ samme mønster som resten av appen).
+
+ **Verifisert, isolert scratch-miljø:** 6/6 håndregnede sjekker
+ (`/public/rounds/{id}` eksponerer korrekt `owner_user_id`, en venn
+ kan poste via synlighet -- ikke eier/deltaker, begge kan lese, eier
+ kan moderere/slette vennens innlegg). `tsc --noEmit` rent. Ekte
+ nettleser: kommentarseksjonen bekreftet synlig på `/watch/{id}`,
+ ingen konsollfeil utover forventet 401 på `/auth/me` for en anonym
+ leser (allerede tolerert i koden). Scratch-miljøet ryddet opp.
+
+30. **Bugfiks: kamera manglet helt ved bildeopplasting, ekte "prøv igjen"-
+ feil skjult bak en generisk melding — 2026-08-06.** Brukeren
+ rapporterte (skjermdump fra ekte enhet, Pixel 8 Pro, TeeCup installert
+ som PWA i Chrome) at "Legg til bilde" ALDRI viste noe kamera-
+ alternativ, kun galleri -- og at selve posten deretter feilet med
+ "Kunne ikke poste kommentaren. Prøv igjen." uten noen forklaring.
+
+ **To atskilte, reelle rotårsaker, begge bekreftet:**
+ 1. Siden Android 13 bruker Chrome (og andre nettlesere) systemets
+ eget "Photo Picker" for `` UTEN
+ `capture`-attributt -- denne velgeren viser KUN galleri, ALDRI et
+ kamera-alternativ, uansett hvilken `accept`-verdi som er satt
+ (den tidligere fiksen, `accept="image/*"` alene, var altså
+ nødvendig, men ikke tilstrekkelig for Android 13+). Rettet ved å
+ splitte den ene filvelgeren i to atskilte inputs/knapper -- én
+ med `capture="environment"` (rett til kamera), én uten (rett til
+ galleri) -- i `round-messages.tsx`, `team-chat.tsx`, og
+ `public-tournament.tsx` sin `TournamentFeed` (de tre stedene som
+ faktisk brukes aktivt for bildeopplasting fra mobil; `account-
+ settings.tsx`/`tournament-presentation.tsx` sine engangs-avatar-/
+ hero-bilde-opplastinger har fortsatt kun galleri -- lavere
+ prioritet, tas senere om ønskelig).
+ 2. Selve feilmeldingen ved en mislykket post var en blindt generisk
+ `catch { setPostError("Kunne ikke poste... Prøv igjen.") }` --
+ den faktiske, spesifikke backend-feilen (f.eks. "Bildet er for
+ stort (maks 8 MB)") ble aldri lest fra svaret og dermed aldri
+ vist. Rettet til å faktisk lese `{"detail":{"code","message"}}`-
+ kontrakten (samme form som resten av API-et, `app/errors.py`) og
+ vise den ekte meldingen, med en generisk norsk fallback kun hvis
+ svaret mot formodning ikke er JSON. Lagt til et klient-side
+ størrelsessjekk (samme 8 MB-grense som `storage.MAX_UPLOAD_BYTES`)
+ for umiddelbar, presis feilmelding uten en unødvendig tur-retur
+ til serveren for noe som uansett ville blitt avvist -- sannsynlig
+ den FAKTISKE årsaken til brukerens opprinnelige feil, siden
+ moderne telefonkamera-bilder lett kan overstige 8 MB.
+
+ **Oppfølging samme dag:** brukeren spurte -- helt riktig -- om selve
+ 8 MB-grensen egentlig var et praktisk problem, siden alle bilder
+ uansett konverteres til AVIF (mye mindre) før lagring. Bekreftet i
+ `app/storage.py`: `MAX_UPLOAD_BYTES`-sjekken skjer på RÅ input, FØR
+ konverteringen -- den beskytter altså ikke lagringsstørrelsen (det
+ gjør AVIF-steget, uavhengig av inputstørrelse), den var bare en
+ vilkårlig øvre grense som kunne avvise et helt normalt telefonbilde
+ før det fikk sjansen til å konverteres ned. Hevet til 20 MB (server-
+ siden `app/storage.py` OG de tre nye klient-side sjekkene over,
+ samt feilteksten) -- rikelig for moderne telefonkamera-JPEG-er,
+ fortsatt en reell grense mot noe genuint urimelig stort.
+
+ Ren frontend + denne ene backend-konstanten, ingen migrasjon.
+ `tsc --noEmit` rent, `py_compile` rent. **Rullet ut live 2026-08-06**,
+ bruker bekreftet eksplisitt: `docker compose up -d --build teecup_api
+ teecup_frontend`, begge containere boot-et rent. `/health` → 200,
+ `/dashboard` → 200, `teeoff.no` upåvirket.
+
+31. **Kommentarstrømmen på en runde sortert om: nyeste øverst — 2026-08-06.**
+ Brukeren ba om dette rett etter forrige punkt -- `round_message`-
+ listen (`round-messages.tsx`) var kronologisk eldst-først (samme
+ mønster som en vanlig chat), brukeren ville ha nyeste øverst i
+ stedet (en oppdateringsstrøm, ikke en samtale man leser fra start).
+ Endret `ORDER BY created_at` → `created_at DESC` i `GET /rounds/{id}/
+ messages` (`app/routers/round_messages.py`), og en ny post legges nå
+ øverst i listen lokalt (`[created, ...prev]` i stedet for `[...prev,
+ created]`). Lag-chatten (`messaging.py`) og org-oppslagstavlen
+ (`public-tournament.tsx`) er BEVISST uendret -- en ekte samtale
+ leses fortsatt kronologisk.
+
+ Verifisert i isolert scratch-miljø (postet tre meldinger, bekreftet
+ `GET`-rekkefølgen er nyeste-først). `tsc`/`py_compile` rent.
+ **Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt (samme
+ utrulling som punkt 30). `teeoff.no` upåvirket.
+
+32. **"Nyeste først" utvidet til ALLE meldingsstrømmer — 2026-08-06,
+ overstyrer punkt 31 sin "bevisst uendret"-begrunnelse.** Brukeren:
+ "Nyeste først. Over alt." -- en eksplisitt, direkte instruks om at
+ lag-chatten og org-oppslagstavlen (som punkt 31 bevisst lot forbli
+ kronologiske, med en UX-begrunnelse om at "en ekte samtale leses
+ fortsatt kronologisk") OGSÅ skulle bli nyeste-først, på tvers av min
+ egen tidligere vurdering.
+
+ Endret `ORDER BY created_at` → `created_at DESC` for BÅDE
+ `list_team_messages` og `get_feed` (`app/routers/messaging.py`).
+ `team-chat.tsx`: WS-mottak og lokal post-oppdatering endret fra
+ append (`[...prev, msg]`) til prepend (`[msg, ...prev]`); fjernet
+ `bottomRef`-baserte auto-scroll-til-bunn-`useEffect`en (unødvendig
+ når nyeste alltid er øverst, synlig uten scrolling).
+ `public-tournament.tsx` sin `TournamentFeed`: samme prepend-endring i
+ BÅDE WS-mottak og `send()`.
+
+ Verifisert i isolert scratch-DB (fra `teecup_db`-mal): satte inn tre
+ meldinger med forskjøvne tidsstempler direkte i `message`-tabellen
+ for både `scope='team'` og `scope='tournament_feed'`, kjørte de
+ EKSAKTE SELECT-spørringene fra koden, bekreftet nyeste-først-
+ rekkefølge for begge. `tsc --noEmit`/`py_compile` rent.
+ `test_isolation.sql` uendret 12/12. Scratch-DB/rolle ryddet opp.
+
+ **Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt ("Ja
+ takk."): `docker compose up -d --build teecup_api teecup_frontend`,
+ begge containere boot-et rent (ingen migrasjon). `teeoff.no`
+ upåvirket.
+
+33. **Emoji-reaksjoner + trådede kommentarer på innlegg — 2026-08-06, se
+ ADR-046 for full begrunnelse/beslutningsdetalj.** Samme melding som
+ punkt 32 ("... Husk også at man skal kunne like (eller bruke andre
+ emojier) og kommentere på innlegg."). Migrasjon 059
+ (`round_message_reaction`/`_comment`, `message_reaction`/`_comment`
+ -- to tabellpar, ett uten RLS speiler `round_message`, ett RLS'et
+ speiler `message`). Nye endepunkter i `round_messages.py` og
+ `messaging.py`: `PUT`/`DELETE .../reaction` (upsert, ett fast
+ kuratert emoji-sett, én reaksjon per bruker per innlegg), `GET`/
+ `POST .../comments` + `DELETE .../comments/{id}` (flat lagring,
+ frontend bygger tre-strukturen, kaskade-sletting av svar).
+ Autorisasjon speiler hver innleggstypes EKSISTERENDE post-/
+ slette-regler uendret (ingen ny modell).
+
+ **Én reell regresjon funnet og rettet FØR utrulling, under ekte
+ nettleserverifisering (ikke fanget av API-testskriptet alene):** de
+ nye kommentar-endepunktene i `round_messages.py` kalte først
+ `broadcast_round_update()` ved en inkonsekvens mot egen plan (planen
+ sa eksplisitt ingen live-push for kommentarer/reaksjoner i v1) --
+ dette trigget rundens eksisterende WS-tick, som fikk `RoundMessages`
+ til å refetche HELE meldingslisten og med det kollapse enhver
+ allerede-utvidet kommentartråd andre steder på siden ved hver eneste
+ kommentarhandling. Fjernet fra de nye endepunktene, bekreftet rettet
+ ved re-test (tråd forble utvidet gjennom en påfølgende reaksjon/
+ kommentar).
+
+ **Frontend:** ny delt `post-engagement.tsx` (via ÉN Claude-skrevet
+ V0-prompt), integrert i `round-messages.tsx`, `feed.tsx`
+ (`FeedCard`), `team-chat.tsx`, `public-tournament.tsx`
+ (`TournamentFeed`). To integrasjonsjusteringer utover ren copy-inn:
+ byttet V0-eksportens egendefinerte SVG-ikoner til `lucide-react`
+ (DESIGN_SYSTEM.md sin "ingen annen ikonpakke"-regel), og flyttet
+ `PostEngagement` UT av `feed.tsx` sin `FeedCard`-`` (var
+ opprinnelig nøstet inni hele kort-lenken -- ville trigget navigering
+ ved reaksjonsklikk og vært ugyldig nøstet interaktivt innhold).
+
+ **Kjent, bevisst forenkling (ikke en bug):** `public-tournament.tsx`
+ sin Oppslagstavle setter `canModerate={false}` alltid i UI-et --
+ backend håndhever fortsatt korrekt "forfatter ELLER org-admin"
+ uansett, men en org-admin ser ikke en slett-knapp for ANDRES
+ kommentarer der ennå (ren UI-fullstendighets-luke, ikke et
+ sikkerhetshull).
+
+ **Scratch-verifisert grundig, to runder:** (1) API-nivå -- full
+ autorisasjonsmatrise for alle tre innleggstyper (upsert-reaksjon
+ bekreftet ikke-stablende, tråding, kaskade-sletting, riktig
+ avvisning per type sin faktiske regel, RLS-isolasjon for
+ `message_reaction` eksplisitt bekreftet), `test_isolation.sql`
+ 12/12, ekte produksjonsbuild av frontend kjørt og bekreftet ren.
+ (2) Ekte nettleser (egen scratch-Caddy for sesjon/WS) -- reagert,
+ byttet reaksjon, postet trådet samtale, slettet med kaskade-
+ bekreftelse, bekreftet `feed.tsx` sin Link-nøsting-fiks IKKE
+ trigger navigering, bekreftet komponenten rendrer korrekt i alle
+ fire flater uten konsollfeil. Begge scratch-miljøer (DB/rolle/API-/
+ frontend-/Caddy-containere/images) ryddet opp fullstendig.
+
+ **Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt ("Ja
+ takk"): migrasjon 059 kjørt mot ekte `teecup_db` (fire nye tabeller,
+ additiv, ingen endring av eksisterende data), deretter `docker
+ compose up -d --build teecup_api teecup_frontend`. Begge containere
+ boot-et rent. Bekreftet via live `openapi.json`: alle ni nye ruter
+ (`.../reaction` × 3, `.../comments` × 3, `.../comments/{id}` × 3)
+ riktig registrert og nåbare. `teeoff.no` upåvirket.
+
+34. **Leaderboard lukket gapet mot en konkurrentapp-referanse — 2026-08-06,
+ se ADR-047 for full begrunnelse/beslutningsdetalj.** Brukeren viste en
+ video av GolfGameBook sitt leaderboard (trykk-for-å-utvide rad → fullt
+ hull-for-hull-scorekort + sosiale handlinger per spiller), ba om noe
+ minst like bra i TeeCup -- eksplisitt IKKE en kopi av utseendet.
+ Research avdekket at kjernemekanikken (utvidbare rader med
+ hull-for-hull-rutenett, egen form+farge-språk) allerede var bygget og
+ live for `IndividualBoard` siden 2026-07-26 -- denne runden lukket de
+ reelle gapene i stedet for å bygge om fra bunnen: ingen sosial-tilgang
+ noe sted i leaderboardet, ingen kompakt oppsummeringslinje,
+ `TwoSidedBoard`/`HighLowBoard` (to-sidede formater) hadde INGEN
+ utvidelse i det hele tatt, `TeamFlightBoard` hadde en flat,
+ ikke-utvidbar råscore-liste.
+
+ Avklart eksplisitt med bruker (AskUserQuestion) før bygging: (1)
+ `post-engagement.tsx` (ADR-046) reagerer på ETT spesifikt innlegg, ikke
+ runden -- alle utvidede rader lenker derfor til den SAMME, allerede
+ eksisterende kommentarseksjonen på rundesiden i stedet for en
+ (ikke-eksisterende) tråd per spiller. (2) To-sidede formater fikk ÉN
+ utvidbar detaljseksjon for hele kampen, ikke et per-rad-mønster (kun 2
+ sider, ikke en spillerliste).
+
+ Alt i `frontend/components/round-leaderboard.tsx` + anker-id på
+ kommentarseksjonen i `round-detail.tsx`/`watch-round.tsx`: `HoleByHole`
+ fikk en oppsummeringslinje + "Kommentarer"-lenke; `TeamFlightBoard`
+ sin råscore-liste konvertert til utvidbare rader (ny `TeamMemberRow`,
+ gjenbruker `HoleByHole` uendret); `TwoSidedBoard`/`HighLowBoard` fikk
+ en ny, delt `MatchDetailSection` som lazy-monterer den nå eksporterte
+ `MatchScorecardGrid` (`round-scorecard.tsx`, samme ADR-045-presedens).
+ Ingen ny V0-runde, INGEN backend-endring -- ren gjenbruk/eksport av
+ allerede godkjente komponenter.
+
+ **Funnet og rettet under nettleserverifisering:** nettleserens egen
+ anker-scroll (`#kommentarer`) rakk ikke frem siden elementet ikke
+ fantes i DOM-en før async rundedata var lastet -- løst med en liten
+ `useEffect` som trigger scrollet på nytt når dataen ankommer.
+
+ **Verifisert:** `tsc --noEmit` rent, ekte produksjonsbuild rent.
+ Isolert scratch-miljø, ekte nettleser: regresjon FØRST (slagspill-
+ formatets eksisterende utvidelse identisk før noe nytt ble testet),
+ deretter ny oppsummeringslinje+lenke (inkl. faktisk fungerende
+ anker-scroll) og ny `MatchDetailSection`/`MatchScorecardGrid`-utvidelse
+ bekreftet på et fourball-format med ekte 4-spiller-data, samme format
+ bekreftet identisk i `publicMode` (anonym tilskuer, egen isolert
+ nettleser-kontekst, kommentar-lenken riktig pekende til
+ `/watch/{id}#kommentarer`). `TeamFlightBoard`/`HighLowBoard` ikke
+ direkte browser-testet (ingen testdata for disse formatene i
+ scratch-DB-malen) -- vurdert lav risiko siden de gjenbruker nøyaktig
+ samme, allerede-testede kodeveier (`HoleByHole` uendret;
+ `MatchDetailSection`/`toGridRound`/`MatchScorecardGrid` uendret), se
+ ADR-047 for full begrunnelse.
+
+ **Egen feil funnet og rettet underveis, verdt å notere:** det første
+ forsøket på scratch-frontend-bygg pekte ved en kopier-lim-feil
+ `TEECUP_API_ORIGIN` mot den EKTE produksjons-API-en (`teecup_api`) i
+ stedet for scratch-containeren -- oppdaget da innlogging som en
+ scratch-testbruker konsekvent feilet. All interaksjon i det vinduet
+ var lesing (sidevisning/rad-utvidelse, ingen skriving/mutasjon), men
+ bildet ble likevel bygget på nytt med riktig scratch-API-peker og HELE
+ nettleser-verifiseringen kjørt om igjen fra bunnen mot den korrekt
+ isolerte stacken før noe ble konkludert.
+
+ Ren frontend, ingen migrasjon. **Rullet ut live 2026-08-06**, bruker
+ bekreftet eksplisitt ("Ja takk"): `docker compose up -d --build
+ teecup_frontend` (Compose gjenskapte også `teecup_api`-containeren i
+ samme kommando -- ingen kodeendring der denne runden, kun en ren
+ restart). Begge containere boot-et rent, `/health` → 200. `teeoff.no`
+ upåvirket.
+
+35. **Reaksjonsraden viste kun et antall, ikke HVEM som reagerte —
+ 2026-08-07.** Brukeren, rett etter å ha godkjent V0-eksperimentet på
+ `/logg-inn`: "Vi kan ikke se HVEM som liker noe i feeden. Det må på
+ plass." En reell, riktig funnet mangel i ADR-046-bygget fra dagen
+ før.
+
+ Løst uten noen ny tur-retur til serveren: `ReactionSummary` fikk et
+ nytt felt `reactors: list[str]` (fulle navn, roster-kontekst --
+ CLAUDE.md sin navneformat-regel), fylt i SAMME grupperte batch-
+ spørring som allerede beregnet antallet (`array_agg(...) ORDER BY
+ created_at`), i både `round_messages.py` (JOIN `app_user`, samme
+ "fornavn+etternavn eller display_name"-fallback som
+ `_resolve_round_message_author_name`) og `messaging.py` (LEFT JOIN
+ `player` for org-spesifikt visningsnavn, samme mønster som
+ `_resolve_author_display_name` -- fallback til e-postens lokaldel).
+
+ `post-engagement.tsx`: et alltid-synlig sammendrag under
+ reaksjonsraden ("Kari Nordmann og 3 andre reagerte" --
+ `formatReactorSummary()`, ALDRI kun synlig ved hover/tap alene,
+ tilgjengelighetsregelen), utvidbart (44px trykkflate) til en full
+ liste gruppert per emoji. Ingen ny henting -- dataen var allerede i
+ `reactionRow`.
+
+ **Scratch-verifisert:** isolert DB fra `teecup_db`-mal, egen API-
+ container. Bekreftet for begge tabellpar (`round_message_reaction`:
+ én reaksjon → riktig navn; `message_reaction`, org-scopet: to
+ brukere reagerer med samme emoji → begge navn i riktig rekkefølge,
+ org-spesifikt spillernavn brukt, ikke kontoens globale navn).
+ `tsc --noEmit`/`py_compile` rent. Ekte nettleser: sammendragslinjen
+ og den utvidbare per-emoji-listen begge bekreftet fungerende, ingen
+ konsollfeil. Scratch ryddet opp fullstendig.
+
+ Ren tillegg til eksisterende `ReactionSummary`-kontrakt (ingen
+ migrasjon, ingen brytende endring) + `post-engagement.tsx`.
+
+ **Rullet ut live 2026-08-07**, bruker bekreftet eksplisitt ("Ja
+ takk"): `docker compose up -d --build teecup_api teecup_frontend`.
+ Begge containere boot-et rent, `/health` → 200. `teeoff.no`
+ upåvirket.
+
+36. **"Ingen"/"Alle"-hurtigknapp for venne-kategori-velgeren i
+ rundedeling — 2026-08-07.** Brukeren: "Der man, i et rundeoppsett,
+ velger hvem man skal dele runden med, så bør det være en 'bryter'
+ med 'Ingen (helt privat)' eller 'Alle'. Deretter velger eller
+ fravelger man etter det som passer en best." Avklart eksplisitt med
+ bruker (AskUserQuestion) hvilket av to funn passet: Privat/Venner/
+ Offentlig-hovedvalget forblir uendret (3-veis, uendret semantikk);
+ hurtigknappen legges TIL kategori-pillisten som vises når "Venner"
+ er valgt.
+
+ Fant samtidig et reelt, eksisterende avvik mellom de to stedene
+ denne velgeren finnes: opprettelses-veiviseren (`new-round.tsx`)
+ forhåndsvelger alle 10 kategoriene med opt-out-ordlyd, mens
+ rediger-dialogen (`round-detail.tsx`) verken har noen hurtigknapp
+ eller noe "ingen valgt"-varsel i det hele tatt (kun veiviseren hadde
+ det varselet fra før). Begge fikk nå identisk `Pill`-/knapp-basert
+ "Ingen"/"Alle" (setter kategorisettet til tomt/alle ti, finjusteres
+ deretter med enkeltpiller som før) OG samme "ingen kategori valgt"-
+ varsel -- lukker det avviket som en naturlig del av samme endring,
+ ikke en egen runde.
+
+ Ren frontend, ingen migrasjon, ingen ny backend-logikk (API-et tok
+ allerede imot tomme/fulle `visible_categories`-lister uendret).
+ `tsc --noEmit` rent. Scratch-verifisert i ekte nettleser for
+ rediger-dialogen (isolert DB-mal, egen API-/frontend-/Caddy-
+ container): "Ingen" tømmer alle ti pillene og viser varselet,
+ "Alle" fyller alle ti, begge fremhever seg selv korrekt når
+ tilstanden allerede matcher. Veiviserens versjon (identisk kode-
+ mønster, samme delte `Pill`-komponent) bekreftet via kodegjennomgang
+ i stedet for full klikk-gjennom (banesøk mot ekte teeoff-oppslag
+ gjorde en full E2E-kjøring uforholdsmessig tidkrevende for en
+ kodemessig identisk endring). Scratch ryddet opp fullstendig.
+
+ **Rullet ut live 2026-08-07**, bruker bekreftet ("ja"): `docker
+ compose up -d --build teecup_frontend`. Begge containere boot-et
+ rent, `/health` → 200. `teeoff.no` upåvirket.
+
+37. **Slag-for-slag GPS-avstandsmåling — backend + skjema, 2026-08-07, se
+ ADR-048 for full begrunnelse/beslutningsdetalj.** Brukeren: "Måle
+ lenge på slag ... Fra der man er ELLER fra et valgt punkt på et
+ satellittfoto, til der man står ved siden av ballen. Man skal også
+ kunne si hvilken kølle man slo med. Dette kan deles i feeden."
+ Formell planleggingsrunde kjørt (Explore-agenter for
+ `round_hole`-skjema/scoreførings-UI/`BAG_CLUBS`/geolocation-bruk,
+ Plan-agent for konkret design), etterfulgt av AskUserQuestion for tre
+ gjenstående valg: v1 dekker BÅDE individuelle runder OG lagformater
+ fra start, delingstekst er auto-generert-men-redigerbar PLUSS et
+ satellitt-utsnitt av slaget, og måling er tilgjengelig både i
+ scoreførings-veiviseren og som en retroaktiv hull-kort-handling.
+
+ Migrasjon `060_round_shot.sql`: ny tabell, kjenner kun
+ `round_hole_id` (arver eierskap individuell/lagformat transitivt via
+ `round_hole` sin eksisterende XOR, migrasjon 031 -- ingen egen
+ XOR-logikk trengs). Eksplisitt, hull-tolerant `shot_number` (ikke
+ `ORDER BY captured_at`). Kun `UPDATE (shared_round_message_id)`
+ grantet -- ellers samme "slett og opprett på nytt"-prinsipp som
+ `round_message`.
+
+ Backend (`app/routers/rounds.py`): `ShotIn`/`ShotOut`/`ShotShareIn` +
+ seks endepunkter -- deltaker-GET/POST og side-GET/POST (speiler
+ `RoundHoleOut`/`RoundSideHoleOut` sitt eksisterende doble mønster for
+ å dekke lagformater), eierskap-agnostisk DELETE (join via
+ `round_hole` → `round_participant`/`round_side` for å bekrefte
+ tilhørighet til riktig runde), og `POST .../shots/{id}/share` som
+ genererer et satellitt-utsnitt SERVER-SIDE (Mapbox Static Images
+ API, ny `TEECUP_MAPBOX_SECRET_TOKEN`-setting i `config.py`, egen
+ polyline-encoder skrevet for topunkts-overlayet, URL-format
+ verifisert mot Mapbox sin offisielle dokumentasjon via WebFetch
+ fremfor antatt fra hukommelse) og laster opp via eksisterende
+ `storage.upload_image()` inn i en vanlig `round_message`-rad.
+ Rekkefølgen (slag opprettes ALLTID først, uten deling) unngår en
+ foreldreløs feed-melding ved en feilet slag-opprettelse. Mapbox-
+ kallet degraderer grasiøst til tekst-only ved manglende token/feil
+ (samme mønster som SMTP/push). `_resolve_round_message_author_name`
+ flyttet fra `round_messages.py` til `rounds.py` (unngår sirkulær
+ import, `rounds.py` var allerede den importerte parten) slik at
+ `share_shot` kunne gjenbruke navnefeltet uendret i stedet for en
+ duplisert kopi.
+
+ Frontend (kun det som IKKE avhenger av V0-eksporten ennå):
+ `frontend/lib/geo.ts` (ren Haversine, ingen avhengigheter). Det
+ eksisterende kølle-plukker-pill-mønsteret i `ScoringWizard`
+ (`round-detail.tsx`) trukket ut til en delt `ClubPicker`-komponent
+ (ren refaktor, `tsc --noEmit` rent før/etter). `mapbox-gl` lagt til
+ `package.json` (versjon slått opp direkte mot npm-registeret, ikke
+ gjettet) -- `@types/mapbox-gl` bevisst IKKE lagt til, `pnpm install`
+ varslet at den er en utdatert stub siden `mapbox-gl` nå leverer egne
+ typedefinisjoner. `pnpm-lock.yaml` regenerert (`pnpm install
+ --lockfile-only`) -- oppdaget under scratch-frontend-bygg at den
+ ELLERS ville vært ute av synk med `package.json`, noe som ville feilet
+ `pnpm install --frozen-lockfile` i BÅDE scratch- og ekte Docker-bygg
+ (fanget her, ikke først ved ekte utrulling). `NEXT_PUBLIC_MAPBOX_TOKEN`
+ tredd gjennom `frontend/Dockerfile` (KUN builder-steget, siden bruken
+ er ren klient-side -- speiler `TEECUP_API_ORIGIN`s build-tid-fallgruve
+ som allerede var dokumentert der) og `docker-compose.yml`. To tomme
+ plassholder-linjer lagt til i `.env` (`NEXT_PUBLIC_MAPBOX_TOKEN`,
+ `TEECUP_MAPBOX_SECRET_TOKEN`) -- MÅ fylles inn med reelle Mapbox-
+ kontoverdier før kart-/delings-bilde-flyten kan testes fullt ut eller
+ rulles ut live.
+
+ V0-prompt for selve `ShotMeasurementSheet` (alle steg + et bevisst
+ MOCK satellitt-kart-plassholder, ikke et ekte kartbibliotek -- ekte
+ Mapbox GL JS kobles på hånd-kodet etter eksport, for å unngå at V0
+ genererer en kart-komponent som remonterer per interaksjon og bryter
+ ADR-048 Beslutning B) skrevet og sendt til bruker. **Venter på
+ V0-eksport** før inngangspunktene (veiviser-knapp + hull-kort-merkelapp)
+ kan kobles til i `round-detail.tsx`.
+
+ **Scratch-verifisert grundig, kun backend** (isolert `teecup_scratch7`-
+ DB, alle 60 migrasjoner kjørt friskt, isolert `teecup_app_scratch7`-
+ rolle, isolert scratch-MinIO, engangs API-container). **Ett reelt
+ funn og umiddelbar retting UNDER selve scratch-oppsettet:**
+ migrasjon 002 hardkoder BÅDE rollenavn OG databasenavn i sin
+ `GRANT CONNECT ON DATABASE teecup_db`-linje -- rutinemessig
+ sed-omdøping av kun rollenavnet (`teecup_app` → `teecup_app_scratch7`)
+ før migrasjonene kjøres mot scratch-DB-en resulterte i at den nye
+ scratch-rollen fikk CONNECT-rettighet på den EKTE `teecup_db`
+ (kun en tilkoblings-rettighet, ingen tabell-/dataadgang fulgte med,
+ men uansett et utilsiktet avtrykk på ekte database uten bekreftelse).
+ Oppdaget og revokert umiddelbart (`REVOKE CONNECT ON DATABASE
+ teecup_db FROM teecup_app_scratch7`), verifisert at `teecup_db` sin
+ `datacl` var tilbake til nøyaktig samme tilstand som før. Ingen data i
+ `teecup_db` ble noensinne lest/skrevet. Notert her i tråd med
+ CLAUDE.md sin åpenhetsplikt -- vil unngå denne fallgruven ved å
+ ekskludere/håndtere migrasjon 002 sin databasenavn-linje separat neste
+ gang en fersk scratch-DB settes opp fra migrasjoner 001+.
+
+ Testmatrise kjørt mot ekte HTTP (magic-link-innlogging, ekte
+ sesjonscookie, `TEECUP_DEV_LOG_MAGIC_LINKS=true`): opprett slag
+ gps/gps og map_tap/gps (deltaker-eid hull), opprett slag på side-eid
+ hull (fourball-format), avstand >500m avvist (422), ugyldig lat
+ avvist (422), slett midt i sekvensen + bekreftet gap (neste slag fikk
+ nummer 3, ikke gjenbruk av 1), id-gjetting på tvers av runder avvist
+ (404), ikke-medlem avvist (403), linket medspiller kan måle for hele
+ flighten (bekreftet med en andre, faktisk innlogget testbruker),
+ slett/liste av ikke-eksisterende ressurser gir 404, delings-endepunkt
+ kjørt uten Mapbox-token satt (bekreftet grasiøs tekst-only-degradering,
+ `shared_image_url: null`, meldingen dukket opp korrekt i
+ `/rounds/{id}/messages` med riktig `author_display_name`-fallback).
+ `test_isolation.sql` 12/12 uendret. `py_compile` rent. Scratch-miljøet
+ (DB/rolle/API-/MinIO-container/image) ryddet opp fullstendig
+ etterpå, bekreftet tomt.
+
+ **Ikke testet i denne runden** (avhenger av ting bruker/V0 ennå ikke
+ har levert): selve satellitt-bilde-genereringen (krever en ekte
+ `TEECUP_MAPBOX_SECRET_TOKEN`), hele frontend-flyten (avhenger av
+ V0-eksporten), ekte nettleserverifisering av "null Mapbox-kall på
+ ren-GPS-veien"-kostnadskontroll-invarianten (ADR-048 Beslutning B) --
+ gjøres når V0-eksporten er integrert.
+
+38. **Fast bunn-navigasjon manglet på fem av hovedsidene — 2026-08-07.**
+ Brukeren spurte, i forbindelse med dashbord-V0-forhåndsvisningen,
+ "hva skjedde med den sticky menyen i bunnen?" på andre sider enn
+ dashbordet. Undersøkt: `BottomTabBar` (eksportert fra
+ `dashboard.tsx`, migrasjon 2026-08-01) var kun faktisk importert i
+ `dashboard.tsx` selv og `more-menu.tsx` -- til tross for at BÅDE
+ `dashboard.tsx` sin egen kodekommentar ("vist på ALLE skjermer som
+ importerer denne") og CHANGELOG-oppføringen fra samme dag ("brukt av
+ flere sider") beskrev en bredere utrulling enn det som faktisk ble
+ koblet inn. Ingen ADR/CHANGELOG-oppføring dokumenterte en bevisst
+ innsnevring -- vurdert som en ufullstendig utrulling, ikke en
+ beslutning, og rettet direkte uten en egen avklaringsrunde (bekreftet
+ med bruker: fiks nå).
+
+ Lagt til i `own-rounds.tsx` (`active="rounds"` -- direkte fanemål),
+ `account-settings.tsx` (`active="profile"` -- direkte fanemål, Profil-
+ fanen peker allerede til `/account`), og `friends.tsx`/`feed.tsx`/
+ `notifications.tsx` (`active="more"` -- ingen av de tre er et direkte
+ fanemål, men alle tre er allerede listet som "Mer"-menyens egne
+ undersider i `more-menu.tsx`, så dette matcher appens eksisterende
+ IA i stedet for å oppfinne en ny). ``-bunnpolstring justert til
+ `pb-24` (samme verdi som dashbordet selv bruker for å ikke overlappe
+ den faste raden) i de fire filene som ikke allerede hadde nok
+ (`notifications.tsx` hadde fra før `pb-28`, urørt). Ren tillegging av
+ en allerede ferdig, gjenbrukt komponent -- ingen ny logikk.
+
+ **Reelt funn og rettet UNDER selve arbeidet, ikke i selve
+ bunn-nav-fiksen:** `frontend/pnpm-lock.yaml` hadde vært ute av synk
+ med `package.json` siden `mapbox-gl`/`@types/mapbox-gl` ble lagt til
+ (ADR-048, punkt 37 over) -- oppdaget først her fordi dette var første
+ gang siden den endringen at en full `pnpm install --frozen-lockfile`-
+ Docker-bygg faktisk ble kjørt. Ville ha feilet ETHVERT fremtidig
+ scratch- ELLER ekte frontend-bygg. Rettet: `@types/mapbox-gl` fjernet
+ helt (pnpm varslet at den er en utdatert stub -- `mapbox-gl` leverer
+ egne typer nå), `mapbox-gl` endret fra `^3.28.1` til eksakt `3.27.0`
+ (3.28.x var under ett døgn gammel og feilet pnpms egen
+ minimumReleaseAge-leverandørkjede-policy inni Docker-bygget -- en
+ fornuftig sikkerhetssperre mot ferskpubliserte pakker, ikke en feil
+ i verktøyet), lockfile
+ regenerert (`pnpm clean --lockfile && pnpm install`).
+
+ **Scratch-verifisert i ekte nettleser** (åttende scratch-miljø denne
+ økten: isolert DB fra migrasjoner 001-060 kjørt friskt, isolert
+ `teecup_app_scratch8`-rolle, isolert scratch-MinIO, scratch-API- og
+ -frontend-container -- frontend bygget med riktig scratch-
+ `TEECUP_API_ORIGIN`). Logget inn med ekte magic-link-flyt, fullførte
+ profil, besøkte alle fem rettede sider (`/my-rounds`, `/my-friends`,
+ `/account`, `/my-feed`, `/my-notifications`) pluss dashbordet --
+ bunn-raden vises korrekt nederst uten å overlappe innhold på noen av
+ dem, riktig fane fremhevet grønt på hver (Runder/Profil/Mer×3), ingen
+ konsollfeil på noen av sidene. Scratch-miljøet ryddet opp fullstendig
+ etterpå (denne runden traff samme fallgruve som punkt 37 med
+ migrasjon 002s hardkodede `teecup_db`-referanse -- rettet i den
+ kopierte migrasjonsfilen FØR kjøring denne gangen, ikke etterpå, og
+ eksplisitt bekreftet at ekte `teecup_db` sin `datacl` var uendret
+ etterpå).
+
+ **Ikke rullet ut ennå** -- venter på samlet bekreftelse sammen med
+ resten av det som gjenstår i denne økten (slagmåling-frontend,
+ dashbord-V0-beslutning).
+
+39. **Dashbordet reskinnet til "clubhouse"-paletten — 2026-08-07, bruker
+ bekreftet eksplisitt "full overtagelse, også bakgrunnen".** Egen,
+ parallell V0-utforskning (ikke samme retning som "Forest Green" fra
+ 2026-08-01/02) — sendt som en eksplisitt EKSPLORERENDE prompt ("samme
+ ånd som login-siden-utforskningen... ikke bedt om å matche eksisterende
+ stil"), forhåndsvist for bruker (screenshots av en `npm install`+
+ `next dev`-kjøring av selve V0-eksporten, mock-data) FØR noe ble
+ integrert i den ekte, datakoblede `dashboard.tsx`.
+
+ **Viktig funn før integrering:** paletten (`--tee`/`--tee-strong`/
+ `--cup`/`--cup-strong`/`--clubhouse-*`) og `TeeCupWordmark`-komponenten
+ fantes ALLEREDE i prosjektet — fra en tidligere, ennå ikke besluttet
+ `/logg-inn`-utforskning (egen isolert sammenligningsside, additive
+ CSS-tokens i `globals.css`, rørte ikke skya-tokens). V0 gjenbrukte dem
+ konsekvent for dashbord-eksporten fordi de allerede var etablert i
+ prosjektkonteksten, ikke fordi det gjenbrukte "gamle" farger — bekreftet
+ ved faktisk pikselsampling av skjermdumpen (`#2f6b1e`/`#ff5427`, begge
+ forskjellige fra "Forest Green" sin `#1f6b08`/ingen oransje i det hele
+ tatt). `/logg-inn`s egen etablerte presedens ble fulgt: Bricolage-
+ visningsfonten IKKE lagt til (samme utelatelse som der), kun paletten
+ og wordmarken.
+
+ **Omfangsgrenser, avklart eksplisitt med bruker underveis (ikke
+ antatt):** `RoundCard`/`TournamentCard` (delt med `/my-rounds`) IKKE
+ re-stylet — beholder sin eksisterende shadcn-token-baserte styling
+ (viste seg uansett visuelt kompatibelt, siden begge alt bruker et
+ grønt-for-primær/oransje-for-sekundær-mønster). `BottomTabBar` (delt
+ med de fem sidene fra punkt 38) IKKE re-stylet direkte av meg — bruker
+ ba eksplisitt om en egen V0-prompt for den ("lag et prompt til V0, og
+ la den bestemme utseendet") fremfor at jeg skulle avgjøre det selv,
+ siden komponenten må fungere rimelig godt mot BÅDE den nye og den gamle
+ paletten avhengig av hvilken side den vises på. Egen prompt skrevet og
+ sendt, venter på eksport.
+
+ **Faktisk re-stylet:** header (erstattet flagg-i-sirkel med ekte
+ `TeeCupWordmark`), hilsen, `InstallPrompt` (KUN dens egen selvstendige
+ JSX-retur, mørkegrønn gradient endret til en tee-strong-forankret
+ gradient — all ekte iOS/Android-deteksjons-/14-dagers-utsettelses-logikk
+ uendret, samme "logikk uendret, kun styling"-disiplin som Forest Green-
+ runden fulgte), `QuickAction` (fikk `primary`/`expanded`-varianter som
+ matcher V0-eksportens mønster — "Ny runde" solid, de to andre kort-
+ stil), delt seksjon-"chrome" (`SectionHeader`/`SeeAllLink`/`EmptyState`/
+ `ShortcutButton`), "Venner på banen", `Sparkline`/`StatTile`/
+ `StatsSection`, "Spilte baner", "Venner"-oppsummeringen, og
+ organisasjon-foten. `AVATAR_HUES` (venne-avatar-fargene) beholdt
+ uendret — allerede nøytrale nok til å fungere i begge paletter.
+
+ `tsc --noEmit` rent. **Scratch-verifisert i ekte nettleser** (niende
+ scratch-miljø denne økten, samme mønster som punkt 37/38 — inkludert
+ samme forsiktighet med migrasjon 002s databasenavn-linje, bekreftet
+ `teecup_db` uendret både før og etter): logget inn, fullført profil,
+ besøkt dashbordet i både tom tilstand og med ekte seed-data (pågående
+ runde, aktiv turnering som arrangør, statistikk, spilt bane) —
+ korrekt rendret i begge tilstander, `RoundCard`/`TournamentCard` sitter
+ visuelt fint på den nye bakgrunnen, "Ny turnering"-hurtighandlingen
+ utvidet korrekt og det eksisterende skjemaet (uendret) fungerte som før.
+ Ingen konsollfeil. Scratch-miljøet ryddet opp fullstendig.
+
+ **Ikke rullet ut ennå** -- venter på BottomTabBar-V0-eksporten (så
+ hele dashbordet, inkludert navigasjonen, kan rulles ut samlet i én
+ omgang i stedet for i to synlig usammenhengende steg) og på samlet
+ bekreftelse sammen med resten av det som gjenstår denne økten.
diff --git a/CLAUDE.md b/CLAUDE.md
index 743bd24..1e05270 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,11 +13,13 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
bruksanvisning.
- Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold
disse filene oppdatert når noe avgjøres.
-- **Regelverk for HCP/slagfordeling — tre PDF-er lastet opp av brukeren til
- prosjektroten 2026-07-19** (ikke innsjekket i git, kun lokale filer på
- serveren): `spilletyper-og-spilleformer-2023.pdf`,
- `Live Tourney _ A Guide to Handicap Scoring in Golf for Tournaments.pdf`,
- `SCGA Club Digest.pdf`. Brukeren: disse tre gir til sammen en tydelig
+- **Regelverk for HCP/slagfordeling — tre PDF-er lastet opp av brukeren
+ 2026-07-19** (ikke innsjekket i git, kun lokale filer på serveren, flyttet
+ fra prosjektroten til `Temp-uploads/` 2026-08-07 — se eget notat om denne
+ mappen lenger ned): `Temp-uploads/spilletyper-og-spilleformer-2023.pdf`,
+ `Temp-uploads/Live Tourney _ A Guide to Handicap Scoring in Golf for
+ Tournaments.pdf`, `Temp-uploads/SCGA Club Digest.pdf`. Brukeren: disse tre
+ gir til sammen en tydelig
beskrivelse av hvordan HCP og mottatte/tildelte slag skal beregnes/
fordeles. Les disse FØR videre arbeid med `handicap_engine.py`,
`app/handicap.py`, allowance-strategier (ADR-005/014) eller
@@ -38,6 +40,13 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
kildekoden (DESIGN_SYSTEM.md beskriver den), men spec-dokumentets §3
"Compliance-pass" er en konkret sjekkliste over kjente avvik — les den FØR
videre visuelt arbeid på scorekort-/score-entry-skjermene.
+- **`Temp-uploads/`-mappen (opprettet av bruker 2026-08-07, gitignored)**
+ er der brukeren laster opp støttefiler — skjermdumper, videoer, PDF-er,
+ V0-eksport-zip-er og lignende — ment som illustrasjon/kildemateriale til
+ Claude, ikke som permanente prosjektfiler. Sjekk denne mappen når
+ brukeren nevner at de "har lastet opp" noe uten å oppgi full sti. Flytt
+ nye støttefiler dit etter hvert som de dukker opp i prosjektroten,
+ fremfor å la dem hope seg opp løst i roten.
## Sikkerhetsregler (ufravikelige)
- Rør ALDRI `teeoff`-databasen eller den ekte `teecup_db` uten at brukeren
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index 6af5e42..9ecb2bd 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -1947,14 +1947,14 @@ forskjell mellom scramble og greensome i noen av domenene.
| Video | 💤 | Fortsatt utsatt, egen ADR om/når etterspurt. |
| 1-til-1 direktemeldinger | 💤 | Fortsatt utsatt, ikke etterspurt. |
| Moderering (offentlig feed) | ✅ LIVE | Forfatteren selv, ELLER org-eier/admin, kan slette et innlegg. Lag-chatten har ingen moderering utover forfatteren (rommet er privat, org-admin har uansett ikke lesetilgang). |
-| Bilder + kommentarer i frittstående (personlige) runder + samlet `/my-feed` | ✅ BYGGET, SCRATCH-VERIFISERT, venter på utrulling mot ekte `teecup_db` | ADR-044, migrasjon 058. Skriverett = kan-se-kan-poste (ikke kun eier/medspillere), se ADR-044 for full begrunnelse. |
+| Bilder + kommentarer i frittstående (personlige) runder + samlet `/my-feed` | ✅ LIVE 2026-08-06 | ADR-044, migrasjon 058. Skriverett = kan-se-kan-poste (ikke kun eier/medspillere), se ADR-044 for full begrunnelse. |
Se ARCHITECTURE_DECISIONS.md ADR-025 for alle fire hovedbeslutningene og
CHANGELOG.md for full byggerunde (datamodell, autorisasjon, scratch-
verifisering med 20 automatiserte sjekker inkl. reell WebSocket-sanntid,
og utrulling).
-### Bilder + kommentarer i frittstående runder + samlet feed — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-08-06 (ADR-044, migrasjon 058), venter på utrulling mot ekte `teecup_db`
+### Bilder + kommentarer i frittstående runder + samlet feed — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-06 (ADR-044, migrasjon 058)
Brukeren: "I org-modulen har vi muligheter til bilder og kommentarer. Jeg
ønsker at dette også skal være mulig i single runder... Det må kanskje
@@ -1985,8 +1985,8 @@ teste dette). Én kritisk rute-/rewrite-kollisjon (`/feed` vs. API-ets
`GET /feed`) funnet og rettet under verifisering — se ADR-044 for detalj.
`test_isolation.sql` uendret 12/12.
-**Ikke rullet ut mot ekte `teecup_db` ennå** — venter på brukerens
-eksplisitte bekreftelse, samme rutine som alltid. Se CHANGELOG.md
+**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt: migrasjon 058
+kjørt mot ekte `teecup_db`, begge containere redeployet. Se CHANGELOG.md
2026-08-06 for full byggelogg.
---
@@ -3427,6 +3427,140 @@ eller bygget -- eneste konkrete tiltak er en kode-KOMMENTAR i
siden av GIR-merket, slik at en fremtidig avstand-indikator kan legges
til uten en layout-endring. Ingen data, ingen funksjonalitet.
+**Brainstorm-runde 2026-08-06 -- fortsatt IKKE designet/besluttet, kun
+retning avklart.** Brukeren ba eksplisitt om å starte konkretisering av
+avstandsmåling, pekte ut ekte spiller-GPS-posisjon (ikke bare statiske
+banepunkter) som første prioritet, og ba meg lese en opplastet PDF
+("Golfbane API for internasjonal app.pdf" -- en eksportert Gemini-
+samtale om internasjonal banedata for TeeCup) som grunnlag.
+
+**Fra PDF-en (fakta, ikke min vurdering):** anbefalt dataleverandør for
+banedata utenfor Norge er golfapi.io (~42 000 baner, REST-API ELLER
+CSV-database-eksport). Priser: API 29 €/mnd (50 kall) opp til 399 €/mnd
+(ubegrenset, årsabonnement), eller en ikke-utløpende engangspakke på
+500 kall for 199 €. CSV-eksport MED koordinater: 2 995 € (Europa) til
+5 995 € (alle baner) -- betydelig dyrere engangskostnad. **Avgjørende
+lisensdetalj:** golfapi.io tillater eksplisitt permanent caching av
+hentede banedata ("no need to call the API to fetch the same course
+multiple times") -- ett kall per bane NOEN GANG er nok, ikke ett per
+oppslag. Alternativ leverandør nevnt: iGolf (40 000+ baner, har
+"greenkart" -- trolig ekte polygon-geometri, ikke bare punkter -- men
+eksplisitt "ofte dyrt for mindre apper", B2B/enterprise).
+
+**Min vurdering (Claude, denne datoen) -- IKKE besluttet, kun anbefalt:**
+1. Caching-tillatelsen gjør API-veien (billig inngangspakke, 199 €)
+ klart mer kostnadseffektiv enn CSV-kjøpet i tidlig fase -- enig med
+ PDF-ens egen anbefaling. **Merk arkitektur-nyansen:** dette er en
+ BEVISST ANNERLEDES policy enn teeoff-regelen i CLAUDE.md ("banedata
+ hentes fra teeoff via lesende API, ikke delt database") -- teeoff
+ forblir levende oppslag uendret, golfapi.io (skulle det velges) ville
+ fått sin EGEN, ny policy (persistent cache tillatt av leverandørens
+ egen lisens). Bør få en egen ADR den dagen dette faktisk bygges, ikke
+ stille anta at det er samme regel.
+2. **Brukerens hovedbekymring (kostnad ved å illustrere hvert hull) er
+ en ANNEN datatype enn selve avstandsdataene, og bør skilles fra dem:**
+ punkt-koordinater (pin/green-senter/hazard) for AVSTANDSTALL er
+ billig og dekket av planen over. Et ekte VISUELT, konturriktig
+ hullkart (fairway-/bunker-/green-FORM, ikke bare punkter) er
+ polygon-geometri -- en annen, dyrere datatype som verken golfapi.io
+ sin liste eller PDF-en nevner at de har (det er nettopp det
+ iGolf/"Golf Intelligence" sin enterprise-prising dekker).
+3. **Anbefalt v1, for å unngå den kostnaden brukeren er bekymret for:**
+ bygg avstandsmåling som REN TALL-/TEKSTVISNING (f.eks. "142 m til
+ green"), IKKE et tegnet hullkart -- løser hele "avstandsmåling"-
+ ambisjonen med kun punktdata, ingen kartrendring-kostnad i det hele
+ tatt. Et ekte visuelt hullkart bør være et bevisst SENERE, adskilt
+ steg. Når/hvis det steget kommer: undersøk OpenStreetMap sin
+ `golf=*`-tagging (fairway/green/bunker-polygoner, community-kartlagt,
+ GRATIS via Overpass API, rendret med et gratis bibliotek som Leaflet)
+ for banene brukerne faktisk spiller, FØR proprietær enterprise-
+ geometri (iGolf/Golf Intelligence) vurderes -- OSM-dekning varierer
+ per bane (bedre for kjente baner), men koster 0 kr der den finnes.
+
+**Fortsatt åpent, ikke avklart:** eksakt datakilde-valg (golfapi.io vs.
+alternativ), om/når et visuelt hullkart faktisk skal bygges, hvordan
+spiller-GPS-posisjon (Geolocation API) kombineres med de lagrede
+punktene til en løpende avstandsberegning, og UI-plassering (samme
+reserverte plass ved GIR-merket nevnt 2026-07-25, eller noe nytt).
+
+**Brainstorm-runde 2026-08-07 -- "slaglengde-måling" konkretisert
+(fortsatt IKKE designet/besluttet).** Brukeren beskrev en konkret flyt:
+mål avstand FRA enten (a) spillerens live GPS-posisjon, eller (b) et
+manuelt valgt punkt på et satellittkart, TIL der spilleren står ved
+ballen -- pluss hvilken kølle som ble brukt. Kan deles i feeden eller
+holdes privat.
+
+**Nøkkelinnsikt fra research:** dette er BILLIGERE enn "avstand til
+faste banepunkter" (notatet over) -- det trenger INGEN forhåndskjent
+banegeometri (pin/hazard-koordinater), kun to AD HOC-punkter nær
+spilleren der og da. Kartet trenger bare sentreres på spillerens egen
+posisjon, ikke vite noe om hullet på forhånd.
+
+**Avklart med bruker (AskUserQuestion, to spørsmål):**
+1. **Omfang v1:** IKKE bare utslaget -- full slag-for-slag-logg fra
+ start (ethvert slag på hullet skal kunne måles, ikke kun det første).
+ Krever en NY, egen tabell (dagens `round_hole` har kun kategoriske
+ per-hull-felt som `club_off_tee`/`tee_shot_result`, ingen ekte
+ slag-for-slag-logg med avstand). Kølle-valg gjenbruker den
+ eksisterende, faste 28-køllers `BAG_CLUBS`-listen
+ (`app/routers/auth.py`) uendret.
+2. **Kart-leverandør:** usikker, ba om videre diskusjon FØR beslutning
+ (se under).
+
+**Kart-leverandør-sammenligning (ferske, verifiserte tall/vilkår,
+2026-08-07 -- IKKE fra treningsdata alene):**
+- **Mapbox GL JS:** 50 000 gratis kart-innlastinger/mnd, INGEN
+ betalingskort kreves for å starte, kommersiell bruk eksplisitt tillatt.
+ Over grensen: ca. $5/1000 ekstra innlastinger.
+- **Leaflet + Esri World Imagery** (den vanlige "gratis"-kombinasjonen):
+ **reelt IKKE gratis for et kommersielt produkt** -- Esri sine egne
+ vilkår sier eksplisitt at World Imagery-laget ikke er tillatt for
+ kommersiell bruk uten egen ArcGIS-lisens. Siden TeeCup skal
+ kommersialiseres, diskvalifiserer dette denne kombinasjonen som et
+ reelt gratis-alternativ, selv om Leaflet-BIBLIOTEKET selv er gratis.
+- **Anbefaling:** Mapbox GL JS -- eneste av de to reelt gratis OG
+ ToS-trygt for et kommersielt produkt på TeeCups sannsynlige skala.
+
+**Kostnadsoverslag ved 100 000 brukere (grovt anslag, IKKE en garanti):**
+Nøkkelen er at én "kart-innlasting" telles per gang kartkomponenten
+INITIALISERES, ikke per slag/trykk -- ÉTT kartobjekt holdt levende
+gjennom en hel runde (oppdatert med nye punkter per slag, ikke
+re-montert) gjør at 14 slag i én runde koster like mye som ett. Med
+konservative antakelser (30 % månedlig aktive, 30 % bruker funksjonen,
+3 runder/mnd) lander man rundt 27 000 innlastinger/mnd -- under
+gratisgrensen. Et aggressivt anslag (50/50/4) lander rundt 100 000/mnd,
+altså ~$250/mnd i overforbruk -- en normal, forutsigbar
+infrastrukturkostnad ved den brukerskalaen, ikke noe som truer en
+forretningsmodell med betalende klubber/bedrifter. To konkrete grep som
+holder volumet nede uansett skala: (1) kartet vises KUN når brukeren
+eksplisitt velger "velg punkt på kart" i stedet for "bruk min posisjon"
+-- ren GPS krever ingen kart-innlasting i det hele tatt, (2) selve
+avstandsberegningen (Haversine-formel) er ren matte og treffer aldri
+Mapbox. Ved genuint mye større skala (millioner av brukere): forhandle
+en Enterprise-avtale direkte med Mapbox, eller revurder da -- ikke bygg
+inn kompleksitet for det nå.
+
+**AVKLART OG DESIGNET 2026-08-07, se ADR-048 i ARCHITECTURE_DECISIONS.md
+for den fulle beslutningen.** De tre gjenstående spørsmålene over ble
+avklart med bruker (AskUserQuestion): v1 dekker BÅDE individuelle runder
+OG lagformater fra start (ikke bare individuell), delingsteksten er
+auto-generert MEN redigerbar pluss et satellitt-utsnitt av slaget, og
+måling er tilgjengelig både i scoreførings-veiviseren og som en
+retroaktiv hull-kort-handling. Skjema: ny `round_shot`-tabell (migrasjon
+060), skjema-detaljene i ADR-048.
+
+**Status: backend FERDIG og scratch-verifisert 2026-08-07** (migrasjon,
+alle endepunkter, delings-flyt med satellitt-bilde-generering) — se
+CHANGELOG.md punkt 37 for full detalj. Frontend delvis (Haversine-
+hjelper, delt `ClubPicker`, Mapbox-avhengighet + token-plumbing).
+**Gjenstår:** V0-eksport for selve `ShotMeasurementSheet` (prompt sendt
+til bruker, venter på svar), kobling av inngangspunktene i
+`round-detail.tsx`, reelle Mapbox-token i `.env` (kun tomme
+plassholdere satt inn foreløpig), ekte nettleserverifisering av
+kostnadskontroll-invarianten (ADR-048 Beslutning B), og til slutt
+utrulling til ekte database/containere (krever migrasjon 060 kjørt mot
+ekte `teecup_db` — IKKE gjort ennå, venter på eksplisitt bekreftelse).
+
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
Brukeren bekreftet 2026-07-22 at teeoff-banedata skal hentes via LIVE
@@ -4224,6 +4358,24 @@ utrulling), bruker bekreftet eksplisitt.
---
+## Tilskuer-visning (`/watch/[id]`): tre faner + "Spillere og runde" — ✅ HELT FERDIG, BYGGET, SCRATCH-VERIFISERT OG LIVE 2026-08-06 (ADR-045)
+
+To bugger fanget opp samme dag (video-illustrasjon fra bruker): "Venner på
+banen" på dashbordet lenket til feil rute (`/my-rounds/{id}` i stedet for
+`/watch/{id}`), og selve `/watch/[id]` var kun ÉN enkelt side (kompakt
+status + leaderboard/matchstatus, kun 8 av 17 formater dekket). Begge
+rettet -- se ADR-045 for full detalj. Tilskueren får nå samme tre faner
+som eieren (Score/Scorekort/Leaderboard, ekte full formatparitet via
+`RoundScorecard`/`RoundLeaderboard` sin nye `publicMode`-prop) og
+"Spillere og runde" (deltakerliste, HCP/utslag/tildelte slag, flight-
+gruppering via et nytt `GET /public/rounds/{id}/flight-group`-endepunkt),
+uten noen av eier-handlingene (Rediger/Fullfør/Slett runde, +Medspiller).
+
+**Rullet ut live 2026-08-06**, bruker bekreftet eksplisitt. Se
+CHANGELOG.md 2026-08-06 for full byggelogg.
+
+---
+
## Bevisst endret fra opprinnelige (Gemini-)råd
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
@@ -4233,3 +4385,47 @@ utrulling), bruker bekreftet eksplisitt.
- 🔀 **Tenant-modell:** organisasjon som tenant med RLS, ikke bare «turnering-ID».
- 🔀 **Sesjons-secret:** egne secrets for TeeCup, ikke fallback til teeoffs
(teeoff selv bruker en slik fallback — bevisst unngått her).
+
+## Visuell redesign-utforskning — parallell `/logg-inn`-side (V0), 2026-08-07
+
+Brukeren er lite fornøyd med appens visuelle uttrykk generelt, og ba om
+en test: gi V0 en ny chat med (1) et fyldig merkevare-/personlighets-
+brief om hva TeeCup er og skal bli, (2) en presis, mekanisk teknisk
+spesifikasjon av hva innloggingsskjermen faktisk må inneholde/gjøre
+(hentet fra ekte kode, `login-form.tsx`/`verify-form.tsx` — fire
+tilstander: magic-link, lenke-sendt, passord-fallback, bli-med-via-kode),
+og (3) selve logoen, uten noen ytterligere beskrivelse av den — bevisst
+IKKE bundet til dagens låste "Forest Green"-palett, for å se om et friskt
+blikk gir noe bedre. V0-prompten er skrevet av Claude, sendt til bruker
+for innliming i en NY, egen V0-chat (samme "Frontend via V0"-arbeidsdeling
+som ellers, men her eksplisitt uten dagens design-tokens som ledetekst).
+
+**Resultat:** V0 leverte en selvstendig, fungerende sluttbrukerflyt på
+`app/logg-inn/page.tsx` + `components/teecup/{teecup-auth,wordmark}.tsx`
+— alle fire tilstander bygget med ekte klientsidig validering/cooldown/
+feilhåndtering (mocket backend internt, siden dette er en isolert
+design-utforskning, ikke koblet til ekte `/auth/*`-endepunkter ennå).
+Egen ordmerke tegnet på nytt som rene SVG-baner fra den opplastede
+logoen ("Tee" i grønt, "Cup" i oransjerødt), og en egen, SKOPET
+"clubhouse"-fargepalett (`--tee-strong: #2f6b1e`, `--cup-strong:
+#cf3c17`, varm off-white bakgrunn) — en helt annen visuell retning enn
+dagens låste palett.
+
+**Integrert av Claude som parallell, isolert rute** (`/logg-inn`, IKKE en
+overskriving av `/`): kopiert inn uendret, "clubhouse"-fargetokens lagt
+til ADDITIVT nederst i `app/globals.css` (skoper kun til denne siden,
+rører ingen eksisterende tokens). Typesjekket rent, ekte produksjonsbuild
+kjørt og bekreftet ren, alle fire tilstander browserverifisert i en
+isolert forhåndsvisnings-container (ingen konsollfeil).
+
+**Rullet ut live 2026-08-07**, bruker bekreftet eksplisitt ("Ja takk"):
+`docker compose up -d --build teecup_frontend`. Begge containere boot-et
+rent, `/logg-inn` → 200, `/` (dagens innlogging, uendret) → 200 fortsatt.
+Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet.
+
+**Ingen beslutning tatt ennå om å faktisk ta i bruk denne retningen** —
+kun en parallell sammenligning. Ingen backend-kobling gjort — hvis denne
+retningen velges, må `teecup-auth.tsx` sine mockede API-kall kobles til
+de ekte `/auth/request-link`/`/auth/verify-link`/`/auth/login-password`/
+`/public/tournaments/by-code`-endepunktene (samme kontrakt som
+`login-form.tsx` allerede bruker) før den kan erstatte dagens `/`.
diff --git a/Live Tourney _ A Guide to Handicap Scoring in Golf for Tournaments.pdf b/Live Tourney _ A Guide to Handicap Scoring in Golf for Tournaments.pdf
deleted file mode 100644
index 2f8a570..0000000
Binary files a/Live Tourney _ A Guide to Handicap Scoring in Golf for Tournaments.pdf and /dev/null differ
diff --git a/Order of Merit - Opret Order of Merit – GolfBox Support og salg.pdf b/Order of Merit - Opret Order of Merit – GolfBox Support og salg.pdf
deleted file mode 100644
index c3ee2bf..0000000
Binary files a/Order of Merit - Opret Order of Merit – GolfBox Support og salg.pdf and /dev/null differ
diff --git a/SCGA Club Digest.pdf b/SCGA Club Digest.pdf
deleted file mode 100644
index 699459b..0000000
Binary files a/SCGA Club Digest.pdf and /dev/null differ
diff --git a/Screenshot_20260723-113235.png b/Screenshot_20260723-113235.png
deleted file mode 100644
index 6d8d107..0000000
Binary files a/Screenshot_20260723-113235.png and /dev/null differ
diff --git a/Screenshot_20260723-113302.png b/Screenshot_20260723-113302.png
deleted file mode 100644
index db941bd..0000000
Binary files a/Screenshot_20260723-113302.png and /dev/null differ
diff --git a/Screenshot_20260726-054841.png b/Screenshot_20260726-054841.png
deleted file mode 100644
index fa76649..0000000
Binary files a/Screenshot_20260726-054841.png and /dev/null differ
diff --git a/Screenshot_20260726-054853.png b/Screenshot_20260726-054853.png
deleted file mode 100644
index 182793c..0000000
Binary files a/Screenshot_20260726-054853.png and /dev/null differ
diff --git a/Screenshot_20260729-104329.png b/Screenshot_20260729-104329.png
deleted file mode 100644
index 6dfb428..0000000
Binary files a/Screenshot_20260729-104329.png and /dev/null differ
diff --git a/Screenshot_20260729-123421.png b/Screenshot_20260729-123421.png
deleted file mode 100644
index 42acdbf..0000000
Binary files a/Screenshot_20260729-123421.png and /dev/null differ
diff --git a/Video_2026-08-03_145419.mp4 b/Video_2026-08-03_145419.mp4
deleted file mode 100644
index 363062a..0000000
Binary files a/Video_2026-08-03_145419.mp4 and /dev/null differ
diff --git a/WHS_Rules_of_Handicapping_2024.pdf b/WHS_Rules_of_Handicapping_2024.pdf
deleted file mode 100644
index cefb18f..0000000
Binary files a/WHS_Rules_of_Handicapping_2024.pdf and /dev/null differ
diff --git a/app/config.py b/app/config.py
index 5f0711e..a685471 100644
--- a/app/config.py
+++ b/app/config.py
@@ -106,5 +106,13 @@ class Settings:
VAPID_SUBJECT: str = os.getenv("TEECUP_VAPID_SUBJECT", "https://teecup.golf")
PUSH_CONFIGURED: bool = bool(VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY)
+ # Mapbox Static Images API (ADR-048) -- serverside genererte satellitt-
+ # utsnitt for delte slag-målinger. Egen, HEMMELIG token -- IKKE samme
+ # som frontendens offentlige NEXT_PUBLIC_MAPBOX_TOKEN (URL-restriktert,
+ # trygt eksponert i nettleseren). Denne skal ALDRI nå klienten. Valgfritt,
+ # samme grasiøs-degraderings-mønster som SMTP/push: deling uten satellitt-
+ # bilde faller tilbake til ren tekst hvis ikke satt.
+ MAPBOX_SECRET_TOKEN: str | None = os.environ.get("TEECUP_MAPBOX_SECRET_TOKEN", "").strip() or None
+
settings = Settings()
diff --git a/app/routers/messaging.py b/app/routers/messaging.py
index d9d05b3..53851f3 100644
--- a/app/routers/messaging.py
+++ b/app/routers/messaging.py
@@ -35,6 +35,7 @@ from ..realtime import live_sockets_for
from ..team_authz import is_org_admin, user_is_rostered_on_team
from .. import storage
from .registration import check_visibility, code_matches, is_participant, resolve_org
+from .round_messages import ALLOWED_REACTION_EMOJIS, CommentIn, ReactionIn, ReactionSummary
router = APIRouter(tags=["messaging"])
public_router = APIRouter(prefix="/public/tournaments", tags=["public-messaging"])
@@ -51,9 +52,13 @@ class MessageOut(BaseModel):
body: str | None
image_url: str | None
created_at: str
+ reactions: list[ReactionSummary] = []
+ comment_count: int = 0
-def _row_to_message(row) -> MessageOut:
+def _row_to_message(
+ row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
+) -> MessageOut:
return MessageOut(
id=row["id"],
author_user_id=row["author_user_id"],
@@ -61,9 +66,92 @@ def _row_to_message(row) -> MessageOut:
body=row["body"],
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
created_at=row["created_at"].isoformat(),
+ reactions=reactions or [],
+ comment_count=comment_count,
)
+# --- Reaksjoner/kommentarer (ADR-046) -- delt mellom lag-chat og ---------
+# --- oppslagstavle, siden begge lagrer i `message` med scope-diskriminator.
+
+_MESSAGE_COMMENT_COLUMNS = """
+ id::text, message_id::text, parent_comment_id::text, author_user_id::text,
+ author_display_name, body, created_at
+"""
+
+
+class CommentOut(BaseModel):
+ id: str
+ message_id: str
+ parent_comment_id: str | None
+ author_user_id: str
+ author_display_name: str
+ body: str
+ created_at: str
+
+
+def _row_to_message_comment(row) -> CommentOut:
+ return CommentOut(
+ id=row["id"],
+ message_id=row["message_id"],
+ parent_comment_id=row["parent_comment_id"],
+ author_user_id=row["author_user_id"],
+ author_display_name=row["author_display_name"],
+ body=row["body"],
+ created_at=row["created_at"].isoformat(),
+ )
+
+
+async def _message_reactions(
+ conn, message_ids: list[str], viewer_user_id: str | None
+) -> dict[str, list[ReactionSummary]]:
+ if not message_ids:
+ return {}
+ rows = await conn.fetch(
+ """
+ SELECT mr.message_id::text AS message_id, mr.emoji, count(*) AS count,
+ COALESCE(bool_or(mr.user_id = $2), false) AS reacted_by_me,
+ array_agg(
+ COALESCE(p.display_name, split_part(au.email, '@', 1))
+ ORDER BY mr.created_at
+ ) AS reactors
+ FROM message_reaction mr
+ JOIN app_user au ON au.id = mr.user_id
+ LEFT JOIN player p ON p.organization_id = mr.organization_id AND p.user_id = mr.user_id
+ WHERE mr.message_id = ANY($1::uuid[])
+ GROUP BY mr.message_id, mr.emoji
+ """,
+ message_ids,
+ viewer_user_id,
+ )
+ result: dict[str, list[ReactionSummary]] = {}
+ for r in rows:
+ result.setdefault(r["message_id"], []).append(
+ ReactionSummary(
+ emoji=r["emoji"],
+ count=r["count"],
+ reacted_by_me=r["reacted_by_me"],
+ reactors=list(r["reactors"]),
+ )
+ )
+ return result
+
+
+async def _message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
+ if not message_ids:
+ return {}
+ rows = await conn.fetch(
+ """
+ SELECT message_id::text AS message_id, count(*) AS count
+ FROM message_comment
+ WHERE message_id = ANY($1::uuid[])
+ GROUP BY message_id
+ """,
+ message_ids,
+ )
+ return {r["message_id"]: r["count"] for r in rows}
+
+
async def _resolve_author_display_name(conn, organization_id: str, user_id: str) -> str:
"""Fryses ved skrivetidspunkt (samme prinsipp som handicap_index_snapshot,
ADR-007) -- avsenderens player.display_name i DENNE org-en hvis den
@@ -129,15 +217,23 @@ async def list_team_messages(
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
if not await user_is_rostered_on_team(conn, team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
+ # Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over
+ # alt", samme rekkefølge som round_message og tournament_feed nå).
rows = await conn.fetch(
f"""
SELECT {_MESSAGE_COLUMNS} FROM message
WHERE team_id = $1 AND scope = 'team'
- ORDER BY created_at
+ ORDER BY created_at DESC
""",
team_id,
)
- return [_row_to_message(r) for r in rows]
+ message_ids = [r["id"] for r in rows]
+ reactions_by_id = await _message_reactions(conn, message_ids, user.user_id)
+ comment_counts = await _message_comment_counts(conn, message_ids)
+ return [
+ _row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
+ for r in rows
+ ]
@router.post(
@@ -219,6 +315,176 @@ async def delete_team_message(
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
+# --- Reaksjoner/kommentarer på lag-chat-meldinger (ADR-046) ---------------
+# Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve
+# meldingen (rostret på laget); sletting av kommentar er kun forfatteren,
+# samme "ekte privat rom" begrunnelse som `delete_team_message`.
+
+
+async def _get_team_message_or_404(conn, team_id: str, message_id: str) -> None:
+ exists = await conn.fetchval(
+ "SELECT id FROM message WHERE id = $1 AND team_id = $2 AND scope = 'team'",
+ message_id,
+ team_id,
+ )
+ if exists is None:
+ raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
+
+
+@router.put(
+ "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction",
+ response_model=list[ReactionSummary],
+)
+async def set_team_message_reaction(
+ team_id: str,
+ organization_id: str,
+ message_id: str,
+ payload: ReactionIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ReactionSummary]:
+ if payload.emoji not in ALLOWED_REACTION_EMOJIS:
+ raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
+ async with org_connection(organization_id) as conn:
+ if not await user_is_rostered_on_team(conn, team_id, user.user_id):
+ raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
+ await _get_team_message_or_404(conn, team_id, message_id)
+ await conn.execute(
+ """
+ INSERT INTO message_reaction (organization_id, message_id, user_id, emoji)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
+ """,
+ organization_id,
+ message_id,
+ user.user_id,
+ payload.emoji,
+ )
+ summaries = await _message_reactions(conn, [message_id], user.user_id)
+ return summaries.get(message_id, [])
+
+
+@router.delete(
+ "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/reaction",
+ response_model=list[ReactionSummary],
+)
+async def remove_team_message_reaction(
+ team_id: str,
+ organization_id: str,
+ message_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ReactionSummary]:
+ async with org_connection(organization_id) as conn:
+ if not await user_is_rostered_on_team(conn, team_id, user.user_id):
+ raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
+ await _get_team_message_or_404(conn, team_id, message_id)
+ await conn.execute(
+ "DELETE FROM message_reaction WHERE message_id = $1 AND user_id = $2",
+ message_id,
+ user.user_id,
+ )
+ summaries = await _message_reactions(conn, [message_id], user.user_id)
+ return summaries.get(message_id, [])
+
+
+@router.get(
+ "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments",
+ response_model=list[CommentOut],
+)
+async def list_team_message_comments(
+ team_id: str,
+ organization_id: str,
+ message_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[CommentOut]:
+ async with org_connection(organization_id) as conn:
+ if not await user_is_rostered_on_team(conn, team_id, user.user_id):
+ raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
+ await _get_team_message_or_404(conn, team_id, message_id)
+ rows = await conn.fetch(
+ f"""
+ SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment
+ WHERE message_id = $1 ORDER BY created_at ASC
+ """,
+ message_id,
+ )
+ return [_row_to_message_comment(r) for r in rows]
+
+
+@router.post(
+ "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments",
+ response_model=CommentOut,
+ status_code=201,
+)
+async def post_team_message_comment(
+ team_id: str,
+ organization_id: str,
+ message_id: str,
+ payload: CommentIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> CommentOut:
+ body = payload.body.strip()
+ if not body:
+ raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
+ async with org_connection(organization_id) as conn, translate_db_errors():
+ if not await user_is_rostered_on_team(conn, team_id, user.user_id):
+ raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
+ await _get_team_message_or_404(conn, team_id, message_id)
+ if payload.parent_comment_id is not None:
+ parent_exists = await conn.fetchval(
+ "SELECT id FROM message_comment WHERE id = $1 AND message_id = $2",
+ payload.parent_comment_id,
+ message_id,
+ )
+ if parent_exists is None:
+ raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
+
+ display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO message_comment
+ (organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING {_MESSAGE_COMMENT_COLUMNS}
+ """,
+ organization_id,
+ message_id,
+ payload.parent_comment_id,
+ user.user_id,
+ display_name,
+ body,
+ )
+ return _row_to_message_comment(row)
+
+
+@router.delete(
+ "/orgs/{organization_id}/teams/{team_id}/messages/{message_id}/comments/{comment_id}",
+ status_code=204,
+)
+async def delete_team_message_comment(
+ team_id: str,
+ organization_id: str,
+ message_id: str,
+ comment_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> None:
+ """Kun forfatteren selv, INGEN unntak -- samme "ekte privat rom"-
+ begrunnelse som `delete_team_message`."""
+ async with org_connection(organization_id) as conn:
+ row = await conn.fetchrow(
+ """
+ SELECT author_user_id::text AS author_user_id FROM message_comment
+ WHERE id = $1 AND message_id = $2
+ """,
+ comment_id,
+ message_id,
+ )
+ if row is None:
+ raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
+ if row["author_user_id"] != user.user_id:
+ raise app_error(403, "NOT_COMMENT_AUTHOR", "Du kan kun slette dine egne kommentarer.")
+ await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_id)
+
+
@router.websocket("/ws/orgs/{organization_id}/teams/{team_id}/messages")
async def team_chat_ws(websocket: WebSocket, organization_id: str, team_id: str) -> None:
user = await get_current_user_from_websocket(websocket)
@@ -284,15 +550,23 @@ async def get_feed(
if not code_matches(row["join_code"], code):
await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
+ # Nyeste øverst (2026-08-06, brukerens eksplisitte ønske -- "over
+ # alt", samme rekkefølge som round_message og lag-chatten nå).
rows = await conn.fetch(
f"""
SELECT {_MESSAGE_COLUMNS} FROM message
WHERE tournament_id = $1 AND scope = 'tournament_feed'
- ORDER BY created_at
+ ORDER BY created_at DESC
""",
tournament_id,
)
- return [_row_to_message(r) for r in rows]
+ message_ids = [r["id"] for r in rows]
+ reactions_by_id = await _message_reactions(conn, message_ids, user.user_id if user else None)
+ comment_counts = await _message_comment_counts(conn, message_ids)
+ return [
+ _row_to_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
+ for r in rows
+ ]
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
@@ -381,6 +655,192 @@ async def delete_feed_message(
await conn.execute("DELETE FROM message WHERE id = $1", message_id)
+# --- Reaksjoner/kommentarer på oppslagstavle-innlegg (ADR-046) -----------
+# Samme "hvem kan poste = hvem kan reagere/kommentere"-modell som selve
+# innlegget (`_may_post_to_feed`); sletting av kommentar er forfatter
+# ELLER org-admin, samme moderasjonsvei som `delete_feed_message`.
+
+
+async def _get_feed_message_or_404(conn, tournament_id: str, message_id: str) -> None:
+ exists = await conn.fetchval(
+ "SELECT id FROM message WHERE id = $1 AND tournament_id = $2 AND scope = 'tournament_feed'",
+ message_id,
+ tournament_id,
+ )
+ if exists is None:
+ raise app_error(404, "NOT_FOUND", "Meldingen finnes ikke.")
+
+
+@public_router.put("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary])
+async def set_feed_message_reaction(
+ tournament_id: str,
+ message_id: str,
+ payload: ReactionIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ReactionSummary]:
+ if payload.emoji not in ALLOWED_REACTION_EMOJIS:
+ raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
+ organization_id = await resolve_org(tournament_id)
+ async with org_connection(organization_id) as conn:
+ if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
+ raise app_error(
+ 403,
+ "NOT_A_PARTICIPANT",
+ "Du må være medlem av organisasjonen eller delta i turneringen for å reagere.",
+ )
+ await _get_feed_message_or_404(conn, tournament_id, message_id)
+ await conn.execute(
+ """
+ INSERT INTO message_reaction (organization_id, message_id, user_id, emoji)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
+ """,
+ organization_id,
+ message_id,
+ user.user_id,
+ payload.emoji,
+ )
+ summaries = await _message_reactions(conn, [message_id], user.user_id)
+ return summaries.get(message_id, [])
+
+
+@public_router.delete("/{tournament_id}/feed/{message_id}/reaction", response_model=list[ReactionSummary])
+async def remove_feed_message_reaction(
+ tournament_id: str,
+ message_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ReactionSummary]:
+ organization_id = await resolve_org(tournament_id)
+ async with org_connection(organization_id) as conn:
+ if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
+ raise app_error(
+ 403,
+ "NOT_A_PARTICIPANT",
+ "Du må være medlem av organisasjonen eller delta i turneringen for å reagere.",
+ )
+ await _get_feed_message_or_404(conn, tournament_id, message_id)
+ await conn.execute(
+ "DELETE FROM message_reaction WHERE message_id = $1 AND user_id = $2",
+ message_id,
+ user.user_id,
+ )
+ summaries = await _message_reactions(conn, [message_id], user.user_id)
+ return summaries.get(message_id, [])
+
+
+@public_router.get("/{tournament_id}/feed/{message_id}/comments", response_model=list[CommentOut])
+async def list_feed_message_comments(
+ tournament_id: str,
+ message_id: str,
+ code: str | None = None,
+ user: CurrentUser | None = Depends(get_current_user_optional),
+) -> list[CommentOut]:
+ organization_id = await resolve_org(tournament_id)
+ async with org_connection(organization_id) as conn:
+ row = await conn.fetchrow(
+ "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
+ )
+ if row is None:
+ raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
+ if not code_matches(row["join_code"], code):
+ await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
+ await _get_feed_message_or_404(conn, tournament_id, message_id)
+ rows = await conn.fetch(
+ f"""
+ SELECT {_MESSAGE_COMMENT_COLUMNS} FROM message_comment
+ WHERE message_id = $1 ORDER BY created_at ASC
+ """,
+ message_id,
+ )
+ return [_row_to_message_comment(r) for r in rows]
+
+
+@public_router.post("/{tournament_id}/feed/{message_id}/comments", response_model=CommentOut, status_code=201)
+async def post_feed_message_comment(
+ tournament_id: str,
+ message_id: str,
+ payload: CommentIn,
+ code: str | None = None,
+ user: CurrentUser = Depends(get_current_user),
+) -> CommentOut:
+ body = payload.body.strip()
+ if not body:
+ raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
+ organization_id = await resolve_org(tournament_id)
+ async with org_connection(organization_id) as conn, translate_db_errors():
+ row = await conn.fetchrow(
+ "SELECT visibility, join_code FROM tournament WHERE id = $1", tournament_id
+ )
+ if row is None:
+ raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
+ if not code_matches(row["join_code"], code):
+ await check_visibility(conn, row["visibility"], organization_id, tournament_id, user)
+ if not await _may_post_to_feed(conn, user, organization_id, tournament_id):
+ raise app_error(
+ 403,
+ "NOT_A_PARTICIPANT",
+ "Du må være medlem av organisasjonen eller delta i turneringen for å kommentere.",
+ )
+ await _get_feed_message_or_404(conn, tournament_id, message_id)
+ if payload.parent_comment_id is not None:
+ parent_exists = await conn.fetchval(
+ "SELECT id FROM message_comment WHERE id = $1 AND message_id = $2",
+ payload.parent_comment_id,
+ message_id,
+ )
+ if parent_exists is None:
+ raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
+
+ display_name = await _resolve_author_display_name(conn, organization_id, user.user_id)
+ inserted = await conn.fetchrow(
+ f"""
+ INSERT INTO message_comment
+ (organization_id, message_id, parent_comment_id, author_user_id, author_display_name, body)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING {_MESSAGE_COMMENT_COLUMNS}
+ """,
+ organization_id,
+ message_id,
+ payload.parent_comment_id,
+ user.user_id,
+ display_name,
+ body,
+ )
+ return _row_to_message_comment(inserted)
+
+
+@public_router.delete("/{tournament_id}/feed/{message_id}/comments/{comment_id}", status_code=204)
+async def delete_feed_message_comment(
+ tournament_id: str,
+ message_id: str,
+ comment_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> None:
+ """Forfatteren selv, ELLER org-eier/admin -- samme moderasjonsvei som
+ `delete_feed_message`."""
+ organization_id = await resolve_org(tournament_id)
+ async with org_connection(organization_id) as conn:
+ row = await conn.fetchrow(
+ """
+ SELECT author_user_id::text AS author_user_id FROM message_comment
+ WHERE id = $1 AND message_id = $2
+ """,
+ comment_id,
+ message_id,
+ )
+ if row is None:
+ raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
+ if row["author_user_id"] != user.user_id and not await is_org_admin(
+ conn, organization_id, user.user_id
+ ):
+ raise app_error(
+ 403,
+ "NOT_COMMENT_AUTHOR",
+ "Du kan kun slette dine egne kommentarer (eller som organisasjonsadministrator).",
+ )
+ await conn.execute("DELETE FROM message_comment WHERE id = $1", comment_id)
+
+
@router.websocket("/ws/public/tournaments/{tournament_id}/feed")
async def feed_ws(websocket: WebSocket, tournament_id: str, code: str | None = None) -> None:
user = await get_current_user_from_websocket(websocket)
diff --git a/app/routers/round_messages.py b/app/routers/round_messages.py
index 7053ad6..9d67042 100644
--- a/app/routers/round_messages.py
+++ b/app/routers/round_messages.py
@@ -32,7 +32,7 @@ from ..auth import CurrentUser, get_current_user, get_current_user_optional
from ..db import plain_connection
from ..errors import app_error, translate_db_errors
from ..realtime import broadcast_round_update
-from .rounds import _get_viewable_round_or_404
+from .rounds import _get_viewable_round_or_404, _resolve_round_message_author_name
router = APIRouter(tags=["round-messages"])
@@ -40,6 +40,26 @@ _ROUND_MESSAGE_COLUMNS = """
id::text, round_id::text, author_user_id::text, author_display_name, body, image_key, created_at
"""
+# ADR-046: ett fast, kuratert reaksjonssett -- "like (eller andre emojier)",
+# ikke et fritt emoji-utvalg. Delt med messaging.py (importert derfra).
+ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"}
+
+
+class ReactionIn(BaseModel):
+ emoji: str
+
+
+class ReactionSummary(BaseModel):
+ emoji: str
+ count: int
+ reacted_by_me: bool
+ # Fulle navn (roster-kontekst, ikke direkte adressering -- CLAUDE.md
+ # navneformat-regel), i rekkefølgen de reagerte -- brukeren ba
+ # eksplisitt 2026-08-07 om å kunne se HVEM som reagerte, ikke bare et
+ # antall. Hentes i SAMME grupperte batch-spørring som selve
+ # oppsummeringen (array_agg), ingen ekstra tur-retur ved visning.
+ reactors: list[str] = []
+
class RoundMessageOut(BaseModel):
id: str
@@ -49,9 +69,13 @@ class RoundMessageOut(BaseModel):
body: str | None
image_url: str | None
created_at: str
+ reactions: list[ReactionSummary] = []
+ comment_count: int = 0
-def _row_to_round_message(row) -> RoundMessageOut:
+def _row_to_round_message(
+ row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
+) -> RoundMessageOut:
return RoundMessageOut(
id=row["id"],
round_id=row["round_id"],
@@ -60,22 +84,60 @@ def _row_to_round_message(row) -> RoundMessageOut:
body=row["body"],
image_url=storage.public_url(row["image_key"]) if row["image_key"] else None,
created_at=row["created_at"].isoformat(),
+ reactions=reactions or [],
+ comment_count=comment_count,
)
-async def _resolve_round_message_author_name(conn, user_id: str) -> str:
- """Frosset ved skrivetidspunkt, samme prinsipp som
- `message.author_display_name` -- her FULLT navn (forfatter-liste, ikke
- direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet
- `player.display_name` å falle tilbake på her (runder er ikke
- org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
- stede, så ingen ekstra fallback trengs."""
- row = await conn.fetchrow(
- "SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id
+async def _round_message_reactions(
+ conn, message_ids: list[str], viewer_user_id: str | None
+) -> dict[str, list[ReactionSummary]]:
+ """Grupperte reaksjonsoppsummeringer for en batch med round_message-ID-er
+ -- unngår N+1 når en meldingsliste rendres."""
+ if not message_ids:
+ return {}
+ rows = await conn.fetch(
+ """
+ SELECT rmr.round_message_id::text AS message_id, rmr.emoji, count(*) AS count,
+ COALESCE(bool_or(rmr.user_id = $2), false) AS reacted_by_me,
+ array_agg(
+ COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name)
+ ORDER BY rmr.created_at
+ ) AS reactors
+ FROM round_message_reaction rmr
+ JOIN app_user au ON au.id = rmr.user_id
+ WHERE rmr.round_message_id = ANY($1::uuid[])
+ GROUP BY rmr.round_message_id, rmr.emoji
+ """,
+ message_ids,
+ viewer_user_id,
)
- if row["first_name"] and row["last_name"]:
- return f"{row['first_name']} {row['last_name']}"
- return row["display_name"]
+ result: dict[str, list[ReactionSummary]] = {}
+ for r in rows:
+ result.setdefault(r["message_id"], []).append(
+ ReactionSummary(
+ emoji=r["emoji"],
+ count=r["count"],
+ reacted_by_me=r["reacted_by_me"],
+ reactors=list(r["reactors"]),
+ )
+ )
+ return result
+
+
+async def _round_message_comment_counts(conn, message_ids: list[str]) -> dict[str, int]:
+ if not message_ids:
+ return {}
+ rows = await conn.fetch(
+ """
+ SELECT round_message_id::text AS message_id, count(*) AS count
+ FROM round_message_comment
+ WHERE round_message_id = ANY($1::uuid[])
+ GROUP BY round_message_id
+ """,
+ message_ids,
+ )
+ return {r["message_id"]: r["count"] for r in rows}
# ---------------------------------------------------------------------------
@@ -90,11 +152,21 @@ async def list_round_messages(
) -> list[RoundMessageOut]:
async with plain_connection() as conn:
await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
+ # Nyeste øverst (2026-08-06, brukerens eksplisitte ønske) -- dette
+ # er en oppdateringsstrøm om runden, ikke en samtale man leser
+ # kronologisk fra start (ulikt lag-chatten, som beholder eldst-
+ # først -- se app/routers/messaging.py).
rows = await conn.fetch(
- f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at",
+ f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 ORDER BY created_at DESC",
round_id,
)
- return [_row_to_round_message(r) for r in rows]
+ message_ids = [r["id"] for r in rows]
+ reactions_by_id = await _round_message_reactions(conn, message_ids, user.user_id if user else None)
+ comment_counts = await _round_message_comment_counts(conn, message_ids)
+ return [
+ _row_to_round_message(r, reactions_by_id.get(r["id"]), comment_counts.get(r["id"], 0))
+ for r in rows
+ ]
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
@@ -169,6 +241,210 @@ async def delete_round_message(
await broadcast_round_update(round_id)
+# ---------------------------------------------------------------------------
+# Reaksjoner (ADR-046) -- "kan se = kan bidra", samme som selve innlegget.
+# ---------------------------------------------------------------------------
+
+
+async def _get_round_message_or_404(conn, round_id: str, message_id: str) -> None:
+ exists = await conn.fetchval(
+ "SELECT id FROM round_message WHERE id = $1 AND round_id = $2", message_id, round_id
+ )
+ if exists is None:
+ raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
+
+
+@router.put("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary])
+async def set_round_message_reaction(
+ round_id: str,
+ message_id: str,
+ payload: ReactionIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ReactionSummary]:
+ if payload.emoji not in ALLOWED_REACTION_EMOJIS:
+ raise app_error(400, "VALIDATION_FAILED", "Ugyldig emoji.")
+ async with plain_connection() as conn:
+ await _get_viewable_round_or_404(conn, round_id, user.user_id)
+ await _get_round_message_or_404(conn, round_id, message_id)
+ # Én reaksjon per bruker per innlegg -- bytte av emoji erstatter,
+ # stables ikke (ADR-046).
+ await conn.execute(
+ """
+ INSERT INTO round_message_reaction (round_message_id, user_id, emoji)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (round_message_id, user_id) DO UPDATE SET emoji = EXCLUDED.emoji, created_at = now()
+ """,
+ message_id,
+ user.user_id,
+ payload.emoji,
+ )
+ summaries = await _round_message_reactions(conn, [message_id], user.user_id)
+ return summaries.get(message_id, [])
+
+
+@router.delete("/rounds/{round_id}/messages/{message_id}/reaction", response_model=list[ReactionSummary])
+async def remove_round_message_reaction(
+ round_id: str,
+ message_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ReactionSummary]:
+ async with plain_connection() as conn:
+ await _get_viewable_round_or_404(conn, round_id, user.user_id)
+ await _get_round_message_or_404(conn, round_id, message_id)
+ await conn.execute(
+ "DELETE FROM round_message_reaction WHERE round_message_id = $1 AND user_id = $2",
+ message_id,
+ user.user_id,
+ )
+ summaries = await _round_message_reactions(conn, [message_id], user.user_id)
+ return summaries.get(message_id, [])
+
+
+# ---------------------------------------------------------------------------
+# Trådede kommentarer (ADR-046) -- flat lagring (parent_comment_id), frontend
+# bygger tre-strukturen. Kronologisk (eldst først) -- en samtaletråd leses
+# top-til-bunn, ulikt de kronologisk OMVENDTE innleggs-/meldingsstrømmene
+# ("nyeste først, over alt", 2026-08-06) som dette henger under.
+# ---------------------------------------------------------------------------
+
+_ROUND_MESSAGE_COMMENT_COLUMNS = """
+ id::text, round_message_id::text, parent_comment_id::text, author_user_id::text,
+ author_display_name, body, created_at
+"""
+
+
+class CommentIn(BaseModel):
+ body: str
+ parent_comment_id: str | None = None
+
+
+class CommentOut(BaseModel):
+ id: str
+ round_message_id: str
+ parent_comment_id: str | None
+ author_user_id: str
+ author_display_name: str
+ body: str
+ created_at: str
+
+
+def _row_to_round_message_comment(row) -> CommentOut:
+ return CommentOut(
+ id=row["id"],
+ round_message_id=row["round_message_id"],
+ parent_comment_id=row["parent_comment_id"],
+ author_user_id=row["author_user_id"],
+ author_display_name=row["author_display_name"],
+ body=row["body"],
+ created_at=row["created_at"].isoformat(),
+ )
+
+
+@router.get("/rounds/{round_id}/messages/{message_id}/comments", response_model=list[CommentOut])
+async def list_round_message_comments(
+ round_id: str,
+ message_id: str,
+ user: CurrentUser | None = Depends(get_current_user_optional),
+) -> list[CommentOut]:
+ async with plain_connection() as conn:
+ await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
+ await _get_round_message_or_404(conn, round_id, message_id)
+ rows = await conn.fetch(
+ f"""
+ SELECT {_ROUND_MESSAGE_COMMENT_COLUMNS} FROM round_message_comment
+ WHERE round_message_id = $1 ORDER BY created_at ASC
+ """,
+ message_id,
+ )
+ return [_row_to_round_message_comment(r) for r in rows]
+
+
+@router.post(
+ "/rounds/{round_id}/messages/{message_id}/comments", response_model=CommentOut, status_code=201
+)
+async def post_round_message_comment(
+ round_id: str,
+ message_id: str,
+ payload: CommentIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> CommentOut:
+ body = payload.body.strip()
+ if not body:
+ raise app_error(400, "VALIDATION_FAILED", "Kommentaren kan ikke være tom.")
+
+ async with plain_connection() as conn, translate_db_errors():
+ await _get_viewable_round_or_404(conn, round_id, user.user_id)
+ await _get_round_message_or_404(conn, round_id, message_id)
+ if payload.parent_comment_id is not None:
+ parent_exists = await conn.fetchval(
+ "SELECT id FROM round_message_comment WHERE id = $1 AND round_message_id = $2",
+ payload.parent_comment_id,
+ message_id,
+ )
+ if parent_exists is None:
+ raise app_error(404, "NOT_FOUND", "Kommentaren du svarer på finnes ikke.")
+
+ display_name = await _resolve_round_message_author_name(conn, user.user_id)
+ row = await conn.fetchrow(
+ f"""
+ INSERT INTO round_message_comment
+ (round_message_id, parent_comment_id, author_user_id, author_display_name, body)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING {_ROUND_MESSAGE_COMMENT_COLUMNS}
+ """,
+ message_id,
+ payload.parent_comment_id,
+ user.user_id,
+ display_name,
+ body,
+ )
+ comment = _row_to_round_message_comment(row)
+ # INGEN broadcast_round_update her (ADR-046) -- kommentarer/reaksjoner
+ # har bevisst ingen live-push til andre samtidige seere i v1 (kun egen
+ # handling refetcher). Ville ellers trigget RoundMessages sin
+ # "noe endret seg"-refetch for ALLE meldinger på siden ved hver eneste
+ # kommentar, og med det kollapset enhver allerede utvidet tråd andre
+ # steder på siden -- utilsiktet UX-regresjon oppdaget under
+ # nettleserverifisering.
+ return comment
+
+
+@router.delete("/rounds/{round_id}/messages/{message_id}/comments/{comment_id}", status_code=204)
+async def delete_round_message_comment(
+ round_id: str,
+ message_id: str,
+ comment_id: str,
+ user: CurrentUser = Depends(get_current_user),
+) -> None:
+ """Forfatteren selv, ELLER rundens eier -- samme modell som selve
+ innlegget (`delete_round_message`). Kaskade-sletter eventuelle svar
+ under (FK ON DELETE CASCADE, ADR-046)."""
+ async with plain_connection() as conn:
+ row = await conn.fetchrow(
+ """
+ SELECT c.author_user_id::text AS author_user_id, r.owner_user_id::text AS owner_user_id
+ FROM round_message_comment c
+ JOIN round_message rm ON rm.id = c.round_message_id
+ JOIN round r ON r.id = rm.round_id
+ WHERE c.id = $1 AND c.round_message_id = $2 AND rm.round_id = $3
+ """,
+ comment_id,
+ message_id,
+ round_id,
+ )
+ if row is None:
+ raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
+ if user.user_id not in (row["author_user_id"], row["owner_user_id"]):
+ raise app_error(
+ 403,
+ "NOT_COMMENT_AUTHOR",
+ "Du kan kun slette dine egne kommentarer (eller som rundeeier).",
+ )
+ await conn.execute("DELETE FROM round_message_comment WHERE id = $1", comment_id)
+ # INGEN broadcast_round_update her -- se samme begrunnelse i
+ # post_round_message_comment over.
+
+
# ---------------------------------------------------------------------------
# Samlet feed -- egne runder + venners synlige runder (ADR-036 fase 2)
# ---------------------------------------------------------------------------
@@ -186,6 +462,8 @@ class FeedEntryOut(BaseModel):
body: str | None
image_url: str | None
created_at: str
+ reactions: list[ReactionSummary] = []
+ comment_count: int = 0
@router.get("/feed", response_model=list[FeedEntryOut])
@@ -251,6 +529,9 @@ async def get_feed(
before,
limit,
)
+ entry_ids = [r["id"] for r in rows]
+ reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id)
+ comment_counts = await _round_message_comment_counts(conn, entry_ids)
return [
FeedEntryOut(
id=r["id"],
@@ -264,6 +545,8 @@ async def get_feed(
body=r["body"],
image_url=storage.public_url(r["image_key"]) if r["image_key"] else None,
created_at=r["created_at"].isoformat(),
+ reactions=reactions_by_id.get(r["id"], []),
+ comment_count=comment_counts.get(r["id"], 0),
)
for r in rows
]
diff --git a/app/routers/rounds.py b/app/routers/rounds.py
index d6d15ff..538b362 100644
--- a/app/routers/rounds.py
+++ b/app/routers/rounds.py
@@ -36,15 +36,17 @@ import json
import math
import secrets
import traceback
+import urllib.parse
import uuid
from datetime import date, datetime, timedelta, timezone
from typing import Literal
import asyncpg
+import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, EmailStr, Field
-from .. import teeoff_client
+from .. import storage, teeoff_client
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
from ..config import settings
from ..db import plain_connection
@@ -5075,6 +5077,334 @@ async def update_hole(
return RoundHoleOut(**dict(row), strokes_received=strokes_received)
+# ---------------------------------------------------------------------------
+# Slag-for-slag GPS-avstandsmåling (ADR-048) -- helt frikoblet fra
+# round_hole sine faste hull-aggregater over. round_shot kjenner kun
+# round_hole_id, og arver dermed eierskap (deltaker ELLER side) transitivt
+# via round_hole sin eksisterende round_participant_id/round_side_id-XOR
+# (migrasjon 031) -- ingen egen XOR-sjekk trengs her, se 060_round_shot.sql.
+# ---------------------------------------------------------------------------
+
+class ShotIn(BaseModel):
+ club: str = Field(max_length=50)
+ distance_meters: float = Field(gt=0, lt=500)
+ start_method: Literal["gps", "map_tap"]
+ start_lat: float = Field(ge=-90, le=90)
+ start_lng: float = Field(ge=-180, le=180)
+ end_lat: float = Field(ge=-90, le=90)
+ end_lng: float = Field(ge=-180, le=180)
+
+
+class ShotOut(BaseModel):
+ id: str
+ shot_number: int
+ club: str
+ distance_meters: float
+ start_method: str
+ start_lat: float
+ start_lng: float
+ end_lat: float
+ end_lng: float
+ shared_round_message_id: str | None
+ # Avledet fra shared_round_message_id -> round_message.image_key, ikke
+ # lagret på round_shot selv (samme "aldri lagre det som kan avledes"-
+ # prinsipp som storage.public_url() ellers brukes for).
+ shared_image_url: str | None
+ captured_at: str
+
+
+_SHOT_SELECT = """
+ SELECT rs.id::text AS id, rs.shot_number, rs.club, rs.distance_meters, rs.start_method,
+ rs.start_lat, rs.start_lng, rs.end_lat, rs.end_lng,
+ rs.shared_round_message_id::text AS shared_round_message_id,
+ rs.captured_at, rm.image_key AS shared_image_key
+ FROM round_shot rs
+ LEFT JOIN round_message rm ON rm.id = rs.shared_round_message_id
+"""
+
+
+def _shot_out(row: asyncpg.Record) -> ShotOut:
+ return ShotOut(
+ id=row["id"],
+ shot_number=row["shot_number"],
+ club=row["club"],
+ distance_meters=float(row["distance_meters"]),
+ start_method=row["start_method"],
+ start_lat=row["start_lat"],
+ start_lng=row["start_lng"],
+ end_lat=row["end_lat"],
+ end_lng=row["end_lng"],
+ shared_round_message_id=row["shared_round_message_id"],
+ shared_image_url=storage.public_url(row["shared_image_key"]) if row["shared_image_key"] else None,
+ captured_at=row["captured_at"].isoformat(),
+ )
+
+
+async def _resolve_round_message_author_name(conn, user_id: str) -> str:
+ """Frosset ved skrivetidspunkt, samme prinsipp som
+ `message.author_display_name` -- her FULLT navn (forfatter-liste, ikke
+ direkte adressering, CLAUDE.md navneformat-regel). Ingen org-scopet
+ `player.display_name` å falle tilbake på her (runder er ikke
+ org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
+ stede, så ingen ekstra fallback trengs.
+
+ Flyttet hit fra round_messages.py (som importerer den tilbake herfra)
+ slik at share_shot under kan gjenbruke den uten sirkulær import --
+ rounds.py var allerede den importerte, ikke-importerende parten."""
+ row = await conn.fetchrow(
+ "SELECT first_name, last_name, display_name FROM app_user WHERE id = $1", user_id
+ )
+ if row["first_name"] and row["last_name"]:
+ return f"{row['first_name']} {row['last_name']}"
+ return row["display_name"]
+
+
+async def _resolve_participant_hole_id(conn, round_id: str, participant_id: str, hole_number: int) -> str:
+ participant_exists = await conn.fetchval(
+ "SELECT 1 FROM round_participant WHERE id = $1 AND round_id = $2", participant_id, round_id
+ )
+ if not participant_exists:
+ raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke på denne runden.")
+ hole_id = await conn.fetchval(
+ "SELECT id::text FROM round_hole WHERE round_participant_id = $1 AND hole_number = $2",
+ participant_id, hole_number,
+ )
+ if hole_id is None:
+ raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne deltakeren.")
+ return hole_id
+
+
+async def _resolve_side_hole_id(conn, round_id: str, side_id: str, hole_number: int) -> str:
+ side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
+ if not side_exists:
+ raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
+ hole_id = await conn.fetchval(
+ "SELECT id::text FROM round_hole WHERE round_side_id = $1 AND hole_number = $2",
+ side_id, hole_number,
+ )
+ if hole_id is None:
+ raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
+ return hole_id
+
+
+async def _list_shots_for_hole(conn, hole_id: str) -> list[ShotOut]:
+ rows = await conn.fetch(_SHOT_SELECT + " WHERE rs.round_hole_id = $1 ORDER BY rs.shot_number", hole_id)
+ return [_shot_out(r) for r in rows]
+
+
+async def _insert_shot(conn, hole_id: str, user_id: str, body: ShotIn) -> ShotOut:
+ async with translate_db_errors():
+ row = await conn.fetchrow(
+ """
+ INSERT INTO round_shot (
+ round_hole_id, recorded_by_user_id, shot_number, club, distance_meters,
+ start_method, start_lat, start_lng, end_lat, end_lng
+ )
+ VALUES (
+ $1, $2,
+ COALESCE((SELECT MAX(shot_number) FROM round_shot WHERE round_hole_id = $1), 0) + 1,
+ $3, $4, $5, $6, $7, $8, $9
+ )
+ RETURNING id::text AS id
+ """,
+ hole_id, user_id, body.club, body.distance_meters,
+ body.start_method, body.start_lat, body.start_lng, body.end_lat, body.end_lng,
+ )
+ full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE rs.id = $1", row["id"])
+ return _shot_out(full_row)
+
+
+@router.get(
+ "/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}/shots",
+ response_model=list[ShotOut],
+)
+async def list_participant_shots(
+ round_id: str, participant_id: str, hole_number: int,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ShotOut]:
+ async with plain_connection() as conn:
+ await _get_accessible_round_or_404(conn, round_id, user.user_id)
+ hole_id = await _resolve_participant_hole_id(conn, round_id, participant_id, hole_number)
+ return await _list_shots_for_hole(conn, hole_id)
+
+
+@router.post(
+ "/rounds/{round_id}/participants/{participant_id}/holes/{hole_number}/shots",
+ response_model=ShotOut,
+ status_code=201,
+)
+async def create_participant_shot(
+ round_id: str, participant_id: str, hole_number: int, body: ShotIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> ShotOut:
+ async with plain_connection() as conn:
+ # Samme begrunnelse som update_hole -- en lenket medspiller kan
+ # måle slag for hele flighten, ikke bare sin egen rad.
+ await _get_accessible_round_or_404(conn, round_id, user.user_id)
+ hole_id = await _resolve_participant_hole_id(conn, round_id, participant_id, hole_number)
+ shot = await _insert_shot(conn, hole_id, user.user_id, body)
+ await broadcast_round_update(round_id)
+ return shot
+
+
+@router.get(
+ "/rounds/{round_id}/sides/{side_id}/holes/{hole_number}/shots",
+ response_model=list[ShotOut],
+)
+async def list_side_shots(
+ round_id: str, side_id: str, hole_number: int,
+ user: CurrentUser = Depends(get_current_user),
+) -> list[ShotOut]:
+ async with plain_connection() as conn:
+ await _get_accessible_round_or_404(conn, round_id, user.user_id)
+ hole_id = await _resolve_side_hole_id(conn, round_id, side_id, hole_number)
+ return await _list_shots_for_hole(conn, hole_id)
+
+
+@router.post(
+ "/rounds/{round_id}/sides/{side_id}/holes/{hole_number}/shots",
+ response_model=ShotOut,
+ status_code=201,
+)
+async def create_side_shot(
+ round_id: str, side_id: str, hole_number: int, body: ShotIn,
+ user: CurrentUser = Depends(get_current_user),
+) -> ShotOut:
+ async with plain_connection() as conn:
+ await _get_accessible_round_or_404(conn, round_id, user.user_id)
+ hole_id = await _resolve_side_hole_id(conn, round_id, side_id, hole_number)
+ shot = await _insert_shot(conn, hole_id, user.user_id, body)
+ await broadcast_round_update(round_id)
+ return shot
+
+
+@router.delete("/rounds/{round_id}/shots/{shot_id}", status_code=204)
+async def delete_shot(
+ round_id: str, shot_id: str, user: CurrentUser = Depends(get_current_user),
+) -> None:
+ async with plain_connection() as conn:
+ await _get_accessible_round_or_404(conn, round_id, user.user_id)
+ # Eierskap-agnostisk sletting -- round_shot kjenner kun round_hole_id,
+ # så tilhørighet til DENNE runden bekreftes via round_hole sin
+ # round_participant- ELLER round_side-kjede (forsvar mot id-gjetting
+ # på tvers av runder).
+ deleted = await conn.fetchval(
+ """
+ DELETE FROM round_shot rs
+ USING round_hole rh
+ LEFT JOIN round_participant rp ON rp.id = rh.round_participant_id
+ LEFT JOIN round_side rsd ON rsd.id = rh.round_side_id
+ WHERE rs.round_hole_id = rh.id
+ AND rs.id = $1
+ AND COALESCE(rp.round_id, rsd.round_id) = $2
+ RETURNING rs.id
+ """,
+ shot_id, round_id,
+ )
+ if deleted is None:
+ raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
+ await broadcast_round_update(round_id)
+
+
+def _encode_polyline(points: list[tuple[float, float]]) -> str:
+ """Google sin polyline-algoritme (5 desimaler) -- kun det appen faktisk
+ trenger, nøyaktig to punkter for et rett slag-overlay på Mapbox Static
+ Images API (path-overlay-parameteren krever encoded polyline, se
+ https://docs.mapbox.com/api/maps/static-images/)."""
+ result: list[str] = []
+ prev_lat = prev_lng = 0
+ for lat, lng in points:
+ lat_i = round(lat * 1e5)
+ lng_i = round(lng * 1e5)
+ for value, prev in ((lat_i, prev_lat), (lng_i, prev_lng)):
+ delta = value - prev
+ shifted = ~(delta << 1) if delta < 0 else (delta << 1)
+ chunk = ""
+ while shifted >= 0x20:
+ chunk += chr((0x20 | (shifted & 0x1F)) + 63)
+ shifted >>= 5
+ chunk += chr(shifted + 63)
+ result.append(chunk)
+ prev_lat, prev_lng = lat_i, lng_i
+ return "".join(result)
+
+
+def _mapbox_static_snippet_url(start_lat: float, start_lng: float, end_lat: float, end_lng: float) -> str:
+ encoded = urllib.parse.quote(_encode_polyline([(start_lat, start_lng), (end_lat, end_lng)]), safe="")
+ overlay = (
+ f"pin-s-a+ff5a1f({start_lng},{start_lat}),"
+ f"pin-s-b+2f7a3f({end_lng},{end_lat}),"
+ f"path-4+ff5a1f-0.9({encoded})"
+ )
+ return (
+ f"https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/{overlay}/auto/600x400@2x"
+ f"?padding=60&access_token={settings.MAPBOX_SECRET_TOKEN}"
+ )
+
+
+class ShotShareIn(BaseModel):
+ # Brukerredigert, forhåndsutfylt i klienten med en auto-generert tekst
+ # ("⛳ {klubb}, {avstand} m – hull {n}") -- helt fritt herfra, samme
+ # "ingen lengdebegrensning utover dette" som round_messages.py.
+ body: str = Field(max_length=2000)
+
+
+@router.post("/rounds/{round_id}/shots/{shot_id}/share", response_model=ShotOut)
+async def share_shot(
+ round_id: str, shot_id: str, body: ShotShareIn, user: CurrentUser = Depends(get_current_user),
+) -> ShotOut:
+ async with plain_connection() as conn:
+ await _get_accessible_round_or_404(conn, round_id, user.user_id)
+ shot_row = await conn.fetchrow(
+ """
+ SELECT rs.id::text AS id, rs.start_lat, rs.start_lng, rs.end_lat, rs.end_lng
+ FROM round_shot rs
+ JOIN round_hole rh ON rh.id = rs.round_hole_id
+ LEFT JOIN round_participant rp ON rp.id = rh.round_participant_id
+ LEFT JOIN round_side rsd ON rsd.id = rh.round_side_id
+ WHERE rs.id = $1 AND COALESCE(rp.round_id, rsd.round_id) = $2
+ """,
+ shot_id, round_id,
+ )
+ if shot_row is None:
+ raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
+
+ author_display_name = await _resolve_round_message_author_name(conn, user.user_id)
+
+ image_key = None
+ if settings.MAPBOX_SECRET_TOKEN:
+ url = _mapbox_static_snippet_url(
+ shot_row["start_lat"], shot_row["start_lng"], shot_row["end_lat"], shot_row["end_lng"]
+ )
+ try:
+ async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
+ resp = await client.get(url)
+ resp.raise_for_status()
+ image_key = await storage.upload_image("round_messages", round_id, resp.content)
+ except httpx.HTTPError:
+ # Deling degraderer grasiøst til ren tekst uten satellitt-
+ # utsnitt -- samme prinsipp som SMTP/push (config.py) --
+ # fremfor å blokkere hele delingen på et Mapbox-utfall.
+ image_key = None
+
+ async with translate_db_errors():
+ message_row = await conn.fetchrow(
+ """
+ INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id::text AS id
+ """,
+ round_id, user.user_id, author_display_name, body.body, image_key,
+ )
+ await conn.execute(
+ "UPDATE round_shot SET shared_round_message_id = $1 WHERE id = $2",
+ message_row["id"], shot_id,
+ )
+ full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE rs.id = $1", shot_id)
+ await broadcast_round_update(round_id)
+ return _shot_out(full_row)
+
+
# ---------------------------------------------------------------------------
# Fullføring -- Adjusted Gross Score / Score Differential (ADR-033 Beslutning G)
# ---------------------------------------------------------------------------
@@ -5345,6 +5675,12 @@ class PublicRoundOut(BaseModel):
play_format: str
completed_at: str | None
owner_display_name: str
+ # Lagt til 2026-08-06 -- RoundMessages (ADR-044) sin "forfatter ELLER
+ # rundeeier kan slette"-sjekk trenger eierens app_user.id, ikke bare
+ # visningsnavnet. Ikke en større personvern-innrømmelse enn navnet
+ # selv (allerede eksponert over) -- en ugjennomsiktig UUID, sammenlignet
+ # mot innlogget bruker sin egen id, avslører ingenting nytt.
+ owner_user_id: str
participants: list[PublicRoundParticipantOut]
sides: list[RoundSideOut]
@@ -5390,6 +5726,7 @@ async def _build_public_round_out(conn, round_id: str) -> PublicRoundOut:
play_format=round_row["play_format"],
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
owner_display_name=owner_display_name or "Ukjent",
+ owner_user_id=round_row["owner_user_id"],
participants=[PublicRoundParticipantOut(**dict(r)) for r in participant_rows],
sides=[RoundSideOut(**dict(r)) for r in side_rows],
)
@@ -5450,6 +5787,72 @@ async def get_public_format_result(
return await _build_format_result(conn, round_id)
+async def _viewable_flight_rows(conn, round_id: str, viewer_user_id: str | None):
+ """Offentlig/tredjeparts-variant av `_accessible_flight_rows` (samme
+ "avslør aldri en søsken-flight brukeren ikke har tilgang til"-prinsipp),
+ men bruker `_can_view_round` (eier/medspiller/`public`/riktig-
+ kategorisert `friends`-venn) i stedet for den strengere eier-eller-
+ medspiller-sjekken -- dette endepunktet betjener også anonyme/
+ tredjeparts lesere (ADR-036 fase 2), ikke bare eieren selv. Hver
+ søsken-flight sjekkes UAVHENGIG (kan ha forskjellig visibility_mode
+ enn anker-runden), samme prinsipp som resten av tilskuer-visningen."""
+ anchor = await conn.fetchrow(
+ "SELECT id::text AS id, flight_group_id::text AS flight_group_id FROM round WHERE id = $1",
+ round_id,
+ )
+ group_id = anchor["flight_group_id"]
+ if group_id is None:
+ sibling_ids = [round_id]
+ else:
+ sibling_rows = await conn.fetch(
+ "SELECT id::text AS id FROM round WHERE flight_group_id = $1 ORDER BY created_at", group_id
+ )
+ sibling_ids = [r["id"] for r in sibling_rows]
+ rows = []
+ for sibling_id in sibling_ids:
+ if not await _can_view_round(conn, sibling_id, viewer_user_id):
+ continue
+ row = await conn.fetchrow(
+ """
+ SELECT r.id::text AS id, r.name, r.course_name_snapshot, r.played_at,
+ r.started_at, r.completed_at
+ FROM round r WHERE r.id = $1
+ """,
+ sibling_id,
+ )
+ if row is not None:
+ rows.append(row)
+ return group_id, rows
+
+
+@router.get("/public/rounds/{round_id}/flight-group", response_model=FlightGroupOut)
+async def get_public_flight_group(
+ round_id: str,
+ user: CurrentUser | None = Depends(get_current_user_optional),
+) -> FlightGroupOut:
+ async with plain_connection() as conn:
+ await _get_viewable_round_or_404(conn, round_id, user.user_id if user else None)
+ group_id, rows = await _viewable_flight_rows(conn, round_id, user.user_id if user else None)
+ flights = []
+ for row in rows:
+ participant_count = await conn.fetchval(
+ "SELECT COUNT(*) FROM round_participant WHERE round_id = $1", row["id"]
+ )
+ flights.append(
+ FlightSummaryOut(
+ round_id=row["id"],
+ name=row["name"],
+ course_name_snapshot=row["course_name_snapshot"],
+ played_at=row["played_at"],
+ started_at=row["started_at"].isoformat() if row["started_at"] else None,
+ completed_at=row["completed_at"].isoformat() if row["completed_at"] else None,
+ participant_count=participant_count,
+ is_anchor=row["id"] == round_id,
+ )
+ )
+ return FlightGroupOut(flight_group_id=group_id, flights=flights)
+
+
@router.websocket("/ws/public/rounds/{round_id}/live")
async def public_round_live_ws(websocket: WebSocket, round_id: str) -> None:
user = await get_current_user_from_websocket(websocket)
diff --git a/app/storage.py b/app/storage.py
index a6e691e..38520f4 100644
--- a/app/storage.py
+++ b/app/storage.py
@@ -28,7 +28,13 @@ from .config import settings
from .errors import app_error
ALLOWED_INPUT_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
-MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8 MB rå input, før konvertering
+# 20 MB rå input, FØR konvertering -- denne grensen beskytter ikke selve
+# lagringsstørrelsen (det gjør AVIF-konverteringen under, som uansett gir en
+# liten fil uavhengig av inputstørrelse), den er kun en øvre sanity-grense.
+# Hevet fra 8 MB 2026-08-06 -- moderne telefonkameraer (bl.a. Pixel 8 Pro i
+# høy kvalitet/HDR+) produserer rutinemessig JPEG-er i 8-15 MB-klassen, som
+# den gamle grensen avviste FØR bildet fikk sjansen til å konverteres ned.
+MAX_UPLOAD_BYTES = 20 * 1024 * 1024
_MAX_DIMENSION = 2400 # nedskaler urimelig store bilder før konvertering
_AVIF_QUALITY = 65
@@ -69,7 +75,7 @@ async def read_optional_image(image: UploadFile | None) -> bytes | None:
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
raw = await image.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
- raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
+ raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 20 MB).")
return raw
diff --git a/docker-compose.yml b/docker-compose.yml
index 9a28869..da78d47 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -37,6 +37,7 @@ services:
TEECUP_VAPID_PRIVATE_KEY: ${TEECUP_VAPID_PRIVATE_KEY}
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
+ TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
networks:
- teeoff_default
restart: unless-stopped
@@ -48,6 +49,7 @@ services:
context: ./frontend
args:
TEECUP_API_ORIGIN: http://teecup_api:8000
+ NEXT_PUBLIC_MAPBOX_TOKEN: ${NEXT_PUBLIC_MAPBOX_TOKEN}
container_name: teecup_frontend
networks:
- teeoff_default
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 5b2dc19..557c8e8 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -14,6 +14,12 @@ COPY . .
# bare som en runtime env-var på "docker run".
ARG TEECUP_API_ORIGIN=http://teecup_api:8000
ENV TEECUP_API_ORIGIN=$TEECUP_API_ORIGIN
+# NEXT_PUBLIC_-variabler bakes inn i klient-bundlen VED BUILD-TID (ulikt
+# TEECUP_API_ORIGIN over, som også trengs ved runtime -- se runner-steget).
+# Offentlig, URL-restriktert Mapbox-token (ADR-048) -- trygt at denne når
+# nettleseren, IKKE samme som backendens hemmelige MAPBOX_SECRET_TOKEN.
+ARG NEXT_PUBLIC_MAPBOX_TOKEN=""
+ENV NEXT_PUBLIC_MAPBOX_TOKEN=$NEXT_PUBLIC_MAPBOX_TOKEN
RUN pnpm build
FROM node:22-slim AS runner
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 9b3cada..8ac73cf 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -204,3 +204,36 @@
@apply bg-background text-foreground;
}
}
+
+/* ------------------------------------------------------------------ */
+/* /logg-inn -- parallell V0-utforskning (ADR-047-oppfølging, 2026-08-07) */
+/* Egen, skopet "clubhouse"-palett KUN brukt av components/teecup/* -- */
+/* additiv, rører ikke tokens over. Fast lys-modus (leser likt uansett */
+/* systemtema), siden dette er en isolert sammenligningsside, ikke en */
+/* endring av appens faktiske tema. */
+/* ------------------------------------------------------------------ */
+:root {
+ --tee: #8bbd51;
+ --tee-strong: #2f6b1e;
+ --cup: #ff5427;
+ --cup-strong: #cf3c17;
+ --clubhouse-bg: #f3f6ec;
+ --clubhouse-card: #ffffff;
+ --clubhouse-ink: #17200f;
+ --clubhouse-muted: #515b45;
+ --clubhouse-border: #dbe1cd;
+ --clubhouse-field: #f7f9f3;
+}
+
+@theme inline {
+ --color-tee: var(--tee);
+ --color-tee-strong: var(--tee-strong);
+ --color-cup: var(--cup);
+ --color-cup-strong: var(--cup-strong);
+ --color-clubhouse-bg: var(--clubhouse-bg);
+ --color-clubhouse-card: var(--clubhouse-card);
+ --color-clubhouse-ink: var(--clubhouse-ink);
+ --color-clubhouse-muted: var(--clubhouse-muted);
+ --color-clubhouse-border: var(--clubhouse-border);
+ --color-clubhouse-field: var(--clubhouse-field);
+}
diff --git a/frontend/app/logg-inn/page.tsx b/frontend/app/logg-inn/page.tsx
new file mode 100644
index 0000000..d970a6f
--- /dev/null
+++ b/frontend/app/logg-inn/page.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from "next"
+import { TeeCupWordmark } from "@/components/teecup/wordmark"
+import { TeeCupAuth } from "@/components/teecup/teecup-auth"
+
+export const metadata: Metadata = {
+ title: "Logg inn – TeeCup",
+ description: "Logg inn på TeeCup for live-scoring og golfturneringer med vennegjengen, klubben eller bedriften.",
+}
+
+export default function LoggInnPage() {
+ return (
+
+ {/* Quiet turf-horizon backdrop — the one bit of atmosphere, kept subtle. */}
+
+
+
+ {/* Header */}
+
+
+
+ Live-scoring og turneringer — fra første tee til pokalen.
+
+
+
+ {/* Card — the scorecard. Continuation screens (2FA, link confirm) can
+ reuse this exact container. */}
+
+ {/* Signature element: a fairway-green scorecard header stripe. */}
+
+
+
+
+
+
+
+ Ved å logge inn godtar du vilkårene våre. Trenger du hjelp? Ta kontakt med klubben din.
+
+
+
+ )
+}
diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts
index c4b4825..384a700 100644
--- a/frontend/app/manifest.ts
+++ b/frontend/app/manifest.ts
@@ -1,8 +1,9 @@
import type { MetadataRoute } from "next"
-// PWA-manifest (ADR-028). Ikonene under er en enkel, midlertidig
-// merkevareriktig placeholder (grønn golfflagg) generert programmatisk --
-// se FEATURE_BACKLOG.md, skal erstattes med ekte design senere.
+// PWA-manifest (ADR-028). Ikonene under er beskåret fra den ekte TeeCup-
+// logoen (2026-08-06, se public/teecup-wordmark.svg for hele ordmerket
+// ikon+tekst) -- erstatter den tidligere midlertidige grønne
+// golfflagg-placeholderen.
export default function manifest(): MetadataRoute.Manifest {
return {
name: "TeeCup",
diff --git a/frontend/app/watch/[id]/leaderboard/page.tsx b/frontend/app/watch/[id]/leaderboard/page.tsx
new file mode 100644
index 0000000..039b8da
--- /dev/null
+++ b/frontend/app/watch/[id]/leaderboard/page.tsx
@@ -0,0 +1,6 @@
+import { WatchLeaderboard } from "@/components/watch-leaderboard"
+
+export default async function WatchLeaderboardPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ return
+}
diff --git a/frontend/app/watch/[id]/scorecard/page.tsx b/frontend/app/watch/[id]/scorecard/page.tsx
new file mode 100644
index 0000000..6eeee3f
--- /dev/null
+++ b/frontend/app/watch/[id]/scorecard/page.tsx
@@ -0,0 +1,6 @@
+import { WatchScorecard } from "@/components/watch-scorecard"
+
+export default async function WatchScorecardPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ return
+}
diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx
index 6c71191..c3db3ab 100644
--- a/frontend/components/account-settings.tsx
+++ b/frontend/components/account-settings.tsx
@@ -35,6 +35,7 @@ import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
+import { BottomTabBar } from "@/components/dashboard"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer.
@@ -263,7 +264,7 @@ export function AccountSettings() {
-
+
{settingUp2fa ? (
)}
+
+
)
}
@@ -655,7 +658,9 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx
index d815ab5..671f550 100644
--- a/frontend/components/dashboard.tsx
+++ b/frontend/components/dashboard.tsx
@@ -22,6 +22,25 @@
// respekterer nøyaktig samme regel som all annen rundedeling (ADR-036
// fase 2, `_can_view_round`s 'friends'-gren) -- håndhevet server-side, ikke
// duplisert her.
+//
+// "Clubhouse"-visuell redesign (2026-08-07, V0, egen parallell utforskning
+// -- IKKE samme retning som "Forest Green" over). Brukeren: "full
+// overtagelse, også bakgrunnen" -- eksplisitt bekreftet å gå imot
+// 2026-08-02-fiksen som fjernet en utilsiktet grønn hue fra de nøytrale
+// tokenene (DESIGN_SYSTEM.md), siden clubhouse-bakgrunnen er en BEVISST
+// varm/turf-tonet off-white, ikke en utilsiktet lekkasje. Gjenbruker
+// `--tee`/`--tee-strong`/`--cup`/`--cup-strong`/`--clubhouse-*`-tokenene og
+// `TeeCupWordmark` som ALLEREDE fantes i `globals.css`/`components/teecup/`
+// fra en tidligere, ennå ikke integrert `/logg-inn`-utforskning -- samme
+// palett, ikke funnet opp på nytt. Bevisst IKKE lagt til Bricolage-
+// visningsfonten (samme utelatelse som `/logg-inn` selv gjorde) -- Nunito
+// beholdt for å unngå en ekstra font-avhengighet for kun to skjermer.
+//
+// Omfangsgrenser (delte komponenter IKKE re-stylet her, se CHANGELOG.md):
+// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) og `BottomTabBar`
+// (delt med de fem sidene som nettopp fikk den, venter på egen V0-prompt)
+// beholder sin eksisterende styling inntil videre -- kun selve
+// dashbord-skjermens EGNE, ikke-delte JSX er re-stylet i denne runden.
import type React from "react"
import { useCallback, useEffect, useState } from "react"
@@ -57,6 +76,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { InstallPrompt } from "@/components/install-prompt"
+import { TeeCupWordmark } from "@/components/teecup/wordmark"
import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
@@ -432,8 +452,8 @@ export function Dashboard() {
if (loadingMe) {
return (
-
-
- Tilbake til egne runder
+
+ {publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
)
@@ -1399,6 +1633,14 @@ export function RoundLeaderboard({
)
}
+ // Delt kommentartråd (ADR-047) -- round_message-reaksjoner/-kommentarer
+ // fester seg til ETT spesifikt innlegg, ikke runden som helhet, så
+ // hver utvidet rad/kamp-seksjon lenker til den SAMME, allerede
+ // eksisterende kommentarseksjonen på selve rundesiden (anker-id
+ // "kommentarer" i round-detail.tsx/watch-round.tsx) i stedet for å late
+ // som om det finnes en egen tråd per spiller/kamp.
+ const commentsHref = publicMode ? `/watch/${roundId}#kommentarer` : `/my-rounds/${roundId}#kommentarer`
+
const holeOrder = buildHoleOrder(round.start_hole, round.holes_planned)
const holes = buildHoles(leaderboard.entries, holeOrder)
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
@@ -1413,7 +1655,7 @@ export function RoundLeaderboard({
@@ -1458,6 +1700,12 @@ export function RoundLeaderboard({
totalA={formatResult.hlh_points_a ?? 0}
totalB={formatResult.hlh_points_b ?? 0}
formatName="High-low-high"
+ round={round}
+ roundId={roundId}
+ viewerId={viewerParticipantId}
+ refreshKey={refreshKey}
+ apiPrefix={apiPrefix}
+ commentsHref={commentsHref}
/>
)
})()
@@ -1505,6 +1753,12 @@ export function RoundLeaderboard({
leader={leader}
isDormie={Boolean(formatResult.match_is_dormie)}
isClosed={Boolean(formatResult.match_is_closed)}
+ round={round}
+ roundId={roundId}
+ viewerId={viewerParticipantId}
+ refreshKey={refreshKey}
+ apiPrefix={apiPrefix}
+ commentsHref={commentsHref}
/>
)
})()
@@ -1535,6 +1789,7 @@ export function RoundLeaderboard({
members={members}
holes={holes}
formatName={round.play_format === "shamble" ? "Shamble" : "Money Ball"}
+ commentsHref={commentsHref}
/>
)
})()
@@ -1553,6 +1808,7 @@ export function RoundLeaderboard({
defaultMetric={config.defaultMetric}
pointsUnit={config.pointsUnit}
holes={holes}
+ commentsHref={commentsHref}
/>
>
)
diff --git a/frontend/components/round-messages.tsx b/frontend/components/round-messages.tsx
index b0e5313..38d7743 100644
--- a/frontend/components/round-messages.tsx
+++ b/frontend/components/round-messages.tsx
@@ -1,7 +1,8 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
-import { ImagePlus, Send, Trash2, X } from "lucide-react"
+import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react"
+import { PostEngagement, type ReactionSummary } from "./post-engagement"
/** Rå meldingsform fra backend. */
export type ApiRoundMessage = {
@@ -12,6 +13,8 @@ export type ApiRoundMessage = {
body: string | null
image_url: string | null
created_at: string // ISO 8601
+ reactions: ReactionSummary[]
+ comment_count: number
}
export type RoundMessagesProps = {
@@ -25,8 +28,38 @@ export type RoundMessagesProps = {
refreshKey?: number
}
-const ACCEPTED_IMAGE_TYPES = "image/jpeg,image/png,image/webp,image/gif"
+// "image/*" (ikke en eksplisitt liste av MIME-typer) -- mange mobilnett-
+// lesere viser bare galleri, ikke kamera, i filvelgeren når accept lister
+// flere eksplisitte typer. Server-siden validerer uansett strengt mot
+// storage.ALLOWED_INPUT_CONTENT_TYPES, så dette svekker ingen kontroll.
+const ACCEPTED_IMAGE_TYPES = "image/*"
+// Funnet 2026-08-06 (ekte enhet, Android/Chrome, installert som PWA): siden
+// Android 13 bruker Chrome systemets eget bilde-vlger ("Photo Picker") for
+// UTEN capture-attributt -- den viser
+// KUN galleri, aldri et kamera-alternativ. Løsning: to atskilte knapper/
+// inputs -- én med capture="environment" (rett til kamera), én uten (rett
+// til galleri) -- i stedet for å stole på at nettleseren tilbyr begge i
+// samme velger.
const MAX_IMAGE_HEIGHT = 300
+// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
+// klient-side også for en umiddelbar, presis feilmelding uten en unødvendig
+// tur-retur til serveren for noe som uansett vil bli avvist.
+const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
+
+/** Leser {detail:{code,message}} fra et feil-svar, samme kontrakt som
+ * resten av API-et (app/errors.py). Faller tilbake til en generisk
+ * norsk melding hvis svaret ikke har denne formen (f.eks. et rått 502
+ * fra en proxy, ikke fra selve API-et). */
+async function readErrorMessage(res: Response, fallback: string): Promise {
+ try {
+ const data = await res.json()
+ const message = data?.detail?.message
+ if (typeof message === "string" && message.length > 0) return message
+ } catch {
+ // Ikke JSON -- bruk fallback.
+ }
+ return fallback
+}
/** Kort, norsk relativt tidsstempel (f.eks. "nå", "5 min", "2 t", "i går"). */
function formatRelativeTime(iso: string): { label: string; title: string } {
@@ -72,6 +105,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
const [deletingId, setDeletingId] = useState(null)
const fileInputRef = useRef(null)
+ const cameraInputRef = useRef(null)
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
// (WS-signal fra round-detail.tsx).
@@ -113,11 +147,17 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
return null
})
if (fileInputRef.current) fileInputRef.current.value = ""
+ if (cameraInputRef.current) cameraInputRef.current.value = ""
}, [])
function handleFileChange(e: React.ChangeEvent) {
const file = e.target.files?.[0]
if (!file) return
+ if (file.size > MAX_UPLOAD_BYTES) {
+ setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
+ e.target.value = ""
+ return
+ }
setPostError(null)
setImageFile(file)
setImagePreview((prev) => {
@@ -145,15 +185,19 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
credentials: "include",
body: formData,
})
- if (res.status !== 201) throw new Error(`Status ${res.status}`)
+ if (res.status !== 201) {
+ setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
+ return
+ }
const created: ApiRoundMessage = await res.json()
- // Legg nederst — listen er kronologisk, eldst til nyest.
- setMessages((prev) => [...prev, created])
+ // Legg øverst — nyeste først (2026-08-06, samme rekkefølge som
+ // backend nå returnerer listen i).
+ setMessages((prev) => [created, ...prev])
setBody("")
clearSelectedImage()
} catch {
- setPostError("Kunne ikke poste kommentaren. Prøv igjen.")
+ setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setPosting(false)
}
@@ -222,23 +266,50 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
)}
+ {/* To atskilte inputs -- se MAX_IMAGE_HEIGHT-kommentaren over:
+ Android sin systembilde-vlger (Photo Picker) viser ALDRI
+ et kamera-alternativ når accept="image/*" mangler
+ capture, uansett hvilken nettleser. capture="environment"
+ her sender rett til kamera i stedet for å stole på at
+ velgeren tilbyr det. */}
+
+ cameraInputRef.current?.click()}
+ className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
+ >
+
+ Ta bilde
+
+
+
+ fileInputRef.current?.click()}
+ className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
+ >
+
+ Velg fra galleri
+
+
)}
+
+
)
})}
diff --git a/frontend/components/round-scorecard.tsx b/frontend/components/round-scorecard.tsx
index c5d3a54..128ef29 100644
--- a/frontend/components/round-scorecard.tsx
+++ b/frontend/components/round-scorecard.tsx
@@ -597,11 +597,11 @@ type ApiFormatResult = {
holes: ApiFormatHoleOut[] | null
}
-function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
+function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
const [result, setResult] = useState(null)
useEffect(() => {
let cancelled = false
- fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
+ fetch(`${apiPrefix}/${roundId}/format-result`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFormatResult | null) => {
if (!cancelled) setResult(data)
@@ -610,7 +610,7 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
return () => {
cancelled = true
}
- }, [roundId, refreshKey])
+ }, [roundId, refreshKey, apiPrefix])
return result
}
@@ -778,18 +778,20 @@ function LeadBar({
type MatchRow = { id: string; side: "a" | "b"; label: string; hcp: number | null }
-function MatchScorecardGrid({
+export function MatchScorecardGrid({
round,
roundId,
viewerId,
refreshKey,
+ apiPrefix = "/rounds",
}: {
round: ApiRound
roundId: string
viewerId: string | null
refreshKey: number
+ apiPrefix?: string
}) {
- const result = useFormatResult(roundId, refreshKey)
+ const result = useFormatResult(roundId, refreshKey, apiPrefix)
if (!result) return null
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
@@ -1005,8 +1007,16 @@ function MatchScorecardGrid({
)
}
-function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
- const result = useFormatResult(roundId, refreshKey)
+function SkinsProgressTable({
+ roundId,
+ refreshKey,
+ apiPrefix = "/rounds",
+}: {
+ roundId: string
+ refreshKey: number
+ apiPrefix?: string
+}) {
+ const result = useFormatResult(roundId, refreshKey, apiPrefix)
if (!result || !result.holes || result.holes.length === 0) return null
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
@@ -1069,6 +1079,7 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
export function RoundScorecard({
roundId,
embedded = false,
+ publicMode = false,
}: {
roundId: string
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
@@ -1077,7 +1088,16 @@ export function RoundScorecard({
// `RoundPageShell`, dekker det nå) når den rendres som øverste halvdel
// av den slåtte sammen siden, i stedet for standalone.
embedded?: boolean
+ // ADR-044 (tilskuer-visning, 2026-08-06) -- gjenbruker DENNE komponenten
+ // uendret for `/watch/{id}/scorecard`, kun med annen datakilde: alle
+ // `/rounds/*`-kall byttes til `/public/rounds/*` (samme respons-form,
+ // `_can_view_round`-autorisert i stedet for eier/medspiller-only), og
+ // WS-kanalen byttes til den offentlige. INGEN visuell/atferdsmessig
+ // endring når `publicMode` er `false` (standard) -- eiersiden er
+ // uberørt.
+ publicMode?: boolean
}) {
+ const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
const [round, setRound] = useState(null)
const [unitId, setUnitId] = useState(null)
const [holes, setHoles] = useState(null)
@@ -1104,15 +1124,16 @@ export function RoundScorecard({
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
- const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
+ const wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
+ const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
- }, [roundId])
+ }, [roundId, publicMode])
useEffect(() => {
let cancelled = false
async function load() {
- const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
+ const res = await fetch(`${apiPrefix}/${roundId}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
@@ -1158,7 +1179,7 @@ export function RoundScorecard({
return () => {
cancelled = true
}
- }, [roundId, viewerId, refreshKey])
+ }, [roundId, viewerId, refreshKey, apiPrefix])
useEffect(() => {
if (!unitId || !round) return
@@ -1171,8 +1192,8 @@ export function RoundScorecard({
// den ene eller den andre typen).
const isSideUnit = round.sides.some((s) => s.id === unitId)
const endpoint = isSideUnit
- ? `/rounds/${roundId}/sides/${unitId}/holes`
- : `/rounds/${roundId}/participants/${unitId}/holes`
+ ? `${apiPrefix}/${roundId}/sides/${unitId}/holes`
+ : `${apiPrefix}/${roundId}/participants/${unitId}/holes`
let cancelled = false
async function loadHoles() {
const res = await fetch(endpoint, { credentials: "include" })
@@ -1184,14 +1205,17 @@ export function RoundScorecard({
return () => {
cancelled = true
}
- }, [roundId, unitId, round?.play_format, refreshKey])
+ }, [roundId, unitId, round?.play_format, refreshKey, apiPrefix])
if (error) {
return (
{error}
-
- Tilbake til egne runder
+
+ {publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
)
@@ -1222,7 +1246,7 @@ export function RoundScorecard({
@@ -1318,7 +1342,7 @@ export function RoundScorecard({
@@ -1436,9 +1460,9 @@ export function RoundScorecard({
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
{isTwoSided && round.sides.length === 2 && (
-
+
)}
- {isSkins && }
+ {isSkins && }
)
diff --git a/frontend/components/rounds-stats-summary.tsx b/frontend/components/rounds-stats-summary.tsx
index 38e18b6..55708f3 100644
--- a/frontend/components/rounds-stats-summary.tsx
+++ b/frontend/components/rounds-stats-summary.tsx
@@ -610,17 +610,17 @@ export function RoundsStatsSummary() {
/>
diff --git a/frontend/components/team-chat.tsx b/frontend/components/team-chat.tsx
index c248e26..3fb837d 100644
--- a/frontend/components/team-chat.tsx
+++ b/frontend/components/team-chat.tsx
@@ -3,9 +3,10 @@
import type React from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
-import { ArrowLeft, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
+import { ArrowLeft, Camera, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
+import { PostEngagement, type ReactionSummary } from "./post-engagement"
// --- Types (matcher API-kontrakten i app/routers/messaging.py) -------------
@@ -16,6 +17,8 @@ type ApiMessage = {
body: string | null
image_url: string | null
created_at: string
+ reactions: ReactionSummary[]
+ comment_count: number
}
const TIME_FMT = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
@@ -46,8 +49,8 @@ export function TeamChat({
const [text, setText] = useState("")
const [image, setImage] = useState(null)
const [sending, setSending] = useState(false)
- const bottomRef = useRef(null)
const fileInputRef = useRef(null)
+ const cameraInputRef = useRef(null)
useEffect(() => {
let cancelled = false
@@ -90,7 +93,8 @@ export function TeamChat({
socket.onmessage = (event) => {
try {
const msg: ApiMessage = JSON.parse(event.data)
- setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
+ // Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
+ setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
} catch {
// ignorer ugyldig payload
}
@@ -98,9 +102,19 @@ export function TeamChat({
return () => socket.close()
}, [organizationId, teamId, accessError])
- useEffect(() => {
- bottomRef.current?.scrollIntoView({ block: "end" })
- }, [messages.length])
+ // Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
+ // klient-side for en umiddelbar, presis feilmelding.
+ function handleImageSelected(e: React.ChangeEvent) {
+ const file = e.target.files?.[0]
+ if (!file) return
+ if (file.size > 20 * 1024 * 1024) {
+ setError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
+ e.target.value = ""
+ return
+ }
+ setError(null)
+ setImage(file)
+ }
async function send(e: React.FormEvent) {
e.preventDefault()
@@ -116,14 +130,26 @@ export function TeamChat({
credentials: "include",
body: form,
})
- if (!res.ok) throw new Error(`send: ${res.status}`)
+ if (!res.ok) {
+ let message = "Klarte ikke å sende meldingen. Prøv igjen."
+ try {
+ const data = await res.json()
+ if (typeof data?.detail?.message === "string") message = data.detail.message
+ } catch {
+ // Ikke JSON -- behold standardmeldingen.
+ }
+ setError(message)
+ return
+ }
const msg: ApiMessage = await res.json()
- setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
+ // Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
+ setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
setText("")
setImage(null)
if (fileInputRef.current) fileInputRef.current.value = ""
+ if (cameraInputRef.current) cameraInputRef.current.value = ""
} catch {
- setError("Klarte ikke å sende meldingen. Prøv igjen.")
+ setError("Klarte ikke å sende meldingen. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSending(false)
}
@@ -224,10 +250,18 @@ export function TeamChat({
{TIME_FMT.format(new Date(m.created_at))}
+
+
+
)
})}
-
@@ -245,6 +279,7 @@ export function TeamChat({
onClick={() => {
setImage(null)
if (fileInputRef.current) fileInputRef.current.value = ""
+ if (cameraInputRef.current) cameraInputRef.current.value = ""
}}
aria-label="Fjern bilde"
>
@@ -253,19 +288,41 @@ export function TeamChat({
)}
+ {/* To atskilte inputs -- Android sin systembilde-vlger
+ (Photo Picker) viser ALDRI et kamera-alternativ når
+ accept="image/*" mangler capture, uansett nettleser
+ (funnet 2026-08-06, ekte enhet). */}
+
+ cameraInputRef.current?.click()}
+ aria-label="Ta bilde"
+ className="size-11 shrink-0 rounded-2xl text-muted-foreground"
+ >
+
+
setImage(e.target.files?.[0] ?? null)}
+ onChange={handleImageSelected}
/>
fileInputRef.current?.click()}
- aria-label="Legg ved bilde"
+ aria-label="Velg bilde fra galleri"
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
>
diff --git a/frontend/components/teecup/teecup-auth.tsx b/frontend/components/teecup/teecup-auth.tsx
new file mode 100644
index 0000000..c1c266a
--- /dev/null
+++ b/frontend/components/teecup/teecup-auth.tsx
@@ -0,0 +1,520 @@
+"use client"
+
+import type React from "react"
+import { useEffect, useId, useRef, useState } from "react"
+import { cn } from "@/lib/utils"
+
+/* ------------------------------------------------------------------ */
+/* Icons — inline SVG (currentColor), so every action can pair an */
+/* icon with a real text label and we control size/stroke precisely. */
+/* ------------------------------------------------------------------ */
+type IconProps = { className?: string }
+const stroke = {
+ fill: "none",
+ stroke: "currentColor",
+ strokeWidth: 1.75,
+ strokeLinecap: "round" as const,
+ strokeLinejoin: "round" as const,
+}
+function MailIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function KeyIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function TicketIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function EyeIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function EyeOffIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function ArrowLeftIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function AlertIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function CheckIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function FlagIcon({ className }: IconProps) {
+ return (
+
+ )
+}
+function Spinner({ className }: IconProps) {
+ return (
+
+ )
+}
+
+/* ------------------------------------------------------------------ */
+/* Shared styles */
+/* ------------------------------------------------------------------ */
+const focusRing =
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong focus-visible:ring-offset-2 focus-visible:ring-offset-clubhouse-card"
+
+const inputBase =
+ "w-full h-12 rounded-xl border bg-clubhouse-field px-4 text-base text-clubhouse-ink placeholder:text-clubhouse-muted/70 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong/70"
+
+const primaryBtn = cn(
+ "inline-flex w-full min-h-[3.25rem] items-center justify-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-semibold text-white shadow-sm transition-colors hover:bg-[#265a18] active:translate-y-px disabled:cursor-not-allowed disabled:opacity-55",
+ focusRing,
+)
+
+const secondaryBtn = cn(
+ "inline-flex w-full min-h-[2.75rem] items-center justify-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card px-4 py-2.5 text-base font-medium text-clubhouse-ink transition-colors hover:border-tee-strong/40 hover:bg-clubhouse-field",
+ focusRing,
+)
+
+const backBtn = cn(
+ "-mx-2 inline-flex min-h-[2.75rem] items-center gap-1.5 rounded-lg px-2 text-base font-medium text-tee-strong transition-colors hover:underline",
+ focusRing,
+)
+
+/* ------------------------------------------------------------------ */
+/* Mocked backend — stands in for the real API so the flow actually */
+/* runs (loading, success, and error responses are all real here). */
+/* ------------------------------------------------------------------ */
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+async function apiSendMagicLink(_email: string) {
+ await sleep(1100)
+ return { ok: true as const }
+}
+async function apiPasswordLogin(_email: string, password: string) {
+ await sleep(1100)
+ // Demo credential so the success path is reachable; anything else fails.
+ if (password === "teecup123") return { ok: true as const }
+ return { ok: false as const, error: "Feil e-post eller passord. Prøv igjen." }
+}
+async function apiJoinByCode(code: string) {
+ await sleep(1000)
+ const known: Record = {
+ TEECUP: "Lørdagsrunden",
+ "RYDER-25": "Ryder Cup 2025",
+ HOST2026: "Høstcupen 2026",
+ }
+ const name = known[code.trim().toUpperCase()]
+ if (name) return { ok: true as const, name }
+ return {
+ ok: false as const,
+ error: `Fant ingen turnering med koden «${code.trim()}». Sjekk at den er riktig skrevet.`,
+ }
+}
+
+type Mode = "email" | "sent" | "password" | "code"
+const RESEND_SECONDS = 30
+
+/* ------------------------------------------------------------------ */
+/* Small building blocks */
+/* ------------------------------------------------------------------ */
+function FieldError({ id, children }: { id: string; children: React.ReactNode }) {
+ return (
+
- Ingen live-visning tilgjengelig for dette formatet ennå.
-
- )}
+ {/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
+ aldri her, kun på round-detail.tsx sin egen "Score"-fane. En
+ person runden er delt med hadde dermed ingen vei til å se eller
+ delta i samtalen. `GET/POST /rounds/{id}/messages` er allerede
+ synlighets-gatet (_can_view_round, ikke eier/deltaker-only), så
+ samme endepunkt gjenbrukes uendret -- ingen ny backend-kode. */}
+ {/* Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
+ rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat. */}
+
+
+
)
diff --git a/frontend/components/watch-scorecard.tsx b/frontend/components/watch-scorecard.tsx
new file mode 100644
index 0000000..39440e9
--- /dev/null
+++ b/frontend/components/watch-scorecard.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+// Scorekort-fanen for tilskuer-visningen (ADR-044, 2026-08-06). Gjenbruker
+// round-scorecard.tsx sin RoundScorecard UENDRET (samme formatspesifikke
+// rutenett som eiersiden -- se komponentens `publicMode`-prop), kun matet
+// fra /public/rounds/* i stedet for /rounds/*. Egen sticky header + fane-
+// stripe her, siden RoundScorecard sin egen header slås av med
+// `embedded`.
+
+import { Trophy } from "lucide-react"
+import { WatchTabs } from "@/components/watch-tabs"
+import { RoundScorecard } from "@/components/round-scorecard"
+
+export function WatchScorecard({ roundId }: { roundId: string }) {
+ return (
+