Før redesign

This commit is contained in:
Erol Haagenrud 2026-08-07 22:02:56 +02:00
parent f55a51e9e2
commit 12f10d92a3
73 changed files with 6682 additions and 1390 deletions

View file

@ -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",

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.env
__pycache__/
*.pyc
Temp-uploads/

View file

@ -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;

75
060_round_shot.sql Normal file
View file

@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View file

@ -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
`<Link>` 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:

View file

@ -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: `<input type="file"
accept="image/jpeg,image/png,image/webp,image/gif">` -- 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å-`<g>` i den innlastede SVG-en for å
måle faktiske posisjoner -- avslørte at ikonet (ball/pokal/tee) består
av tre atskilte grupper (x: 0169 av 656 totalt) mens "TeeCup"-
ordmerket er seks separate bokstav-grupper (x: 179407) -- 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``PublicRoundOut` (samme personvernsnivå som
det allerede eksponerte visningsnavnet -- en ugjennomsiktig UUID
avslører ingenting nytt).
**Rettet:** `watch-round.tsx` monterer nå `<RoundMessages>`
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 `<input type=file accept="image/*">` 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`-`<Link>` (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). `<main>`-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.

View file

@ -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

View file

@ -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 `/`.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Binary file not shown.

View file

@ -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()

View file

@ -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)

View file

@ -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 her (runder er ikke
org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
stede, 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
]

View file

@ -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 her (runder er ikke
org-scopet) -- `app_user.display_name` er NOT NULL og alltid til
stede, 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 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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);
}

View file

@ -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 (
<main className="relative flex min-h-dvh flex-col items-center justify-center overflow-hidden bg-clubhouse-bg px-4 py-8 text-clubhouse-ink">
{/* Quiet turf-horizon backdrop — the one bit of atmosphere, kept subtle. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-tee/15 to-transparent" />
<div className="relative z-10 w-full max-w-md">
{/* Header */}
<header className="mb-6 flex flex-col items-center text-center">
<TeeCupWordmark className="h-11" />
<p className="mt-3 text-pretty text-[0.9375rem] font-medium text-clubhouse-muted">
Live-scoring og turneringer fra første tee til pokalen.
</p>
</header>
{/* Card the scorecard. Continuation screens (2FA, link confirm) can
reuse this exact container. */}
<section className="overflow-hidden rounded-3xl border border-clubhouse-border bg-clubhouse-card shadow-[0_1px_0_rgba(23,32,15,0.04),0_18px_40px_-24px_rgba(23,32,15,0.35)]">
{/* Signature element: a fairway-green scorecard header stripe. */}
<div className="h-2 w-full bg-gradient-to-r from-tee-strong via-tee to-cup" />
<div className="p-6 sm:p-8">
<TeeCupAuth />
</div>
</section>
<p className="mt-6 text-center text-[0.875rem] leading-relaxed text-clubhouse-muted">
Ved å logge inn godtar du vilkårene våre. Trenger du hjelp? Ta kontakt med klubben din.
</p>
</div>
</main>
)
}

View file

@ -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",

View file

@ -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 <WatchLeaderboard roundId={id} />
}

View file

@ -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 <WatchScorecard roundId={id} />
}

View file

@ -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() {
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<main className="mx-auto w-full max-w-2xl flex-1 px-5 pb-24 pt-6 sm:pt-8">
{settingUp2fa ? (
<div className="rounded-3xl border border-border bg-card p-6 shadow-md shadow-black/8 sm:p-8">
<TwoFactorSetupForm
@ -337,6 +338,8 @@ export function AccountSettings() {
</div>
)}
</main>
<BottomTabBar active="profile" />
</div>
)
}
@ -655,7 +658,9 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
// "image/*" -- mange mobilnettlesere viser bare galleri, ikke
// kamera, når accept lister flere eksplisitte MIME-typer.
accept="image/*"
className="hidden"
onChange={handleAvatarSelected}
/>

View file

@ -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 (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-clubhouse-bg">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-tee-strong/20 border-t-tee-strong" />
</div>
)
}
@ -469,26 +489,21 @@ export function Dashboard() {
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header
className="sticky top-0 z-10 border-b bg-background pt-[env(safe-area-inset-top)]"
style={{ borderColor: C.border }}
>
<div className="flex min-h-[100dvh] flex-col bg-clubhouse-bg">
<header className="sticky top-0 z-10 border-b border-clubhouse-border bg-clubhouse-card/95 backdrop-blur pt-[env(safe-area-inset-top)] supports-[backdrop-filter]:bg-clubhouse-card/80">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Link href="/dashboard" aria-label="TeeCup til forsiden" className="flex items-center gap-2 rounded-xl">
<span className="flex size-9 items-center justify-center rounded-xl" style={{ backgroundColor: C.primary }}>
<Flag aria-hidden="true" className="size-5" fill={C.successBg} color={C.successBg} />
</span>
<span className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
TeeCup
</span>
<Link
href="/dashboard"
aria-label="TeeCup til forsiden"
className="flex items-center rounded-xl py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<TeeCupWordmark className="h-7" />
</Link>
<div className="flex items-center gap-1">
<NotificationBell unreadCount={unreadNotifications} />
<Link
href="/account"
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold transition-all duration-200 ease-in-out active:opacity-70"
style={{ color: C.muted }}
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70"
>
<UserCircle aria-hidden="true" className="size-4" />
Konto
@ -496,8 +511,7 @@ export function Dashboard() {
<button
type="button"
onClick={handleLogout}
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold transition-all duration-200 ease-in-out active:opacity-70"
style={{ color: C.muted }}
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
@ -512,16 +526,16 @@ export function Dashboard() {
klasse feil som å glemme at en fixed bar tar plass fra layouten). */}
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-6 sm:pt-8">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-balance" style={{ color: C.ink }}>
<h1 className="text-2xl font-extrabold tracking-tight text-balance text-clubhouse-ink">
God dag, {me.first_name ?? me.display_name}
</h1>
<p className="text-base leading-relaxed text-pretty" style={{ color: C.muted }}>
<p className="text-base leading-relaxed text-pretty text-clubhouse-muted">
Klar for en ny runde? Her er golfen din ett sted.
</p>
</div>
{error && (
<p role="alert" className="mt-4 text-sm font-medium" style={{ color: "#b3261e" }}>
<p role="alert" className="mt-4 text-sm font-medium text-cup-strong">
{error}
</p>
)}
@ -615,15 +629,13 @@ function NotificationBell({ unreadCount }: { unreadCount: number }) {
<Link
href="/my-notifications"
aria-label={label}
className="relative inline-flex size-11 items-center justify-center rounded-lg transition-all duration-200 ease-in-out active:opacity-70 focus-visible:outline-none focus-visible:ring-2"
style={{ color: C.muted, outlineColor: C.accent }}
className="relative inline-flex size-11 items-center justify-center rounded-lg text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<Bell aria-hidden="true" className="size-5" />
{hasUnread && (
<span
aria-hidden="true"
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px var(--background)` }}
className="absolute right-1 top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[11px] font-bold leading-none text-white ring-2 ring-clubhouse-card"
>
{badgeText}
</span>
@ -648,9 +660,19 @@ function QuickActions({
return (
<section id="hurtighandlinger" aria-label="Hurtighandlinger" className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2 sm:gap-3">
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" />
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => onOpenChange(open === "tournament" ? null : "tournament")} />
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => onOpenChange(open === "code" ? null : "code")} />
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" primary />
<QuickAction
icon={Trophy}
label="Ny turnering"
onClick={() => onOpenChange(open === "tournament" ? null : "tournament")}
expanded={open === "tournament"}
/>
<QuickAction
icon={KeyRound}
label="Bli med med kode"
onClick={() => onOpenChange(open === "code" ? null : "code")}
expanded={open === "code"}
/>
</div>
{open === "tournament" && (
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => onOpenChange(null)} />
@ -665,34 +687,39 @@ function QuickAction({
label,
href,
onClick,
primary = false,
expanded = false,
}: {
icon: typeof Flag
label: string
href?: string
onClick?: () => void
primary?: boolean
expanded?: boolean
}) {
const style: React.CSSProperties = {
backgroundColor: C.primary,
color: C.primaryInk,
boxShadow: "0 14px 30px -20px rgba(31, 107, 8, 0.55)",
}
const className =
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 text-center transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
const className = cn(
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl border px-2 py-3 text-center font-semibold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong",
primary
? "border-transparent bg-tee-strong text-white shadow-sm hover:brightness-110"
: expanded
? "border-tee-strong bg-tee/10 text-clubhouse-ink"
: "border-clubhouse-border bg-clubhouse-card text-clubhouse-ink shadow-sm hover:border-tee-strong/50 hover:bg-tee/5",
)
const inner = (
<>
<Icon aria-hidden="true" className="size-5 sm:size-6" color={C.primaryInk} />
<Icon aria-hidden="true" className={cn("size-5 sm:size-6", primary ? "text-white" : "text-tee-strong")} />
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
</>
)
if (href) {
return (
<Link href={href} className={className} style={style}>
<Link href={href} className={className}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className} style={style}>
<button type="button" onClick={onClick} aria-expanded={primary ? undefined : expanded} className={className}>
{inner}
</button>
)
@ -868,9 +895,7 @@ function JoinByCodeInline({ onClose }: { onClose: () => void }) {
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
{title}
</h2>
<h2 className="text-lg font-extrabold tracking-tight text-clubhouse-ink">{title}</h2>
{action}
</div>
)
@ -880,8 +905,7 @@ function SeeAllLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
style={{ color: C.accent, outlineColor: C.accent }}
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-tee-strong transition-all duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
{label}
<ChevronRight aria-hidden="true" className="size-4" />
@ -901,20 +925,13 @@ function EmptyState({
children?: React.ReactNode
}) {
return (
<div
className="flex flex-col items-center gap-3 rounded-2xl border border-dashed px-6 py-8 text-center"
style={{ borderColor: C.border, backgroundColor: "rgba(255,255,255,0.5)" }}
>
<div className="flex size-12 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
<Icon aria-hidden="true" className="size-6" color={C.muted} />
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-clubhouse-border bg-clubhouse-card/60 px-6 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-clubhouse-field">
<Icon aria-hidden="true" className="size-6 text-clubhouse-muted" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-base font-bold text-balance" style={{ color: C.ink }}>
{title}
</h3>
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.muted }}>
{description}
</p>
<h3 className="text-base font-bold text-balance text-clubhouse-ink">{title}</h3>
<p className="text-sm leading-relaxed text-pretty text-clubhouse-muted">{description}</p>
</div>
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
</div>
@ -934,12 +951,12 @@ function ShortcutButton({
onClick?: () => void
variant?: "primary" | "outline"
}) {
const style: React.CSSProperties =
const className = cn(
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong",
variant === "primary"
? { backgroundColor: C.primary, color: C.primaryInk }
: { backgroundColor: C.card, color: C.ink, border: `1px solid ${C.border}` }
const className =
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
? "bg-tee-strong text-white hover:brightness-110"
: "border border-clubhouse-border bg-clubhouse-card text-clubhouse-ink hover:bg-clubhouse-field",
)
const inner = (
<>
<Icon aria-hidden="true" className="size-4" />
@ -948,13 +965,13 @@ function ShortcutButton({
)
if (href) {
return (
<Link href={href} className={className} style={style}>
<Link href={href} className={className}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className} style={style}>
<button type="button" onClick={onClick} className={className}>
{inner}
</button>
)
@ -986,9 +1003,8 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
return (
<Link
key={friend.round_id}
href={`/my-rounds/${friend.round_id}`}
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl p-4 transition-colors hover:bg-black/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: C.card, border: `1px solid ${C.borderSoft}` }}
href={`/watch/${friend.round_id}`}
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm transition hover:border-tee-strong/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<div className="flex items-center gap-3">
<div className="relative shrink-0">
@ -999,26 +1015,32 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
{friendOnCourseInitials(friend.friend_name)}
</span>
{playing && (
<span aria-hidden="true" className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full" style={{ backgroundColor: C.card }}>
<span className="absolute inline-flex size-2.5 animate-ping rounded-full opacity-75" style={{ backgroundColor: C.successBg }} />
<span className="relative inline-flex size-2.5 rounded-full" style={{ backgroundColor: C.successInk }} />
<span aria-hidden="true" className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-clubhouse-card">
<span className="absolute inline-flex size-2.5 animate-ping rounded-full bg-cup opacity-75" />
<span className="relative inline-flex size-2.5 rounded-full bg-cup" />
</span>
)}
</div>
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
{friend.friend_name}
</span>
<span className="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide" style={{ color: playing ? C.successInk : C.muted }}>
<span className="truncate text-base font-bold text-clubhouse-ink">{friend.friend_name}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide",
playing ? "text-cup-strong" : "text-clubhouse-muted",
)}
>
{playing ? "Spiller nå" : friend.completed_at ? `Fullførte ${timeAgoLabel(friend.completed_at)}` : "Fullførte nylig"}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm" style={{ color: C.muted }}>
{friend.course_name}
</span>
<span className="shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums" style={{ backgroundColor: C.tint, color: C.ink }}>
<span className="truncate text-sm text-clubhouse-muted">{friend.course_name}</span>
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums",
playing ? "bg-tee/15 text-tee-strong" : "bg-clubhouse-field text-clubhouse-muted",
)}
>
{playing
? `Hull ${friend.holes_played} av ${friend.holes_planned}`
: friend.score_to_par !== null
@ -1031,9 +1053,7 @@ function LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
})}
</div>
) : (
<p className="text-sm leading-relaxed" style={{ color: C.muted }}>
Ingen venner er banen akkurat .
</p>
<p className="text-sm leading-relaxed text-clubhouse-muted">Ingen venner er banen akkurat .</p>
)}
</section>
)
@ -1048,7 +1068,7 @@ function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
{loading ? (
<div className="flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-tee-strong/20 border-t-tee-strong" />
</div>
) : visible.length > 0 ? (
<div className="flex flex-col gap-3">
@ -1109,8 +1129,8 @@ function Sparkline({ values }: { values: number[] }) {
return (
<div
key={i}
className="w-1 rounded-full"
style={{ height: `${height}%`, backgroundColor: i === values.length - 1 ? C.successInk : C.border }}
className={cn("w-1 rounded-full", i === values.length - 1 ? "bg-tee-strong" : "bg-clubhouse-border")}
style={{ height: `${height}%` }}
/>
)
})}
@ -1120,16 +1140,9 @@ function Sparkline({ values }: { values: number[] }) {
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return (
<div
className="flex flex-col gap-1.5 rounded-2xl p-4"
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
<span className="text-xs font-medium uppercase tracking-wide" style={{ color: C.muted }}>
{label}
</span>
<span className="text-2xl font-extrabold tabular-nums sm:text-3xl" style={{ color: C.ink }}>
{value}
</span>
<div className="flex flex-col gap-1.5 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm">
<span className="text-xs font-medium uppercase tracking-wide text-clubhouse-muted">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-clubhouse-ink sm:text-3xl">{value}</span>
{children}
</div>
)
@ -1160,17 +1173,15 @@ function StatsSection({
<StatTile label="HCP nå" value={hcpNow}>
{hcpTrend !== null ? (
<div className="flex items-center justify-between gap-1">
<span className="inline-flex items-center gap-1 text-xs font-bold" style={{ color: C.ink }}>
<TrendIcon aria-hidden="true" className="size-4" color={C.successInk} />
<span className="inline-flex items-center gap-1 text-xs font-bold text-tee-strong">
<TrendIcon aria-hidden="true" className="size-4" />
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
</span>
<Sparkline values={hcpHistory} />
</div>
) : (
<span className="text-xs font-medium" style={{ color: C.muted }}>
Ingen data
</span>
<span className="text-xs font-medium text-clubhouse-muted">Ingen data</span>
)}
</StatTile>
<StatTile label="Snitt til par" value={avgToPar} />
@ -1186,31 +1197,25 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
<section aria-label="Spilte baner">
<SectionHeader title="Spilte baner" />
{courses.length > 0 ? (
<ul
className="flex flex-col overflow-hidden rounded-2xl"
style={{ border: `1px solid ${C.border}`, backgroundColor: C.card, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
<ul className="flex flex-col overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-card shadow-sm">
{courses.map((course, i) => {
const roundLabel = course.visits === 1 ? "runde" : "runder"
return (
<li key={course.name} style={i > 0 ? { borderTop: `1px solid ${C.border}` } : undefined}>
<li key={course.name} className={cn(i > 0 && "border-t border-clubhouse-border")}>
<Link
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
style={{ outlineColor: C.accent }}
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out hover:bg-clubhouse-field focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
<MapPin aria-hidden="true" className="size-5" color={C.ink} />
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-tee/12 text-tee-strong">
<MapPin aria-hidden="true" className="size-5" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
{course.name}
</span>
<span className="truncate text-sm font-medium" style={{ color: C.muted }}>
<span className="truncate text-base font-bold text-clubhouse-ink">{course.name}</span>
<span className="truncate text-sm font-medium text-clubhouse-muted">
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-5 shrink-0" color={C.muted} />
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-clubhouse-muted" />
</Link>
</li>
)
@ -1243,46 +1248,38 @@ function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
<section aria-label="Venner">
<SectionHeader title="Venner" />
{friends.length > 0 ? (
<div
className="flex flex-col gap-4 rounded-2xl p-4 sm:flex-row sm:items-center sm:justify-between"
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
<div className="flex flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center">
{visible.map((friend, i) => (
<span
key={i}
className="flex size-11 items-center justify-center rounded-full text-sm font-bold"
style={{ backgroundColor: C.tint, color: C.ink, border: `2px solid ${C.card}`, marginLeft: i > 0 ? "-0.75rem" : undefined }}
className="flex size-11 items-center justify-center rounded-full bg-clubhouse-field text-sm font-bold text-clubhouse-ink ring-2 ring-clubhouse-card"
style={{ marginLeft: i > 0 ? "-0.75rem" : undefined }}
>
{friendInitials(friend.first_name, friend.last_name)}
</span>
))}
{overflow > 0 && (
<span
className="flex size-11 items-center justify-center rounded-full text-xs font-bold tabular-nums"
style={{ backgroundColor: C.accent, color: C.inkOn, border: `2px solid ${C.card}`, marginLeft: "-0.75rem" }}
className="flex size-11 items-center justify-center rounded-full bg-clubhouse-ink text-xs font-bold tabular-nums text-white ring-2 ring-clubhouse-card"
style={{ marginLeft: "-0.75rem" }}
>
+{overflow}
</span>
)}
</div>
<div className="flex flex-col">
<span className="text-base font-bold" style={{ color: C.ink }}>
<span className="text-base font-bold text-clubhouse-ink">
{friends.length} {friends.length === 1 ? "venn" : "venner"}
</span>
{pending > 0 ? (
<span
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ backgroundColor: C.warmBg, color: C.warmInk }}
>
<span aria-hidden="true" className="size-1.5 rounded-full" style={{ backgroundColor: C.warmInk }} />
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-cup/15 px-2.5 py-0.5 text-xs font-bold text-cup-strong">
<span aria-hidden="true" className="size-1.5 rounded-full bg-cup-strong" />
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
</span>
) : (
<span className="text-sm font-medium" style={{ color: C.muted }}>
Ingen nye forespørsler
</span>
<span className="text-sm font-medium text-clubhouse-muted">Ingen nye forespørsler</span>
)}
</div>
</div>
@ -1305,10 +1302,7 @@ function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
return (
<div className="flex justify-center pt-2">
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
style={{ color: C.muted, outlineColor: C.accent }}
>
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-clubhouse-muted transition-all duration-200 ease-in-out hover:text-clubhouse-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong">
<Building2 aria-hidden="true" className="size-4" />
Dine organisasjoner ({organizations.length})
</DropdownMenuTrigger>

View file

@ -4,6 +4,8 @@ import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { BottomTabBar } from "@/components/dashboard"
/** Rå feed-oppføring fra backend. */
export type ApiFeedEntry = {
@ -18,6 +20,8 @@ export type ApiFeedEntry = {
body: string | null
image_url: string | null
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
}
const PAGE_SIZE = 20
@ -119,7 +123,7 @@ export function Feed() {
</div>
</header>
<main className="mx-auto w-full max-w-xl px-4 pb-10">
<main className="mx-auto w-full max-w-xl px-4 pb-24">
{status === "loading" ? (
<div className="flex justify-center py-16" aria-live="polite" aria-busy="true">
<span
@ -180,6 +184,8 @@ export function Feed() {
</>
)}
</main>
<BottomTabBar active="more" />
</div>
)
}
@ -188,44 +194,57 @@ function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId
const isOthersRound = entry.round_owner_user_id !== currentUserId
return (
<Link
href={`/my-rounds/${entry.round_id}`}
className="flex flex-col gap-3 py-5 transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{/* Kontekstlinje */}
<p className="text-sm text-muted-foreground text-pretty">
<span className="font-semibold text-foreground">{entry.author_display_name}</span>
{" på "}
{entry.course_name}
{" · "}
{formatPlayedAt(entry.played_at)}
</p>
{/* Eierkontekst kun for andres runder */}
{isOthersRound && (
<p className="-mt-1.5 text-sm text-muted-foreground">
{entry.round_owner_display_name} sin runde
<div className="flex flex-col gap-3 py-5">
{/* Selve innholdet er lenken til runden -- reaksjons-/kommentarkontrollene
under IKKE havne inni en <a> (ugyldig nøstet interaktivt innhold,
og ville trigget navigering ved hvert tastetrykk/klikk der). */}
<Link
href={isOthersRound ? `/watch/${entry.round_id}` : `/my-rounds/${entry.round_id}`}
className="flex flex-col gap-3 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{/* Kontekstlinje */}
<p className="text-sm text-muted-foreground text-pretty">
<span className="font-semibold text-foreground">{entry.author_display_name}</span>
{" på "}
{entry.course_name}
{" · "}
{formatPlayedAt(entry.played_at)}
</p>
)}
{/* Bilde */}
{entry.image_url && (
<img
src={entry.image_url || "/placeholder.svg"}
alt=""
className="w-full rounded-xl object-cover"
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
loading="lazy"
/>
)}
{/* Eierkontekst kun for andres runder */}
{isOthersRound && (
<p className="-mt-1.5 text-sm text-muted-foreground">
{entry.round_owner_display_name} sin runde
</p>
)}
{/* Tekst */}
{entry.body && (
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
{entry.body}
</p>
)}
</Link>
{/* Bilde */}
{entry.image_url && (
<img
src={entry.image_url || "/placeholder.svg"}
alt=""
className="w-full rounded-xl object-cover"
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
loading="lazy"
/>
)}
{/* Tekst */}
{entry.body && (
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
{entry.body}
</p>
)}
</Link>
<PostEngagement
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
currentUserId={currentUserId}
initialReactions={entry.reactions}
initialCommentCount={entry.comment_count}
canModerate={entry.round_owner_user_id === currentUserId}
/>
</div>
)
}

View file

@ -23,6 +23,7 @@ import {
import { Input } from "@/components/ui/input"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
import { BottomTabBar } from "@/components/dashboard"
// --- Kategorier --------------------------------------------------------------
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
@ -161,7 +162,7 @@ export function Friends() {
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-8 sm:pt-10">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
Mine venner
@ -184,6 +185,8 @@ export function Friends() {
<FriendsSection friends={data?.friends ?? []} onChanged={loadFriends} />
</div>
</main>
<BottomTabBar active="more" />
</div>
)
}

View file

@ -88,8 +88,8 @@ export function InstallPrompt() {
setDismissed(true)
}
// "Forest Green"-visuell redesign (2026-08-01) -- samme palett som
// dashboard.tsx/login-form.tsx sin C-konstant, inline-anvendt siden dette
// "Clubhouse"-visuell redesign (2026-08-07) -- samme `--tee`/`--tee-strong`-
// familie som resten av dashbordets nye reskinn, inline-anvendt siden dette
// er den eneste komponenten som trenger den. All logikk over uendret.
return (
<div
@ -97,16 +97,16 @@ export function InstallPrompt() {
aria-label="Installer TeeCup"
className="relative overflow-hidden rounded-2xl p-4 sm:p-5"
style={{
background: "linear-gradient(180deg, #1a4325 0%, #123019 100%)",
boxShadow: "0 18px 40px -22px rgba(1, 44, 17, 0.5)",
background: "linear-gradient(180deg, #2f6b1e 0%, #1c4212 100%)",
boxShadow: "0 18px 40px -22px rgba(23, 32, 15, 0.5)",
}}
>
<div className="flex items-start gap-3.5">
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: "rgba(255,255,255,0.12)" }}>
{platform === "ios" ? (
<Share aria-hidden="true" className="size-5" color="#bfeec4" />
<Share aria-hidden="true" className="size-5" color="#d8f0c4" />
) : (
<Download aria-hidden="true" className="size-5" color="#bfeec4" />
<Download aria-hidden="true" className="size-5" color="#d8f0c4" />
)}
</span>
@ -116,13 +116,13 @@ export function InstallPrompt() {
Legg TeeCup til hjemskjermen
</h2>
{platform === "ios" ? (
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i
Safari, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
«Legg til Hjem-skjerm».
</p>
) : (
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#bfeec4" }}>
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
Rask tilgang, fungerer som en egen app, og virker delvis uten nett.
</p>
)}
@ -134,7 +134,7 @@ export function InstallPrompt() {
type="button"
onClick={handleInstall}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 text-sm font-bold transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
style={{ backgroundColor: "#ffffff", color: "#012c11" }}
style={{ backgroundColor: "#ffffff", color: "#17200f" }}
>
<Download aria-hidden="true" className="size-4" />
Installer
@ -144,7 +144,7 @@ export function InstallPrompt() {
type="button"
onClick={handleDismiss}
className="inline-flex min-h-[44px] items-center rounded-xl px-3 text-sm font-semibold transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
style={{ color: "#bfeec4" }}
style={{ color: "#d8f0c4" }}
>
Ikke
</button>
@ -157,7 +157,7 @@ export function InstallPrompt() {
onClick={handleDismiss}
aria-label="Lukk installasjonsoppfordringen"
className="absolute right-2 top-2 flex size-9 items-center justify-center rounded-lg transition-colors hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
style={{ color: "#bfeec4" }}
style={{ color: "#d8f0c4" }}
>
<X aria-hidden="true" className="size-4" />
</button>

View file

@ -3331,8 +3331,20 @@ function Step5({
{visibility === "venner" && (
<div className="flex flex-col gap-3">
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
Alle er forhåndsvalgt velg heller bort det du ikke vil skal se den.
Alle er forhåndsvalgt velg heller bort det du ikke vil skal se den. Bruk "Ingen"/"Alle"
under for å starte nytt, og finjuster deretter med enkeltkategoriene.
</p>
<div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
<Pill selected={categories.size === 0} onClick={() => setCategories(new Set())}>
Ingen
</Pill>
<Pill
selected={categories.size === CATEGORY_OPTIONS.length}
onClick={() => setCategories(new Set(CATEGORY_OPTIONS.map((c) => c.code)))}
>
Alle
</Pill>
</div>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(({ code, label }) => (
<Pill key={code} selected={categories.has(code)} onClick={() => toggleCategory(code)}>

View file

@ -16,6 +16,7 @@ import {
} from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
import { BottomTabBar } from "@/components/dashboard"
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
@ -157,6 +158,8 @@ export function Notifications() {
</ul>
)}
</main>
<BottomTabBar active="more" />
</div>
)
}

View file

@ -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() {
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-8 sm:pt-10">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
@ -179,6 +180,8 @@ export function OwnRounds() {
)}
</div>
</main>
<BottomTabBar active="rounds" />
</div>
)
}

View file

@ -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<string> {
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<ReactionSummary[]>(initialReactions)
const [reactionError, setReactionError] = useState<string | null>(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<CommentOut[]>([])
const [fetched, setFetched] = useState(false)
const [loading, setLoading] = useState(false)
const [threadError, setThreadError] = useState<string | null>(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<string, CommentOut[]>()
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<string>([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<string | null>(null)
/* Hvem-reagerte-detalj (2026-08-07) dataen er allerede i reactionRow
* (samme batch-henting som selve tellingen), 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 (
<section className="flex flex-col gap-4 pt-2" aria-label="Reaksjoner og kommentarer">
{/* ---------------------------- Reactions ---------------------------- */}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-1 rounded-full bg-muted p-1" role="group" aria-label="Reaksjoner">
{reactionRow.map((r) => {
const active = r.reacted_by_me
return (
<button
key={r.emoji}
type="button"
onClick={() => toggleReaction(r)}
disabled={!loggedIn || reactionPending}
aria-pressed={active}
aria-label={`Reager med ${r.emoji}${r.count > 0 ? `, ${r.count}` : ""}`}
className={cn(
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-full px-3 text-lg leading-none transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
"enabled:hover:brightness-95 enabled:active:scale-[0.98] disabled:cursor-default disabled:opacity-70",
active
? "bg-primary text-primary-foreground"
: "border border-border bg-card text-foreground",
)}
>
<span aria-hidden="true">{r.emoji}</span>
{r.count > 0 && (
<span
className={cn(
"text-sm font-bold tabular-nums",
active ? "text-primary-foreground" : "text-muted-foreground",
)}
>
{r.count}
</span>
)}
</button>
)
})}
</div>
{reactionError && (
<p role="alert" className="px-1 text-sm font-semibold text-destructive">
{reactionError}
</p>
)}
{/* Hvem reagerte -- alltid synlig sammendrag (ikke bare hover/tap,
tilgjengelighetsregelen), utvidbart til full per-emoji-liste. */}
{allReactorNames.length > 0 && (
<div className="flex flex-col gap-1 px-1">
<button
type="button"
onClick={() => setShowReactors((v) => !v)}
aria-expanded={showReactors}
className="flex min-h-11 w-fit items-center text-left text-sm text-muted-foreground hover:text-foreground hover:underline"
>
{formatReactorSummary(allReactorNames)}
</button>
{showReactors && (
<ul className="flex flex-col gap-1">
{activeReactionGroups.map((r) => (
<li key={r.emoji} className="flex items-start gap-2 text-sm text-foreground">
<span aria-hidden="true">{r.emoji}</span>
<span>{r.reactors.join(", ")}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
{/* ------------------------ Expand / count ------------------------ */}
<div>
<Button
type="button"
variant="ghost"
onClick={toggleExpanded}
aria-expanded={expanded}
className="h-11 gap-2 rounded-xl px-3 text-base font-bold text-foreground hover:bg-accent/50 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
<MessageSquare aria-hidden="true" className="size-5" />
Kommentarer ({commentCount})
</Button>
</div>
{/* ---------------------------- Thread ---------------------------- */}
{expanded && (
<div className="flex flex-col gap-4">
{loading && (
<div className="flex justify-center py-6">
<span
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
role="status"
aria-label="Laster kommentarer"
/>
</div>
)}
{threadError && !loading && (
<div className="flex flex-col gap-2">
<p role="alert" className="text-sm font-semibold text-destructive">
{threadError}
</p>
<Button
type="button"
variant="outline"
onClick={() => void loadComments()}
className="h-11 w-fit rounded-xl text-base font-bold active:scale-[0.98]"
>
Prøv igjen
</Button>
</div>
)}
{!loading && !threadError && fetched && (
<>
{roots.length === 0 ? (
<p className="py-2 text-base text-muted-foreground">Ingen kommentarer ennå.</p>
) : (
<ul className="flex flex-col gap-4">
{roots.map((c) => (
<CommentNode
key={c.id}
comment={c}
depth={0}
childrenOf={childrenOf}
apiBase={apiBase}
currentUserId={currentUserId}
canModerate={canModerate}
replyingTo={replyingTo}
setReplyingTo={setReplyingTo}
onAppend={appendComment}
onRemoveSubtree={removeSubtree}
/>
))}
</ul>
)}
{/* Bottom composer (top-level) */}
{loggedIn ? (
<CommentComposer
apiBase={apiBase}
parentCommentId={null}
onPosted={appendComment}
autoFocus={false}
/>
) : (
<p className="text-base text-muted-foreground">Logg inn for å kommentere.</p>
)}
</>
)}
</div>
)}
</section>
)
}
/* ------------------------------------------------------------------ */
/* Single comment (recursive) */
/* ------------------------------------------------------------------ */
function CommentNode({
comment,
depth,
childrenOf,
apiBase,
currentUserId,
canModerate,
replyingTo,
setReplyingTo,
onAppend,
onRemoveSubtree,
}: {
comment: CommentOut
depth: number
childrenOf: Map<string, CommentOut[]>
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<string | null>(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 (
<li
className={cn(indented && "border-l-2 border-border pl-4")}
>
<div className="flex flex-col gap-2">
{/* Author + timestamp */}
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-base font-bold text-foreground">{comment.author_display_name}</span>
<span className="text-sm tabular-nums text-muted-foreground">
{formatTimestamp(comment.created_at)}
</span>
</div>
{/* Body */}
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-foreground">
{comment.body}
</p>
{/* Actions */}
<div className="flex flex-wrap items-center gap-1">
{loggedIn && (
<Button
type="button"
variant="ghost"
onClick={() => setReplyingTo(isReplyOpen ? null : comment.id)}
aria-expanded={isReplyOpen}
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-muted-foreground hover:bg-accent/50 hover:text-foreground active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
<Reply aria-hidden="true" className="size-4" />
Svar
</Button>
)}
{canDelete && !confirming && (
<Button
type="button"
variant="ghost"
onClick={() => setConfirming(true)}
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-destructive hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett
</Button>
)}
{canDelete && confirming && (
<div className="flex flex-wrap items-center gap-1">
<Button
type="button"
variant="ghost"
onClick={handleDelete}
disabled={deleting}
className="h-11 gap-1.5 rounded-xl px-3 text-sm font-bold text-destructive hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 aria-hidden="true" className="size-4" />
Sikker?
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setConfirming(false)}
disabled={deleting}
className="h-11 rounded-xl px-3 text-sm font-bold text-foreground hover:bg-accent/50 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
Avbryt
</Button>
{hasReplies && (
<p className="basis-full text-sm text-muted-foreground text-pretty">
Sletter du denne, forsvinner også svarene under.
</p>
)}
</div>
)}
</div>
{deleteError && (
<p role="alert" className="text-sm font-semibold text-destructive">
{deleteError}
</p>
)}
{/* Inline reply composer */}
{isReplyOpen && loggedIn && (
<CommentComposer
apiBase={apiBase}
parentCommentId={comment.id}
autoFocus
onPosted={(c) => {
onAppend(c)
setReplyingTo(null)
}}
onCancel={() => setReplyingTo(null)}
/>
)}
</div>
{/* Children */}
{hasReplies && (
<ul className="mt-4 flex flex-col gap-4">
{children.map((child) => (
<CommentNode
key={child.id}
comment={child}
depth={cappedForChildren + 1}
childrenOf={childrenOf}
apiBase={apiBase}
currentUserId={currentUserId}
canModerate={canModerate}
replyingTo={replyingTo}
setReplyingTo={setReplyingTo}
onAppend={onAppend}
onRemoveSubtree={onRemoveSubtree}
/>
))}
</ul>
)}
</li>
)
}
/* ------------------------------------------------------------------ */
/* 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<string | null>(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 (
<div className="flex flex-col gap-2">
<textarea
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
autoFocus={autoFocus}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
// Cmd/Ctrl+Enter submits; plain Enter keeps newlines. Respect IME.
if (
(e.metaKey || e.ctrlKey) &&
e.key === "Enter" &&
!e.nativeEvent.isComposing &&
e.keyCode !== 229
) {
e.preventDefault()
void submit()
}
}}
rows={parentCommentId ? 2 : 3}
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
className="min-h-11 w-full resize-y rounded-2xl border border-border bg-card px-4 py-3 text-base leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
/>
{error && (
<p role="alert" className="text-sm font-semibold text-destructive">
{error}
</p>
)}
<div className="flex items-center gap-2">
<Button
type="button"
onClick={() => void submit()}
disabled={empty || submitting}
className="h-11 rounded-xl px-5 text-base font-bold active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
{submitting ? "Sender …" : "Send"}
</Button>
{onCancel && (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
className="h-11 rounded-xl px-4 text-base font-bold text-muted-foreground hover:bg-accent/50 hover:text-foreground active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring"
>
Avbryt
</Button>
)}
</div>
</div>
)
}
export default PostEngagement

View file

@ -5,6 +5,7 @@ import { useEffect, useState } from "react"
import Link from "next/link"
import {
Calendar,
Camera,
Users,
ChevronDown,
ChevronRight,
@ -24,6 +25,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "@/components/post-engagement"
// --- Types -----------------------------------------------------------------
// Interne visningstyper -- matcher ikke API-et 1:1 (se ApiTournamentInfo/
@ -286,6 +288,8 @@ type ApiFeedMessage = {
body: string | null
image_url: string | null
created_at: string
reactions: ReactionSummary[]
comment_count: number
}
const FEED_TIME_FMT = new Intl.DateTimeFormat("no-NO", {
@ -308,8 +312,25 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
const [image, setImage] = useState<File | null>(null)
const [sending, setSending] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
// Kun brukt til å avgjøre om PostEngagement skal vise reaksjons-/
// kommentarfeltet som innlogget (samme "beste innsats" som feed.tsx --
// selve autorisasjonen håndheves av backend uansett).
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setCurrentUserId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
fetch(`/public/tournaments/${tournamentId}/feed${codeParam}`, { credentials: "include" })
@ -331,7 +352,8 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
socket.onmessage = (event) => {
try {
const msg: ApiFeedMessage = JSON.parse(event.data)
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
} catch {
// ignorer ugyldig payload
}
@ -362,18 +384,43 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
setPostError("Du må være medlem av arrangøren eller delta i turneringen for å poste.")
return
}
if (!res.ok) throw new Error(`post: ${res.status}`)
if (!res.ok) {
let message = "Klarte ikke å poste. Prøv igjen."
try {
const data = await res.json()
if (typeof data?.detail?.message === "string") message = data.detail.message
} catch {
// Ikke JSON -- behold standardmeldingen.
}
setPostError(message)
return
}
const msg: ApiFeedMessage = await res.json()
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
setText("")
setImage(null)
} catch {
setPostError("Klarte ikke å poste. Prøv igjen.")
setPostError("Klarte ikke å poste. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSending(false)
}
}
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
// klient-side for en umiddelbar, presis feilmelding.
function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 20 * 1024 * 1024) {
setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
e.target.value = ""
return
}
setPostError(null)
setImage(file)
}
return (
<section aria-label="Runde-feed" className="flex flex-col gap-3 border-t border-border pt-6">
<div className="flex items-center gap-2">
@ -406,6 +453,14 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
<img src={m.image_url} alt="" className="mb-2 max-h-72 w-full rounded-xl object-cover" />
)}
{m.body && <p className="text-sm leading-relaxed text-foreground text-pretty">{m.body}</p>}
<PostEngagement
apiBase={`/public/tournaments/${tournamentId}/feed/${m.id}`}
currentUserId={currentUserId}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={false}
/>
</div>
))}
</div>
@ -426,15 +481,35 @@ function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: s
</div>
)}
<div className="flex items-center gap-2">
{/* To atskilte inputs -- Android sin systembilde-vlger (Photo
Picker) viser ALDRI et kamera-alternativ når accept="image/*"
mangler capture, uansett nettleser (funnet 2026-08-06, ekte
enhet). */}
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
accept="image/*"
capture="environment"
id="feed-camera-input"
className="hidden"
onChange={handleImageSelected}
/>
<label
htmlFor="feed-camera-input"
aria-label="Ta bilde"
className="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<Camera aria-hidden="true" className="size-5" />
</label>
<input
type="file"
accept="image/*"
id="feed-image-input"
className="hidden"
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
onChange={handleImageSelected}
/>
<label
htmlFor="feed-image-input"
aria-label="Velg bilde fra galleri"
className="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<ImagePlus aria-hidden="true" className="size-5" />

View file

@ -551,6 +551,16 @@ export function RoundDetail({ roundId }: { roundId: string }) {
void loadRound()
}, [loadRound])
// Anker-scroll til kommentarseksjonen (ADR-047) -- leaderboardets nye
// "Kommentarer"-lenke peker hit, men elementet finnes ikke i DOM-en før
// `round` er lastet, så nettleserens EGEN anker-scroll (som skjer FØR
// dette) rekker ikke frem. Trigges på nytt manuelt her når dataen er der.
useEffect(() => {
if (!round) return
if (window.location.hash !== "#kommentarer") return
document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
}, [round])
// Løpende matchstatus/skins-tavle (ADR-039) -- avledet ved LESING (aldri
// lagret), delt av FormatResultPanel OG ScorecardGrid/SideScorecardGrid
// (per-hull-fargelegging + Stilling-rad, 2026-07-29). Slagspill/Stableford
@ -1592,7 +1602,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)}
{round && viewerId && (
<div className="mt-6">
// Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
// rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat.
<div id="kommentarer" className="mt-6 scroll-mt-20">
<RoundMessages
roundId={roundId}
currentUserId={viewerId}
@ -1916,41 +1928,13 @@ function ScoringWizard({
{step === "details" && (
<>
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{player.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = stat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onUpdate({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={stat.club}
onChange={(e) => onUpdate({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
<ClubPicker
label="Kølle brukt ved utslaget"
value={stat.club}
onChange={(club) => onUpdate({ club })}
ownBagClubs={player.isSelf ? ownBagClubs : []}
readOnly={readOnly}
/>
{hole.par !== 3 && (
<DirectionCross
@ -4190,6 +4174,34 @@ function EditRoundPanel({
<p className="text-xs font-semibold text-muted-foreground">
Hvilke av dine venne-kategorier skal kunne se runden?
</p>
<div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
<button
type="button"
onClick={() => setVisibleCategories([])}
aria-pressed={visibleCategories.length === 0}
className={cn(
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
visibleCategories.length === 0
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
)}
>
Ingen
</button>
<button
type="button"
onClick={() => setVisibleCategories(VISIBILITY_CATEGORY_OPTIONS.map((c) => c.code))}
aria-pressed={visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length}
className={cn(
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
visibleCategories.length === VISIBILITY_CATEGORY_OPTIONS.length
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
)}
>
Alle
</button>
</div>
<div className="flex flex-wrap gap-2">
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
const active = visibleCategories.includes(code)
@ -4215,6 +4227,11 @@ function EditRoundPanel({
)
})}
</div>
{visibleCategories.length === 0 && (
<p className="rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm font-semibold text-destructive">
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
</p>
)}
</div>
)}
</div>
@ -6238,6 +6255,63 @@ function DirectionCross({
)
}
// --- Kølle-plukker -----------------------------------------------------
// Delt mellom ScoringWizard sitt "details"-steg (utslags-kølle) og
// ShotMeasurementSheet (ADR-048, slag-for-slag måling) -- ren utrekking,
// ingen atferdsendring for veiviserens eksisterende bruk.
function ClubPicker({
value,
onChange,
ownBagClubs,
readOnly = false,
label = "Kølle",
}: {
value: string
onChange: (club: string) => void
ownBagClubs: string[]
readOnly?: boolean
label?: string
}) {
return (
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">{label}</Label>
{ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = value === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onChange(club)}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
function ChoiceRow({

View file

@ -22,9 +22,10 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, Trophy } from "lucide-react"
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
import { MatchScorecardGrid } from "@/components/round-scorecard"
// --- API-typer ---------------------------------------------------------------
@ -61,6 +62,18 @@ type ApiRoundParticipant = {
user_id: string | null
round_side_id: string | null
handicap_index_snapshot: number | null
// Felt under er allerede returnert av samme `${apiPrefix}/{roundId}`-
// endepunkt (samme JSON som round-scorecard.tsx sin `ApiParticipant`
// fetcher, se der) -- bare ikke tidligere typet her, siden de ikke
// trengtes før MatchScorecardGrid-gjenbruken (ny utvid-seksjon i
// TwoSidedBoard/HighLowBoard). `guest_name` er valgfritt -- fraværende i
// publicMode (PublicRoundParticipantOut har ikke feltet), defaultes til
// null der (se toGridRound under) -- ubrukt av MatchScorecardGrid uansett
// (den leser kun display_name).
guest_name?: string | null
display_name: string
is_owner: boolean
playing_handicap: number | null
}
type ApiSide = {
@ -75,6 +88,9 @@ type ApiRoundHeader = {
id: string
name: string | null
course_name_snapshot: string
// Samme "allerede i JSON-en, bare ikke tidligere typet"-begrunnelse som
// ApiRoundParticipant sine nye felt over.
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
@ -427,12 +443,14 @@ function IndividualBoard({
defaultMetric,
pointsUnit = "p",
holes,
commentsHref,
}: {
players: IndividualPlayer[]
metrics: MetricKey[]
defaultMetric: MetricKey
pointsUnit?: string
holes: Hole[]
commentsHref: string
}) {
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
const [openId, setOpenId] = useState<string | null>(null)
@ -468,6 +486,7 @@ function IndividualBoard({
holes={holes}
open={openId === e.player.id}
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
commentsHref={commentsHref}
/>
))}
{unranked.map((e) => (
@ -483,6 +502,7 @@ function IndividualBoard({
holes={holes}
open={openId === e.player.id}
onToggle={() => setOpenId((cur) => (cur === e.player.id ? null : e.player.id))}
commentsHref={commentsHref}
/>
))}
</ol>
@ -539,6 +559,7 @@ function IndividualRow({
holes,
open,
onToggle,
commentsHref,
}: {
player: IndividualPlayer
rank: number | null
@ -550,6 +571,7 @@ function IndividualRow({
holes: Hole[]
open: boolean
onToggle: () => void
commentsHref: string
}) {
const played = playedHoles(player)
const totalHoles = holes.length
@ -595,16 +617,33 @@ function IndividualRow({
{open && (
<div id={panelId} className="px-3 pb-3">
<HoleByHole player={player} metric={metric} holes={holes} />
<HoleByHole player={player} metric={metric} holes={holes} rank={rank} tied={tied} commentsHref={commentsHref} />
</div>
)}
</li>
)
}
function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metric: MetricKey; holes: Hole[] }) {
function HoleByHole({
player,
metric,
holes,
rank,
tied,
commentsHref,
}: {
player: IndividualPlayer
metric: MetricKey
holes: Hole[]
rank: number | null
tied: boolean
commentsHref: string
}) {
const front = holes.slice(0, 9)
const back = holes.slice(9)
const totalPar = holes.reduce((s, h) => s + h.par, 0)
const totalGross = player.gross.reduce<number>((s, g) => s + (g ?? 0), 0)
const holesWithScore = player.gross.filter((g) => g !== null).length
return (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
<p className="text-xs font-semibold text-muted-foreground">
@ -675,6 +714,20 @@ function HoleByHole({ player, metric, holes }: { player: IndividualPlayer; metri
</div>
)
})}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 border-t border-border pt-2">
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
Par {totalPar} · Score {holesWithScore > 0 ? totalGross : ""}
{rank !== null ? ` · ${tied ? "Delt " : ""}${rank}. plass` : ""}
</span>
<Link
href={commentsHref}
className="inline-flex h-11 items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
>
<MessageSquare aria-hidden="true" className="size-4" />
Kommentarer
</Link>
</div>
</div>
)
}
@ -722,6 +775,12 @@ function TwoSidedBoard({
leader,
isDormie,
isClosed,
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
@ -733,7 +792,14 @@ function TwoSidedBoard({
leader: "a" | "b" | null
isDormie: boolean
isClosed: boolean
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
const [showDetail, setShowDetail] = useState(false)
const holesPlayed = results.filter((r) => r !== null).length
let aWins = 0
let bWins = 0
@ -778,6 +844,17 @@ function TwoSidedBoard({
</div>
<HoleWinners sideA={sideA} sideB={sideB} results={results} totalHoles={totalHoles} />
<MatchDetailSection
showDetail={showDetail}
onToggle={() => setShowDetail((v) => !v)}
round={round}
roundId={roundId}
viewerId={viewerId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
</div>
)
}
@ -834,6 +911,72 @@ function HoleWinners({ sideA, sideB, results, totalHoles }: { sideA: SideTeamInf
)
}
// Delt av TwoSidedBoard/HighLowBoard (ADR-047) -- ÉN utvidbar detaljseksjon
// for HELE kampen (ikke et per-rad-mønster, siden formatet kun har 2 sider,
// ikke en spillerliste). Viser faktiske hull-for-hull-SCORER (ikke bare
// hvem som vant hullet, som HoleWinners/DuelRow allerede gjør statisk over)
// via den allerede eksisterende, nå eksporterte MatchScorecardGrid --
// samme scorekort som `/my-rounds/{id}/scorecard` viser, ingen ny visuell
// design. `MatchScorecardGrid` gjør sin egen `useFormatResult`-henting
// internt, så dette henter kun ved faktisk utvidelse, ikke ved mount.
// `MatchScorecardGrid` (round-scorecard.tsx) sin `ApiParticipant.guest_name`
// er required (string|null), men `publicMode` sin `PublicRoundParticipantOut`
// har ikke feltet i det hele tatt (undefined ved kjøretid) -- defaultes til
// null her. Trygt: MatchScorecardGrid leser aldri guest_name (kun
// display_name via participantLabel), så dette endrer ingen visning.
function toGridRound(round: ApiRoundHeader) {
return {
...round,
participants: round.participants.map((p) => ({ ...p, guest_name: p.guest_name ?? null })),
}
}
function MatchDetailSection({
showDetail,
onToggle,
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
}: {
showDetail: boolean
onToggle: () => void
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
return (
<div className="rounded-2xl border border-border bg-card">
<button
type="button"
onClick={onToggle}
aria-expanded={showDetail}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="text-sm font-bold text-foreground">Vis hull-for-hull med score</span>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", showDetail && "rotate-180")} />
</button>
{showDetail && (
<div className="flex flex-col gap-3 border-t border-border p-3">
<MatchScorecardGrid round={toGridRound(round)} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
<Link
href={commentsHref}
className="inline-flex h-11 w-fit items-center gap-1.5 rounded-lg px-1 text-sm font-bold text-primary hover:underline active:scale-[0.98]"
>
<MessageSquare aria-hidden="true" className="size-4" />
Kommentarer
</Link>
</div>
)}
</div>
)
}
// ===========================================================================
// 3. HIGH-LOW-HIGH -- to duller per hull, poeng per side (IKKE hull-opp)
// ===========================================================================
@ -848,6 +991,12 @@ function HighLowBoard({
totalA,
totalB,
formatName,
round,
roundId,
viewerId,
refreshKey,
apiPrefix,
commentsHref,
}: {
sideA: SideTeamInfo
sideB: SideTeamInfo
@ -855,7 +1004,14 @@ function HighLowBoard({
totalA: number
totalB: number
formatName: string
round: ApiRoundHeader
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix: string
commentsHref: string
}) {
const [showDetail, setShowDetail] = useState(false)
const leader = totalA > totalB ? "a" : totalB > totalA ? "b" : null
const fmt = (n: number) => NF1.format(n)
return (
@ -905,6 +1061,17 @@ function HighLowBoard({
<span className="flex items-center gap-1.5"><span className="flex size-5 items-center justify-center rounded border border-border bg-muted text-[10px] font-bold">=</span>Delt (½½)</span>
</div>
</div>
<MatchDetailSection
showDetail={showDetail}
onToggle={() => setShowDetail((v) => !v)}
round={round}
roundId={roundId}
viewerId={viewerId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
</div>
)
}
@ -970,6 +1137,7 @@ function TeamFlightBoard({
members,
holes,
formatName,
commentsHref,
}: {
teamName: string
toPar: number | null
@ -979,8 +1147,10 @@ function TeamFlightBoard({
members: IndividualPlayer[]
holes: Hole[]
formatName: string
commentsHref: string
}) {
const [showRaw, setShowRaw] = useState(false)
const [openMemberId, setOpenMemberId] = useState<string | null>(null)
const ranked = [...flight].sort((a, b) => a.toPar - b.toPar)
const myRank = ranked.findIndex((t) => t.isThisTeam) + 1
@ -1041,18 +1211,15 @@ function TeamFlightBoard({
{showRaw && (
<ol className="flex flex-col border-t border-border">
{members.map((m) => (
<li key={m.id} className="flex min-h-14 items-center gap-3 border-b border-border px-4 py-2 last:border-b-0">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
{m.name}
{m.isSelf && <Badge tone="self">Deg</Badge>}
</span>
<span className="text-sm text-muted-foreground">
{m.hcpIndex !== null ? `Hcp ${NF1.format(m.hcpIndex)}` : "Uten HCP"} · {playedHoles(m)}/{totalHoles} hull
</span>
</span>
<MetricPill kind="topar" value={grossToPar(m, holes)} />
</li>
<TeamMemberRow
key={m.id}
member={m}
holes={holes}
totalHoles={totalHoles}
open={openMemberId === m.id}
onToggle={() => setOpenMemberId((cur) => (cur === m.id ? null : m.id))}
commentsHref={commentsHref}
/>
))}
</ol>
)}
@ -1061,6 +1228,56 @@ function TeamFlightBoard({
)
}
// Utvidbar rad for ETT medlem av flighten (ADR-047) -- samme mønster
// (klikk → hull-for-hull via HoleByHole) som IndividualRow, men uten
// rangeringskolonnen (et lags medlemmer rangeres ikke seg imellom her,
// kun laget som helhet gjør, vist i flight-gruppen over).
function TeamMemberRow({
member,
holes,
totalHoles,
open,
onToggle,
commentsHref,
}: {
member: IndividualPlayer
holes: Hole[]
totalHoles: number
open: boolean
onToggle: () => void
commentsHref: string
}) {
const panelId = `tfm-${member.id}`
return (
<li className="border-b border-border last:border-b-0">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-controls={panelId}
className="flex min-h-14 w-full items-center gap-3 px-4 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 truncate text-base font-bold text-foreground">
{member.name}
{member.isSelf && <Badge tone="self">Deg</Badge>}
</span>
<span className="text-sm text-muted-foreground">
{member.hcpIndex !== null ? `Hcp ${NF1.format(member.hcpIndex)}` : "Uten HCP"} · {playedHoles(member)}/{totalHoles} hull
</span>
</span>
<MetricPill kind="topar" value={grossToPar(member, holes)} />
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
{open && (
<div id={panelId} className="px-4 pb-3">
<HoleByHole player={member} metric="brutto" holes={holes} rank={null} tied={false} commentsHref={commentsHref} />
</div>
)}
</li>
)
}
// ===========================================================================
// Datalag: fetch-hooks (uendret fra forrige runde)
// ===========================================================================
@ -1109,11 +1326,11 @@ type ApiFormatResult = {
holes: ApiFormatHole[] | null
}
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
fetch(`${apiPrefix}/${roundId}/format-result`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFormatResult | null) => {
if (!cancelled) setResult(data)
@ -1122,7 +1339,7 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
return () => {
cancelled = true
}
}, [roundId, refreshKey])
}, [roundId, refreshKey, apiPrefix])
return result
}
@ -1176,12 +1393,17 @@ function buildScrambleSoloResult(round: ApiRoundHeader, formatResult: ApiFormatR
type FlightSibling = { round_id: string; name: string | null; isAnchor: boolean; teamScore: number | null; par: number | null; through: number }
function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey: number) {
function useFlightTeamStandings(
roundId: string,
playFormat: string,
refreshKey: number,
apiPrefix: string = "/rounds",
) {
const [standings, setStandings] = useState<FlightSibling[] | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const flightRes = await fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
const flightRes = await fetch(`${apiPrefix}/${roundId}/flight-group`, { credentials: "include" })
if (!flightRes.ok) return
const flight: { flight_group_id: string | null; flights: { round_id: string; name: string | null; is_anchor: boolean }[] } =
await flightRes.json()
@ -1191,7 +1413,7 @@ function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey:
}
const results = await Promise.all(
flight.flights.map(async (f) => {
const r = await fetch(`/rounds/${f.round_id}/format-result`, { credentials: "include" })
const r = await fetch(`${apiPrefix}/${f.round_id}/format-result`, { credentials: "include" })
const data: ApiFormatResult | null = r.ok ? await r.json() : null
const teamScore = playFormat === "shamble" ? data?.shamble_team_score ?? null : data?.money_ball_team_score ?? null
const holes = data?.holes ?? []
@ -1205,16 +1427,17 @@ function useFlightTeamStandings(roundId: string, playFormat: string, refreshKey:
return () => {
cancelled = true
}
}, [roundId, playFormat, refreshKey])
}, [roundId, playFormat, refreshKey, apiPrefix])
return standings
}
function useLeaderboardData(roundId: string) {
function useLeaderboardData(roundId: string, apiPrefix: string = "/rounds") {
const [round, setRound] = useState<ApiRoundHeader | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
const [viewerId, setViewerId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const publicMode = apiPrefix !== "/rounds"
useEffect(() => {
let cancelled = false
@ -1231,17 +1454,18 @@ function useLeaderboardData(roundId: string) {
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
const wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
}, [roundId, publicMode])
useEffect(() => {
let cancelled = false
async function load() {
const [roundRes, leaderboardRes] = await Promise.all([
fetch(`/rounds/${roundId}`, { credentials: "include" }),
fetch(`/rounds/${roundId}/leaderboard`, { credentials: "include" }),
fetch(`${apiPrefix}/${roundId}`, { credentials: "include" }),
fetch(`${apiPrefix}/${roundId}/leaderboard`, { credentials: "include" }),
])
if (roundRes.status === 403 || roundRes.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
@ -1262,7 +1486,7 @@ function useLeaderboardData(roundId: string) {
return () => {
cancelled = true
}
}, [roundId, refreshKey])
}, [roundId, refreshKey, apiPrefix])
const viewerParticipantId = round?.participants.find((p) => p.user_id === viewerId)?.id ?? null
@ -1372,20 +1596,30 @@ const METRICS_FOR_FORMAT: Record<string, { metrics: MetricKey[]; defaultMetric:
export function RoundLeaderboard({
roundId,
embedded = false,
publicMode = false,
}: {
roundId: string
embedded?: boolean
// ADR-044 (tilskuer-visning, 2026-08-06) -- samme mønster som
// `RoundScorecard` sin `publicMode`: bytter alle `/rounds/*`-kall til
// `/public/rounds/*` og WS-kanalen til den offentlige. INGEN endring
// i eiersidens oppførsel når denne er `false` (standard).
publicMode?: boolean
}) {
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId)
const formatResult = useFormatResult(roundId, refreshKey)
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey)
const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
const { round, leaderboard, viewerParticipantId, error, refreshKey } = useLeaderboardData(roundId, apiPrefix)
const formatResult = useFormatResult(roundId, refreshKey, apiPrefix)
const standings = useFlightTeamStandings(roundId, round?.play_format ?? "", refreshKey, apiPrefix)
if (error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
<Link
href={publicMode ? "/dashboard" : "/my-rounds"}
className="text-base font-semibold text-primary underline underline-offset-2"
>
{publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
</Link>
</div>
)
@ -1399,6 +1633,14 @@ export function RoundLeaderboard({
)
}
// Delt kommentartråd (ADR-047) -- round_message-reaksjoner/-kommentarer
// fester seg til ETT spesifikt innlegg, ikke runden som helhet, så
// hver utvidet rad/kamp-seksjon lenker til den SAMME, allerede
// eksisterende kommentarseksjonen på selve rundesiden (anker-id
// "kommentarer" i round-detail.tsx/watch-round.tsx) i stedet for å late
// som om det finnes en egen tråd per spiller/kamp.
const commentsHref = publicMode ? `/watch/${roundId}#kommentarer` : `/my-rounds/${roundId}#kommentarer`
const holeOrder = buildHoleOrder(round.start_hole, round.holes_planned)
const holes = buildHoles(leaderboard.entries, holeOrder)
const isHighLow = HIGH_LOW_FORMATS.has(round.play_format)
@ -1413,7 +1655,7 @@ export function RoundLeaderboard({
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
@ -1458,6 +1700,12 @@ export function RoundLeaderboard({
totalA={formatResult.hlh_points_a ?? 0}
totalB={formatResult.hlh_points_b ?? 0}
formatName="High-low-high"
round={round}
roundId={roundId}
viewerId={viewerParticipantId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
)
})()
@ -1505,6 +1753,12 @@ export function RoundLeaderboard({
leader={leader}
isDormie={Boolean(formatResult.match_is_dormie)}
isClosed={Boolean(formatResult.match_is_closed)}
round={round}
roundId={roundId}
viewerId={viewerParticipantId}
refreshKey={refreshKey}
apiPrefix={apiPrefix}
commentsHref={commentsHref}
/>
)
})()
@ -1535,6 +1789,7 @@ export function RoundLeaderboard({
members={members}
holes={holes}
formatName={round.play_format === "shamble" ? "Shamble" : "Money Ball"}
commentsHref={commentsHref}
/>
)
})()
@ -1553,6 +1808,7 @@ export function RoundLeaderboard({
defaultMetric={config.defaultMetric}
pointsUnit={config.pointsUnit}
holes={holes}
commentsHref={commentsHref}
/>
</>
)

View file

@ -1,7 +1,8 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { ImagePlus, Send, Trash2, X } from "lucide-react"
import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
/** Rå meldingsform fra backend. */
export type ApiRoundMessage = {
@ -12,6 +13,8 @@ export type ApiRoundMessage = {
body: string | null
image_url: string | null
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
}
export type RoundMessagesProps = {
@ -25,8 +28,38 @@ export type RoundMessagesProps = {
refreshKey?: number
}
const ACCEPTED_IMAGE_TYPES = "image/jpeg,image/png,image/webp,image/gif"
// "image/*" (ikke en eksplisitt liste av MIME-typer) -- mange mobilnett-
// lesere viser bare galleri, ikke kamera, i filvelgeren når accept lister
// flere eksplisitte typer. Server-siden validerer uansett strengt mot
// storage.ALLOWED_INPUT_CONTENT_TYPES, så dette svekker ingen kontroll.
const ACCEPTED_IMAGE_TYPES = "image/*"
// Funnet 2026-08-06 (ekte enhet, Android/Chrome, installert som PWA): siden
// Android 13 bruker Chrome systemets eget bilde-vlger ("Photo Picker") for
// <input type=file accept="image/*"> UTEN capture-attributt -- den viser
// KUN galleri, aldri et kamera-alternativ. Løsning: to atskilte knapper/
// inputs -- én med capture="environment" (rett til kamera), én uten (rett
// til galleri) -- i stedet for å stole på at nettleseren tilbyr begge i
// samme velger.
const MAX_IMAGE_HEIGHT = 300
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
// klient-side også for en umiddelbar, presis feilmelding uten en unødvendig
// tur-retur til serveren for noe som uansett vil bli avvist.
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
/** Leser {detail:{code,message}} fra et feil-svar, samme kontrakt som
* resten av API-et (app/errors.py). Faller tilbake til en generisk
* norsk melding hvis svaret ikke har denne formen (f.eks. et rått 502
* fra en proxy, ikke fra selve API-et). */
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json()
const message = data?.detail?.message
if (typeof message === "string" && message.length > 0) return message
} catch {
// Ikke JSON -- bruk fallback.
}
return fallback
}
/** Kort, norsk relativt tidsstempel (f.eks. "nå", "5 min", "2 t", "i går"). */
function formatRelativeTime(iso: string): { label: string; title: string } {
@ -72,6 +105,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
const [deletingId, setDeletingId] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
// (WS-signal fra round-detail.tsx).
@ -113,11 +147,17 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
return null
})
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}, [])
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > MAX_UPLOAD_BYTES) {
setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
e.target.value = ""
return
}
setPostError(null)
setImageFile(file)
setImagePreview((prev) => {
@ -145,15 +185,19 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
credentials: "include",
body: formData,
})
if (res.status !== 201) throw new Error(`Status ${res.status}`)
if (res.status !== 201) {
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
return
}
const created: ApiRoundMessage = await res.json()
// Legg nederst — listen er kronologisk, eldst til nyest.
setMessages((prev) => [...prev, created])
// Legg øverst — nyeste først (2026-08-06, samme rekkefølge som
// backend nå returnerer listen i).
setMessages((prev) => [created, ...prev])
setBody("")
clearSelectedImage()
} catch {
setPostError("Kunne ikke poste kommentaren. Prøv igjen.")
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setPosting(false)
}
@ -222,23 +266,50 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<ImagePlus aria-hidden="true" className="size-5" />
Legg til bilde
</button>
<div className="flex flex-wrap items-center gap-2">
{/* To atskilte inputs -- se MAX_IMAGE_HEIGHT-kommentaren over:
Android sin systembilde-vlger (Photo Picker) viser ALDRI
et kamera-alternativ når accept="image/*" mangler
capture, uansett hvilken nettleser. capture="environment"
her sender rett til kamera i stedet for å stole at
velgeren tilbyr det. */}
<input
ref={cameraInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => cameraInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<Camera aria-hidden="true" className="size-5" />
Ta bilde
</button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<ImagePlus aria-hidden="true" className="size-5" />
Velg fra galleri
</button>
</div>
<button
type="submit"
@ -338,6 +409,14 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
{m.body}
</p>
)}
<PostEngagement
apiBase={`/rounds/${roundId}/messages/${m.id}`}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={roundOwnerUserId === currentUserId}
/>
</li>
)
})}

View file

@ -597,11 +597,11 @@ type ApiFormatResult = {
holes: ApiFormatHoleOut[] | null
}
function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult | null {
function useFormatResult(roundId: string, refreshKey: number, apiPrefix: string = "/rounds"): ApiFormatResult | null {
const [result, setResult] = useState<ApiFormatResult | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
fetch(`${apiPrefix}/${roundId}/format-result`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFormatResult | null) => {
if (!cancelled) setResult(data)
@ -610,7 +610,7 @@ function useFormatResult(roundId: string, refreshKey: number): ApiFormatResult |
return () => {
cancelled = true
}
}, [roundId, refreshKey])
}, [roundId, refreshKey, apiPrefix])
return result
}
@ -778,18 +778,20 @@ function LeadBar({
type MatchRow = { id: string; side: "a" | "b"; label: string; hcp: number | null }
function MatchScorecardGrid({
export function MatchScorecardGrid({
round,
roundId,
viewerId,
refreshKey,
apiPrefix = "/rounds",
}: {
round: ApiRound
roundId: string
viewerId: string | null
refreshKey: number
apiPrefix?: string
}) {
const result = useFormatResult(roundId, refreshKey)
const result = useFormatResult(roundId, refreshKey, apiPrefix)
if (!result) return null
const isSharedBall = SHARED_BALL_FORMATS.includes(round.play_format)
@ -1005,8 +1007,16 @@ function MatchScorecardGrid({
)
}
function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshKey: number }) {
const result = useFormatResult(roundId, refreshKey)
function SkinsProgressTable({
roundId,
refreshKey,
apiPrefix = "/rounds",
}: {
roundId: string
refreshKey: number
apiPrefix?: string
}) {
const result = useFormatResult(roundId, refreshKey, apiPrefix)
if (!result || !result.holes || result.holes.length === 0) return null
const hasNet = result.holes.some((h) => h.entries.some((e) => e.net !== null))
@ -1069,6 +1079,7 @@ function SkinsProgressTable({ roundId, refreshKey }: { roundId: string; refreshK
export function RoundScorecard({
roundId,
embedded = false,
publicMode = false,
}: {
roundId: string
// ADR-040 Beslutning D ("det store grepet") -- Scorekort+Statistikk
@ -1077,7 +1088,16 @@ export function RoundScorecard({
// `RoundPageShell`, dekker det nå) når den rendres som øverste halvdel
// av den slåtte sammen siden, i stedet for standalone.
embedded?: boolean
// ADR-044 (tilskuer-visning, 2026-08-06) -- gjenbruker DENNE komponenten
// uendret for `/watch/{id}/scorecard`, kun med annen datakilde: alle
// `/rounds/*`-kall byttes til `/public/rounds/*` (samme respons-form,
// `_can_view_round`-autorisert i stedet for eier/medspiller-only), og
// WS-kanalen byttes til den offentlige. INGEN visuell/atferdsmessig
// endring når `publicMode` er `false` (standard) -- eiersiden er
// uberørt.
publicMode?: boolean
}) {
const apiPrefix = publicMode ? "/public/rounds" : "/rounds"
const [round, setRound] = useState<ApiRound | null>(null)
const [unitId, setUnitId] = useState<string | null>(null)
const [holes, setHoles] = useState<ApiHole[] | null>(null)
@ -1104,15 +1124,16 @@ export function RoundScorecard({
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
const wsPath = publicMode ? "ws/public/rounds" : "ws/rounds"
const socket = new WebSocket(`${protocol}//${window.location.host}/${wsPath}/${roundId}/live`)
socket.onmessage = () => setRefreshKey((k) => k + 1)
return () => socket.close()
}, [roundId])
}, [roundId, publicMode])
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
const res = await fetch(`${apiPrefix}/${roundId}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) {
if (!cancelled) setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
@ -1158,7 +1179,7 @@ export function RoundScorecard({
return () => {
cancelled = true
}
}, [roundId, viewerId, refreshKey])
}, [roundId, viewerId, refreshKey, apiPrefix])
useEffect(() => {
if (!unitId || !round) return
@ -1171,8 +1192,8 @@ export function RoundScorecard({
// den ene eller den andre typen).
const isSideUnit = round.sides.some((s) => s.id === unitId)
const endpoint = isSideUnit
? `/rounds/${roundId}/sides/${unitId}/holes`
: `/rounds/${roundId}/participants/${unitId}/holes`
? `${apiPrefix}/${roundId}/sides/${unitId}/holes`
: `${apiPrefix}/${roundId}/participants/${unitId}/holes`
let cancelled = false
async function loadHoles() {
const res = await fetch(endpoint, { credentials: "include" })
@ -1184,14 +1205,17 @@ export function RoundScorecard({
return () => {
cancelled = true
}
}, [roundId, unitId, round?.play_format, refreshKey])
}, [roundId, unitId, round?.play_format, refreshKey, apiPrefix])
if (error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
<Link
href={publicMode ? "/dashboard" : "/my-rounds"}
className="text-base font-semibold text-primary underline underline-offset-2"
>
{publicMode ? "Tilbake til dashbordet" : "Tilbake til egne runder"}
</Link>
</div>
)
@ -1222,7 +1246,7 @@ export function RoundScorecard({
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
@ -1318,7 +1342,7 @@ export function RoundScorecard({
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<Link
href={`/my-rounds/${roundId}`}
href={publicMode ? `/watch/${roundId}` : `/my-rounds/${roundId}`}
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
>
<ArrowLeft aria-hidden="true" className="size-6" />
@ -1436,9 +1460,9 @@ export function RoundScorecard({
{/* Matchforløp / skins-forløp -- HVORFOR ble resultatet som det ble
(brutto/netto/slag mottatt per hull), ikke bare et sluttall. */}
{isTwoSided && round.sides.length === 2 && (
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} />
<MatchScorecardGrid round={round} roundId={roundId} viewerId={viewerId} refreshKey={refreshKey} apiPrefix={apiPrefix} />
)}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} />}
{isSkins && <SkinsProgressTable roundId={roundId} refreshKey={refreshKey} apiPrefix={apiPrefix} />}
</main>
</div>
)

View file

@ -610,17 +610,17 @@ export function RoundsStatsSummary() {
/>
<CompareToPar
title="Bunker"
aLabel="I bunker"
aValue={s.avg_to_par_with_bunker}
bLabel="Ikke i bunker"
bValue={s.avg_to_par_without_bunker}
aLabel="Ikke i bunker"
aValue={s.avg_to_par_without_bunker}
bLabel="I bunker"
bValue={s.avg_to_par_with_bunker}
/>
<CompareToPar
title="Anywayslag"
aLabel="Med anywayslag"
aValue={s.avg_to_par_with_anyway}
bLabel="Uten anywayslag"
bValue={s.avg_to_par_without_anyway}
aLabel="Uten anywayslag"
aValue={s.avg_to_par_without_anyway}
bLabel="Med anywayslag"
bValue={s.avg_to_par_with_anyway}
/>
</Card>

View file

@ -3,9 +3,10 @@
import type React from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
import { ArrowLeft, Camera, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
// --- Types (matcher API-kontrakten i app/routers/messaging.py) -------------
@ -16,6 +17,8 @@ type ApiMessage = {
body: string | null
image_url: string | null
created_at: string
reactions: ReactionSummary[]
comment_count: number
}
const TIME_FMT = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
@ -46,8 +49,8 @@ export function TeamChat({
const [text, setText] = useState("")
const [image, setImage] = useState<File | null>(null)
const [sending, setSending] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let cancelled = false
@ -90,7 +93,8 @@ export function TeamChat({
socket.onmessage = (event) => {
try {
const msg: ApiMessage = JSON.parse(event.data)
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
} catch {
// ignorer ugyldig payload
}
@ -98,9 +102,19 @@ export function TeamChat({
return () => socket.close()
}, [organizationId, teamId, accessError])
useEffect(() => {
bottomRef.current?.scrollIntoView({ block: "end" })
}, [messages.length])
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
// klient-side for en umiddelbar, presis feilmelding.
function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 20 * 1024 * 1024) {
setError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
e.target.value = ""
return
}
setError(null)
setImage(file)
}
async function send(e: React.FormEvent) {
e.preventDefault()
@ -116,14 +130,26 @@ export function TeamChat({
credentials: "include",
body: form,
})
if (!res.ok) throw new Error(`send: ${res.status}`)
if (!res.ok) {
let message = "Klarte ikke å sende meldingen. Prøv igjen."
try {
const data = await res.json()
if (typeof data?.detail?.message === "string") message = data.detail.message
} catch {
// Ikke JSON -- behold standardmeldingen.
}
setError(message)
return
}
const msg: ApiMessage = await res.json()
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
setText("")
setImage(null)
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
} catch {
setError("Klarte ikke å sende meldingen. Prøv igjen.")
setError("Klarte ikke å sende meldingen. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSending(false)
}
@ -224,10 +250,18 @@ export function TeamChat({
<span className="px-1 text-[11px] text-muted-foreground">
{TIME_FMT.format(new Date(m.created_at))}
</span>
<div className="w-full px-1">
<PostEngagement
apiBase={`/orgs/${organizationId}/teams/${teamId}/messages/${m.id}`}
currentUserId={currentUserId}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={false}
/>
</div>
</div>
)
})}
<div ref={bottomRef} />
</main>
<div className="sticky bottom-0 border-t border-border bg-background/95 backdrop-blur">
@ -245,6 +279,7 @@ export function TeamChat({
onClick={() => {
setImage(null)
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}}
aria-label="Fjern bilde"
>
@ -253,19 +288,41 @@ export function TeamChat({
</div>
)}
<div className="flex items-center gap-2">
{/* To atskilte inputs -- Android sin systembilde-vlger
(Photo Picker) viser ALDRI et kamera-alternativ når
accept="image/*" mangler capture, uansett nettleser
(funnet 2026-08-06, ekte enhet). */}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleImageSelected}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => cameraInputRef.current?.click()}
aria-label="Ta bilde"
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
>
<Camera aria-hidden="true" className="size-5" />
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
accept="image/*"
className="hidden"
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
onChange={handleImageSelected}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
aria-label="Legg ved bilde"
aria-label="Velg bilde fra galleri"
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
>
<ImagePlus aria-hidden="true" className="size-5" />

View file

@ -0,0 +1,520 @@
"use client"
import type React from "react"
import { useEffect, useId, useRef, useState } from "react"
import { cn } from "@/lib/utils"
/* ------------------------------------------------------------------ */
/* Icons — inline SVG (currentColor), so every action can pair an */
/* icon with a real text label and we control size/stroke precisely. */
/* ------------------------------------------------------------------ */
type IconProps = { className?: string }
const stroke = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
}
function MailIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="m4 7 8 6 8-6" />
</svg>
)
}
function KeyIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<circle cx="8" cy="15" r="4.5" />
<path d="M11 12 21 2m-4 1 3 3m-6 0 3 3" />
</svg>
)
}
function TicketIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h15A1.5 1.5 0 0 1 21 8.5V10a2 2 0 0 0 0 4v1.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 15.5V14a2 2 0 0 0 0-4Z" />
<path d="M14 7v10" strokeDasharray="1.5 2.5" />
</svg>
)
}
function EyeIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
function EyeOffIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M3 3l18 18" />
<path d="M10.6 6.1A9.7 9.7 0 0 1 12 6c6 0 9.5 6 9.5 6a16 16 0 0 1-3.3 3.9M6.3 7.9A16 16 0 0 0 2.5 12S6 18 12 18a9.5 9.5 0 0 0 3.4-.6" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</svg>
)
}
function ArrowLeftIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M11 5 4 12l7 7M4 12h16" />
</svg>
)
}
function AlertIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
<path d="M12 10v4.5" />
<circle cx="12" cy="17.6" r="0.4" fill="currentColor" stroke="none" />
</svg>
)
}
function CheckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="m4 12.5 5 5 11-11" />
</svg>
)
}
function FlagIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M6 21V4" />
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" fill="var(--cup)" stroke="none" />
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" />
<circle cx="6" cy="21" r="1.6" fill="currentColor" stroke="none" />
</svg>
)
}
function Spinner({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("animate-spin", className)} aria-hidden="true" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)
}
/* ------------------------------------------------------------------ */
/* Shared styles */
/* ------------------------------------------------------------------ */
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong focus-visible:ring-offset-2 focus-visible:ring-offset-clubhouse-card"
const inputBase =
"w-full h-12 rounded-xl border bg-clubhouse-field px-4 text-base text-clubhouse-ink placeholder:text-clubhouse-muted/70 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong/70"
const primaryBtn = cn(
"inline-flex w-full min-h-[3.25rem] items-center justify-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-semibold text-white shadow-sm transition-colors hover:bg-[#265a18] active:translate-y-px disabled:cursor-not-allowed disabled:opacity-55",
focusRing,
)
const secondaryBtn = cn(
"inline-flex w-full min-h-[2.75rem] items-center justify-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card px-4 py-2.5 text-base font-medium text-clubhouse-ink transition-colors hover:border-tee-strong/40 hover:bg-clubhouse-field",
focusRing,
)
const backBtn = cn(
"-mx-2 inline-flex min-h-[2.75rem] items-center gap-1.5 rounded-lg px-2 text-base font-medium text-tee-strong transition-colors hover:underline",
focusRing,
)
/* ------------------------------------------------------------------ */
/* Mocked backend — stands in for the real API so the flow actually */
/* runs (loading, success, and error responses are all real here). */
/* ------------------------------------------------------------------ */
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
async function apiSendMagicLink(_email: string) {
await sleep(1100)
return { ok: true as const }
}
async function apiPasswordLogin(_email: string, password: string) {
await sleep(1100)
// Demo credential so the success path is reachable; anything else fails.
if (password === "teecup123") return { ok: true as const }
return { ok: false as const, error: "Feil e-post eller passord. Prøv igjen." }
}
async function apiJoinByCode(code: string) {
await sleep(1000)
const known: Record<string, string> = {
TEECUP: "Lørdagsrunden",
"RYDER-25": "Ryder Cup 2025",
HOST2026: "Høstcupen 2026",
}
const name = known[code.trim().toUpperCase()]
if (name) return { ok: true as const, name }
return {
ok: false as const,
error: `Fant ingen turnering med koden «${code.trim()}». Sjekk at den er riktig skrevet.`,
}
}
type Mode = "email" | "sent" | "password" | "code"
const RESEND_SECONDS = 30
/* ------------------------------------------------------------------ */
/* Small building blocks */
/* ------------------------------------------------------------------ */
function FieldError({ id, children }: { id: string; children: React.ReactNode }) {
return (
<p id={id} role="alert" className="mt-1.5 flex items-start gap-1.5 text-[0.9375rem] font-medium text-cup-strong">
<AlertIcon className="mt-0.5 size-4 shrink-0" />
<span>{children}</span>
</p>
)
}
function FormAlert({ children }: { children: React.ReactNode }) {
return (
<div
role="alert"
className="flex items-start gap-2.5 rounded-xl border border-cup-strong/45 bg-cup/10 px-4 py-3 text-[0.9375rem] leading-snug text-clubhouse-ink"
>
<AlertIcon className="mt-0.5 size-5 shrink-0 text-cup-strong" />
<span>{children}</span>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export function TeeCupAuth() {
const [mode, setMode] = useState<Mode>("email")
// shared field state (email persists across email <-> password)
const [email, setEmail] = useState("")
const [emailTouched, setEmailTouched] = useState(false)
const emailInvalid = emailTouched && email.length > 0 && !EMAIL_RE.test(email)
const emailIds = useId()
const emailErrId = `${emailIds}-email-err`
/* form-level (server) error, shared across states */
const [formError, setFormError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
/* ---- State 1: magic link ---- */
async function handleSendLink(e: React.FormEvent) {
e.preventDefault()
setEmailTouched(true)
if (!EMAIL_RE.test(email)) return
setFormError(null)
setLoading(true)
const res = await apiSendMagicLink(email)
setLoading(false)
if (res.ok) setMode("sent")
}
/* ---- State 2: cooldown ---- */
const [cooldown, setCooldown] = useState(0)
const [resentNote, setResentNote] = useState(false)
useEffect(() => {
if (mode !== "sent") return
setCooldown(RESEND_SECONDS)
}, [mode])
useEffect(() => {
if (cooldown <= 0) return
const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
return () => clearInterval(t)
}, [cooldown])
async function handleResend() {
if (cooldown > 0 || loading) return
setResentNote(false)
setLoading(true)
await apiSendMagicLink(email)
setLoading(false)
setCooldown(RESEND_SECONDS)
setResentNote(true)
}
/* ---- State 3: password ---- */
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const pwIds = useId()
async function handlePasswordLogin(e: React.FormEvent) {
e.preventDefault()
if (!email || !password) return
setFormError(null)
setLoading(true)
const res = await apiPasswordLogin(email, password)
setLoading(false)
if (res.ok) {
setSuccess("Logget inn! Neste steg er en engangskode på e-post (2FA).")
} else {
setFormError(res.error)
}
}
/* ---- State 4: join by code ---- */
const [code, setCode] = useState("")
async function handleJoin(e: React.FormEvent) {
e.preventDefault()
if (!code.trim()) return
setFormError(null)
setLoading(true)
const res = await apiJoinByCode(code)
setLoading(false)
if (res.ok) {
setSuccess(`Fant turneringen «${res.name}». Sender deg dit …`)
} else {
setFormError(res.error)
}
}
/* ---- shared success confirmation ---- */
const [success, setSuccess] = useState<string | null>(null)
// Reset transient per-mode state when switching modes.
function switchMode(next: Mode, opts?: { clearEmail?: boolean }) {
setFormError(null)
setSuccess(null)
setPassword("")
setShowPassword(false)
setResentNote(false)
if (opts?.clearEmail) {
setEmail("")
setEmailTouched(false)
}
if (next === "code") setCode("")
setMode(next)
}
/* ---------------- success view (shared continuation) ------------- */
if (success) {
return (
<div className="text-center">
<div className="mx-auto mb-5 flex size-16 items-center justify-center rounded-full bg-tee/20 text-tee-strong">
<CheckIcon className="size-8" />
</div>
<h2 className="text-pretty text-xl font-bold text-clubhouse-ink">Alt klart</h2>
<p className="mx-auto mt-2 max-w-xs text-pretty text-base leading-relaxed text-clubhouse-muted">{success}</p>
<button type="button" className={cn(backBtn, "mt-6 justify-center")} onClick={() => switchMode("email", { clearEmail: true })}>
<ArrowLeftIcon className="size-5" />
Tilbake til innlogging
</button>
</div>
)
}
return (
<div>
{/* ---------- State 1: Magic link (primary path) ---------- */}
{mode === "email" && (
<form onSubmit={handleSendLink} noValidate>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn</h2>
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
Klart for en runde? Skriv inn e-posten din, sender vi deg en lenke.
</p>
<div className="mt-6">
<label htmlFor="email" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
E-post
</label>
<input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
enterKeyHint="go"
placeholder="deg@eksempel.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setEmailTouched(true)}
aria-invalid={emailInvalid || undefined}
aria-describedby={emailInvalid ? emailErrId : undefined}
className={cn(inputBase, emailInvalid ? "border-cup-strong ring-2 ring-cup-strong/30" : "border-clubhouse-border")}
/>
{emailInvalid && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
</div>
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
{loading ? "Sender …" : "Send innloggingslenke"}
</button>
<p className="mt-3 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
Ingen passord nødvendig. Vi sender deg en sikker lenke e-post.
</p>
<div className="mt-7 border-t border-clubhouse-border pt-5">
<div className="grid gap-2.5">
<button type="button" className={secondaryBtn} onClick={() => switchMode("password")}>
<KeyIcon className="size-5 text-tee-strong" />
Logg inn med e-post og passord i stedet
</button>
<button type="button" className={secondaryBtn} onClick={() => switchMode("code")}>
<TicketIcon className="size-5 text-cup-strong" />
Har du en invitasjonskode? rett til turneringen
</button>
</div>
</div>
</form>
)}
{/* ---------- State 2: Link sent ---------- */}
{mode === "sent" && (
<div>
<div className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-tee/20 text-tee-strong">
<MailIcon className="size-7" />
</div>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Lenke sendt</h2>
<p className="mt-2 text-pretty text-base leading-relaxed text-clubhouse-ink">
Vi sendte en innloggingslenke til <span className="font-semibold break-all">{email}</span>.
</p>
<p className="mt-2 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
Åpne lenken denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
</p>
{resentNote && (
<p role="status" className="mt-4 flex items-center gap-2 text-[0.9375rem] font-medium text-tee-strong">
<CheckIcon className="size-5 shrink-0" />
Lenken er sendt nytt.
</p>
)}
<button type="button" onClick={handleResend} disabled={cooldown > 0 || loading} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
{loading ? "Sender …" : cooldown > 0 ? `Send igjen om ${cooldown}s` : "Send lenken igjen"}
</button>
<div className="mt-6">
<button type="button" className={backBtn} onClick={() => switchMode("email", { clearEmail: true })}>
<ArrowLeftIcon className="size-5" />
Bruk en annen e-postadresse
</button>
</div>
</div>
)}
{/* ---------- State 3: Password fallback ---------- */}
{mode === "password" && (
<form onSubmit={handlePasswordLogin} noValidate>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn med passord</h2>
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
For deg som har satt et passord kontoen din.
</p>
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
<div className="mt-5">
<label htmlFor="email-pw" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
E-post
</label>
<input
id="email-pw"
name="email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@eksempel.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(inputBase, "border-clubhouse-border")}
/>
</div>
<div className="mt-4">
<label htmlFor={pwIds} className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
Passord
</label>
<div className="relative">
<input
id={pwIds}
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Passordet ditt"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(inputBase, "border-clubhouse-border pr-14")}
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
aria-pressed={showPassword}
className={cn(
"absolute right-1 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg text-clubhouse-muted transition-colors hover:text-clubhouse-ink",
focusRing,
)}
>
{showPassword ? <EyeOffIcon className="size-5" /> : <EyeIcon className="size-5" />}
</button>
</div>
</div>
<button type="submit" disabled={loading || !email || !password} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
{loading ? "Logger inn …" : "Logg inn"}
</button>
<div className="mt-6">
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
<ArrowLeftIcon className="size-5" />
Bruk innloggingslenke e-post i stedet
</button>
</div>
</form>
)}
{/* ---------- State 4: Join by code ---------- */}
{mode === "code" && (
<form onSubmit={handleJoin} noValidate>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Bli med via kode</h2>
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for å se turneringen eller melde deg .
</p>
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
<div className="mt-5">
<label htmlFor="invite" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
Invitasjonskode
</label>
<input
id="invite"
name="invite"
type="text"
autoFocus
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
enterKeyHint="go"
placeholder="F.EKS. RYDER-25"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
aria-describedby="invite-hint"
className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")}
/>
<p id="invite-hint" className="mt-1.5 text-[0.875rem] text-clubhouse-muted">
Prøv <span className="font-mono font-semibold text-clubhouse-ink">TEECUP</span> for en demo.
</p>
</div>
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <FlagIcon className="size-5" />}
{loading ? "Sjekker …" : "Gå til turnering"}
</button>
<div className="mt-6">
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
<ArrowLeftIcon className="size-5" />
Tilbake til innlogging
</button>
</div>
</form>
)}
</div>
)
}

View file

@ -0,0 +1,45 @@
import { cn } from "@/lib/utils"
/**
* TeeCup wordmark, rebuilt from the supplied logo as pure vector paths so it
* stays razor-sharp at small sizes (e.g. the page header). "Tee" is drawn in
* fairway green, "Cup" in the brand orange-red.
*/
export function TeeCupWordmark({
className,
title = "TeeCup",
}: {
className?: string
title?: string
}) {
return (
<svg
viewBox="198 136 214 62"
role="img"
aria-label={title}
className={cn("h-auto w-auto", className)}
>
{/* Tee */}
<g fill="var(--tee-strong)">
<path
transform="translate(201.67657,181.31221)"
d="m 33.5625,-41.859375 v 6.953125 H 21.46875 V 0 H 13.234375 V -34.90625 H 1.140625 v -6.953125 z m 0,0"
/>
<path
transform="translate(227.37493,181.31221)"
d="M 31.203125,-14 H 10.90625 c 0.226562,2.75 1.007812,4.75 2.34375,6 1.34375,1.25 3.164062,1.875 5.46875,1.875 1.820312,0 3.296875,-0.335938 4.421875,-1.015625 1.125,-0.675781 2.03125,-1.703125 2.71875,-3.078125 h 0.59375 l 5.265625,3.375 c -1.03125,2.21875 -2.589844,4.015625 -4.671875,5.390625 -2.074219,1.3671875 -4.851563,2.046875 -8.328125,2.046875 -3.261719,0 -6.078125,-0.6171875 -8.453125,-1.859375 -2.375,-1.238281 -4.210937,-3.082031 -5.5,-5.53125 -1.292969,-2.457031 -1.9375,-5.453125 -1.9375,-8.984375 0,-3.582031 0.675781,-6.601562 2.03125,-9.0625 1.363281,-2.46875 3.195313,-4.304688 5.5,-5.515625 2.3125,-1.21875 4.910156,-1.828125 7.796875,-1.828125 4.46875,0 7.890625,1.1875 10.265625,3.5625 2.375,2.367188 3.5625,5.839844 3.5625,10.421875 0,1.65625 -0.05859,2.945313 -0.171875,3.859375 z m -6.8125,-5.125 c -0.07422,-2.40625 -0.632813,-4.15625 -1.671875,-5.25 -1.042969,-1.101562 -2.5625,-1.65625 -4.5625,-1.65625 -4.011719,0 -6.371094,2.304688 -7.078125,6.90625 z m 0,0"
/>
<path
transform="translate(261.70112,181.31221)"
d="M 31.203125,-14 H 10.90625 c 0.226562,2.75 1.007812,4.75 2.34375,6 1.34375,1.25 3.164062,1.875 5.46875,1.875 1.820312,0 3.296875,-0.335938 4.421875,-1.015625 1.125,-0.675781 2.03125,-1.703125 2.71875,-3.078125 h 0.59375 l 5.265625,3.375 c -1.03125,2.21875 -2.589844,4.015625 -4.671875,5.390625 -2.074219,1.3671875 -4.851563,2.046875 -8.328125,2.046875 -3.261719,0 -6.078125,-0.6171875 -8.453125,-1.859375 -2.375,-1.238281 -4.210937,-3.082031 -5.5,-5.53125 -1.292969,-2.457031 -1.9375,-5.453125 -1.9375,-8.984375 0,-3.582031 0.675781,-6.601562 2.03125,-9.0625 1.363281,-2.46875 3.195313,-4.304688 5.5,-5.515625 2.3125,-1.21875 4.910156,-1.828125 7.796875,-1.828125 4.46875,0 7.890625,1.1875 10.265625,3.5625 2.375,2.367188 3.5625,5.839844 3.5625,10.421875 0,1.65625 -0.05859,2.945313 -0.171875,3.859375 z m -6.8125,-5.125 c -0.07422,-2.40625 -0.632813,-4.15625 -1.671875,-5.25 -1.042969,-1.101562 -2.5625,-1.65625 -4.5625,-1.65625 -4.011719,0 -6.371094,2.304688 -7.078125,6.90625 z m 0,0"
/>
</g>
{/* Cup */}
<g fill="var(--cup)">
<path d="M 22.875 0.59375 C 19.019531 0.59375 15.625 -0.203125 12.6875 -1.796875 C 9.75 -3.398438 7.445312 -5.8125 5.78125 -9.03125 C 4.125 -12.25 3.296875 -16.21875 3.296875 -20.9375 C 3.296875 -25.644531 4.125 -29.609375 5.78125 -32.828125 C 7.445312 -36.046875 9.75 -38.457031 12.6875 -40.0625 C 15.625 -41.664062 19.019531 -42.46875 22.875 -42.46875 C 26.90625 -42.46875 30.328125 -41.675781 33.140625 -40.09375 C 35.953125 -38.519531 37.960938 -36.125 39.171875 -32.90625 L 33.125 -29.203125 L 32.40625 -29.234375 C 31.507812 -31.410156 30.289062 -32.96875 28.75 -33.90625 C 27.21875 -34.851562 25.257812 -35.328125 22.875 -35.328125 C 19.40625 -35.328125 16.695312 -34.175781 14.75 -31.875 C 12.800781 -29.582031 11.828125 -25.9375 11.828125 -20.9375 C 11.828125 -15.945312 12.800781 -12.300781 14.75 -10 C 16.695312 -7.707031 19.40625 -6.5625 22.875 -6.5625 C 28.3125 -6.5625 31.644531 -9.203125 32.875 -14.484375 L 33.59375 -14.484375 L 39.6875 -11.3125 C 38.539062 -7.34375 36.582031 -4.363281 33.8125 -2.375 C 31.039062 -0.394531 27.394531 0.59375 22.875 0.59375 Z M 22.875 0.59375 " transform="translate(294.859051,181.312207)" />
<path d="M 14.453125 0.59375 C 10.910156 0.59375 8.289062 -0.375 6.59375 -2.3125 C 4.90625 -4.25 4.0625 -7.03125 4.0625 -10.65625 L 4.0625 -31.578125 L 12 -31.578125 L 12 -11.65625 C 12 -9.726562 12.410156 -8.316406 13.234375 -7.421875 C 14.066406 -6.535156 15.351562 -6.09375 17.09375 -6.09375 C 19.289062 -6.09375 21 -6.8125 22.21875 -8.25 C 23.4375 -9.695312 24.046875 -11.96875 24.046875 -15.0625 L 24.046875 -31.578125 L 31.984375 -31.578125 L 31.984375 0 L 24.359375 0 L 24.359375 -4.640625 L 23.765625 -4.78125 C 22.753906 -2.96875 21.515625 -1.617188 20.046875 -0.734375 C 18.585938 0.148438 16.722656 0.59375 14.453125 0.59375 Z M 14.453125 0.59375 " transform="translate(335.913048,181.312207)" />
<path d="M 4.546875 12.796875 L 4.546875 -31.578125 L 12.484375 -31.578125 L 12.484375 -27.453125 L 13.078125 -27.34375 C 14.015625 -28.945312 15.265625 -30.15625 16.828125 -30.96875 C 18.398438 -31.78125 20.234375 -32.1875 22.328125 -32.1875 C 24.867188 -32.1875 27.085938 -31.582031 28.984375 -30.375 C 30.890625 -29.175781 32.367188 -27.347656 33.421875 -24.890625 C 34.484375 -22.441406 35.015625 -19.40625 35.015625 -15.78125 C 35.015625 -12.164062 34.457031 -9.132812 33.34375 -6.6875 C 32.226562 -4.25 30.675781 -2.425781 28.6875 -1.21875 C 26.707031 -0.0078125 24.414062 0.59375 21.8125 0.59375 C 19.851562 0.59375 18.15625 0.261719 16.71875 -0.390625 C 15.289062 -1.046875 14.078125 -2.070312 13.078125 -3.46875 L 12.484375 -3.34375 L 12.484375 12.796875 Z M 19.609375 -6.125 C 21.984375 -6.125 23.78125 -6.894531 25 -8.4375 C 26.21875 -9.988281 26.828125 -12.4375 26.828125 -15.78125 C 26.828125 -19.113281 26.21875 -21.554688 25 -23.109375 C 23.78125 -24.671875 21.984375 -25.453125 19.609375 -25.453125 C 17.398438 -25.453125 15.644531 -24.648438 14.34375 -23.046875 C 13.039062 -21.441406 12.390625 -19.019531 12.390625 -15.78125 C 12.390625 -12.550781 13.039062 -10.132812 14.34375 -8.53125 C 15.644531 -6.925781 17.398438 -6.125 19.609375 -6.125 Z " transform="translate(372.443662,181.312207)" />
</g>
</svg>
)
}

View file

@ -329,7 +329,9 @@ export function TournamentPresentationPanel({
<input
ref={heroInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
// "image/*" -- mange mobilnettlesere viser bare
// galleri, ikke kamera, med en eksplisitt MIME-liste.
accept="image/*"
className="hidden"
onChange={handleHeroSelected}
/>

View file

@ -0,0 +1,28 @@
"use client"
// Leaderboard-fanen for tilskuer-visningen (ADR-044, 2026-08-06). Gjenbruker
// round-leaderboard.tsx sin RoundLeaderboard UENDRET (samme formatspesifikke
// tavler som eiersiden -- IndividualBoard/TwoSidedBoard/HighLowBoard/
// TeamFlightBoard, alle ni+ formater -- se komponentens `publicMode`-prop),
// kun matet fra /public/rounds/* i stedet for /rounds/*.
import { Trophy } from "lucide-react"
import { WatchTabs } from "@/components/watch-tabs"
import { RoundLeaderboard } from "@/components/round-leaderboard"
export function WatchLeaderboard({ roundId }: { roundId: string }) {
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
<Trophy aria-hidden="true" className="size-5 text-primary" />
Følger live
</span>
</div>
<WatchTabs roundId={roundId} active="leaderboard" />
</header>
<RoundLeaderboard roundId={roundId} embedded publicMode />
</div>
)
}

View file

@ -0,0 +1,143 @@
"use client"
// "Spillere og runde", read-only tilskuer-variant (ADR-044, 2026-08-06).
// Bevisst IKKE et forsøk på å gjenbruke round-detail.tsx sin PlayerList
// via en canManage=false-prop -- den er en lokal, ikke-eksportert funksjon
// tett koblet til eier-autentiserte handlere (onPatchParticipant m.fl.),
// og selv canManage dekker i dag ikke alt konsekvent der (Fullfør runde-
// knappen er kun gated på !completed). Denne komponenten inneholder rett
// og slett INGEN handlings-kode (ingen +Medspiller, ingen Rediger/Fullfør/
// Slett) -- ingen risiko for at en eier-handling glipper gjennom.
import Link from "next/link"
import { Users } from "lucide-react"
export type ApiPublicParticipant = {
id: string
display_name: string
is_owner: boolean
tee_name_snapshot: string
handicap_index_snapshot: number | null
course_handicap_snapshot: number | null
round_side_id: string | null
playing_handicap: number | null
}
export type ApiLeaderboardEntry = {
participant_id: string
display_name: string
holes_played: number
total_score: number | null
score_to_par: number | null
}
export type ApiFlightSummary = {
round_id: string
name: string | null
course_name_snapshot: string
played_at: string
started_at: string | null
completed_at: string | null
participant_count: number
is_anchor: boolean
}
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function ParticipantCard({ participant, progress }: { participant: ApiPublicParticipant; progress: ApiLeaderboardEntry | null }) {
// Tildelte slag: allowance-justert spillehandicap der satt, ellers
// course handicap -- samme prioritet som eiersidens PlayerList bruker
// for "Tildelte slag".
const strokesGiven = participant.playing_handicap ?? participant.course_handicap_snapshot
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-bold text-foreground">{participant.display_name}</span>
{participant.is_owner && (
<span className="rounded-full border border-border px-2 py-0.5 text-xs font-semibold text-muted-foreground">
Eier
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
Utslag: <span className="font-semibold text-foreground">{participant.tee_name_snapshot}</span>
{participant.handicap_index_snapshot !== null && (
<>
{" · "}HCP: <span className="font-semibold text-foreground">{participant.handicap_index_snapshot.toFixed(1)}</span>
</>
)}
{strokesGiven !== null && (
<>
{" · "}Tildelte slag: <span className="font-semibold text-foreground">{strokesGiven}</span>
</>
)}
</p>
{progress && (
<p className="text-sm text-muted-foreground">
langt: <span className="font-semibold text-foreground">{progress.holes_played} hull</span>
{progress.total_score !== null && (
<>
{" · "}
<span className="font-semibold text-foreground">{progress.total_score} slag</span>
</>
)}
{progress.score_to_par !== null && (
<>
{" · "}
<span className="font-semibold text-foreground">{formatToPar(progress.score_to_par)} til par</span>
</>
)}
</p>
)}
</div>
)
}
export function WatchPlayers({
participants,
leaderboardEntries,
flightGroup,
}: {
participants: ApiPublicParticipant[]
leaderboardEntries: ApiLeaderboardEntry[]
flightGroup: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null
}) {
const progressByParticipant = new Map(leaderboardEntries.map((e) => [e.participant_id, e]))
const otherFlights = (flightGroup?.flights ?? []).filter((f) => !f.is_anchor)
return (
<section className="flex flex-col gap-4" aria-label="Spillere og runde">
{otherFlights.length > 0 && (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
<Users aria-hidden="true" className="size-4 text-primary" />
Flere flighter deler dette leaderboardet
</span>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{otherFlights.map((f) => (
<li key={f.round_id}>
<Link
href={`/watch/${f.round_id}`}
className="flex min-h-11 items-center justify-between gap-2 px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<span>{f.name || "Flight"}</span>
<span className="text-xs font-medium text-muted-foreground">{f.participant_count} spillere</span>
</Link>
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-3">
{participants.map((p) => (
<ParticipantCard key={p.id} participant={p} progress={progressByParticipant.get(p.id) ?? null} />
))}
</div>
</section>
)
}

View file

@ -1,33 +1,27 @@
"use client"
// Tredjeparts/offentlig live-visning av en frittstående runde (ADR-036
// fase 2, 2026-07-28) -- der en venn (i en synlig kategori) eller alle
// (visibility_mode='public') kan følge runden UTEN å være eier/deltaker.
// Ferskbygget, IKKE en gjenbruk av round-detail.tsx/round-leaderboard.tsx
// fase 2, 2026-07-28). Ferskbygget, IKKE en gjenbruk av round-detail.tsx
// (som antar eier-/deltaker-tilgang og full skrive-UI) -- denne siden er
// rendyrket lesevisning mot de nye /public/rounds/*-endepunktene, som
// fungerer like fint anonymt som innlogget (get_current_user_optional).
//
// Tre distinkte presentasjoner avhengig av play_format, samme inndeling
// som round-leaderboard.tsx sin TWO_SIDED_FORMATS-logikk:
// - Slagspill: individuell rangering (brutto til par).
// - Skins: skins-tavle (hvem har vunnet flest skins).
// - Match/fourball/foursome/greensome/scramble: matchstatus + hull-merker.
// Score-fanen (denne filen): kompakt status + "Spillere og runde"
// (deltakerliste, HCP/utslag/tildelte slag, "så langt", flight-
// gruppering) -- se watch-players.tsx. Selve matchstatus/skins-tavle/
// leaderboard flyttet til /watch/[id]/leaderboard (ADR-044, 2026-08-06),
// som gjenbruker den ALLEREDE eksisterende round-leaderboard.tsx uendret
// (kun `publicMode`-bryteren), for full formatparitet med eiersiden.
import { useEffect, useState } from "react"
import Link from "next/link"
import { Radio, Trophy } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { WatchTabs } from "@/components/watch-tabs"
import { WatchPlayers, type ApiPublicParticipant, type ApiLeaderboardEntry, type ApiFlightSummary } from "@/components/watch-players"
import { RoundMessages } from "@/components/round-messages"
type ApiSide = { id: string; label: string | null }
type ApiParticipant = {
id: string
display_name: string
is_owner: boolean
}
type ApiRound = {
id: string
name: string | null
@ -37,47 +31,13 @@ type ApiRound = {
play_format: string
completed_at: string | null
owner_display_name: string
participants: ApiParticipant[]
owner_user_id: string
participants: ApiPublicParticipant[]
sides: ApiSide[]
}
type ApiLeaderboardEntry = {
participant_id: string
display_name: string
holes_played: number
total_score: number | null
score_to_par: number | null
}
type ApiLeaderboard = { holes_planned: number; completed: boolean; entries: ApiLeaderboardEntry[] }
type ApiFormatResult = {
play_format: string
ready: boolean
match_lead: number | null
match_holes_played: number | null
match_holes_remaining: number | null
match_is_closed: boolean | null
match_is_dormie: boolean | null
match_status_text: string | null
hole_results: string[] | null
skins_won: Record<string, number> | null
// High-low-high (2026-07-30) -- literal poengsum, IKKE match-play
// "X UP" (match_status_text settes aldri for dette formatet).
hlh_points_a: number | null
hlh_points_b: number | null
}
// Territorium-bar (2026-07-29, samme visuelle språk som identitetsbanneret i
// round-detail.tsx/round-scorecard.tsx/session-scorecard.tsx/round-
// leaderboard.tsx) -- ingen navn her (vises andre steder på siden), kun
// fargesonene + den sentrerte statuspillen, for full visuell konsistens.
function leadZoneFraction(lead: number, totalHoles: number): number {
if (totalHoles <= 0) return 0.5
const fraction = 0.5 + (lead / totalHoles) * 0.5
return Math.min(0.82, Math.max(0.18, fraction))
}
const FORMAT_LABELS: Record<string, string> = {
stroke: "Slagspill",
stableford: "Stableford",
@ -98,176 +58,32 @@ const FORMAT_LABELS: Record<string, string> = {
scramble_solo: "Scramble mot enkeltspiller",
scramble_solo_match: "Scramble mot enkeltspiller (matchspill)",
}
const TWO_SIDED_FORMATS = new Set([
"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high",
])
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function formatToPar(value: number): string {
if (value === 0) return "E"
return value > 0 ? `+${value}` : `${Math.abs(value)}`
}
function substituteSideLabels(text: string, sides: ApiSide[]): string {
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
}
// --- Slagspill: individuell rangering ---------------------------------------
function StrokeLeaderboard({ leaderboard }: { leaderboard: ApiLeaderboard }) {
const ranked = [...leaderboard.entries].sort((a, b) => {
if (a.score_to_par === null) return 1
if (b.score_to_par === null) return -1
return a.score_to_par - b.score_to_par
})
return (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{ranked.map((entry, i) => (
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
i === 0 && entry.score_to_par !== null ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
>
{entry.score_to_par !== null ? `#${i + 1}` : ""}
</span>
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{entry.display_name}</span>
<span className="shrink-0 text-sm text-muted-foreground">
{leaderboard.completed ? "Ferdig" : entry.holes_played > 0 ? `${entry.holes_played} hull` : "Ikke startet"}
</span>
{entry.score_to_par !== null && (
<span className="shrink-0 text-base font-extrabold tabular-nums text-foreground">
{formatToPar(entry.score_to_par)}
</span>
)}
</li>
))}
</ul>
)
}
// --- Skins-tavle -------------------------------------------------------------
function SkinsBoard({ participants, skinsWon }: { participants: ApiParticipant[]; skinsWon: Record<string, number> }) {
const rows = participants
.map((p) => ({ participant: p, skins: skinsWon[p.id] ?? 0 }))
.sort((a, b) => b.skins - a.skins)
const totalAwarded = Object.values(skinsWon).reduce((sum, v) => sum + v, 0)
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Skins-tavle</span>
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
{totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
</span>
</div>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{rows.map(({ participant, skins }) => (
<li key={participant.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{participant.display_name}</span>
<span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
</li>
))}
</ul>
</div>
)
}
// --- Matchstatus (to-sidede formater) ----------------------------------------
function MatchStatus({ result, sides }: { result: ApiFormatResult; sides: ApiSide[] }) {
if (!result.ready) {
return (
<div className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
Venter at begge sider er komplette og har beregnet handicap før matchstatus kan vises.
</div>
)
}
const statusText = result.match_status_text
? substituteSideLabels(result.match_status_text, sides)
: result.hlh_points_a !== null && result.hlh_points_b !== null
? `${result.hlh_points_a}-${result.hlh_points_b}`
: null
const lead = result.match_lead ?? 0
const holesPlayed = result.match_holes_played ?? 0
const holesRemaining = result.match_holes_remaining ?? 0
const totalHoles = holesPlayed + holesRemaining
const tone: "a" | "b" | "neutral" = lead > 0 ? "a" : lead < 0 ? "b" : "neutral"
const widthA = holesPlayed > 0 ? leadZoneFraction(lead, totalHoles) * 100 : 50
const widthB = 100 - widthA
const zoneAClass = tone === "a" ? "bg-primary" : tone === "b" ? "bg-primary/12" : "bg-muted"
const zoneBClass = tone === "b" ? "bg-brand-orange" : tone === "a" ? "bg-brand-orange/12" : "bg-muted"
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-bold text-muted-foreground">Matchstatus</span>
{result.match_is_closed && <Badge variant="default">Avgjort</Badge>}
{result.match_is_dormie && !result.match_is_closed && <Badge variant="outline">Dormie</Badge>}
</div>
<div
className="grid h-20 w-full overflow-hidden rounded-2xl"
style={{ gridTemplateColumns: `minmax(0, ${widthA}fr) auto minmax(0, ${widthB}fr)` }}
>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneAClass)} />
<div className="flex h-full items-center justify-center px-3">
<div className="flex flex-col items-center rounded-xl border border-border/60 bg-card/95 px-3 py-1.5 shadow-sm shadow-black/10 backdrop-blur-sm">
{statusText ? (
<>
<span
className={cn(
"text-xl font-extrabold leading-none tabular-nums sm:text-2xl",
tone === "a" ? "text-primary" : tone === "b" ? "text-brand-orange" : "text-foreground",
)}
>
{statusText}
</span>
<span className="mt-1 text-[11px] font-semibold text-muted-foreground">
{holesPlayed} hull spilt{holesRemaining > 0 && `, ${holesRemaining} igjen`}
</span>
</>
) : (
<span className="text-xs font-bold text-muted-foreground text-pretty">Ingen hull spilt ennå</span>
)}
</div>
</div>
<div className={cn("h-full transition-[grid-template-columns] duration-300", zoneBClass)} />
</div>
{result.hole_results && result.hole_results.length > 0 && (
<ul className="flex flex-wrap gap-1">
{result.hole_results.map((r, i) => (
<li
key={i}
aria-label={
r === "halved" ? `Hull ${i + 1}: delt` : `Hull ${i + 1}: ${r === "a" ? sides[0]?.label?.trim() || "Side A" : sides[1]?.label?.trim() || "Side B"} vant`
}
className={cn(
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
r === "a" && "bg-primary text-primary-foreground",
r === "b" && "bg-brand-orange text-brand-orange-foreground",
r === "halved" && "border border-border bg-muted text-muted-foreground",
)}
>
{r === "halved" ? "½" : r.toUpperCase()}
</li>
))}
</ul>
)}
</div>
)
}
// --- Screen ------------------------------------------------------------------
export function WatchRound({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboard | null>(null)
const [formatResult, setFormatResult] = useState<ApiFormatResult | null>(null)
const [flightGroup, setFlightGroup] = useState<{ flight_group_id: string | null; flights: ApiFlightSummary[] } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
// Innlogget bruker (kan være anonym -- se moduldoc). Kun brukt til
// RoundMessages sin slett-knapp-synlighet; "" er trygt som fallback
// siden ingen ekte author_user_id/eier-id noensinne er en tom streng.
const [viewerId, setViewerId] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setViewerId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
@ -296,14 +112,12 @@ export function WatchRound({ roundId }: { roundId: string }) {
if (cancelled) return
setRound(roundData)
if (roundData.play_format === "stroke" || roundData.play_format === "stableford" || roundData.play_format === "skins") {
const lbRes = await fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" })
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
}
if (TWO_SIDED_FORMATS.has(roundData.play_format) || roundData.play_format === "skins") {
const frRes = await fetch(`/public/rounds/${roundId}/format-result`, { credentials: "include" })
if (frRes.ok && !cancelled) setFormatResult(await frRes.json())
}
const [lbRes, flightRes] = await Promise.all([
fetch(`/public/rounds/${roundId}/leaderboard`, { credentials: "include" }),
fetch(`/public/rounds/${roundId}/flight-group`, { credentials: "include" }),
])
if (lbRes.ok && !cancelled) setLeaderboard(await lbRes.json())
if (flightRes.ok && !cancelled) setFlightGroup(await flightRes.json())
}
void load()
return () => {
@ -311,6 +125,14 @@ export function WatchRound({ roundId }: { roundId: string }) {
}
}, [roundId, refreshKey])
// Anker-scroll til kommentarseksjonen (ADR-047) -- se samme mønster/
// begrunnelse i round-detail.tsx.
useEffect(() => {
if (!round) return
if (window.location.hash !== "#kommentarer") return
document.getElementById("kommentarer")?.scrollIntoView({ block: "start" })
}, [round])
if (error) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
@ -341,6 +163,7 @@ export function WatchRound({ roundId }: { roundId: string }) {
Følger live
</span>
</div>
<WatchTabs roundId={roundId} active="score" />
</header>
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
@ -363,26 +186,28 @@ export function WatchRound({ roundId }: { roundId: string }) {
<span className="text-sm text-muted-foreground">Spilt av {round.owner_display_name}</span>
</div>
{TWO_SIDED_FORMATS.has(round.play_format) && formatResult && (
<MatchStatus result={formatResult} sides={round.sides} />
)}
<WatchPlayers
participants={round.participants}
leaderboardEntries={leaderboard?.entries ?? []}
flightGroup={flightGroup}
/>
{round.play_format === "skins" && formatResult?.skins_won && (
<SkinsBoard participants={round.participants} skinsWon={formatResult.skins_won} />
)}
{(round.play_format === "stroke" || round.play_format === "stableford") && leaderboard && (
<StrokeLeaderboard leaderboard={leaderboard} />
)}
{!TWO_SIDED_FORMATS.has(round.play_format) &&
round.play_format !== "skins" &&
round.play_format !== "stroke" &&
round.play_format !== "stableford" && (
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen live-visning tilgjengelig for dette formatet ennå.
</p>
)}
{/* Kommentarer/bilder (ADR-044) -- BUG funnet 2026-08-06: fantes
aldri her, kun round-detail.tsx sin egen "Score"-fane. En
person runden er delt med hadde dermed ingen vei til å se eller
delta i samtalen. `GET/POST /rounds/{id}/messages` er allerede
synlighets-gatet (_can_view_round, ikke eier/deltaker-only),
samme endepunkt gjenbrukes uendret -- ingen ny backend-kode. */}
{/* Anker-mål for "Kommentarer"-lenken fra leaderboardets utvidede
rader (round-leaderboard.tsx) -- samme seksjon, ikke en duplikat. */}
<div id="kommentarer" className="scroll-mt-20">
<RoundMessages
roundId={roundId}
currentUserId={viewerId ?? ""}
roundOwnerUserId={round.owner_user_id}
refreshKey={refreshKey}
/>
</div>
</main>
</div>
)

View file

@ -0,0 +1,29 @@
"use client"
// Scorekort-fanen for tilskuer-visningen (ADR-044, 2026-08-06). Gjenbruker
// round-scorecard.tsx sin RoundScorecard UENDRET (samme formatspesifikke
// rutenett som eiersiden -- se komponentens `publicMode`-prop), kun matet
// fra /public/rounds/* i stedet for /rounds/*. Egen sticky header + fane-
// stripe her, siden RoundScorecard sin egen header slås av med
// `embedded`.
import { Trophy } from "lucide-react"
import { WatchTabs } from "@/components/watch-tabs"
import { RoundScorecard } from "@/components/round-scorecard"
export function WatchScorecard({ roundId }: { roundId: string }) {
return (
<div className="min-h-dvh bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
<span className="flex min-h-11 items-center gap-1.5 text-base font-bold text-foreground">
<Trophy aria-hidden="true" className="size-5 text-primary" />
Følger live
</span>
</div>
<WatchTabs roundId={roundId} active="scorecard" />
</header>
<RoundScorecard roundId={roundId} embedded publicMode />
</div>
)
}

View file

@ -0,0 +1,47 @@
"use client"
// Fane-stripe for tilskuer-visningen (/watch/[id], ADR-044, 2026-08-06).
// Bevisst IKKE en gjenbruk av RoundHeader (eiersidens fane-mekanisme) --
// RoundHeader render alltid et "Administrer"-tannhjul som åpner
// ManageRoundDialog (Rediger/Fullfør/Slett-runde), uansett hvilke handlere
// som gis inn. En tilskuer skal aldri kunne nå de handlingene i det hele
// tatt -- denne komponenten inneholder rett og slett ingen slik kode, så
// det finnes ingen vei til at de vises ved et uhell.
import Link from "next/link"
import { cn } from "@/lib/utils"
export type WatchTab = "score" | "scorecard" | "leaderboard"
const TABS: { key: WatchTab; label: string }[] = [
{ key: "score", label: "Score" },
{ key: "scorecard", label: "Scorekort" },
{ key: "leaderboard", label: "Leaderboard" },
]
export function WatchTabs({ roundId, active }: { roundId: string; active: WatchTab }) {
const hrefFor = (tab: WatchTab) =>
tab === "score" ? `/watch/${roundId}` : `/watch/${roundId}/${tab}`
return (
<nav aria-label="Rundevisning" className="mx-auto w-full max-w-xl px-4 pt-3">
<div className="inline-flex w-full gap-1 rounded-full bg-muted p-1">
{TABS.map((tab) => (
<Link
key={tab.key}
href={hrefFor(tab.key)}
aria-current={active === tab.key ? "page" : undefined}
className={cn(
"flex min-h-11 flex-1 items-center justify-center rounded-full px-3 text-sm font-bold transition-all duration-200 ease-in-out",
active === tab.key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab.label}
</Link>
))}
</div>
</nav>
)
}

16
frontend/lib/geo.ts Normal file
View file

@ -0,0 +1,16 @@
export type LatLng = { lat: number; lng: number }
/**
* Storsirkel-avstand mellom to koordinater, i meter. Ren klient-side
* beregning -- ingen Mapbox-kall (ADR-048 kostnadskontroll-invariant B).
*/
export function haversineMeters(a: LatLng, b: LatLng): number {
const R = 6371000
const toRad = (d: number) => (d * Math.PI) / 180
const dLat = toRad(b.lat - a.lat)
const dLng = toRad(b.lng - a.lng)
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}

View file

@ -13,6 +13,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"mapbox-gl": "3.27.0",
"next": "16.2.6",
"react": "^19",
"react-dom": "^19",

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 B

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 939 B

After

Width:  |  Height:  |  Size: 909 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 406 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.