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. */} +
+ ) +} 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 ( -
-
+ + ) } diff --git a/frontend/components/own-rounds.tsx b/frontend/components/own-rounds.tsx index 26d2af3..e691cd3 100644 --- a/frontend/components/own-rounds.tsx +++ b/frontend/components/own-rounds.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button" import { Wordmark } from "@/components/wordmark" import { RoundCard, type Round } from "@/components/round-card" import { ArrowLeft } from "lucide-react" +import { BottomTabBar } from "@/components/dashboard" type ApiRoundParticipant = { id: string @@ -136,7 +137,7 @@ export function OwnRounds() { -
+

@@ -179,6 +180,8 @@ export function OwnRounds() { )}

+ + ) } diff --git a/frontend/components/post-engagement.tsx b/frontend/components/post-engagement.tsx new file mode 100644 index 0000000..5ad5fa6 --- /dev/null +++ b/frontend/components/post-engagement.tsx @@ -0,0 +1,674 @@ +"use client" + +import { useCallback, useMemo, useState } from "react" +import { MessageSquare, Reply, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +/* ------------------------------------------------------------------ */ +/* Data contracts (match the real API shape) */ +/* ------------------------------------------------------------------ */ + +export type ReactionSummary = { + emoji: string + count: number + reacted_by_me: boolean + reactors: string[] +} + +export type CommentOut = { + id: string + parent_comment_id: string | null + author_user_id: string + author_display_name: string + body: string + created_at: string // ISO 8601 +} + +export type PostEngagementProps = { + /** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This + * component appends "/reaction" and "/comments". */ + apiBase: string + currentUserId: string | null + initialReactions: ReactionSummary[] + initialCommentCount: number + canModerate: boolean +} + +/** Curated, fixed reaction set — exact order, no free picker. */ +const REACTION_SET = ["👍", "❤️", "😂", "😮", "😢", "🙏"] as const + +/** Cap visual indent growth so deep threads stay readable on mobile. */ +const MAX_INDENT_DEPTH = 3 + +const GENERIC_ERROR = "Noe gikk galt. Prøv igjen." + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** Parse `{ detail: { code, message } }` error envelope, with fallback. */ +async function readError(res: Response): Promise { + try { + const data = await res.json() + const msg = data?.detail?.message + return typeof msg === "string" && msg.trim() !== "" ? msg : GENERIC_ERROR + } catch { + return GENERIC_ERROR + } +} + +/** "Kari, Ola og 3 andre reagerte" — 2026-08-07, brukerens eksplisitte krav + * om å faktisk kunne se HVEM som reagerte, ikke bare et antall. */ +function formatReactorSummary(names: string[]): string { + if (names.length === 0) return "" + if (names.length === 1) return `${names[0]} reagerte` + if (names.length === 2) return `${names[0]} og ${names[1]} reagerte` + const rest = names.length - 2 + return `${names[0]}, ${names[1]} og ${rest} ${rest === 1 ? "annen" : "andre"} reagerte` +} + +/** Short relative / clock-style timestamp, matching the rest of the app. */ +function formatTimestamp(iso: string): string { + const then = new Date(iso) + if (Number.isNaN(then.getTime())) return "" + const now = Date.now() + const diffMs = now - then.getTime() + const min = Math.floor(diffMs / 60000) + if (min < 1) return "nå" + if (min < 60) return `${min} min` + const hours = Math.floor(min / 60) + if (hours < 24) return `${hours} t` + const days = Math.floor(hours / 24) + if (days < 7) return `${days} d` + return then.toLocaleDateString("nb-NO", { day: "numeric", month: "short" }) +} + +/* ------------------------------------------------------------------ */ +/* Main component */ +/* ------------------------------------------------------------------ */ + +export function PostEngagement({ + apiBase, + currentUserId, + initialReactions, + initialCommentCount, + canModerate, +}: PostEngagementProps) { + const loggedIn = currentUserId !== null + + /* -------------------------- Reactions -------------------------- */ + const [reactions, setReactions] = useState(initialReactions) + const [reactionError, setReactionError] = useState(null) + const [reactionPending, setReactionPending] = useState(false) + + // Merge the curated set with server data so all six always render in order, + // even when a given emoji has no reactions yet. + const reactionRow = useMemo(() => { + const byEmoji = new Map(reactions.map((r) => [r.emoji, r])) + return REACTION_SET.map( + (emoji) => byEmoji.get(emoji) ?? { emoji, count: 0, reacted_by_me: false, reactors: [] }, + ) + }, [reactions]) + + const toggleReaction = useCallback( + async (r: ReactionSummary) => { + if (!loggedIn || reactionPending) return + setReactionPending(true) + setReactionError(null) + // Tap your own reaction -> DELETE; otherwise PUT (switching is just a PUT, + // the backend replaces any prior reaction of yours). + const method = r.reacted_by_me ? "DELETE" : "PUT" + try { + const res = await fetch(`${apiBase}/reaction`, { + method, + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emoji: r.emoji }), + }) + if (!res.ok) { + setReactionError(await readError(res)) + return + } + const fresh = (await res.json()) as ReactionSummary[] + setReactions(fresh) + } catch { + setReactionError(GENERIC_ERROR) + } finally { + setReactionPending(false) + } + }, + [apiBase, loggedIn, reactionPending], + ) + + /* --------------------------- Comments -------------------------- */ + const [expanded, setExpanded] = useState(false) + const [comments, setComments] = useState([]) + const [fetched, setFetched] = useState(false) + const [loading, setLoading] = useState(false) + const [threadError, setThreadError] = useState(null) + const [commentCount, setCommentCount] = useState(initialCommentCount) + + const loadComments = useCallback(async () => { + setLoading(true) + setThreadError(null) + try { + const res = await fetch(`${apiBase}/comments`, { credentials: "include" }) + if (!res.ok) { + setThreadError(await readError(res)) + return + } + const data = (await res.json()) as CommentOut[] + setComments(data) + setFetched(true) + } catch { + setThreadError(GENERIC_ERROR) + } finally { + setLoading(false) + } + }, [apiBase]) + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => { + const next = !prev + if (next && !fetched && !loading) void loadComments() + return next + }) + }, [fetched, loading, loadComments]) + + // Build a nested tree from the flat list, preserving API order (chronological + // oldest-first). Children are grouped by parent id. + const { roots, childrenOf } = useMemo(() => { + const childrenOf = new Map() + const roots: CommentOut[] = [] + for (const c of comments) { + if (c.parent_comment_id === null) { + roots.push(c) + } else { + const arr = childrenOf.get(c.parent_comment_id) ?? [] + arr.push(c) + childrenOf.set(c.parent_comment_id, arr) + } + } + return { roots, childrenOf } + }, [comments]) + + const appendComment = useCallback((c: CommentOut) => { + setComments((prev) => [...prev, c]) + setCommentCount((n) => n + 1) + }, []) + + const removeSubtree = useCallback((id: string) => { + setComments((prev) => { + // Collect the target plus all transitive descendants. + const toRemove = new Set([id]) + let grew = true + while (grew) { + grew = false + for (const c of prev) { + if (c.parent_comment_id && toRemove.has(c.parent_comment_id) && !toRemove.has(c.id)) { + toRemove.add(c.id) + grew = true + } + } + } + setCommentCount((n) => Math.max(0, n - toRemove.size)) + return prev.filter((c) => !toRemove.has(c.id)) + }) + }, []) + + /* One reply composer open at a time, tracked by target comment id. */ + const [replyingTo, setReplyingTo] = useState(null) + + /* Hvem-reagerte-detalj (2026-08-07) — dataen er allerede i reactionRow + * (samme batch-henting som selve tellingen), så dette er ren + * visningslogikk, ingen ekstra fetch. */ + const [showReactors, setShowReactors] = useState(false) + const activeReactionGroups = reactionRow.filter((r) => r.count > 0) + const allReactorNames = activeReactionGroups.flatMap((r) => r.reactors) + + return ( +
+ {/* ---------------------------- Reactions ---------------------------- */} +
+
+ {reactionRow.map((r) => { + const active = r.reacted_by_me + return ( + + ) + })} +
+ {reactionError && ( +

+ {reactionError} +

+ )} + + {/* Hvem reagerte -- alltid synlig sammendrag (ikke bare hover/tap, + tilgjengelighetsregelen), utvidbart til full per-emoji-liste. */} + {allReactorNames.length > 0 && ( +
+ + {showReactors && ( +
    + {activeReactionGroups.map((r) => ( +
  • + + {r.reactors.join(", ")} +
  • + ))} +
+ )} +
+ )} +
+ + {/* ------------------------ Expand / count ------------------------ */} +
+ +
+ + {/* ---------------------------- Thread ---------------------------- */} + {expanded && ( +
+ {loading && ( +
+ +
+ )} + + {threadError && !loading && ( +
+

+ {threadError} +

+ +
+ )} + + {!loading && !threadError && fetched && ( + <> + {roots.length === 0 ? ( +

Ingen kommentarer ennå.

+ ) : ( +
    + {roots.map((c) => ( + + ))} +
+ )} + + {/* Bottom composer (top-level) */} + {loggedIn ? ( + + ) : ( +

Logg inn for å kommentere.

+ )} + + )} +
+ )} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* Single comment (recursive) */ +/* ------------------------------------------------------------------ */ + +function CommentNode({ + comment, + depth, + childrenOf, + apiBase, + currentUserId, + canModerate, + replyingTo, + setReplyingTo, + onAppend, + onRemoveSubtree, +}: { + comment: CommentOut + depth: number + childrenOf: Map + apiBase: string + currentUserId: string | null + canModerate: boolean + replyingTo: string | null + setReplyingTo: (id: string | null) => void + onAppend: (c: CommentOut) => void + onRemoveSubtree: (id: string) => void +}) { + const loggedIn = currentUserId !== null + const children = childrenOf.get(comment.id) ?? [] + const hasReplies = children.length > 0 + const canDelete = comment.author_user_id === currentUserId || canModerate + + const [confirming, setConfirming] = useState(false) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(null) + + const isReplyOpen = replyingTo === comment.id + + const handleDelete = useCallback(async () => { + setDeleting(true) + setDeleteError(null) + try { + const res = await fetch(`${apiBase}/comments/${comment.id}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok) { + setDeleteError(await readError(res)) + return + } + onRemoveSubtree(comment.id) + } catch { + setDeleteError(GENERIC_ERROR) + } finally { + setDeleting(false) + setConfirming(false) + } + }, [apiBase, comment.id, onRemoveSubtree]) + + // Indent per level via a left border, but stop growing past the cap so long + // threads on mobile don't squeeze content into a sliver. + const indented = depth > 0 + const cappedForChildren = Math.min(depth, MAX_INDENT_DEPTH) + + return ( +
  • +
    + {/* Author + timestamp */} +
    + {comment.author_display_name} + + {formatTimestamp(comment.created_at)} + +
    + + {/* Body */} +

    + {comment.body} +

    + + {/* Actions */} +
    + {loggedIn && ( + + )} + + {canDelete && !confirming && ( + + )} + + {canDelete && confirming && ( +
    + + + {hasReplies && ( +

    + Sletter du denne, forsvinner også svarene under. +

    + )} +
    + )} +
    + + {deleteError && ( +

    + {deleteError} +

    + )} + + {/* Inline reply composer */} + {isReplyOpen && loggedIn && ( + { + onAppend(c) + setReplyingTo(null) + }} + onCancel={() => setReplyingTo(null)} + /> + )} +
    + + {/* Children */} + {hasReplies && ( +
      + {children.map((child) => ( + + ))} +
    + )} +
  • + ) +} + +/* ------------------------------------------------------------------ */ +/* Composer */ +/* ------------------------------------------------------------------ */ + +function CommentComposer({ + apiBase, + parentCommentId, + onPosted, + onCancel, + autoFocus, +}: { + apiBase: string + parentCommentId: string | null + onPosted: (c: CommentOut) => void + onCancel?: () => void + autoFocus?: boolean +}) { + const [body, setBody] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const empty = body.trim() === "" + + const submit = useCallback(async () => { + if (empty || submitting) return + setSubmitting(true) + setError(null) + try { + const res = await fetch(`${apiBase}/comments`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + body: body.trim(), + ...(parentCommentId ? { parent_comment_id: parentCommentId } : {}), + }), + }) + if (!res.ok) { + setError(await readError(res)) + return + } + const created = (await res.json()) as CommentOut + onPosted(created) + setBody("") + } catch { + setError(GENERIC_ERROR) + } finally { + setSubmitting(false) + } + }, [apiBase, body, empty, onPosted, parentCommentId, submitting]) + + return ( +
    +