Compare commits

..

No commits in common. "bcb712810fe0fc6db9b801a42860f199029abc3d" and "546900c2d6e6c6e640454dc033cfd31daeb332fc" have entirely different histories.

46 changed files with 867 additions and 8487 deletions

View file

@ -539,10 +539,7 @@
"Bash(python3 -m py_compile tests/test_account_merge.py)",
"Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")",
"Bash(grep -n 'href={`/tournaments/\\\\|Link.*tournaments/\\\\${' components/tournament-detail.tsx)",
"Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)",
"Bash(TEECUP_API_ORIGIN=\"http://127.0.0.1:8102\" npx next build)",
"Bash(cp -r public .next/standalone/public)",
"Bash(cp -r .next/static .next/standalone/.next/static)"
"Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

1
.gitignore vendored
View file

@ -5,4 +5,3 @@ Temp-uploads/
.venv/
.pytest_cache/
.forgejo-runner/
backups/

View file

@ -1,27 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 089
-- RLS på tournament_round_group (lukker et hull fra migrasjon 084)
-- =====================================================================
-- Oppdaget under kartlegging til parity-arbeidet (score-føring i
-- turnering vs. frittstående runde, 2026-08-21): tournament_round_group
-- (opprettet i 084_tournament_round_groups.sql, utvidet i
-- 088_shotgun_start.sql) fikk ALDRI standard org_isolation-RLS-policyen
-- som ellers gjelder alle domenetabeller (ADR-003) -- i strid med
-- arkitektur-invarianten i CLAUDE.md. Ingen begrunnet unntak (i
-- motsetning til f.eks. round_shot/round_hole/round_message, som
-- bevisst står utenfor RLS per ADR-033 Beslutning A). I praksis kun
-- beskyttet av WHERE-ledd i applikasjonskoden frem til nå.
--
-- Ingen dataendring, kun policy -- alle eksisterende spørringer går
-- allerede via org_connection() (setter app.current_org), så dette er
-- additivt, ikke en atferdsendring for korrekt scopet kode.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament_round_group ENABLE ROW LEVEL SECURITY;
ALTER TABLE tournament_round_group FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON tournament_round_group
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);

View file

@ -1,109 +0,0 @@
-- =====================================================================
-- TeeCup — Rundespesifikk kommentartråd for org-turneringer, migrasjon
-- 090 (ADR-102). Speiler `058_round_messages.sql` +
-- `059_post_reactions_and_comments.sql` sin round_message/
-- round_message_reaction/round_message_comment, MEN med organization_id
-- + full RLS -- i motsetning til den frittstående sidens bevisst
-- RLS-frie plain_connection()-mønster (ADR-033 Beslutning A), er
-- turneringer ER org-scopet (CLAUDE.md-invarianten).
--
-- Én tråd PER TURNERINGSRUNDE (hele feltet på tvers av flighter deler
-- tråd, IKKE per flight) -- bevisst valgt av bruker, se ADR-102.
--
-- Skriverett = leserett = org-medlemskap (get_authorized_org), IKKE en
-- ny synlighetsmodell -- samme presedens som individual_tournaments.py
-- sin egen modul-docstring allerede fastslår for runde-/deltaker-
-- OPPSETT ("vanlig org-medlemsnivå", ikke det strengere self-only-kravet
-- update_hole har for selve SCORING-en). Moderering: forfatter ELLER
-- org-admin (is_org_admin), samme regel som Banter Board (ADR-025).
--
-- Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging (round_
-- message_tag-motparten er utelatt), ingen WebSocket-kringkasting
-- (individual-tournament-detail.tsx har ingen sanntids-tilkobling i
-- det hele tatt ennå -- eget, uprioritert gap).
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_message (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_id uuid NOT NULL,
author_user_id uuid NOT NULL REFERENCES app_user(id),
-- Frosset ved skrivetidspunkt, samme prinsipp som round_message.author_display_name
-- -- fullt navn (roster-/forfatter-kontekst, ikke direkte adressering,
-- CLAUDE.md navneformat-regel).
author_display_name text NOT NULL,
body text,
image_key text,
-- Offline-kø-idempotens (samme mønster som round_message.client_message_id,
-- migrasjon 073) -- et gjentatt POST med samme id (avbrutt synk prøvd på
-- nytt) returnerer den allerede opprettede meldingen fremfor en duplikat.
client_message_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_id)
REFERENCES tournament_round(organization_id, id) ON DELETE CASCADE,
CHECK (body IS NOT NULL OR image_key IS NOT NULL)
);
CREATE INDEX ON tournament_round_message (organization_id, tournament_round_id, created_at);
CREATE INDEX ON tournament_round_message (organization_id, author_user_id);
CREATE UNIQUE INDEX ON tournament_round_message (tournament_round_id, client_message_id)
WHERE client_message_id IS NOT NULL;
CREATE TABLE tournament_round_message_reaction (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_message_id uuid NOT NULL REFERENCES tournament_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 (tournament_round_message_id, user_id)
);
CREATE INDEX ON tournament_round_message_reaction (organization_id, tournament_round_message_id);
CREATE TABLE tournament_round_message_comment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_message_id uuid NOT NULL REFERENCES tournament_round_message(id) ON DELETE CASCADE,
parent_comment_id uuid REFERENCES tournament_round_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 ON tournament_round_message_comment (organization_id, tournament_round_message_id, created_at);
CREATE INDEX ON tournament_round_message_comment (organization_id, parent_comment_id);
-- ---------------------------------------------------------------------
-- Row-Level Security (samme org_isolation-policy-mønster som 040)
-- ---------------------------------------------------------------------
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'tournament_round_message', 'tournament_round_message_reaction',
'tournament_round_message_comment'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t);
EXECUTE format($p$
CREATE POLICY org_isolation ON %I
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
$p$, t);
END LOOP;
END $$;
-- Samme "slett og opprett på nytt, aldri rediger"-prinsipp som
-- round_message/message -- ingen UPDATE-grant på selve meldingen/
-- kommentaren. Reaksjoner får UPDATE (emoji-bytte er en UPSERT).
GRANT SELECT, INSERT, DELETE ON tournament_round_message TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_round_message_reaction TO teecup_app;
GRANT SELECT, INSERT, DELETE ON tournament_round_message_comment TO teecup_app;

View file

@ -1,75 +0,0 @@
-- =====================================================================
-- TeeCup — Slag-for-slag GPS-avstandsmåling for org-turneringer,
-- migrasjon 091 (ADR-103). Speiler 060_round_shot.sql/
-- 061_round_shot_end_method.sql (ADR-048, frittstående runder)
-- strukturelt, men org-scopet (RLS) og med en annen eierskaps-nøkkel.
--
-- Eierskap: FK direkte til (organization_id, tournament_round_
-- participant_id) + hole_number -- IKKE tournament_round_hole_id.
-- `tournament_round_hole` opprettes (ulikt frittstående round_hole,
-- som pre-oppretter alle 18 ved rundestart) FØRST ved første
-- score-innsending (update_hole sin INSERT ... ON CONFLICT,
-- individual_tournaments.py) -- en spiller skal kunne måle et slag
-- FØR de fullfører hullets score, så en FK mot en hull-rad som kanskje
-- ikke finnes ennå ville vært feil. Samme LEFT JOIN-tolerante mønster
-- som get_tournament_round_hole_target_points/list_round_participant_
-- holes allerede bruker for nøyaktig dette problemet.
--
-- Turneringsmodellen har ALDRI en side/lag-XOR (kun individuelt) --
-- ingen tilsvarende round_side-gren trengs her, ulikt round_shot.
--
-- Koordinater: samme rene double precision lat/lng-par som round_shot
-- (ADR-048 Beslutning D) -- ingen PostGIS.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_shot (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_participant_id uuid NOT NULL,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
-- Hvem som MÅLTE slaget -- ikke nødvendigvis samme som deltakeren selv,
-- samme "en lenket medspiller/org-admin kan føre" som update_hole.
recorded_by_user_id uuid NOT NULL REFERENCES app_user(id),
-- Eksplisitt, hull-tolerant rekkefølge (IKKE ORDER BY captured_at) --
-- samme begrunnelse som round_shot.shot_number.
shot_number integer NOT NULL CHECK (shot_number > 0),
club text NOT NULL CHECK (char_length(club) <= 50),
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_method text NOT NULL CHECK (end_method IN ('gps', 'map_tap')) DEFAULT 'gps',
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 den rundespesifikke kommentartråden (ADR-102) -- lenke,
-- ikke bare et bool-flagg, samme prinsipp som round_shot.
shared_tournament_round_message_id uuid REFERENCES tournament_round_message(id) ON DELETE SET NULL,
captured_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_participant_id)
REFERENCES tournament_round_participant(organization_id, id) ON DELETE CASCADE,
UNIQUE (tournament_round_participant_id, hole_number, shot_number)
);
CREATE INDEX ON tournament_round_shot (organization_id, tournament_round_participant_id, hole_number, shot_number);
DO $$
BEGIN
ALTER TABLE tournament_round_shot ENABLE ROW LEVEL SECURITY;
ALTER TABLE tournament_round_shot FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON tournament_round_shot
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
END $$;
-- Samme "slett og opprett på nytt, aldri rediger"-prinsipp som round_shot
-- -- ett smalt UPDATE-unntak for delings-lenken.
GRANT SELECT, INSERT, DELETE ON tournament_round_shot TO teecup_app;
GRANT UPDATE (shared_tournament_round_message_id) ON tournament_round_shot TO teecup_app;

View file

@ -1,36 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 092
-- Visningsmodus for netto-scoring ("Jakter Jesper"): start på
-- -spillehandicap, brutto-mot-par løpende -- IKKE en ny scoring_method,
-- se ADR (neste ledige nummer) for hele resonnementet. Pluss en ny,
-- generelt tilgjengelig tiebreak-verdi (lavest spillehandicap).
-- =====================================================================
-- `net_display_style`: 'standard' (uendret oppførsel -- dagens
-- gradvise, slagindeks-baserte netto-visning, default, ingen regresjon
-- for eksisterende turneringer) eller 'hunt' (hele spillehandicapet
-- trukket fra med én gang, brutto-til-par-differansen vist løpende).
-- Kun meningsfullt for scoring_method='stroke_net' -- håndheves i
-- app-laget (samme presedens som _SCORING_METHODS_REQUIRING_HANDICAP),
-- ikke en tverr-kolonne CHECK her.
--
-- `lowest_playing_hcp`: ny tiebreak-verdi (utvider migrasjon 085/086
-- sitt mønster) -- spilleren med lavest SPILLEHANDICAP (tee-spesifikt
-- tall, IKKE WHS-indeksen `lowest_hcp` bruker) i turneringens siste
-- runde vinner uavgjortheten. Generelt tilgjengelig for alle
-- stroke-play-turneringer, ikke bare hunt-visningen -- tiebreak er
-- allerede uavhengig av scoring_method.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
CHECK (net_display_style IN ('standard', 'hunt'));
ALTER TABLE tournament
DROP CONSTRAINT tournament_tiebreak_winner_method_check,
ADD CONSTRAINT tournament_tiebreak_winner_method_check
CHECK (tiebreak_winner_method IN ('none', 'countback', 'lowest_hcp', 'playoff', 'lowest_playing_hcp')),
DROP CONSTRAINT tournament_tiebreak_field_method_check,
ADD CONSTRAINT tournament_tiebreak_field_method_check
CHECK (tiebreak_field_method IN ('none', 'countback', 'lowest_hcp', 'lowest_playing_hcp'));

View file

@ -1,28 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 093
-- "Jakter Jesper"-visningsmodus (ADR-106) utvidet til frittstående
-- runder OG lagturnering-økter -- del 2, se ARCHITECTURE_DECISIONS.md.
-- =====================================================================
-- `round.net_display_style`: samme betydning som `tournament.net_
-- display_style` (migrasjon 092) -- 'standard' (default, uendret
-- gradvis netto-visning) eller 'hunt' (helt spillehandicap trukket
-- fra med én gang, brutto-til-par vist løpende). Kun meningsfullt for
-- play_format='stroke' -- håndheves i app-laget.
--
-- `session.net_display_style`: samme, men for én økt i en org-
-- LAGturnering (singles/fourball, scoring_mode='stroke'). Lagturneringer
-- har scoring_method=NULL på selve turneringen (ADR-011s to-lags-modell
-- har ikke det begrepet) -- derfor øktnivå, ikke turneringnivå, se
-- ADR-106-tillegget for begrunnelsen (ingen eksisterende innstillings-UI
-- på turneringnivå for lagformatet, økt-editoren fantes allerede).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
CHECK (net_display_style IN ('standard', 'hunt'));
ALTER TABLE session
ADD COLUMN net_display_style text NOT NULL DEFAULT 'standard'
CHECK (net_display_style IN ('standard', 'hunt'));

View file

@ -9912,868 +9912,6 @@ utvidelsene faktisk bygges.
---
## ADR-098: Offentlig "Følg live" for individuelle turneringer (2026-08-21)
**Kontekst:** bruker satte "Klubbmesterskap 2026" (individuell turnering)
til offentlig synlig og delte lenken. Mottaker (ikke innlogget) fikk et
ødelagt Cup/lag-format-adminskjermbilde. Undersøkelse avdekket at
org-individuelle turneringer aldri har hatt en fungerende offentlig
tilskuerside -- bevisst utsatt 2026-08-14 (FEATURE_BACKLOG.md,
"Flaggturnering Del B": "egen, større oppgave hvis/når etterspurt").
Bruker: "gjør det ordentlig på første forsøk", pluss bruk V0 til det
visuelle. Grundig todelt research-runde gjennomført FØR bygging (Cup-
sidens fulle funksjonssett + ADR-027 sin sanntidsmekanisme; den
autentiserte leaderboard-beregningens kostnad og offentlig-trygghet;
eksisterende offentlig runde-leaderboard som designreferanse, IKKE som
kode-gjenbruk -- ulik datamodell, se under).
**Beslutning A -- gjenbruk ADR-027 sin sanntidsmekanisme UENDRET, ingen ny
WebSocket-infrastruktur.** `broadcast_live_update(tournament_id)` (samme
funksjon Cup-formatets `recompute_and_cache_match_state` allerede kaller)
kobles inn i `_recompute_round_score` (individual_tournaments.py) --
kjøres etter HVER hull-innsending for en individuell runde, uansett
scoringsmetode (Copenhagen/BBB sine egne recompute-funksjoner kalles
alltid ETTER denne, dekket automatisk). Samme "noe endret, hent på
nytt"-signal over samme `/ws/public/tournaments/{id}/live`-kanal Cup-
siden allerede åpner -- den nye siden trenger ingen egen WS-rute.
**Beslutning B -- ETT samlet, kumulativt leaderboard, ikke ett per
runde.** Den autentiserte `_compute_individual_standings` (gjenbrukt
UENDRET for den offentlige varianten) beregner posisjon/i dag/thru/total
på tvers av HELE turneringen, ikke isolert per runde -- "i dag" er alltid
gjeldende/sist påbegynte runde, "total" er summen så langt. Den første
V0-mock-modelleringen (hver runde = sitt eget separate leaderboard) matchet
IKKE denne modellen -- rettet ved integrering: rundevelgeren i UI-et endrer
IKKE selve leaderboard-radene lenger, den styrer kun hvilken rundes
hull-for-hull-rutenett som lastes (lat, per rad) når en rad utvides.
Kostnadsvurdert FØR bygging: samme billige ~4-spørrings-beregning som det
autentiserte leaderboardet, trygt å kjøre på hver offentlige sidevisning
og hvert sanntids-signal.
**Beslutning C -- offentlig-trygg respons, bevisst valgt HCP-synlighet.**
Ny `PublicLeaderboardEntry`-modell (registration.py) speiler den
autentiserte `LeaderboardEntry`, fjerner kun `player_id` (ingen annen PII
funnet -- ingen DSQ-begrunnelsestekst finnes i modellen, `status` er en
trygg enum). HCP-indeks lagt til bevisst etter eksplisitt spørsmål til
bruker (AskUserQuestion) -- "ja, vis HCP", samme presedens som
frittstående runders offentlige leaderboard (`round-leaderboard.tsx`)
allerede har.
**Beslutning D -- rundestatus utledes, ingen ny kolonne.**
`tournament_round` har ingen status-kolonne (bekreftet mot skjemaet).
Ny `GET /public/tournaments/{id}/rounds` utleder "ikke startet"/"pågår"/
"fullført" fra `tournament_round_score.holes_played` sammenlignet mot
banens hullantall -- samme prinsipp den interne `today_sequence`-
utregningen i `_attach_stroke_play_columns` allerede bruker for "dagens
runde", generalisert til å gjelde HVER runde og lagt til en "alle
ferdig"-gren. Ingen migrasjon.
**Beslutning E -- eksisterende offentlig runde-leaderboard
(`/public/rounds/{id}/leaderboard`, `round-leaderboard.tsx` sin
`publicMode`) er en DESIGNREFERANSE, ikke gjenbrukt kode.** Den hører til
den frittstående personlige "Runder"-funksjonen (`round`/
`round_participant`-tabellene), en helt annen datamodell enn org-
turneringers `tournament_round`/`tournament_round_participant`. Egen ny
rute (`GET .../rounds/{round_id}/participants/{tournament_participant_id}
/holes`) bygget i stedet, samme spørringsmønster som den autentiserte
`list_round_participant_holes`, men nøkkelt på `tournament_participant_id`
(offentlig-trygg, matcher leaderboard-responsen) i stedet for det
runde-interne `tournament_round_participant_id`.
**Beslutning F -- format avgjøres SERVER-side, ikke klient-side.**
`app/tournaments/[id]/print/...` og `tournament-router.tsx` (samme dag,
se punktet rett over dette i loggen) viste hvorfor: en klient-side
formatoppslag som feiler (f.eks. 401) er lett å håndtere galt. Den nye
`app/t/[id]/live/page.tsx` gjør derfor et enkelt server-side `fetch()`
mot `/public/tournaments/{id}` (samme "ikke gjennom next.config.mjs sin
rewrites()"-mønster som `app/logg-inn/page.tsx`) FØR noe rendres, og
velger `PublicLive` (Cup) eller `PublicIndividualLive` (individuell)
deretter -- en individuell turnering kan aldri, ikke engang kortvarig,
vise Cup-sidens UI.
**Frontend:** ny `components/public-individual-live.tsx` (Claude-skrevet
V0-prompt, `Temp-uploads/v0-prompt-public-individual-live.md`,
presentasjonskomponentene UENDRET fra eksporten -- kun datalaget skrevet
om for ekte fetching+sanntid). `public-tournament.tsx` sin "Program"-
seksjon (viste før alltid en tom Cup-økt-liste for individuelle
turneringer) viser nå en rundeliste med samme "Pågår nå"/"Fullført"-merker
som resten av appen (`watch-round.tsx`).
**Status:** `tsc --noEmit` rent, 55/55 vitest, backend importverifisert
(`app.main` boot-testet i kjørende container). IKKE bygget/rullet ut ennå.
Ingen migrasjon.
---
## ADR-099: Flight/gruppe-scoping i ScoreTab (2026-08-21)
**Kontekst:** bruker ba om full opplevelses-paritet mellom score-føring i
en org-turnering og en frittstående individuell runde ("Jeg skal i
utgangspunktet ha en identisk opplevelse når jeg fører score for meg (og
eventuelt de andre i flighten min)..."). Grundig undersøkelse fant det
mest strukturelle gapet: `ScoreTab` viste HELE turneringsfeltet
(alfabetisk, alle deltakere) i stedet for kun brukerens egen flight/
gruppe -- `tournament_round_group` (bygget for utslags-/tee-sheet-paring,
ADR-096) ble KUN lest av administrator-parings-skjermen, aldri av selve
score-føringsskjermen. Første punkt i en avtalt 8-punkts parity-
rekkefølge; auto-advance mellom spillere i flighten er et EGET, senere
steg -- denne runden endrer kun HVEM som vises/velges som standard.
**Beslutning A -- visningsendring, IKKE en ny autorisasjonsmodell.**
`update_hole` er allerede self-only (eller org-admin) via
`user_is_own_tournament_participant` (`team_authz.py`, innstrammet
2026-07-30). Denne runden endrer kun `ScoreTab`s participant-VELGER
(pill-listen) og standardvalg av `participantId` -- en org-admin kan
fortsatt bla til og rette en hvilken som helst deltaker via "Vis hele
feltet". Lesetilgang på andres hull (`list_round_participant_holes`) og
BBB-scoring (bevisst hele-feltet, egen docstring-begrunnelse) er UENDRET.
**Beslutning B -- fallback til hele feltet når gruppe mangler, ikke en
blokkerende tilstand.** Ikke alle runder har kjørt "Grupper og
startliste" ennå. `ScoreTab` faller da tilbake til dagens oppførsel (hele
feltet) med en synlig inline-merknad om hvorfor, i stedet for å skjule
scoring-muligheten. Verifisert eksplisitt i scratch (se Status).
**Beslutning C -- `is_self` beregnes server-side, ikke rå `user_id` til
klienten.** `list_round_participants`/`add_round_participant`/
`update_round_participant_tee` (`individual_tournaments.py`) utvidet med
`(p.user_id::text = $2)`-sammenligning (samme `is_mine`-mønster som
`rounds.py`), IKKE et rått `user_id`-felt -- unngår å lekke andre
spilleres kontokobling til enhver org-medlem-klient. `player.user_id` er
nullable (spillere uten konto) -- NULL-verdien måtte eksplisitt
`COALESCE`-es til `false` (fanget av en dedikert ny test,
`test_round_participant_flight_scoping.py`, ellers 500 pga.
Pydantic-validering av et NULL-boolsk felt).
**Beslutning D -- lukket et reelt RLS-hull funnet underveis.**
`tournament_round_group` (migrasjon 084/088) manglet HELT standard
org_isolation-RLS (bekreftet: ingen `ENABLE ROW LEVEL SECURITY`/
`CREATE POLICY` i noen av de to migrasjonene, uten den typen begrunnede
unntak `round_shot`/`round_hole` har per ADR-033). Lukket med ny
migrasjon `089_tournament_round_group_rls.sql`, samme mønster som
`001_initial_schema.sql`. I praksis var isolasjonen tidligere kun
app-lagets WHERE-ledd -- additiv, ikke en atferdsendring.
**Status:** 164/164 pytest (inkl. ny dedikert test), `tsc --noEmit` rent.
RLS-policyen verifisert direkte mot en engangs scratch-database (SQL som
`teecup_app`-rollen med feil `app.current_org` satt returnerte 0 rader,
riktig org 1 rad). Full-stack scratch-verifisering (egen scratch-db, egen
API-container, egen MinIO, ekte innlogging via `/auth/login-password`,
ingen fetch-mocking) bekreftet: gruppert runde defaulter til egen flight
(3 av 6 vist), "Vis hele feltet"/"Vis kun min flight" veksler korrekt
begge veier, ugruppert runde faller korrekt tilbake til hele feltet med
riktig merknad, mørk modus og smal mobilskjerm uendret lesbare. Alle
scratch-ressurser (container/db/rolle) ryddet opp etter verifisering.
IKKE bygget/rullet ut mot ekte containere ennå.
---
## ADR-100: Kjedet flerspiller-scoring i ScoreTab (2026-08-22)
**Kontekst:** steg 2 av den samme 8-punkts parity-rekkefølgen som
ADR-099 (steg 1, flight-scoping) startet. Bruker godkjente en V0-prompt
(`Temp-uploads/v0-prompt-tournament-scoring-wizard.md`) for en hull-
først kortliste + kjedet flerspiller-veiviser, samme mønster
frittstående runder alt har (`PlayerHoleCards`/`ScoringWizard`,
`round-detail.tsx`). Eksporten (`Temp-uploads/tee-cup-login-screen.zip`
-- filnavnet stemte ikke med innholdet, verifisert nøye før bruk, jf.
tidligere feilopplastings-hendelse denne uken) ble lest i sin helhet
(830 linjer) og hadde høy troskap mot prompten.
**Beslutning A -- V0s EGNE skjemakontroller ble IKKE tatt i bruk, kun
dens NYE lag (hull-navigator/kortliste/kontekst-rad/kjede-footer).**
`HoleStatsSheet` (nå omdøpt `TournamentScoringWizard`) hadde ALLEREDE
ekte, mer fullverdige steg-komponenter (golf-fagbegreper, netto-par-
markering via `strokesReceived`, `ownBagClubs`, rangefinder, hull-
historikk-oversikt) enn V0s forenklede stand-ins -- disse gjenbrukes
UENDRET, kun utvidet med kjeding. `sheetStepsFor` fikk et nytt
`strokes_only`-tilfelle (ett steg, ingen "overview") slik at ALLE tre
statLevel-nivåer nå går via wizard -- `HoleGrid`s tidligere bare
inline-tallfelt for strokes_only er fjernet (død kode).
**Beslutning B -- kjede-logikken eies av `ScoreTab` (forelderen), ikke
av wizard-komponenten selv.** Speiler `advanceWizardPlayer()`-mønsteret
i `round-detail.tsx` nøyaktig: wizardkomponenten er ren presentasjon
(kontekst-rad + `onAdvance`/`onSwitchPlayer`-callbacks), `ScoreTab`
beregner `chain` (flight-scoped, ekskluderer DSQ/RTD/DNF/DNS via
`tournament_participant.status`), hvem som er neste, og hull-wrap ved
"Ferdig". Ingen ny autorisasjon -- samme self-only/org-admin-regel som
ADR-099 Beslutning A, uendret.
**Beslutning C -- ingen backend-endring i det hele tatt.** Gjenbruker
`GET .../rounds/{roundId}/participants/{id}/holes` parallelt per
flight-medlem (samme N-parallelle mønster som `round-detail.tsx` sin
`holesByParticipant`/`loadHoles`) og eksisterende
`PATCH .../holes/{holeNumber}` uendret.
**Beslutning D -- tre reelle bugs funnet i scratch, alle rettet FØR
utrulling (ingen av dem synlige ved kun `tsc`/typesjekk):**
1. Standardhull-valget (`activeHole`) kunne låse seg til hull 1 --
effekten kjørte FØR alle flight-medlemmers hull-data faktisk hadde
kommet inn, konkluderte feilaktig "hull 1 er første åpne" basert på
ufullstendige data, og ventet aldri på et bedre svar (guard-en
`if (activeHole !== null) return` hindret ny beregning). Rettet med
en eksplisitt "er ALT lastet ennå"-sjekk før standardvalget settes.
2. Auto-hopp mellom steg uteble for spiller nummer to (og senere) i
kjeden -- fordi wizard-instansen nå holdes I LIVE på tvers av
spillerbytter (ikke lenger remount per åpning som før), leste
vakt-nullstillingen (`enteredWithValueRef`) fortsatt FORRIGE spillers
`strokes`-verdi (React batcher `setStrokes(...)` fra reset-effekten,
den nye verdien er ikke synlig før neste render). Rettet ved å lese
fra `hole`-PROPEN (alltid fersk) i stedet for det lokale utkastet.
3. En diskvalifisert spiller blokkerte "hull ferdig"-sjekken (og dermed
standardhull-beregningen) PERMANENT, siden den daværende sjekken
krevde registrering fra HELE flighten (`flightParticipants`), ikke
bare de som faktisk kan scores (`chain`). En DSQ'et spiller får
ALDRI registrert ny score (self-only-regelen), så "alle ferdig"
ville aldri blitt sant for noe hull. Rettet: fullførthet-sjekkene
(hurtig-hopp-stripen OG standardhull-beregningen) bruker nå `chain`
-- kortlisten viser fortsatt ALLE flight-medlemmer, inkl. DSQ'et.
**Status:** 164/164 pytest (uendret -- ingen backend rørt), `tsc
--noEmit` rent. Full-stack scratch-verifisering (egen scratch-db, egen
API-container, egen MinIO, ekte innlogging, ingen fetch-mocking --
samme oppskrift som ADR-099) med en flight på 4 (blandet `stat_level`:
full/strokes_and_putts/strokes_only, én DSQ'et) bekreftet, ETTER at de
tre bugene over var rettet: standardhull-valg riktig, kjeding gjennom
alle tre gjenværende spillere riktig (inkl. korrekt "Neste: {navn}"-
tekst per steg-antall), DSQ'et spiller korrekt hoppet over i kjeden
men fortsatt synlig som kort, hull-fremgang etter "Ferdig" riktig,
"Vis hele scorekortet" (sekundær visning fra ADR-099) uendret og
fungerende, mørk modus OK. Selvregistrering (403 for ikke-egne
deltakere) bekreftet fungerende -- måtte midlertidig gjøre scratch-
brukeren til org-eier for å teste HELE kjeden (samme
selv-only-begrensning som ADR-099 Beslutning A, ikke en bug). Alle
scratch-ressurser ryddet opp etter verifisering. IKKE bygget/rullet ut
mot ekte containere ennå.
---
## ADR-101: Hull-for-hull-utvidelse i den autentiserte leaderboard-fanen (2026-08-22)
**Kontekst:** bruker, midt i forrige runde: "Leaderboard: Det må være
mulig å klikke på en enkeltspiller og få ekspandert raden, slik at man
ser scoren på hvert enkelt hull vedkommende har spilt (og summen av
runden(e) og første og siste ni)." `LeaderboardTab` hadde utvidelse
KUN for eclectic -- slagspill/netto/Stableford (`isStrokePlay`, vist
via `StrokePlayLeaderboard`, den klart vanligste gruppen) hadde INGEN
utvidelse. Mønsteret fantes allerede, ferdig bygget og verifisert, for
den OFFENTLIGE siden (`public-individual-live.tsx` sin `LeaderboardRow`
+ `HoleNine`, bygget samme uke) -- en reell asymmetri: tilskuersiden
hadde en funksjon den autentiserte org-siden manglet. Denne runden
PORTERER mønsteret (ingen ny V0-runde -- gjenbruker et allerede
designet/godkjent visuelt mønster), tilpasset den autentiserte
flerrunde-visningen.
**Beslutning A -- omfang: slagspill/netto/Stableford, ikke Copenhagen/
BBB/eclectic.** Eclectic har allerede sitt eget hull-for-hull-konsept
(beste-per-hull). Copenhagen (poeng) og BBB (observasjon om HELE
feltet) passer ikke inn i "hvert hull + sum + ut/inn"-rammen uten en
egen vurdering -- bevisst utenfor omfanget her.
**Beslutning B -- `stroke-play-leaderboard.tsx` sin `LeaderboardRow`-
TYPE ("eksakt kontrakt -- ikke endre") er UENDRET.** Nytt, valgfritt
prop-sett på selve `StrokePlayLeaderboard`-KOMPONENTEN i stedet
(`expandedId`/`onToggleExpand`/`renderExpanded`) -- forelderen
(`LeaderboardTab`) eier state og henting, komponenten er ren
presentasjon, samme "eier-logikk-i-forelder"-prinsipp som
`TournamentScoringWizard`/`ScoreTab` (ADR-100). Ingen effekt når
propsettet ikke sendes.
**Beslutning C -- kun FELT-rader (ikke ledere) er utvidbare.**
`StrokePlayLeaderboard` er en autoscrollende "kiosk-ticker"
(sticky-posisjonerte ledere, `leaderTops` beregnet via ResizeObserver)
-- en utvidet LEDER-rad ville i teorien håndteres riktig av den
eksisterende ResizeObserver-kaskaden, men risikoen for et sjeldent
geometri-avvik ble vurdert unødvendig når lederen(e) uansett allerede
er fremhevet og synlig øverst uten å måtte utvides. Rene felt-rader
har ingen slik sticky-avhengighet.
**Beslutning D -- nytt lite backend-endepunkt, IKKE gjenbruk av det
eksisterende round_participant_id-nøkkede.** `RoundCellOut` (per-runde-
cellen i leaderboard-responsen) += `tournament_round_id` (rent
tillegg, populert fra en spørring som allerede har `tr` i scope). Nytt
endepunkt `GET .../individual-leaderboard/{tournament_participant_id}
/rounds/{round_id}/holes` -- speiler `registration.py` sitt offentlige
`tournament_participant_id`-nøkkede endepunkt nøyaktig (samme
oppløsning til `tournament_round_participant.id` internt), men under
et EGET stiprefiks: samme stiform som den eksisterende
`.../participants/{round_participant_id}/holes` ville kollidert
(FastAPI kan ikke skille to param med ulik betydning på samme
posisjon). Leaderboardet viser ALLE spilte runder stablet ved
utvidelse (ikke ett hull-sett per valgt runde som den offentlige
siden) -- `StrokePlayLeaderboard` viser allerede én kolonne per runde,
ingen ekstra rundevelger trengs.
**Beslutning E -- netto-par-bevisst golfscore-form, ikke offentlig
sidens enklere brutto-only variant.** Nye `LeaderboardHoleNine`/
`LeaderboardRoundHoles` bruker filens EGNE `classify`/
`scoreMarkClasses` (samme `strokes_received`-bevisste logikk som
`HoleGrid`), ikke `public-individual-live.tsx` sin enklere `toneOf` --
konsistent med resten av den autentiserte scoreførings-/scorekort-
visningen.
**Status:** 165/165 pytest (164 + én ny dedikert test for det nye
endepunktet: riktig hull-data, 404 for ukjent runde). `tsc --noEmit`
rent. Full-stack scratch-verifisering (egen scratch-db, egen API-
container, egen MinIO, ekte innlogging, ingen fetch-mocking) med en
flerrunde-turnering (2 runder, én spiller begge, én kun runde 1)
bekreftet: utvidelse viser riktig Ut/Inn-seksjon (kun én, ingen
"Runde N"-overskrift siden spilleren bare spilte én runde), korrekt
Par-/Slag-sum per ni (36/63 verifisert mot faktisk seed-data), korrekt
golfscore-form (fylt oransje ved 3 over par, matcher "2+ fra
par"-regelen), chevron-rotasjon, riktig i mørk OG lys modus, smal
mobilskjerm. Alle scratch-ressurser ryddet opp. IKKE bygget/rullet ut
mot ekte containere ennå.
---
## ADR-102: Rundespesifikk kommentartråd for org-turneringer (2026-08-22)
**Kontekst:** punkt 7 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md, skrevet ned samme dag).
Bruker valgte å bygge dette FØR punkt 6 (GPS-slagmåling), fordi GPS-
delingsfunksjonen (ADR-048 Beslutning E) forutsetter et sted å dele
til. Frittstående runder har `RoundMessages` (ADR-044): en rundespesifikk
tråd med bilder, emoji-reaksjoner og trådede kommentarer (ADR-046),
offline-kø, @-tagging. Org-turneringer hadde kun den eksisterende, hele-
turnering-brede "Banter Board" (ADR-025, `message` med
scope='tournament_feed', kun på den offentlige `/t/[id]`-siden) — ingen
tråd knyttet til én bestemt runde i den autentiserte visningen.
**Beslutning A — ekte parallell til RoundMessages, IKKE en utvidelse av
Banter Board.** Bekreftet eksplisitt med bruker (AskUserQuestion):
Banter Board er turnering-bredt og upåvirket av dette; en NY
`tournament_round_message`-tabell (migrasjon 090) speiler
`round_message`/`round_message_reaction`/`round_message_comment`
(058/059) strukturelt, men med `organization_id` + full RLS (org-tenant-
invariant, CLAUDE.md) i stedet for frittstående sidens bevisste RLS-frie
`plain_connection()`-mønster (ADR-033 Beslutning A gjelder ikke her --
turneringer ER org-scopet).
**Beslutning B — én tråd PER TURNERINGSRUNDE, ikke per flight.**
Bekreftet eksplisitt med bruker: til tross for at en turneringsrunde kan
ha titalls/hundrevis av deltakere på tvers av mange flighter (ulikt en
frittstående runde, som alltid er en liten gruppe), deler HELE feltet
én tråd for runden. Enklere datamodell, nærmere Banter Board sin
åpenhet enn den opprinnelige "kun min flight"-intimiteten -- en bevisst
avveining, ikke en forglemmelse.
**Beslutning C — lese-/skriverett = org-medlemskap
(`get_authorized_org`), ikke en ny synlighetsmodell.** Frittstående
runders `_can_view_round` (eier/medspiller/public/friends-kategori,
`rounds.py`) har ingen reell motpart i org-turnering-modellen (RLS-
basert, ikke app-lags synlighet). Matcher den allerede uttalte
presedensen i `individual_tournaments.py` sin egen modul-docstring:
runde-/deltaker-OPPSETT er bevisst på "vanlig org-medlemsnivå" --
kommentering hører til samme kategori handling, ikke den strengere
self-only-regelen `update_hole` har for selve SCORING-en. Bekreftet i
scratch: et vanlig medlem (ikke admin, ikke deltaker i turneringen) kan
poste.
**Beslutning D — moderering: forfatter ELLER org-admin
(`is_org_admin`), speiler Banter Board (ADR-025), ikke frittstående
sidens "forfatter eller RUNDEEIER"** (en turneringsrunde har ingen eier
i den forstand `round.owner_user_id` gir). `app/team_authz.py`s
`is_org_admin` gjenbrukt uendret.
**Beslutning E — bevisst UTENFOR omfang i v1.** (1) Ingen @-tagging --
`PostEngagement`-komponenten (`post-engagement.tsx`) er allerede
gjenbrukt URL-agnostisk av to andre backends (`team-chat.tsx`,
`public-tournament.tsx`) UTEN tagging (samme `roundId`-prop-utelatt-
mønster brukt her), så dette er en presedens, ikke en ad-hoc
avgrensning. (2) Ingen WebSocket-/sanntid-piggyback -- den autentiserte
turneringsvisningen (`individual-tournament-detail.tsx`) har ingen
WS-tilkobling i det hele tatt ennå (bekreftet ved kodesøk), det er et
eget, uprioritert gap (gap-punkt 2 i samme liste). Tråden refetches ved
åpning + en manuell "Oppdater"-knapp. Reaksjoner og trådede kommentarer
ER med (allerede fullt støttet av `PostEngagement` uendret) -- kun
tagging og sanntid er kuttet.
**Backend:** ny fil `app/routers/tournament_round_messages.py`, speiler
`round_messages.py` endepunkt-for-endepunkt (liste/opprett/slett
melding, PUT/DELETE reaksjon, liste/opprett/slett trådet kommentar) men
`org_connection(organization_id)` + `Depends(get_authorized_org)` i
stedet for `plain_connection()`. `_get_tournament_round_or_404` speiler
det etablerte mønsteret fra `update_bbb_hole` (bekreft
`round_row["tournament_id"] == tournament_id`, RLS gjør resten).
Bilde-opplasting gjenbruker `storage.read_optional_image`/
`storage.upload_image` UENDRET (samme MinIO+AVIF-pipeline), ny
`prefix="tournament_round_messages"`. `client_message_id`-idempotens
speiler migrasjon 073 sitt mønster (offline-kø-retry-sikkerhet).
**Frontend:** ny `frontend/components/tournament-round-messages.tsx`,
adaptert kopi av `round-messages.tsx` med URL-ene byttet til
`${base}/rounds/${roundId}/messages...` og uten
MentionTextarea/tagging. `frontend/lib/offline-queue.ts` GJENBRUKT
UENDRET (bekreftet 100% generisk under research -- ingen
rundespesifikk antakelse i selve biblioteket), egen kø-nøkkel
`${base}:${roundId}:tournament-messages` for å unngå kollisjon med en
evt. fremtidig hull-score-offline-kø (gap-punkt 1, ikke bygget ennå).
Montert som en ny, kollapsbar "Kommentarer"-seksjon i `ScoreTab`, samme
mønster som "Vis hele scorekortet" (ADR-100). `individual-tournament-
detail.tsx` sin `/auth/me`-henting utvidet til å hente `currentUserId`
(manglet før, kun `role` ble hentet) -- trengs for forfatter-/
moderasjonssjekk i UI-et.
**Status:** 177/177 pytest (12 nye, dekker hele autorisasjonsmatrisen:
post/slett egen, org-admin sletter andres, ikke-forfatter-ikke-admin
nektet, RLS på feil org gir 404, vanlig medlem kan poste, reaksjon-
upsert/fjern, ugyldig emoji avvist, trådet kommentar + kaskade-sletting,
`client_message_id`-idempotens, body-eller-bilde-krav, feil
tournament_id gir 404), `tsc --noEmit` rent. Full-stack scratch (egen
db/API-/MinIO-container, ekte innlogging inkl. et ekte TOTP-2FA-oppsett
for å teste admin-moderering i nettleseren -- ikke bare i pytest, ingen
fetch-mocking) bekreftet: tekst+bilde postet (AVIF-konvertering
verifisert direkte mot scratch-MinIO), reaksjon, trådet svar, forfatter
sletter egen, ORG-ADMIN sletter en annens (ekte 2FA-innlogging
gjennomført for å bevise dette, ikke anslått), offline-kø fungerende
ende-til-ende (frakoblet → post → banner/"venter på synk" → tilkoblet →
automatisk synk → refetch bekreftet riktig serverstate), mørk modus +
smal mobilskjerm. Alle scratch-ressurser (containere/db/rolle/images)
ryddet opp etter verifisering. IKKE bygget/rullet ut mot ekte
containere ennå.
---
## ADR-103: Slag-for-slag GPS-avstandsmåling for org-turneringer (2026-08-22)
**Kontekst:** punkt 6 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md), bevisst valgt ETTER
punkt 7 (rundespesifikk kommentartråd, ADR-102) fordi delings-
funksjonen (ADR-048 Beslutning E) forutsetter et sted å dele til.
Bruker stilte et avklarende spørsmål før planen ble godkjent: er
avstand-til-green + hindringer-underveis (rangefinderen) dekket? Svar:
JA, allerede portert og live identisk i begge flyter siden ADR-064/083
(`HoleTargetDistance`, delt komponent, allerede montert i
`TournamentScoringWizard`) -- IKKE forveksle med denne ADR-en, som
dekker den ATSKILTE, frie slag-for-slag-målingen (utslag→ball,
ADR-048), ikke faste banepunkter.
**Beslutning A -- ny eierskaps-nøkkel: `tournament_round_participant_id`
+ `hole_number`, IKKE `tournament_round_hole_id`.** `tournament_round_
hole` opprettes (ulikt frittstående `round_hole`, som pre-oppretter
alle 18 ved rundestart) FØRST ved `update_hole`s første score-
innsending -- en spiller må kunne måle et slag FØR hullets score er
registrert. Ny migrasjon `091_tournament_round_shot.sql` speiler
`round_shot` (060/061) strukturelt, med `organization_id` + full RLS
(turneringer ER org-scopet) og FK direkte til `tournament_round_
participant`, ikke via en hull-rad. Samme LEFT JOIN-tolerante
oppløsning som `get_tournament_round_hole_target_points`/
`list_round_participant_holes` allerede bruker for nøyaktig dette.
Ingen side/lag-XOR (ulikt `round_shot`) -- turneringsmodellen er alltid
individuell.
**Beslutning B -- autorisasjon speiler `update_hole` (self ELLER
org-admin), IKKE ADR-102s "org-medlem"-nivå.** Å måle/slette et slag er
samme sensitivitetsklasse som selve SCORE-registreringen (en handling
FOR en bestemt deltaker), ikke oppsett-/kommentar-klassen. Lesing
krever kun org-medlemskap, samme mønster som `list_round_participant_
holes`.
**Beslutning C -- deling reuser den NYE rundespesifikke kommentartråden
(ADR-102, `tournament_round_message`), ikke Banter Board.** Ny fil
`app/routers/tournament_round_shots.py` speiler `rounds.py::share_shot`
sitt Mapbox Static Images-snippet-mønster (`_mapbox_static_snippet_url`/
`_encode_polyline`) som en bevisst LOKAL kopi -- org-turnering-
subsystemet importerer aldri fra det frittstående-runde-subsystemet
(samme presedens som ADR-102s `_resolve_author_name`). Grasiøs
degradering til ren tekst uten satellittutsnitt ved Mapbox-feil,
identisk med den frittstående sidens.
**Beslutning D -- `ShotMeasurementEntry` generalisert til en delt fil,
IKKE duplisert.** `ShotMeasurementSheet`/`MapPointPicker` (Mapbox GL JS
+ GPS-flyten) var allerede 100 % generiske (ingen `roundId`-kobling i
det hele tatt) -- gjenbrukt UENDRET. `ShotMeasurementEntry` selv (lokal
funksjon i `round-detail.tsx`, hardkodet `/rounds/${roundId}/...`-URL-
bygging) ble flyttet til ny `frontend/components/shot/shot-measurement-
entry.tsx` og generalisert til å ta eksplisitte URL-er
(`shotsUrl`/`shotsRoundBaseUrl`/`targetPointsUrl`) i stedet for
`roundId`+`owner`. De to backendenes ulike delings-felt-navn
(`shared_round_message_id` vs. `shared_tournament_round_message_id`)
normaliseres til ett felles `shared: boolean`-flagg i komponentens egen
`normalizeShot()`. `round-detail.tsx`s tre eksisterende kallsteder
(ScoringWizard-steg, PlayerHoleCards-merkelapp, SideScorecardGrid-
merkelapp) oppdatert til å bygge de samme URL-strengene eksplisitt --
atferd UENDRET der, kun en signaturomskrivning.
**Beslutning E -- mount-punkt: KUN i `TournamentScoringWizard`s
"direction"-steg, ingen egen badge i flight-kortlisten.** Speiler
nøyaktig `round-detail.tsx` sin plassering (rett under `<ClubPicker>`).
Siden BÅDE den kjedede flerspiller-wizarden OG `HoleGrid` sitt enkelt-
spiller-kall (ADR-100) gjenbruker SAMME `TournamentScoringWizard`,
dekker dette ene mount-punktet begge overflatene automatisk -- ulikt
frittstående siden, som trengte 3 separate mount-punkter for samme
funksjon fordi den har 3 separate komponenter. Samme "kun i wizarden"-
presedens som rangefinderen (`HoleTargetDistance`) allerede har i denne
filen.
**Status:** 188/188 pytest (11 nye -- opprett/liste, shot_number-
inkrementering per hull, måling FØR tournament_round_hole finnes,
self-only-avvisning, org-admin-unntak, self-only-sletting, RLS feil-org
404, feil tournament_id 404, deling oppretter ekte tournament_round_
message, deling nektet for ikke-eier, ugyldig avstand avvist),
`tsc --noEmit` rent. Full-stack scratch (egen db/API-/MinIO-container
MED de ekte Mapbox-tokenene, ekte innlogging, ingen fetch-mocking) --
GPS-flyten testet med en injisert `navigator.geolocation`-overstyring
(samme "headless Chrome nekter ekte geolokasjon"-arbeidsomgåelse som
ADR-083 selv dokumenterte -- CDP sin native geolocation-emulering alene
holdt IKKE), kart-velg-punkt-flyten testet med et EKTE Mapbox-kart
(satellittfliser lastet, WebGL rendret) -- bekreftet: slag målt (GPS OG
kart-tap), kølle registrert, avstand riktig beregnet, deling til
kommentartråden (ADR-102) opprettet en EKTE `tournament_round_message`
med et EKTE satellittbilde-utsnitt (14 444 byte AVIF, verifisert
direkte mot scratch-MinIO -- ikke antatt), "Delt"-merkelapp korrekt,
sletting av et målt slag fjernet slaget men lot den allerede delte
kommentaren stå (samme `ON DELETE SET NULL`-oppførsel som frittstående
siden), mørk modus + smal mobilskjerm. Alle scratch-ressurser (to
runder, inkl. en omstart av API-containeren for å rette en glemt
Mapbox-token-miljøvariabel underveis) ryddet opp etter verifisering.
---
## ADR-104: Fullt flerspiller-scorekort-grid + offline-kø for score-føring i ScoreTab (2026-08-22)
**Kontekst:** punkt 4 og 1 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md), bedt om samtidig av
bruker siden punkt 1 er en liten oppgave og begge berører samme fil
(`ScoreTab` i `individual-tournament-detail.tsx`). Ingen backend-
endring i noen av delene -- ren frontend-runde.
**Punkt 4 -- flerspiller-scorekort-grid.**
**Beslutning A -- gjenbruk denne filens EGNE `classify`/
`scoreMarkClasses`, ikke `round-detail.tsx` sin `ScorecardCell`.** Den
nye `TournamentScorecardGrid`-komponenten speiler `ScorecardGrid`
strukturelt (sticky spillerkolonne, hull-kolonner gruppert Ut(1-9)/
Inn(10-18) på fysisk hullnummer, Sum-kolonne), men bruker
`individual-tournament-detail.tsx` sitt eget score-tone-språk (allerede
brukt av flight-kortlisten) for visuell konsistens innad i filen,
fremfor å importere `round-detail.tsx` sin lokale celle-komponent.
**Beslutning B -- droppet ved porting, bekreftet trygt.** Ingen
`sides`/`isTwoSided`/`formatResult`/match-fargelegging/"Stilling"-rad --
individuelle turneringer har ALDRI et side-begrep (denne grenen i
`ScorecardGrid` er alltid death-path for ren slagspill uansett, bekreftet
ved kodesøk). Ingen "plukket opp"/PU-merkelapp -- `tournament_round_hole`
har ingen tilsvarende kolonne.
**Beslutning C -- gridet ERSTATTER pille-rad + enkelt-spiller-`HoleGrid`,
ikke lagt til ved siden av.** `HoleGrid`-komponenten (kun brukt fra dette
ene stedet) ble slettet -- ingen gjenværende kallesteder. Cellene tapper
DIREKTE inn i den eksisterende `TournamentScoringWizard`-mounten (samme
instans som hull-navigatoren/flight-kortlisten over bruker) ved å sette
`activeHole` + `wizardPlayerId` -- ingen ny wizard-instans, ingen separat
`sheetHole`-state slik det gamle `HoleGrid` hadde.
**Beslutning D -- datahenting utvidet, men betinget.** `holesByParticipant`-
hente-effekten hentet tidligere kun flighten + valgt spiller. Gridet
trenger ALLE `visibleParticipants` sine hull (avhenger av "Vis hele
feltet"-bryteren) -- effekten utvidet til å dekke hele det synlige feltet,
men KUN når `showFullScorecard`-seksjonen faktisk er åpen (unngår
unødvendig henting når den er kollapset). `showFullScorecard`-state
flyttet tidligere i komponenten (var deklarert etter det opprinnelige
bruksstedet) slik at hente-effekten kan lese den.
**Punkt 1 -- offline-kø for score-føring.**
**Beslutning E -- speiler `round-detail.tsx` sitt
`queueParticipantHoleWrite`/`updateWizardStat`-mønster (ADR-028/057/060)
nøyaktig, samme delte `frontend/lib/offline-queue.ts` uendret.**
`updateHoleFull` fikk samme tre-veis gren: `!navigator.onLine` → kø
umiddelbart; `fetch` kaster (ekte nettverksfeil) → samme kø-fallback;
`res.status === 409` → konfliktvarsel + reload for den deltakeren (manglet
HELT tidligere -- `!res.ok` kastet uansett statuskode). Andre `!res.ok`
kaster fortsatt en generisk feil (uendret fra før -- kun 409 og ekte
nettverksfeil fikk ny håndtering, ingen regresjon på ekte
valideringsfeil). `patch.expected_version` er allerede satt av
`TournamentScoringWizard` sin `save()` (`expected_version: hole.version`)
-- ingen eget versjonsoppslag trengs i kø-hjelperen, ulikt
round-detail.tsx sin variant (som har flere kallesteder uten den
garantien).
**Beslutning F -- samme tre synk-utløsere, samme banner-språk.**
`window` sitt `"online"`-event, én engangssjekk ved mount
(`queueCount(roundId)`), og en manuell "Synkroniser nå"-knapp -- pluss
identisk offline-/venter-banner-markup (amber, `WifiOff`-ikon) plassert
øverst i `ScoreTab`s JSX (synlig uansett hvilken underseksjon som er
åpen). `TournamentScoringWizard.save()` trengte INGEN endring -- den
kaster kun videre hvis `onSave` selv kaster, og en kølagt skriving kaster
aldri (resolver normalt), så arket lukkes/avanserer allerede riktig ved
en offline-registrering.
**Status:** `tsc --noEmit` rent, 188/188 pytest uendret (ingen
backend-endring i det hele tatt). Full-stack scratch (egen db-/API-/
MinIO-container, ekte innlogging, ingen fetch-mocking) med en flight på 3
spillere (én med delvis utfylt scorekort fra før) bekreftet: gridet viser
alle tre samtidig med riktig Ut/Inn/Sum/golfscore-form, tapp på en
annen spillers celle enn den aktive åpner riktig wizard for akkurat den
spilleren+hullet (kjeden/"Neste"-navigasjonen uendret), et reelt 403
(selv-only-avvisning, forsøkte lagre for en annen deltaker som vanlig
medlem) bekreftet UENDRET riktig oppførsel fra før. Offline-kø: frakoblet
→ registrerte score → banner + optimistisk grid-oppdatering uten
feilmelding → tilkoblet → automatisk synk (banner forsvant) → bekreftet
direkte mot databasen at BÅDE det online og det kølagte hullet faktisk
ble lagret riktig server-side. Mørk modus + smal mobilskjerm. 409-
konflikthåndteringen ble IKKE eksplisitt live-reprodusert denne runden
(vurdert lavere presserende å simulere enn de to nye atferdene over,
siden selve mekanismen -- `expected_version`/409-semantikk -- allerede
er bevist riktig på nøyaktig dette endepunktet fra tidligere runder,
og round-detail.tsx sitt identiske mønster er langvarig i produksjon).
Alle scratch-ressurser ryddet opp.
---
## ADR-105: Personlig statistikk-dashboard blander inn org-turnering-runder (2026-08-22)
**Kontekst:** punkt 3 i "Scoring-paritet: frittstående runde vs.
org-turnering"-gap-listen (FEATURE_BACKLOG.md), bedt om av bruker rett
etter ADR-104. `GET /rounds/stats/summary` (`/my-rounds/stats`)
aggregerte tidligere KUN `round_hole`/`round_participant` -- en spillers
GIR%/putting/fairway-trender fra org-turneringer vistes aldri der.
**Beslutning A -- bland tallene, vis kilde-antall separat (bruker-
bekreftet via AskUserQuestion).** Org-turnering-hull blandes rett inn i
de SAMME trend-graf-/snitt-tallene som frittstående runder (ingen
visuell markering i selve grafene, ikke filtrerbart), men
`RoundStatsSummary` fikk to nye felt --
`rounds_completed_personal`/`rounds_completed_tournament` -- vist som en
parentes i "Oversikt"-seksjonens ingress ("N fullførte runder (X
frittstående + Y turnering)"). Speiler eksakt presedensen i
`app/hole_history.py` sin `times_personal`/`times_tournament` (ADR-072).
**Beslutning B -- ingen endring i selve aggregeringslogikken.**
`_resolve_stats_window`/`_build_round_series`/`_summarize_rounds`
(`rounds.py`) er allerede fullt generiske over dict-nøklede runde-data
(aksesserer felt via `h["felt"]`, likegyldig om raden kom fra
`round_hole` eller `tournament_round_hole`) -- kun datahenting/
sammenslåing i selve endepunktet ble utvidet. `_summarize_rounds` fikk
ett nytt parameter, `source_by_round`, brukt UTELUKKENDE til å telle de
to nye kilde-antall-feltene.
**Beslutning C -- ny `_fetch_tournament_stats_rows()`, speiler
`hole_history.py` sin N+1-per-org-henting.** Samme mønster som
`fetch_tournament_hole_history` (`plain_connection()` →
`player_organizations_for_user``org_connection()` per org), men uten
course-key-filtreringen (ikke nødvendig -- stats trenger ikke
cross-system bane-identitet, kun ALLE en brukers turnering-runder).
Namespacet nøkkel (`f"t:{trp_id}"`) slås inn i de SAMME
`by_round`/`stat_level_by_round`/`played_at_by_round`-dict-ene som de
personlige radene, ingen egen kodesti for turnering-siden i selve
aggregeringen.
**Beslutning D -- "fullført runde" avgjøres ved radtelling, ikke en
kolonne.** `tournament_round` har INGEN `completed_at`-kolonne (bekreftet
mot `040_individual_tournaments.sql`). En turneringsrunde regnes som
fullført når antall `tournament_round_hole`-rader for deltakeren er lik
`played_hole_numbers(hole_config)` sin lengde (9/9/18) -- samme
radtellings-prinsipp `individual_tournaments.py` allerede bruker andre
steder (flaggturnering-resultatet). En rad uten `tr.scheduled_at`
(nullable) ekskluderes helt fra stats (kan ikke plasseres kronologisk
eller i et vindu uten dato) -- ikke bare fra sortering slik
`hole_history.py` sin `NULLS LAST` gjør, siden vindu-logikken her
faktisk krever en dato. `hole.par` (gjeldende, ikke et snapshot) leses
via samme join som `hole_history.py` bruker -- akseptert, allerede
presedert begrensning.
**Beslutning E -- sortering må gjøres eksplisitt etter sammenslåing.**
`_resolve_stats_window` krever `ordered_rounds` sortert NYEST FØRST.
Før denne endringen kunne koden stole på at SQL-ens `ORDER BY
r.played_at DESC` bevarte riktig innsettingsrekkefølge i
Python-dict-en. Etter sammenslåing av to kilder stemmer ikke det lenger
-- `ordered_rounds` sorteres nå eksplisitt (`sorted(..., reverse=True)`)
i stedet for å stole på innsettingsrekkefølge.
**Uttrykkelig utenfor omfang:** ingen ny per-turnering-runde
stats-side (`round-stats.tsx`-ekvivalent for én `tournament_round`) --
punkt 3 slik gap-listen beskriver det er spesifikt AGGREGAT-dashbordet.
Ingen migrasjon -- alle kolonner fantes fra før (migrasjon 075).
**Status:** 5 nye pytest-tester (blanding av kilder, strokes_only-
turneringsrunde ekskludert fra putt-snittet, ufullført turneringsrunde
ekskludert, runde uten `scheduled_at` ekskludert, RLS -- runde i en org
brukeren ikke er spiller i ekskludert) + full regresjonssuite, 193/193
grønt mot en scratch-database. `tsc --noEmit` rent.
Full-stack scratch-verifisert (egen db + to one-off-containere for
teecup_api/teecup_frontend, ekte innlogging, Chrome DevTools MCP) --
funnet OG rettet underveis: frontend-imagets `next.config.mjs`
`rewrites()` leses ved BUILD-tid for standalone-output (dokumentert i
`frontend/Dockerfile`s egen kommentar), IKKE ved container-oppstart --
en første one-off frontend-container bygget uten riktig
`--build-arg TEECUP_API_ORIGIN` proxyet derfor tre innloggingsforsøk til
den EKTE, kjørende `teecup_api` (2×422 ugyldig e-postdomene, 1×401
ukjent bruker) før feilen ble oppdaget. Ingen skriving skjedde (begge
feilkodene er før/uten DB-skriving, rate-limiteren er kun en in-memory-
teller), men containeren ble stanset umiddelbart og brukeren varslet.
Rettet ved å bygge frontend-imaget på nytt med riktig
`--build-arg TEECUP_API_ORIGIN=http://<scratch-api-container>:8000`.
Etter fiksen: bruker med runder i to ULIKE organisasjoner + én
frittstående runde -- dashbordet viste riktig "3 fullførte runder (1
frittstående + 2 turnering)", riktig blandet snitt/GIR/putt-tall, ingen
konsollfeil. Mørk modus + smal mobilskjerm (375px) bekreftet lesbart.
Alle scratch-ressurser (db, rolle, containere, images) ryddet opp.
---
## ADR-106: "Jakter Jesper" er en visningsmodus for netto-scoring, ikke en ny turneringsform (migrasjon 092, 2026-08-23)
**Kontekst:** bruker beskrev en turneringsform: start på -spillehandicap,
hullscore måles brutto mot par gjennom runden, lavest sluttsum vinner,
tiebreak på lavest spillehandicap ved likt resultat. "Kun interessant
dersom man har et leaderboard å forholde seg til."
**Beslutning A -- IKKE en ny scoring_method, en visningsmodus på
eksisterende `stroke_net`.** Sluttsummen er matematisk identisk med
`stroke_net` sitt allerede eksisterende netto-til-par
(`net_total - par_played`): `-handicap + Σ(brutto-par) = (brutto-
handicap)-par = netto-til-par`. Det eneste som faktisk er nytt er
hvordan tallet vises LØPENDE midt i runden -- dagens netto "tjener inn"
handicap-slag gradvis per slagindeks (`allocate_over_played_holes`,
partial allocation basert på hvilke hull som faktisk er spilt), mens
Jakter Jesper trekker fra HELE spillehandicapet med én gang og viser
brutto-til-par-differansen løpende. Vurdert og avvist som egen
scoring_method (ville krevd berøring av ~15 filer -- CHECK-constraint,
to Pydantic-regex-steder, dispatch-gren, cut-gate, frontend-labelkart --
for null reell ny beregningslogikk).
**Beslutning B -- ny kolonne `tournament.net_display_style`
('standard'/'hunt', default 'standard'), KUN gyldig sammen med
scoring_method='stroke_net'** (fail-loudly 400 begge veier: kan ikke
settes til 'hunt' på en annen metode, OG kan ikke bytte bort fra
stroke_net mens 'hunt' står -- se `update_tournament` i
`app/routers/tournaments.py`). `_attach_stroke_play_columns`
(`individual_tournaments.py`) fikk en `stroke_val(row)`-hjelper som
bytter ut `row["net_total"]` med `row["gross_total"] -
row["playing_handicap"]` når hunt er aktiv -- ett sted, ingen duplisert
beregningslogikk. `playing_handicap` hentes nå per (runde, deltaker) i
samme spørring (ny JOIN mot `tournament_round_participant`) siden den
kan variere per runde/tee i en flerrunde-turnering.
**Beslutning C -- ny tiebreak-verdi `lowest_playing_hcp`, GENERELT
tilgjengelig (ikke bundet til hunt-visningen).** Bruker presiserte
"spillehandicap" (det tee-spesifikke tallet som faktisk ga
startverdien), ikke WHS-indeksen den eksisterende `lowest_hcp`-
tiebreaken bruker (`tournament_participant.handicap_index_snapshot`).
Ny `_lowest_playing_hcp_sort_keys` leser
`tournament_round_participant.playing_handicap` for turneringens
ABSOLUTT SISTE runde (samme presedens som `_countback_sort_keys`).
Tiebreak forblir organisator-valgfritt som i dag (bruker bekreftet
eksplisitt: IKKE tvunget på for formatet, samme fleksible to-
uavhengige-innstillinger-mønster som `countback`/`lowest_hcp`/
`playoff`).
**Beslutning D -- omfang: kun org-individuelle turneringer denne
runden.** Bruker bekreftet sekvensiell bygging -- frittstående runder
(`round.play_format`) og en kolonne på lagturnering-øktens individuelle
leaderboard (`fetch_individual_leaderboard`) er bevisst UTENFOR,
egne senere oppfølgingsrunder. Org-LAG-turneringer (Ryder Cup-
modellen) har strukturelt ikke noe `scoring_method`-begrep i det hele
tatt (ADR-011) -- kan aldri hoste denne visningsmodusen direkte, kun
potensielt som en tilleggskolonne på den separate økt-leaderboard-
mekanismen.
**Status:** 5 nye pytest-tester (løpende tall skiller seg fra standard
midt i runden men konvergerer nøyaktig ved 18 fullførte hull -- selve
beviset på ekvivalensen; validering begge veier; `lowest_playing_hcp`
løser en reell uavgjort; manglende spillehandicap sorteres sist) + full
regresjonssuite, 198/198 grønt. `tsc --noEmit` rent.
Full-stack scratch-verifisert (egen db + to one-off-containere, ekte
innlogging inkl. et ekte 2FA-e-post-oppsett for org-eier-tilgang): en
stroke_net-turnering med to spillere (spillehandicap 12/4), hunt PÅ --
leaderboardet viste -12/-2 (hånd-utregnet på forhånd, matchet eksakt).
Slått AV live -- tallene endret seg umiddelbart til standard-visningens
-5/-2 (samme underliggende data, kun presentasjonen endret), ingen
sideoppfriskning nødvendig. Bekreftet at BÅDE den offentlige "Følg
live"-siden OG utskrifts-resultatlisten viste hunt-tallene korrekt UTEN
noen kodeendring der -- beviser at de kun konsumerer `total_label`/
`position` som opake strenger, akkurat som antatt i planen. Mørk modus
bekreftet lesbart. Alle scratch-ressurser ryddet opp.
**Rullet ut 2026-08-23** -- bruker bekreftet. Migrasjon 092 kjørt mot
ekte `teecup_db` (kun to `ADD COLUMN`/CHECK-utvidelser, ingen
eksisterende data endret -- bekreftet med `\d tournament` rett etter
kjøring). `docker compose build teecup_api teecup_frontend && up -d`,
begge containere friske, rene logger. `https://teecup.golf/health` 200
etter utrulling.
---
## ADR-107: "Jakter Jesper" del 2 -- frittstående runder og lagturnering-
økt-leaderboard (migrasjon 093, 2026-08-24)
**Kontekst:** ADR-106 leverte "Jakter Jesper" kun for org-individuelle
`stroke_net`-turneringer, med to punkter bevisst utenfor (se
FEATURE_BACKLOG.md): frittstående runder, og en kolonne på lagturnering-
øktens individuelle leaderboard. Bruker ba om å fullføre begge i denne
runden ("La oss fullføre Jakter Jesper").
**Beslutning A -- Del A (frittstående runder): ny kolonne
`round.net_display_style`, samme fail-loudly-mønster som ADR-106, kun
gyldig for `play_format='stroke'`.** `_load_round_out`s
`my_net_score_to_par` og `_build_leaderboard`s `net_score_to_par` bytter
fra `- total_strokes_received` (gradvis) til `- course_handicap_snapshot`
(helt) når hunt er aktiv -- ren swap på backend. Nøkkelforskjell fra
org-individuell: `round-leaderboard.tsx` regner netto-til-par selv,
klientsidig, fra rå `strokes_received` (ikke fra backendens felt) --
løsningen fikk derfor en ny parallell klientsidig `huntNettoToPar()`-
funksjon i tillegg til backend-swappen, ikke bare en backend-endring.
**Beslutning B -- Del B (lagturnering-økt): ny kolonne
`session.net_display_style`, PER ØKT, ikke per turnering.** Vurdert og
avvist: gjenbruk av `tournament.net_display_style` (allerede en kolonne)
-- men lagturneringer har `scoring_method=NULL` alltid, og
`tournament-detail.tsx` (lagformat) har ingen motpart til
`individual-tournament-detail.tsx` sin "Resultat og opsjoner"-fane;
`TournamentSettingsCard` importeres ikke der. En turneringnivå-bryter
ville krevd en helt ny innstillings-UI-seksjon; en øktnivå-kolonne
kunne derimot henge rett på den eksisterende `EditSessionForm`. Også mer
presist: en lagturnering kan ha noen singles/fourball-økter i hunt-modus
og andre ikke. Gyldig kun for `format IN ('singles','fourball') AND
scoring_mode='stroke'` (samme betingelse `fetch_individual_leaderboard`
allerede håndhevet ved lesing -- nå også håndhevet proaktivt ved lagring
i `update_session`). `net_score`/`net_to_par` i
`fetch_individual_leaderboard` bytter fra `- strokes_received_total`
til `- match_participant.playing_handicap` når hunt er aktiv, samme
"swap, ikke ny beregning"-filosofi. `total_points`/Stableford-formelen
er uendret og upåvirket i begge deler, som forventet (kun til-par-tallet
endrer presentasjon, poengberegning bryr seg aldri om hvor mye handicap
er "opptjent" på et gitt tidspunkt).
**Funn under scratch-verifisering: en tredje, tidligere uidentifisert
netto-beregning.** Planen dekket `my_net_score_to_par` (rounds.py) og
`round-leaderboard.tsx`s `nettoToPar`, men rundekortets "Så
langt"-oppsummeringswidget (`ScoreSoFar` i round-detail.tsx) regner sin
egen `nettoTotal` uavhengig, fra `strokes_received` per hull -- helt uten
hunt-bevissthet. Oppdaget live i scratch (viste feil `+15` i stedet for
korrekt `+8` for en 12-hcp-spiller med 20 brutto slag over 5 hull). Fikset
med samme `courseHandicap`/`netDisplayStyle`-props-mønster og re-
verifisert. Nøyaktig den typen glipp fullstack-scratch-disiplinen finnes
for å fange -- planen alene hadde ikke fanget den.
**Status:** 6 nye pytest-tester (3 for hver del, samme konvergens-bevis-
mønster som ADR-106: løpende tall skiller seg fra standard midt i
runden/økten, konvergerer eksakt ved 18 fullførte hull; validering begge
veier for begge deler) + full regresjonssuite, 204/204 grønt. `tsc
--noEmit` rent.
Full-stack scratch-verifisert (egen db + to one-off-containere, ekte
innlogging): en frittstående runde (hcp 12) -- rundekort OG leaderboard
viste -12 midt i runden under hunt (5 hull, alle par), konvergerte til
samme -12 ved 18 fullførte hull under standard (hånd-utregnet på
forhånd, matchet eksakt). En lagturnering-singles-økt, identisk oppsett
-- leaderboardet viste net_score=-8/net_to_par=-12 under hunt, -5 under
standard etter tilbakebytting (5 hull, stroke_index 1-5, alle mottar
slag). Bekreftet bryterne kun vises der de gir mening
(`EditRoundPanel` kun for `play_format='stroke'`, `EditSessionForm` kun
for singles/fourball+stroke). Mørk modus + smal mobilskjerm (375px)
bekreftet lesbart for begge deler, inkludert hunt-bannerets kontrast og
tekstbryting. Alle scratch-ressurser ryddet opp (containere, db, rolle,
images).
---
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -14014,797 +14014,9 @@ Neste steg:
prosjektet, hele `vitest`-suiten grønn (55 tester, ingen regresjon).
`python3 -m py_compile` rent på de nye backend-filene.
**Rullet ut 2026-08-21** -- bruker bekreftet, kjørt sammen med flere
andre endringer samme dag (se punkt 151-153). `docker compose build
teecup_api teecup_frontend && up -d`, begge containere friske, rene
logger, bekreftet via helsesjekk mot ekte `teecup.golf`.
**⚠️ ÉN ting fortsatt IKKE gjort:** ekte klikk-gjennom av selve
utskriftsknappene (alle fire sidene, bekreft faktisk PDF-nedlasting
fungerer) -- kun kodenivå-verifisering + containernes helsesjekk er
gjort, ikke en reell bruker-flyt-test av selve PDF-genereringen.
Bør gjøres FØR noen faktisk stoler på "Last ned PDF"-knappen.
**⏳ IKKE rullet ut ennå.** Ny Chromium-avhengighet i produksjons-
API-imaget (~300-500MB større) venter på eksplisitt bekreftelse før
`docker compose build && up -d` kjøres. Ekte scratch-verifisering
(klikk gjennom alle fire med ekte turneringsdata, bekreft faktisk
PDF-nedlasting) gjenstår også -- kun kodenivå-verifisering gjort
denne runden.
151. **Rotårsak funnet for "banen er allerede importert"-forvirringen +
ærlig feilmelding lagt til, 2026-08-21.** Bruker rapporterte at
scorekortet for "Tjøme Invitational 2026" viste feil par på hull
2/5/11/14 (par 6/3/6/3 i stedet for 5/4/5/4). Sporet via en Explore-
agent + direkte spørring mot ekte `teecup_db` (bekreftet, ikke
gjettet): organisasjonen "Erol Haagenruds turneringer" har tre
manuelt håndlagde `custom`-baner ("Tjøme"/"Tjøme G&CC"/"Tjøme
Golfklubb Bane", opprettet 2026-08-18) -- INGEN ekte offisiell
import -- og alle 4 berørte runder (begge "Klubbmesterskap 2026" og
begge "Tjøme Invitational 2026") pekte på den håndlagde varianten
med 4 feiltastede par-verdier.
**Den ekte rotårsaken, ikke bare symptomet:** teeoff sin egen
kildedata for "Tjøme Golfklubb Hovedbanen" hadde en reell datafeil
-- hcp-indeks 13 var oppført på to hull, indeks 14 manglet helt.
Reprodusert direkte (kalte teeoff sitt eget `/api/facilities/
tjome-golfklubb`-endepunkt fra API-containeren) og bekreftet med en
tilbakerullet test-transaksjon mot ekte `teecup_db` (BEGIN → forsøkt
ekte innsetting med reelle data → ROLLBACK, ingen varig endring) at
selve databaseskjemaet var uskyldig -- innsettingen gikk knirkefritt
med KORREKT data. `import_official_course` sin validering sjekket at
hvert hull HADDE en hcp-indeks, men aldri at SETTET av 18 verdier
faktisk var 1-18 uten duplikater -- den ugyldige teeoff-dataen kom
derfor gjennom valideringen og feilet først på `hole`-tabellens
UNIQUE-constraint ved selve lagringen. Denne databasefeilen ble
fanget av den generiske `translate_db_errors()`-oversetteren og vist
til brukeren som "allerede importert til organisasjonen" -- NØYAKTIG
samme melding som et ekte duplikat-forsøk, en totalt misvisende
feilmelding som skjulte den ekte årsaken i en måned. Mest sannsynlige
hendelsesforløp: samme teeoff-datafeil blokkerte trolig importen
allerede 2026-08-18, brukeren fikk samme forvirrende melding, ga opp
og tastet banen inn for hånd i stedet -- og introduserte da sine
egne 4 skrivefeil underveis.
Bruker rettet selv kildedataen direkte hos teeoff (hull 16s indeks
korrigert 13→14). Bekreftet live etterpå (samme direkte kall mot
teeoff sitt API) at rettelsen faktisk slo gjennom.
**Kodefiks (uavhengig av teeoffs egen rettelse, forhindrer samme
misvisende feil for ENHVER fremtidig kilde-datafeil):** ny eksplisitt
validering i `import_official_course` OG `import_international_course`
(`app/routers/courses.py`) -- sjekker at hull-nummer- og hcp-indeks-
settene faktisk er 1..18 uten duplikater/hull FØR noe forsøkes
lagret, med en ny, ærlig feilkode (`EXTERNAL_DATA_INVALID`) og
melding som navngir det faktiske problemet i stedet for å late som
det er et duplikat. Frontend-feilhåndtering oppdatert på alle fire
stedene som kaller disse import-endepunktene: `individual-tournament-
detail.tsx` (individuell turnering), `tournament-program.tsx` (Cup-
format, både teeoff- og GolfAPI-import), og `course-template-
editor.tsx` sin "TeeOff-bane som mal"-flyt.
**Verifisert:** rå teeoff-data hentet på nytt etter brukerens
rettelse -- hcp-indeksene er nå korrekt 1-18. `tsc --noEmit` rent,
`python3 -m py_compile` rent på courses.py.
**Oppdatering samme dag -- full opprydning gjennomført og verifisert
mot ekte `teecup_db`.** Bruker rettet selv teeoff-dataen (hcp-indeks
16→14), men da banen ble hentet på nytt viste det seg at HELE
indeks-fordelingen var endret (10 av 18 hull), ikke bare det ene
hullet -- bruker bekreftet dette var bevisst, en fullstendig
oppdatert kortversjon, ikke en ny feil. Full backup av `teecup_db`
tatt først (`pg_dump` -- måtte bruke `teeoff_admin`-superbrukeren,
ikke `teecup_app`, siden RLS blokkerer en vanlig dump på tvers av
organisasjoner selv for eierens egen app-rolle). Under selve
rettingen viste det seg at en ekte offisiell import ALLEREDE hadde
lyktes i mellomtiden (kl. 13:44, trolig via appens egen "Hent bane
fra teeoff" da teeoff-dataen ble frisk) -- og at Klubbmesterskap
2026 sine to runder alt var gjenopprettet med nye ID-er, allerede
pekende korrekt på den nye banen, uten manuell inngripen. Kun de to
"Tjøme Invitational 2026"-rundene og 51 allerede innsjekkede
spilleres utslagstildelinger trengte faktisk manuell SQL-retting.
Bekreftet etterpå: alle 4 runder peker på riktig bane, alle 51
spilleres utslag stemmer med banen de faktisk spiller på, de tre
feilaktige duplikat-banene slettet, og par-tallene rettet i den ene
av de to lekkede `personal_course`-kopiene som faktisk hadde feilen
(den andre viste seg allerede å ha riktige tall -- sjekket FØR
retting i stedet for å anta begge trengte den samme fiksen).
Sidespørsmål avklart underveis: en tredje, urelatert organisasjon
("Tjøme Gents") har en egen import av samme bane med den GAMLE
indeks-fordelingen, brukt av en aktiv turnering der -- bruker
bekreftet den kun er en testturnering, rørt ikke.
**Gjenstående, ikke en feil, bare vanlig oppsett:** de tre nå tomme
rundene (Klubbmesterskap Runde 1+2, Tjøme Invitational Runde 2)
trenger spillere lagt til på nytt via "Spillere"/"Grupper og
startliste" -- de gamle deltaker-radene forsvant da Klubbmesterskap
sine runder ble gjenopprettet med nye ID-er.
152. **To reelle bugs funnet mens bruker testet offentlig deling av
Klubbmesterskap 2026, 2026-08-21.** Bruker satte turneringen til
"Offentlig" og delte URL-en rett fra egen adresselinje
(`/tournaments/{id}?org=...`) -- en ikke-innlogget mottaker fikk et
ødelagt Cup/lag-format-administrasjonsskjermbilde ("Lag og spillere",
"Klarte ikke å laste lag og spillere"), selv om turneringen er
individuell.
**Fiks A -- riktig lenke nå kopierbar.** Den ekte offentlige lenken
(`/t/{id}`) fantes fra før, men kun som ren, ikke-kopierbar tekst i
"Presentasjon"-fanen -- lett å bomme på til fordel for adresselinjens
egen (feil) URL. `tournament-presentation.tsx` fikk en ekte
kopier-knapp (samme mønster som `JoinCodeChip`).
**Fiks B -- ekte bug, uavhengig av feil lenke.**
`tournament-router.tsx` hentet turneringsformat med et innlogget-only
kall, og falt ved ENHVER feil (inkl. 401/ikke-innlogget) ubetinget
tilbake til Cup-format-adminskjermen -- uansett turneringens faktiske
format. Skiller nå eksplisitt 401 fra andre feil: en ikke-innlogget
besøkende på en organisator-URL sendes videre til den ekte offentlige
siden (`/t/{id}`) i stedet for å se en ødelagt adminskjerm.
Underveis oppdaget bruker et TREDJE, større hull: **org-individuelle
turneringer har ingen fungerende offentlig "Følg live"-side i det
hele tatt** -- allerede notert som bevisst utsatt i FEATURE_BACKLOG.md
("Flaggturnering Del B", 2026-08-14: "egen, større oppgave hvis/når
etterspurt"). Bruker: "gjør det ordentlig på første forsøk", V0 til
det visuelle. Grundig todelt analyse gjennomført (Cup-sidens fulle
funksjonssett + sanntidsmekanisme, ADR-027; den autentiserte
individuell-leaderboard-beregningens kostnad/offentlig-trygghet;
eksisterende offentlig runde-leaderboard som designreferanse) FØR
bygging, se ARCHITECTURE_DECISIONS.md for ADR (kommer når hele
funksjonen er ferdig).
**Backend bygget og importverifisert (`app.main` boot-testet i
kjørende container), rullet ut sammen med punkt 153 -- se der:**
- `PublicTournamentInfo` (`registration.py`) += `format_type` --
manglet før, landingssiden kunne ikke skille format offentlig.
- Ny `GET /public/tournaments/{id}/rounds` -- rundeliste med utledet
status (ikke startet/pågår/fullført; ingen statuskolonne finnes på
`tournament_round` selv, utledes fra `tournament_round_score`, samme
prinsipp som den eksisterende interne "dagens runde"-logikken).
- Ny `GET /public/tournaments/{id}/individual-leaderboard` --
gjenbruker `_compute_individual_standings` UENDRET (samme billige
4-spørrings-beregning som det autentiserte leaderboardet), fjerner
kun `player_id`. HCP-indeks lagt til bevisst (bruker valgte "ja,
vis HCP" da spurt eksplisitt -- samme presedens som frittstående
runders offentlige leaderboard allerede har).
- Sanntid: `broadcast_live_update(tournament_id)` koblet inn i
`_recompute_round_score` (individual_tournaments.py) -- kjøres etter
HVER hull-innsending uansett scoringsmetode, samme "delt funksjon
kringkaster selv"-mønster som Cup-formatets
`recompute_and_cache_match_state` (ADR-027). Gjenbruker SAMME
WebSocket-kanal (`/ws/public/tournaments/{id}/live}`) Cup-siden
allerede åpner -- ingen ny WS-infrastruktur.
- `public-tournament.tsx` sin "Program"-seksjon (viste før alltid en
tom Cup-økt-liste for individuelle turneringer) viser nå en
rundeliste med "Pågår nå" (samme fylte Radio-ikon-merke som
`watch-round.tsx`)/"Fullført"-merker.
**Gjenstår:** selve "Følg live"-siden sitt UI for individuelle
turneringer -- V0-prompt skrevet og sendt (rundevelger + leaderboard
med golfscore-formspråk fra DESIGN_SYSTEM.md, HCP synlig, mobil
først), venter på eksport. `/t/[id]/live` sin format-forgrening
(Cup uendret, individuell til den nye siden) kobles inn når
komponenten finnes.
`tsc --noEmit` rent, 55/55 vitest, ingen migrasjon (ingen nye
kolonner -- status utledes, lagres ikke).
153. **Offentlig "Følg live" for individuelle turneringer FERDIG BYGGET,
2026-08-21 (ADR-098) -- direkte fortsettelse av #152.** V0-eksporten
kom tilbake (`Temp-uploads/`, samme filnavnmønster som tidligere
zip-er denne uken -- innhold sjekket mot faktisk prompt, ikke
filnavnet). Høy troskap mot prompten, men mock-en modellerte hver
runde som sitt eget separate leaderboard -- matchet ikke den ekte,
KUMULATIVE beregningen (`_compute_individual_standings`). Rettet ved
integrering: rundevelgeren endrer ikke lenger leaderboard-radene,
kun hvilken rundes hull-for-hull som lastes ved utvidelse (se
ADR-098 Beslutning B for full begrunnelse).
Ny `GET .../rounds/{round_id}/participants/{tournament_participant_id}
/holes` (offentlig, nøkler på tournament_participant_id -- IKKE det
runde-interne tournament_round_participant_id) lagt til underveis,
egen ny rute (ikke gjenbruk av frittstående-runders tilsvarende --
ulik datamodell, ADR-098 Beslutning E). `PublicLeaderboardEntry` fikk
`rounds`-feltet lagt til (per-runde-celler, til fremtidig bruk).
`app/t/[id]/live/page.tsx` skrevet om til å avgjøre format SERVER-side
(enkelt `fetch()` mot `/public/tournaments/{id}`, samme mønster som
`app/logg-inn/page.tsx`) FØR noe rendres -- unngår
`tournament-router.tsx` sin klient-side-feilklasse fra #152 helt (en
individuell turnering kan aldri vise Cup-UI-et, ikke engang kortvarig).
**Verifisert:** `tsc --noEmit` rent på hele frontend, 55/55 vitest,
backend importverifisert (`app.main` boot-testet med de nye rutene i
en kjørende container FØR selve bygget, kun for importsjekk). Ingen
unused imports (sjekket for hånd, samme rutine som utskriftsfunksjonen).
Ingen migrasjon.
**Ekte scratch-verifisering gjennomført FØR utrulling** (isolerte
containere på egne porter -- `teecup_api_scratch`/`_scratch2` mot ekte
`teecup_db` skrivebeskyttet, egen `next dev`-instans, IKKE de faktiske
produksjonscontainerne, som fortsatt betjente ekte trafikk hele tiden):
tomtilstand mot ekte data (alle 3 Klubbmesterskap-deltakere, korrekt
par-rekkefølge i hull-for-hull-utvidelsen -- bekreftet at rettelsen fra
punkt 151/152 faktisk holder), pluss en mocket "under spilling"-
tilstand (leder/delt plassering/cut-linje/DSQ/"Pågår nå"-puls) siden
ingen ekte runder er i gang ennå. Fant og rettet én reell sak
underveis: spillernavn ble avkortet med "..." på 360px-skjermer (under
lesbarhetsbarren, CLAUDE.md) -- rettet til linjebryting i stedet, re-
verifisert med et bevisst langt testnavn. Mørk modus og 390px+
(vanlig telefonbredde) OK uten endring.
**Rullet ut 2026-08-21** -- bruker bekreftet. `docker compose build
teecup_api teecup_frontend && up -d`, begge containere friske, rene
logger. Bekreftet direkte mot ekte `teecup.golf`: `/health` 200,
`/public/tournaments/{id}` returnerer `format_type: "individual"`,
`/t/{id}/live` svarer 200. Scratch-ressursene (containere, dev-
server) ryddet opp og bekreftet stoppet etterpå.
154. **Rett Thru-kolonnens vertikale justering i offentlig leaderboard,
2026-08-21 -- ekte bruk-funnet bug etter #153 sin utrulling.** Bruker
rapporterte fra faktisk mobilbruk (teecup.golf, live): THRU-etiketten
sto lavere enn I DAG/TOTAL. Årsak: Thru-verdien manglet samme
`min-h-10` som `ScoreShape` (brukt av I dag/Total) gir de andre
kolonnene -- hele Thru-kolonnen ble dermed lavere, senterte etiketten
ut av linje med søsknene sine. Rettet i
`public-individual-live.tsx`. Rullet ut sammen med punkt 155.
155. **Skjul HCP på offentlig leaderboard for rene bruttoslagspill-
turneringer, 2026-08-21.** Bruker: "når det er brutto som er
hovedkonkurransen trenger vi ikke se hcp i leaderboarden" -- HCP er
kun meningsfullt der den faktisk brukes til slagfordeling (netto/
stableford/Københavner/BBB), ikke ren brutto/eclectic-gross.
`PublicTournamentInfo` += `scoring_method` (manglet før på den
offentlige modellen), ny `shouldShowHcp(scoringMethod)`-hjelper i
`public-individual-live.tsx` (`GROSS_ONLY_METHODS = {"stroke_gross",
"eclectic_gross"}`). Bruker stilte oppfølgingsspørsmål om dette var en
systemendring (ja) -- eskalerte til den bredere parity-diskusjonen som
punkt 156 svarer på. Rullet ut sammen med punkt 154 (`docker compose
build teecup_api teecup_frontend && up -d`).
156. **Flight/gruppe-scoping i ScoreTab (ADR-099), 2026-08-21 -- steg 1
av en avtalt 8-punkts parity-plan.** Bruker: "Jeg skal i utgangspunktet
ha en identisk opplevelse når jeg fører score for meg (og eventuelt de
andre i flighten min) i en org-turnering som det jeg har når jeg fører
en individuell runde." Grundig Explore-undersøkelse kartla hele gapet
mellom org-turneringers scoring og frittstående runders scoring
(rapportert til bruker, som ba om rekkefølgen). Denne runden bygger
punkt 1: `ScoreTab` viste tidligere HELE turneringsfeltet i stedet for
brukerens egen flight -- `tournament_round_group` ble kun lest av
admin-parings-skjermen. Se ADR-099 for de fire beslutningene (visning
ikke autorisasjon; fallback til hele feltet uten gruppe; `is_self`
server-side, aldri rå `user_id`; et reelt RLS-hull på
`tournament_round_group` funnet og lukket underveis, migrasjon 089).
**Backend:** `RoundParticipantOut` += `tournament_round_group_id`,
`is_self` (tre spørringer i `individual_tournaments.py` utvidet:
`add_round_participant`, `list_round_participants`,
`update_round_participant_tee`). Ny migrasjon
`089_tournament_round_group_rls.sql`.
**Frontend:** `ScoreTab` (`individual-tournament-detail.tsx`)
defaulter pill-listen og `participantId`-valget til egen gruppe når
en finnes, med "Vis hele feltet"/"Vis kun min flight"-veksling og en
inline-merknad når runden mangler gruppeinndeling. Ingen ny V0-runde
-- filter+lenke lagt til en allerede eksisterende, V0-bygget skjerm.
**Verifisert:** 164/164 pytest (inkl. ny
`test_round_participant_flight_scoping.py` -- fanget en reell NULL-
håndteringsbug: `p.user_id::text = $2` ga SQL NULL, ikke `false`, for
spillere uten konto, rettet med `COALESCE`), `tsc --noEmit` rent.
RLS-policyen verifisert direkte (engangs scratch-db: `teecup_app`-
rollen med feil `app.current_org` satt fikk 0 rader, riktig org 1
rad). Full-stack scratch-verifisering (egen scratch-db, egen
API-container, egen isolert MinIO, ekte innlogging via
`/auth/login-password`, INGEN fetch-mocking -- et tidligere forsøk med
`window.fetch`-mocking i chrome-devtools-MCP-miljøet brøt selve
sidenavigasjonen på en måte som ikke var reproduserbar med ekte
nettverkstrafikk, byttet derfor til en ekte scratch-backend i stedet
for å grave videre i et miljøspesifikt verktøyproblem) bekreftet:
gruppert runde defaulter riktig til 3 av 6 (egen flight), veksling
fungerer begge veier, ugruppert runde faller korrekt tilbake med
riktig merknad, mørk modus og 390px mobilskjerm uendret lesbare.
Alle scratch-ressurser (API-/MinIO-container, db, rolle) ryddet opp
etter verifisering.
**Rullet ut 2026-08-21** -- bruker bekreftet. Migrasjon 089 kjørt
mot ekte `teecup_db` først (kun RLS-policy på
`tournament_round_group`, ingen dataendring -- lest av og verifisert
tomt før/etter, 1 rad uendret), deretter `docker compose build
teecup_api teecup_frontend && up -d`. Begge containere friske, rene
logger. `https://teecup.golf/health` 200 etter utrulling.
157. **Kjedet flerspiller-scoring i ScoreTab (ADR-100), 2026-08-22 --
steg 2 av parity-planen, direkte fortsettelse av #156.** V0-
eksporten (`Temp-uploads/tee-cup-login-screen.zip` -- filnavnet
stemte ikke med innholdet, sjekket nøye før bruk) kom tilbake, høy
troskap mot prompten. Integrert ved å beholde V0s NYE lag (hull-
navigator + kortliste + kontekst-rad + kjede-footer) men fylle
steg-INNHOLDET med de allerede eksisterende, mer fullverdige
komponentene fra `HoleStatsSheet` (omdøpt `TournamentScoringWizard`)
i stedet for V0s egne forenklede skjemakontroller -- samme prinsipp
som print-funksjonen og "Følg live" tidligere denne uken.
**Ingen backend-endring.** Gjenbruker eksisterende endepunkter
uendret (parallell henting per flight-medlem + samme PATCH som før).
**Tre reelle bugs funnet og rettet i scratch før utrulling** (se
ADR-100 Beslutning D for full detalj): (1) standardhull-valget
kunne låse seg til hull 1 pga. en race mot ufullstendig lastet data,
(2) auto-hopp mellom steg uteble for spiller 2+ i kjeden pga. en
stale-closure-lesning av forrige spillers utkast, (3) en
diskvalifisert spiller blokkerte "hull ferdig"-sjekken permanent
siden den daværende sjekken krevde registrering fra HELE flighten
i stedet for kun den faktisk score-bare kjeden.
**Verifisert:** 164/164 pytest (uendret), `tsc --noEmit` rent.
Full-stack scratch (egen db/API-container/MinIO, ekte innlogging,
ingen fetch-mocking) med en flight på 4 (blandet stat_level, én
DSQ) -- kjeding gjennom alle steg og alle spillere bekreftet riktig
ETTER de tre rettelsene, DSQ hoppet korrekt over i kjeden men
fortsatt synlig som kort, "Vis hele scorekortet" (steg 1) uendret,
mørk modus OK. Alle scratch-ressurser ryddet opp.
**Rullet ut 2026-08-22** -- bruker bekreftet. Ingen migrasjon (ingen
backend-endring). `docker compose build teecup_api teecup_frontend
&& up -d`, begge containere friske, rene logger.
`https://teecup.golf/health` 200 etter utrulling.
158. **Hull-for-hull-utvidelse i den autentiserte leaderboard-fanen
(ADR-101), 2026-08-22.** Bruker, mens forrige runde pågikk: klikk
på en spiller skal ekspandere raden og vise scoren på hvert hull,
pluss sum per runde og for ut/inn. `LeaderboardTab` hadde utvidelse
kun for eclectic -- slagspill/netto/Stableford (StrokePlayLeaderboard,
den vanligste gruppen) hadde ingen. Mønsteret fantes allerede,
ferdig bygget, for den OFFENTLIGE siden (`public-individual-
live.tsx` sin LeaderboardRow/HoleNine) -- portert derfra (ingen ny
V0-runde), ikke bygget fra bunnen.
**Backend, to små tillegg, ingen migrasjon:** `RoundCellOut` +=
`tournament_round_id`. Nytt endepunkt
`.../individual-leaderboard/{tournament_participant_id}/rounds/
{round_id}/holes`, speiler registration.py sitt offentlige
endepunkt (eget stiprefiks -- unngår kollisjon med det eksisterende
round_participant_id-nøkkede).
**Frontend:** `StrokePlayLeaderboard` sin `LeaderboardRow`-type
("eksakt kontrakt") UENDRET -- nytt valgfritt prop-sett på selve
komponenten i stedet (`expandedId`/`onToggleExpand`/
`renderExpanded`), forelderen eier state/henting. Kun felt-rader
(ikke ledere) er utvidbare -- unngår risiko i den sticky
leder-geometrien. Nye `LeaderboardHoleNine`/`LeaderboardRoundHoles`
bruker filens egne netto-par-bevisste `classify`/`scoreMarkClasses`
(samme som HoleGrid), ikke den offentlige sidens enklere variant.
**Verifisert:** 165/165 pytest (+1 ny), `tsc --noEmit` rent.
Full-stack scratch (egen db/API-container/MinIO, ekte innlogging,
ingen fetch-mocking) med en flerrunde-turnering (2 runder, én
spiller begge, én kun runde 1) -- riktig Ut/Inn-seksjon(er), riktig
Par-/Slag-sum, riktig golfscore-form, mørk og lys modus, smal
mobilskjerm. Alle scratch-ressurser ryddet opp.
**Rullet ut 2026-08-22** -- bruker bekreftet. Ingen migrasjon.
`docker compose build teecup_api teecup_frontend && up -d`, begge
containere friske, rene logger. `https://teecup.golf/health` 200
etter utrulling.
159. **Gap-analyse skrevet ned: scoring-paritet frittstående runde vs.
org-turnering, 2026-08-22 -- ren dokumentasjon, ingen kode.** Bruker
ba om at den tidligere "avtalte 8-punkts parity-rekkefølgen" (nevnt
kun muntlig, referert i ADR-099/100) ble gjort grundig på nytt og
denne gangen faktisk skrevet ned, siden listen aldri ble committet
til noen fil -- et hull i egen dokumentasjonsdisiplin (CLAUDE.md-
regelen om å holde disse filene oppdatert er noe JEG må huske å
gjøre når arbeid fullføres, ikke en automatisk mekanisme). Grundig
kodesøk (Explore-agent) sammenlignet `round-detail.tsx`/`rounds.py`
mot `individual-tournament-detail.tsx`/`individual_tournaments.py`
på tvers av åtte konkrete kandidat-funksjoner (offline-kø, live
push, stats-dashboard, full flerspiller-scorekort-grid, "plukket
opp"-knapp, GPS-skuddmåling, kommentartråd, tilskuer-metrikkveksling)
pluss én i motsatt retning (print/eksport). Bekreftet at ADR-099/
100/101 samt rangefinder (ADR-064), Flagg-GPS (ADR-066) og kombinert
hull-historikk (ADR-071) allerede er reell paritet og ikke skal
fanges opp på nytt. Full liste, med filnavn/antatt størrelse per
punkt, skrevet inn i FEATURE_BACKLOG.md ("Scoring-paritet:
frittstående runde vs. org-turnering") -- erstatter den tidligere
muntlige listen. Ingen prioritering/bygging startet -- venter på at
bruker velger rekkefølge blant punktene.
160. **Rundespesifikk kommentartråd for org-turneringer (ADR-102),
2026-08-22 -- punkt 7 i gap-listen, valgt før punkt 6 (GPS-
slagmåling, som avhenger av et delingsmål).** Bruker bekreftet
(AskUserQuestion) at dette skal være en EKTE parallell til
frittstående runders `RoundMessages` (ADR-044) -- ikke en utvidelse
av den eksisterende, hele-turnering-brede Banter Board (ADR-025) --
og at tråden skal være ÉN per turneringsrunde (hele feltet deler
tråd, ikke per flight, til tross for at en turneringsrunde kan ha
langt flere deltakere enn en frittstående runde).
**Ny migrasjon `090_tournament_round_message.sql`:**
`tournament_round_message`/`_reaction`/`_comment`, speiler
`round_message`/`round_message_reaction`/`round_message_comment`
(058/059) strukturelt, men MED `organization_id` + full RLS
(org-tenant-invariant) i stedet for frittstående sidens RLS-frie
mønster.
**Backend:** ny fil `app/routers/tournament_round_messages.py`
(speiler `round_messages.py` endepunkt-for-endepunkt, org-scopet).
Lese-/skriverett = org-medlemskap (`get_authorized_org`, samme
presedens som runde-/deltaker-OPPSETT ellers i
`individual_tournaments.py`). Moderering: forfatter ELLER org-admin
(`is_org_admin`), speiler Banter Board. Bilde-opplasting gjenbruker
`storage.py` sin MinIO+AVIF-pipeline uendret.
**Bevisst utenfor omfang i v1** (se ADR-102 Beslutning E): ingen
@-tagging (samme presedens som `team-chat.tsx`/`public-
tournament.tsx`s bruk av `PostEngagement`), ingen WebSocket-/
sanntid-piggyback (den autentiserte turneringsvisningen har ingen
WS-tilkobling ennå -- eget, uprioritert gap). Reaksjoner + trådede
kommentarer ER med.
**Frontend:** ny `frontend/components/tournament-round-messages.tsx`
(adaptert `round-messages.tsx`), montert som en kollapsbar
"Kommentarer"-seksjon i `ScoreTab`, samme mønster som "Vis hele
scorekortet" (ADR-100). `frontend/lib/offline-queue.ts` gjenbrukt
UENDRET (bekreftet 100% generisk).
**Verifisert:** 177/177 pytest (12 nye -- hele autorisasjonsmatrisen,
RLS, idempotens), `tsc --noEmit` rent. Full-stack scratch (egen
db/API-/MinIO-container, ekte innlogging -- inkludert et ekte
TOTP-2FA-oppsett for å teste admin-moderering i nettleseren, ikke
bare i pytest) bekreftet: tekst+bilde postet (AVIF-konvertering
verifisert direkte mot scratch-MinIO), reaksjon, trådet svar,
forfatter sletter egen, org-admin sletter en annens, offline-kø
ende-til-ende (frakoblet -> post -> "venter på synk" -> tilkoblet ->
automatisk synk -> riktig serverstate), mørk modus + smal
mobilskjerm. Alle scratch-ressurser ryddet opp.
**Rullet ut 2026-08-22** -- bruker bekreftet. Migrasjon 090 kjørt
mot ekte `teecup_db` (kun tre nye tabeller, ingen endring av
eksisterende data -- bekreftet tom rett etter kjøring). `docker
compose build teecup_api teecup_frontend && up -d`, begge
containere friske, rene logger. `https://teecup.golf/health` 200
etter utrulling.
161. **Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103),
2026-08-22 -- punkt 6 i gap-listen, bygget etter punkt 7 (ADR-102)
fordi delingsfunksjonen forutsatte kommentartråden.** Bruker
bekreftet før planen ble godkjent at avstand-til-green/hindringer
(rangefinderen) allerede er dekket (ADR-064/083, delt komponent,
live siden 2026-08-17) -- denne runden dekker den atskilte, frie
slag-for-slag-målingen (utslag→ball, ADR-048), ikke faste
banepunkter.
**Ny migrasjon `091_tournament_round_shot.sql`:** speiler
`round_shot` (060/061) strukturelt, org-scopet (RLS), FK-et direkte
til `tournament_round_participant` + `hole_number` (IKKE
`tournament_round_hole_id` -- den raden finnes kanskje ikke ennå,
siden `tournament_round_hole` først opprettes ved første
score-innsending, ulikt frittstående `round_hole`).
**Backend:** ny fil `app/routers/tournament_round_shots.py`.
Autorisasjon speiler `update_hole` (self ELLER org-admin) for
skriving -- strengere enn ADR-102s "org-medlem"-nivå, siden dette
er samme sensitivitetsklasse som selve scoringen. Deling reuser den
NYE rundespesifikke kommentartråden (ADR-102), ikke Banter Board --
lokal kopi av `rounds.py::share_shot` sitt Mapbox-snippet-mønster
(samme "ingen kryssimport mellom subsystemene"-presedens).
**Frontend:** `ShotMeasurementSheet`/`MapPointPicker` (Mapbox GL
JS + GPS) var allerede 100 % generiske -- gjenbrukt UENDRET.
`ShotMeasurementEntry` generalisert fra en lokal `round-detail.tsx`-
funksjon til ny delt `frontend/components/shot/shot-measurement-
entry.tsx` (eksplisitte URL-props i stedet for `roundId`+`owner`) --
de tre eksisterende frittstående-kallstedene fikk kun en
signaturomskrivning, atferd uendret. Ny bruk i
`TournamentScoringWizard`s "direction"-steg (samme sted som
round-detail.tsx sin plassering) -- dekker BÅDE kjedet flerspiller-
scoring OG `HoleGrid` sitt enkelt-spiller-kall siden begge deler
samme wizard-komponent.
**Verifisert:** 188/188 pytest (11 nye), `tsc --noEmit` rent.
Full-stack scratch MED ekte Mapbox-tokens (ekte innlogging, ingen
fetch-mocking): GPS-flyten testet via injisert
`navigator.geolocation`-overstyring (samme arbeidsomgåelse som
ADR-083 for headless Chrome), kart-velg-punkt testet med et EKTE
Mapbox-kart. Slag målt (begge metoder), kølle+avstand riktig,
deling opprettet en ekte `tournament_round_message` med et ekte
satellittbilde (14 444 byte AVIF, verifisert direkte mot
scratch-MinIO), "Delt"-merkelapp korrekt, sletting av et slag lot
den allerede delte kommentaren stå urørt, mørk modus + smal
mobilskjerm. Alle scratch-ressurser ryddet opp.
**Rullet ut 2026-08-22** -- bruker bekreftet. Migrasjon 091 kjørt
mot ekte `teecup_db` (kun én ny tabell, `tournament_round_shot`,
ingen endring av eksisterende data -- bekreftet tom rett etter
kjøring). `docker compose build teecup_api teecup_frontend && up
-d`, begge containere friske, rene logger.
`https://teecup.golf/health` 200 etter utrulling.
162. **Fullt flerspiller-scorekort-grid + offline-kø for score-føring i
ScoreTab (ADR-104), 2026-08-22 -- punkt 4 og 1 i gap-listen, bedt om
samtidig siden punkt 1 er en liten oppgave.** Ren frontend-runde,
ingen backend-endring.
**Punkt 4:** "Vis hele scorekortet" viste tidligere kun en navne-
pille-rad + ett enkelt-spiller-`HoleGrid` (bytt HVEM som vises). Ny
`TournamentScorecardGrid` speiler `round-detail.tsx` sin
`ScorecardGrid` strukturelt (sticky spillerkolonne, Ut/Inn/Sum), men
bruker denne filens EGNE `classify`/`scoreMarkClasses` for visuell
konsistens med flight-kortlisten. `HoleGrid` (kun ett kallsted)
slettet -- cellene tapper direkte inn i den eksisterende
`TournamentScoringWizard`-mounten (setter `activeHole` +
`wizardPlayerId`), ingen ny wizard-instans. Hente-effekten for
`holesByParticipant` utvidet til å dekke hele det synlige feltet,
men kun når seksjonen faktisk er åpen.
**Punkt 1:** `updateHoleFull` hadde ingen offline-kø-kobling --
speiler nå `round-detail.tsx` sitt `queueParticipantHoleWrite`/
`updateWizardStat`-mønster (ADR-028/057/060) nøyaktig, samme delte
`frontend/lib/offline-queue.ts` uendret. Samme banner-språk, samme
tre synk-utløsere (online-event, mount-sjekk, manuell knapp). 409-
håndtering lagt til for første gang i denne filen (manglet helt før).
**Verifisert:** `tsc --noEmit` rent, 188/188 pytest uendret (ingen
backend rørt). Full-stack scratch (egen db/API-/MinIO-container,
ekte innlogging, ingen fetch-mocking) med en flight på 3 spillere:
gridet viser alle samtidig med riktig Ut/Inn/Sum/golfscore-form,
tapp på annen spiller enn aktiv åpner riktig wizard for nøyaktig den
spilleren+hullet, et reelt 403 (selv-only-avvisning) bekreftet
uendret riktig. Offline-kø: frakoblet → registrert → banner +
optimistisk oppdatering uten feil → tilkoblet → automatisk synk →
bekreftet direkte mot databasen at BÅDE det online og det kølagte
hullet ble lagret riktig. Mørk modus + smal mobilskjerm. Alle
scratch-ressurser ryddet opp.
**Rullet ut 2026-08-22** -- bruker bekreftet. Ingen migrasjon --
kun `docker compose build teecup_frontend && up -d`, container
frisk, rene logger. `https://teecup.golf/health` 200 etter
utrulling.
163. **Personlig statistikk-dashboard blander inn org-turnering-runder
(ADR-105), 2026-08-22 -- punkt 3 i gap-listen, bedt om rett etter
ADR-104, med punkt 2 (live push) varslet som neste.** `GET
/rounds/stats/summary` (`/my-rounds/stats`) aggregerte tidligere KUN
frittstående runder -- en spillers GIR%/putting/fairway-trender fra
org-turneringer vistes aldri der.
**Design-avklaring (AskUserQuestion, bekreftet):** bland tallene inn
i de eksisterende trend-/snitt-tallene (ingen visuell markering),
men vis kilde-antall separat -- speiler `hole_history.py` (ADR-072)
sin `times_personal`/`times_tournament`. Ny `RoundStatsSummary`-felt
`rounds_completed_personal`/`rounds_completed_tournament`, vist som
"(X frittstående + Y turnering)" i Oversikt-ingressen.
**Backend:** ny `_fetch_tournament_stats_rows()` i `rounds.py`,
speiler `hole_history.py` sin N+1-per-org-henting
(`player_organizations_for_user` → `org_connection` per org), uten
course-key-filtreringen (ikke nødvendig her). Resultatet slås inn i
de SAMME `by_round`/`stat_level_by_round`/`played_at_by_round`-
dict-ene som personlige rader (namespacet nøkkel `f"t:{trp_id}"`) --
`_resolve_stats_window`/`_build_round_series`/`_summarize_rounds`
trengte INGEN endring i selve regnelogikken (kun ett nytt
`source_by_round`-parameter til `_summarize_rounds` for de to nye
kilde-antall-feltene). `tournament_round` har ingen `completed_at`
-- "fullført" avgjøres ved radtelling mot
`played_hole_numbers(hole_config)`, samme prinsipp
`individual_tournaments.py` bruker for flaggturnering-resultatet.
Rader uten `tr.scheduled_at` ekskluderes helt (kan ikke plasseres i
et tidsvindu uten dato). `ordered_rounds`-sorteringen måtte gjøres
eksplisitt (kunne før stole på SQL-ens `ORDER BY` som
innsettingsrekkefølge -- stemmer ikke lenger etter sammenslåing av
to kilder).
**Frontend:** `ApiStatsSummary`-typen fikk de to nye feltene,
`rounds-stats-summary.tsx` sin "Aggregert over N fullførte
runder"-setning utvidet med kilde-parentesen når
`rounds_completed_tournament > 0`. Ingen ny komponent.
**Fant og rettet underveis i full-stack scratch-verifiseringen:**
en første one-off scratch-frontend-container ble bygget UTEN riktig
`--build-arg TEECUP_API_ORIGIN` -- `next.config.mjs` sin
`rewrites()` leses ved BUILD-tid for standalone-output (dokumentert
i `frontend/Dockerfile`s egen kommentar), IKKE ved
container-oppstart som antatt. Containeren proxyet derfor tre
innloggingsforsøk til den EKTE, kjørende `teecup_api` (2×422 ugyldig
e-postdomene, 1×401 ukjent testbruker) før feilen ble oppdaget.
Ingen skriving til ekte `teecup_db` skjedde (begge feilkodene
inntreffer før/uten DB-skriving; rate-limiteren er en ren
in-memory-teller) -- containeren ble stanset umiddelbart og
brukeren varslet direkte i økten. Rettet ved å bygge
scratch-frontend-imaget på nytt med riktig
`--build-arg TEECUP_API_ORIGIN` pekt mot scratch-API-containeren.
**Verifisert:** 5 nye pytest-tester (kildeblanding,
strokes_only-turneringsrunde ekskludert fra putt-snittet, ufullført
turneringsrunde ekskludert, runde uten `scheduled_at` ekskludert,
RLS -- runde i en org brukeren ikke er spiller i ekskludert) + full
regresjonssuite, 193/193 grønt. `tsc --noEmit` rent. Full-stack
scratch (egen db + to one-off-containere for
teecup_api/teecup_frontend, ekte innlogging) med en bruker som har
runder i TO ulike organisasjoner + én frittstående runde: dashbordet
viste riktig "3 fullførte runder (1 frittstående + 2 turnering)",
riktig blandet snitt/GIR/putt, ingen konsollfeil. Mørk modus + smal
mobilskjerm (375px) bekreftet lesbart. Alle scratch-ressurser (db,
rolle, containere, images) ryddet opp.
**Rullet ut 2026-08-22** -- bruker bekreftet. Ingen migrasjon --
`docker compose build teecup_api teecup_frontend && up -d`, begge
containere friske, rene logger. `https://teecup.golf/health` 200
etter utrulling.
164. **Flytende "Registrer score" -- punktfiks forsøkt, deretter
reversert til fordel for en redesign, 2026-08-23.** Bruker viste to
skjermdumper: scrollet helt ned til kommentartråden, den flytende
knappen hang fortsatt igjen nederst og dekket delvis over
"Kommentarer (1)"-overskriften.
**Rot-årsak funnet og først punktfikset (senere reversert, se
under):** en stale vakt igjen fra FØR #146 frikoblet knappens
synlighets-TRIGGER fra `mapExpanded` (byttet til
`inFlowButtonVisible`), men selve `playerListReached`-MÅLE-effekten
beholdt sin egen, aldri fjernede `if (!mapExpanded) return`-vakt.
Siden banekartet er kollapset som standard, kjørte scroll/resize-
lytteren for `playerListReached` aldri for de fleste brukere --
tilstanden forble fastlåst på `false`, og synlighets-betingelsen
degraderte reelt til bare `inFlowButtonVisible`. Knappen dukket
dermed opp forbi side-flyt-knappen tidlig på siden og forsvant
ALDRI igjen, inkl. over Kommentarer lengst ned. Verifisert i
full-stack scratch (3 spillere + 6 kommentarer, samme scenario som
skjermdumpene) at en énlinjes fjerning av vakten løste akkurat dette
symptomet -- se detaljene i den reverterte commit-meldingen.
**Bruker avviste punktfiksen som utilstrekkelig løsning** (samme
runde, før utrulling ble vurdert): "Nei, dét er ikke løsningen.
Knappen er der av en grunn. Kanskje den heller kan gjøres smalere og
plasseres ut på den ene siden av skjermen? Slik at den ikke er så
dominerende?" -- pekte på at dette er tredje runde med bugs i akkurat
denne skjul/vis-mekanismen (#141/142/146, nå denne), og at en
hjørne-plassert, kompakt FAB fremfor dagens fullbredde-bunnbanner
trolig kan fjerne HELE `playerListReached`/`inFlowButtonVisible`-
maskineriet, ikke bare rette enda en bug i det.
**Reversert** (`git revert`, ikke reset -- unpushet commit, men
revert bevarer historikken eksplisitt fremfor å late som forsøket
aldri skjedde). Går videre med en Claude-skrevet V0-prompt for en
kompakt, hjørne-forankret "Score"-FAB som erstatning, jf.
stående instruks om at nye UI-overflater skal gjennom V0 fremfor
håndkodes direkte. Ingen utrulling av punktfiksen fant sted.
165. **Kompakt "Score"-FAB integrert, erstatter fullbredde-bunnbanneret
helt (V0-eksport, oppfølging av punkt 164), 2026-08-23.** V0-prompt
(`Temp-uploads/v0-prompt-compact-score-fab.md`) levert til bruker,
eksport mottatt (`components/compact-score-fab.tsx`) og integrert
tro mot eksporten -- ny fil `frontend/components/compact-score-fab.tsx`,
uendret fra V0 bortsett fra kommentarer.
**`round-detail.tsx`:** `RegisterScoreButton` (den vanlige, i-flyt-
plasserte knappen) er UENDRET og fortsatt eneste sted "Registrer
score ↓"-teksten/pil-ikonet brukes. Den gamle flytende
fullbredde-banner-JSX-en (`fixed inset-x-0 bottom-0` + gradient-scrim
+ `h-20`-spacer) er fjernet i sin helhet, erstattet med
`<CompactScoreFab visible={!inFlowButtonVisible}
onClick={scrollToPlayerList} />`. Hele `playerListReached`-
mekanismen (state + effekt + den sårbare `mapExpanded`-vakten som
forårsaket punkt 164) er fjernet -- ikke lenger nødvendig, siden en
liten hjørne-FAB ikke trenger å skjule seg igjen for å unngå å dekke
over innhold. `inFlowButtonRef`/`inFlowButtonVisible`/
`scrollToPlayerList`/`playerListRef`/`SCROLL_GAP_PX`/
`getHeaderHeight()` er alle UENDRET og gjenbrukt som før (sistnevnte
to også brukt av `scrollToHolePanel()`, urørt av denne runden).
**Verifisert i full-stack scratch** (samme oppskrift som punkt 164 --
3 spillere + 6 kommentarer for å gi nok scroll-lengde): FAB-en
bekreftet skjult mens i-flyt-knappen er synlig, dukker opp
(fade+skaler, IKKE slide-opp) idet i-flyt-knappen ruller ut av syne,
forblir korrekt synlig helt ned til og forbi Kommentarer UTEN å dekke
over innholdet (bekreftet med skjermbilder, både lys og mørk modus),
og skjules igjen ved scroll tilbake til toppen. Trykkflate målt til
48px høyde (over 44px-gulvet), innholds-bredde (~110px, ikke full
bredde). `tsc --noEmit` rent, ingen konsollfeil. Alle scratch-
ressurser ryddet opp.
**Rullet ut 2026-08-23** -- bruker bekreftet. Ingen migrasjon -- ren
frontend, `docker compose build teecup_frontend && up -d` (gjenskapte
også `teecup_api` som vanlig bivirkning, ingen backend-kode rørt).
Begge containere friske, rene logger. `https://teecup.golf/health`
200 etter utrulling.
166. **"Jakter Jesper" bygget som visningsmodus for netto-scoring, ikke
en ny turneringsform (ADR-106), 2026-08-23.** Bruker beskrev en
turneringsform (start på -spillehandicap, brutto mot par løpende,
lavest sluttsum vinner, tiebreak på lavest spillehandicap) og spurte
om det egentlig var en ny presentasjonsform fremfor en ny
turneringsform -- riktig observasjon: sluttsummen er matematisk
identisk med `stroke_net` sitt netto-til-par.
**Ny kolonne `tournament.net_display_style`** ('standard'/'hunt',
migrasjon 092), kun gyldig sammen med `scoring_method='stroke_net'`
(fail-loudly 400 begge veier). `_attach_stroke_play_columns` fikk én
`stroke_val()`-hjelper som bytter `net_total` (gradvis slagindeks-
basert) med `gross_total - playing_handicap` (hele handicapet trukket
fra med én gang) når hunt er aktiv -- ingen ny motorfunksjon, ingen
duplisert beregning. Ny, GENERELT tilgjengelig tiebreak-verdi
`lowest_playing_hcp` (spillehandicap, ikke WHS-indeksen `lowest_hcp`
bruker) -- organisator-valgfri som de andre, ikke tvunget på for
formatet (brukerbekreftet). `LeaderboardRow`-kontrakten (ADR-101)
RØRT IKKE -- alle felt var allerede opake, ferdig-formaterte strenger.
**Verifisert:** 5 nye pytest-tester (løpende tall skiller seg fra
standard midt i runden, konvergerer eksakt ved 18 fullførte hull;
validering begge veier; `lowest_playing_hcp` løser en reell
uavgjort; manglende spillehandicap sorteres sist) + full
regresjonssuite, 198/198 grønt. `tsc --noEmit` rent. Full-stack
scratch (egen db + to one-off-containere, ekte innlogging inkl. et
ekte 2FA-e-post-oppsett): stroke_net-turnering med to spillere
(spillehandicap 12/4), hunt PÅ -- leaderboardet viste -12/-2,
hånd-utregnet på forhånd og matchet eksakt. Slått av live -- tallene
endret seg umiddelbart til standard-visningens -5/-2 uten
sideoppfriskning. Offentlig "Følg live"-side OG utskrifts-
resultatlisten viste hunt-tallene korrekt UTEN kodeendring der,
bekrefter at de kun konsumerer opake strenger. Mørk modus bekreftet
lesbart. Alle scratch-ressurser ryddet opp.
**Rullet ut 2026-08-23** -- bruker bekreftet. Migrasjon 092 kjørt
mot ekte `teecup_db` (kun to `ADD COLUMN`/CHECK-utvidelser, ingen
eksisterende data endret). `docker compose build teecup_api
teecup_frontend && up -d`, begge containere friske, rene logger.
`https://teecup.golf/health` 200 etter utrulling.
167. **"Jakter Jesper" del 2: frittstående runder + lagturnering-økt-
leaderboard (ADR-107), 2026-08-24.** Bruker: "La oss fullføre Jakter
Jesper" -- fullførte begge oppfølgingspunktene som ADR-106 bevisst
lot ligge (se FEATURE_BACKLOG.md).
**Del A -- frittstående runder:** ny kolonne `round.net_display_style`
(migrasjon 093), kun gyldig for `play_format='stroke'`, samme
fail-loudly-mønster begge veier som ADR-106. `my_net_score_to_par`
(rounds.py) og leaderboardets `net_score_to_par` bytter fra gradvis
(`- total_strokes_received`) til helt (`- course_handicap_snapshot`)
når hunt er aktiv. Nøkkelforskjell fra org-individuell:
`round-leaderboard.tsx` regner netto-til-par klientsidig fra rå
`strokes_received`, ikke fra et backend-felt -- løsningen fikk derfor
en ny parallell `huntNettoToPar()`-funksjon der, i tillegg til
backend-swappen. `EditRoundPanel` fikk en ny bryter (samme
segmenterte-knapp-mønster som `flagMapVisible`), synlig kun for
`play_format==='stroke'`.
**Del B -- lagturnering-økt-leaderboard:** ny kolonne
`session.net_display_style` (samme migrasjon), PER ØKT ikke per
turnering -- vurdert og avvist: gjenbruk av
`tournament.net_display_style`, men lagturneringer har
`scoring_method=NULL` alltid og `tournament-detail.tsx` har ingen
motpart til `individual-tournament-detail.tsx` sin
"Resultat og opsjoner"-fane (`TournamentSettingsCard` importeres
ikke der). En øktnivå-kolonne kunne i stedet henge rett på den
allerede eksisterende `EditSessionForm` -- mindre nytt UI, og mer
presist (en lagturnering kan ha noen singles/fourball-økter i
hunt-modus og andre ikke). Gyldig for `format IN
('singles','fourball') AND scoring_mode='stroke'` (samme betingelse
`fetch_individual_leaderboard` allerede håndhevet ved lesing, nå også
håndhevet proaktivt ved lagring i `update_session`).
`net_score`/`net_to_par` i `fetch_individual_leaderboard` bytter fra
gradvis til `- match_participant.playing_handicap` når hunt er
aktiv. `total_points`/Stableford-formelen uendret i begge deler, som
forventet.
**Funn under scratch-verifisering:** rundekortets "Så langt"-widget
(`ScoreSoFar`, round-detail.tsx) hadde en TREDJE, uavhengig
netto-beregning (fra `strokes_received` per hull) som ikke var
hunt-bevisst -- ikke identifisert i planen på forhånd. Viste feil
`+15` i stedet for korrekt `+8` (12-hcp, 20 brutto slag over 5 hull)
inntil oppdaget live i scratch. Fikset med samme
`courseHandicap`/`netDisplayStyle`-props-mønster og re-verifisert
("Netto +8" korrekt etter fiks). Fanget nettopp fordi fullstack-
scratch-disiplinen ble fulgt i stedet for å stole på planen alene.
**Verifisert:** 6 nye pytest-tester (`test_round_net_hunt_
display.py`, `test_session_net_hunt_display.py`, 3 hver, samme
konvergens-bevis-mønster som ADR-106: løpende tall skiller seg fra
standard midt i runden/økten, konvergerer eksakt ved 18 fullførte
hull) + full regresjonssuite, 204/204 grønt. `tsc --noEmit` rent.
Full-stack scratch (egen db + to one-off-containere, ekte innlogging):
en frittstående runde (hcp 12, 5 hull par) -- rundekort OG leaderboard
viste -12 under hunt, konvergerte til samme -12 ved 18 fullførte hull
under standard, hånd-utregnet på forhånd og matchet eksakt. En
lagturnering-singles-økt, identisk oppsett -- leaderboardet viste
net_score=-8/net_to_par=-12 under hunt, -5 under standard etter
tilbakebytting (5 hull, stroke_index 1-5, alle mottar slag).
Bekreftet bryterne kun vises der de gir mening. Mørk modus + smal
mobilskjerm (375px) bekreftet lesbart for begge deler, inkludert
hunt-bannerets kontrast og tekstbryting. Alle scratch-ressurser
ryddet opp (containere, db, rolle, images).

View file

@ -143,10 +143,6 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
- Bruk git (remote: brukerens Forgejo). Commit i logiske steg med tydelige
meldinger.
- Er du usikker på omfang eller en beslutning: spør heller enn å gjette.
- Kommuniser på norsk i chatten som standard (brukerens eksplisitte
instruks, notert her 2026-08-23 siden det ikke sto skriftlig fra før).
Kode, commit-meldinger og identifikatorer følger fortsatt vanlig
engelsk konvensjon — dette gjelder kun samtalen med brukeren.
## Status og neste steg
Den detaljerte, kronologiske statusloggen (hva som er ferdig, verifisert

View file

@ -7,9 +7,9 @@
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
>
> Sist oppdatert: 2026-08-22 (full gap-analyse: scoring-paritet
> frittstående runde vs. org-turnering, erstatter den tidligere kun-
> muntlige "8-punktslisten" -- se nederste seksjon)
> Sist oppdatert: 2026-08-21 (funksjonelle hull funnet under utskrift-
> planlegging notert -- hullengde, turneringslogo, bracket-format
> utsatt, shotgun-start under bygging -- se de to nederste seksjonene)
---
@ -598,12 +598,6 @@
er lagturnerings-only — egen, større oppgave hvis/når etterspurt).
Migrasjon 071 — se ADR-067.
**✅ BYGGET 2026-08-21 (ADR-098)** — `/t/[id]/live` forgrener seg nå
server-side på `format_type`, ny `PublicIndividualLive`-side (V0-bygget)
med rundevelger + samlet leaderboard + hull-for-hull, samme sanntids-
kanal som Cup-siden. Se ARCHITECTURE_DECISIONS.md for full detalj,
CHANGELOG.md for byggelogg. IKKE rullet ut ennå.
**✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del,
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
org-individuelle turneringer (`scoring_method = eclectic_gross/
@ -4877,209 +4871,3 @@ dem designet i detalj ennå:
**Neste steg:** begge idéene trenger en egen avklaringsrunde med bruker
FØR design/bygging -- ingen beslutninger tatt ennå, kun fanget opp her
for å ikke gå tapt.
## Idé, ikke spesifisert: inkrementell deltaker-id (startnummer) for turneringer (2026-08-22) — 📋 fanget, ikke designet
Bruker: "Alle turneringsdeltagere skal ha en id. Id'en blir tildelt ved
påmelding, og øker inkrementelt med 1 for hver som melder seg på.
Id'en skal være synlig på scorekort og på startlister. Den brukes til
å definere startrekkefølge innad i flighter (om ikke turneringsleder
har valgt at lavest hcp starter før de(n) med høyere hcp)."
Finnes ikke i dag: `tournament_participant.id` (migrasjon 040) er en
UUID, ikke et menneskelesbart, fortløpende tildelt tall -- ingen egen
"startnummer"/"bib"-kolonne finnes. Ingen eksisterende sortering av
spillere INNAD i en `tournament_round_group` (utslagsgruppe, ADR-096)
er funnet -- gruppene har i dag kun en gruppe-sekvens
(`tournament_round_group.sequence`) og et valgfritt `tee_time_override`,
ingen fastsatt spillerekkefølge INNE i selve gruppen.
Uavklart, trenger en egen designrunde FØR bygging:
- **Omfang -- individuelle turneringer, eller også Cup/lagformat?**
"Flighter" er et individuell-turnering-konsept (`tournament_round_
group`) -- Cup-formatet har `session`/`match`, ingen tilsvarende
gruppe-av-spillere-enhet. Antas individuelt-format-only inntil
bekreftet, men ikke eksplisitt avklart med bruker ennå.
- **Tildelingstidspunkt/-omfang:** "ved påmelding" -- gjelder dette KUN
selvregistrering (`registration.py`, offentlig påmeldingsskjema),
eller også når en organisator legger til en deltaker manuelt
(`add_round_participant`/tilsvarende i Oppsett-fanen)? Trolig begge,
men ikke bekreftet.
- **Nullstilles/gjenbrukes id ved avmelding?** Fortløpende +1 antyder
NEI (samme prinsipp som et fysisk startnummer -- forsvinner ikke selv
om noen trekker seg), men ikke eksplisitt sagt.
- **HCP-sorteringsalternativet** ("laveste hcp starter først") --
en NY, valgfri turneringsinnstilling (sannsynligvis nær de andre
turneringsvide bryterne i Oppsett-fanen, `tournament_settings_card`/
`TournamentSettingsCard`), som når PÅ overstyrer deltaker-id som
sorteringsnøkkel innad i flighten. Snittsdetaljer (stigende/synkende,
hva som skjer ved likt hcp) ikke spesifisert.
- **Visning:** "synlig på scorekort og startlister" -- gjelder dette
BÅDE de nylig bygde utskriftsflatene (`print-scorecard.tsx`/
`print-startlist.tsx`, ADR-097) OG skjermvisningene (`HoleGrid`/
`ScoreTab`/`tournament-program.tsx`), eller kun utskrift? Ikke
spesifisert.
**Neste steg:** egen avklaringsrunde med bruker (omfang, tildelings-
regler, HCP-bryterens eksakte oppførsel, hvor id-en skal vises) FØR
datamodell/migrasjon designes -- ingen beslutninger tatt ennå, kun
fanget opp her for å ikke gå tapt.
## Scoring-paritet: frittstående runde vs. org-turnering (2026-08-22) — 📋 fanget, delvis planlagt
Bruker: "Jeg ønsker at opplevelsen skal være tilnærmet identisk, enten
du fører score i en individuell runde, eller om du fører for deg eller
flighten din i en org-turnering." Dette punktet ble opprinnelig avtalt
muntlig som en "8-punkts parity-rekkefølge" (referert i ADR-099/ADR-100)
uten at listen selv noensinne ble skrevet ned ordrett i noen fil -- et
hull i egen dokumentasjonsdisiplin. Denne seksjonen ERSTATTER den
muntlige listen med en fullstendig, gjenfunnet og verifisert
gap-analyse (grundig kodesøk 2026-08-22, ikke bare hukommelse).
**Allerede bygget (ikke fang opp på nytt):** flight/gruppe-scoping i
ScoreTab (ADR-099), kjedet flerspiller hull-for-hull-scoring (ADR-100),
hull-for-hull-utvidelse i den autentiserte leaderboarden for slagspill/
netto/Stableford (ADR-101), rangefinder/avstand-til-flagg (ADR-064,
begge flyter), GPS-"plant flagg" for Flagg-formatet (ADR-066, begge
flyter), kombinert frittstående+turnering hull-historikk (ADR-071).
**Gjenstående gap, rangert etter antatt størrelse/verdi (frittstående
runde HAR, org-turnering MANGLER, om ikke annet er sagt):**
1. ✅ **Offline-kø for score-føring -- FERDIG BYGGET OG RULLET UT
2026-08-22 (ADR-104), se CHANGELOG punkt 162.** `updateHoleFull` i `ScoreTab`
speiler nå `round-detail.tsx` sitt kø-/synk-mønster nøyaktig, samme
delte `frontend/lib/offline-queue.ts` uendret. Bygget samtidig med
punkt 4 (se under).
2. **Sanntidsoppdatering (live push) i den autentiserte scorings-/
leaderboard-visningen.** `round-detail.tsx` åpner en websocket
(`/ws/rounds/{roundId}/live`) og oppdaterer automatisk når andre i
flighten fører score. `individual-tournament-detail.tsx` har ingen
websocket-abonnement -- kun egen-utløst refresh. Backenden
kringkaster allerede (`broadcast_live_update` i
`individual_tournaments.py`), men kun til den OFFENTLIGE
tilskuersiden, ikke den autentiserte. Antatt **liten/middels**.
3. ✅ **Personlig stats-dashboard blander nå inn turneringsrunder --
FERDIG BYGGET 2026-08-22 (ADR-105), se CHANGELOG punkt 163.**
`GET /rounds/stats/summary` henter nå ny `_fetch_tournament_stats_rows()`
(speiler `hole_history.py` sin N+1-per-org-henting) og blander inn i
de samme trend-/snitt-tallene som frittstående runder, med kilde-
antall vist separat ("N fullførte runder (X frittstående + Y
turnering)"), samme presedens som `hole_history.py` sin
`times_personal`/`times_tournament`. Ikke rullet ut ennå -- venter på
brukerens bekreftelse.
4. ✅ **Fullt flerspiller-scorekort-grid -- FERDIG BYGGET OG RULLET UT
2026-08-22 (ADR-104), se CHANGELOG punkt 162.** Ny `TournamentScorecardGrid`
(ekte alle-spillere×alle-hull-matrise, samme struktur som
round-detail.tsx sin ScorecardGrid) erstattet den gamle pille-rad +
enkelt-spiller-`HoleGrid`-visningen (nå slettet). Bygget samtidig med
punkt 1 (se over).
5. **"Plukket opp"-hurtigknapp finnes ikke for turneringer, selv der
Stableford er scoringsmetoden.** `round_hole.picked_up` (migrasjon
038) + knapp finnes i frittstående runder. `tournament_round_hole`
har ingen tilsvarende kolonne, `TournamentScoringWizard` ingen
tilsvarende knapp. Antatt **middels** -- ny migrasjon + backend-speil
av eksisterende capping-logikk (`max_hole_score_for_handicap`).
6. ✅ **Slag-for-slag GPS-avstandsmåling -- FERDIG BYGGET OG RULLET UT
2026-08-22 (ADR-103), se CHANGELOG punkt 161.** Ny `tournament_
round_shot`-tabell (org-scopet, RLS, FK-et til tournament_round_
participant + hole_number). `ShotMeasurementSheet`/`MapPointPicker`
gjenbrukt uendret; `ShotMeasurementEntry` generalisert til delt fil.
Deling går til den nye kommentartråden (punkt 7/ADR-102). Verifisert
i scratch med ekte Mapbox-tokens (GPS + kart-valgt punkt begge
testet).
7. ✅ **Kommentarfelt/tråd på runden -- FERDIG BYGGET OG RULLET UT
2026-08-22 (ADR-102), se CHANGELOG punkt 160.** Ny
`tournament_round_message`-tabell (org-scopet, RLS), én tråd per
turneringsrunde (hele feltet, ikke per flight -- bevisst valg).
Reaksjoner + trådede kommentarer med. Bevisst utenfor v1: @-tagging,
sanntid/WebSocket.
8. **Tilskuer-metrikkveksling (brutto/netto/Stableford/poeng) finnes kun
for frittstående runder.** `round-leaderboard.tsx` sin `MetricToggle`
har ingen motpart i `stroke-play-leaderboard.tsx` (som eksplisitt har
en "eksakt kontrakt -- ikke endre" på `LeaderboardRow`, ADR-101).
Antatt **middels** -- må legges til additivt for å ikke bryte den
kontrakten.
**Gap i motsatt retning (org-turnering HAR, frittstående MANGLER) --
lavere prioritet per brukerens uttalte fokus, men notert for
fullstendighet:**
9. **Print/eksport.** Org-turneringer har en full utskriftspakke
(kart-lapper/resultatliste/startliste/scorekort, ADR-097).
Frittstående runder har ingen print/eksport-affordance i det hele
tatt. Antatt **liten/middels** -- kan trolig gjenbruke
`print-scorecard.tsx`s tilnærming for én enkelt frittstående runde.
**Neste steg:** bruker prioriterer rekkefølge blant punktene 1-8 (og
avgjør om punkt 9 skal tas med i samme runde eller utsettes). Ingen
bygging startet på noen av disse ennå -- denne seksjonen er kun
gap-fangst, ikke et design. Når et punkt velges, skriv en egen ADR før
bygging (samme mønster som ADR-099/100/101).
## Idé, ikke spesifisert: notatbok/anteckningsbok i scoreførings-sidene (2026-08-22) — 📋 fanget, ikke designet
Bruker: "Jeg ønsker meg en notatbok, gjerne en popup fra menyen på
toppen når jeg er i scoreføringssidene. Den skal kunne gis beskjed om
kun å åpnes manuelt, kun åpnes før eller etter hver runde, eller før
eller etter registrering av score på et hull."
Fanget opp mens arbeidet med punkt 7 over (rundespesifikk
kommentartråd, ADR-102) pågikk -- ikke undersøkt eller designet ennå.
Uavklart, trenger en egen avklaringsrunde FØR bygging:
- **Innhold:** hva slags notater -- fritekst? faste felt (vær, baneforhold,
utstyrsvalg)? knyttet til ett hull, én runde, eller spilleren generelt
på tvers av runder?
- **Lagring/omfang:** frittstående runder, org-turneringer, eller begge
(samme "parity"-spørsmål som resten av denne gap-listen)? Privat for
spilleren alene, eller delt med medspillere/flighten (som
kommentartråden over)?
- **Triggerlogikk for automatisk åpning:** "før/etter hver runde" og
"før/etter registrering av score på et hull" er fire distinkte
tidspunkt -- trenger presis avklaring av hva "runde"/"hull" betyr her
(hver runde i en flerrundeturnering? hvert nytt hull spilleren
beveger seg til?), og om brukeren kan velge FLERE av de fire
samtidig eller kun ett.
- **Plassering:** "popup fra menyen på toppen" -- gjelder dette samme
toppmeny i BÅDE frittstående rundesiden og org-turnering-siden
(ScoreTab), eller kun én av dem?
**Neste steg:** egen avklaringsrunde med bruker (innhold, lagringsomfang,
eksakt triggerlogikk, plassering) FØR design/bygging -- ingen
beslutninger tatt ennå, kun fanget opp her for å ikke gå tapt.
## Jakter Jesper (visningsmodus for netto-scoring) — ✅ HELT FERDIG OG LIVE 2026-08-24 (ADR-106 + ADR-107)
Bruker beskrev en turneringsform (start på -spillehandicap, hullscore
brutto mot par løpende, lavest sluttsum vinner, tiebreak på lavest
spillehandicap) for org-individuelle turneringer. Etter diskusjon avklart
som en VISNINGSMODUS på eksisterende `stroke_net`-scoring (sluttsummen er
matematisk identisk med netto-til-par), ikke en ny scoring_method/
turneringsform -- se ADR-106 for hele resonnementet.
**Ferdig, live 2026-08-23 (ADR-106):** ny `tournament.net_display_style`
('standard'/'hunt', migrasjon 092) for org-individuelle turneringer,
pluss en ny, generelt tilgjengelig tiebreak-verdi `lowest_playing_hcp`.
**Ferdig, live 2026-08-24 (ADR-107) -- begge tidligere bevisst utsatte
oppfølgere fullført i samme runde:**
1. **Frittstående runder** (`round.net_display_style`, migrasjon 093).
`my_net_score_to_par` (rounds.py) og leaderboardets `net_score_to_par`
bytter fra gradvis (`- total_strokes_received`) til helt (`-
course_handicap_snapshot`) når hunt er aktiv. `round-leaderboard.tsx`
fikk en egen klientsidig `huntNettoToPar()` (regner netto-til-par selv,
ulikt org-individuell). Underveis avdekket og fikset: rundekortets
"Så langt"-widget (`ScoreSoFar`, round-detail.tsx) hadde en tredje,
uavhengig netto-beregning som ikke var hunt-bevisst -- se ADR-107.
2. **Kolonne på lagturnering-øktens individuelle leaderboard**
(`session.net_display_style`, migrasjon 093, PER ØKT ikke per
turnering -- se ADR-107 for begrunnelsen). Gyldig for
`format IN ('singles','fourball') AND scoring_mode='stroke'`.
`fetch_individual_leaderboard` bytter `net_score`/`net_to_par` fra
gradvis til `- match_participant.playing_handicap` når hunt er aktiv.
**Verifisert:** 6 nye pytest-tester (konvergens-bevis-mønster, begge
deler) + full regresjon 204/204 grønt, `tsc --noEmit` rent, full-stack
scratch (desktop+mobil, lys+mørk). Se ADR-107 og CHANGELOG.md #167 for
fullstendig detalj.

View file

@ -32,8 +32,6 @@ from .routers import (
round_messages,
rounds,
scoring,
tournament_round_messages,
tournament_round_shots,
tournaments,
)
from . import storage
@ -68,8 +66,6 @@ app.include_router(messaging.router)
app.include_router(messaging.public_router)
app.include_router(rounds.router)
app.include_router(round_messages.router)
app.include_router(tournament_round_messages.router)
app.include_router(tournament_round_shots.router)
app.include_router(friends.router)
app.include_router(notifications.router)
app.include_router(print_pdf.router)

View file

@ -525,30 +525,6 @@ async def import_official_course(
"Banen mangler par eller HCP-index på ett eller flere hull i teeoff.",
)
# Funnet 2026-08-21: teeoff sin egen kildedata for en bane kan ha
# UGYLDIGE hcp-indekser (f.eks. duplisert -- én verdi to ganger, en
# annen mangler helt) uten at feltene over fanger det opp (de sjekker
# bare at HVER rad HAR en verdi, ikke at SETTET av 18 verdier faktisk
# er 1..18). Ubemerket kom dette forbi valideringen og traff i stedet
# `hole`-tabellens UNIQUE-constraint på (course_id, stroke_index) ved
# selve INSERT-en -- fanget opp av den generiske
# translate_db_errors()-oversetteren og vist til brukeren som
# "allerede importert" (samme kode som et EKTE duplikat-forsøk), en
# totalt misvisende feilmelding som skjulte den ekte årsaken. Sjekkes
# derfor eksplisitt her, FØR noe skrives, med sin egen ærlige
# feilmelding -- samme "feil tydelig FØR skriving"-prinsipp som
# sjekken over (ADR-019 Beslutning C).
hole_numbers = [h["hole_number"] for h in holes]
hcp_indexes = [h["hcp_index"] for h in holes]
if sorted(hole_numbers) != list(range(1, 19)) or sorted(hcp_indexes) != list(range(1, 19)):
raise app_error(
400,
"EXTERNAL_DATA_INVALID",
"Banedataen fra teeoff er ugyldig (hull-nummer eller HCP-indeks er duplisert eller "
"mangler for ett eller flere hull) -- dette må rettes hos teeoff før banen kan "
"importeres.",
)
# ADR-019 Beslutning D: kun full_18-rating importeres, teeoff har ingen
# egen front9/back9-rating. ADR-029: kjønn hører til RATINGEN, ikke selve
# utslaget -- ett teeoff-utslag blir HER importert som ÉN fysisk
@ -852,24 +828,6 @@ async def import_international_course(
raise app_error(
400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI."
)
if any(h["par_men"] is None or h["index_men"] is None for h in holes):
raise app_error(
400, "EXTERNAL_DATA_INCOMPLETE", "Banen mangler par eller HCP-index på ett eller flere hull hos GolfAPI."
)
# Samme sjekk, samme begrunnelse som import_official_course (teeoff)
# -- se kommentaren der. En kilde kan ha gyldig-utseende, men
# dupliserte/manglende index-verdier som ellers først feiler stille
# som en misvisende "allerede importert" ved selve INSERT-en.
hole_numbers = [h["hole_number"] for h in holes]
hcp_indexes = [h["index_men"] for h in holes]
if sorted(hole_numbers) != list(range(1, 19)) or sorted(hcp_indexes) != list(range(1, 19)):
raise app_error(
400,
"EXTERNAL_DATA_INVALID",
"Banedataen fra GolfAPI er ugyldig (hull-nummer eller HCP-indeks er duplisert eller "
"mangler for ett eller flere hull) -- dette må rettes hos GolfAPI før banen kan "
"importeres.",
)
par_total = sum(h["par_men"] for h in holes)
course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"]

View file

@ -68,7 +68,6 @@ from handicap_engine import (
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..realtime import broadcast_live_update
from ..hole_history import (
HoleHistoryOut,
hole_history_for_user,
@ -520,12 +519,6 @@ class RoundParticipantOut(BaseModel):
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
stat_level: str
# Flight-scoping i ScoreTab (ADR-099) -- gruppe-id fra migrasjon 084
# (NULL = ikke gruppert ennå) og om raden er den innloggede brukeren
# selv (aldri rå user_id til klienten -- samme is_mine-mønster som
# rounds.py).
tournament_round_group_id: str | None
is_self: bool
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
@ -643,7 +636,6 @@ async def add_round_participant(
round_id: str,
body: RoundParticipantCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with org_connection(organization_id) as conn, translate_db_errors():
await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id)
@ -671,9 +663,7 @@ async def add_round_participant(
"""
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
trp.course_handicap, trp.playing_handicap, tp.stat_level,
trp.tournament_round_group_id::text AS tournament_round_group_id,
COALESCE(p.user_id::text = $2, false) AS is_self
trp.course_handicap, trp.playing_handicap, tp.stat_level
FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
@ -681,7 +671,6 @@ async def add_round_participant(
WHERE trp.id = $1
""",
round_participant_id,
user.user_id,
)
return RoundParticipantOut(**dict(out))
@ -694,16 +683,13 @@ async def list_round_participants(
tournament_id: str,
round_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[RoundParticipantOut]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
trp.course_handicap, trp.playing_handicap, tp.stat_level,
trp.tournament_round_group_id::text AS tournament_round_group_id,
COALESCE(p.user_id::text = $2, false) AS is_self
trp.course_handicap, trp.playing_handicap, tp.stat_level
FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
@ -712,7 +698,6 @@ async def list_round_participants(
ORDER BY p.display_name
""",
round_id,
user.user_id,
)
return [RoundParticipantOut(**dict(r)) for r in rows]
@ -747,7 +732,6 @@ async def update_round_participant_tee(
round_participant_id: str,
body: RoundParticipantTeeUpdate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> RoundParticipantOut:
async with org_connection(organization_id) as conn, translate_db_errors():
existing = await conn.fetchrow(
@ -774,9 +758,7 @@ async def update_round_participant_tee(
"""
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
trp.course_handicap, trp.playing_handicap, tp.stat_level,
trp.tournament_round_group_id::text AS tournament_round_group_id,
COALESCE(p.user_id::text = $2, false) AS is_self
trp.course_handicap, trp.playing_handicap, tp.stat_level
FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
@ -784,7 +766,6 @@ async def update_round_participant_tee(
WHERE trp.id = $1
""",
round_participant_id,
user.user_id,
)
return RoundParticipantOut(**dict(out))
@ -1113,7 +1094,6 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str,
ctx = await conn.fetchrow(
"""
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
tr.tournament_id::text AS tournament_id,
trp.tournament_participant_id::text AS tournament_participant_id,
trp.playing_handicap
FROM tournament_round_participant trp
@ -1167,12 +1147,6 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str,
net_total,
stableford_points,
)
# Sanntid (2026-08-21, samme mønster som Cup-formatets
# recompute_and_cache_match_state, ADR-027) -- gjenbruker SAMME
# WebSocket-kanal (/ws/public/tournaments/{id}/live) den offentlige
# Cup-siden allerede lytter på. Ingen ny WS-infrastruktur; kun et
# "noe endret, hent på nytt"-signal, ikke selve dataene.
await broadcast_live_update(ctx["tournament_id"])
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
@ -1690,11 +1664,6 @@ class RoundCellOut(BaseModel):
round_number: int
label: str
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
# Hull-for-hull-utvidelse i leaderboardet (2026-08-22) -- lar frontend
# hente denne rundens hull direkte via det nye
# individual-leaderboard/{tp}/rounds/{round_id}/holes-endepunktet uten
# en ekstra oppslags-runde.
tournament_round_id: str
class EclecticHoleCellOut(BaseModel):
@ -1785,7 +1754,7 @@ async def _compute_individual_standings(
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
som faktisk er meningsfullt å lese videre."""
tournament = await conn.fetchrow(
"SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method, net_display_style "
"SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method "
"FROM tournament WHERE id = $1",
tournament_id,
)
@ -1842,7 +1811,6 @@ async def _compute_individual_standings(
max_sequence=max_sequence,
tiebreak_winner_method=tournament["tiebreak_winner_method"],
tiebreak_field_method=tournament["tiebreak_field_method"],
net_display_style=tournament["net_display_style"],
)
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
@ -1910,70 +1878,6 @@ async def individual_leaderboard(
return entries
@router.get(
# Eget stiprefiks (2026-08-22) -- IKKE
# .../rounds/{round_id}/participants/{x}/holes (samme stiform som den
# eksisterende round_participant_id-nøkkede list_round_participant_holes
# over, FastAPI kan ikke skille dem). Autentisert speilbilde av
# registration.py sitt offentlige, tournament_participant_id-nøkkede
# endepunkt -- bygget for leaderboardets hull-for-hull-utvidelse
# (ADR-101), som kun kjenner tournament_participant_id (leaderboard-
# radens egen nøkkel), ikke det interne tournament_round_participant_id.
"/orgs/{organization_id}/tournaments/{tournament_id}"
"/individual-leaderboard/{tournament_participant_id}/rounds/{round_id}/holes",
response_model=list[RoundHoleOut],
)
async def leaderboard_participant_round_holes(
tournament_id: str,
tournament_participant_id: str,
round_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[RoundHoleOut]:
async with org_connection(organization_id) as conn:
round_row = await conn.fetchrow(
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1 AND tournament_id = $2",
round_id,
tournament_id,
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
participant_row = await conn.fetchrow(
"SELECT id::text AS id, playing_handicap FROM tournament_round_participant "
"WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
round_id,
tournament_participant_id,
)
if participant_row is None:
raise app_error(404, "NOT_FOUND", "Deltakeren har ikke spilt denne runden.")
holes = await conn.fetch(
"""
SELECT h.hole_number, h.par, h.stroke_index, trh.gross_strokes,
trh.putts, trh.club_off_tee, trh.tee_shot_result, trh.approach_result,
trh.chip_count, trh.bunker_shot_count, trh.penalty_strokes,
trh.first_putt_distance_bucket, trh.anyway_strokes,
COALESCE(trh.version, 1) AS version
FROM hole h
LEFT JOIN tournament_round_hole trh
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
WHERE h.course_id = $2
ORDER BY h.hole_number
""",
participant_row["id"],
round_row["course_id"],
)
allocation = (
allocate_over_played_holes(
participant_row["playing_handicap"], [h["stroke_index"] for h in holes], [h["hole_number"] for h in holes]
)
if participant_row["playing_handicap"] is not None
else [None] * len(holes)
)
return [
RoundHoleOut(strokes_received=a, **dict(h))
for h, a in zip(holes, allocation)
]
class CutResult(BaseModel):
cut_after_round: int
cut_size: int
@ -2183,31 +2087,6 @@ async def _lowest_hcp_sort_keys(conn, participant_ids: list[str]) -> dict[str, t
return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows}
async def _lowest_playing_hcp_sort_keys(conn, tournament_id: str, participant_ids: list[str]) -> dict[str, tuple]:
"""Tiebreak 'lowest_playing_hcp' (migrasjon 092) -- laveste
SPILLEHANDICAP (tee-spesifikt tall, IKKE WHS-indeksen `lowest_hcp`
bruker) vinner. Leser turneringens ABSOLUTT SISTE runde (samme
presedens som `_countback_sort_keys` -- en reell uavgjort forutsetter
normalt likt antall runder spilt). Manglende verdi (runde ikke spilt
av denne deltakeren, eller handicap ikke beregnet ennå) sorteres
SIST, samme mønster som `_lowest_hcp_sort_keys`."""
last_round = await conn.fetchrow(
"SELECT id FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence DESC LIMIT 1",
tournament_id,
)
if last_round is None:
return {pid: (float("inf"),) for pid in participant_ids}
rows = await conn.fetch(
"SELECT tournament_participant_id::text AS id, playing_handicap::float AS hcp "
"FROM tournament_round_participant "
"WHERE tournament_round_id = $1 AND tournament_participant_id = ANY($2::uuid[])",
last_round["id"],
participant_ids,
)
hcp_by_id = {r["id"]: r["hcp"] for r in rows}
return {pid: (hcp_by_id.get(pid) if hcp_by_id.get(pid) is not None else float("inf"),) for pid in participant_ids}
async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]:
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens
@ -2318,7 +2197,6 @@ async def _attach_stroke_play_columns(
max_sequence: int | None = None,
tiebreak_winner_method: str = "none",
tiebreak_field_method: str = "none",
net_display_style: str = "standard",
) -> None:
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
@ -2339,21 +2217,7 @@ async def _attach_stroke_play_columns(
aldri max_sequence, tiebreak gjelder der -- inkludert midt i
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
beste vi vet akkurat "-filosofi som resten av leaderboardet).
`net_display_style` (migrasjon 092, 2026-08-23) -- 'hunt' er IKKE en
ny scoring-motor, kun en annen presentasjon av samme underliggende
data for `stroke_net`-turneringer ("Jakter Jesper", se ADR): i
stedet for å "tjene inn" handicap-slag gradvis per slagindeks
(dagens `net_total`, partial allocation via
`allocate_over_played_holes`), trekkes HELE spillehandicapet fra med
én gang og brutto-til-par-differansen vises løpende -- dramatisk
forskjellig tall MIDT i en runde, men konvergerer til NØYAKTIG samme
sluttall som standard-visningen ved 18 fullførte hull (siden summen
av per-hull-tildelte slag over alle 18 hull uansett er lik hele
spillehandicapet). Kun virksom når `method == "stroke_net"` --
stableford/gross har ingen netto-til-par-visning å endre .
"""
hunt_active = net_display_style == "hunt" and method == "stroke_net"
rounds_meta = await conn.fetch(
"""
SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole
@ -2376,15 +2240,12 @@ async def _attach_stroke_play_columns(
# er ferdigspilt).
round_rows = await conn.fetch(
"""
SELECT tr.sequence, tr.id::text AS tournament_round_id,
SELECT tr.sequence,
trs.tournament_participant_id::text AS tournament_participant_id,
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
trp.playing_handicap,
COALESCE(pp.par_played, 0)::int AS par_played
FROM tournament_round tr
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
LEFT JOIN tournament_round_participant trp
ON trp.tournament_round_id = tr.id AND trp.tournament_participant_id = trs.tournament_participant_id
LEFT JOIN LATERAL (
SELECT SUM(h.par) AS par_played
FROM tournament_round_participant trp
@ -2417,18 +2278,6 @@ async def _attach_stroke_play_columns(
"net_total" if method == "stroke_net" else "gross_total"
)
def stroke_val(row) -> int | None:
"""Netto/brutto-tallet for én runde-rad -- `hunt_active` bytter
ut den gradvise `net_total` med "trekk fra HELE spillehandicapet
med én gang" (se `net_display_style`-docstringen over). `None`
hvis spillehandicap ikke er beregnet ennå for raden (samme
nøytrale "ingen data"-håndtering som `value_col` allerede har)."""
if not hunt_active:
return row[value_col]
if row["playing_handicap"] is None or row["gross_total"] is None:
return None
return row["gross_total"] - row["playing_handicap"]
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
@ -2443,7 +2292,6 @@ async def _attach_stroke_play_columns(
rounds_out: list[RoundCellOut] = []
total_par_played = 0
total_holes_played = 0
total_stroke_val = 0
for seq, row in sorted(pmap.items()):
if row["holes_played"] == 0:
continue
@ -2453,24 +2301,17 @@ async def _attach_stroke_play_columns(
points = row["stableford_points"] or 0
expected = 2 * row["holes_played"]
rounds_out.append(
RoundCellOut(
round_number=seq,
label=f"{points} p",
tone=_to_par_tone(points - expected),
tournament_round_id=row["tournament_round_id"],
)
RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected))
)
else:
val = stroke_val(row)
val = row[value_col]
if val is None:
continue
total_stroke_val += val
rounds_out.append(
RoundCellOut(
round_number=seq,
label=str(val),
tone=_to_par_tone(val - row["par_played"]),
tournament_round_id=row["tournament_round_id"],
)
)
e.rounds = rounds_out
@ -2483,7 +2324,7 @@ async def _attach_stroke_play_columns(
if method == "stableford":
e.today_label = f"{today_row['stableford_points'] or 0} p"
else:
val = stroke_val(today_row)
val = today_row[value_col]
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
else:
e.thru_label = "-"
@ -2507,12 +2348,6 @@ async def _attach_stroke_play_columns(
# runde spilt ennå (total_holes_played == 0) -> total_label forblir
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
# jevn/poengløs score i stedet for "ikke startet").
#
# `hunt_active`: TOTAL kan IKKE gjenbruke `e.net_total` direkte --
# `playing_handicap` kan variere per runde (ulik tee), så
# hunt-summen må bygges runde for runde (`total_stroke_val`,
# akkumulert i løkken over) i stedet for kalleren sin ferdig-
# summerte `net_total`-kolonne.
started_any = total_holes_played > 0
if method == "stableford":
e.total_label = f"{e.stableford_total or 0} p" if started_any else None
@ -2521,7 +2356,7 @@ async def _attach_stroke_play_columns(
else -(e.stableford_total - 2 * total_holes_played)
)
else:
total_val = total_stroke_val if hunt_active else (e.net_total if method == "stroke_net" else e.gross_total)
total_val = e.net_total if method == "stroke_net" else e.gross_total
e.total_label = (
_to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None
)
@ -2561,12 +2396,10 @@ async def _attach_stroke_play_columns(
# gruppe faller gjennom til else-grenen under akkurat som 'none':
# delt plassering, ingen automatisk oppløsning. Forskjellen er
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
if tiebreak_method in ("countback", "lowest_hcp", "lowest_playing_hcp"):
if tiebreak_method in ("countback", "lowest_hcp"):
group_ids = [entries[k].tournament_participant_id for k in range(i, j)]
if tiebreak_method == "countback":
sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids)
elif tiebreak_method == "lowest_playing_hcp":
sort_keys = await _lowest_playing_hcp_sort_keys(conn, tournament_id, group_ids)
else:
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)

View file

@ -28,7 +28,6 @@ from ..db import org_connection, plain_connection
from ..errors import app_error, translate_db_errors
from .. import storage
from ..team_authz import user_is_tournament_participant
from .individual_tournaments import LeaderboardEntry, RoundCellOut, _compute_individual_standings
from .matches import MatchOut, fetch_matches
from .scoring import Scorecard, fetch_scorecard
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
@ -129,18 +128,6 @@ class PublicTournamentInfo(BaseModel):
name: str
organization_name: str
status: str
# 2026-08-21: manglet her -- landingssiden/"Følg live"-lenken kunne
# derfor ikke skille individuell fra Cup-format offentlig, og rutet
# ALLTID til Cup-sidens /t/{id}/live (som feilet stille for en
# individuell turnering, se ARCHITECTURE_DECISIONS.md/CHANGELOG.md
# samme dag).
format_type: str
# 2026-08-21: for offentlig visning av HCP på leaderboardet -- bruker:
# "når det er brutto som er hovedkonkurransen trenger vi ikke se hcp".
# HCP er kun meningsfullt der den faktisk brukes til slagfordeling
# (netto/stableford/Københavner/BBB), ikke ren bruttoslagspill/eclectic
# gross. `None` for lagturneringer (scoring_method er individuell-kun).
scoring_method: str | None
visibility: str
description: str | None
start_date: date | None
@ -164,7 +151,7 @@ async def get_public_tournament(
row = await conn.fetchrow(
"""
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
t.format_type::text, t.scoring_method, t.visibility, t.description, t.hero_image_key, t.join_code,
t.visibility, t.description, t.hero_image_key, t.join_code,
t.start_date, t.end_date, t.registration_deadline,
t.registration_capacity,
(SELECT count(*)::int FROM tournament_registration tr
@ -195,8 +182,6 @@ async def get_public_tournament(
name=row["name"],
organization_name=row["organization_name"],
status=row["status"],
format_type=row["format_type"],
scoring_method=row["scoring_method"],
visibility=row["visibility"],
description=row["description"],
start_date=row["start_date"],
@ -236,242 +221,6 @@ async def get_public_sessions(
return await _fetch_sessions(conn, tournament_id)
# --- Individuelle turneringer, offentlig "Følg live" (2026-08-21) ---------
#
# Cup-formatets tilskuer-rolle over har eksistert siden ADR-026 -- denne
# siden manglet helt for individuelle turneringer (bevisst utsatt,
# FEATURE_BACKLOG.md "Flaggturnering Del B", 2026-08-14: "egen, større
# oppgave hvis/når etterspurt"). Samme synlighets-/kode-mønster som resten
# av filen, ingen ny mekanisme.
class PublicTournamentRound(BaseModel):
id: str
sequence: int
name: str
course_name: str
scheduled_at: datetime | None
# Utledet (ingen status-kolonne finnes på tournament_round selv) --
# samme "har noen påbegynt et hull ennå"-logikk som
# _attach_stroke_play_columns sin today_sequence-utregning bruker
# internt, generalisert til å gjelde HVER runde (ikke bare den seneste)
# og lagt til en "alle ferdig"-gren for "completed".
status: str # "not_started" | "in_progress" | "completed"
_HOLES_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
@router.get("/{tournament_id}/rounds", response_model=list[PublicTournamentRound])
async def get_public_rounds(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[PublicTournamentRound]:
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)
rounds = await conn.fetch(
"""
SELECT tr.id::text, tr.sequence, tr.name, tr.hole_config::text,
c.name AS course_name, tr.scheduled_at
FROM tournament_round tr
JOIN course c ON c.id = tr.course_id
WHERE tr.tournament_id = $1
ORDER BY tr.sequence
""",
tournament_id,
)
progress = await conn.fetch(
"""
SELECT trs.tournament_round_id::text,
count(*) FILTER (WHERE trs.holes_played > 0) AS started,
count(*) FILTER (WHERE trs.holes_played >= h.hole_count) AS completed,
count(*) AS total
FROM tournament_round_score trs
JOIN tournament_round tr ON tr.id = trs.tournament_round_id
JOIN LATERAL (SELECT count(*) AS hole_count FROM hole WHERE hole.course_id = tr.course_id) h ON true
WHERE tr.tournament_id = $1
GROUP BY trs.tournament_round_id
""",
tournament_id,
)
progress_by_round = {r["tournament_round_id"]: r for r in progress}
def status_for(round_id: str) -> str:
p = progress_by_round.get(round_id)
if p is None or p["started"] == 0:
return "not_started"
if p["completed"] == p["total"] and p["total"] > 0:
return "completed"
return "in_progress"
return [
PublicTournamentRound(
id=r["id"],
sequence=r["sequence"],
name=r["name"] or f"Runde {r['sequence']}",
course_name=r["course_name"],
scheduled_at=r["scheduled_at"],
status=status_for(r["id"]),
)
for r in rounds
]
class PublicLeaderboardEntry(BaseModel):
tournament_participant_id: str
player_name: str
rounds_played: int
gross_total: int | None
net_total: int | None
stableford_total: int | None
copenhagen_total: int | None
bbb_total: int | None
class_id: str | None
class_name: str | None
position: str | None
is_leader: bool
today_label: str | None
thru_label: str | None
total_label: str | None
rank: int | None
cut: bool
status: str
eclectic_total: int | None
handicap_index: float | None
rounds: list[RoundCellOut]
@router.get(
"/{tournament_id}/individual-leaderboard", response_model=list[PublicLeaderboardEntry]
)
async def get_public_individual_leaderboard(
tournament_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[PublicLeaderboardEntry]:
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)
_, entries = await _compute_individual_standings(conn, tournament_id)
# HCP-indeks hentes separat (bevisst valgt synlig her, jf. samme
# presedens som frittstående runders offentlige leaderboard) --
# LeaderboardEntry selv har den ikke, kun tournament_participant.
hcp_rows = await conn.fetch(
"SELECT id::text, handicap_index_snapshot FROM tournament_participant WHERE tournament_id = $1",
tournament_id,
)
hcp_by_participant = {r["id"]: r["handicap_index_snapshot"] for r in hcp_rows}
return [
PublicLeaderboardEntry(
tournament_participant_id=e.tournament_participant_id,
player_name=e.player_name,
rounds_played=e.rounds_played,
gross_total=e.gross_total,
net_total=e.net_total,
stableford_total=e.stableford_total,
copenhagen_total=e.copenhagen_total,
bbb_total=e.bbb_total,
class_id=e.class_id,
class_name=e.class_name,
position=e.position,
is_leader=e.is_leader,
today_label=e.today_label,
thru_label=e.thru_label,
total_label=e.total_label,
rank=e.rank,
cut=e.cut,
status=e.status,
eclectic_total=e.eclectic_total,
handicap_index=float(hcp_by_participant[e.tournament_participant_id])
if hcp_by_participant.get(e.tournament_participant_id) is not None
else None,
rounds=e.rounds,
)
for e in entries
]
class PublicRoundHole(BaseModel):
hole_number: int
par: int
gross_strokes: int | None
@router.get(
"/{tournament_id}/rounds/{round_id}/participants/{tournament_participant_id}/holes",
response_model=list[PublicRoundHole],
)
async def get_public_round_participant_holes(
tournament_id: str,
round_id: str,
tournament_participant_id: str,
code: str | None = None,
user: CurrentUser | None = Depends(get_current_user_optional),
) -> list[PublicRoundHole]:
"""Hull-for-hull for ÉN spiller i ÉN runde -- brukt av den offentlige
leaderboard-radens utvidbare rutenett. Nøkkelen er bevisst
tournament_participant_id (samme id som i PublicLeaderboardEntry over),
IKKE tournament_round_participant_id (den er runde-spesifikk og aldri
eksponert offentlig) -- slår opp riktig runde-deltaker-rad internt."""
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)
round_row = await conn.fetchrow(
"SELECT course_id::text AS course_id FROM tournament_round WHERE id = $1 AND tournament_id = $2",
round_id,
tournament_id,
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke i denne turneringen.")
round_participant_id = await conn.fetchval(
"SELECT id FROM tournament_round_participant WHERE tournament_round_id = $1 AND tournament_participant_id = $2",
round_id,
tournament_participant_id,
)
if round_participant_id is None:
return []
holes = await conn.fetch(
"""
SELECT h.hole_number, h.par, trh.gross_strokes
FROM hole h
LEFT JOIN tournament_round_hole trh
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
WHERE h.course_id = $2
ORDER BY h.hole_number
""",
round_participant_id,
round_row["course_id"],
)
return [PublicRoundHole(**dict(h)) for h in holes]
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
#
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny

View file

@ -49,7 +49,7 @@ from pydantic import BaseModel, EmailStr, Field
from .. import golfapi_cache, golfapi_client, 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 org_connection, plain_connection
from ..db import plain_connection
from ..email import RoundSummaryHole, send_round_summary_email
from ..errors import app_error, translate_db_errors
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
@ -62,7 +62,6 @@ from ..realtime import broadcast_round_update, live_sockets_for_round
from ..target_points import TargetPoint, get_target_points
from .auth import _hash_secret
from .notifications import create_notification
from .scoring import played_hole_numbers
from handicap_engine import (
HoleResult,
PerPlayerPercentage,
@ -1477,10 +1476,6 @@ class RoundOut(BaseModel):
allowance_override: dict | None
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: bool
# "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
# KUN meningsfullt for play_format='stroke'. Se my_net_score_to_par
# under for hvordan 'hunt' faktisk endrer beregningen.
net_display_style: str
async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
@ -1490,7 +1485,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id,
bbb_sweep_bonus_enabled, shamble_best_n, scramble_solo_individual_pct,
allowance_override::text AS allowance_override, flag_map_visible, net_display_style
allowance_override::text AS allowance_override, flag_map_visible
FROM round WHERE id = $1
""",
round_id,
@ -1561,18 +1556,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
total_strokes_received = sum(
strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None
)
# "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun
# gyldig for play_format='stroke' (håndhevet ved lagring i
# RoundUpdate/update_round, så en stableford-runde ser aldri
# net_display_style='hunt' her). Trekker fra HELE spille-
# handicapet med én gang i stedet for kun de slagene som er
# "tjent inn" for de spilte hullene -- konvergerer til
# NØYAKTIG samme tall som standard-visningen ved 18 fullførte
# hull (samme bevis som org-individuell, se ADR-106).
if round_row["net_display_style"] == "hunt":
my_net_score_to_par = (my_score_to_par or 0) - target_row["course_handicap_snapshot"]
else:
my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received
my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received
# Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN.
# Gjenbruker _build_format_result uendret (samme sannhetskilde som selve
@ -1627,7 +1611,6 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
flight_group_id=round_row["flight_group_id"],
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
flag_map_visible=round_row["flag_map_visible"],
net_display_style=round_row["net_display_style"],
)
@ -1887,12 +1870,6 @@ class RoundStatsSeriesPoint(BaseModel):
class RoundStatsSummary(BaseModel):
rounds_completed: int
# Kilde-nedbrytning av `rounds_completed` -- org-turnering-runder
# blandes inn i alle tallene under (ADR: se CHANGELOG), men antallet
# per kilde vises separat, samme prinsipp som `hole_history.py` sin
# `times_personal`/`times_tournament`.
rounds_completed_personal: int
rounds_completed_tournament: int
avg_score_to_par: float | None
avg_putts_per_18: float | None
putts_tracked_rounds: int
@ -2061,7 +2038,6 @@ def _summarize_rounds(
by_round: dict[str, list],
stat_level_by_round: dict[str, str],
played_at_by_round: dict[str, date],
source_by_round: dict[str, str],
) -> RoundStatsSummary:
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
@ -2149,8 +2125,6 @@ def _summarize_rounds(
return RoundStatsSummary(
rounds_completed=len(round_ids),
rounds_completed_personal=sum(1 for rid in round_ids if source_by_round.get(rid) == "personal"),
rounds_completed_tournament=sum(1 for rid in round_ids if source_by_round.get(rid) == "tournament"),
avg_score_to_par=avg(to_par_values),
avg_putts_per_18=avg(putt_totals),
putts_tracked_rounds=len(putt_totals),
@ -2186,83 +2160,6 @@ def _summarize_rounds(
)
_TOURNAMENT_HOLE_COUNT_BY_CONFIG = {"full_18": 18, "front_9": 9, "back_9": 9}
async def _fetch_tournament_stats_rows(user_id: str) -> list[dict]:
"""Speiler `hole_history.py` sin `fetch_tournament_hole_history`
N+1-per-org-mønster (`player_organizations_for_user` ->
`org_connection` per org), men henter ALLE en brukers turnering-hull
tvers av org-er i stedet for ett hullnummer for én bane.
`tournament_round` har ingen `completed_at`-kolonne (bekreftet mot
040_individual_tournaments.sql) -- "fullført runde" avgjøres i
stedet ved rad-telling per `tournament_round_participant_id` mot
`played_hole_numbers(hole_config)`, samme prinsipp som
`individual_tournaments.py` sin `holes_completed`/flag-resultat
bruker andre steder i kodebasen.
"""
async with plain_connection() as conn:
org_rows = await conn.fetch(
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
user_id,
)
rows: list[dict] = []
for org_row in org_rows:
async with org_connection(org_row["organization_id"]) as conn:
trh_rows = await conn.fetch(
"""
SELECT trp.id::text AS trp_id, tr.hole_config::text AS hole_config,
tp.stat_level, tr.scheduled_at,
h.par, trh.gross_strokes AS score, trh.putts,
trh.tee_shot_result, trh.approach_result, trh.chip_count,
trh.bunker_shot_count, trh.penalty_strokes, trh.anyway_strokes
FROM tournament_round_hole trh
JOIN tournament_round_participant trp ON trp.id = trh.tournament_round_participant_id
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
WHERE p.user_id = $1
""",
user_id,
)
by_trp: dict[str, list] = {}
for r in trh_rows:
by_trp.setdefault(r["trp_id"], []).append(r)
for trp_id, holes in by_trp.items():
hole_config = holes[0]["hole_config"]
expected = _TOURNAMENT_HOLE_COUNT_BY_CONFIG.get(hole_config, 18)
if len(holes) < expected:
continue # runden er ikke fullført ennå
scheduled_at = holes[0]["scheduled_at"]
if scheduled_at is None:
continue # kan ikke plasseres kronologisk uten dato
for h in holes:
rows.append(
{
"round_id": f"t:{trp_id}",
"stat_level": h["stat_level"],
"played_at": scheduled_at.date(),
"source": "tournament",
"par": h["par"],
"played": True,
"score": h["score"],
"putts": h["putts"],
"tee_shot_result": h["tee_shot_result"],
"approach_result": h["approach_result"],
"chip_count": h["chip_count"],
"bunker_shot_count": h["bunker_shot_count"],
"penalty_strokes": h["penalty_strokes"],
"anyway_strokes": h["anyway_strokes"],
}
)
return rows
@router.get("/rounds/stats/summary", response_model=RoundStatsWindowSummary)
async def get_rounds_stats_summary(
window: StatsWindow = Query("all"),
@ -2305,40 +2202,22 @@ async def get_rounds_stats_summary(
by_round: dict[str, list] = {}
stat_level_by_round: dict[str, str] = {}
played_at_by_round: dict[str, date] = {}
source_by_round: dict[str, str] = {}
for r in rows:
by_round.setdefault(r["round_id"], []).append(r)
stat_level_by_round[r["round_id"]] = r["stat_level"]
played_at_by_round[r["round_id"]] = r["played_at"]
source_by_round[r["round_id"]] = "personal"
# Org-turnering-runder (ADR: se CHANGELOG) blandes inn i de SAMME
# dict-ene -- `_resolve_stats_window`/`_summarize_rounds`/
# `_build_round_series` forblir uendret, de aksesserer felt via
# `h["felt"]` og bryr seg ikke om raden kom fra `round_hole` eller
# `tournament_round_hole`.
for r in await _fetch_tournament_stats_rows(user.user_id):
rid = r["round_id"]
by_round.setdefault(rid, []).append(r)
stat_level_by_round[rid] = r["stat_level"]
played_at_by_round[rid] = r["played_at"]
source_by_round[rid] = r["source"]
# Nyest først -- må sorteres eksplisitt nå (kunne før stole på at
# SQL-en sin ORDER BY ga riktig innsettingsrekkefølge, men det
# gjelder ikke lenger etter sammenslåingen over).
ordered_rounds = sorted(
((rid, played_at_by_round[rid]) for rid in by_round),
key=lambda pair: pair[1],
reverse=True,
)
# Distinkte runder, nyest først -- rekkefølgen `rows` allerede har
# (ORDER BY r.played_at DESC) bevares siden Python-dicts er innsettings-
# ordnet, ingen ny sortering nødvendig.
ordered_rounds = [(rid, played_at_by_round[rid]) for rid in by_round]
current_ids, previous_ids = _resolve_stats_window(window, ordered_rounds)
return RoundStatsWindowSummary(
window=window,
current=_summarize_rounds(current_ids, by_round, stat_level_by_round, played_at_by_round, source_by_round),
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round, played_at_by_round, source_by_round),
current=_summarize_rounds(current_ids, by_round, stat_level_by_round, played_at_by_round),
previous=_summarize_rounds(previous_ids, by_round, stat_level_by_round, played_at_by_round),
round_series=_build_round_series(current_ids, by_round, stat_level_by_round, played_at_by_round),
)
@ -2771,12 +2650,6 @@ class RoundUpdate(BaseModel):
# (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw);
# None = ikke rør, samme konvensjon som resten av modellen her.
flag_map_visible: bool | None = None
# "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
# eier-only, samme "None = ikke rør"-konvensjon. Kun gyldig for
# play_format='stroke' -- håndhevet i update_round (fail-loudly 400
# for andre formater, samme prinsipp som org-individuell sin
# scoring_method-sjekk).
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
@router.patch("/rounds/{round_id}", response_model=RoundOut)
@ -2809,7 +2682,7 @@ async def update_round(
async with plain_connection() as conn:
await _get_owned_round_or_404(conn, round_id, user.user_id)
round_row = await conn.fetchrow(
"SELECT started_at, completed_at, play_format, net_display_style FROM round WHERE id = $1", round_id
"SELECT started_at, completed_at, play_format FROM round WHERE id = $1", round_id
)
is_completed = round_row["completed_at"] is not None
@ -2834,23 +2707,6 @@ async def update_round(
):
raise app_error(400, "VALIDATION_FAILED", "Fullført-tidspunktet må være etter utslagstidspunktet.")
# "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som
# org-individuell sin scoring_method-sjekk (ARCHITECTURE_DECISIONS.md
# ADR-106): 'hunt' gir ingen mening utenfor play_format='stroke'.
# Sjekker den EFFEKTIVE tilstanden ETTER denne PATCH-en (dekker BEGGE
# retninger -- satt til 'hunt' på feil format, ELLER play_format
# byttet bort fra 'stroke' mens 'hunt' henger igjen fra før).
if body.net_display_style is not None or body.play_format is not None:
effective_play_format = body.play_format if body.play_format is not None else round_row["play_format"]
effective_style = (
body.net_display_style if body.net_display_style is not None else round_row["net_display_style"]
)
if effective_style == "hunt" and effective_play_format != "stroke":
raise app_error(
400, "VALIDATION_FAILED",
"Jakter Jesper-visningen krever play_format='stroke'.",
)
resolved: _ResolvedCourse | None = None
participants = None
if body.course_source is not None:
@ -2917,10 +2773,6 @@ async def update_round(
await conn.execute(
"UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible
)
if body.net_display_style is not None:
await conn.execute(
"UPDATE round SET net_display_style = $2 WHERE id = $1", round_id, body.net_display_style
)
if body.flight_group_id is not None:
await conn.execute("UPDATE round SET flight_group_id = $2 WHERE id = $1", round_id, body.flight_group_id)
if body.visible_categories is not None:
@ -5288,30 +5140,19 @@ class LeaderboardEntryOut(BaseModel):
# allerede ble hentet for å beregne total_score/net_score_to_par over,
# bare også eksponert rått slik at leaderboardet kan vise dem per hull.
holes: list[LeaderboardHoleOut]
# "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- rå course
# handicap eksponert slik at frontend (som allerede regner netto-til-
# par selv, klientsidig, fra strokes_received per hull) kan regne ut
# hunt-visningens tall selv (gross-til-par minus HELE handicapet) uten
# en egen backend-utregningsvei. Samme None-betingelse som net_score_
# to_par over.
course_handicap: int | None
class LeaderboardOut(BaseModel):
holes_planned: int
completed: bool
entries: list[LeaderboardEntryOut]
# "Jakter Jesper" (migrasjon 093) -- hvilken visningsmodus runden er
# satt til. Frontend velger nettoToPar vs. huntNettoToPar basert på
# denne, se round-leaderboard.tsx.
net_display_style: str
async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
/public/rounds-varianten (ADR-036 fase 2)."""
round_row = await conn.fetchrow(
"SELECT holes_planned, completed_at, net_display_style FROM round WHERE id = $1", round_id
"SELECT holes_planned, completed_at FROM round WHERE id = $1", round_id
)
participant_rows = await conn.fetch(
"""
@ -5351,20 +5192,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
total_points = None
if total_score is not None and strokes_received_by_hole:
strokes_received_total = sum(strokes_received_by_hole[h["hole_number"]] for h in played_holes)
# "Jakter Jesper" (migrasjon 093) -- kun netto-til-par-tallet
# bytter til hunt-semantikk (helt handicap trukket fra med én
# gang). total_points (Stableford) er UBERØRT uansett -- poeng
# tildeles per hull ut fra DET hullets netto, ingen "hunt"-
# ekvivalent gir mening for en poengsum (ikke en til-par-
# størrelse). `RoundLeaderboardMini` (valueFor/rankEntries)
# leser dette feltet direkte -- får hunt-tallet "gratis" her,
# samme "gjenbruk feltet, ikke egen visningsvei"-mønster som
# org-individuell sine offentlige/utskrift-sider (ADR-106).
net_score_to_par = (
score_to_par - p["course_handicap_snapshot"]
if round_row["net_display_style"] == "hunt"
else score_to_par - strokes_received_total
)
net_score_to_par = score_to_par - strokes_received_total
# Samme stableford-formel som round-scorecard.tsx sin
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
# gulvet på 0.
@ -5387,7 +5215,6 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
score_to_par=score_to_par,
net_score_to_par=net_score_to_par,
total_points=total_points,
course_handicap=p["course_handicap_snapshot"],
holes=[
LeaderboardHoleOut(
hole_number=h["hole_number"],
@ -5409,7 +5236,6 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
holes_planned=round_row["holes_planned"],
completed=round_row["completed_at"] is not None,
entries=entries,
net_display_style=round_row["net_display_style"],
)

View file

@ -1,473 +0,0 @@
"""
Rundespesifikk kommentartråd for org-turneringer (ADR-102) -- ekte
parallell til `round_messages.py` sin `RoundMessages` for frittstående
runder, IKKE en utvidelse av den eksisterende, hele-turnering-brede
"Banter Board" (`messaging.py`, scope='tournament_feed', ADR-025) --
den røres ikke av denne modulen.
Bevisste forskjeller fra round_messages.py sitt mønster:
- `tournament_round_message` (migrasjon 090) har `organization_id` +
full RLS -- i motsetning til frittstående runders bevisst RLS-frie
`plain_connection()`-mønster (ADR-033 Beslutning A gjelder ikke her,
turneringer ER org-scopet, CLAUDE.md-invarianten). All lesing/
skriving går via `org_connection(organization_id)`.
- Skriverett = leserett = org-medlemskap (`get_authorized_org`), IKKE
frittstående sidens `_can_view_round` (eier/medspiller/public/friends-
kategori har ingen reell motpart i org-turnering-modellen). Samme
presedens som individual_tournaments.py sin egen modul-docstring
fastslår for runde-/deltaker-OPPSETT for øvrig ("vanlig org-
medlemsnivå") -- kommentering er samme kategori handling, ikke den
strengere self-only-regelen `update_hole` har for selve SCORING-en.
- Moderering: forfatter ELLER org-admin (`is_org_admin`), speiler
Banter Board sin egen moderasjonsregel (ADR-025), ikke frittstående
sidens "forfatter eller RUNDEEIER" (turneringsrunder har ingen eier i
den forstand).
- Én tråd PER TURNERINGSRUNDE (hele feltet tvers av flighter deler
tråd), bevisst valgt av bruker -- IKKE per flight.
- Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging, ingen
WebSocket-kringkasting (den autentiserte turneringsvisningen har ingen
sanntids-tilkobling i det hele tatt ennå -- eget, uprioritert gap).
"""
from fastapi import APIRouter, Depends, File, Form, UploadFile
from pydantic import BaseModel, Field
from .. import storage
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..team_authz import is_org_admin
router = APIRouter(tags=["tournament-round-messages"])
# Samme kuraterte reaksjonssett som round_messages.py/messaging.py (ADR-046).
ALLOWED_REACTION_EMOJIS = {"👍", "❤️", "😂", "😮", "😢", "🙏"}
_MESSAGE_COLUMNS = """
id::text, tournament_round_id::text, author_user_id::text, author_display_name,
body, image_key, created_at
"""
_COMMENT_COLUMNS = """
id::text, tournament_round_message_id::text, parent_comment_id::text,
author_user_id::text, author_display_name, body, created_at
"""
class ReactionIn(BaseModel):
emoji: str
class ReactionSummary(BaseModel):
emoji: str
count: int
reacted_by_me: bool
reactors: list[str] = []
class RoundMessageOut(BaseModel):
id: str
tournament_round_id: str
author_user_id: str
author_display_name: str
body: str | None
image_url: str | None
created_at: str
reactions: list[ReactionSummary] = []
comment_count: int = 0
class CommentIn(BaseModel):
body: str = Field(min_length=1, max_length=2000)
parent_comment_id: str | None = None
class CommentOut(BaseModel):
id: str
tournament_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_message(
row, reactions: list[ReactionSummary] | None = None, comment_count: int = 0
) -> RoundMessageOut:
return RoundMessageOut(
id=row["id"],
tournament_round_id=row["tournament_round_id"],
author_user_id=row["author_user_id"],
author_display_name=row["author_display_name"],
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,
)
def _row_to_comment(row) -> CommentOut:
return CommentOut(
id=row["id"],
tournament_round_message_id=row["tournament_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(),
)
async def _resolve_author_name(conn, user_id: str) -> str:
"""Fullt navn (roster-/forfatter-kontekst, ikke direkte adressering --
CLAUDE.md navneformat-regel), frosset ved skrivetidspunkt. Egen, lokal
kopi av samme oppslag round_messages.py/messaging.py hver har sin egen
versjon av -- denne modulen har ikke tidligere importert fra noen av
dem, og hører uansett ikke hjemme i rounds.py (frittstående-side)."""
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 _get_tournament_round_or_404(conn, tournament_id: str, round_id: str) -> None:
row = await conn.fetchval(
"SELECT tournament_id::text FROM tournament_round WHERE id = $1", round_id
)
if row is None or row != tournament_id:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
async def _get_message_or_404(conn, round_id: str, message_id: str) -> None:
exists = await conn.fetchval(
"SELECT id FROM tournament_round_message WHERE id = $1 AND tournament_round_id = $2",
message_id, round_id,
)
if exists is None:
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
async def _message_reactions(
conn, message_ids: list[str], viewer_user_id: str
) -> dict[str, list[ReactionSummary]]:
if not message_ids:
return {}
rows = await conn.fetch(
"""
SELECT trmr.tournament_round_message_id::text AS message_id, trmr.emoji, count(*) AS count,
COALESCE(bool_or(trmr.user_id = $2), false) AS reacted_by_me,
array_agg(
COALESCE(NULLIF(btrim(au.first_name || ' ' || au.last_name), ''), au.display_name)
ORDER BY trmr.created_at
) AS reactors
FROM tournament_round_message_reaction trmr
JOIN app_user au ON au.id = trmr.user_id
WHERE trmr.tournament_round_message_id = ANY($1::uuid[])
GROUP BY trmr.tournament_round_message_id, trmr.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 tournament_round_message_id::text AS message_id, count(*) AS count
FROM tournament_round_message_comment
WHERE tournament_round_message_id = ANY($1::uuid[])
GROUP BY tournament_round_message_id
""",
message_ids,
)
return {r["message_id"]: r["count"] for r in rows}
# ---------------------------------------------------------------------------
# Runde-nivå kommentarer/bilder
# ---------------------------------------------------------------------------
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages",
response_model=list[RoundMessageOut],
)
async def list_tournament_round_messages(
tournament_id: str,
round_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[RoundMessageOut]:
async with org_connection(organization_id) as conn:
await _get_tournament_round_or_404(conn, tournament_id, round_id)
# Nyeste øverst -- samme "oppdateringsstrøm, ikke kronologisk
# samtale"-mønster som round_messages.py (ADR-044).
rows = await conn.fetch(
f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message "
"WHERE tournament_round_id = $1 ORDER BY created_at DESC",
round_id,
)
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(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages",
response_model=RoundMessageOut,
status_code=201,
)
async def post_tournament_round_message(
tournament_id: str,
round_id: str,
body: str | None = Form(default=None, max_length=2000),
image: UploadFile | None = File(default=None),
client_message_id: str | None = Form(default=None),
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> RoundMessageOut:
if not body and image is None:
raise app_error(400, "VALIDATION_FAILED", "Meldingen må ha tekst eller bilde.")
raw = await storage.read_optional_image(image)
async with org_connection(organization_id) as conn, translate_db_errors():
await _get_tournament_round_or_404(conn, tournament_id, round_id)
if client_message_id is not None:
existing = await conn.fetchrow(
f"SELECT {_MESSAGE_COLUMNS} FROM tournament_round_message "
"WHERE tournament_round_id = $1 AND client_message_id = $2",
round_id, client_message_id,
)
if existing is not None:
reactions = await _message_reactions(conn, [existing["id"]], user.user_id)
comment_counts = await _message_comment_counts(conn, [existing["id"]])
return _row_to_message(
existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0)
)
image_key = None
if raw is not None:
try:
image_key = await storage.upload_image("tournament_round_messages", round_id, raw)
except storage.InvalidImageError:
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
display_name = await _resolve_author_name(conn, user.user_id)
row = await conn.fetchrow(
f"""
INSERT INTO tournament_round_message
(organization_id, tournament_round_id, author_user_id, author_display_name,
body, image_key, client_message_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING {_MESSAGE_COLUMNS}
""",
organization_id, round_id, user.user_id, display_name, body, image_key, client_message_id,
)
return _row_to_message(row)
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}",
status_code=204,
)
async def delete_tournament_round_message(
tournament_id: str,
round_id: str,
message_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Forfatteren selv, ELLER org-eier/admin -- samme modell som Banter
Board sin `delete_feed_message` (ADR-025), ikke frittstående sidens
"runde-eier" (turneringsrunder har ingen eier i den forstand)."""
async with org_connection(organization_id) as conn:
await _get_tournament_round_or_404(conn, tournament_id, round_id)
author_id = await conn.fetchval(
"SELECT author_user_id::text FROM tournament_round_message "
"WHERE id = $1 AND tournament_round_id = $2",
message_id, round_id,
)
if author_id is None:
raise app_error(404, "NOT_FOUND", "Innlegget finnes ikke.")
if user.user_id != author_id and not await is_org_admin(conn, organization_id, user.user_id):
raise app_error(
403, "NOT_MESSAGE_AUTHOR", "Du kan kun slette dine egne innlegg (eller som organisasjonsadministrator)."
)
await conn.execute("DELETE FROM tournament_round_message WHERE id = $1", message_id)
# ---------------------------------------------------------------------------
# Reaksjoner
# ---------------------------------------------------------------------------
@router.put(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction",
response_model=list[ReactionSummary],
)
async def set_tournament_round_message_reaction(
tournament_id: str,
round_id: str,
message_id: str,
payload: ReactionIn,
organization_id: str = Depends(get_authorized_org),
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:
await _get_tournament_round_or_404(conn, tournament_id, round_id)
await _get_message_or_404(conn, round_id, message_id)
await conn.execute(
"""
INSERT INTO tournament_round_message_reaction (organization_id, tournament_round_message_id, user_id, emoji)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tournament_round_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}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/reaction",
response_model=list[ReactionSummary],
)
async def remove_tournament_round_message_reaction(
tournament_id: str,
round_id: str,
message_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> list[ReactionSummary]:
async with org_connection(organization_id) as conn:
await _get_tournament_round_or_404(conn, tournament_id, round_id)
await _get_message_or_404(conn, round_id, message_id)
await conn.execute(
"DELETE FROM tournament_round_message_reaction WHERE tournament_round_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, [])
# ---------------------------------------------------------------------------
# Trådede kommentarer -- flat lagring (parent_comment_id), frontend bygger
# tre-strukturen. Kronologisk (eldst først), ulikt meldingslisten selv.
# ---------------------------------------------------------------------------
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments",
response_model=list[CommentOut],
)
async def list_tournament_round_message_comments(
tournament_id: str,
round_id: str,
message_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[CommentOut]:
async with org_connection(organization_id) as conn:
await _get_tournament_round_or_404(conn, tournament_id, round_id)
await _get_message_or_404(conn, round_id, message_id)
rows = await conn.fetch(
f"SELECT {_COMMENT_COLUMNS} FROM tournament_round_message_comment "
"WHERE tournament_round_message_id = $1 ORDER BY created_at ASC",
message_id,
)
return [_row_to_comment(r) for r in rows]
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments",
response_model=CommentOut,
status_code=201,
)
async def post_tournament_round_message_comment(
tournament_id: str,
round_id: str,
message_id: str,
payload: CommentIn,
organization_id: str = Depends(get_authorized_org),
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():
await _get_tournament_round_or_404(conn, tournament_id, round_id)
await _get_message_or_404(conn, round_id, message_id)
if payload.parent_comment_id is not None:
parent_exists = await conn.fetchval(
"SELECT id FROM tournament_round_message_comment "
"WHERE id = $1 AND tournament_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_author_name(conn, user.user_id)
row = await conn.fetchrow(
f"""
INSERT INTO tournament_round_message_comment
(organization_id, tournament_round_message_id, parent_comment_id, author_user_id, author_display_name, body)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING {_COMMENT_COLUMNS}
""",
organization_id, message_id, payload.parent_comment_id, user.user_id, display_name, body,
)
return _row_to_comment(row)
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/messages/{message_id}/comments/{comment_id}",
status_code=204,
)
async def delete_tournament_round_message_comment(
tournament_id: str,
round_id: str,
message_id: str,
comment_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Forfatteren selv, ELLER org-eier/admin -- samme modell som selve
innlegget. Kaskade-sletter eventuelle svar under (FK ON DELETE CASCADE)."""
async with org_connection(organization_id) as conn:
await _get_tournament_round_or_404(conn, tournament_id, round_id)
await _get_message_or_404(conn, round_id, message_id)
author_id = await conn.fetchval(
"SELECT author_user_id::text FROM tournament_round_message_comment "
"WHERE id = $1 AND tournament_round_message_id = $2",
comment_id, message_id,
)
if author_id is None:
raise app_error(404, "NOT_FOUND", "Kommentaren finnes ikke.")
if user.user_id != author_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 tournament_round_message_comment WHERE id = $1", comment_id)

View file

@ -1,327 +0,0 @@
"""
Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103) --
speiler `rounds.py` sin slag-seksjon (ADR-048) for frittstående runder,
men org-scopet (RLS) og forenklet: turneringsmodellen har ALDRI en
side/lag-XOR (kun individuelt), kun participant-varianten trengs
(ingen `/sides/`-speilet endepunktsett).
Eierskap: `tournament_round_shot` (migrasjon 091) kjenner
`tournament_round_participant_id` + `hole_number` DIREKTE -- IKKE
`tournament_round_hole_id`. `tournament_round_hole` opprettes (ulikt
frittstående `round_hole`) først ved FØRSTE score-innsending
(`update_hole`), en spiller kunne måle et slag FØR hullets score
er registrert -- en FK mot en hull-rad som kanskje ikke finnes ennå
ville vært feil. Samme LEFT JOIN-tolerante oppløsning som
`get_tournament_round_hole_target_points`/`list_round_participant_holes`
allerede bruker.
Autorisasjon, bevisst STRENGERE enn ADR-102s kommentartråd: skriving
(POST/DELETE) krever `user_is_own_tournament_participant` (self ELLER
org-admin) -- samme sensitivitetsklasse som selve SCORE-registreringen
(`update_hole`), ikke oppsett-klassen kommentarer hører til. Lesing
(GET) krever kun org-medlemskap, samme mønster som
`list_round_participant_holes`.
Deling: reuser den rundespesifikke kommentartråden (ADR-102,
`tournament_round_message`) i stedet for Banter Board, speiler
`rounds.py::share_shot` sitt Mapbox Static Images-snippet-mønster.
`_mapbox_static_snippet_url`/`_encode_polyline` er en bevisst LOKAL kopi
(ikke importert fra rounds.py) -- org-turnering-subsystemet importerer
aldri fra det frittstående-runde-subsystemet (samme presedens ADR-102
selv fulgte for `_resolve_author_name`).
"""
import urllib.parse
from typing import Literal
import httpx
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from .. import storage
from ..auth import CurrentUser, get_authorized_org, get_current_user
from ..config import settings
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..team_authz import user_is_own_tournament_participant
from .tournament_round_messages import _resolve_author_name
router = APIRouter(tags=["tournament-round-shots"])
_SHOT_SELECT = """
SELECT trs.id::text AS id, trs.shot_number, trs.club, trs.distance_meters, trs.start_method,
trs.start_lat, trs.start_lng, trs.end_method, trs.end_lat, trs.end_lng,
trs.shared_tournament_round_message_id::text AS shared_tournament_round_message_id,
trs.captured_at, trm.image_key AS shared_image_key
FROM tournament_round_shot trs
LEFT JOIN tournament_round_message trm ON trm.id = trs.shared_tournament_round_message_id
"""
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_method: Literal["gps", "map_tap"] = "gps"
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_method: str
end_lat: float
end_lng: float
shared_tournament_round_message_id: str | None
shared_image_url: str | None
captured_at: str
def _shot_out(row) -> 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_method=row["end_method"],
end_lat=row["end_lat"],
end_lng=row["end_lng"],
shared_tournament_round_message_id=row["shared_tournament_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_participant_hole(
conn, tournament_id: str, round_id: str, round_participant_id: str, hole_number: int
) -> str:
"""LEFT JOIN-tolerant: bekrefter at deltakeren/runden/turneringen
faktisk henger sammen, men krever IKKE at tournament_round_hole
finnes ennå (spilleren kan måle et slag før de fullfører hullets
score). Returnerer tournament_participant_id (for autorisasjon)."""
row = await conn.fetchrow(
"""
SELECT trp.tournament_participant_id::text AS tournament_participant_id, tr.tournament_id::text AS tournament_id
FROM tournament_round_participant trp
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
WHERE trp.id = $1 AND trp.tournament_round_id = $2
""",
round_participant_id, round_id,
)
if row is None or row["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
if hole_number < 1 or hole_number > 18:
raise app_error(400, "VALIDATION_FAILED", "Ugyldig hullnummer.")
return row["tournament_participant_id"]
async def _list_shots(conn, round_participant_id: str, hole_number: int) -> list[ShotOut]:
rows = await conn.fetch(
_SHOT_SELECT + " WHERE trs.tournament_round_participant_id = $1 AND trs.hole_number = $2 ORDER BY trs.shot_number",
round_participant_id, hole_number,
)
return [_shot_out(r) for r in rows]
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
"/participants/{round_participant_id}/holes/{hole_number}/shots",
response_model=list[ShotOut],
)
async def list_tournament_round_shots(
tournament_id: str, round_id: str, round_participant_id: str, hole_number: int,
organization_id: str = Depends(get_authorized_org),
) -> list[ShotOut]:
async with org_connection(organization_id) as conn:
await _resolve_participant_hole(conn, tournament_id, round_id, round_participant_id, hole_number)
return await _list_shots(conn, round_participant_id, hole_number)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
"/participants/{round_participant_id}/holes/{hole_number}/shots",
response_model=ShotOut,
status_code=201,
)
async def create_tournament_round_shot(
tournament_id: str, round_id: str, round_participant_id: str, hole_number: int, body: ShotIn,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ShotOut:
async with org_connection(organization_id) as conn, translate_db_errors():
tournament_participant_id = await _resolve_participant_hole(
conn, tournament_id, round_id, round_participant_id, hole_number
)
if not await user_is_own_tournament_participant(conn, organization_id, tournament_participant_id, user.user_id):
raise app_error(
403, "NOT_TOURNAMENT_PARTICIPANT",
"Du kan kun måle slag for deg selv (eller være organisasjonsadministrator).",
)
row = await conn.fetchrow(
"""
INSERT INTO tournament_round_shot (
organization_id, tournament_round_participant_id, hole_number, recorded_by_user_id,
shot_number, club, distance_meters, start_method, start_lat, start_lng, end_method, end_lat, end_lng
)
VALUES (
$1, $2, $3, $4,
COALESCE((SELECT MAX(shot_number) FROM tournament_round_shot
WHERE tournament_round_participant_id = $2 AND hole_number = $3), 0) + 1,
$5, $6, $7, $8, $9, $10, $11, $12
)
RETURNING id::text AS id
""",
organization_id, round_participant_id, hole_number, user.user_id,
body.club, body.distance_meters, body.start_method, body.start_lat, body.start_lng,
body.end_method, body.end_lat, body.end_lng,
)
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE trs.id = $1", row["id"])
return _shot_out(full_row)
@router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/shots/{shot_id}",
status_code=204,
)
async def delete_tournament_round_shot(
tournament_id: str, round_id: str, shot_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
async with org_connection(organization_id) as conn:
row = await conn.fetchrow(
"""
SELECT trs.tournament_round_participant_id::text AS round_participant_id,
trp.tournament_participant_id::text AS tournament_participant_id
FROM tournament_round_shot trs
JOIN tournament_round_participant trp ON trp.id = trs.tournament_round_participant_id
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
WHERE trs.id = $1 AND tr.id = $2 AND tr.tournament_id = $3
""",
shot_id, round_id, tournament_id,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
if not await user_is_own_tournament_participant(
conn, organization_id, row["tournament_participant_id"], user.user_id
):
raise app_error(
403, "NOT_TOURNAMENT_PARTICIPANT",
"Du kan kun slette dine egne målte slag (eller være organisasjonsadministrator).",
)
await conn.execute("DELETE FROM tournament_round_shot WHERE id = $1", shot_id)
def _encode_polyline(points: list[tuple[float, float]]) -> str:
"""Google sin polyline-algoritme (5 desimaler) -- lokal kopi av
rounds.py sin, se modulens docstring for hvorfor den ikke importeres."""
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):
body: str = Field(max_length=2000)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/shots/{shot_id}/share",
response_model=ShotOut,
)
async def share_tournament_round_shot(
tournament_id: str, round_id: str, shot_id: str, body: ShotShareIn,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> ShotOut:
async with org_connection(organization_id) as conn:
shot_row = await conn.fetchrow(
"""
SELECT trs.id::text AS id, trs.start_lat, trs.start_lng, trs.end_lat, trs.end_lng,
trp.tournament_participant_id::text AS tournament_participant_id
FROM tournament_round_shot trs
JOIN tournament_round_participant trp ON trp.id = trs.tournament_round_participant_id
JOIN tournament_round tr ON tr.id = trp.tournament_round_id
WHERE trs.id = $1 AND tr.id = $2 AND tr.tournament_id = $3
""",
shot_id, round_id, tournament_id,
)
if shot_row is None:
raise app_error(404, "NOT_FOUND", "Slaget finnes ikke på denne runden.")
if not await user_is_own_tournament_participant(
conn, organization_id, shot_row["tournament_participant_id"], user.user_id
):
raise app_error(
403, "NOT_TOURNAMENT_PARTICIPANT",
"Du kan kun dele dine egne målte slag (eller være organisasjonsadministrator).",
)
author_display_name = await _resolve_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("tournament_round_messages", round_id, resp.content)
except httpx.HTTPError:
# Grasiøs degradering, samme prinsipp som rounds.py::share_shot
# -- ren tekst uten satellittutsnitt fremfor å blokkere delingen.
image_key = None
async with translate_db_errors():
message_row = await conn.fetchrow(
"""
INSERT INTO tournament_round_message
(organization_id, tournament_round_id, author_user_id, author_display_name, body, image_key)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id::text AS id
""",
organization_id, round_id, user.user_id, author_display_name, body.body, image_key,
)
await conn.execute(
"UPDATE tournament_round_shot SET shared_tournament_round_message_id = $1 WHERE id = $2",
message_row["id"], shot_id,
)
full_row = await conn.fetchrow(_SHOT_SELECT + " WHERE trs.id = $1", shot_id)
return _shot_out(full_row)

View file

@ -63,7 +63,7 @@ _TOURNAMENT_COLUMNS = """
registration_overflow_policy, registration_requires_approval, hero_image_key,
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method,
tiebreak_playoff_rule, net_display_style
tiebreak_playoff_rule
"""
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
@ -118,14 +118,6 @@ class Tournament(BaseModel):
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
tiebreak_playoff_rule: str | None
# Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- "Jakter
# Jesper": IKKE en ny scoring_method (sluttallet er matematisk identisk
# med stroke_net sitt netto-til-par), kun en annen presentasjon av
# LØPENDE tall midt i runden -- se individual_tournaments.py sin
# _attach_stroke_play_columns. KUN gyldig sammen med
# scoring_method='stroke_net', håndhevet i update_tournament/
# create_tournament under.
net_display_style: str
def _tournament_from_row(row) -> Tournament:
@ -159,8 +151,7 @@ async def list_tournaments(
t.registration_requires_approval, t.hero_image_key,
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at,
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule,
t.net_display_style
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
FROM tournament t
LEFT JOIN (
SELECT tournament_id,
@ -256,16 +247,9 @@ class TournamentUpdate(BaseModel):
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
# for vinner-metoden, ikke felt-metoden.
tiebreak_winner_method: str | None = Field(
default=None, pattern="^(none|countback|lowest_hcp|playoff|lowest_playing_hcp)$"
)
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|lowest_playing_hcp)$")
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$")
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
# Visningsmodus for netto-scoring (migrasjon 092, 2026-08-23) -- se
# Tournament sin kommentar. Gyldigheten mot scoring_method='stroke_net'
# håndheves i update_tournament under (ikke her -- denne modellen kjenner
# ikke turneringens gjeldende scoring_method).
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
# hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
@ -298,25 +282,6 @@ async def update_tournament(
values.append(tournament_id)
async with org_connection(organization_id) as conn, translate_db_errors():
# Fail-loudly (migrasjon 092): 'hunt' gir ingen mening utenfor
# stroke_net -- de andre metodene har ingen netto-til-par-visning å
# endre presentasjonen av. Sjekker den EFFEKTIVE tilstanden ETTER
# denne PATCH-en (kan selv sette scoring_method/net_display_style i
# samme kall) -- dekker BEGGE retninger: (a) satt til 'hunt' på en
# ikke-stroke_net-turnering, (b) scoring_method byttet bort fra
# stroke_net mens 'hunt' henger igjen fra før, uendret i denne
# PATCH-en. Unngår en stille, meningsløs kombinasjon i databasen.
if "scoring_method" in updates or "net_display_style" in updates:
current = await conn.fetchrow(
"SELECT scoring_method, net_display_style FROM tournament WHERE id = $1", tournament_id
)
effective_method = updates.get("scoring_method", current["scoring_method"] if current else None)
effective_style = updates.get("net_display_style", current["net_display_style"] if current else "standard")
if effective_style == "hunt" and effective_method != "stroke_net":
raise app_error(
400, "VALIDATION_FAILED",
"Jakter Jesper-visningen krever scoring_method='stroke_net'.",
)
row = await conn.fetchrow(
f"""
UPDATE tournament SET {', '.join(set_clauses)}
@ -962,14 +927,6 @@ class SessionOut(BaseModel):
shamble_best_n: int | None
locked_team_ids: list[str]
revealed: bool
# "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- KUN
# meningsfullt for format IN ('singles','fourball') OG
# scoring_mode='stroke' (samme betingelse fetch_individual_leaderboard
# allerede håndhever ved lesing). Øktnivå, ikke turneringnivå -- se
# ADR-106-tillegget for hvorfor (lagturneringer har ikke
# scoring_method på selve turneringen, og har ingen tilsvarende
# innstillings-UI på turneringnivå slik individuelle turneringer har).
net_display_style: str
def _session_out(row, locked: set[str]) -> SessionOut:
@ -990,7 +947,6 @@ def _session_out(row, locked: set[str]) -> SessionOut:
shamble_best_n=row["shamble_best_n"],
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
net_display_style=row["net_display_style"],
)
@ -1003,8 +959,7 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
net_display_style
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
FROM session
WHERE tournament_id = $1
ORDER BY sequence
@ -1066,8 +1021,7 @@ async def create_session(
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
net_display_style
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
""",
organization_id,
tournament_id,
@ -1104,11 +1058,6 @@ class SessionUpdate(BaseModel):
points_per_match: float | None = None
allowance_override: dict | None = None
course_id: str | None = None
# "Jakter Jesper"-visningsmodus (migrasjon 093) -- se SessionOut sin
# kommentar. Gyldigheten mot format/scoring_mode håndheves i
# update_session under (denne modellen kjenner ikke øktens gjeldende
# format/scoring_mode).
net_display_style: str | None = Field(default=None, pattern="^(standard|hunt)$")
async def _remap_course(conn, session_id: str, new_course_id: str) -> None:
@ -1205,26 +1154,12 @@ async def update_session(
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow(
"SELECT format, scoring_mode, course_id::text AS course_id FROM session WHERE id = $1",
"SELECT format, course_id::text AS course_id FROM session WHERE id = $1",
session_id,
)
if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
# "Jakter Jesper" (migrasjon 093) -- fail-loudly, samme prinsipp som
# org-individuell (ADR-106). format/scoring_mode kan ALDRI selv
# endres i samme kall (SessionUpdate ekskluderer dem bevisst, se
# modellens docstring), så det er alltid ØKTENS LAGREDE verdi som
# avgjør -- ingen "effektiv verdi etter denne PATCH-en"-vurdering
# nødvendig her, ulikt tournament/round-varianten.
if updates.get("net_display_style") == "hunt" and not (
session["format"] in ("singles", "fourball") and session["scoring_mode"] == "stroke"
):
raise app_error(
400, "VALIDATION_FAILED",
"Jakter Jesper-visningen krever singles/fourball med brutto-/nettoslagspill.",
)
new_course_id = updates.pop("course_id", None)
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
@ -1263,8 +1198,7 @@ async def update_session(
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
net_display_style
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
FROM session WHERE id = $1
""",
session_id,
@ -1934,9 +1868,6 @@ class IndividualLeaderboard(BaseModel):
session_id: str
format: str
entries: list[IndividualLeaderboardEntry]
# "Jakter Jesper" (migrasjon 093) -- frontend viser en forklarende
# banner og vet dermed hvorfor "Netto"-tallene ser dype/uvante ut.
net_display_style: str
async def fetch_individual_leaderboard(
@ -1945,8 +1876,7 @@ async def fetch_individual_leaderboard(
session = await conn.fetchrow(
"""
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
hole_config::text AS hole_config, course_id::text AS course_id,
net_display_style
hole_config::text AS hole_config, course_id::text AS course_id
FROM session WHERE id = $1
""",
session_id,
@ -2015,17 +1945,8 @@ async def fetch_individual_leaderboard(
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
strokes_by_hole = dict(zip(played, strokes_list))
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
# "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- kun
# net_score/net_to_par bytter til hunt-semantikk (helt
# spillehandicap trukket fra med én gang). total_points
# (Stableford) er UBERØRT -- poeng tildeles per hull ut fra
# DET hullets netto, ingen "hunt"-ekvivalent for en poengsum.
if session["net_display_style"] == "hunt":
net_score = gross_score - r["playing_handicap"]
net_to_par = gross_to_par - r["playing_handicap"]
else:
net_score = gross_score - strokes_received_total
net_to_par = gross_to_par - strokes_received_total
net_score = gross_score - strokes_received_total
net_to_par = gross_to_par - strokes_received_total
# Samme stableford-formel som round-leaderboardets
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
# per slag, gulvet på 0.
@ -2055,10 +1976,7 @@ async def fetch_individual_leaderboard(
)
)
return IndividualLeaderboard(
session_id=session_id, format=session["format"], entries=entries,
net_display_style=session["net_display_style"],
)
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
@router.get(

View file

@ -1,15 +1,4 @@
import { PublicLive } from "@/components/public-live"
import { PublicIndividualLive } from "@/components/public-individual-live"
// Server-side, IKKE nettleser-fetch -- samme mønster som app/logg-inn/
// page.tsx og generateMetadata i app/t/[id]/page.tsx (peker direkte på
// API-et, går IKKE gjennom next.config.mjs sin rewrites()). Avgjør
// format_type FØR noe rendres, slik at en individuell turnering aldri
// (heller ikke kortvarig) viser Cup-formatets side -- se CHANGELOG.md
// 2026-08-21 for hele bakgrunnen (tournament-router.tsx sin tilsvarende
// klient-side feil, som denne siden bevisst unngår ved å avgjøre server-
// side i stedet).
const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
export default async function PublicLivePage({
params,
@ -20,23 +9,5 @@ export default async function PublicLivePage({
}) {
const { id } = await params
const { code } = await searchParams
let formatType = "team"
try {
const url = `${API_ORIGIN}/public/tournaments/${id}${code ? `?code=${encodeURIComponent(code)}` : ""}`
const res = await fetch(url, { cache: "no-store" })
if (res.ok) {
const data: { format_type?: string } = await res.json()
formatType = data.format_type === "individual" ? "individual" : "team"
}
} catch {
// Klarte ikke å avgjøre format server-side -- fall tilbake til Cup-
// siden sin egen feilhåndtering (samme "trygg fallback"-prinsipp som
// resten av appen), ikke en hard krasj her.
}
if (formatType === "individual") {
return <PublicIndividualLive tournamentId={id} code={code} />
}
return <PublicLive tournamentId={id} code={code} />
}

View file

@ -1,49 +0,0 @@
"use client"
import { PencilLine } from "lucide-react"
import { cn } from "@/lib/utils"
export type CompactScoreFabProps = {
/** Controls the fade + scale in/out. Driven from outside (the page's
* existing `inFlowButtonVisible` state) -- this component owns no
* hide/show-on-scroll logic of its own. */
visible: boolean
/** Scrolls the user back to the player list (same action as the in-flow
* button). No navigation, no new page. */
onClick: () => void
}
/**
* Compact, corner-anchored "Score" quick button (FAB).
*
* Replaces the old full-width bottom banner (round-detail.tsx, removed
* 2026-08-23 -- see CHANGELOG). Because it's small and pinned to the
* bottom-right corner, it never needs to hide to avoid covering content --
* so there is no scrim, no reserved bottom spacer, and no slide-up
* animation. It simply fades + scales in place.
*/
export function CompactScoreFab({ visible, onClick }: CompactScoreFabProps) {
return (
<button
type="button"
onClick={onClick}
aria-hidden={!visible}
tabIndex={visible ? 0 : -1}
className={cn(
"fixed bottom-[calc(1.25rem+env(safe-area-inset-bottom))] right-[calc(1.25rem+env(safe-area-inset-right))] z-30",
"inline-flex h-12 items-center gap-2 rounded-full px-5",
"bg-primary text-primary-foreground font-bold",
"shadow-lg shadow-black/20",
"transition-all duration-200 ease-in-out",
"hover:brightness-95 active:scale-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
visible ? "scale-100 opacity-100" : "pointer-events-none scale-90 opacity-0",
)}
>
<PencilLine aria-hidden="true" className="size-5" />
Score
</button>
)
}
export default CompactScoreFab

View file

@ -550,21 +550,7 @@ function OfficialCourseList({
credentials: "include",
body: JSON.stringify({ facility_slug: facility.slug, teeoff_course_id: course.teeoff_course_id }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
} else if (code === "EXTERNAL_DATA_INVALID") {
setError(
body?.detail?.message ??
"Banedataen fra teeoff er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos teeoff før banen kan importeres.",
)
} else {
setError("Klarte ikke å hente banedata fra teeoff. Prøv igjen.")
}
return
}
if (!res.ok) throw new Error(`import: ${res.status}`)
const imported: { id: string; name: string } = await res.json()
const [holesRes, teesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),

File diff suppressed because it is too large Load diff

View file

@ -1,675 +0,0 @@
"use client"
// Offentlig "Følg live" for INDIVIDUELLE turneringer (2026-08-21) --
// motstykket til public-live.tsx (Cup-format). Se CHANGELOG.md samme dag
// for hele bakgrunnen (bevisst utsatt siden 2026-08-14, FEATURE_BACKLOG.md
// "Flaggturnering Del B", nå bygget etter grundig todelt analyse).
//
// Visuelt: Claude-skrevet V0-eksport (v0-prompt-public-individual-live.md),
// presentasjonskomponentene under er UENDRET fra eksporten. Denne filen
// bytter ut mock-datalaget med ekte fetching + sanntid.
//
// VIKTIG forenkling fra mock til ekte data: mock-en modellerte hver runde
// som sitt eget, helt separate leaderboard. Den ekte backend-modellen
// (individual_tournaments.py _compute_individual_standings) beregner ETT
// SAMLET, kumulativt turnering-leaderboard -- "i dag"/"thru" gjelder
// alltid gjeldende/siste påbegynte runde, "total" er summen på tvers av
// ALLE spilte runder. Rundevelgeren endrer derfor IKKE selve
// leaderboard-radene (position/thru/i dag/total er alltid de samme,
// uansett hvilken rundefane som er valgt) -- den styrer KUN hvilken
// rundes hull-for-hull-rutenett som lastes når en rad utvides.
import { useEffect, useMemo, useRef, useState } from "react"
import { ArrowLeft, Radio, Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Wordmark } from "@/components/wordmark"
/* ------------------------------------------------------------------ *
* API-typer (matcher app/routers/registration.py sine nye endepunkt) *
* ------------------------------------------------------------------ */
type RoundStatus = "not_started" | "in_progress" | "completed"
type ApiRound = {
id: string
sequence: number
name: string
course_name: string
scheduled_at: string | null
status: RoundStatus
}
type Tone = "under" | "even" | "over" | null
type ApiRoundCell = { round_number: number; label: string; tone: Tone }
type PlayerStatus = "active" | "dsq" | "rtd" | "dnf" | "dns"
type ApiLeaderboardEntry = {
tournament_participant_id: string
player_name: string
class_id: string | null
class_name: string | null
position: string | null
is_leader: boolean
today_label: string | null
thru_label: string | null
total_label: string | null
rank: number | null
cut: boolean
status: PlayerStatus
handicap_index: number | null
rounds: ApiRoundCell[]
}
type ApiHole = { hole_number: number; par: number; gross_strokes: number | null }
type ApiTournamentInfo = { name: string; format_type: string; status: string; scoring_method: string | null }
// HCP er kun meningsfullt der den faktisk brukes til slagfordeling --
// ren bruttoslagspill (og brutto-eclectic) har ingen HCP-avhengighet.
// Bruker 2026-08-21: "når det er brutto som er hovedkonkurransen trenger
// vi ikke se hcp i leaderboarden".
const GROSS_ONLY_METHODS = new Set(["stroke_gross", "eclectic_gross"])
function shouldShowHcp(scoringMethod: string | null): boolean {
return scoringMethod != null && !GROSS_ONLY_METHODS.has(scoringMethod)
}
/* ------------------------------------------------------------------ *
* Visningstyper for hull-rutenettet (per runde, hentes lat ved utvidelse)
* ------------------------------------------------------------------ */
type HoleResult = { holeNumber: number; par: number; toParTone: Tone; label: string }
function toneOf(strokesOverPar: number): Tone {
return strokesOverPar < 0 ? "under" : strokesOverPar > 0 ? "over" : "even"
}
function holesFromApi(holes: ApiHole[]): HoleResult[] {
return holes.map((h) => {
if (h.gross_strokes == null) return { holeNumber: h.hole_number, par: h.par, toParTone: null, label: "" }
return {
holeNumber: h.hole_number,
par: h.par,
toParTone: toneOf(h.gross_strokes - h.par),
label: String(h.gross_strokes),
}
})
}
/* ------------------------------------------------------------------ *
* Delt golfscore-visuelt språk -- UENDRET fra V0-eksporten. *
* under = sirkel · over = avrundet firkant · even = ren tekst. *
* emphasis = fylt (leder); ellers tint + kant. *
* ------------------------------------------------------------------ */
function ScoreShape({
label,
tone,
emphasis = false,
size = "md",
}: {
label: string | null
tone: Tone
emphasis?: boolean
size?: "sm" | "md"
}) {
const dims = size === "sm" ? "min-h-7 min-w-7 px-1 text-xs" : "min-h-10 min-w-10 px-2 text-sm"
const base = cn("inline-flex items-center justify-center font-bold tabular-nums", dims)
if (label == null) {
return (
<span className={cn(base, "text-muted-foreground")} aria-hidden="true">
</span>
)
}
if (tone === "even") {
return (
<span className={cn(base, "text-foreground")}>
{label}
<span className="sr-only"> likt med par</span>
</span>
)
}
if (tone == null) {
return <span className={cn(base, "text-muted-foreground")}>{label}</span>
}
const under = tone === "under"
const shape = under ? "rounded-full" : "rounded-[5px]"
const skin = emphasis
? under
? "bg-primary text-primary-foreground"
: "bg-brand-orange text-brand-orange-foreground"
: under
? "border-2 border-primary/35 bg-primary/10 text-primary"
: "border-2 border-brand-orange/35 bg-brand-orange/10 text-brand-orange"
return (
<span className={cn(base, shape, skin)}>
{label}
<span className="sr-only">{under ? " under par" : " over par"}</span>
</span>
)
}
function RankBadge({ position, isLeader }: { position: string | null; isLeader: boolean }) {
if (!position) {
return (
<span className="inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg bg-muted px-1.5 text-sm font-bold text-muted-foreground">
</span>
)
}
return (
<span
className={cn(
"inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg px-1.5 text-sm font-extrabold tabular-nums",
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
)}
>
{position}
</span>
)
}
function StatusPill({ label }: { label: string }) {
return (
<span className="inline-flex min-h-8 items-center justify-center rounded-md border border-dashed border-border bg-muted/40 px-2.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
)
}
function RoundStatusBadge({ status }: { status: RoundStatus }) {
if (status === "in_progress") {
return (
<span className="inline-flex items-center gap-1.5 self-start rounded-full bg-primary px-2.5 py-1 text-xs font-bold text-primary-foreground">
<Radio aria-hidden="true" className="size-3.5 animate-pulse" />
Pågår
</span>
)
}
if (status === "completed") {
return (
<span className="inline-flex items-center gap-1.5 self-start text-xs font-semibold text-muted-foreground">
<Check aria-hidden="true" className="size-3.5" />
Fullført
</span>
)
}
return <span className="self-start text-xs font-semibold text-muted-foreground">Ikke startet</span>
}
const STATUS_LABEL: Record<Exclude<PlayerStatus, "active">, string> = {
dsq: "DSQ",
rtd: "RTD",
dnf: "DNF",
dns: "DNS",
}
/* ------------------------------------------------------------------ *
* Utvidbart hull-for-hull-rutenett *
* ------------------------------------------------------------------ */
function HoleNine({ holes, label }: { holes: HoleResult[]; label: string }) {
const parSum = holes.reduce((s, h) => s + h.par, 0)
const strokeSum = holes.reduce((s, h) => s + (h.label ? Number(h.label) : 0), 0)
return (
<div className="overflow-hidden rounded-xl border border-border">
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center bg-muted/60 text-center text-[11px] font-semibold text-muted-foreground">
<div className="py-1">{label}</div>
{holes.map((h) => (
<div key={h.holeNumber} className="py-1">
{h.holeNumber}
</div>
))}
<div className="py-1">Sum</div>
</div>
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center border-t border-border text-center text-[11px] text-muted-foreground">
<div className="py-1 font-semibold">Par</div>
{holes.map((h) => (
<div key={h.holeNumber} className="py-1 tabular-nums">
{h.par}
</div>
))}
<div className="py-1 font-semibold tabular-nums">{parSum}</div>
</div>
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center justify-items-center border-t border-border py-1 text-center">
<div className="text-[11px] font-semibold text-foreground">Deg</div>
{holes.map((h) =>
h.label ? (
<ScoreShape key={h.holeNumber} label={h.label} tone={h.toParTone} size="sm" />
) : (
<span key={h.holeNumber} className="text-xs text-muted-foreground" aria-hidden="true">
</span>
),
)}
<div className="text-xs font-bold tabular-nums text-foreground">{strokeSum || ""}</div>
</div>
</div>
)
}
function HoleGrid({ holes }: { holes: HoleResult[] }) {
if (holes.length === 0) {
return <p className="px-3 pb-3 text-sm text-muted-foreground">Ingen hull registrert for denne runden ennå.</p>
}
return (
<div className="flex flex-col gap-2 px-3 pb-3">
<HoleNine holes={holes.slice(0, 9)} label="Ut" />
{holes.length > 9 && <HoleNine holes={holes.slice(9)} label="Inn" />}
</div>
)
}
/* ------------------------------------------------------------------ *
* Leaderboard-rad -- henter hull-for-hull LAT, for VALGT runde, *
* kun når raden faktisk utvides. *
* ------------------------------------------------------------------ */
function LeaderboardRow({
entry,
organizationlessBase,
roundId,
showHcp,
}: {
entry: ApiLeaderboardEntry
organizationlessBase: string
roundId: string | null
showHcp: boolean
}) {
const [open, setOpen] = useState(false)
const [holes, setHoles] = useState<HoleResult[] | null>(null)
const [loadingHoles, setLoadingHoles] = useState(false)
const panelId = `holes-${entry.tournament_participant_id}`
const isStatus = entry.status !== "active"
// Rundevalg endret seg mens raden var åpen -- kast cachet data, hent på nytt.
useEffect(() => {
setHoles(null)
}, [roundId])
async function toggle() {
const next = !open
setOpen(next)
if (next && roundId && holes === null) {
setLoadingHoles(true)
try {
const res = await fetch(
`${organizationlessBase}/rounds/${roundId}/participants/${entry.tournament_participant_id}/holes`,
{ credentials: "include" },
)
if (res.ok) {
const data: ApiHole[] = await res.json()
setHoles(holesFromApi(data))
}
} finally {
setLoadingHoles(false)
}
}
}
const rowInner = (
<>
<RankBadge position={entry.position} isLeader={entry.is_leader} />
<span className="flex min-w-0 flex-1 flex-col">
{/* Navnet får bryte til ny linje i stedet for å avkortes med "..." på
smale skjermer (360px) -- funnet 2026-08-21 under scratch-
verifisering: et reelt navn ("Erol Haagenrud") kuttet ned til
"Erol Haag..." er under lesbarhetsbarren (CLAUDE.md, ufravikelig).
Undertittelen (HCP/klasse, mindre viktig) beholder truncate. */}
<span className="text-pretty text-[15px] font-bold leading-tight text-foreground">{entry.player_name}</span>
<span className="truncate text-xs text-muted-foreground">
{showHcp && entry.handicap_index != null && `HCP ${entry.handicap_index.toFixed(1).replace(".", ",")}`}
{showHcp && entry.handicap_index != null && entry.class_name ? " · " : ""}
{entry.class_name}
</span>
</span>
<span className="flex flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Thru</span>
{/* min-h-10 matcher ScoreShape sin høyde (I dag/Total-kolonnene) --
uten denne blir hele Thru-kolonnen lavere og etiketten sentreres
ut av linje med de to andre (funnet i ekte mobilbruk 2026-08-21). */}
<span className="flex min-h-10 items-center text-sm font-bold tabular-nums text-foreground">
{entry.thru_label ?? "-"}
</span>
</span>
<span className="flex flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">I dag</span>
{isStatus ? (
<span className="text-sm text-muted-foreground" aria-hidden="true">
</span>
) : (
<ScoreShape
label={entry.today_label}
tone={entry.today_label?.startsWith("-") || entry.today_label?.startsWith("") ? "under" : entry.today_label?.startsWith("+") ? "over" : entry.today_label === "E" ? "even" : null}
/>
)}
</span>
<span className="flex flex-col items-center gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Total</span>
{isStatus || entry.cut ? (
<StatusPill label={entry.cut ? "Kuttet" : STATUS_LABEL[entry.status as Exclude<PlayerStatus, "active">]} />
) : (
<ScoreShape
label={entry.total_label}
tone={entry.total_label?.startsWith("-") || entry.total_label?.startsWith("") ? "under" : entry.total_label?.startsWith("+") ? "over" : entry.total_label === "E" ? "even" : null}
emphasis={entry.is_leader}
/>
)}
</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-5 shrink-0 text-muted-foreground transition-transform duration-200",
roundId ? (open ? "rotate-180" : "") : "invisible",
)}
/>
</>
)
if (!roundId) {
return <li className="flex items-center gap-2 px-2.5 py-3 sm:gap-2.5 sm:px-4">{rowInner}</li>
}
return (
<li>
<button
type="button"
onClick={() => void toggle()}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-center gap-2 px-2.5 py-3 text-left sm:gap-2.5 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-4"
>
{rowInner}
<span className="sr-only">{open ? "Skjul hull for hull" : "Vis hull for hull"}</span>
</button>
<div
id={panelId}
className={cn(
"grid transition-all duration-300 ease-out",
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">
<div className={cn(!open && "invisible")} aria-hidden={!open}>
{loadingHoles ? (
<p className="px-3 pb-3 text-sm text-muted-foreground">Laster hull </p>
) : (
<HoleGrid holes={holes ?? []} />
)}
</div>
</div>
</div>
</li>
)
}
/* ------------------------------------------------------------------ *
* Side *
* ------------------------------------------------------------------ */
function pickDefaultRound(rounds: ApiRound[]): string | null {
const live = rounds.find((r) => r.status === "in_progress")
if (live) return live.id
const completed = [...rounds].reverse().find((r) => r.status === "completed")
if (completed) return completed.id
return rounds[0]?.id ?? null
}
function rankKey(e: ApiLeaderboardEntry): number {
if (e.position) return Number.parseInt(e.position.replace(/\D/g, ""), 10) || 999
return 1000
}
export function PublicIndividualLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
const base = `/public/tournaments/${tournamentId}`
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
const [rounds, setRounds] = useState<ApiRound[]>([])
const [entries, setEntries] = useState<ApiLeaderboardEntry[]>([])
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const [classFilter, setClassFilter] = useState<string>("all")
const [loading, setLoading] = useState(true)
async function loadAll(isFirst: boolean) {
const [tRes, rRes, lRes] = await Promise.all([
isFirst ? fetch(`${base}${codeParam}`, { credentials: "include" }) : Promise.resolve(null),
fetch(`${base}/rounds${codeParam}`, { credentials: "include" }),
fetch(`${base}/individual-leaderboard${codeParam}`, { credentials: "include" }),
])
if (tRes?.ok) {
const data: ApiTournamentInfo = await tRes.json()
setTournament(data)
}
if (rRes.ok) {
const data: ApiRound[] = await rRes.json()
setRounds(data)
if (isFirst) setSelectedRoundId(pickDefaultRound(data))
}
if (lRes.ok) {
const data: ApiLeaderboardEntry[] = await lRes.json()
setEntries(data)
}
}
useEffect(() => {
let cancelled = false
loadAll(true).finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournamentId])
// Sanntid -- SAMME WebSocket-kanal og "noe endret, hent på nytt"-mønster
// som public-live.tsx (Cup-format) allerede bruker, ADR-027.
const isFirstRefresh = useRef(true)
useEffect(() => {
const proto = window.location.protocol === "https:" ? "wss" : "ws"
const ws = new WebSocket(
`${proto}://${window.location.host}/ws/public/tournaments/${tournamentId}/live${codeParam}`,
)
ws.onmessage = () => {
if (isFirstRefresh.current) {
isFirstRefresh.current = false
return
}
void loadAll(false)
}
return () => ws.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tournamentId])
const classes = useMemo(
() => Array.from(new Set(entries.map((e) => e.class_name).filter((c): c is string => !!c))),
[entries],
)
const visible = useMemo(() => {
const filtered = classFilter === "all" ? entries : entries.filter((e) => e.class_name === classFilter)
return [...filtered].sort((a, b) => rankKey(a) - rankKey(b))
}, [entries, classFilter])
const advancing = visible.filter((e) => e.status === "active" && !e.cut)
const belowCut = visible.filter((e) => e.cut || e.status !== "active")
const hasCut = belowCut.length > 0
const isFinished = tournament?.status === "completed" || tournament?.status === "archived"
const showHcp = shouldShowHcp(tournament?.scoring_method ?? null)
if (loading) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
return (
<div className="min-h-dvh bg-background text-foreground">
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3">
<a
href={`/t/${tournamentId}${codeParam}`}
aria-label="Tilbake til turneringen"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</a>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={cn("size-2 rounded-full", isFinished ? "bg-muted-foreground" : "bg-primary animate-pulse")}
/>
<span className="text-[11px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
{isFinished ? "Avsluttet" : "Følg live"}
</span>
</span>
<h1 className="truncate text-lg font-extrabold leading-tight text-foreground text-balance">
{tournament?.name ?? "Turnering"}
</h1>
</div>
<Wordmark compact />
</div>
</header>
<main className="mx-auto flex max-w-3xl flex-col gap-5 px-4 py-5">
{rounds.length > 0 && (
<nav aria-label="Velg runde" className="-mx-4 overflow-x-auto px-4">
<ul className="flex gap-2.5">
{rounds.map((r) => {
const selected = r.id === selectedRoundId
const date = r.scheduled_at
? new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }).format(
new Date(r.scheduled_at),
)
: null
return (
<li key={r.id}>
<button
type="button"
onClick={() => setSelectedRoundId(r.id)}
aria-pressed={selected}
className={cn(
"flex min-w-[10.5rem] flex-col gap-1.5 rounded-2xl border p-3 text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-border bg-card hover:bg-muted/50",
)}
>
<span className="text-sm font-extrabold text-foreground">{r.name}</span>
<span className="text-xs text-muted-foreground">
{r.course_name}
{date ? ` · ${date}` : ""}
</span>
<RoundStatusBadge status={r.status} />
</button>
</li>
)
})}
</ul>
</nav>
)}
<section aria-label="Resultatliste" className="flex flex-col gap-3">
{classes.length > 0 && entries.length > 0 && (
<div
role="group"
aria-label="Filtrer på klasse"
className="flex flex-wrap gap-1.5 rounded-xl border border-border bg-card p-1"
>
{[{ key: "all", label: "Alle" }, ...classes.map((c) => ({ key: c, label: c }))].map((opt) => {
const active = classFilter === opt.key
return (
<button
key={opt.key}
type="button"
onClick={() => setClassFilter(opt.key)}
aria-pressed={active}
className={cn(
"min-h-9 flex-1 rounded-lg px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
)}
>
{opt.label}
</button>
)
})}
</div>
)}
{entries.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
<p className="text-sm font-semibold text-foreground">Ingen resultater ennå</p>
<p className="mt-1 text-sm text-muted-foreground text-pretty">
Ingen resultater registrert ennå for denne turneringen.
</p>
</div>
) : visible.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
<p className="text-sm text-muted-foreground">Ingen spillere i denne klassen.</p>
</div>
) : (
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/[0.06]">
{advancing.map((e) => (
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} showHcp={showHcp} />
))}
{hasCut && (
<li aria-hidden="true" className="flex items-center gap-3 bg-muted/50 px-4 py-2">
<span className="h-px flex-1 bg-border" />
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Cut · {advancing.length} videre
</span>
<span className="h-px flex-1 bg-border" />
</li>
)}
{belowCut.map((e) => (
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} showHcp={showHcp} />
))}
</ol>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="flex size-5 items-center justify-center rounded-full border-2 border-primary/35 bg-primary/10 text-[10px] font-bold text-primary">
</span>
Under par
</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-foreground">E</span>
Par
</span>
<span className="flex items-center gap-1.5">
<span className="flex size-5 items-center justify-center rounded-[4px] border-2 border-brand-orange/35 bg-brand-orange/10 text-[10px] font-bold text-brand-orange">
+
</span>
Over par
</span>
</div>
</section>
</main>
</div>
)
}

View file

@ -15,7 +15,6 @@ import {
ImagePlus,
Lock,
MessageSquare,
Radio,
Send,
ShieldAlert,
Trophy,
@ -72,7 +71,6 @@ type ApiTournamentInfo = {
name: string
organization_name: string
status: string
format_type: string
visibility: string
description: string | null
start_date: string | null
@ -92,19 +90,6 @@ type ApiSession = {
scheduled_at: string | null
}
// Individuell turnering sitt "Program"-motstykke (2026-08-21) -- sessions
// er et rent Cup-format-konsept (`session`-tabellen), individuelle
// turneringer bruker `tournament_round` i stedet. Egen type/fetch/seksjon,
// ikke tvunget inn i Session-formen over.
type ApiRound = {
id: string
sequence: number
name: string
course_name: string
scheduled_at: string | null
status: "not_started" | "in_progress" | "completed"
}
function mapOutcome(status: string): Outcome {
return status === "waitlisted" ? "waitlist" : (status as Outcome)
}
@ -134,7 +119,6 @@ function formatDateRange(start?: Date, end?: Date) {
export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) {
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
const [sessions, setSessions] = useState<Session[]>([])
const [rounds, setRounds] = useState<ApiRound[]>([])
const [loading, setLoading] = useState(true)
const [accessError, setAccessError] = useState<string | null>(null)
@ -163,30 +147,20 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
if (cancelled) return
setInfo(data)
if (data.format_type === "individual") {
const roundsRes = await fetch(`/public/tournaments/${tournamentId}/rounds${codeParam}`, {
credentials: "include",
})
if (roundsRes.ok) {
const roundData: ApiRound[] = await roundsRes.json()
if (!cancelled) setRounds(roundData)
}
} else {
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
credentials: "include",
})
if (sessionsRes.ok) {
const sessionData: ApiSession[] = await sessionsRes.json()
if (!cancelled) {
setSessions(
sessionData.map((s) => ({
id: s.id,
name: s.name,
format: s.format,
time: s.scheduled_at ?? undefined,
})),
)
}
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
credentials: "include",
})
if (sessionsRes.ok) {
const sessionData: ApiSession[] = await sessionsRes.json()
if (!cancelled) {
setSessions(
sessionData.map((s) => ({
id: s.id,
name: s.name,
format: s.format,
time: s.scheduled_at ?? undefined,
})),
)
}
}
} catch {
@ -282,7 +256,7 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</Link>
{info.format_type === "individual" ? <ProgramRounds rounds={rounds} /> : <Program sessions={t.sessions} />}
<Program sessions={t.sessions} />
{info.registration_open ? (
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
@ -690,53 +664,6 @@ function Program({ sessions }: { sessions: Session[] }) {
)
}
// Individuell turnering sitt "Program" (2026-08-21) -- runder i stedet for
// økter. Ingen tom-tilstand-tekst manglet her fra start (i motsetning til
// Program over) siden dette er nytt.
function ProgramRounds({ rounds }: { rounds: ApiRound[] }) {
if (rounds.length === 0) {
return (
<section aria-label="Program" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Program</h2>
<p className="text-sm text-muted-foreground">Ingen runder satt opp ennå.</p>
</section>
)
}
return (
<section aria-label="Program" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Program</h2>
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{rounds.map((r) => {
const date = r.scheduled_at ? DATE_FMT.format(new Date(r.scheduled_at)) : null
return (
<li key={r.id} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3.5 last:border-b-0">
<div className="flex min-w-0 flex-col">
<span className="truncate font-semibold text-foreground">{r.name}</span>
<span className="truncate text-sm text-muted-foreground">
{r.course_name}
{date ? ` · ${date}` : ""}
</span>
</div>
{r.status === "in_progress" && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
<Radio aria-hidden="true" className="size-3 animate-pulse" />
Pågår
</span>
)}
{r.status === "completed" && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-bold text-muted-foreground">
<CheckCircle2 aria-hidden="true" className="size-3" />
Fullført
</span>
)}
</li>
)
})}
</ul>
</section>
)
}
// --- Sponsors --------------------------------------------------------------
function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {

View file

@ -31,6 +31,7 @@ import {
MapPin,
Plus,
RefreshCw,
Ruler,
Search,
Settings2,
Trophy,
@ -55,10 +56,11 @@ import {
Stepper,
WizardSection,
} from "@/components/hole-stat-inputs"
import { ShotMeasurementEntry } from "@/components/shot/shot-measurement-entry"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { HoleTargetDistance } from "@/components/hole-target-distance"
import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { CompactScoreFab } from "@/components/compact-score-fab"
import { bearingDegrees } from "@/lib/geo"
import { midpoint } from "@/lib/hole-geometry"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types -----------------------------------------------------------------
@ -259,9 +261,6 @@ type ApiRound = {
flight_group_id: string | null
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: boolean
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
// kun relevant for play_format='stroke'.
net_display_style: "standard" | "hunt"
}
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
@ -1720,14 +1719,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
løpende til-par, dette er rendyrket "se mer detaljer"
-- hører hjemme i administrasjons-fanen, ikke i veien for
score-registreringen. */}
{!completed && (
<ScoreSoFar
holes={apiHoles}
order={holeOrder}
courseHandicap={activePlayer.courseHandicap}
netDisplayStyle={round.net_display_style}
/>
)}
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
</>
)}
@ -1813,10 +1805,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
{round.sides.map((s, i) => (
<ShotMeasurementEntry
key={s.id}
shotsUrl={`/rounds/${roundId}/sides/${s.id}/holes/${activeHole}/shots`}
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
targetPointsUrl={`/rounds/${roundId}/holes/${activeHole}/target-points`}
roundId={roundId}
holeNumber={activeHole}
owner={{ kind: "side", id: s.id }}
ownBagClubs={ownBagClubs}
readOnly={readOnly}
variant="badge"
@ -2294,10 +2285,9 @@ function ScoringWizard({
/>
<ShotMeasurementEntry
shotsUrl={`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`}
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
targetPointsUrl={`/rounds/${roundId}/holes/${hole.holeNumber}/target-points`}
roundId={roundId}
holeNumber={hole.holeNumber}
owner={{ kind: "participant", id: player.id }}
ownBagClubs={player.isSelf ? ownBagClubs : []}
readOnly={readOnly}
variant="inline"
@ -2378,6 +2368,403 @@ function ScoringWizard({
)
}
// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- delt mellom
// tre bruksteder: ScoringWizard sitt detalj-steg (inline-variant,
// deltaker-eid hull), PlayerHoleCards sin alltid-synlige merkelapp
// (badge-variant, deltaker-eid, retroaktiv måling), og SideScorecardGrid
// sin tilsvarende merkelapp per side (badge-variant, side-eid hull --
// dekker delt-ball-formatene som ScoringWizard aldri når siden de bruker
// en egen, enklere side-veiviser uten dette steget). Samme
// urlBase-konstruksjon dekker begge hull-eiertyper (ADR-048 sine speilede
// deltaker-/side-endepunkt), ingen egen gren trengs utover selve URL-en.
type ShotRecord = {
id: string
club: string
distance_meters: number
shared_round_message_id: string | null
start_lat: number
start_lng: number
end_lat: number
end_lng: number
}
function ShotMeasurementEntry({
roundId,
holeNumber,
owner,
ownBagClubs,
readOnly,
variant,
label,
}: {
roundId: string
holeNumber: number
owner: { kind: "participant" | "side"; id: string }
ownBagClubs: string[]
readOnly: boolean
variant: "inline" | "badge"
label?: string
}) {
const urlBase = `/rounds/${roundId}/${owner.kind === "participant" ? "participants" : "sides"}/${owner.id}/holes/${holeNumber}/shots`
const [open, setOpen] = useState(false)
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
// det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen
// måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen
// (ikke bare .length), viser den i en utvidbar liste, med sletting av et
// feilmålt slag.
const [shots, setShots] = useState<ShotRecord[]>([])
const [listOpen, setListOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(urlBase, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ShotRecord[]) => {
if (!cancelled) setShots(data)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [urlBase])
const count = shots.length
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
// utslags- OG green-koordinater kjent (samme datakilde som
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
// kan avvike, f.eks. en slice/hook). Egen, liten fetch her (i stedet
// for å prop-drille et tall gjennom RoundDetail/PlayerHoleCards/
// ScoringWizard -- ShotMeasurementEntry brukes fra tre atskilte steder
// i denne filen, kun roundId+holeNumber er felles for alle) -- ett
// ekstra, lett GET-kall per spillerkort er en akseptert kostnad for at
// ALLE tre inngangene til kartet får riktig retning, ikke bare én.
const [courseBearing, setCourseBearing] = useState<number | undefined>(undefined)
useEffect(() => {
let cancelled = false
setCourseBearing(undefined)
fetch(`/rounds/${roundId}/holes/${holeNumber}/target-points`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => {
if (cancelled) return
const middle = points.find((p) => p.poi_type === "green" && p.location === "middle")
const teeFront = points.find((p) => p.poi_type === "tee_front")
const teeBack = points.find((p) => p.poi_type === "tee_back")
const teeRef =
teeFront && teeBack
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
: teeFront
? { lat: teeFront.latitude, lng: teeFront.longitude }
: teeBack
? { lat: teeBack.latitude, lng: teeBack.longitude }
: null
setCourseBearing(
teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined,
)
})
.catch(() => {
if (!cancelled) setCourseBearing(undefined)
})
return () => {
cancelled = true
}
}, [roundId, holeNumber])
// Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet
// 2026-08-21): "opp på skjermen" følger banens EGEN spillretning
// (utslag->green, courseBearing over) når den er kjent -- ellers
// faller tilbake til spillerens EGEN gangretning, regnet ut fra det
// SISTE tidligere målte slaget på dette hullet (start→slutt-retningen)
// -- `shots` er allerede sortert stigende på shot_number av GET-
// endepunktet (se _list_shots_for_hole i rounds.py), så siste element
// ER forrige slag. `undefined` (ingen rotasjon regnet ut ennå) for det
// aller første slaget på et hull uten kjent banegeometri -- MapPoint-
// Picker faller da selv tilbake til enhetens GPS-heading/nord, se den
// komponentens dokumentasjon. Bevisst en STATISK retning, satt idet
// arket åpnes -- ikke løpende oppdatert (ville vært urolig ved lav
// gangfart), se map-point-picker.tsx.
const initialBearing = useMemo(() => {
if (typeof courseBearing === "number") return courseBearing
const previous = shots[shots.length - 1]
if (!previous) return undefined
return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng },
)
}, [shots, courseBearing])
async function deleteShot(shotId: string) {
const res = await fetch(`/rounds/${roundId}/shots/${shotId}`, { method: "DELETE", credentials: "include" })
if (res.ok) {
setShots((prev) => prev.filter((s) => s.id !== shotId))
}
}
// Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
// fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
// aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
// holder arket åpent i stedet for å anta suksess.
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endMethod: "gps" | "map_tap"
endLat: number
endLng: number
share: boolean
shareText: string
}) {
setSubmitError(null)
setSubmitting(true)
try {
const res = await fetch(urlBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_method: result.endMethod,
end_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const detail = body?.detail
const message =
typeof detail === "string"
? detail
: typeof detail?.message === "string"
? detail.message
: "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
setSubmitError(message)
return
}
let shot = await res.json()
setShareError(null)
if (result.share) {
const shareRes = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
// Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en
// avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt
// gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er
// uansett lagret på dette tidspunktet -- arket lukkes derfor alltid
// her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og
// en mislykket deling vises separat ved siden av merkelappen i
// stedet for å holde arket åpent.
if (shareRes.ok) {
shot = await shareRes.json()
} else {
setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
}
}
setShots((prev) => [...prev, shot])
setOpen(false)
} catch {
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function shareExisting(shot: ShotRecord) {
const res = await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: `${shot.club}, ${shot.distance_meters} m hull ${holeNumber}` }),
})
if (res.ok) {
const updated = await res.json()
setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s)))
setShareError(null)
} else {
setShareError("Delingen feilet. Prøv igjen om litt.")
}
}
if (readOnly) {
// Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE
// egne målte slag, bare uten å kunne måle/dele/slette nye.
if (count === 0) return null
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-3.5 shrink-0" aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
{listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
</div>
)
}
const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<div className="flex items-center gap-1.5">
{count > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSubmitError(null)
setOpen(true)
}}
aria-label={addLabel}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
count > 0
? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
: variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
{count === 0 && addLabel}
</button>
</div>
{shareError && (
<p role="alert" className="text-xs font-medium text-destructive">
{shareError}
</p>
)}
{listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}
{open && (
<ShotMeasurementSheet
holeNumber={holeNumber}
existingShotCount={count}
ownBagClubs={ownBagClubs}
initialBearing={initialBearing}
submitError={submitError}
submitting={submitting}
onSubmit={submitShot}
onCancel={() => setOpen(false)}
/>
)}
</div>
)
}
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
function shotThumbnailUrl(shot: ShotRecord): string | null {
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
}
function ShotList({
shots,
onDelete,
onShare,
}: {
shots: ShotRecord[]
onDelete?: (shotId: string) => void
onShare?: (shot: ShotRecord) => void
}) {
return (
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => {
const thumbUrl = shotThumbnailUrl(shot)
return (
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
{thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={thumbUrl || "/placeholder.svg"}
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
className="size-14 shrink-0 rounded-lg border border-border object-cover"
/>
) : null}
<span className="flex-1 truncate font-medium text-foreground">
{shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span>
</span>
{shot.shared_round_message_id ? (
<span className="text-[11px] font-semibold text-primary">Delt</span>
) : onShare ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onShare(shot)
}}
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
>
Del
</button>
) : null}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(shot.id)
}}
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-4" aria-hidden="true" />
</button>
)}
</li>
)
})}
</ul>
)
}
// --- Completed banner ------------------------------------------------------
function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) {
@ -2498,20 +2885,7 @@ function formatSignedAvg(n: number): string {
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
}
function ScoreSoFar({
holes,
order,
courseHandicap,
netDisplayStyle = "standard",
}: {
holes: ApiHole[] | undefined
order: number[]
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- brukt kun når
// netDisplayStyle='hunt', for å regne ut nettoTotal uten gradvis
// strokes_received-allokering (se under).
courseHandicap?: number | null
netDisplayStyle?: "standard" | "hunt"
}) {
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
const [expanded, setExpanded] = useState(false)
if (!holes || holes.length === 0) return null
@ -2531,14 +2905,8 @@ function ScoreSoFar({
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
const hasNetto = withNetto.length > 0
// "Jakter Jesper" (migrasjon 093) -- trekker fra HELE course handicap med
// én gang i stedet for gradvis strokes_received-sum. Faller tilbake til
// standard hvis courseHandicap uventet mangler (bør ikke skje -- samme
// deltaker har jo strokes_received satt, som krever en beregnet HCP).
const nettoTotal = hasNetto
? netDisplayStyle === "hunt" && courseHandicap !== null && courseHandicap !== undefined
? totalStrokes - courseHandicap
: withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
: null
const stablefordTotal = hasNetto
? withNetto.reduce(
@ -4572,9 +4940,6 @@ function EditRoundPanel({
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible)
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
// kun relevant for play_format='stroke'.
const [netDisplayStyle, setNetDisplayStyle] = useState(round.net_display_style)
const [saving, setSaving] = useState(false)
const [showChangeCourse, setShowChangeCourse] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -4591,8 +4956,7 @@ function EditRoundPanel({
playFormat !== currentEditablePlayFormat ||
visibilityMode !== round.visibility_mode ||
(visibilityMode === "friends" && !sameCategories) ||
flagMapVisible !== round.flag_map_visible ||
netDisplayStyle !== round.net_display_style
flagMapVisible !== round.flag_map_visible
async function saveMetadata() {
setSaving(true)
@ -4613,7 +4977,6 @@ function EditRoundPanel({
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible
if (netDisplayStyle !== round.net_display_style) body.net_display_style = netDisplayStyle
const result = await onPatch(body)
if (!result.ok) setError(result.message)
setSaving(false)
@ -4804,42 +5167,6 @@ function EditRoundPanel({
</div>
)}
{/* "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) --
kun relevant for slagspill. Sluttresultatet blir identisk uansett,
bryteren endrer kun hvordan netto-tallet vises LØPENDE. */}
{playFormat === "stroke" && (
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Visningsmodus for netto-score</span>
<div className="flex gap-2">
{(
[
{ value: "standard" as const, label: "Standard" },
{ value: "hunt" as const, label: "Jakter Jesper" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setNetDisplayStyle(opt.value)}
aria-pressed={netDisplayStyle === opt.value}
className={cn(
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
netDisplayStyle === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Jakter Jesper: alle starter minus spillehandicapet sitt, og hullscore vises brutto mot par
løpende -- i stedet for at slag "tjenes inn" gradvis gjennom runden. Standard som standard.
</p>
</div>
)}
{!isCompleted && (
<>
<div className="flex flex-col gap-2">
@ -6386,21 +6713,25 @@ function sumForPlayer(list: ApiHole[] | undefined, numbers: number[]): { strokes
// linjer avhengig av runden), så en fast pixel-gjetning viste seg feil for
// en runde med lengre innhold enn den opprinnelige gjetningen (112px) var
// kalibrert mot (scratch-verifisering 2026-08-20: ekte høyde 141,5px for
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av
// scroll-hoppene til spillerlisten/hull-panelet under.
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av BÅDE
// scroll-hoppet til spillerlisten og `playerListReached`-sjekken under.
function getHeaderHeight(): number {
return document.querySelector("header")?.getBoundingClientRect().height ?? 112
}
// Pusterom under headeren når det scrolles til spillerlisten/hull-panelet
// -- se `scrollToPlayerList()`/`scrollToHolePanel()` under.
// Pusterom under headeren når spillerlisten scrolles til -- brukt for
// BÅDE selve scroll-målposisjonen og "reached"-terskelen (se begge
// under). Delt konstant med vilje: scroll-hoppet lander bevisst
// `SCROLL_GAP_PX` under headeren (ikke flush), så "reached"-sjekken må
// bruke SAMME avstand, ellers forblir den flytende knappen synlig etter
// at hoppet har landet (funnet i scratch-verifisering 2026-08-20 -- de to
// brukte ulike terskler).
const SCROLL_GAP_PX = 16
// "Registrer score" -- den vanlige, i normal side-flyt-plasserte knappen
// (se PlayerHoleCards). Den kompakte, hjørne-forankrede "Score"-FAB-en som
// dukker opp når DENNE ruller ut av syne er en egen komponent,
// `CompactScoreFab` (redesignet 2026-08-23, se CHANGELOG) -- ikke lenger
// samme knapp flyttet til en flytende posisjon.
// "Registrer score" -- samme knapp, to plasseringer (se PlayerHoleCards):
// i normal side-flyt når banekartet er kollapset, flytende festet nederst
// på skjermen når det er utvidet (V0-eksport, 2026-08-20, brukerønske --
// det utvidede diagrammet kunne dytte knappen langt utenfor synlig område).
function RegisterScoreButton({ onClick }: { onClick: () => void }) {
return (
<button
@ -6482,20 +6813,62 @@ function PlayerHoleCards({
const [mapExpanded, setMapExpanded] = useState(false)
const [distanceAvailable, setDistanceAvailable] = useState(false)
const playerListRef = useRef<HTMLUListElement>(null)
// Flytende "Score"-hurtigknapp (CompactScoreFab, redesignet 2026-08-23
// -- se CHANGELOG): en liten, hjørne-forankret FAB nederst til høyre,
// IKKE lenger et fullbredde-bunnbanner. Trenger derfor ikke lenger noe
// eget "har brukeren nådd spillerlisten"-signal for å skjule seg igjen
// (den tidligere `playerListReached`-mekanismen, fjernet) -- en liten
// knapp i et hjørne dekker ikke nevneverdig over innhold under, uansett
// hvor langt ned siden brukeren har scrollet. Eneste synlighets-signal
// som gjenstår er `inFlowButtonVisible` under: er selve den vanlige,
// side-flyt-plasserte knappen synlig akkurat nå. Målt direkte med
// `getBoundingClientRect()` + scroll/resize-lytter (ikke
// IntersectionObserver -- viste seg upålitelig i scratch-verifisering
// 2026-08-20: med standard threshold=[0] fyrer den kun ved
// 0%->>0%-kryssinger, ikke ved at `top`/`bottom` passerer en INTERN
// linje midt i et allerede-overlappende element).
// Flytende "Registrer score" (2026-08-20) skal kun vises mens
// spillerlisten fortsatt er UTENFOR synlig område -- brukerfunn: den
// hang igjen synlig (og dekket delvis over spillerkort 1) selv etter at
// brukeren allerede hadde scrollet forbi/til spillerlisten, der den ikke
// lenger gir mening ("Registrer score ↓" når du allerede ser
// spillerne). "Reached" = listens toppkant har nådd/passert headerens
// faktiske høyde -- forblir sant BÅDE mens listen vises OG etter at
// brukeren har scrollet helt forbi den (listen ligger da over synlig
// område, `top` er negativ). Kun scroll TILBAKE OPP forbi listen gjør
// at knappen skal dukke opp igjen.
//
// Første forsøk brukte IntersectionObserver med en krympet rootMargin
// -- viste seg FEIL i scratch-verifisering: med standard threshold=[0]
// fyrer den kun ved 0%->>0%-kryssinger (element helt inn/ut av roten),
// ikke ved at `boundingClientRect.top` passerer en INTERN linje midt i
// et allerede-overlappende element. Listen ble dermed hengende på
// "ikke nådd" gjennom hele resten av scrollingen forbi grensen. Vanlig
// scroll-lytter med direkte `getBoundingClientRect()`-sjekk gir korrekt,
// kontinuerlig oppdatering.
//
// ANNET funn i scratch, samme runde: en HARDKODET grense (112px, lånt
// fra `scroll-mt-28` andre steder i filen) var selv FEIL -- headeren
// sin faktiske høyde målte 141.5px for denne runden, ikke 112. Headeren
// sitt innhold (rundenavn/dato/klokkeslett) kan pakkes til flere linjer
// avhengig av runden, så høyden er reelt DYNAMISK, ikke en konstant --
// en ny hardkodet gjetning (f.eks. 160px) ville bare flyttet samme
// sårbarhet til en annen runde med enda lenger innhold. Målt LIVE i
// stedet, se `getHeaderHeight()` under.
const [playerListReached, setPlayerListReached] = useState(false)
useEffect(() => {
if (!mapExpanded) return
function check() {
const el = playerListRef.current
if (!el) return
setPlayerListReached(el.getBoundingClientRect().top <= getHeaderHeight() + SCROLL_GAP_PX)
}
check()
window.addEventListener("scroll", check, { passive: true })
window.addEventListener("resize", check)
return () => {
window.removeEventListener("scroll", check)
window.removeEventListener("resize", check)
}
}, [mapExpanded])
// Den flytende knappen ble opprinnelig styrt av `mapExpanded` alene --
// FEIL, funnet av bruker 2026-08-20: den IKKE-utvidede ("compact")
// visningen viser OGSÅ hele hindringslisten (ADR-083, alle hindringer,
// ikke bare nærmeste), som på hull med mange hindringer alene kan bli
// høy nok til å dytte den vanlige, side-flyt-plasserte knappen utenfor
// synlig område -- UTEN at banekartet noensinne ble utvidet. Riktig
// signal er derfor ikke "er kartet utvidet", men "er selve
// side-flyt-knappen faktisk synlig akkurat nå" -- målt direkte, samme
// mønster som `playerListReached` over (ikke IntersectionObserver, av
// samme grunn som der: unngår enhver antakelse om når komponenten
// regnes som "utvidet" eller ikke -- fungerer likt uansett ÅRSAK til at
// knappen er utenfor synlig område).
const inFlowButtonRef = useRef<HTMLDivElement>(null)
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
useEffect(() => {
@ -6825,10 +7198,9 @@ function PlayerHoleCards({
measureShot={
player.isSelf && !readOnly ? (
<ShotMeasurementEntry
shotsUrl={`/rounds/${roundId}/participants/${player.id}/holes/${currentHole}/shots`}
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
targetPointsUrl={`/rounds/${roundId}/holes/${currentHole}/target-points`}
roundId={roundId}
holeNumber={currentHole}
owner={{ kind: "participant", id: player.id }}
ownBagClubs={ownBagClubs}
readOnly={readOnly}
variant="badge"
@ -6908,17 +7280,47 @@ function PlayerHoleCards({
/>
)}
{/* Kompakt "Score"-hurtigknapp (CompactScoreFab, redesignet
2026-08-23 -- se CHANGELOG). Erstatter det tidligere fullbredde-
bunnbanneret, som hadde tre runder med skjul/vis-bugs (#141/142,
samt en fjerde runde der den ble hengende synlig over
Kommentarer-seksjonen). En liten, hjørne-forankret FAB trenger
ikke den samme skjul-igjen-mekanismen -- den er liten nok til at
den ikke dekker nevneverdig over innhold, uansett hvor langt ned
siden brukeren har scrollet. Vises alene når
`!inFlowButtonVisible` (den vanlige, side-flyt-plasserte knappen
er utenfor synlig område). */}
<CompactScoreFab visible={!inFlowButtonVisible} onClick={scrollToPlayerList} />
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
logikk rettet samme dag) -- vises når side-flyt-knappen over
IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
OG spillerlisten fortsatt er utenfor synlig område (se
`playerListReached` -- uten denne dekket den delvis over
spillerkort 1 selv etter at brukeren allerede hadde scrollet
dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
her setter transform/filter, det er trygt), gradient-scrim +
skygge løfter den visuelt over innholdet under,
`env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
kanten telefoner med gest-navigasjon. `pointer-events-none`
selve scrim-sonen når skjult/i overgang, slik at den aldri
blokkerer klikk innhold bak. */}
<div
aria-hidden={inFlowButtonVisible || playerListReached}
className={cn(
"fixed inset-x-0 bottom-0 z-30 px-5 pb-[calc(0.875rem+env(safe-area-inset-bottom))] pt-8 transition-all duration-300 ease-out",
"bg-gradient-to-t from-background via-background/90 to-transparent",
!inFlowButtonVisible && !playerListReached
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-full opacity-0",
)}
>
<div
className={cn(
"mx-auto w-full max-w-3xl",
!inFlowButtonVisible && !playerListReached ? "pointer-events-auto" : "pointer-events-none",
)}
>
<div className="rounded-2xl shadow-xl shadow-black/25">
<RegisterScoreButton
onClick={scrollToPlayerList}
/>
</div>
</div>
</div>
{/* Gir siste innhold på siden rom til å scrolle klar av den flytende
knappen i stedet for å bli permanent skjult bak den -- samme
betingelse som selve knappen. */}
{!inFlowButtonVisible && !playerListReached && <div aria-hidden="true" className="h-20" />}
</div>
)
}

View file

@ -22,7 +22,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Crown, Flag as FlagIcon, MessageSquare, Target, 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"
@ -49,16 +49,12 @@ type ApiLeaderboardEntry = {
net_score_to_par: number | null
total_points: number | null
holes: ApiLeaderboardHole[]
// "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av
// huntNettoToPar() til å regne ut hunt-visningens tall klientsidig.
course_handicap: number | null
}
type ApiLeaderboard = {
holes_planned: number
completed: boolean
entries: ApiLeaderboardEntry[]
net_display_style: "standard" | "hunt"
}
type ApiRoundParticipant = {
@ -354,10 +350,6 @@ type IndividualPlayer = {
finished?: boolean
gross: (number | null)[]
holePoints?: (number | null)[]
// "Jakter Jesper" (migrasjon 093) -- rå course handicap, brukt av
// huntNettoToPar() (i stedet for strokesReceived, som er gradvis/
// partial-allokert). Samme null-betydning som hcpIndex/strokesReceived.
courseHandicap: number | null
}
function hasHcp(p: IndividualPlayer): boolean {
@ -393,19 +385,6 @@ function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
return diff
}
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- trekker fra HELE
// spillehandicapet med én gang i stedet for kun de slagene som er "tjent
// inn" for de spilte hullene (nettoToPar over). Konvergerer til NØYAKTIG
// samme tall som nettoToPar når alle 18 hull er spilt.
function huntNettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
if (p.courseHandicap === null) return null
let diff = 0
p.gross.forEach((g, i) => {
if (g !== null) diff += g - holes[i].par
})
return diff - p.courseHandicap
}
function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null {
if (!hasHcp(p)) return null
let pts = 0
@ -425,7 +404,6 @@ function evalMetric(
metric: MetricKey,
pointsUnit: string,
holes: Hole[],
netDisplayStyle: "standard" | "hunt" = "standard",
): {
rankable: boolean
sortVal: number
@ -438,7 +416,7 @@ function evalMetric(
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
}
if (metric === "netto") {
const v = netDisplayStyle === "hunt" ? huntNettoToPar(p, holes) : nettoToPar(p, holes)
const v = nettoToPar(p, holes)
if (v === null) return { rankable: false, sortVal: 0, higherWins: false, reason: "Ingen HCP registrert", pill: <NoData /> }
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
}
@ -466,7 +444,6 @@ function IndividualBoard({
pointsUnit = "p",
holes,
commentsHref,
netDisplayStyle = "standard",
}: {
players: IndividualPlayer[]
metrics: MetricKey[]
@ -474,13 +451,11 @@ function IndividualBoard({
pointsUnit?: string
holes: Hole[]
commentsHref: string
// "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style.
netDisplayStyle?: "standard" | "hunt"
}) {
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
const [openId, setOpenId] = useState<string | null>(null)
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes, netDisplayStyle) }))
const evaluated = players.map((p) => ({ player: p, ...evalMetric(p, metric, pointsUnit, holes) }))
const rankable = evaluated.filter((e) => e.rankable)
const unranked = evaluated.filter((e) => !e.rankable)
rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal))
@ -1581,7 +1556,6 @@ function buildIndividualPlayers(
finished: false,
gross,
holePoints,
courseHandicap: e.course_handicap,
}
})
}
@ -1828,14 +1802,6 @@ export function RoundLeaderboard({
return (
<>
<h2 className="text-base font-bold text-foreground">Stilling</h2>
{leaderboard.net_display_style === "hunt" && (
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
Jakter Jesper: alle starter minus spillehandicapet sitt -- lavest sluttsum vinner.
</p>
</div>
)}
<IndividualBoard
players={players}
metrics={config.metrics}
@ -1843,7 +1809,6 @@ export function RoundLeaderboard({
pointsUnit={config.pointsUnit}
holes={holes}
commentsHref={commentsHref}
netDisplayStyle={leaderboard.net_display_style}
/>
</>
)

View file

@ -40,8 +40,6 @@ type ApiRoundSeriesPoint = {
type ApiStatsSummary = {
rounds_completed: number
rounds_completed_personal: number
rounds_completed_tournament: number
avg_score_to_par: number | null
avg_putts_per_18: number | null
putts_tracked_rounds: number
@ -837,9 +835,6 @@ export function RoundsStatsSummary() {
<>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
Aggregert over {s.rounds_completed} {s.rounds_completed === 1 ? "fullført runde" : "fullførte runder"}
{s.rounds_completed_tournament > 0 && (
<> ({s.rounds_completed_personal} frittstående + {s.rounds_completed_tournament} turnering)</>
)}
{p.rounds_completed > 0 && (
<>
{" "}

View file

@ -16,7 +16,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Target, Trophy } from "lucide-react"
import { ArrowLeft, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
type ApiEntry = {
@ -37,11 +37,6 @@ type ApiLeaderboard = {
session_id: string
format: string
entries: ApiEntry[]
// "Jakter Jesper" (migrasjon 093, del 2 av ADR-106) -- backend har
// allerede byttet net_score/net_to_par til hunt-semantikk når aktiv,
// ingen klientsidig utregning nødvendig her (ulikt round-leaderboard.tsx,
// som regner netto selv). Kun brukt til å vise en forklarende banner.
net_display_style: "standard" | "hunt"
}
const FORMAT_LABELS: Record<string, string> = {
@ -257,15 +252,6 @@ export function SessionIndividualLeaderboard({
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
</div>
{data.net_display_style === "hunt" && (
<div className="flex items-start gap-3 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3">
<Target aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-primary" />
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
Jakter Jesper: alle starter minus spillehandicapet sitt -- lavest sluttsum vinner.
</p>
</div>
)}
{data.entries.length === 0 ? (
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.

View file

@ -1,424 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { ChevronDown, Plus, Ruler, Trash2 } from "lucide-react"
import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet"
import { bearingDegrees } from "@/lib/geo"
import { midpoint } from "@/lib/hole-geometry"
import { cn } from "@/lib/utils"
/**
* Slag-for-slag GPS-avstandsmåling (ADR-048), UI-laget som binder
* `ShotMeasurementSheet`/`MapPointPicker` (begge helt generiske, ingen
* endring) mot et faktisk backend-endepunkt. Generalisert 2026-08-22
* (ADR-103) fra en lokal funksjon i round-detail.tsx til denne delte
* filen -- kalleren bygger URL-ene selv i stedet for at komponenten
* antar `/rounds/{roundId}/...`-formen, slik at BÅDE frittstående
* runder OG org-turneringer (helt ulike REST-stier) kan gjenbruke den
* uendret.
*/
export type ShotRecord = {
id: string
club: string
distance_meters: number
shared: boolean
start_lat: number
start_lng: number
end_lat: number
end_lng: number
}
/** De to backendene navngir delings-lenken ulikt (`shared_round_message_id`
* vs. `shared_tournament_round_message_id`, ulike måltabeller) --
* normaliseres her til ett felles `shared`-flagg, resten av feltene er
* identiske tvers av begge API-kontraktene. */
function normalizeShot(raw: Record<string, unknown>): ShotRecord {
return {
id: raw.id as string,
club: raw.club as string,
distance_meters: raw.distance_meters as number,
shared: Boolean(raw.shared_round_message_id ?? raw.shared_tournament_round_message_id),
start_lat: raw.start_lat as number,
start_lng: raw.start_lng as number,
end_lat: raw.end_lat as number,
end_lng: raw.end_lng as number,
}
}
export type ShotMeasurementEntryProps = {
/** GET (liste) + POST (opprett) -- fullt sti, f.eks.
* `/rounds/{roundId}/participants/{id}/holes/{n}/shots`. */
shotsUrl: string
/** Rundescopet base for DELETE `${x}/${id}` og POST `${x}/${id}/share`
* -- f.eks. `/rounds/{roundId}/shots`. */
shotsRoundBaseUrl: string
/** Banegeometri (utslag->green) for kartretning -- utelates helt for
* en bane uten kjent geometri, faller da tilbake til forrige-slag-
* retning (uendret atferd). */
targetPointsUrl?: string
holeNumber: number
ownBagClubs: string[]
readOnly: boolean
variant: "inline" | "badge"
label?: string
}
export function ShotMeasurementEntry({
shotsUrl,
shotsRoundBaseUrl,
targetPointsUrl,
holeNumber,
ownBagClubs,
readOnly,
variant,
label,
}: ShotMeasurementEntryProps) {
const [open, setOpen] = useState(false)
// Brukertilbakemelding 2026-08-08: "jeg burde jo se slaget selv, selv om
// det ikke er delt" -- merkelappen viste tidligere KUN et antall, ingen
// måte å se kølle/avstand for egne, private slag. Lagrer nå hele listen
// (ikke bare .length), viser den i en utvidbar liste, med sletting av et
// feilmålt slag.
const [shots, setShots] = useState<ShotRecord[]>([])
const [listOpen, setListOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [shareError, setShareError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
fetch(shotsUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: Record<string, unknown>[]) => {
if (!cancelled) setShots(data.map(normalizeShot))
})
.catch(() => {})
return () => {
cancelled = true
}
}, [shotsUrl])
const count = shots.length
// Ekte banegeometri (2026-08-21, brukerønske) -- når hullet har BÅDE
// utslags- OG green-koordinater kjent (samme datakilde som
// HoleTargetDistance/HoleDiagram bruker), skal kartretningen ALLTID
// følge utslag->green, ikke gjettes fra spillerens forrige slag (som
// kan avvike, f.eks. en slice/hook).
const [courseBearing, setCourseBearing] = useState<number | undefined>(undefined)
useEffect(() => {
let cancelled = false
setCourseBearing(undefined)
if (!targetPointsUrl) return
fetch(targetPointsUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((points: { poi_type: string; location: string | null; latitude: number; longitude: number }[]) => {
if (cancelled) return
const middle = points.find((p) => p.poi_type === "green" && p.location === "middle")
const teeFront = points.find((p) => p.poi_type === "tee_front")
const teeBack = points.find((p) => p.poi_type === "tee_back")
const teeRef =
teeFront && teeBack
? midpoint({ lat: teeFront.latitude, lng: teeFront.longitude }, { lat: teeBack.latitude, lng: teeBack.longitude })
: teeFront
? { lat: teeFront.latitude, lng: teeFront.longitude }
: teeBack
? { lat: teeBack.latitude, lng: teeBack.longitude }
: null
setCourseBearing(
teeRef && middle ? bearingDegrees(teeRef, { lat: middle.latitude, lng: middle.longitude }) : undefined,
)
})
.catch(() => {
if (!cancelled) setCourseBearing(undefined)
})
return () => {
cancelled = true
}
}, [targetPointsUrl])
// Kartretning ved slagmåling (brukerønske 2026-08-10, utvidet
// 2026-08-21): "opp på skjermen" følger banens EGEN spillretning
// (utslag->green, courseBearing over) når den er kjent -- ellers
// faller tilbake til spillerens EGEN gangretning, regnet ut fra det
// SISTE tidligere målte slaget på dette hullet (start→slutt-retningen).
// `undefined` (ingen rotasjon regnet ut ennå) for det aller første
// slaget på et hull uten kjent banegeometri -- MapPointPicker faller
// da selv tilbake til enhetens GPS-heading/nord.
const initialBearing = useMemo(() => {
if (typeof courseBearing === "number") return courseBearing
const previous = shots[shots.length - 1]
if (!previous) return undefined
return bearingDegrees(
{ lat: previous.start_lat, lng: previous.start_lng },
{ lat: previous.end_lat, lng: previous.end_lng },
)
}, [shots, courseBearing])
async function deleteShot(shotId: string) {
const res = await fetch(`${shotsRoundBaseUrl}/${shotId}`, { method: "DELETE", credentials: "include" })
if (res.ok) {
setShots((prev) => prev.filter((s) => s.id !== shotId))
}
}
// Reell bug funnet 2026-08-08: en avvist innsending (f.eks. serverens
// distance_meters > 0-sjekk, hvis brukeren rakk å målt begge punkter for
// fort) lukket tidligere arket STILLE -- ingen feilmelding, brukeren så
// aldri hvorfor ingenting ble lagret/delt. Viser nå en reell feil og
// holder arket åpent i stedet for å anta suksess.
async function submitShot(result: {
club: string
distanceMeters: number
startMethod: "gps" | "map_tap"
startLat: number
startLng: number
endMethod: "gps" | "map_tap"
endLat: number
endLng: number
share: boolean
shareText: string
}) {
setSubmitError(null)
setSubmitting(true)
try {
const res = await fetch(shotsUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
club: result.club,
distance_meters: result.distanceMeters,
start_method: result.startMethod,
start_lat: result.startLat,
start_lng: result.startLng,
end_method: result.endMethod,
end_lat: result.endLat,
end_lng: result.endLng,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const detail = body?.detail
const message =
typeof detail === "string"
? detail
: typeof detail?.message === "string"
? detail.message
: "Kunne ikke lagre slaget. Sjekk at avstanden er gyldig (over 0 meter) og prøv igjen."
setSubmitError(message)
return
}
let shot = normalizeShot(await res.json())
setShareError(null)
if (result.share) {
const shareRes = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: result.shareText }),
})
// Reell bug funnet 2026-08-08: dette svaret ble aldri sjekket -- en
// avvist deling (f.eks. en driftsfeil) lot slaget lagres som om alt
// gikk bra, ingen feilmelding, ingen melding i feeden. Slaget er
// uansett lagret på dette tidspunktet -- arket lukkes derfor alltid
// her (unngår en duplikat-innsending ved gjentatt "Lagre slag"), og
// en mislykket deling vises separat ved siden av merkelappen i
// stedet for å holde arket åpent.
if (shareRes.ok) {
shot = normalizeShot(await shareRes.json())
} else {
setShareError("Slaget ble lagret, men delingen feilet. Prøv å dele på nytt fra listen.")
}
}
setShots((prev) => [...prev, shot])
setOpen(false)
} catch {
setSubmitError("Klarte ikke å nå serveren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function shareExisting(shot: ShotRecord) {
const res = await fetch(`${shotsRoundBaseUrl}/${shot.id}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ body: `${shot.club}, ${shot.distance_meters} m hull ${holeNumber}` }),
})
if (res.ok) {
const updated = normalizeShot(await res.json())
setShots((prev) => prev.map((s) => (s.id === shot.id ? updated : s)))
setShareError(null)
} else {
setShareError("Delingen feilet. Prøv igjen om litt.")
}
}
if (readOnly) {
// Skrivebeskyttet visning (fullført/eldre runde): fortsatt nyttig å SE
// egne målte slag, bare uten å kunne måle/dele/slette nye.
if (count === 0) return null
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-dashed border-border bg-background px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-3.5 shrink-0" aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
{listOpen && <ShotList shots={shots} onDelete={undefined} onShare={undefined} />}
</div>
)
}
const addLabel = label ? `${label}: Mål et slag` : "Mål et slag"
return (
<div className={cn("flex flex-col gap-1.5", variant === "inline" ? "" : "items-end")}>
<div className="flex items-center gap-1.5">
{count > 0 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setListOpen((o) => !o)
}}
aria-expanded={listOpen}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Ruler className={variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0"} aria-hidden="true" />
{label ? `${label}: ` : ""}
{count} slag målt
<ChevronDown className={cn("size-3.5 shrink-0 transition-transform", listOpen && "rotate-180")} aria-hidden="true" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setSubmitError(null)
setOpen(true)
}}
aria-label={addLabel}
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-full border border-dashed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
count > 0
? "size-11 shrink-0 border-border bg-card text-foreground hover:bg-accent/50"
: variant === "inline"
? "min-h-11 justify-center border-border bg-card px-4 text-base font-semibold text-foreground hover:bg-accent/50"
: "min-h-11 border-border bg-background px-3 text-xs font-semibold text-muted-foreground hover:bg-accent/50",
)}
>
<Plus className={cn(count > 0 ? "size-5" : variant === "inline" ? "size-5 shrink-0" : "size-3.5 shrink-0")} aria-hidden="true" />
{count === 0 && addLabel}
</button>
</div>
{shareError && (
<p role="alert" className="text-xs font-medium text-destructive">
{shareError}
</p>
)}
{listOpen && count > 0 && <ShotList shots={shots} onDelete={deleteShot} onShare={shareExisting} />}
{open && (
<ShotMeasurementSheet
holeNumber={holeNumber}
existingShotCount={count}
ownBagClubs={ownBagClubs}
initialBearing={initialBearing}
submitError={submitError}
submitting={submitting}
onSubmit={submitShot}
onCancel={() => setOpen(false)}
/>
)}
</div>
)
}
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
function shotThumbnailUrl(shot: ShotRecord): string | null {
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
}
function ShotList({
shots,
onDelete,
onShare,
}: {
shots: ShotRecord[]
onDelete?: (shotId: string) => void
onShare?: (shot: ShotRecord) => void
}) {
return (
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
{shots.map((shot) => {
const thumbUrl = shotThumbnailUrl(shot)
return (
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
{thumbUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={thumbUrl || "/placeholder.svg"}
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
className="size-14 shrink-0 rounded-lg border border-border object-cover"
/>
) : null}
<span className="flex-1 truncate font-medium text-foreground">
{shot.club} <span className="tabular-nums text-muted-foreground"> {shot.distance_meters} m</span>
</span>
{shot.shared ? (
<span className="text-[11px] font-semibold text-primary">Delt</span>
) : onShare ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onShare(shot)
}}
className="text-[11px] font-semibold text-primary underline-offset-2 hover:underline"
>
Del
</button>
) : null}
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(shot.id)
}}
aria-label={`Slett ${shot.club}, ${shot.distance_meters} meter`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-4" aria-hidden="true" />
</button>
)}
</li>
)
})}
</ul>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { ChevronDown, Clock, Flag, Pause, Play, Trophy } from "lucide-react"
import { Clock, Flag, Pause, Play, Trophy } from "lucide-react"
import { cn } from "@/lib/utils"
// --- Types (exact contract — do not change) --------------------------------
@ -119,26 +119,9 @@ const cellPad = "px-3 py-4 sm:px-4 xl:px-6 xl:py-6"
export function StrokePlayLeaderboard({
rows = MOCK_ROWS,
caption = "Resultattavle",
expandedId = null,
onToggleExpand,
renderExpanded,
}: {
rows?: LeaderboardRow[]
caption?: string
// Hull-for-hull-utvidelse (2026-08-22, ADR-101) -- IKKE en del av
// LeaderboardRow-kontrakten over ("eksakt kontrakt -- ikke endre"), et
// helt separat, valgfritt prop-sett på selve komponenten. Forelderen
// (LeaderboardTab) eier state + henting, akkurat som
// TournamentScoringWizard/ScoreTab -- denne komponenten er ren
// presentasjon. `playerName` er nøkkelen (samme identitet komponenten
// allerede bruker som React-key), siden LeaderboardRow ikke bærer noen
// egen id. Kun FELT-rader (ikke ledere) er utvidbare -- en utvidet
// ledertrad ville krevd at leaderTops-beregningen over tok høyde for
// utvidelsens ekstra høyde i den STICKY posisjoneringen, en unødvendig
// risiko når lederen(e) uansett allerede er fremhevet og synlig øverst.
expandedId?: string | null
onToggleExpand?: (row: LeaderboardRow) => void
renderExpanded?: (row: LeaderboardRow) => React.ReactNode
}) {
const reducedMotion = usePrefersReducedMotion()
const [playing, setPlaying] = useState(true)
@ -375,15 +358,7 @@ export function StrokePlayLeaderboard({
return field.map((row, i) => (
<Fragment key={`field-${row.playerName}`}>
{i === firstCutIndex && <CutLineRow totalCols={totalCols} />}
<FieldRow
row={row}
roundNumbers={roundNumbers}
striped={i % 2 === 1}
expanded={expandedId === row.playerName}
onToggleExpand={onToggleExpand ? () => onToggleExpand(row) : undefined}
renderExpanded={renderExpanded}
totalCols={totalCols}
/>
<FieldRow row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
</Fragment>
))
})()}
@ -506,18 +481,10 @@ function FieldRow({
row,
roundNumbers,
striped,
expanded,
onToggleExpand,
renderExpanded,
totalCols,
}: {
row: LeaderboardRow
roundNumbers: number[]
striped: boolean
expanded: boolean
onToggleExpand?: () => void
renderExpanded?: (row: LeaderboardRow) => React.ReactNode
totalCols: number
}) {
const status = row.totalLabel != null && isStatus(row.totalLabel)
// Hasn't played a single hole yet anywhere in the tournament (backend
@ -528,87 +495,48 @@ function FieldRow({
const notStarted = !status && row.totalLabel == null
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
const border = "border-b border-clubhouse-border"
const canExpand = Boolean(onToggleExpand && renderExpanded)
const panelId = `stroke-play-holes-${row.playerName}`
return (
<>
<tr
className={cn(bg, (status || notStarted) && "opacity-70", canExpand && "cursor-pointer")}
{...(canExpand
? {
onClick: onToggleExpand,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onToggleExpand?.()
}
},
tabIndex: 0,
role: "button",
"aria-expanded": expanded,
"aria-controls": panelId,
}
: {})}
>
<td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}>
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span>
</td>
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
<span className="flex items-center gap-1.5">
<span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
{row.playerName}
</span>
{canExpand && (
<ChevronDown
aria-hidden="true"
className={cn(
"size-4 shrink-0 text-clubhouse-muted transition-transform duration-200 xl:size-6",
expanded && "rotate-180",
)}
/>
)}
</span>
</td>
<tr className={cn(bg, (status || notStarted) && "opacity-70")}>
<td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}>
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span>
</td>
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
<span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
{row.playerName}
</span>
</td>
{status ? (
<>
<DashTd bg={bg} border={border} />
<DashTd bg={bg} border={border} />
<td className={cn(cellPad, bg, border, "text-right")}>
<span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-sm font-black uppercase tracking-wide text-clubhouse-muted xl:text-xl">
{row.totalLabel}
</span>
</td>
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
})}
</>
) : (
<>
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
<ThruTd bg={bg} border={border} thru={row.thruLabel} />
{notStarted ? (
<TeeInfoTd bg={bg} border={border} teeTime={row.nextTeeTime ?? null} startHole={row.nextStartHole ?? null} />
) : (
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
)}
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
})}
</>
)}
</tr>
{canExpand && expanded && (
<tr id={panelId} className={bg}>
<td colSpan={totalCols} className={cn(bg, border, "p-0")}>
{renderExpanded?.(row)}
{status ? (
<>
<DashTd bg={bg} border={border} />
<DashTd bg={bg} border={border} />
<td className={cn(cellPad, bg, border, "text-right")}>
<span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-sm font-black uppercase tracking-wide text-clubhouse-muted xl:text-xl">
{row.totalLabel}
</span>
</td>
</tr>
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
})}
</>
) : (
<>
<ValueTd bg={bg} border={border} label={row.todayLabel} tone={toneFrom(row.todayIsUnderPar, row.todayLabel)} size="today" />
<ThruTd bg={bg} border={border} thru={row.thruLabel} />
{notStarted ? (
<TeeInfoTd bg={bg} border={border} teeTime={row.nextTeeTime ?? null} startHole={row.nextStartHole ?? null} />
) : (
<ValueTd bg={bg} border={border} label={row.totalLabel} tone={toneFrom(row.totalIsUnderPar, row.totalLabel)} size="total" />
)}
{roundNumbers.map((n) => {
const cell = row.rounds.find((c) => c.roundNumber === n)
return <RoundTd key={n} bg={bg} border={border} cell={cell} />
})}
</>
)}
</>
</tr>
)
}

View file

@ -15,7 +15,6 @@ import {
ArrowLeft,
Camera,
Check,
Copy,
ExternalLink,
Globe,
Lock,
@ -106,21 +105,6 @@ export function TournamentPresentationPanel({
const [sponsorName, setSponsorName] = useState("")
const [sponsorUrl, setSponsorUrl] = useState("")
const [addingSponsor, setAddingSponsor] = useState(false)
// Kopier-lenke (2026-08-21) -- fant reell forvirring: organisator delte
// sin egen administrator-URL (/tournaments/{id}?org=...) i stedet for
// denne, siden lenken under kun var ren, ikke-kopierbar tekst. Samme
// kopier-mønster som JoinCodeChip i individual-tournament-detail.tsx.
const [linkCopied, setLinkCopied] = useState(false)
async function copyPublicLink() {
try {
await navigator.clipboard.writeText(`${window.location.origin}/t/${tournamentId}`)
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 1500)
} catch {
// stille -- ikke kritisk
}
}
const [sponsorError, setSponsorError] = useState<string | null>(null)
const [logoTargetId, setLogoTargetId] = useState<string | null>(null)
const [uploadingLogoFor, setUploadingLogoFor] = useState<string | null>(null)
@ -298,7 +282,7 @@ export function TournamentPresentationPanel({
return (
<div className="flex flex-col gap-1">
<div className="mb-4 flex flex-col gap-2">
<div className="mb-4 flex flex-col gap-1">
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Hvordan turneringen vises sin offentlige side (
<Link
@ -311,18 +295,6 @@ export function TournamentPresentationPanel({
</Link>
) og klubbsiden.
</p>
<button
type="button"
onClick={copyPublicLink}
className="inline-flex min-h-11 w-fit items-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
{linkCopied ? (
<Check aria-hidden="true" className="size-4 text-primary" />
) : (
<Copy aria-hidden="true" className="size-4 text-muted-foreground" />
)}
{linkCopied ? "Kopiert!" : "Kopier offentlig lenke"}
</button>
</div>
<div className="flex flex-col gap-4">

View file

@ -109,10 +109,6 @@ type ApiSession = {
start_mode: "consecutive" | "shotgun"
locked_team_ids: string[]
revealed: boolean
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- kun
// meningsfullt for format IN ('singles','fourball') OG
// scoring_mode='stroke'.
net_display_style: "standard" | "hunt"
}
// --- Norwegian display labels ----------------------------------------------
@ -882,15 +878,10 @@ function EditSessionForm({
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
const [courseId, setCourseId] = useState(session.course_id)
const [courseName, setCourseName] = useState(course?.name ?? "")
const [netDisplayStyle, setNetDisplayStyle] = useState(session.net_display_style)
const [submitting, setSubmitting] = useState(false)
const hasTime = scheduledAt.trim() !== ""
const uid = session.id
// "Jakter Jesper" (migrasjon 093) -- samme betingelse
// fetch_individual_leaderboard håndhever ved lesing (tournaments.py).
const supportsNetDisplayStyle =
(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke"
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@ -908,9 +899,6 @@ function EditSessionForm({
tee_interval_minutes:
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
course_id: courseId,
...(supportsNetDisplayStyle && netDisplayStyle !== session.net_display_style
? { net_display_style: netDisplayStyle }
: {}),
})
setSubmitting(false)
}
@ -1059,37 +1047,6 @@ function EditSessionForm({
</div>
)}
{supportsNetDisplayStyle && (
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Visningsmodus for netto-score</Label>
<div role="radiogroup" aria-label="Visningsmodus for netto-score" className="inline-flex w-fit rounded-2xl border border-border bg-background p-1">
{(
[
{ value: "standard" as const, label: "Standard" },
{ value: "hunt" as const, label: "Jakter Jesper" },
]
).map((opt) => (
<Button
key={opt.value}
type="button"
role="radio"
aria-checked={netDisplayStyle === opt.value}
variant={netDisplayStyle === opt.value ? "default" : "ghost"}
onClick={() => setNetDisplayStyle(opt.value)}
className="h-10 rounded-xl px-4 text-sm font-bold"
>
{opt.label}
</Button>
))}
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Jakter Jesper: alle starter minus spillehandicapet sitt, og hullscore vises brutto mot
par løpende -- i stedet for at slag "tjenes inn" gradvis gjennom økten. Sluttresultatet
blir identisk uansett. Standard som standard.
</p>
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
{submitting ? "Lagrer…" : "Lagre"}
@ -1872,11 +1829,6 @@ function OfficialCourseSearch({
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
} else if (code === "EXTERNAL_DATA_INVALID") {
setError(
body?.detail?.message ??
"Banedataen fra teeoff er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos teeoff før banen kan importeres.",
)
} else if (code === "DUPLICATE") {
setError("Denne banen er allerede importert til organisasjonen.")
} else {
@ -2081,11 +2033,6 @@ function InternationalCourseSearch({
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data hos GolfAPI til å importeres ennå.")
} else if (code === "EXTERNAL_DATA_INVALID") {
setError(
body?.detail?.message ??
"Banedataen fra GolfAPI er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos GolfAPI før banen kan importeres.",
)
} else if (code === "NOT_CONFIGURED") {
setError("GolfAPI er ikke konfigurert ennå.")
} else {

View file

@ -1,561 +0,0 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { cn } from "@/lib/utils"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
/**
* Rundespesifikk kommentartråd for org-turneringer (ADR-102) -- ekte
* parallell til `RoundMessages` (frittstående runder), IKKE en utvidelse
* av den eksisterende hele-turnering-brede Banter Board (ADR-025).
*
* Bevisst UTENFOR omfang i v1 (se ADR-102): ingen @-tagging (samme
* presedens som team-chat.tsx/public-tournament.tsx -- PostEngagement
* sin `roundId`-prop utelates bevisst, komposereren er en vanlig
* tekstboks), ingen sanntid/WebSocket-piggyback (denne autentiserte
* visningen har ingen WS-tilkobling i det hele tatt ennå -- eget,
* uprioritert gap). Refetches ved mount + en manuell "Oppdater"-knapp.
*/
export type ApiTournamentRoundMessage = {
id: string
tournament_round_id: string
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
}
// Et lokalt, ikke-synkronisert innlegg (offline-kø), speiler round-messages.tsx.
type PendingMessage = ApiTournamentRoundMessage & { _clientMessageId: string }
export type TournamentRoundMessagesProps = {
base: string // `/orgs/{organizationId}/tournaments/{tournamentId}`
roundId: string
currentUserId: string
isOrgAdmin: boolean
}
const ACCEPTED_IMAGE_TYPES = "image/*"
const MAX_IMAGE_HEIGHT = 300
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
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
}
function formatRelativeTime(iso: string): { label: string; title: string } {
const then = new Date(iso)
const title = then.toLocaleString("nb-NO", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
const diffMs = Date.now() - then.getTime()
const diffSec = Math.round(diffMs / 1000)
const diffMin = Math.round(diffSec / 60)
const diffHour = Math.round(diffMin / 60)
const diffDay = Math.round(diffHour / 24)
let label: string
if (diffSec < 45) label = "nå nettopp"
else if (diffMin < 60) label = `for ${diffMin} min siden`
else if (diffHour < 24) label = `for ${diffHour} t siden`
else if (diffDay === 1) label = "i går"
else if (diffDay < 7) label = `for ${diffDay} dager siden`
else
label = then.toLocaleDateString("nb-NO", {
day: "numeric",
month: "short",
})
return { label, title }
}
export function TournamentRoundMessages({ base, roundId, currentUserId, isOrgAdmin }: TournamentRoundMessagesProps) {
const messagesUrl = `${base}/rounds/${roundId}/messages`
const [messages, setMessages] = useState<ApiTournamentRoundMessage[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [body, setBody] = useState("")
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
// Egen kø-navnerom -- unngår kollisjon med en evt. fremtidig hull-score-kø
// på samme runde (gap-punkt 1, ikke koblet på ennå), selv om begge ville
// delt samme IndexedDB-database.
const messagesMatchId = `${base}:${roundId}:tournament-messages`
const [isOnline, setIsOnline] = useState(true)
const [pendingCount, setPendingCount] = useState(0)
const [syncingMessages, setSyncingMessages] = useState(false)
const [pendingMessages, setPendingMessages] = useState<PendingMessage[]>([])
const fileInputRef = useRef<HTMLInputElement | null>(null)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const load = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const res = await fetch(messagesUrl, { credentials: "include" })
if (!res.ok) throw new Error(`Status ${res.status}`)
const data: ApiTournamentRoundMessage[] = await res.json()
setMessages(data)
} catch {
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}, [messagesUrl])
useEffect(() => {
void load()
}, [load])
const flushPendingMessages = useCallback(async () => {
if (syncingMessages) return
setSyncingMessages(true)
try {
const outcomes = await flushQueue(messagesMatchId)
if (outcomes.length === 0) return
for (const o of outcomes) {
const sentBody = o.entry.body as Record<string, unknown>
const clientMessageId = sentBody.client_message_id as string | undefined
setPendingMessages((prev) => {
const match = prev.find((m) => m._clientMessageId === clientMessageId)
if (match?.image_url) URL.revokeObjectURL(match.image_url)
return prev.filter((m) => m._clientMessageId !== clientMessageId)
})
}
const failed = outcomes.filter((o) => !o.ok)
if (failed.length > 0) {
setPostError(
`${failed.length} ${failed.length === 1 ? "kommentar" : "kommentarer"} kunne ikke synkroniseres: ${failed[0].message}`,
)
}
setPendingCount(await queueCount(messagesMatchId))
await load()
} finally {
setSyncingMessages(false)
}
}, [messagesMatchId, syncingMessages, load])
useEffect(() => {
setIsOnline(navigator.onLine)
function handleOnline() {
setIsOnline(true)
void flushPendingMessages()
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)
return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesMatchId])
useEffect(() => {
let cancelled = false
queueCount(messagesMatchId)
.then((count) => {
if (cancelled) return
setPendingCount(count)
if (count > 0 && navigator.onLine) void flushPendingMessages()
})
.catch(() => {})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesMatchId])
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview)
}
}, [imagePreview])
const clearSelectedImage = useCallback(() => {
setImageFile(null)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
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) => {
if (prev) URL.revokeObjectURL(prev)
return URL.createObjectURL(file)
})
}
const trimmedBody = body.trim()
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
async function queueMessageOffline(fields: Record<string, string | Blob>, clientMessageId: string) {
await enqueueWrite({
url: messagesUrl,
method: "POST",
body: fields,
matchId: messagesMatchId,
isMultipart: true,
})
const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null
setPendingMessages((prev) => [
{
id: `pending-${clientMessageId}`,
tournament_round_id: roundId,
author_user_id: currentUserId,
author_display_name: "Du",
body: trimmedBody || null,
image_url: pendingImageUrl,
created_at: new Date().toISOString(),
reactions: [],
comment_count: 0,
_clientMessageId: clientMessageId,
},
...prev,
])
setPendingCount((c) => c + 1)
setBody("")
clearSelectedImage()
}
async function handlePost(e: React.FormEvent) {
e.preventDefault()
if (!canPost) return
setPosting(true)
setPostError(null)
const clientMessageId = crypto.randomUUID()
const fields: Record<string, string | Blob> = { client_message_id: clientMessageId }
if (trimmedBody.length > 0) fields.body = trimmedBody
if (imageFile) fields.image = imageFile
if (!navigator.onLine) {
await queueMessageOffline(fields, clientMessageId)
setPosting(false)
return
}
try {
const formData = new FormData()
for (const [k, v] of Object.entries(fields)) formData.append(k, v)
const res = await fetch(messagesUrl, {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) {
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
return
}
const created: ApiTournamentRoundMessage = await res.json()
setMessages((prev) => [created, ...prev])
setBody("")
clearSelectedImage()
} catch {
await queueMessageOffline(fields, clientMessageId)
} finally {
setPosting(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
setPostError(null)
try {
const res = await fetch(`${messagesUrl}/${id}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) throw new Error(`Status ${res.status}`)
setMessages((prev) => prev.filter((m) => m.id !== id))
} catch {
setPostError("Kunne ikke slette kommentaren. Prøv igjen.")
} finally {
setDeletingId(null)
}
}
return (
<section className="flex flex-col gap-4" aria-label="Kommentarer og bilder fra turneringsrunden">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-bold text-foreground">Kommentarer</h3>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="inline-flex h-9 items-center gap-1.5 rounded-lg px-2.5 text-xs font-semibold text-muted-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", loading && "animate-spin")} />
Oppdater
</button>
</div>
{/* Composer */}
<form
onSubmit={handlePost}
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5"
>
<label htmlFor="tournament-round-message-body" className="sr-only">
Skriv en kommentar
</label>
<textarea
id="tournament-round-message-body"
value={body}
onChange={(e) => {
setBody(e.target.value)
if (postError) setPostError(null)
}}
placeholder="Skriv en kommentar..."
rows={3}
className="w-full resize-y rounded-xl border border-border bg-background px-3 py-2.5 text-base text-foreground outline-none transition-all duration-200 ease-in-out placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{imagePreview && (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted p-2.5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview || "/placeholder.svg"}
alt="Forhåndsvisning av valgt bilde"
className="size-14 shrink-0 rounded-lg object-cover"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{imageFile?.name}
</span>
<button
type="button"
onClick={clearSelectedImage}
className="inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground outline-none transition-all duration-200 ease-in-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<X aria-hidden="true" className="size-4" />
Fjern
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<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"
disabled={!canPost}
className="inline-flex h-11 min-w-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-base font-medium text-primary-foreground outline-none transition-all duration-200 ease-in-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{posting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"
/>
) : (
<Send aria-hidden="true" className="size-5" />
)}
{posting ? "Poster..." : "Post"}
</button>
</div>
{postError && (
<p role="alert" className="text-sm font-medium text-destructive">
{postError}
</p>
)}
</form>
{!isOnline && (
<div className="flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
<span>Du er offline. Kommentaren lagres lokalt og postes automatisk når du er tilbake nett.</span>
</div>
)}
{pendingCount > 0 && (
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
<span>
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} synkronisering.
</span>
{isOnline && (
<button
type="button"
onClick={() => void flushPendingMessages()}
disabled={syncingMessages}
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncingMessages && "animate-spin")} />
Synkroniser
</button>
)}
</div>
)}
{loading ? (
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
<span
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<span className="sr-only">Laster kommentarer...</span>
</div>
) : loadError ? (
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
{loadError}
</p>
) : pendingMessages.length === 0 && messages.length === 0 ? (
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
Ingen kommentarer ennå vær den første til å dele et bilde eller en kommentar fra runden.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{[...pendingMessages, ...messages].map((m) => {
const isPending = "_clientMessageId" in m
const canDelete = !isPending && (m.author_user_id === currentUserId || isOrgAdmin)
const isDeleting = deletingId === m.id
const { label, title } = formatRelativeTime(m.created_at)
return (
<li key={m.id} className="flex flex-col gap-3 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-semibold text-foreground">
{m.author_display_name}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
<Clock aria-hidden="true" className="size-3.5 shrink-0" />
Venter synk
</span>
) : (
<time
dateTime={m.created_at}
title={title}
className="text-sm font-medium text-muted-foreground"
>
{label}
</time>
)}
</div>
{canDelete && (
<button
type="button"
onClick={() => handleDelete(m.id)}
disabled={isDeleting}
className="inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-destructive outline-none transition-all duration-200 ease-in-out hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{isDeleting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-destructive/30 border-t-destructive"
/>
) : (
<Trash2 aria-hidden="true" className="size-4" />
)}
Slett
</button>
)}
</div>
{m.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.image_url || "/placeholder.svg"}
alt={`Bilde delt av ${m.author_display_name}`}
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
className="w-full rounded-xl object-cover"
/>
)}
{m.body && (
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">{m.body}</p>
)}
{!isPending && (
<PostEngagement
apiBase={`${messagesUrl}/${m.id}`}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={isOrgAdmin}
/>
)}
</li>
)
})}
</ul>
)}
</section>
)
}
export default TournamentRoundMessages

View file

@ -8,7 +8,6 @@
// egen rad"-mønster som tournament-detail.tsx allerede bruker for join_code.
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { TournamentDetail } from "@/components/tournament-detail"
import { IndividualTournamentDetail } from "@/components/individual-tournament-detail"
@ -21,30 +20,13 @@ export function TournamentRouter({
tournamentId: string
tournamentName: string
}) {
const router = useRouter()
const [formatType, setFormatType] = useState<"team" | "individual" | null>(null)
const [error, setError] = useState(false)
// Ikke-innlogget besøkende på denne organisator-URL-en (f.eks. en delt
// /tournaments/{id}?org=...-lenke) -- funnet 2026-08-21: den generiske
// feil-fallback-en under viste tidligere ALLTID Cup/lag-format-
// administrasjonsskjermen (samme "trygt mot krasj"-begrunnelse som før),
// helt uavhengig av turneringens faktiske format og helt uavhengig av
// ÅRSAKEN til feilen -- en anonym besøkende på en individuell turnering
// fikk et forvirrende, ødelagt Ryder Cup-oppsett-skjermbilde i stedet for
// en ren "logg inn"-beskjed. Skiller nå eksplisitt 401 (ikke innlogget)
// fra andre feil, og sender i stedet videre til den ekte offentlige siden.
const [notAuthenticated, setNotAuthenticated] = useState(false)
useEffect(() => {
let cancelled = false
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" })
.then((res) => {
if (res.status === 401) {
if (!cancelled) setNotAuthenticated(true)
return Promise.reject()
}
return res.ok ? res.json() : Promise.reject()
})
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((list: { id: string; format_type: string }[]) => {
if (cancelled) return
const mine = list.find((t) => t.id === tournamentId)
@ -58,27 +40,10 @@ export function TournamentRouter({
}
}, [organizationId, tournamentId])
useEffect(() => {
if (notAuthenticated) router.replace(`/t/${tournamentId}`)
}, [notAuthenticated, tournamentId, router])
if (notAuthenticated) {
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 border-primary/20 border-t-primary"
/>
</div>
)
}
if (error) {
// Samme trygge fallback som resten av appen: en ukjent/util­gjengelig
// turnering skal ikke krasje -- TournamentDetail sin egen feilhåndtering
// (join_code/teams/players) tar over og viser en tydelig feilmelding.
// (Denne grenen er nå kun for EKTE feil hos en innlogget bruker --
// 401/ikke-innlogget håndteres over, IKKE lenger her.)
return (
<TournamentDetail organizationId={organizationId} tournamentId={tournamentId} tournamentName={tournamentName} />
)

File diff suppressed because one or more lines are too long

View file

@ -69,12 +69,6 @@ Dette er skjermens største enkeltløft.
`text-sm muted-foreground`. Fast rad-høyde (`min-h-[Npx]` + `truncate`)
så rader med ulik navnelengde blir like høye (DS-mønsteret fra
2026-07-26).
> **Notert 2026-08-22, IKKE designet/bygget ennå** (se
> FEATURE_BACKLOG.md "Inkrementell deltaker-id (startnummer) for
> turneringer"): bruker vil at hver deltaker skal ha et fortløpende
> tildelt startnummer, synlig her (og på startlister). Foreløpig
> ingen avgjørelse om plassering i denne raden (f.eks. foran navnet)
> -- avklares når selve funksjonen designes.
- **Hull = kolonner**, horisontalt scrollbare. Kolonnetopp:
hull-nr (`font-bold`), Par og evt. HCP-indeks under i
`text-sm muted-foreground`. Aktivt/valgt hull markeres med `bg-primary`

View file

@ -6,14 +6,10 @@ har et oppgitt klokkeslett/starthull, vise det i stedet. Kaller den ekte
router-funksjonen direkte (samme mønster som test_order_of_merit.py).
"""
from fastapi import HTTPException
import pytest
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
individual_leaderboard,
leaderboard_participant_round_holes,
update_hole as tournament_update_hole,
)
@ -123,48 +119,3 @@ async def test_all_not_started_has_no_leader(pool):
assert entries[0].total_label is None
assert entries[0].position is None
assert entries[0].is_leader is False
async def test_leaderboard_round_id_and_holes_endpoint(pool):
"""ADR-101: RoundCellOut.tournament_round_id skal peke til riktig runde,
og det nye tournament_participant_id-nøkkede hull-endepunktet (bygget for
leaderboardets hull-for-hull-utvidelse) skal returnere de samme hullene
som den eksisterende round_participant_id-nøkkede varianten -- kun
oppslagsveien er ny."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id = await create_tournament(org_id, name="Hull-for-hull-turnering")
course_id = await create_course(org_id, name="Testbanen")
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
tournament_id,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
player_id = await create_player(org_id, display_name="Spiller Ett")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
await _score_all_holes(tournament_id, round_id, rp_id, org_id, owner_id, score=5)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
assert len(entry.rounds) == 1
assert entry.rounds[0].tournament_round_id == round_id
holes = await leaderboard_participant_round_holes(
tournament_id, tp_id, round_id, organization_id=org_id,
)
assert len(holes) == 18
assert all(h.gross_strokes == 5 for h in holes)
with pytest.raises(HTTPException) as exc_info:
await leaderboard_participant_round_holes(
tournament_id, tp_id, "00000000-0000-0000-0000-000000000000", organization_id=org_id,
)
assert exc_info.value.status_code == 404

View file

@ -1,221 +0,0 @@
"""
Visningsmodus for netto-scoring ("Jakter Jesper", migrasjon 092,
2026-08-23). IKKE en ny scoring_method -- sluttallet er matematisk
identisk med stroke_net sitt netto-til-par (`net_total - par_played`).
Det eneste som faktisk er nytt er den LØPENDE visningen: standard
"tjener inn" handicap-slag gradvis per slagindeks etter hvert som hull
spilles, mens 'hunt' trekker fra HELE spillehandicapet med én gang og
viser brutto-til-par-differansen løpende. Se
`_attach_stroke_play_columns` i individual_tournaments.py.
"""
from fastapi import HTTPException
import pytest
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
individual_leaderboard,
update_hole as tournament_update_hole,
)
from app.routers.tournaments import TournamentUpdate, update_tournament
from app.auth import CurrentUser
from tests.conftest import (
add_membership,
create_org,
create_org_hole,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> None:
user = CurrentUser(user_id=user_id)
for n, gross in enumerate(scores, start=1):
await tournament_update_hole(
tournament_id, round_id, rp_id, n,
TournamentHoleUpdate(gross_strokes=gross, expected_version=None),
organization_id=org_id, user=user,
)
async def _setup_stroke_net_round(org_id, method="stroke_net"):
tournament_id = await create_tournament(org_id, name="Jakter Jesper-turnering")
course_id = await create_course(org_id, name="Hunt Links")
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
tournament_id, method,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, course_id, tee_id, round_id
async def _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, name, playing_handicap):
player_id = await create_player(org_id, display_name=name)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament_round_participant SET course_handicap = $1, playing_handicap = $1 WHERE id = $2",
playing_handicap, rp_id,
)
return tp_id, rp_id
async def test_hunt_differs_mid_round_but_converges_at_completion(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id)
p1, rp1 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P1", 12)
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4])
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
# Standard (default): netto = 4-1=3, til par = 3-4 = -1 -- slaget er
# kun DELVIS "tjent inn" ennå (1 av 12 spilte hull).
assert by_id[p1].total_label == "-1"
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
# Hunt: gross(4) - FULLT spillehandicap(12) = -8, til par = -8-4 = -12
# -- hele handicapet trukket fra med én gang, mye "dypere" tall.
assert by_id[p1].total_label == "-12"
# Fullfør runden med par på alle resterende hull.
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 17)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
# Fortsatt -12 under hunt -- flatt hele veien når hvert hull er par.
assert by_id[p1].total_label == "-12"
# Bytt tilbake til standard nå som runden er fullført -- KONVERGERER
# til nøyaktig samme tall som hunt (selve beviset på ekvivalensen).
await update_tournament(tournament_id, TournamentUpdate(net_display_style="standard"), organization_id=org_id)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[p1].total_label == "-12"
async def test_hunt_display_style_rejected_for_non_stroke_net(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id = await create_tournament(org_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stableford' WHERE id = $1",
tournament_id,
)
with pytest.raises(HTTPException) as exc_info:
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
assert exc_info.value.status_code == 400
async def test_switching_away_from_stroke_net_rejected_while_hunt_active(pool):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id)
await update_tournament(tournament_id, TournamentUpdate(net_display_style="hunt"), organization_id=org_id)
with pytest.raises(HTTPException) as exc_info:
await update_tournament(
tournament_id, TournamentUpdate(scoring_method="stableford"), organization_id=org_id
)
assert exc_info.value.status_code == 400
# Men lov når begge felt endres sammen i samme kall.
out = await update_tournament(
tournament_id,
TournamentUpdate(scoring_method="stableford", net_display_style="standard"),
organization_id=org_id,
)
assert out.scoring_method == "stableford"
assert out.net_display_style == "standard"
async def test_lowest_playing_hcp_tiebreak_resolves_field_tie(pool):
"""Speiler `test_lowest_hcp_resolves_field_tie_but_not_winner`
(test_tiebreak.py) sitt oppsett nøyaktig, men med `stroke_gross`
(handicap påvirker IKKE selve rangeringen der -- kun tiebreaken --
slik at identiske bruttoscorer faktisk BLIR en reell uavgjort å
løse). `stroke_net` ville gjort dette meningsløst: ulikt
spillehandicap med identisk brutto gir ulik netto, altså ingen
uavgjort i det hele tatt å teste tiebreaken ."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id, method="stroke_gross")
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method="none", tiebreak_field_method="lowest_playing_hcp"),
organization_id=org_id,
)
leader, rp_leader = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "Leader", 5)
p2, rp2 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P2", 8)
p3, rp3 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P3", 20)
await _score_holes(tournament_id, round_id, rp_leader, org_id, owner_id, [3] * 18) # klar leder
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) # uavgjort brutto med p3
await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, [4] * 18) # uavgjort brutto med p2
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[leader].position == "1"
assert by_id[p2].position == "2" # lavere spillehandicap (8 < 20) -> foran
assert by_id[p3].position == "3"
async def test_lowest_playing_hcp_missing_value_sorts_last(pool):
"""Speiler `lowest_hcp` sin regel: manglende data kan ALDRI vinne en
tiebreak ved å mangle -- sorteres sist. `stroke_gross`, samme
begrunnelse som testen over -- spillehandicap er ikke påkrevd for
brutto-scoring (`_SCORING_METHODS_REQUIRING_HANDICAP` gjelder ikke
her), et reelt NULL-scenario for tiebreak-metrikken kan faktisk
oppstå (ulikt stroke_net, der manglende handicap gjør at netto-
tallet aldri blir beregnet i det hele tatt -- deltakeren havner da
helt utenfor rangeringen, ikke i en tiebreak-gruppe)."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round_id = await _setup_stroke_net_round(org_id, method="stroke_gross")
# Kun to deltakere, begge uavgjort for 1.-plass -- tiebreak_winner_method
# er den relevante innstillingen her (rank==1), ikke feltmetoden.
await update_tournament(
tournament_id,
TournamentUpdate(tiebreak_winner_method="lowest_playing_hcp", tiebreak_field_method="none"),
organization_id=org_id,
)
p1, rp1 = await _add_participant_with_playing_hcp(org_id, tournament_id, round_id, tee_id, "P1", 10)
# P2 -- ingen spillehandicap beregnet (NULL, samme som "aldri kjørt
# gjennom _compute_round_participant_handicap").
player2 = await create_player(org_id, display_name="P2")
p2 = await create_tournament_participant(org_id, tournament_id, player2)
rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id)
await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 18)
await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
by_id = {e.tournament_participant_id: e for e in entries}
assert by_id[p1].position == "1" # har en verdi -> vinner uavgjortheten
assert by_id[p2].position == "2" # mangler verdi -> sist

View file

@ -1,105 +0,0 @@
"""
"Jakter Jesper"-visningsmodus for frittstående runder (migrasjon 093, del 2
av ADR-106). Speiler tests/test_net_hunt_display.py (org-individuell) sitt
mønster og bevis: løpende tall skiller seg fra standard midt i runden, men
konvergerer eksakt ved 18 fullførte hull.
"""
from fastapi import HTTPException
import pytest
from app.auth import CurrentUser
from app.routers.rounds import HoleUpdate, RoundUpdate, get_leaderboard, get_round, update_hole, update_round
from tests.conftest import create_hole, create_participant, create_round, create_user
import app.db as app_db
async def _score(round_id, participant_id, user_id, scores: list[int]) -> None:
user = CurrentUser(user_id=user_id)
for n, gross in enumerate(scores, start=1):
await update_hole(
round_id, participant_id, n,
HoleUpdate(score=gross, expected_version=None),
user=user,
)
async def _setup_stroke_round(user_id, course_handicap=12):
async with app_db.plain_connection() as conn:
round_id = await create_round(conn, user_id)
participant_id = await create_participant(
conn, round_id, user_id=user_id, is_owner=True, course_handicap_snapshot=course_handicap,
)
for n in range(1, 19):
await create_hole(conn, participant_id, hole_number=n, par=4, stroke_index=n)
await conn.execute("UPDATE round SET play_format = 'stroke' WHERE id = $1", round_id)
return round_id, participant_id
async def test_hunt_differs_mid_round_but_converges_at_completion(pool):
user_id = await create_user()
round_id, participant_id = await _setup_stroke_round(user_id, course_handicap=12)
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
await _score(round_id, participant_id, user_id, [4])
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
# Standard (default): netto = 4-1=3, til par = 3-4 = -1.
assert out.my_net_score_to_par == -1
await update_round(round_id, RoundUpdate(net_display_style="hunt"), user=CurrentUser(user_id=user_id))
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
# Hunt: gross(4) - FULLT course handicap(12) = -8, til par = -8-4 = -12.
assert out.my_net_score_to_par == -12
lb = await get_leaderboard(round_id, user=CurrentUser(user_id=user_id))
assert lb.net_display_style == "hunt"
assert lb.entries[0].net_score_to_par == -12
assert lb.entries[0].course_handicap == 12
# Fullfør runden med par på alle resterende hull -- flatt hele veien
# under hunt (alltid par -> gross-til-par alltid 0).
await _score(round_id, participant_id, user_id, [4] * 17)
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
assert out.my_net_score_to_par == -12
# Bytt tilbake til standard nå som runden er fullført -- KONVERGERER
# til nøyaktig samme tall (selve beviset på ekvivalensen).
await update_round(round_id, RoundUpdate(net_display_style="standard"), user=CurrentUser(user_id=user_id))
out = await get_round(round_id, user=CurrentUser(user_id=user_id))
assert out.my_net_score_to_par == -12
lb = await get_leaderboard(round_id, user=CurrentUser(user_id=user_id))
assert lb.entries[0].net_score_to_par == -12
async def test_hunt_rejected_for_non_stroke_play_format(pool):
user_id = await create_user()
round_id, participant_id = await _setup_stroke_round(user_id)
async with app_db.plain_connection() as conn:
await conn.execute("UPDATE round SET play_format = 'stableford' WHERE id = $1", round_id)
with pytest.raises(HTTPException) as exc_info:
await update_round(round_id, RoundUpdate(net_display_style="hunt"), user=CurrentUser(user_id=user_id))
assert exc_info.value.status_code == 400
async def test_switching_away_from_stroke_rejected_while_hunt_active(pool):
user_id = await create_user()
round_id, participant_id = await _setup_stroke_round(user_id)
await update_round(round_id, RoundUpdate(net_display_style="hunt"), user=CurrentUser(user_id=user_id))
with pytest.raises(HTTPException) as exc_info:
await update_round(round_id, RoundUpdate(play_format="stableford"), user=CurrentUser(user_id=user_id))
assert exc_info.value.status_code == 400
# Men lov når begge felt endres sammen i samme kall.
out = await update_round(
round_id,
RoundUpdate(play_format="stableford", net_display_style="standard"),
user=CurrentUser(user_id=user_id),
)
assert out.play_format == "stableford"
assert out.net_display_style == "standard"

View file

@ -1,93 +0,0 @@
"""
Flight/gruppe-scoping i ScoreTab (ADR-099, 2026-08-21). list_round_participants
speiler ut tournament_round_group_id + is_self per rad, slik at frontend kan
la score-føringsskjermen default til brukerens egen utslagsgruppe i stedet for
hele feltet. is_self beregnes server-side (ikke user_id, samme is_mine-
mønster som rounds.py) for ikke å lekke andre spilleres kontokobling.
"""
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
RoundGroupIn,
SaveRoundGroupsIn,
list_round_participants,
save_round_groups,
)
from tests.conftest import (
create_org,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _setup(org_id):
tournament_id = await create_tournament(org_id, name="Flight-scoping-turnering")
course_id = await create_course(org_id, name="Flight Links")
tee_id = await create_tee(org_id, course_id, name="Gul")
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
tournament_id,
)
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, round_id, tee_id
async def test_is_self_and_group_id_reflect_viewer_and_grouping(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
me_user_id = await create_user()
other_user_id = await create_user()
me_player_id = await create_player(org_id, display_name="Meg Selv", user_id=me_user_id)
other_player_id = await create_player(org_id, display_name="En Annen", user_id=other_user_id)
me_tp_id = await create_tournament_participant(org_id, tournament_id, me_player_id)
other_tp_id = await create_tournament_participant(org_id, tournament_id, other_player_id)
me_rp_id = await create_tournament_round_participant(org_id, round_id, me_tp_id, tee_id)
other_rp_id = await create_tournament_round_participant(org_id, round_id, other_tp_id, tee_id)
# Ingen gruppe satt ennå -- begge rader skal ha tournament_round_group_id=None.
ungrouped = await list_round_participants(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=me_user_id),
)
by_id = {rp.id: rp for rp in ungrouped}
assert by_id[me_rp_id].tournament_round_group_id is None
assert by_id[other_rp_id].tournament_round_group_id is None
assert by_id[me_rp_id].is_self is True
assert by_id[other_rp_id].is_self is False
# Grupper begge i samme gruppe -- gruppe-id skal nå matche på begge rader,
# og is_self skal fortsatt kun være sann for den innloggede brukeren.
await save_round_groups(
tournament_id, round_id,
SaveRoundGroupsIn(groups=[RoundGroupIn(sequence=1, round_participant_ids=[me_rp_id, other_rp_id])]),
organization_id=org_id,
)
grouped = await list_round_participants(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=me_user_id),
)
by_id = {rp.id: rp for rp in grouped}
assert by_id[me_rp_id].tournament_round_group_id is not None
assert by_id[me_rp_id].tournament_round_group_id == by_id[other_rp_id].tournament_round_group_id
assert by_id[me_rp_id].is_self is True
assert by_id[other_rp_id].is_self is False
# Sett fra den ANDRE brukerens perspektiv: is_self bytter side.
as_other = await list_round_participants(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=other_user_id),
)
by_id = {rp.id: rp for rp in as_other}
assert by_id[me_rp_id].is_self is False
assert by_id[other_rp_id].is_self is True

View file

@ -13,7 +13,6 @@ får alltid tournament_round_group_id=NULL).
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
RoundGroupIn,
RoundParticipantTeeUpdate,
@ -31,7 +30,6 @@ from tests.conftest import (
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
@ -71,10 +69,8 @@ async def test_changing_tee_preserves_group_membership(pool):
)
assert group_id_before is not None
user_id = await create_user()
updated = await update_round_participant_tee(
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b),
organization_id=org_id, user=CurrentUser(user_id=user_id),
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b), organization_id=org_id
)
assert updated.tee_id == tee_b
assert updated.id == rp_id # samme rad, ikke slett+gjenopprett
@ -92,12 +88,11 @@ async def test_changing_tee_to_one_outside_course_is_rejected(pool):
other_course_id = await create_course(org_id, name="En annen bane")
foreign_tee_id = await create_tee(org_id, other_course_id, name="Blå")
user_id = await create_user()
with pytest.raises(HTTPException) as exc_info:
await update_round_participant_tee(
tournament_id, round_id, rp_id,
RoundParticipantTeeUpdate(tee_id=foreign_tee_id),
organization_id=org_id, user=CurrentUser(user_id=user_id),
organization_id=org_id,
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"

View file

@ -1,172 +0,0 @@
"""
GET /rounds/stats/summary skal blande org-turnering-runder inn i de samme
trend-/snitt-tallene som frittstående runder (scoring-paritet-gap punkt 3,
se FEATURE_BACKLOG.md), med kilde-antall vist separat
(`rounds_completed_personal`/`rounds_completed_tournament`) -- speiler
`app/hole_history.py` sin `times_personal`/`times_tournament`-presedens.
Speiler `test_hole_history.py` sitt integrasjonsmønster (ekte router-
funksjoner, ikke SQL for selve scoringen) for personal/tournament-
scoring, men mot `get_rounds_stats_summary` i stedet for hull-historikk.
"""
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
update_hole as tournament_update_hole,
)
from app.routers.rounds import HoleUpdate as RoundHoleUpdate, get_rounds_stats_summary, update_hole as round_update_hole
from tests.conftest import (
add_membership,
create_hole,
create_org,
create_org_hole,
create_course,
create_participant,
create_player,
create_round,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _score_personal_hole(round_id, participant_id, user_id, hole_number, score, putts=None):
user = CurrentUser(user_id=user_id)
await round_update_hole(
round_id, participant_id, hole_number,
RoundHoleUpdate(score=score, putts=putts, expected_version=None),
user=user,
)
async def _score_tournament_hole(tournament_id, round_id, rp_id, org_id, user_id, hole_number, score, putts=None):
user = CurrentUser(user_id=user_id)
await tournament_update_hole(
tournament_id, round_id, rp_id, hole_number,
TournamentHoleUpdate(gross_strokes=score, putts=putts, expected_version=None),
organization_id=org_id, user=user,
)
async def _make_completed_personal_round(user_id, *, score_per_hole, putts_per_hole=None, par=4,
stat_level="strokes_and_putts"):
async with app_db.plain_connection() as conn:
round_id = await create_round(conn, user_id)
participant_id = await create_participant(conn, round_id, user_id=user_id, is_owner=True)
for n in range(1, 19):
await create_hole(conn, participant_id, hole_number=n, par=par, stroke_index=n)
await conn.execute("UPDATE round_participant SET stat_level = $1 WHERE id = $2", stat_level, participant_id)
for n in range(1, 19):
await _score_personal_hole(
round_id, participant_id, user_id, n, score_per_hole,
putts=putts_per_hole,
)
async with app_db.plain_connection() as conn:
await conn.execute("UPDATE round SET completed_at = now() WHERE id = $1", round_id)
return round_id, participant_id
async def _make_tournament_round(
user_id, *, score_per_hole, putts_per_hole=None, par=4, holes_scored=18,
stat_level="strokes_and_putts", set_scheduled_at=True, org_id=None,
):
if org_id is None:
org_id = await create_org()
await add_membership(org_id, user_id, role="member")
tournament_id = await create_tournament(org_id)
course_id = await create_course(org_id)
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=par, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
tournament_round_id = await create_tournament_round(org_id, tournament_id, course_id)
if set_scheduled_at:
async with app_db.org_connection(org_id) as conn:
await conn.execute("UPDATE tournament_round SET scheduled_at = now() WHERE id = $1", tournament_round_id)
player_id = await create_player(org_id, user_id=user_id)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute("UPDATE tournament_participant SET stat_level = $1 WHERE id = $2", stat_level, tp_id)
rp_id = await create_tournament_round_participant(org_id, tournament_round_id, tp_id, tee_id)
for n in range(1, holes_scored + 1):
await _score_tournament_hole(
tournament_id, tournament_round_id, rp_id, org_id, user_id, n, score_per_hole,
putts=putts_per_hole,
)
return org_id, tournament_round_id, rp_id
async def test_blends_personal_and_tournament_rounds(pool):
user_id = await create_user()
await _make_completed_personal_round(user_id, score_per_hole=5, putts_per_hole=2, par=4) # +18 til par
await _make_tournament_round(user_id, score_per_hole=4, putts_per_hole=2, par=4) # +0 til par
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
assert out.current.rounds_completed == 2
assert out.current.rounds_completed_personal == 1
assert out.current.rounds_completed_tournament == 1
assert out.current.avg_score_to_par == 9.0 # (18 + 0) / 2
async def test_tournament_strokes_only_round_excluded_from_putts(pool):
user_id = await create_user()
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
await _make_tournament_round(
user_id, score_per_hole=4, putts_per_hole=None, par=4, stat_level="strokes_only",
)
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
assert out.current.rounds_completed == 2
assert out.current.rounds_completed_tournament == 1
# Kun den frittstående runden sporet putter -- strokes_only-turnerings-
# runden skal IKKE forurense putt-snittet (samme regel som gjelder for
# en frittstående strokes_only-runde).
assert out.current.putts_tracked_rounds == 1
assert out.current.avg_putts_per_18 == 36.0
async def test_incomplete_tournament_round_is_excluded(pool):
user_id = await create_user()
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
await _make_tournament_round(user_id, score_per_hole=4, holes_scored=10) # kun 10 av 18 hull
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
assert out.current.rounds_completed == 1
assert out.current.rounds_completed_personal == 1
assert out.current.rounds_completed_tournament == 0
async def test_tournament_round_without_scheduled_at_is_excluded(pool):
user_id = await create_user()
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
await _make_tournament_round(user_id, score_per_hole=4, set_scheduled_at=False)
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
assert out.current.rounds_completed == 1
assert out.current.rounds_completed_tournament == 0
async def test_tournament_round_in_foreign_org_without_player_row_is_excluded(pool):
"""`player_organizations_for_user` returnerer kun org-er brukeren har en
`player`-rad i -- en fullført turneringsrunde i en org der brukeren
IKKE er spiller skal aldri kunne telles med i brukerens egen statistikk."""
user_id = await create_user()
other_user_id = await create_user()
await _make_completed_personal_round(user_id, score_per_hole=4, putts_per_hole=2, par=4)
# Turneringsrunde scoret for en ANNEN bruker, i en org `user_id` ikke er medlem/spiller i.
await _make_tournament_round(other_user_id, score_per_hole=4, putts_per_hole=2, par=4)
out = await get_rounds_stats_summary(window="all", user=CurrentUser(user_id=user_id))
assert out.current.rounds_completed == 1
assert out.current.rounds_completed_tournament == 0

View file

@ -1,126 +0,0 @@
"""
"Jakter Jesper"-visningsmodus for lagturnering-øktens individuelle
leaderboard (migrasjon 093, del 2 av ADR-106). Øktnivå, ikke turneringnivå
-- se ADR-106-tillegget for begrunnelsen. Speiler tests/test_net_hunt_
display.py sitt konvergens-bevis-mønster.
"""
from fastapi import HTTPException
import pytest
from app.auth import CurrentUser
from app.routers.scoring import HoleScoreCreate, submit_hole_score
from app.routers.tournaments import SessionUpdate, get_individual_leaderboard, update_session
from tests.conftest import (
add_membership,
create_course,
create_match,
create_match_participant,
create_org,
create_org_hole,
create_player,
create_session,
create_team,
create_team_roster,
create_tee,
create_tournament,
create_user,
)
import app.db as app_db
async def _setup_singles_session(playing_handicap=12):
org_id = await create_org()
user_id = await create_user()
await add_membership(org_id, user_id, role="owner")
tournament_id = await create_tournament(org_id)
team_a = await create_team(org_id, tournament_id)
team_b = await create_team(org_id, tournament_id)
course_id = await create_course(org_id)
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
session_id = await create_session(org_id, tournament_id, course_id, format="singles", scoring_mode="stroke")
match_id = await create_match(org_id, session_id, team_a, team_b)
player_id = await create_player(org_id, user_id=user_id)
roster_id = await create_team_roster(org_id, team_a, player_id)
participant_id = await create_match_participant(org_id, match_id, "a", roster_id, tee_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE match_participant SET playing_handicap = $1 WHERE id = $2", playing_handicap, participant_id
)
return org_id, user_id, session_id, match_id, participant_id
async def _score(match_id, participant_id, org_id, user_id, scores: list[int]) -> None:
user = CurrentUser(user_id=user_id)
for n, gross in enumerate(scores, start=1):
await submit_hole_score(
match_id,
HoleScoreCreate(
team_side="a", match_participant_id=participant_id, hole_number=n,
gross_strokes=gross, expected_version=None,
),
org_id,
user=user,
)
async def test_hunt_differs_mid_session_but_converges_at_completion(pool):
org_id, user_id, session_id, match_id, participant_id = await _setup_singles_session(playing_handicap=12)
user = CurrentUser(user_id=user_id)
# Hull 1, par -- stroke_index 1 (<=12) -> mottar 1 slag under standard.
await _score(match_id, participant_id, org_id, user_id, [4])
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
assert lb.net_display_style == "standard"
# Standard: netto = 4-1=3, til par = 3-4 = -1.
assert lb.entries[0].net_to_par == -1
await update_session(session_id, SessionUpdate(net_display_style="hunt"), organization_id=org_id)
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
assert lb.net_display_style == "hunt"
# Hunt: gross(4) - FULLT spillehandicap(12) = -8, til par = -8-4 = -12.
assert lb.entries[0].net_to_par == -12
assert lb.entries[0].net_score == -8
# Fullfør økten med par på alle resterende hull -- flatt hele veien
# under hunt (alltid par -> gross-til-par alltid 0).
await _score(match_id, participant_id, org_id, user_id, [4] * 17)
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
assert lb.entries[0].net_to_par == -12
# Bytt tilbake til standard nå som alle 18 hull er spilt -- KONVERGERER
# til nøyaktig samme tall (selve beviset på ekvivalensen).
await update_session(session_id, SessionUpdate(net_display_style="standard"), organization_id=org_id)
lb = await get_individual_leaderboard(session_id, organization_id=org_id, user=user)
assert lb.entries[0].net_to_par == -12
async def test_hunt_rejected_for_non_singles_fourball_session(pool):
org_id = await create_org()
user_id = await create_user()
await add_membership(org_id, user_id, role="owner")
tournament_id = await create_tournament(org_id)
course_id = await create_course(org_id)
session_id = await create_session(org_id, tournament_id, course_id, format="foursome", scoring_mode="stroke")
with pytest.raises(HTTPException) as exc_info:
await update_session(session_id, SessionUpdate(net_display_style="hunt"), organization_id=org_id)
assert exc_info.value.status_code == 400
async def test_hunt_rejected_for_hole_result_scoring_mode(pool):
org_id = await create_org()
user_id = await create_user()
await add_membership(org_id, user_id, role="owner")
tournament_id = await create_tournament(org_id)
course_id = await create_course(org_id)
session_id = await create_session(org_id, tournament_id, course_id, format="singles", scoring_mode="hole_result")
with pytest.raises(HTTPException) as exc_info:
await update_session(session_id, SessionUpdate(net_display_style="hunt"), organization_id=org_id)
assert exc_info.value.status_code == 400

View file

@ -1,316 +0,0 @@
"""
Rundespesifikk kommentartråd for org-turneringer (ADR-102). Ekte parallell
til round_messages.py sin RoundMessages, men org-scopet (RLS) i stedet for
plain_connection() -- skriverett = leserett = org-medlemskap
(get_authorized_org), moderering = forfatter ELLER org-admin (is_org_admin),
samme regel som Banter Board (ADR-025). Én tråd PER TURNERINGSRUNDE (hele
feltet deler tråd, ikke per flight).
"""
import uuid
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.tournament_round_messages import (
CommentIn,
ReactionIn,
delete_tournament_round_message,
delete_tournament_round_message_comment,
list_tournament_round_message_comments,
list_tournament_round_messages,
post_tournament_round_message,
post_tournament_round_message_comment,
remove_tournament_round_message_reaction,
set_tournament_round_message_reaction,
)
from tests.conftest import (
add_membership,
create_org,
create_tournament,
create_tournament_round,
create_course,
create_user,
)
async def _setup(org_id):
tournament_id = await create_tournament(org_id, name="Kommentar-turnering")
course_id = await create_course(org_id, name="Kommentarbanen")
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, round_id
async def test_post_and_list_message(pool):
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user(display_name="Forfatter Testesen")
await add_membership(org_id, author_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Fin runde i dag!", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert created.body == "Fin runde i dag!"
assert created.tournament_round_id == round_id
assert created.author_display_name == "Forfatter Testesen"
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert [m.id for m in listed] == [created.id]
async def test_client_message_id_idempotent(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
client_message_id = str(uuid.uuid4())
first = await post_tournament_round_message(
tournament_id, round_id, body="Duplikat-test", image=None, client_message_id=client_message_id,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
second = await post_tournament_round_message(
tournament_id, round_id, body="Duplikat-test", image=None, client_message_id=client_message_id,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert first.id == second.id
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert len(listed) == 1
async def test_body_or_image_required(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
with pytest.raises(HTTPException) as exc_info:
await post_tournament_round_message(
tournament_id, round_id, body=None, image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 400
async def test_wrong_tournament_id_404(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
other_tournament_id = await create_tournament(org_id, name="En annen turnering")
author_id = await create_user()
await add_membership(org_id, author_id)
with pytest.raises(HTTPException) as exc_info:
await post_tournament_round_message(
other_tournament_id, round_id, body="Feil turnering", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 404
async def test_rls_blocks_cross_org_access(pool) -> None:
org_a = await create_org()
org_b = await create_org()
tournament_id, round_id = await _setup(org_a)
author_id = await create_user()
await add_membership(org_a, author_id)
await add_membership(org_b, author_id)
await post_tournament_round_message(
tournament_id, round_id, body="Kun org A", image=None, client_message_id=None,
organization_id=org_a, user=CurrentUser(user_id=author_id),
)
# Samme (tournament_id, round_id) forsøkt lest med FEIL org_id satt --
# RLS filtrerer bort tournament_round-raden, _get_tournament_round_or_404
# ser den derfor ikke og gir 404 (ikke et 0-treffs, "tom liste"-svar).
with pytest.raises(HTTPException) as exc_info:
await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_b, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 404
async def test_delete_by_author(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Slett meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
await delete_tournament_round_message(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert listed == []
async def test_delete_by_org_admin_not_author(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
admin_id = await create_user()
await add_membership(org_id, author_id, role="member")
await add_membership(org_id, admin_id, role="admin")
created = await post_tournament_round_message(
tournament_id, round_id, body="Slett meg som admin", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
await delete_tournament_round_message(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=admin_id),
)
listed = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert listed == []
async def test_delete_rejected_for_unrelated_member(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
other_member_id = await create_user()
await add_membership(org_id, author_id, role="member")
await add_membership(org_id, other_member_id, role="member")
created = await post_tournament_round_message(
tournament_id, round_id, body="Ikke slett meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
with pytest.raises(HTTPException) as exc_info:
await delete_tournament_round_message(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=other_member_id),
)
assert exc_info.value.status_code == 403
async def test_ordinary_member_can_post(pool) -> None:
"""Skriverett = org-medlemskap, ikke deltaker-i-akkurat-denne-turneringen
eller admin -- bekrefter at et VANLIG medlem (ikke admin/deltaker) kan
poste, samme "vanlig org-medlemsnivå"-presedens som resten av
individual_tournaments.py sitt oppsett (ADR-102)."""
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
member_id = await create_user()
await add_membership(org_id, member_id, role="member")
created = await post_tournament_round_message(
tournament_id, round_id, body="Jeg er bare medlem", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=member_id),
)
assert created.body == "Jeg er bare medlem"
async def test_reaction_upsert_and_remove(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
reactor_id = await create_user()
await add_membership(org_id, author_id)
await add_membership(org_id, reactor_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Reager på meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
summaries = await set_tournament_round_message_reaction(
tournament_id, round_id, created.id, ReactionIn(emoji="👍"),
organization_id=org_id, user=CurrentUser(user_id=reactor_id),
)
assert len(summaries) == 1
assert summaries[0].emoji == "👍"
assert summaries[0].count == 1
assert summaries[0].reacted_by_me is True
# Bytte av emoji erstatter, stables ikke.
summaries = await set_tournament_round_message_reaction(
tournament_id, round_id, created.id, ReactionIn(emoji="❤️"),
organization_id=org_id, user=CurrentUser(user_id=reactor_id),
)
assert len(summaries) == 1
assert summaries[0].emoji == "❤️"
summaries = await remove_tournament_round_message_reaction(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=reactor_id),
)
assert summaries == []
async def test_invalid_emoji_rejected(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
await add_membership(org_id, author_id)
created = await post_tournament_round_message(
tournament_id, round_id, body="Test", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
with pytest.raises(HTTPException) as exc_info:
await set_tournament_round_message_reaction(
tournament_id, round_id, created.id, ReactionIn(emoji="🐸"),
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 400
async def test_threaded_comment_create_list_delete(pool) -> None:
org_id = await create_org()
tournament_id, round_id = await _setup(org_id)
author_id = await create_user()
commenter_id = await create_user()
admin_id = await create_user()
await add_membership(org_id, author_id, role="member")
await add_membership(org_id, commenter_id, role="member")
await add_membership(org_id, admin_id, role="admin")
message = await post_tournament_round_message(
tournament_id, round_id, body="Kommenter meg", image=None, client_message_id=None,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
top_comment = await post_tournament_round_message_comment(
tournament_id, round_id, message.id, CommentIn(body="Bra spilt!"),
organization_id=org_id, user=CurrentUser(user_id=commenter_id),
)
reply = await post_tournament_round_message_comment(
tournament_id, round_id, message.id, CommentIn(body="Enig!", parent_comment_id=top_comment.id),
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
comments = await list_tournament_round_message_comments(
tournament_id, round_id, message.id, organization_id=org_id,
)
assert {c.id for c in comments} == {top_comment.id, reply.id}
assert next(c for c in comments if c.id == reply.id).parent_comment_id == top_comment.id
# Ikke-forfatter-ikke-admin nektes.
with pytest.raises(HTTPException) as exc_info:
await delete_tournament_round_message_comment(
tournament_id, round_id, message.id, top_comment.id,
organization_id=org_id, user=CurrentUser(user_id=author_id),
)
assert exc_info.value.status_code == 403
# Org-admin kan slette andres kommentar -- kaskade-sletter svaret under.
await delete_tournament_round_message_comment(
tournament_id, round_id, message.id, top_comment.id,
organization_id=org_id, user=CurrentUser(user_id=admin_id),
)
remaining = await list_tournament_round_message_comments(
tournament_id, round_id, message.id, organization_id=org_id,
)
assert remaining == []

View file

@ -1,264 +0,0 @@
"""
Slag-for-slag GPS-avstandsmåling for org-turneringer (ADR-103). Speiler
ADR-048s round_shot-autorisasjon (self ELLER org-admin, samme klasse som
update_hole), men org-scopet (RLS) og FK-et til
tournament_round_participant + hole_number (ikke tournament_round_hole_id
-- den raden finnes kanskje ikke ennå).
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.tournament_round_shots import (
ShotIn,
ShotShareIn,
create_tournament_round_shot,
delete_tournament_round_shot,
list_tournament_round_shots,
share_tournament_round_shot,
)
from tests.conftest import (
add_membership,
create_course,
create_org,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
_SHOT = ShotIn(
club="Driver",
distance_meters=210.5,
start_method="gps",
start_lat=59.1,
start_lng=10.5,
end_method="gps",
end_lat=59.101,
end_lng=10.501,
)
async def _setup(org_id):
tournament_id = await create_tournament(org_id, name="Slagmåling-turnering")
course_id = await create_course(org_id, name="Slagmålingsbanen")
tee_id = await create_tee(org_id, course_id, name="Gul")
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
return tournament_id, round_id, tee_id
async def _make_participant(org_id, tournament_id, round_id, tee_id, user_id):
player_id = await create_player(org_id, display_name="Slag Testesen", user_id=user_id)
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_id)
return rp_id
async def test_create_and_list_shot(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT,
organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert created.shot_number == 1
assert created.club == "Driver"
assert created.shared_tournament_round_message_id is None
listed = await list_tournament_round_shots(
tournament_id, round_id, rp_id, 1, organization_id=org_id,
)
assert [s.id for s in listed] == [created.id]
async def test_shot_number_increments_per_hole(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
first = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
second = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert first.shot_number == 1
assert second.shot_number == 2
# Et annet hull starter på nytt fra 1.
other_hole = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 2, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert other_hole.shot_number == 1
async def test_can_measure_before_hole_score_exists(pool):
"""Kjernebegrunnelsen for datamodellen: tournament_round_hole
opprettes FØRST ved score-innsending -- måling fungere FØR det."""
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
# Ingen tournament_round_hole-rad finnes -- måling skal likevel virke.
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 5, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert created.shot_number == 1
async def test_non_self_non_admin_rejected(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
other_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, other_id, role="member")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
with pytest.raises(HTTPException) as exc_info:
await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=other_id),
)
assert exc_info.value.status_code == 403
async def test_org_admin_can_measure_for_others(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
admin_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, admin_id, role="admin")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=admin_id),
)
assert created.shot_number == 1
async def test_delete_self_only(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
other_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, other_id, role="member")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=owner_id),
)
with pytest.raises(HTTPException) as exc_info:
await delete_tournament_round_shot(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=other_id),
)
assert exc_info.value.status_code == 403
await delete_tournament_round_shot(
tournament_id, round_id, created.id, organization_id=org_id, user=CurrentUser(user_id=owner_id),
)
listed = await list_tournament_round_shots(tournament_id, round_id, rp_id, 1, organization_id=org_id)
assert listed == []
def test_invalid_distance_rejected():
with pytest.raises(Exception):
ShotIn(
club="Driver", distance_meters=0, start_method="gps",
start_lat=59.1, start_lng=10.5, end_lat=59.101, end_lng=10.501,
)
async def test_wrong_tournament_id_404(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
other_tournament_id = await create_tournament(org_id, name="En annen turnering")
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
with pytest.raises(HTTPException) as exc_info:
await create_tournament_round_shot(
other_tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert exc_info.value.status_code == 404
async def test_rls_blocks_cross_org_access(pool):
org_a = await create_org()
org_b = await create_org()
tournament_id, round_id, tee_id = await _setup(org_a)
user_id = await create_user()
await add_membership(org_a, user_id)
await add_membership(org_b, user_id)
rp_id = await _make_participant(org_a, tournament_id, round_id, tee_id, user_id)
await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_a, user=CurrentUser(user_id=user_id),
)
with pytest.raises(HTTPException) as exc_info:
await list_tournament_round_shots(tournament_id, round_id, rp_id, 1, organization_id=org_b)
assert exc_info.value.status_code == 404
async def test_share_creates_tournament_round_message(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
user_id = await create_user()
await add_membership(org_id, user_id)
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, user_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert created.shared_tournament_round_message_id is None
shared = await share_tournament_round_shot(
tournament_id, round_id, created.id, ShotShareIn(body="⛳ Driver, 210.5 m hull 1"),
organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert shared.shared_tournament_round_message_id is not None
# Kommentartråden (ADR-102) skal nå ha en ekte melding.
from app.routers.tournament_round_messages import list_tournament_round_messages
messages = await list_tournament_round_messages(
tournament_id, round_id, organization_id=org_id, user=CurrentUser(user_id=user_id),
)
assert any(m.id == shared.shared_tournament_round_message_id for m in messages)
assert any(m.body == "⛳ Driver, 210.5 m hull 1" for m in messages)
async def test_share_rejected_for_non_owner(pool):
org_id = await create_org()
tournament_id, round_id, tee_id = await _setup(org_id)
owner_id = await create_user()
other_id = await create_user()
await add_membership(org_id, owner_id, role="member")
await add_membership(org_id, other_id, role="member")
rp_id = await _make_participant(org_id, tournament_id, round_id, tee_id, owner_id)
created = await create_tournament_round_shot(
tournament_id, round_id, rp_id, 1, _SHOT, organization_id=org_id, user=CurrentUser(user_id=owner_id),
)
with pytest.raises(HTTPException) as exc_info:
await share_tournament_round_shot(
tournament_id, round_id, created.id, ShotShareIn(body="Test"),
organization_id=org_id, user=CurrentUser(user_id=other_id),
)
assert exc_info.value.status_code == 403