Compare commits
No commits in common. "bcb712810fe0fc6db9b801a42860f199029abc3d" and "546900c2d6e6c6e640454dc033cfd31daeb332fc" have entirely different histories.
bcb712810f
...
546900c2d6
46 changed files with 867 additions and 8487 deletions
|
|
@ -539,10 +539,7 @@
|
||||||
"Bash(python3 -m py_compile tests/test_account_merge.py)",
|
"Bash(python3 -m py_compile tests/test_account_merge.py)",
|
||||||
"Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")",
|
"Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")",
|
||||||
"Bash(grep -n 'href={`/tournaments/\\\\|Link.*tournaments/\\\\${' components/tournament-detail.tsx)",
|
"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(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)"
|
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,4 +5,3 @@ Temp-uploads/
|
||||||
.venv/
|
.venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.forgejo-runner/
|
.forgejo-runner/
|
||||||
backups/
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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'));
|
|
||||||
|
|
@ -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'));
|
|
||||||
|
|
@ -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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
798
CHANGELOG.md
798
CHANGELOG.md
|
|
@ -14014,797 +14014,9 @@ Neste steg:
|
||||||
prosjektet, hele `vitest`-suiten grønn (55 tester, ingen regresjon).
|
prosjektet, hele `vitest`-suiten grønn (55 tester, ingen regresjon).
|
||||||
`python3 -m py_compile` rent på de nye backend-filene.
|
`python3 -m py_compile` rent på de nye backend-filene.
|
||||||
|
|
||||||
**Rullet ut 2026-08-21** -- bruker bekreftet, kjørt sammen med flere
|
**⏳ IKKE rullet ut ennå.** Ny Chromium-avhengighet i produksjons-
|
||||||
andre endringer samme dag (se punkt 151-153). `docker compose build
|
API-imaget (~300-500MB større) venter på eksplisitt bekreftelse før
|
||||||
teecup_api teecup_frontend && up -d`, begge containere friske, rene
|
`docker compose build && up -d` kjøres. Ekte scratch-verifisering
|
||||||
logger, bekreftet via helsesjekk mot ekte `teecup.golf`.
|
(klikk gjennom alle fire med ekte turneringsdata, bekreft faktisk
|
||||||
**⚠️ ÉN ting fortsatt IKKE gjort:** ekte klikk-gjennom av selve
|
PDF-nedlasting) gjenstår også -- kun kodenivå-verifisering gjort
|
||||||
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.
|
|
||||||
denne runden.
|
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).
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- Bruk git (remote: brukerens Forgejo). Commit i logiske steg med tydelige
|
||||||
meldinger.
|
meldinger.
|
||||||
- Er du usikker på omfang eller en beslutning: spør heller enn å gjette.
|
- 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
|
## Status og neste steg
|
||||||
Den detaljerte, kronologiske statusloggen (hva som er ferdig, verifisert
|
Den detaljerte, kronologiske statusloggen (hva som er ferdig, verifisert
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
|
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
|
||||||
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
|
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
|
||||||
>
|
>
|
||||||
> Sist oppdatert: 2026-08-22 (full gap-analyse: scoring-paritet
|
> Sist oppdatert: 2026-08-21 (funksjonelle hull funnet under utskrift-
|
||||||
> frittstående runde vs. org-turnering, erstatter den tidligere kun-
|
> planlegging notert -- hullengde, turneringslogo, bracket-format
|
||||||
> muntlige "8-punktslisten" -- se nederste seksjon)
|
> 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).
|
er lagturnerings-only — egen, større oppgave hvis/når etterspurt).
|
||||||
Migrasjon 071 — se ADR-067.
|
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,
|
**✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del,
|
||||||
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
|
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
|
||||||
org-individuelle turneringer (`scoring_method = eclectic_gross/
|
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
|
**Neste steg:** begge idéene trenger en egen avklaringsrunde med bruker
|
||||||
FØR design/bygging -- ingen beslutninger tatt ennå, kun fanget opp her
|
FØR design/bygging -- ingen beslutninger tatt ennå, kun fanget opp her
|
||||||
for å ikke gå tapt.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ from .routers import (
|
||||||
round_messages,
|
round_messages,
|
||||||
rounds,
|
rounds,
|
||||||
scoring,
|
scoring,
|
||||||
tournament_round_messages,
|
|
||||||
tournament_round_shots,
|
|
||||||
tournaments,
|
tournaments,
|
||||||
)
|
)
|
||||||
from . import storage
|
from . import storage
|
||||||
|
|
@ -68,8 +66,6 @@ app.include_router(messaging.router)
|
||||||
app.include_router(messaging.public_router)
|
app.include_router(messaging.public_router)
|
||||||
app.include_router(rounds.router)
|
app.include_router(rounds.router)
|
||||||
app.include_router(round_messages.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(friends.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
app.include_router(print_pdf.router)
|
app.include_router(print_pdf.router)
|
||||||
|
|
|
||||||
|
|
@ -525,30 +525,6 @@ async def import_official_course(
|
||||||
"Banen mangler par eller HCP-index på ett eller flere hull i teeoff.",
|
"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
|
# 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
|
# egen front9/back9-rating. ADR-029: kjønn hører til RATINGEN, ikke selve
|
||||||
# utslaget -- ett teeoff-utslag blir HER importert som ÉN fysisk
|
# utslaget -- ett teeoff-utslag blir HER importert som ÉN fysisk
|
||||||
|
|
@ -852,24 +828,6 @@ async def import_international_course(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI."
|
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)
|
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"]
|
course_name = f"{gc['club_name']} – {gc['course_name']}" if gc["course_name"] else gc["club_name"]
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@ from handicap_engine import (
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..realtime import broadcast_live_update
|
|
||||||
from ..hole_history import (
|
from ..hole_history import (
|
||||||
HoleHistoryOut,
|
HoleHistoryOut,
|
||||||
hole_history_for_user,
|
hole_history_for_user,
|
||||||
|
|
@ -520,12 +519,6 @@ class RoundParticipantOut(BaseModel):
|
||||||
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
|
# level), speilet ut her slik at frontend vet om HoleGrid-cellen skal
|
||||||
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
|
# åpne det utvidede HoleStatsSheet-et for denne deltakeren.
|
||||||
stat_level: str
|
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:
|
async def _compute_round_participant_handicap(conn, round_participant_id: str) -> None:
|
||||||
|
|
@ -643,7 +636,6 @@ async def add_round_participant(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
body: RoundParticipantCreate,
|
body: RoundParticipantCreate,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
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)
|
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,
|
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,
|
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.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
|
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
JOIN player p ON p.id = tp.player_id
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
|
@ -681,7 +671,6 @@ async def add_round_participant(
|
||||||
WHERE trp.id = $1
|
WHERE trp.id = $1
|
||||||
""",
|
""",
|
||||||
round_participant_id,
|
round_participant_id,
|
||||||
user.user_id,
|
|
||||||
)
|
)
|
||||||
return RoundParticipantOut(**dict(out))
|
return RoundParticipantOut(**dict(out))
|
||||||
|
|
||||||
|
|
@ -694,16 +683,13 @@ async def list_round_participants(
|
||||||
tournament_id: str,
|
tournament_id: str,
|
||||||
round_id: str,
|
round_id: str,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
|
||||||
) -> list[RoundParticipantOut]:
|
) -> list[RoundParticipantOut]:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
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,
|
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.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
|
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
JOIN player p ON p.id = tp.player_id
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
|
@ -712,7 +698,6 @@ async def list_round_participants(
|
||||||
ORDER BY p.display_name
|
ORDER BY p.display_name
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
user.user_id,
|
|
||||||
)
|
)
|
||||||
return [RoundParticipantOut(**dict(r)) for r in rows]
|
return [RoundParticipantOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
@ -747,7 +732,6 @@ async def update_round_participant_tee(
|
||||||
round_participant_id: str,
|
round_participant_id: str,
|
||||||
body: RoundParticipantTeeUpdate,
|
body: RoundParticipantTeeUpdate,
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
existing = await conn.fetchrow(
|
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,
|
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,
|
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.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
|
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
JOIN player p ON p.id = tp.player_id
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
|
@ -784,7 +766,6 @@ async def update_round_participant_tee(
|
||||||
WHERE trp.id = $1
|
WHERE trp.id = $1
|
||||||
""",
|
""",
|
||||||
round_participant_id,
|
round_participant_id,
|
||||||
user.user_id,
|
|
||||||
)
|
)
|
||||||
return RoundParticipantOut(**dict(out))
|
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(
|
ctx = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config,
|
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.tournament_participant_id::text AS tournament_participant_id,
|
||||||
trp.playing_handicap
|
trp.playing_handicap
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
|
|
@ -1167,12 +1147,6 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str,
|
||||||
net_total,
|
net_total,
|
||||||
stableford_points,
|
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:
|
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
|
||||||
|
|
@ -1690,11 +1664,6 @@ class RoundCellOut(BaseModel):
|
||||||
round_number: int
|
round_number: int
|
||||||
label: str
|
label: str
|
||||||
tone: str | None # "under" | "even" | "over" -- matches stroke-play-leaderboard.tsx sin RoundCell
|
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):
|
class EclecticHoleCellOut(BaseModel):
|
||||||
|
|
@ -1785,7 +1754,7 @@ async def _compute_individual_standings(
|
||||||
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
hver entry (`gross_total`/`net_total`/`stableford_total`/`position`)
|
||||||
som faktisk er meningsfullt å lese videre."""
|
som faktisk er meningsfullt å lese videre."""
|
||||||
tournament = await conn.fetchrow(
|
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",
|
"FROM tournament WHERE id = $1",
|
||||||
tournament_id,
|
tournament_id,
|
||||||
)
|
)
|
||||||
|
|
@ -1842,7 +1811,6 @@ async def _compute_individual_standings(
|
||||||
max_sequence=max_sequence,
|
max_sequence=max_sequence,
|
||||||
tiebreak_winner_method=tournament["tiebreak_winner_method"],
|
tiebreak_winner_method=tournament["tiebreak_winner_method"],
|
||||||
tiebreak_field_method=tournament["tiebreak_field_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
|
# Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en
|
||||||
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
|
# egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på
|
||||||
|
|
@ -1910,70 +1878,6 @@ async def individual_leaderboard(
|
||||||
return entries
|
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):
|
class CutResult(BaseModel):
|
||||||
cut_after_round: int
|
cut_after_round: int
|
||||||
cut_size: 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}
|
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]:
|
async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]:
|
||||||
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
|
"""Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching
|
||||||
cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens
|
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,
|
max_sequence: int | None = None,
|
||||||
tiebreak_winner_method: str = "none",
|
tiebreak_winner_method: str = "none",
|
||||||
tiebreak_field_method: str = "none",
|
tiebreak_field_method: str = "none",
|
||||||
net_display_style: str = "standard",
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
"""Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx):
|
||||||
POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av
|
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, så tiebreak gjelder der -- inkludert midt i
|
aldri max_sequence, så tiebreak gjelder der -- inkludert midt i
|
||||||
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
|
turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det
|
||||||
beste vi vet akkurat nå"-filosofi som resten av leaderboardet).
|
beste vi vet akkurat nå"-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 på.
|
|
||||||
"""
|
"""
|
||||||
hunt_active = net_display_style == "hunt" and method == "stroke_net"
|
|
||||||
rounds_meta = await conn.fetch(
|
rounds_meta = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT sequence, hole_config::text AS hole_config, scheduled_at::text AS scheduled_at, start_hole
|
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).
|
# er ferdigspilt).
|
||||||
round_rows = await conn.fetch(
|
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.tournament_participant_id::text AS tournament_participant_id,
|
||||||
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
trs.holes_played, trs.gross_total, trs.net_total, trs.stableford_points,
|
||||||
trp.playing_handicap,
|
|
||||||
COALESCE(pp.par_played, 0)::int AS par_played
|
COALESCE(pp.par_played, 0)::int AS par_played
|
||||||
FROM tournament_round tr
|
FROM tournament_round tr
|
||||||
JOIN tournament_round_score trs ON trs.tournament_round_id = tr.id
|
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 (
|
LEFT JOIN LATERAL (
|
||||||
SELECT SUM(h.par) AS par_played
|
SELECT SUM(h.par) AS par_played
|
||||||
FROM tournament_round_participant trp
|
FROM tournament_round_participant trp
|
||||||
|
|
@ -2417,18 +2278,6 @@ async def _attach_stroke_play_columns(
|
||||||
"net_total" if method == "stroke_net" else "gross_total"
|
"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,
|
# Rangeringsnøkkel PER DELTAKER, til-par-normalisert (lavere = bedre,
|
||||||
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
|
# uniformt for alle tre metodene -- stableford negeres siden flere poeng
|
||||||
# er bedre der). Kan IKKE bruke rå gross_total/stableford_total: i en
|
# 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] = []
|
rounds_out: list[RoundCellOut] = []
|
||||||
total_par_played = 0
|
total_par_played = 0
|
||||||
total_holes_played = 0
|
total_holes_played = 0
|
||||||
total_stroke_val = 0
|
|
||||||
for seq, row in sorted(pmap.items()):
|
for seq, row in sorted(pmap.items()):
|
||||||
if row["holes_played"] == 0:
|
if row["holes_played"] == 0:
|
||||||
continue
|
continue
|
||||||
|
|
@ -2453,24 +2301,17 @@ async def _attach_stroke_play_columns(
|
||||||
points = row["stableford_points"] or 0
|
points = row["stableford_points"] or 0
|
||||||
expected = 2 * row["holes_played"]
|
expected = 2 * row["holes_played"]
|
||||||
rounds_out.append(
|
rounds_out.append(
|
||||||
RoundCellOut(
|
RoundCellOut(round_number=seq, label=f"{points} p", tone=_to_par_tone(points - expected))
|
||||||
round_number=seq,
|
|
||||||
label=f"{points} p",
|
|
||||||
tone=_to_par_tone(points - expected),
|
|
||||||
tournament_round_id=row["tournament_round_id"],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
val = stroke_val(row)
|
val = row[value_col]
|
||||||
if val is None:
|
if val is None:
|
||||||
continue
|
continue
|
||||||
total_stroke_val += val
|
|
||||||
rounds_out.append(
|
rounds_out.append(
|
||||||
RoundCellOut(
|
RoundCellOut(
|
||||||
round_number=seq,
|
round_number=seq,
|
||||||
label=str(val),
|
label=str(val),
|
||||||
tone=_to_par_tone(val - row["par_played"]),
|
tone=_to_par_tone(val - row["par_played"]),
|
||||||
tournament_round_id=row["tournament_round_id"],
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
e.rounds = rounds_out
|
e.rounds = rounds_out
|
||||||
|
|
@ -2483,7 +2324,7 @@ async def _attach_stroke_play_columns(
|
||||||
if method == "stableford":
|
if method == "stableford":
|
||||||
e.today_label = f"{today_row['stableford_points'] or 0} p"
|
e.today_label = f"{today_row['stableford_points'] or 0} p"
|
||||||
else:
|
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
|
e.today_label = _to_par_label(val - today_row["par_played"]) if val is not None else None
|
||||||
else:
|
else:
|
||||||
e.thru_label = "-"
|
e.thru_label = "-"
|
||||||
|
|
@ -2507,12 +2348,6 @@ async def _attach_stroke_play_columns(
|
||||||
# runde spilt ennå (total_holes_played == 0) -> total_label forblir
|
# runde spilt ennå (total_holes_played == 0) -> total_label forblir
|
||||||
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
|
# None (nøytral -- IKKE "E"/"0 p", som ville sett ut som en ekte,
|
||||||
# jevn/poengløs score i stedet for "ikke startet").
|
# 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
|
started_any = total_holes_played > 0
|
||||||
if method == "stableford":
|
if method == "stableford":
|
||||||
e.total_label = f"{e.stableford_total or 0} p" if started_any else None
|
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 -(e.stableford_total - 2 * total_holes_played)
|
||||||
)
|
)
|
||||||
else:
|
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 = (
|
e.total_label = (
|
||||||
_to_par_label(total_val - total_par_played) if (total_val is not None and started_any) else None
|
_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':
|
# gruppe faller gjennom til else-grenen under akkurat som 'none':
|
||||||
# delt plassering, ingen automatisk oppløsning. Forskjellen er
|
# delt plassering, ingen automatisk oppløsning. Forskjellen er
|
||||||
# ren informasjon (tiebreak_playoff_rule), ikke rangeringslogikk.
|
# 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)]
|
group_ids = [entries[k].tournament_participant_id for k in range(i, j)]
|
||||||
if tiebreak_method == "countback":
|
if tiebreak_method == "countback":
|
||||||
sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids)
|
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:
|
else:
|
||||||
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
|
sort_keys = await _lowest_hcp_sort_keys(conn, group_ids)
|
||||||
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)
|
_assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ from ..db import org_connection, plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from .. import storage
|
from .. import storage
|
||||||
from ..team_authz import user_is_tournament_participant
|
from ..team_authz import user_is_tournament_participant
|
||||||
from .individual_tournaments import LeaderboardEntry, RoundCellOut, _compute_individual_standings
|
|
||||||
from .matches import MatchOut, fetch_matches
|
from .matches import MatchOut, fetch_matches
|
||||||
from .scoring import Scorecard, fetch_scorecard
|
from .scoring import Scorecard, fetch_scorecard
|
||||||
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
|
from .tournaments import Leaderboard, SessionOut, _fetch_sessions, fetch_leaderboard
|
||||||
|
|
@ -129,18 +128,6 @@ class PublicTournamentInfo(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
organization_name: str
|
organization_name: str
|
||||||
status: 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
|
visibility: str
|
||||||
description: str | None
|
description: str | None
|
||||||
start_date: date | None
|
start_date: date | None
|
||||||
|
|
@ -164,7 +151,7 @@ async def get_public_tournament(
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT t.id::text, t.name, o.name AS organization_name, t.status::text,
|
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.start_date, t.end_date, t.registration_deadline,
|
||||||
t.registration_capacity,
|
t.registration_capacity,
|
||||||
(SELECT count(*)::int FROM tournament_registration tr
|
(SELECT count(*)::int FROM tournament_registration tr
|
||||||
|
|
@ -195,8 +182,6 @@ async def get_public_tournament(
|
||||||
name=row["name"],
|
name=row["name"],
|
||||||
organization_name=row["organization_name"],
|
organization_name=row["organization_name"],
|
||||||
status=row["status"],
|
status=row["status"],
|
||||||
format_type=row["format_type"],
|
|
||||||
scoring_method=row["scoring_method"],
|
|
||||||
visibility=row["visibility"],
|
visibility=row["visibility"],
|
||||||
description=row["description"],
|
description=row["description"],
|
||||||
start_date=row["start_date"],
|
start_date=row["start_date"],
|
||||||
|
|
@ -236,242 +221,6 @@ async def get_public_sessions(
|
||||||
return await _fetch_sessions(conn, tournament_id)
|
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 -------------
|
# --- Tilskuer-rolle (ADR-026): leaderboard, matcher, scorekort -------------
|
||||||
#
|
#
|
||||||
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
|
# Samme synlighets-/kode-mønster som info-/sesjon-kallene over -- ingen ny
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ from pydantic import BaseModel, EmailStr, Field
|
||||||
from .. import golfapi_cache, golfapi_client, storage, teeoff_client
|
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 ..auth import CurrentUser, get_current_user, get_current_user_from_websocket, get_current_user_optional
|
||||||
from ..config import settings
|
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 ..email import RoundSummaryHole, send_round_summary_email
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
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 ..target_points import TargetPoint, get_target_points
|
||||||
from .auth import _hash_secret
|
from .auth import _hash_secret
|
||||||
from .notifications import create_notification
|
from .notifications import create_notification
|
||||||
from .scoring import played_hole_numbers
|
|
||||||
from handicap_engine import (
|
from handicap_engine import (
|
||||||
HoleResult,
|
HoleResult,
|
||||||
PerPlayerPercentage,
|
PerPlayerPercentage,
|
||||||
|
|
@ -1477,10 +1476,6 @@ class RoundOut(BaseModel):
|
||||||
allowance_override: dict | None
|
allowance_override: dict | None
|
||||||
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
flag_map_visible: bool
|
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:
|
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,
|
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,
|
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,
|
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
|
FROM round WHERE id = $1
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -1561,18 +1556,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
||||||
total_strokes_received = sum(
|
total_strokes_received = sum(
|
||||||
strokes_by_hole[r["hole_number"]] for r in hole_rows if r["played"] and r["score"] is not None
|
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
|
my_net_score_to_par = (my_score_to_par or 0) - total_strokes_received
|
||||||
# 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
|
|
||||||
|
|
||||||
# Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN.
|
# Viewer-relativ matchstatus (2026-07-29) -- to-sidede formater KUN.
|
||||||
# Gjenbruker _build_format_result uendret (samme sannhetskilde som selve
|
# 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"],
|
flight_group_id=round_row["flight_group_id"],
|
||||||
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
allowance_override=json.loads(round_row["allowance_override"]) if round_row["allowance_override"] else None,
|
||||||
flag_map_visible=round_row["flag_map_visible"],
|
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):
|
class RoundStatsSummary(BaseModel):
|
||||||
rounds_completed: int
|
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_score_to_par: float | None
|
||||||
avg_putts_per_18: float | None
|
avg_putts_per_18: float | None
|
||||||
putts_tracked_rounds: int
|
putts_tracked_rounds: int
|
||||||
|
|
@ -2061,7 +2038,6 @@ def _summarize_rounds(
|
||||||
by_round: dict[str, list],
|
by_round: dict[str, list],
|
||||||
stat_level_by_round: dict[str, str],
|
stat_level_by_round: dict[str, str],
|
||||||
played_at_by_round: dict[str, date],
|
played_at_by_round: dict[str, date],
|
||||||
source_by_round: dict[str, str],
|
|
||||||
) -> RoundStatsSummary:
|
) -> RoundStatsSummary:
|
||||||
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
|
"""Samme formler som `round-stats.tsx` sin `computeStats()` bruker per
|
||||||
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
|
enkeltrunde, pooler kun holdene fra `round_ids` -- gjenbrukt for BÅDE
|
||||||
|
|
@ -2149,8 +2125,6 @@ def _summarize_rounds(
|
||||||
|
|
||||||
return RoundStatsSummary(
|
return RoundStatsSummary(
|
||||||
rounds_completed=len(round_ids),
|
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_score_to_par=avg(to_par_values),
|
||||||
avg_putts_per_18=avg(putt_totals),
|
avg_putts_per_18=avg(putt_totals),
|
||||||
putts_tracked_rounds=len(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
|
|
||||||
på 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)
|
@router.get("/rounds/stats/summary", response_model=RoundStatsWindowSummary)
|
||||||
async def get_rounds_stats_summary(
|
async def get_rounds_stats_summary(
|
||||||
window: StatsWindow = Query("all"),
|
window: StatsWindow = Query("all"),
|
||||||
|
|
@ -2305,40 +2202,22 @@ async def get_rounds_stats_summary(
|
||||||
by_round: dict[str, list] = {}
|
by_round: dict[str, list] = {}
|
||||||
stat_level_by_round: dict[str, str] = {}
|
stat_level_by_round: dict[str, str] = {}
|
||||||
played_at_by_round: dict[str, date] = {}
|
played_at_by_round: dict[str, date] = {}
|
||||||
source_by_round: dict[str, str] = {}
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
by_round.setdefault(r["round_id"], []).append(r)
|
by_round.setdefault(r["round_id"], []).append(r)
|
||||||
stat_level_by_round[r["round_id"]] = r["stat_level"]
|
stat_level_by_round[r["round_id"]] = r["stat_level"]
|
||||||
played_at_by_round[r["round_id"]] = r["played_at"]
|
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
|
# Distinkte runder, nyest først -- rekkefølgen `rows` allerede har
|
||||||
# dict-ene -- `_resolve_stats_window`/`_summarize_rounds`/
|
# (ORDER BY r.played_at DESC) bevares siden Python-dicts er innsettings-
|
||||||
# `_build_round_series` forblir uendret, de aksesserer felt via
|
# ordnet, ingen ny sortering nødvendig.
|
||||||
# `h["felt"]` og bryr seg ikke om raden kom fra `round_hole` eller
|
ordered_rounds = [(rid, played_at_by_round[rid]) for rid in by_round]
|
||||||
# `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,
|
|
||||||
)
|
|
||||||
|
|
||||||
current_ids, previous_ids = _resolve_stats_window(window, ordered_rounds)
|
current_ids, previous_ids = _resolve_stats_window(window, ordered_rounds)
|
||||||
|
|
||||||
return RoundStatsWindowSummary(
|
return RoundStatsWindowSummary(
|
||||||
window=window,
|
window=window,
|
||||||
current=_summarize_rounds(current_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, source_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),
|
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);
|
# (samme "skjult til avslørt"-filosofi som visibility_mode/blind draw);
|
||||||
# None = ikke rør, samme konvensjon som resten av modellen her.
|
# None = ikke rør, samme konvensjon som resten av modellen her.
|
||||||
flag_map_visible: bool | None = None
|
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)
|
@router.patch("/rounds/{round_id}", response_model=RoundOut)
|
||||||
|
|
@ -2809,7 +2682,7 @@ async def update_round(
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||||
round_row = await conn.fetchrow(
|
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
|
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.")
|
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
|
resolved: _ResolvedCourse | None = None
|
||||||
participants = None
|
participants = None
|
||||||
if body.course_source is not None:
|
if body.course_source is not None:
|
||||||
|
|
@ -2917,10 +2773,6 @@ async def update_round(
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"UPDATE round SET flag_map_visible = $2 WHERE id = $1", round_id, body.flag_map_visible
|
"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:
|
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)
|
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:
|
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,
|
# 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.
|
# bare også eksponert rått slik at leaderboardet kan vise dem per hull.
|
||||||
holes: list[LeaderboardHoleOut]
|
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):
|
class LeaderboardOut(BaseModel):
|
||||||
holes_planned: int
|
holes_planned: int
|
||||||
completed: bool
|
completed: bool
|
||||||
entries: list[LeaderboardEntryOut]
|
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:
|
async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
"""Delt mellom den autentiserte GET og den offentlige/venn-gatede
|
||||||
/public/rounds-varianten (ADR-036 fase 2)."""
|
/public/rounds-varianten (ADR-036 fase 2)."""
|
||||||
round_row = await conn.fetchrow(
|
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(
|
participant_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
|
|
@ -5351,20 +5192,7 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
total_points = None
|
total_points = None
|
||||||
if total_score is not None and strokes_received_by_hole:
|
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)
|
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
|
net_score_to_par = score_to_par - strokes_received_total
|
||||||
# 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
|
|
||||||
)
|
|
||||||
# Samme stableford-formel som round-scorecard.tsx sin
|
# Samme stableford-formel som round-scorecard.tsx sin
|
||||||
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
|
# stablefordPoints(): 2 poeng for netto par, +/-1 per slag,
|
||||||
# gulvet på 0.
|
# gulvet på 0.
|
||||||
|
|
@ -5387,7 +5215,6 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
score_to_par=score_to_par,
|
score_to_par=score_to_par,
|
||||||
net_score_to_par=net_score_to_par,
|
net_score_to_par=net_score_to_par,
|
||||||
total_points=total_points,
|
total_points=total_points,
|
||||||
course_handicap=p["course_handicap_snapshot"],
|
|
||||||
holes=[
|
holes=[
|
||||||
LeaderboardHoleOut(
|
LeaderboardHoleOut(
|
||||||
hole_number=h["hole_number"],
|
hole_number=h["hole_number"],
|
||||||
|
|
@ -5409,7 +5236,6 @@ async def _build_leaderboard(conn, round_id: str) -> LeaderboardOut:
|
||||||
holes_planned=round_row["holes_planned"],
|
holes_planned=round_row["holes_planned"],
|
||||||
completed=round_row["completed_at"] is not None,
|
completed=round_row["completed_at"] is not None,
|
||||||
entries=entries,
|
entries=entries,
|
||||||
net_display_style=round_row["net_display_style"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 på 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)
|
|
||||||
|
|
@ -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), så 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`), så en spiller må 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)
|
|
||||||
|
|
@ -63,7 +63,7 @@ _TOURNAMENT_COLUMNS = """
|
||||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||||
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
|
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,
|
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).
|
# 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
|
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
|
||||||
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
|
||||||
tiebreak_playoff_rule: str | None
|
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:
|
def _tournament_from_row(row) -> Tournament:
|
||||||
|
|
@ -159,8 +151,7 @@ async def list_tournaments(
|
||||||
t.registration_requires_approval, t.hero_image_key,
|
t.registration_requires_approval, t.hero_image_key,
|
||||||
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
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.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.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
|
||||||
t.net_display_style
|
|
||||||
FROM tournament t
|
FROM tournament t
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT tournament_id,
|
SELECT tournament_id,
|
||||||
|
|
@ -256,16 +247,9 @@ class TournamentUpdate(BaseModel):
|
||||||
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
|
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
|
||||||
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
|
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
|
||||||
# for vinner-metoden, ikke felt-metoden.
|
# for vinner-metoden, ikke felt-metoden.
|
||||||
tiebreak_winner_method: str | None = Field(
|
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$")
|
||||||
default=None, pattern="^(none|countback|lowest_hcp|playoff|lowest_playing_hcp)$"
|
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
|
||||||
)
|
|
||||||
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|lowest_playing_hcp)$")
|
|
||||||
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
|
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
|
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||||
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||||
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||||
|
|
@ -298,25 +282,6 @@ async def update_tournament(
|
||||||
values.append(tournament_id)
|
values.append(tournament_id)
|
||||||
|
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
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(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
UPDATE tournament SET {', '.join(set_clauses)}
|
UPDATE tournament SET {', '.join(set_clauses)}
|
||||||
|
|
@ -962,14 +927,6 @@ class SessionOut(BaseModel):
|
||||||
shamble_best_n: int | None
|
shamble_best_n: int | None
|
||||||
locked_team_ids: list[str]
|
locked_team_ids: list[str]
|
||||||
revealed: bool
|
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:
|
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"],
|
shamble_best_n=row["shamble_best_n"],
|
||||||
locked_team_ids=sorted(locked),
|
locked_team_ids=sorted(locked),
|
||||||
revealed=len(locked) >= 2,
|
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,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
net_display_style
|
|
||||||
FROM session
|
FROM session
|
||||||
WHERE tournament_id = $1
|
WHERE tournament_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -1066,8 +1021,7 @@ async def create_session(
|
||||||
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
net_display_style
|
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -1104,11 +1058,6 @@ class SessionUpdate(BaseModel):
|
||||||
points_per_match: float | None = None
|
points_per_match: float | None = None
|
||||||
allowance_override: dict | None = None
|
allowance_override: dict | None = None
|
||||||
course_id: str | 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:
|
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():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
session = await conn.fetchrow(
|
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,
|
session_id,
|
||||||
)
|
)
|
||||||
if session is None:
|
if session is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
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)
|
new_course_id = updates.pop("course_id", None)
|
||||||
allowance_or_points_changed = "allowance_override" in updates or "points_per_match" in updates
|
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,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n,
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
net_display_style
|
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -1934,9 +1868,6 @@ class IndividualLeaderboard(BaseModel):
|
||||||
session_id: str
|
session_id: str
|
||||||
format: str
|
format: str
|
||||||
entries: list[IndividualLeaderboardEntry]
|
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(
|
async def fetch_individual_leaderboard(
|
||||||
|
|
@ -1945,8 +1876,7 @@ async def fetch_individual_leaderboard(
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
|
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
|
||||||
hole_config::text AS hole_config, course_id::text AS course_id,
|
hole_config::text AS hole_config, course_id::text AS course_id
|
||||||
net_display_style
|
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
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_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
|
||||||
strokes_by_hole = dict(zip(played, strokes_list))
|
strokes_by_hole = dict(zip(played, strokes_list))
|
||||||
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
|
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 = gross_score - strokes_received_total
|
||||||
# net_score/net_to_par bytter til hunt-semantikk (helt
|
net_to_par = gross_to_par - strokes_received_total
|
||||||
# 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
|
|
||||||
# Samme stableford-formel som round-leaderboardets
|
# Samme stableford-formel som round-leaderboardets
|
||||||
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
|
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
|
||||||
# per slag, gulvet på 0.
|
# per slag, gulvet på 0.
|
||||||
|
|
@ -2055,10 +1976,7 @@ async def fetch_individual_leaderboard(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return IndividualLeaderboard(
|
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
|
||||||
session_id=session_id, format=session["format"], entries=entries,
|
|
||||||
net_display_style=session["net_display_style"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,4 @@
|
||||||
import { PublicLive } from "@/components/public-live"
|
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({
|
export default async function PublicLivePage({
|
||||||
params,
|
params,
|
||||||
|
|
@ -20,23 +9,5 @@ export default async function PublicLivePage({
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const { code } = await searchParams
|
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} />
|
return <PublicLive tournamentId={id} code={code} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -550,21 +550,7 @@ function OfficialCourseList({
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ facility_slug: facility.slug, teeoff_course_id: course.teeoff_course_id }),
|
body: JSON.stringify({ facility_slug: facility.slug, teeoff_course_id: course.teeoff_course_id }),
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`import: ${res.status}`)
|
||||||
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
|
|
||||||
}
|
|
||||||
const imported: { id: string; name: string } = await res.json()
|
const imported: { id: string; name: string } = await res.json()
|
||||||
const [holesRes, teesRes] = await Promise.all([
|
const [holesRes, teesRes] = await Promise.all([
|
||||||
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),
|
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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 nå
|
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Radio,
|
|
||||||
Send,
|
Send,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
|
@ -72,7 +71,6 @@ type ApiTournamentInfo = {
|
||||||
name: string
|
name: string
|
||||||
organization_name: string
|
organization_name: string
|
||||||
status: string
|
status: string
|
||||||
format_type: string
|
|
||||||
visibility: string
|
visibility: string
|
||||||
description: string | null
|
description: string | null
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
|
|
@ -92,19 +90,6 @@ type ApiSession = {
|
||||||
scheduled_at: string | null
|
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 {
|
function mapOutcome(status: string): Outcome {
|
||||||
return status === "waitlisted" ? "waitlist" : (status as 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 }) {
|
export function PublicTournament({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||||||
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
|
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
|
||||||
const [sessions, setSessions] = useState<Session[]>([])
|
const [sessions, setSessions] = useState<Session[]>([])
|
||||||
const [rounds, setRounds] = useState<ApiRound[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accessError, setAccessError] = useState<string | null>(null)
|
const [accessError, setAccessError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -163,30 +147,20 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setInfo(data)
|
setInfo(data)
|
||||||
|
|
||||||
if (data.format_type === "individual") {
|
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
|
||||||
const roundsRes = await fetch(`/public/tournaments/${tournamentId}/rounds${codeParam}`, {
|
credentials: "include",
|
||||||
credentials: "include",
|
})
|
||||||
})
|
if (sessionsRes.ok) {
|
||||||
if (roundsRes.ok) {
|
const sessionData: ApiSession[] = await sessionsRes.json()
|
||||||
const roundData: ApiRound[] = await roundsRes.json()
|
if (!cancelled) {
|
||||||
if (!cancelled) setRounds(roundData)
|
setSessions(
|
||||||
}
|
sessionData.map((s) => ({
|
||||||
} else {
|
id: s.id,
|
||||||
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions${codeParam}`, {
|
name: s.name,
|
||||||
credentials: "include",
|
format: s.format,
|
||||||
})
|
time: s.scheduled_at ?? undefined,
|
||||||
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 {
|
} catch {
|
||||||
|
|
@ -282,7 +256,7 @@ export function PublicTournament({ tournamentId, code }: { tournamentId: string;
|
||||||
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
|
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{info.format_type === "individual" ? <ProgramRounds rounds={rounds} /> : <Program sessions={t.sessions} />}
|
<Program sessions={t.sessions} />
|
||||||
|
|
||||||
{info.registration_open ? (
|
{info.registration_open ? (
|
||||||
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
|
<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 nå
|
|
||||||
</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 --------------------------------------------------------------
|
// --- Sponsors --------------------------------------------------------------
|
||||||
|
|
||||||
function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
|
function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
MapPin,
|
MapPin,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Ruler,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
|
@ -55,10 +56,11 @@ import {
|
||||||
Stepper,
|
Stepper,
|
||||||
WizardSection,
|
WizardSection,
|
||||||
} from "@/components/hole-stat-inputs"
|
} 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 { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { HoleHistoryDetail } from "@/components/hole-history-detail"
|
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"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
// --- Types -----------------------------------------------------------------
|
// --- Types -----------------------------------------------------------------
|
||||||
|
|
@ -259,9 +261,6 @@ type ApiRound = {
|
||||||
flight_group_id: string | null
|
flight_group_id: string | null
|
||||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
flag_map_visible: boolean
|
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
|
// 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, så dette er nå rendyrket "se mer detaljer"
|
løpende til-par, så dette er nå rendyrket "se mer detaljer"
|
||||||
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
||||||
score-registreringen. */}
|
score-registreringen. */}
|
||||||
{!completed && (
|
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
||||||
<ScoreSoFar
|
|
||||||
holes={apiHoles}
|
|
||||||
order={holeOrder}
|
|
||||||
courseHandicap={activePlayer.courseHandicap}
|
|
||||||
netDisplayStyle={round.net_display_style}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1813,10 +1805,9 @@ export function RoundDetail({ roundId }: { roundId: string }) {
|
||||||
{round.sides.map((s, i) => (
|
{round.sides.map((s, i) => (
|
||||||
<ShotMeasurementEntry
|
<ShotMeasurementEntry
|
||||||
key={s.id}
|
key={s.id}
|
||||||
shotsUrl={`/rounds/${roundId}/sides/${s.id}/holes/${activeHole}/shots`}
|
roundId={roundId}
|
||||||
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
|
|
||||||
targetPointsUrl={`/rounds/${roundId}/holes/${activeHole}/target-points`}
|
|
||||||
holeNumber={activeHole}
|
holeNumber={activeHole}
|
||||||
|
owner={{ kind: "side", id: s.id }}
|
||||||
ownBagClubs={ownBagClubs}
|
ownBagClubs={ownBagClubs}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
variant="badge"
|
variant="badge"
|
||||||
|
|
@ -2294,10 +2285,9 @@ function ScoringWizard({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShotMeasurementEntry
|
<ShotMeasurementEntry
|
||||||
shotsUrl={`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`}
|
roundId={roundId}
|
||||||
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
|
|
||||||
targetPointsUrl={`/rounds/${roundId}/holes/${hole.holeNumber}/target-points`}
|
|
||||||
holeNumber={hole.holeNumber}
|
holeNumber={hole.holeNumber}
|
||||||
|
owner={{ kind: "participant", id: player.id }}
|
||||||
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
ownBagClubs={player.isSelf ? ownBagClubs : []}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
variant="inline"
|
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 ------------------------------------------------------
|
// --- Completed banner ------------------------------------------------------
|
||||||
|
|
||||||
function AllHolesEnteredBanner({ completing, onComplete }: { completing: boolean; onComplete: () => void }) {
|
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
|
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScoreSoFar({
|
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
|
||||||
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"
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
if (!holes || holes.length === 0) return null
|
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).
|
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
|
||||||
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
||||||
const hasNetto = withNetto.length > 0
|
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
|
const nettoTotal = hasNetto
|
||||||
? netDisplayStyle === "hunt" && courseHandicap !== null && courseHandicap !== undefined
|
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
|
||||||
? totalStrokes - courseHandicap
|
|
||||||
: withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
|
|
||||||
: null
|
: null
|
||||||
const stablefordTotal = hasNetto
|
const stablefordTotal = hasNetto
|
||||||
? withNetto.reduce(
|
? withNetto.reduce(
|
||||||
|
|
@ -4572,9 +4940,6 @@ function EditRoundPanel({
|
||||||
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
||||||
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
||||||
const [flagMapVisible, setFlagMapVisible] = useState(round.flag_map_visible)
|
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 [saving, setSaving] = useState(false)
|
||||||
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -4591,8 +4956,7 @@ function EditRoundPanel({
|
||||||
playFormat !== currentEditablePlayFormat ||
|
playFormat !== currentEditablePlayFormat ||
|
||||||
visibilityMode !== round.visibility_mode ||
|
visibilityMode !== round.visibility_mode ||
|
||||||
(visibilityMode === "friends" && !sameCategories) ||
|
(visibilityMode === "friends" && !sameCategories) ||
|
||||||
flagMapVisible !== round.flag_map_visible ||
|
flagMapVisible !== round.flag_map_visible
|
||||||
netDisplayStyle !== round.net_display_style
|
|
||||||
|
|
||||||
async function saveMetadata() {
|
async function saveMetadata() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
@ -4613,7 +4977,6 @@ function EditRoundPanel({
|
||||||
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
||||||
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
||||||
if (flagMapVisible !== round.flag_map_visible) body.flag_map_visible = flagMapVisible
|
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)
|
const result = await onPatch(body)
|
||||||
if (!result.ok) setError(result.message)
|
if (!result.ok) setError(result.message)
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|
@ -4804,42 +5167,6 @@ function EditRoundPanel({
|
||||||
</div>
|
</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 på 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 && (
|
{!isCompleted && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-2">
|
<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
|
// linjer avhengig av runden), så en fast pixel-gjetning viste seg feil for
|
||||||
// en runde med lengre innhold enn den opprinnelige gjetningen (112px) var
|
// en runde med lengre innhold enn den opprinnelige gjetningen (112px) var
|
||||||
// kalibrert mot (scratch-verifisering 2026-08-20: ekte høyde 141,5px for
|
// kalibrert mot (scratch-verifisering 2026-08-20: ekte høyde 141,5px for
|
||||||
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av
|
// en runde med bane+utslag+dato+klokkeslett samtidig). Brukt av BÅDE
|
||||||
// scroll-hoppene til spillerlisten/hull-panelet under.
|
// scroll-hoppet til spillerlisten og `playerListReached`-sjekken under.
|
||||||
function getHeaderHeight(): number {
|
function getHeaderHeight(): number {
|
||||||
return document.querySelector("header")?.getBoundingClientRect().height ?? 112
|
return document.querySelector("header")?.getBoundingClientRect().height ?? 112
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pusterom under headeren når det scrolles til spillerlisten/hull-panelet
|
// Pusterom under headeren når spillerlisten scrolles til -- brukt for
|
||||||
// -- se `scrollToPlayerList()`/`scrollToHolePanel()` under.
|
// 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
|
const SCROLL_GAP_PX = 16
|
||||||
|
|
||||||
// "Registrer score" -- den vanlige, i normal side-flyt-plasserte knappen
|
// "Registrer score" -- samme knapp, to plasseringer (se PlayerHoleCards):
|
||||||
// (se PlayerHoleCards). Den kompakte, hjørne-forankrede "Score"-FAB-en som
|
// i normal side-flyt når banekartet er kollapset, flytende festet nederst
|
||||||
// dukker opp når DENNE ruller ut av syne er en egen komponent,
|
// på skjermen når det er utvidet (V0-eksport, 2026-08-20, brukerønske --
|
||||||
// `CompactScoreFab` (redesignet 2026-08-23, se CHANGELOG) -- ikke lenger
|
// det utvidede diagrammet kunne dytte knappen langt utenfor synlig område).
|
||||||
// samme knapp flyttet til en flytende posisjon.
|
|
||||||
function RegisterScoreButton({ onClick }: { onClick: () => void }) {
|
function RegisterScoreButton({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -6482,20 +6813,62 @@ function PlayerHoleCards({
|
||||||
const [mapExpanded, setMapExpanded] = useState(false)
|
const [mapExpanded, setMapExpanded] = useState(false)
|
||||||
const [distanceAvailable, setDistanceAvailable] = useState(false)
|
const [distanceAvailable, setDistanceAvailable] = useState(false)
|
||||||
const playerListRef = useRef<HTMLUListElement>(null)
|
const playerListRef = useRef<HTMLUListElement>(null)
|
||||||
// Flytende "Score"-hurtigknapp (CompactScoreFab, redesignet 2026-08-23
|
// Flytende "Registrer score" (2026-08-20) skal kun vises mens
|
||||||
// -- se CHANGELOG): en liten, hjørne-forankret FAB nederst til høyre,
|
// spillerlisten fortsatt er UTENFOR synlig område -- brukerfunn: den
|
||||||
// IKKE lenger et fullbredde-bunnbanner. Trenger derfor ikke lenger noe
|
// hang igjen synlig (og dekket delvis over spillerkort 1) selv etter at
|
||||||
// eget "har brukeren nådd spillerlisten"-signal for å skjule seg igjen
|
// brukeren allerede hadde scrollet forbi/til spillerlisten, der den ikke
|
||||||
// (den tidligere `playerListReached`-mekanismen, fjernet) -- en liten
|
// lenger gir mening ("Registrer score ↓" når du allerede ser
|
||||||
// knapp i et hjørne dekker ikke nevneverdig over innhold under, uansett
|
// spillerne). "Reached" = listens toppkant har nådd/passert headerens
|
||||||
// hvor langt ned siden brukeren har scrollet. Eneste synlighets-signal
|
// faktiske høyde -- forblir sant BÅDE mens listen vises OG etter at
|
||||||
// som gjenstår er `inFlowButtonVisible` under: er selve den vanlige,
|
// brukeren har scrollet helt forbi den (listen ligger da over synlig
|
||||||
// side-flyt-plasserte knappen synlig akkurat nå. Målt direkte med
|
// område, `top` er negativ). Kun scroll TILBAKE OPP forbi listen gjør
|
||||||
// `getBoundingClientRect()` + scroll/resize-lytter (ikke
|
// at knappen skal dukke opp igjen.
|
||||||
// IntersectionObserver -- viste seg upålitelig i scratch-verifisering
|
//
|
||||||
// 2026-08-20: med standard threshold=[0] fyrer den kun ved
|
// Første forsøk brukte IntersectionObserver med en krympet rootMargin
|
||||||
// 0%->>0%-kryssinger, ikke ved at `top`/`bottom` passerer en INTERN
|
// -- viste seg FEIL i scratch-verifisering: med standard threshold=[0]
|
||||||
// linje midt i et allerede-overlappende element).
|
// 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 inFlowButtonRef = useRef<HTMLDivElement>(null)
|
||||||
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
|
const [inFlowButtonVisible, setInFlowButtonVisible] = useState(true)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -6825,10 +7198,9 @@ function PlayerHoleCards({
|
||||||
measureShot={
|
measureShot={
|
||||||
player.isSelf && !readOnly ? (
|
player.isSelf && !readOnly ? (
|
||||||
<ShotMeasurementEntry
|
<ShotMeasurementEntry
|
||||||
shotsUrl={`/rounds/${roundId}/participants/${player.id}/holes/${currentHole}/shots`}
|
roundId={roundId}
|
||||||
shotsRoundBaseUrl={`/rounds/${roundId}/shots`}
|
|
||||||
targetPointsUrl={`/rounds/${roundId}/holes/${currentHole}/target-points`}
|
|
||||||
holeNumber={currentHole}
|
holeNumber={currentHole}
|
||||||
|
owner={{ kind: "participant", id: player.id }}
|
||||||
ownBagClubs={ownBagClubs}
|
ownBagClubs={ownBagClubs}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
variant="badge"
|
variant="badge"
|
||||||
|
|
@ -6908,17 +7280,47 @@ function PlayerHoleCards({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Kompakt "Score"-hurtigknapp (CompactScoreFab, redesignet
|
{/* Flytende "Registrer score" (V0-eksport, 2026-08-20, utløser-
|
||||||
2026-08-23 -- se CHANGELOG). Erstatter det tidligere fullbredde-
|
logikk rettet samme dag) -- vises når side-flyt-knappen over
|
||||||
bunnbanneret, som hadde tre runder med skjul/vis-bugs (#141/142,
|
IKKE er synlig (uansett ÅRSAK: utvidet banekart ELLER en lang
|
||||||
samt en fjerde runde der den ble hengende synlig over
|
hindringsliste alene, se `inFlowButtonVisible`-kommentaren over)
|
||||||
Kommentarer-seksjonen). En liten, hjørne-forankret FAB trenger
|
OG spillerlisten fortsatt er utenfor synlig område (se
|
||||||
ikke den samme skjul-igjen-mekanismen -- den er liten nok til at
|
`playerListReached` -- uten denne dekket den delvis over
|
||||||
den ikke dekker nevneverdig over innhold, uansett hvor langt ned
|
spillerkort 1 selv etter at brukeren allerede hadde scrollet
|
||||||
siden brukeren har scrollet. Vises alene når
|
dit). `fixed` posisjonerer mot selve viewporten (ingen ancestor
|
||||||
`!inFlowButtonVisible` (den vanlige, side-flyt-plasserte knappen
|
her setter transform/filter, så det er trygt), gradient-scrim +
|
||||||
er utenfor synlig område). */}
|
skygge løfter den visuelt over innholdet under,
|
||||||
<CompactScoreFab visible={!inFlowButtonVisible} onClick={scrollToPlayerList} />
|
`env(safe-area-inset-bottom)` unngår at den klemmes helt inntil
|
||||||
|
kanten på telefoner med gest-navigasjon. `pointer-events-none`
|
||||||
|
på selve scrim-sonen når skjult/i overgang, slik at den aldri
|
||||||
|
blokkerer klikk på 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
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 { cn } from "@/lib/utils"
|
||||||
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/scramble-solo-result"
|
||||||
import { MatchScorecardGrid } from "@/components/round-scorecard"
|
import { MatchScorecardGrid } from "@/components/round-scorecard"
|
||||||
|
|
@ -49,16 +49,12 @@ type ApiLeaderboardEntry = {
|
||||||
net_score_to_par: number | null
|
net_score_to_par: number | null
|
||||||
total_points: number | null
|
total_points: number | null
|
||||||
holes: ApiLeaderboardHole[]
|
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 = {
|
type ApiLeaderboard = {
|
||||||
holes_planned: number
|
holes_planned: number
|
||||||
completed: boolean
|
completed: boolean
|
||||||
entries: ApiLeaderboardEntry[]
|
entries: ApiLeaderboardEntry[]
|
||||||
net_display_style: "standard" | "hunt"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiRoundParticipant = {
|
type ApiRoundParticipant = {
|
||||||
|
|
@ -354,10 +350,6 @@ type IndividualPlayer = {
|
||||||
finished?: boolean
|
finished?: boolean
|
||||||
gross: (number | null)[]
|
gross: (number | null)[]
|
||||||
holePoints?: (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 {
|
function hasHcp(p: IndividualPlayer): boolean {
|
||||||
|
|
@ -393,19 +385,6 @@ function nettoToPar(p: IndividualPlayer, holes: Hole[]): number | null {
|
||||||
return diff
|
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 {
|
function stablefordTotal(p: IndividualPlayer, holes: Hole[]): number | null {
|
||||||
if (!hasHcp(p)) return null
|
if (!hasHcp(p)) return null
|
||||||
let pts = 0
|
let pts = 0
|
||||||
|
|
@ -425,7 +404,6 @@ function evalMetric(
|
||||||
metric: MetricKey,
|
metric: MetricKey,
|
||||||
pointsUnit: string,
|
pointsUnit: string,
|
||||||
holes: Hole[],
|
holes: Hole[],
|
||||||
netDisplayStyle: "standard" | "hunt" = "standard",
|
|
||||||
): {
|
): {
|
||||||
rankable: boolean
|
rankable: boolean
|
||||||
sortVal: number
|
sortVal: number
|
||||||
|
|
@ -438,7 +416,7 @@ function evalMetric(
|
||||||
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||||||
}
|
}
|
||||||
if (metric === "netto") {
|
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 /> }
|
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" /> }
|
return { rankable: true, sortVal: v, higherWins: false, pill: <MetricPill kind="topar" value={v} size="lg" /> }
|
||||||
}
|
}
|
||||||
|
|
@ -466,7 +444,6 @@ function IndividualBoard({
|
||||||
pointsUnit = "p",
|
pointsUnit = "p",
|
||||||
holes,
|
holes,
|
||||||
commentsHref,
|
commentsHref,
|
||||||
netDisplayStyle = "standard",
|
|
||||||
}: {
|
}: {
|
||||||
players: IndividualPlayer[]
|
players: IndividualPlayer[]
|
||||||
metrics: MetricKey[]
|
metrics: MetricKey[]
|
||||||
|
|
@ -474,13 +451,11 @@ function IndividualBoard({
|
||||||
pointsUnit?: string
|
pointsUnit?: string
|
||||||
holes: Hole[]
|
holes: Hole[]
|
||||||
commentsHref: string
|
commentsHref: string
|
||||||
// "Jakter Jesper" (migrasjon 093) -- fra round.net_display_style.
|
|
||||||
netDisplayStyle?: "standard" | "hunt"
|
|
||||||
}) {
|
}) {
|
||||||
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
const [metric, setMetric] = useState<MetricKey>(defaultMetric)
|
||||||
const [openId, setOpenId] = useState<string | null>(null)
|
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 rankable = evaluated.filter((e) => e.rankable)
|
||||||
const unranked = 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))
|
rankable.sort((a, b) => (a.higherWins ? b.sortVal - a.sortVal : a.sortVal - b.sortVal))
|
||||||
|
|
@ -1581,7 +1556,6 @@ function buildIndividualPlayers(
|
||||||
finished: false,
|
finished: false,
|
||||||
gross,
|
gross,
|
||||||
holePoints,
|
holePoints,
|
||||||
courseHandicap: e.course_handicap,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1828,14 +1802,6 @@ export function RoundLeaderboard({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
<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 på minus spillehandicapet sitt -- lavest sluttsum vinner.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<IndividualBoard
|
<IndividualBoard
|
||||||
players={players}
|
players={players}
|
||||||
metrics={config.metrics}
|
metrics={config.metrics}
|
||||||
|
|
@ -1843,7 +1809,6 @@ export function RoundLeaderboard({
|
||||||
pointsUnit={config.pointsUnit}
|
pointsUnit={config.pointsUnit}
|
||||||
holes={holes}
|
holes={holes}
|
||||||
commentsHref={commentsHref}
|
commentsHref={commentsHref}
|
||||||
netDisplayStyle={leaderboard.net_display_style}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ type ApiRoundSeriesPoint = {
|
||||||
|
|
||||||
type ApiStatsSummary = {
|
type ApiStatsSummary = {
|
||||||
rounds_completed: number
|
rounds_completed: number
|
||||||
rounds_completed_personal: number
|
|
||||||
rounds_completed_tournament: number
|
|
||||||
avg_score_to_par: number | null
|
avg_score_to_par: number | null
|
||||||
avg_putts_per_18: number | null
|
avg_putts_per_18: number | null
|
||||||
putts_tracked_rounds: number
|
putts_tracked_rounds: number
|
||||||
|
|
@ -837,9 +835,6 @@ export function RoundsStatsSummary() {
|
||||||
<>
|
<>
|
||||||
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
<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"}
|
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 && (
|
{p.rounds_completed > 0 && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Target, Trophy } from "lucide-react"
|
import { ArrowLeft, Trophy } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type ApiEntry = {
|
type ApiEntry = {
|
||||||
|
|
@ -37,11 +37,6 @@ type ApiLeaderboard = {
|
||||||
session_id: string
|
session_id: string
|
||||||
format: string
|
format: string
|
||||||
entries: ApiEntry[]
|
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> = {
|
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>
|
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
|
||||||
</div>
|
</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 på minus spillehandicapet sitt -- lavest sluttsum vinner.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.entries.length === 0 ? (
|
{data.entries.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
<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.
|
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
|
||||||
|
|
|
||||||
|
|
@ -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 nå 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 på 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// --- Types (exact contract — do not change) --------------------------------
|
// --- 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({
|
export function StrokePlayLeaderboard({
|
||||||
rows = MOCK_ROWS,
|
rows = MOCK_ROWS,
|
||||||
caption = "Resultattavle",
|
caption = "Resultattavle",
|
||||||
expandedId = null,
|
|
||||||
onToggleExpand,
|
|
||||||
renderExpanded,
|
|
||||||
}: {
|
}: {
|
||||||
rows?: LeaderboardRow[]
|
rows?: LeaderboardRow[]
|
||||||
caption?: string
|
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 reducedMotion = usePrefersReducedMotion()
|
||||||
const [playing, setPlaying] = useState(true)
|
const [playing, setPlaying] = useState(true)
|
||||||
|
|
@ -375,15 +358,7 @@ export function StrokePlayLeaderboard({
|
||||||
return field.map((row, i) => (
|
return field.map((row, i) => (
|
||||||
<Fragment key={`field-${row.playerName}`}>
|
<Fragment key={`field-${row.playerName}`}>
|
||||||
{i === firstCutIndex && <CutLineRow totalCols={totalCols} />}
|
{i === firstCutIndex && <CutLineRow totalCols={totalCols} />}
|
||||||
<FieldRow
|
<FieldRow row={row} roundNumbers={roundNumbers} striped={i % 2 === 1} />
|
||||||
row={row}
|
|
||||||
roundNumbers={roundNumbers}
|
|
||||||
striped={i % 2 === 1}
|
|
||||||
expanded={expandedId === row.playerName}
|
|
||||||
onToggleExpand={onToggleExpand ? () => onToggleExpand(row) : undefined}
|
|
||||||
renderExpanded={renderExpanded}
|
|
||||||
totalCols={totalCols}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
|
|
@ -506,18 +481,10 @@ function FieldRow({
|
||||||
row,
|
row,
|
||||||
roundNumbers,
|
roundNumbers,
|
||||||
striped,
|
striped,
|
||||||
expanded,
|
|
||||||
onToggleExpand,
|
|
||||||
renderExpanded,
|
|
||||||
totalCols,
|
|
||||||
}: {
|
}: {
|
||||||
row: LeaderboardRow
|
row: LeaderboardRow
|
||||||
roundNumbers: number[]
|
roundNumbers: number[]
|
||||||
striped: boolean
|
striped: boolean
|
||||||
expanded: boolean
|
|
||||||
onToggleExpand?: () => void
|
|
||||||
renderExpanded?: (row: LeaderboardRow) => React.ReactNode
|
|
||||||
totalCols: number
|
|
||||||
}) {
|
}) {
|
||||||
const status = row.totalLabel != null && isStatus(row.totalLabel)
|
const status = row.totalLabel != null && isStatus(row.totalLabel)
|
||||||
// Hasn't played a single hole yet anywhere in the tournament (backend
|
// 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 notStarted = !status && row.totalLabel == null
|
||||||
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
|
const bg = striped ? "bg-clubhouse-field" : "bg-clubhouse-card"
|
||||||
const border = "border-b border-clubhouse-border"
|
const border = "border-b border-clubhouse-border"
|
||||||
const canExpand = Boolean(onToggleExpand && renderExpanded)
|
|
||||||
const panelId = `stroke-play-holes-${row.playerName}`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<tr className={cn(bg, (status || notStarted) && "opacity-70")}>
|
||||||
<tr
|
<td className={cn(cellPad, bg, border, "sticky left-0 z-10 text-center")}>
|
||||||
className={cn(bg, (status || notStarted) && "opacity-70", canExpand && "cursor-pointer")}
|
<span className="text-base font-bold text-clubhouse-ink xl:text-3xl">{row.position}</span>
|
||||||
{...(canExpand
|
</td>
|
||||||
? {
|
<td className={cn(cellPad, bg, border, "sticky z-10 text-left", PLAYER_LEFT)}>
|
||||||
onClick: onToggleExpand,
|
<span className={cn("block truncate text-base font-semibold xl:text-2xl", (status || notStarted) ? "text-clubhouse-muted" : "text-clubhouse-ink")}>
|
||||||
onKeyDown: (e: React.KeyboardEvent) => {
|
{row.playerName}
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
</span>
|
||||||
e.preventDefault()
|
</td>
|
||||||
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>
|
|
||||||
|
|
||||||
{status ? (
|
{status ? (
|
||||||
<>
|
<>
|
||||||
<DashTd bg={bg} border={border} />
|
<DashTd bg={bg} border={border} />
|
||||||
<DashTd bg={bg} border={border} />
|
<DashTd bg={bg} border={border} />
|
||||||
<td className={cn(cellPad, bg, border, "text-right")}>
|
<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">
|
<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}
|
{row.totalLabel}
|
||||||
</span>
|
</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)}
|
|
||||||
</td>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Camera,
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Globe,
|
Globe,
|
||||||
Lock,
|
Lock,
|
||||||
|
|
@ -106,21 +105,6 @@ export function TournamentPresentationPanel({
|
||||||
const [sponsorName, setSponsorName] = useState("")
|
const [sponsorName, setSponsorName] = useState("")
|
||||||
const [sponsorUrl, setSponsorUrl] = useState("")
|
const [sponsorUrl, setSponsorUrl] = useState("")
|
||||||
const [addingSponsor, setAddingSponsor] = useState(false)
|
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 [sponsorError, setSponsorError] = useState<string | null>(null)
|
||||||
const [logoTargetId, setLogoTargetId] = useState<string | null>(null)
|
const [logoTargetId, setLogoTargetId] = useState<string | null>(null)
|
||||||
const [uploadingLogoFor, setUploadingLogoFor] = useState<string | null>(null)
|
const [uploadingLogoFor, setUploadingLogoFor] = useState<string | null>(null)
|
||||||
|
|
@ -298,7 +282,7 @@ export function TournamentPresentationPanel({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<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">
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
Hvordan turneringen vises på sin offentlige side (
|
Hvordan turneringen vises på sin offentlige side (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -311,18 +295,6 @@ export function TournamentPresentationPanel({
|
||||||
</Link>
|
</Link>
|
||||||
) og klubbsiden.
|
) og klubbsiden.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,6 @@ type ApiSession = {
|
||||||
start_mode: "consecutive" | "shotgun"
|
start_mode: "consecutive" | "shotgun"
|
||||||
locked_team_ids: string[]
|
locked_team_ids: string[]
|
||||||
revealed: boolean
|
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 ----------------------------------------------
|
// --- Norwegian display labels ----------------------------------------------
|
||||||
|
|
@ -882,15 +878,10 @@ function EditSessionForm({
|
||||||
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
||||||
const [courseId, setCourseId] = useState(session.course_id)
|
const [courseId, setCourseId] = useState(session.course_id)
|
||||||
const [courseName, setCourseName] = useState(course?.name ?? "")
|
const [courseName, setCourseName] = useState(course?.name ?? "")
|
||||||
const [netDisplayStyle, setNetDisplayStyle] = useState(session.net_display_style)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const hasTime = scheduledAt.trim() !== ""
|
const hasTime = scheduledAt.trim() !== ""
|
||||||
const uid = session.id
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -908,9 +899,6 @@ function EditSessionForm({
|
||||||
tee_interval_minutes:
|
tee_interval_minutes:
|
||||||
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
||||||
course_id: courseId,
|
course_id: courseId,
|
||||||
...(supportsNetDisplayStyle && netDisplayStyle !== session.net_display_style
|
|
||||||
? { net_display_style: netDisplayStyle }
|
|
||||||
: {}),
|
|
||||||
})
|
})
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -1059,37 +1047,6 @@ function EditSessionForm({
|
||||||
</div>
|
</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 på 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">
|
<div className="flex gap-3">
|
||||||
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
||||||
{submitting ? "Lagrer…" : "Lagre"}
|
{submitting ? "Lagrer…" : "Lagre"}
|
||||||
|
|
@ -1872,11 +1829,6 @@ function OfficialCourseSearch({
|
||||||
const code = body?.detail?.code
|
const code = body?.detail?.code
|
||||||
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
||||||
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
|
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") {
|
} else if (code === "DUPLICATE") {
|
||||||
setError("Denne banen er allerede importert til organisasjonen.")
|
setError("Denne banen er allerede importert til organisasjonen.")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2081,11 +2033,6 @@ function InternationalCourseSearch({
|
||||||
const code = body?.detail?.code
|
const code = body?.detail?.code
|
||||||
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
||||||
setError("Denne banen mangler nok data hos GolfAPI til å importeres ennå.")
|
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") {
|
} else if (code === "NOT_CONFIGURED") {
|
||||||
setError("GolfAPI er ikke konfigurert ennå.")
|
setError("GolfAPI er ikke konfigurert ennå.")
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -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 på 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"} på 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 nå
|
|
||||||
</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 på 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
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
// egen rad"-mønster som tournament-detail.tsx allerede bruker for join_code.
|
// egen rad"-mønster som tournament-detail.tsx allerede bruker for join_code.
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { TournamentDetail } from "@/components/tournament-detail"
|
import { TournamentDetail } from "@/components/tournament-detail"
|
||||||
import { IndividualTournamentDetail } from "@/components/individual-tournament-detail"
|
import { IndividualTournamentDetail } from "@/components/individual-tournament-detail"
|
||||||
|
|
||||||
|
|
@ -21,30 +20,13 @@ export function TournamentRouter({
|
||||||
tournamentId: string
|
tournamentId: string
|
||||||
tournamentName: string
|
tournamentName: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
|
||||||
const [formatType, setFormatType] = useState<"team" | "individual" | null>(null)
|
const [formatType, setFormatType] = useState<"team" | "individual" | null>(null)
|
||||||
const [error, setError] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" })
|
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" })
|
||||||
.then((res) => {
|
.then((res) => (res.ok ? res.json() : Promise.reject()))
|
||||||
if (res.status === 401) {
|
|
||||||
if (!cancelled) setNotAuthenticated(true)
|
|
||||||
return Promise.reject()
|
|
||||||
}
|
|
||||||
return res.ok ? res.json() : Promise.reject()
|
|
||||||
})
|
|
||||||
.then((list: { id: string; format_type: string }[]) => {
|
.then((list: { id: string; format_type: string }[]) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const mine = list.find((t) => t.id === tournamentId)
|
const mine = list.find((t) => t.id === tournamentId)
|
||||||
|
|
@ -58,27 +40,10 @@ export function TournamentRouter({
|
||||||
}
|
}
|
||||||
}, [organizationId, tournamentId])
|
}, [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) {
|
if (error) {
|
||||||
// Samme trygge fallback som resten av appen: en ukjent/utilgjengelig
|
// Samme trygge fallback som resten av appen: en ukjent/utilgjengelig
|
||||||
// turnering skal ikke krasje -- TournamentDetail sin egen feilhåndtering
|
// turnering skal ikke krasje -- TournamentDetail sin egen feilhåndtering
|
||||||
// (join_code/teams/players) tar over og viser en tydelig feilmelding.
|
// (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 (
|
return (
|
||||||
<TournamentDetail organizationId={organizationId} tournamentId={tournamentId} tournamentName={tournamentName} />
|
<TournamentDetail organizationId={organizationId} tournamentId={tournamentId} tournamentName={tournamentName} />
|
||||||
)
|
)
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -69,12 +69,6 @@ Dette er skjermens største enkeltløft.
|
||||||
`text-sm muted-foreground`. Fast rad-høyde (`min-h-[Npx]` + `truncate`)
|
`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
|
så rader med ulik navnelengde blir like høye (DS-mønsteret fra
|
||||||
2026-07-26).
|
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 = kolonner**, horisontalt scrollbare. Kolonnetopp:
|
||||||
hull-nr (`font-bold`), Par og evt. HCP-indeks under i
|
hull-nr (`font-bold`), Par og evt. HCP-indeks under i
|
||||||
`text-sm muted-foreground`. Aktivt/valgt hull markeres med `bg-primary`
|
`text-sm muted-foreground`. Aktivt/valgt hull markeres med `bg-primary`
|
||||||
|
|
|
||||||
|
|
@ -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).
|
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.auth import CurrentUser
|
||||||
from app.routers.individual_tournaments import (
|
from app.routers.individual_tournaments import (
|
||||||
HoleUpdate as TournamentHoleUpdate,
|
HoleUpdate as TournamentHoleUpdate,
|
||||||
individual_leaderboard,
|
individual_leaderboard,
|
||||||
leaderboard_participant_round_holes,
|
|
||||||
update_hole as tournament_update_hole,
|
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].total_label is None
|
||||||
assert entries[0].position is None
|
assert entries[0].position is None
|
||||||
assert entries[0].is_leader is False
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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 på."""
|
|
||||||
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), så 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
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
"""
|
|
||||||
Flight/gruppe-scoping i ScoreTab (ADR-099, 2026-08-21). list_round_participants
|
|
||||||
speiler nå 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 rå 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
|
|
||||||
|
|
@ -13,7 +13,6 @@ får alltid tournament_round_group_id=NULL).
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.auth import CurrentUser
|
|
||||||
from app.routers.individual_tournaments import (
|
from app.routers.individual_tournaments import (
|
||||||
RoundGroupIn,
|
RoundGroupIn,
|
||||||
RoundParticipantTeeUpdate,
|
RoundParticipantTeeUpdate,
|
||||||
|
|
@ -31,7 +30,6 @@ from tests.conftest import (
|
||||||
create_tournament_participant,
|
create_tournament_participant,
|
||||||
create_tournament_round,
|
create_tournament_round,
|
||||||
create_tournament_round_participant,
|
create_tournament_round_participant,
|
||||||
create_user,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import app.db as app_db
|
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
|
assert group_id_before is not None
|
||||||
|
|
||||||
user_id = await create_user()
|
|
||||||
updated = await update_round_participant_tee(
|
updated = await update_round_participant_tee(
|
||||||
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b),
|
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b), organization_id=org_id
|
||||||
organization_id=org_id, user=CurrentUser(user_id=user_id),
|
|
||||||
)
|
)
|
||||||
assert updated.tee_id == tee_b
|
assert updated.tee_id == tee_b
|
||||||
assert updated.id == rp_id # samme rad, ikke slett+gjenopprett
|
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")
|
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å")
|
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:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
await update_round_participant_tee(
|
await update_round_participant_tee(
|
||||||
tournament_id, round_id, rp_id,
|
tournament_id, round_id, rp_id,
|
||||||
RoundParticipantTeeUpdate(tee_id=foreign_tee_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.status_code == 400
|
||||||
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"
|
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"
|
||||||
|
|
|
||||||
|
|
@ -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 rå 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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 == []
|
|
||||||
|
|
@ -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 må 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
|
|
||||||
Loading…
Reference in a new issue