Compare commits

..

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

161 changed files with 1769 additions and 35556 deletions

View file

@ -466,80 +466,7 @@
"Bash(echo \"EXIT CODE: $?\")",
"Bash(python3 -m json.tool /tmp/adapted.json)",
"Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)",
"Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)",
"Bash(./scripts/run_backend_tests.sh)",
"Bash(.venv/bin/python test_handicap_engine.py)",
"Bash(curl -s https://forgejo.jegvil.no/api/v1/repos/envide/teecup/actions/runs)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('default_branch:', d.get\\('default_branch'\\)\\)\")",
"Bash(curl -s -o /tmp/runs.txt -w \"HTTP %{http_code}\\\\n\" https://forgejo.jegvil.no/api/v1/repos/envide/teecup/actions/runs)",
"Bash(curl -s -o /tmp/actions_page.html -w \"HTTP %{http_code}\\\\n\" \"https://forgejo.jegvil.no/envide/teecup/actions\")",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions/runs/1\" -o /tmp/run1.html -w \"HTTP %{http_code}\\\\n\")",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_page2.html -w \"HTTP %{http_code}\\\\n\")",
"Bash(docker pull *)",
"Bash(chmod 600 /opt/teecup/.forgejo-runner/.runner)",
"Bash(stat -c \"%G %g\" /var/run/docker.sock *)",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_page3.html)",
"Bash(python3 -m py_compile app/routers/scoring.py)",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_final.html)",
"Bash(npm --version)",
"Bash(./node_modules/.bin/vitest run *)",
"Bash(git -C /opt/teecup diff frontend/package.json)",
"Bash(git -C /opt/teecup diff --stat frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml)",
"Bash(git mv *)",
"Bash(git -C /opt/teecup log --oneline -3 -- frontend/.gitignore)",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_frontend.html)",
"Bash(git log *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18300/)",
"Bash(curl -s -X POST http://localhost:18311/auth/verify-link -H 'Content-Type: application/json' -d '{\"token\":\"g02xhESalHSdbGV6BuTahriCMScuVsB_UGqXVqY84iQ\"}' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X PATCH http://localhost:18311/auth/profile -H 'Content-Type: application/json' -d '{\"first_name\":\"Erol\",\"last_name\":\"Test\",\"gender\":\"m\",\"handicap_index\":18.4}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -o /dev/null -w 'profile: %{http_code}\\\\n')",
"Bash(curl -s -X POST http://localhost:18311/personal-courses -H 'Content-Type: application/json' -d @/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/sf_course.json -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X POST http://localhost:18311/rounds -H 'Content-Type: application/json' -d '{\"course_source\":\"custom\",\"personal_course_id\":\"64f0eec3-6da2-4b76-b41a-836ffba2316b\",\"tee_name\":\"Gul\",\"played_at\":\"2026-08-11\",\"holes_planned\":18,\"play_format\":\"stroke\"}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X PATCH http://localhost:18311/rounds/0fd25550-f0f6-4c22-a2b1-c1e4eeb3da7d/participants/16044c55-dd0e-4142-b5c1-ed6a70768b4c -H 'Content-Type: application/json' -d '{\"stat_level\":\"full\"}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X PATCH http://localhost:18311/auth/profile -H 'Content-Type: application/json' -d '{\"bag_clubs\":[\"Driver\",\"3-wood\",\"3-hybrid\",\"4-iron\",\"5-iron\",\"6-iron\",\"7-iron\",\"8-iron\",\"9-iron\",\"Pitching wedge\",\"Gap wedge\",\"Sand wedge\",\"Lob wedge\",\"Putter\"]}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -o /dev/null -w '%{http_code}\\\\n')",
"Bash(grep -n \"Committet \\(\\\\`ee91d03\\\\`\\), \\\\*\\\\*ikke rullet ut ennå\\\\*\\\\*\" /opt/teecup/CHANGELOG.md)",
"Bash(python3 -m py_compile app/golfapi_client.py app/golfapi_cache.py app/config.py app/routers/courses.py app/routers/rounds.py)",
"Bash(unzip -o \"Temp-uploads/tee-cup \\(10\\).zip\" -d /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zip10)",
"Bash(grep -n \"Hull \\\\${\" frontend/components/round-detail.tsx)",
"Bash(grep -c \"^[a-f0-9]\\\\{64\\\\}$\")",
"Bash(curl -sS -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18734/logg-inn)",
"Bash(curl -sS -X POST http://localhost:18734/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt)",
"Bash(curl -sS -X POST http://localhost:18735/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}')",
"Bash(curl -sS -X POST http://localhost:18735/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X POST http://localhost:18735/personal-courses/international-import -H 'Content-Type: application/json' -d '{\"golfapi_course_id\":\"0121250146602173\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X POST http://localhost:18735/rounds -H 'Content-Type: application/json' -d '{\"course_source\":\"custom\",\"personal_course_id\":\"cac41997-77f3-44a5-b395-9bb6b3c3c075\",\"tee_name\":\"55\",\"played_at\":\"2026-08-12\",\"start_hole\":1,\"holes_planned\":18,\"play_format\":\"stroke\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"gender\":\"m\",\"handicap_index\":18.0,\"birth_date\":\"1990-01-01\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"first_name\":\"Test\",\"last_name\":\"Verify\"}')",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('profile_complete:', d['profile_complete']\\)\")",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"home_club\":\"Tjøme Golfklubb\",\"country\":\"Norge\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt http://localhost:18735/rounds/99978ceb-55e5-42a6-a0a8-60496bb97e96/holes/1/target-points)",
"Bash(python3 -c \"import json,sys; print\\(json.load\\(sys.stdin\\)['id']\\)\")",
"Bash(curl -sS -X POST http://localhost:18738/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"layout-verify@example.com\",\"locale\":\"nb\"}')",
"Bash(python3 -m py_compile app/routers/rounds.py app/email.py)",
"Bash(python3 -m py_compile app/golfapi_cache.py)",
"Bash(python3 -m py_compile app/golfapi_cache.py app/routers/courses.py app/routers/rounds.py)",
"Bash(python3 -m py_compile /opt/teecup/app/email.py)",
"Bash(pkill -f \"next dev -p 13002\")",
"Bash(python3 -m py_compile app/routers/players.py)",
"Bash(pkill -f \"next dev.*13003\")",
"Bash(pkill -9 -f \"next dev.*13003\")",
"Bash(npx --yes ffmpeg-static --version)",
"Bash(node -e \"console.log\\(require\\('ffmpeg-static'\\)\\)\")",
"Bash(/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/ffmpeg_tool/node_modules/ffmpeg-static/ffmpeg -i /opt/teecup/Temp-uploads/Video_2026-08-15_070115.mp4)",
"Bash(/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/ffmpeg_tool/node_modules/ffmpeg-static/ffmpeg -i /opt/teecup/Temp-uploads/Video_2026-08-15_070115.mp4 -vf fps=1,scale=800:-1 /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/video_frames/frame_%03d.png -y)",
"Bash(pkill -f \"next dev -p 13008\")",
"Bash(rm -rf /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/migrations_oom*)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/oom_*.txt)",
"Bash(curl -s -o /dev/null -w \"public OOM \\(should be 200\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)",
"Bash(curl -s http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)",
"Bash(curl -s -o /dev/null -w \"private OOM \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/b5eb2b39-58e5-4e92-ac3c-266abb70e17d)",
"Bash(curl -s -o /dev/null -w \"nonexistent \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/00000000-0000-0000-0000-000000000000)",
"Bash(curl -s -o /dev/null -w \"public OOM after toggling off \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)",
"Bash(TEECUP_API_ORIGIN=http://localhost:18104 NEXT_PUBLIC_MAPBOX_TOKEN=unused nohup npx next dev -p 13104)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:13104/)",
"Bash(python3 -m py_compile tests/test_account_merge.py)",
"Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")",
"Bash(grep -n 'href={`/tournaments/\\\\|Link.*tournaments/\\\\${' components/tournament-detail.tsx)",
"Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)"
"Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -1,145 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 065
-- GolfAPI.io som tredje banekilde (ADR-064) -- baner utenfor TeeOffs
-- dekning (f.eks. Tjøme Golfklubb, bekreftet IKKE i TeeOff 2026-08-12).
-- =====================================================================
-- GolfAPI-kall koster ekte, begrenset budsjett (kjøpt 20 kall, ~2/bane)
-- -- ULIKT TeeOffs frie, offentlige API. Derfor: ETT delt, globalt
-- cache-lag (golfapi_course + underliggende hull/tee/koordinat-tabeller)
-- som ALDRI hentes på nytt for samme fysiske bane (GolfAPIs avtale
-- tillater eksplisitt permanent caching). Både den org-scopede
-- turneringsmodellen (course/hole/tee/tee_rating) OG den globale
-- personal_course-katalogen (migrasjon 020) kopierer INN fra denne
-- cachen ved eksplisitt import -- selve cachen eies ikke av noen av de
-- to, den er en tredje, uavhengig katalog.
--
-- Koordinatene (grønn front/midt/bak, hindringer, tee-punkter) kopieres
-- ALDRI inn per import -- de er ikke en del av HCP-beregningen (ingen
-- reproduserbarhets-hensyn som par/rating har via ADR-007/ADR-019), så
-- rangefinder-oppslag leser alltid direkte fra golfapi_course_coordinate
-- via banens lagrede GolfAPI-courseID. Én fysisk bane = ett
-- koordinatsett, uansett hvor mange ganger den importeres.
-- =====================================================================
\set ON_ERROR_STOP on
-- ---------------------------------------------------------------------
-- Delt GolfAPI-cache (global, ingen organization_id/owner -- én rad per
-- fysisk bane som NOEN i TeeCup noensinne har importert)
-- ---------------------------------------------------------------------
-- GolfAPIs courseID brukes direkte som PK (globalt unik hos dem, ingen
-- grunn til en egen surrogate-nøkkel).
CREATE TABLE golfapi_course (
course_id text PRIMARY KEY,
club_id text NOT NULL,
club_name text NOT NULL,
course_name text NOT NULL,
city text,
country text,
num_holes smallint NOT NULL CHECK (num_holes BETWEEN 1 AND 18),
-- Alltid hentet med measureUnit=m (golfapi_client.py) -- ingen yard-data
-- lagres, unngår enhets-fellen research allerede advarte om.
has_gps boolean NOT NULL DEFAULT false,
num_coordinates integer NOT NULL DEFAULT 0,
golfapi_updated_at timestamptz, -- deres timestampUpdated, til evt. fremtidig re-sync-vurdering
fetched_at timestamptz NOT NULL DEFAULT now()
);
-- Par/SI per hull. Kun menns verdier er kanonisk brukt av kopier-inn-
-- stegene under (TeeCups eksisterende hole/personal_course_hole har
-- ingen kjønnsdelt par -- samme forenkling som TeeOff-importen, ADR-019).
-- Kvinners verdier caches likevel, i tilfelle en fremtidig utvidelse.
CREATE TABLE golfapi_course_hole (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
golfapi_course_id text NOT NULL REFERENCES golfapi_course(course_id) ON DELETE CASCADE,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
par_men smallint NOT NULL CHECK (par_men BETWEEN 3 AND 6),
index_men smallint NOT NULL CHECK (index_men BETWEEN 1 AND 18),
par_women smallint CHECK (par_women BETWEEN 3 AND 6),
index_women smallint CHECK (index_women BETWEEN 1 AND 18),
UNIQUE (golfapi_course_id, hole_number),
UNIQUE (golfapi_course_id, index_men)
);
CREATE TABLE golfapi_course_tee (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
golfapi_course_id text NOT NULL REFERENCES golfapi_course(course_id) ON DELETE CASCADE,
golfapi_tee_id text NOT NULL,
name text NOT NULL,
color_hex text,
-- Meter per hull, indeks 0 = hull 1 ... indeks 17 = hull 18.
lengths_meters smallint[] NOT NULL,
course_rating_men numeric(4,1) NOT NULL,
slope_men smallint NOT NULL CHECK (slope_men BETWEEN 55 AND 155),
course_rating_women numeric(4,1),
slope_women smallint CHECK (slope_women BETWEEN 55 AND 155),
course_rating_men_front9 numeric(4,1),
course_rating_men_back9 numeric(4,1),
course_rating_women_front9 numeric(4,1),
course_rating_women_back9 numeric(4,1),
UNIQUE (golfapi_course_id, golfapi_tee_id),
CHECK (array_length(lengths_meters, 1) BETWEEN 1 AND 18)
);
-- Rangefinder-datalaget: grønn front/midt/bak, hindringer, tee-punkter.
-- poi_type/green_location/side_fairway holdes som tekst+CHECK (ikke
-- egne enum-typer) -- samme "kan utvides uten ALTER TYPE"-begrunnelse
-- som 001 allerede bruker for `format`.
CREATE TABLE golfapi_course_coordinate (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
golfapi_course_id text NOT NULL REFERENCES golfapi_course(course_id) ON DELETE CASCADE,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
poi_type text NOT NULL CHECK (poi_type IN (
'green', 'green_bunker', 'fairway_bunker', 'water',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back'
)),
-- GolfAPIs `location`-felt er generisk (front/midt/bak for POI-et),
-- ikke kun for 'green' -- f.eks. hadde Tjømes bunkere også front/bak.
-- Ikke navngitt green_location -- ville feilaktig antydet at kun
-- grønn-punkter har denne verdien.
location text CHECK (location IN ('front', 'middle', 'back')),
side_fairway text CHECK (side_fairway IN ('left', 'center', 'right')),
latitude double precision NOT NULL CHECK (latitude BETWEEN -90 AND 90),
longitude double precision NOT NULL CHECK (longitude BETWEEN -180 AND 180)
);
CREATE INDEX ON golfapi_course_hole (golfapi_course_id);
CREATE INDEX ON golfapi_course_tee (golfapi_course_id);
CREATE INDEX ON golfapi_course_coordinate (golfapi_course_id, hole_number);
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course_hole TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course_tee TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON golfapi_course_coordinate TO teecup_app;
-- ---------------------------------------------------------------------
-- Kopier-inn-mål 1: org-scopet turneringsbane (course), ny kilde
-- ---------------------------------------------------------------------
-- Samme mønster som ADR-019s 'official' (teeoff) -- men 'international'
-- betyr GolfAPI, IKKE live-oppslag (Beslutning i ADR-064: GolfAPI-kall
-- koster budsjett, teeoffs gjør ikke -- derfor kopier-inn her også).
ALTER TYPE course_source ADD VALUE 'international';
-- CHECK-constraint kan ikke endres direkte -- droppes og gjenopprettes
-- med samme betingelse utvidet symmetrisk. Navnet (course_check) er
-- Postgres sitt auto-genererte navn for det opprinnelige, unavngitte
-- table-level CHECK-et i 001 -- bekreftet mot ekte teecup_db, ikke gjettet.
ALTER TABLE course DROP CONSTRAINT course_check;
ALTER TABLE course ADD CONSTRAINT course_check
CHECK (source NOT IN ('official', 'international') OR external_course_ref IS NOT NULL);
-- ---------------------------------------------------------------------
-- Kopier-inn-mål 2: global personal_course-katalog, ny provenance-kolonne
-- ---------------------------------------------------------------------
-- round.course_source trenger INGEN endring -- en GolfAPI-importert bane
-- blir en helt vanlig personal_course-rad (course_source='custom' i
-- round, uendret), kun med denne kolonnen satt for å (a) hindre
-- dupliserte importer, (b) la rangefinder-oppslag finne koordinatene.
ALTER TABLE personal_course
ADD COLUMN external_golfapi_course_id text REFERENCES golfapi_course(course_id);
CREATE UNIQUE INDEX personal_course_golfapi_ref_unique
ON personal_course (external_golfapi_course_id)
WHERE external_golfapi_course_id IS NOT NULL;

View file

@ -1,24 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 066
-- Per-deltaker avslutning av frittstående runder
-- =====================================================================
-- round.completed_at er rundeomfattende -- hele runden må fullføres
-- samtidig for ALLE deltakere, noe som ikke holder når spillere i
-- samme flight går ulikt antall hull (f.eks. to spiller 18, én 9, én
-- må avslutte etter 14). Denne kolonnen lar EN deltaker markeres
-- ferdig uavhengig av resten av flighten -- stopper "mangler
-- score"-nagging for DEM, uten å låse scoreføring for de som
-- fortsatt spiller (som `round.completed_at` ville gjort).
--
-- Rundens EGEN fullføring (`round.completed_at`, HCP-beregning,
-- varsler) forblir bevisst en separat, manuell handling (bekreftet
-- med bruker) -- denne kolonnen er KUN en nagging-brems +
-- e-post-trigger, ingen auto-cascade.
--
-- Ingen CHECK mot round.completed_at -- applikasjonslaget garanterer
-- rekkefølgen (app/routers/rounds.py).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round_participant ADD COLUMN completed_at timestamptz;

View file

@ -1,26 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 067
-- Flere POI-typer for manuelt registrerte banepunkter
-- =====================================================================
-- GolfAPIs 12 faste POI-koder dekker ikke alt en bane faktisk kan ha --
-- konkret anledning: Nesbyen (Nesfjellet Golfklubb) manglet GPS-data
-- hos GolfAPI (hasGPS=0), og brukeren registrerte 124 punkter manuelt
-- fra egen bane-befaring. To av navnene der ("fjellknaus"/rock outcrop,
-- "til fairway"/siktepunkt for utslaget) har ingen match i dagens sett.
--
-- `rock` telles som en HINDRING i avstandsvisningen (samme klasse som
-- bunker/vann -- noe å styre unna), `layup` er rent informativt (et
-- siktepunkt, ikke noe å unngå) -- se _HAZARD_LABELS i
-- frontend/components/hole-target-distance.tsx.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE golfapi_course_coordinate DROP CONSTRAINT golfapi_course_coordinate_poi_type_check;
ALTER TABLE golfapi_course_coordinate ADD CONSTRAINT golfapi_course_coordinate_poi_type_check
CHECK (poi_type IN (
'green', 'green_bunker', 'fairway_bunker', 'water',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back',
'rock', 'layup'
));

View file

@ -1,18 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 068
-- golfapi_course_tee: menns rating/slope kan mangle
-- =====================================================================
-- Reell anledning (Nesfjellet Golfklubb/Nesbyen, 2026-08-13): migrasjon
-- 065 antok (som ADR-019s teeoff-import) at menns rating alltid finnes
-- hos kilden -- kvinners er valgfritt. GolfAPI viste seg å returnere en
-- TOM STRENG for `slopeMen` på minst ett utslag her, ikke bare for
-- kvinner. `golfapi_course_tee` sin cache må derfor tåle at BEGGE
-- kjønn kan mangle -- selve import/kopier-inn-stegene (courses.py/
-- rounds.py) avviser fortsatt (EXTERNAL_DATA_INCOMPLETE) dersom et
-- utslag ender opp UTEN noen brukbar rating i det hele tatt.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE golfapi_course_tee ALTER COLUMN course_rating_men DROP NOT NULL;
ALTER TABLE golfapi_course_tee ALTER COLUMN slope_men DROP NOT NULL;

View file

@ -1,66 +0,0 @@
-- =====================================================================
-- TeeCup — Flaggturnering: GPS-flaggplanting + runde 2+ (migrasjon 069,
-- frittstående runder). Se ARCHITECTURE_DECISIONS.md for ADR-tillegget.
--
-- To nye, uavhengige tabeller:
--
-- 1. round_participant_flag_plant -- posisjonen der spilleren gikk tom
-- for slag. ETT aktivt flagg per deltaker (UNIQUE på round_
-- participant_id) -- re-planting er slett+opprett-på-nytt, samme
-- "aldri en delvis UPDATE"-prinsipp som round_shot (migrasjon 060).
-- `lap`/`hole_number` er hvilket hull/runde-nummer flagget faktisk
-- tilhører (beregnet server-side fra registrert scoredata, aldri
-- stolt blindt på fra klienten -- se app/routers/rounds.py).
--
-- 2. round_hole_flag_overflow -- scoreføring for "runde 2+" (spilleren
-- fullførte 18 hull MED slag igjen, fortsetter play_order på nytt).
-- BEVISST en helt egen tabell, IKKE en lap-kolonne på selve
-- round_hole -- den tabellen er delt av samtlige ni eksisterende
-- spilleformater og lest av et stort antall eksisterende spørringer
-- (scorekort/statistikk/HCP-differensial) som alle antar ≤18 rader
-- per deltaker. En isolert tabell holder ALL risiko for denne
-- reelt sjeldne (krever uvanlig sjenerøst slagbudsjett) funksjonen
-- innenfor selve flagg-funksjonen, uten å røre noe annet format.
--
-- Eierskap/tilgang: samme non-RLS plain_connection() + app-lag-
-- autorisasjon som resten av frittstående-rundesystemet (ADR-033
-- Beslutning A) -- speiler round_shot presist.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE round_participant_flag_plant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
round_participant_id uuid NOT NULL UNIQUE
REFERENCES round_participant(id) ON DELETE CASCADE,
lap smallint NOT NULL DEFAULT 1 CHECK (lap >= 1),
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90),
lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180),
-- Landet flagget PÅ selve greenen -- avstand til hullet (cm-presisjon,
-- meter+cm slås sammen til én cm-verdi her, splittes kun i UI-et)
-- brukes til å rangere spillere som gikk tom på SAMME hull mot
-- hverandre (jo nærmere koppen, jo lengre har spilleren kommet).
on_green boolean NOT NULL DEFAULT false,
distance_to_pin_cm integer CHECK (distance_to_pin_cm >= 0),
CHECK (on_green OR distance_to_pin_cm IS NULL),
planted_by_user_id uuid NOT NULL REFERENCES app_user(id),
planted_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE round_hole_flag_overflow (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
round_participant_id uuid NOT NULL
REFERENCES round_participant(id) ON DELETE CASCADE,
lap smallint NOT NULL CHECK (lap >= 2),
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
score smallint CHECK (score > 0),
played boolean NOT NULL DEFAULT false,
UNIQUE (round_participant_id, lap, hole_number)
);
CREATE INDEX round_hole_flag_overflow_participant_idx
ON round_hole_flag_overflow (round_participant_id);
-- INGEN RLS -- samme plain_connection()-mønster som round_shot/round_hole.
GRANT SELECT, INSERT, DELETE ON round_participant_flag_plant TO teecup_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON round_hole_flag_overflow TO teecup_app;

View file

@ -1,64 +0,0 @@
-- =====================================================================
-- TeeCup — Flaggturnering: GPS-flaggplanting + runde 2+, org-
-- individuelle turneringer (migrasjon 070). Speiler migrasjon 069
-- (frittstående runder) presist, samme begrunnelse for hvorfor
-- overflow-scoring er en egen tabell og ikke en lap-kolonne på
-- tournament_round_hole (delt av alle formater, mange eksisterende
-- spørringer antar ≤18 rader per deltaker).
--
-- Ulikt 069: disse tabellene ER org-scopet og RLS-beskyttet (samme
-- org_isolation-policy-mønster som resten av tournament_round-familien,
-- migrasjon 040).
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_participant_flag_plant (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_participant_id uuid NOT NULL UNIQUE,
lap smallint NOT NULL DEFAULT 1 CHECK (lap >= 1),
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90),
lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180),
on_green boolean NOT NULL DEFAULT false,
distance_to_pin_cm integer CHECK (distance_to_pin_cm >= 0),
CHECK (on_green OR distance_to_pin_cm IS NULL),
planted_by_user_id uuid NOT NULL REFERENCES app_user(id),
planted_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_participant_id)
REFERENCES tournament_round_participant(organization_id, id) ON DELETE CASCADE
);
CREATE TABLE tournament_round_hole_flag_overflow (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_participant_id uuid NOT NULL,
lap smallint NOT NULL CHECK (lap >= 2),
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
gross_strokes smallint CHECK (gross_strokes BETWEEN 1 AND 20),
played boolean NOT NULL DEFAULT false,
FOREIGN KEY (organization_id, tournament_round_participant_id)
REFERENCES tournament_round_participant(organization_id, id) ON DELETE CASCADE,
UNIQUE (tournament_round_participant_id, lap, hole_number)
);
CREATE INDEX ON tournament_round_participant_flag_plant (organization_id, tournament_round_participant_id);
CREATE INDEX ON tournament_round_hole_flag_overflow (organization_id, tournament_round_participant_id);
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'tournament_round_participant_flag_plant',
'tournament_round_hole_flag_overflow'
]
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 $$;

View file

@ -1,21 +0,0 @@
-- =====================================================================
-- TeeCup — Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B",
-- se ARCHITECTURE_DECISIONS.md ADR-067 for full begrunnelse).
--
-- Én boolean per hjem, standard AV (samme "skjult til avslørt"-prinsipp
-- som visibility_mode/blind draw) -- ingen, verken medspillere eller
-- tilskuere, ser andres flagg før eieren (frittstående) eller et
-- org-medlem (org-turnering) eksplisitt slår den på. En spiller ser
-- alltid SITT EGET flagg uansett bryter-status (håndtert i app-laget,
-- se app/routers/rounds.py::_build_flag_map og
-- app/routers/individual_tournaments.py::get_flag_map).
--
-- Endres via eksisterende PATCH-endepunkter (PATCH /rounds/{id} eier-only,
-- PATCH /orgs/{id}/tournaments/{id} ethvert org-medlem) -- ingen nye
-- skrive-endepunkter, kun nye felt på eksisterende modeller.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round ADD COLUMN flag_map_visible boolean NOT NULL DEFAULT false;
ALTER TABLE tournament ADD COLUMN flag_map_visible boolean NOT NULL DEFAULT false;

View file

@ -1,24 +0,0 @@
-- =====================================================================
-- TeeCup — Eclectic (org individuell turnering), migrasjon 072, "Del C"
-- av den tredelte Flaggturnering-utvidelsen (se ARCHITECTURE_DECISIONS.md
-- ADR-066/067 for Del A/B -- Del C er ikke Flaggturnering-relatert, men
-- ble bedt om i samme runde). Samme drop+recreate-mønster som
-- 042/044/046 -- se 046 sin header for full begrunnelse.
--
-- Ingen nye tabeller/kolonner -- Eclectic er en NY scoring_method-verdi
-- som gjenbruker eksisterende tournament_round_hole (per-hull
-- bruttoslag, allerede lagret av alle formater) og tournament_round_
-- participant.course_handicap (allerede beregnet for alle scoring_
-- method-verdier). "Beste resultat per hull på tvers av rundene" regnes
-- ut ved LESING (samme "regn ut ved lesing"-filosofi som resten av
-- individuelle turneringers leaderboard), se
-- app/routers/individual_tournaments.py::_attach_eclectic_totals.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
CHECK (scoring_method IS NULL OR scoring_method IN (
'stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo', 'flag',
'eclectic_gross', 'eclectic_net', 'eclectic_stableford'
));

View file

@ -1,25 +0,0 @@
-- =====================================================================
-- TeeCup — Idempotens for kommentar-posting via offline-kø (ADR-028-
-- tillegget "kommentarer/bilder offline", 2026-08-14, migrasjon 073).
--
-- Hull-score-køen (ADR-028) er trygg å synke på nytt fordi PATCH +
-- expected_version konvergerer uansett hvor mange ganger den gjentas.
-- POST /rounds/{id}/messages er IKKE naturlig idempotent -- hvert kall
-- oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
-- flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
-- samme kommentar to ganger. Løsning: klienten sender en selvgenerert
-- `client_message_id` (crypto.randomUUID()); et gjentatt kall med samme
-- id returnerer den allerede opprettede meldingen i stedet for å
-- opprette en duplikat (se app/routers/round_messages.py::
-- post_round_message).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE round_message ADD COLUMN client_message_id uuid;
-- Delvis unik indeks (kun når feltet faktisk er satt) -- eldre/andre
-- kallere som ikke sender client_message_id er upåvirket, samme
-- "None = ikke i bruk"-konvensjon som resten av appen.
CREATE UNIQUE INDEX round_message_client_id_idx
ON round_message (round_id, client_message_id)
WHERE client_message_id IS NOT NULL;

View file

@ -1,38 +0,0 @@
-- =====================================================================
-- TeeCup — Utvidede spillerfelt i org-spillerpoolen (2026-08-14).
-- Bruker: midlertidige/nyopprettede spillere i "Deltakere"-flyten
-- (org-individuelle turneringer, individual-tournament-detail.tsx)
-- manglet felt en reell klubbadministrator trenger ved registrering.
--
-- Splitter navn i for-/etternavn -- SAMME mønster som migrasjon 052
-- (round_participant.guest_first_name/guest_last_name): display_name
-- beholdes UENDRET som det faktisk VISTE fulle navnet (unngår å røre
-- alle eksisterende steder som leser player.display_name direkte,
-- bl.a. leaderboards/matcher/rostre) -- first_name/last_name er en NY,
-- valgfri kilde til sannhet ved REDIGERING i det utvidede skjemaet,
-- ikke en erstatning. App-laget (ikke en DB-trigger) holder dem i sync
-- ved oppretting, samme "kalleren regner ut display_name"-ansvar som
-- round_participant sin ekvivalent.
--
-- `paid`/`comment`: registreringsadministrasjon (betalt-avkrysning,
-- fritekstnotat) -- fantes ikke i noen form før.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE player ADD COLUMN first_name text;
ALTER TABLE player ADD COLUMN last_name text;
ALTER TABLE player ADD COLUMN paid boolean NOT NULL DEFAULT false;
ALTER TABLE player ADD COLUMN comment text;
-- Backfill for EKSISTERENDE spillere -- samme "første ord = fornavn,
-- resten = etternavn"-heuristikk som migrasjon 052 sin backfill.
UPDATE player
SET first_name = CASE
WHEN position(' ' IN display_name) > 0 THEN split_part(display_name, ' ', 1)
ELSE display_name
END,
last_name = CASE
WHEN position(' ' IN display_name) > 0
THEN NULLIF(trim(substring(display_name FROM position(' ' IN display_name) + 1)), '')
ELSE NULL
END;

View file

@ -1,52 +0,0 @@
-- =====================================================================
-- TeeCup — Full statistikkdybde i org-turneringers hull-scoring (ADR-071,
-- 2026-08-15). Steg 1 av "spillerens per-hull-historikk"-funksjonen.
--
-- `tournament_round_hole` har siden opprettelse (migrasjon 040) KUN hatt
-- `gross_strokes` -- ingen putts/utslag-/innspillretning/bunker-/
-- straffeslag-detalj slik `round_hole` (frittstående runder) allerede
-- har. Denne migrasjonen gir turneringssiden samme feltsett, SAMME navn
-- og SAMME CHECK-betingelser som `round_hole` (022_round_stats_and_bag.sql/
-- 062_round_hole_version.sql) -- en ren utvidelse, ingen eksisterende
-- format (Københavner/BBB/Flag/Eclectic) berøres, de leser fortsatt kun
-- `gross_strokes`.
--
-- `version` (optimistisk låsing, ADR-057-mønster) er NY for denne
-- tabellen -- `tournament_round_hole.update_hole` har til nå vært rent
-- siste-skriver-vinner. DEFAULT 1, samme begrunnelse som migrasjon 062:
-- en fersk rad skal kunne treffes med expected_version=1 uten at
-- klienten må vite at "ubrukt" betyr noe annet.
--
-- `tournament_participant.stat_level` (samme tre verdier, samme trygge
-- default 'strokes_only') styrer hvor mye UI/backend faktisk ber om --
-- lagt på `tournament_participant` (tournament-bredt), ikke
-- `tournament_round_participant` (per runde), fordi en deltaker normalt
-- spiller flere runder i samme turnering og ikke skal måtte velge
-- statistikknivå på nytt hver gang.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament_round_hole
ADD COLUMN putts smallint
CHECK (putts IS NULL OR putts BETWEEN 0 AND 10),
ADD COLUMN club_off_tee text,
ADD COLUMN tee_shot_result text
CHECK (tee_shot_result IS NULL OR tee_shot_result IN ('fairway', 'left', 'right')),
ADD COLUMN approach_result text
CHECK (approach_result IS NULL OR approach_result IN ('hit', 'long', 'short', 'left', 'right')),
ADD COLUMN chip_count smallint
CHECK (chip_count IS NULL OR chip_count >= 0),
ADD COLUMN bunker_shot_count smallint
CHECK (bunker_shot_count IS NULL OR bunker_shot_count >= 0),
ADD COLUMN penalty_strokes smallint
CHECK (penalty_strokes IS NULL OR penalty_strokes >= 0),
ADD COLUMN anyway_strokes smallint
CHECK (anyway_strokes IS NULL OR anyway_strokes >= 0),
ADD COLUMN first_putt_distance_bucket text
CHECK (first_putt_distance_bucket IS NULL OR first_putt_distance_bucket IN (
'<1m', '<2m', '<3m', '<5m', '<8m', '8m+'
)),
ADD COLUMN version integer NOT NULL DEFAULT 1;
ALTER TABLE tournament_participant ADD COLUMN stat_level text NOT NULL DEFAULT 'strokes_only'
CHECK (stat_level IN ('strokes_only', 'strokes_and_putts', 'full'));

View file

@ -1,30 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 076
-- Offentlig/delt Order of Merit-visning (ADR-076, "Steg 3" av OOM sin
-- gjenstår-liste fra ADR-043/074/075)
-- =====================================================================
-- Kun én ny bro-funksjon -- order_of_merit.public_visible finnes
-- allerede fra migrasjon 055, aldri brukt til noe før nå.
-- =====================================================================
\set ON_ERROR_STOP on
-- ---------------------------------------------------------------------
-- public_order_of_merit_by_id() -- samme mønster som public_org_by_slug
-- (009)/public_tournament_by_code (011): SECURITY DEFINER-bro, eneste
-- måte et /public/...-endepunkt får vite riktig org-kontekst FØR RLS
-- kan håndheve noe. Returnerer NULL for BÅDE "finnes ikke" og "finnes,
-- men er ikke offentlig" -- samme anti-enumerering som resten.
-- ---------------------------------------------------------------------
CREATE FUNCTION public_order_of_merit_by_id(p_id uuid)
RETURNS uuid
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
STABLE
AS $$
SELECT organization_id FROM order_of_merit WHERE id = p_id AND public_visible = true;
$$;
GRANT EXECUTE ON FUNCTION public_order_of_merit_by_id(uuid) TO teecup_app;

View file

@ -1,42 +0,0 @@
-- =====================================================================
-- TeeCup — kontosammenslåing, "Del 2" av flere-e-postadresser (migrasjon 077)
-- =====================================================================
-- Oppfølging av ADR-032/migrasjon 017 (sekundær e-post) -- "Del 1" dekket
-- KUN det enkle tilfellet (fri, ukrevd adresse). Del 2: hva skjer når
-- adressen som legges til ALLEREDE er en annen, eksisterende kontos
-- primær- eller sekundæradresse -- samme reelle person har endt opp med
-- to separate TeeCup-kontoer. Selvbetjent (bruker bekreftet eksplisitt).
--
-- Samme token-hash-og-utløp-mønster som magic_link_token (004) ->
-- email_change_token (016) -> secondary_email_token (017): bevis
-- eierskap av MÅL-kontoens e-post FØR selve sammenslåingen utføres.
-- Lenken sendes til MÅL-kontoens (taperens) e-post -- beviser eierskap av
-- DEN kontoen, ikke bare at initiativtakeren (keeperen) fortsatt er
-- innlogget, samme prinsipp som ADR-032 Beslutning B.
--
-- `initiator_user_id`/`target_user_id` er BEVISST asymmetriske roller,
-- ikke to likeverdige parter: initiator (keeper) er den som ber om
-- sammenslåingen fra en aktiv økt, target (taper) er kontoen som slås
-- INN i keeperen og til slutt slettes (se app/account_merge.py -- ingen
-- sesjons-tilbakekallingsmekanisme finnes i denne appen, sletting av
-- app_user-raden er eneste måte å tvinge ut en gjenlevende
-- øktinformasjonskapsel for taper-kontoen).
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE account_merge_token (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
initiator_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
target_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (initiator_user_id <> target_user_id)
);
CREATE INDEX ON account_merge_token (initiator_user_id);
CREATE INDEX ON account_merge_token (target_user_id);
GRANT SELECT, INSERT, UPDATE ON account_merge_token TO teecup_app;

View file

@ -1,30 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 078
-- Ny POI-type "landmark" for manuelt registrerte banepunkter
-- =====================================================================
-- Konkret anledning: Tjøme Golfklubb sin GolfAPI-cache erstattet med
-- et nytt, mer detaljert sett feltbefarte koordinater (180 punkter,
-- CSV lastet opp av bruker 2026-08-17). Ett punkt ("Bjella", hull 5+14)
-- er en fysisk bjelle spillerne ringer i for å signalisere til gruppen
-- bak at de kan slå -- ingen av dagens 14 poi_type-verdier (fra
-- migrasjon 001/067) dekker et rent informasjons-/signal-landemerke
-- uten hindrings- eller siktepunkt-betydning.
--
-- `landmark` er, som `layup`, rent informativt -- IKKE en hindring i
-- avstandsvisningen (se _HAZARD_LABELS i
-- frontend/components/hole-target-distance.tsx, som i dag kun
-- filtrerer på green_bunker/fairway_bunker/water/rock -- landmark
-- vises ikke der uten fremtidig frontend-arbeid, samme status som
-- trees/marker_*/dogleg/road/tee_front/tee_back/layup i dag).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE golfapi_course_coordinate DROP CONSTRAINT golfapi_course_coordinate_poi_type_check;
ALTER TABLE golfapi_course_coordinate ADD CONSTRAINT golfapi_course_coordinate_poi_type_check
CHECK (poi_type IN (
'green', 'green_bunker', 'fairway_bunker', 'water',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back',
'rock', 'layup', 'landmark'
));

View file

@ -1,76 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 079
-- Rangefinder-koordinater for OFFISIELLE (TeeOff-koblede) baner
-- =====================================================================
-- Frem til nå har rangefinder-koordinater (golfapi_course_coordinate,
-- 065/067/078) KUN eksistert for GolfAPI-importerte baner. Offisielle
-- baner (course.source = 'official', koblet til den ekte teeoff-
-- databasen via course.external_course_ref = "{facility_slug}:
-- {teeoff_course_id}") har ALDRI hatt noen koordinatkilde -- verken for
-- org-turneringer eller for frittstående runder spilt på en ekte
-- teeoff-bane (round.course_source = 'teeoff'). Konkret anledning:
-- bruker ba om at Tjøme Golfklubb sin OFFISIELLE bane (brukt av
-- organisasjonen "Tjøme Gents") skal få samme feltbefarte koordinater
-- som nettopp ble lagt inn på Erol sin PERSONLIGE GolfAPI-bane for
-- Tjøme -- og at dette skal være en generell, gjenbrukbar mulighet for
-- ALLE offisielle baner fremover, ikke en engangsfiks.
--
-- DEL 1 -- delte domener for poi_type/location/side_fairway. To runder
-- på rad (067: rock/layup, 078: landmark) måtte hver for seg utvide
-- DEN SAMME CHECK-constraint-listen. Å innføre en ny tabell med en
-- IDENTISK, dupliserende CHECK-liste ville gjort dette vedlikeholds-
-- problemet permanent (to steder å huske å endre, garantert å skli).
-- Domenene gir én kilde til sannhet -- fremtidige utvidelser endrer
-- domenet ett sted, brukt av begge tabeller.
--
-- DEL 2 -- teeoff_course_coordinate, samme kolonneform som
-- golfapi_course_coordinate, men nøkkel er course.external_course_ref
-- (tekst, IKKE en FK -- speiler at external_course_ref selv er en myk
-- referanse, ingen global "teeoff_course"-tabell finnes å FK-e mot).
-- GLOBAL (ikke org-scopet, ingen RLS) -- samme begrunnelse som
-- golfapi_course_coordinate: flere organisasjoner kan importere samme
-- fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles,
-- ikke registreres på nytt per organisasjon.
-- =====================================================================
\set ON_ERROR_STOP on
-- --- Del 1: delte domener -------------------------------------------
CREATE DOMAIN course_poi_type AS text CHECK (VALUE IN (
'green', 'green_bunker', 'fairway_bunker', 'water',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back',
'rock', 'layup', 'landmark'
));
CREATE DOMAIN course_poi_location AS text CHECK (VALUE IN ('front', 'middle', 'back'));
CREATE DOMAIN course_poi_side AS text CHECK (VALUE IN ('left', 'center', 'right'));
ALTER TABLE golfapi_course_coordinate
DROP CONSTRAINT golfapi_course_coordinate_poi_type_check,
DROP CONSTRAINT golfapi_course_coordinate_location_check,
DROP CONSTRAINT golfapi_course_coordinate_side_fairway_check;
ALTER TABLE golfapi_course_coordinate
ALTER COLUMN poi_type TYPE course_poi_type USING poi_type::text::course_poi_type,
ALTER COLUMN location TYPE course_poi_location USING location::text::course_poi_location,
ALTER COLUMN side_fairway TYPE course_poi_side USING side_fairway::text::course_poi_side;
-- --- Del 2: teeoff_course_coordinate ---------------------------------
CREATE TABLE teeoff_course_coordinate (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_course_ref text NOT NULL,
hole_number smallint NOT NULL CHECK (hole_number >= 1 AND hole_number <= 18),
poi_type course_poi_type NOT NULL,
location course_poi_location,
side_fairway course_poi_side,
latitude double precision NOT NULL CHECK (latitude >= -90 AND latitude <= 90),
longitude double precision NOT NULL CHECK (longitude >= -180 AND longitude <= 180)
);
CREATE INDEX ON teeoff_course_coordinate (external_course_ref, hole_number);
GRANT SELECT, INSERT, UPDATE, DELETE ON teeoff_course_coordinate TO teecup_app;

View file

@ -1,32 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 080
-- hazard_group: eksplisitt paring av forkant/bakkant til SAMME hindring
-- =====================================================================
-- Konkret anledning (ADR-084): hull-diagrammet for rangefinderen
-- (ADR-083) trengte en måte å vise ÉN hindring med både forkant- og
-- bakkant-avstand (f.eks. en bunker sin fremre og bakre kant), i
-- stedet for å vise hvert koordinatpunkt som sin egen markør. Første
-- forsøk grupperte punkter på `poi_type`+`side_fairway` -- avvist av
-- bruker: to ATSKILTE hindringer av samme type på samme side (f.eks.
-- to venstre fairway-bunkere) ville da feilaktig blitt slått sammen
-- til én.
--
-- Verken `golfapi_course_coordinate` eller `teeoff_course_coordinate`
-- hadde noen eksisterende gruppe-/hindrings-id å bruke i stedet --
-- `id`-kolonnen er en usorterbar `gen_random_uuid()`, ingen
-- `created_at`, og verken GolfAPI sin kildedata eller den manuelle
-- feltbefarings-flyten (ADR-081) har noe naturlig gruppe-nøkkel.
--
-- `hazard_group` er derfor en NY, nullable, FRITT SATT tekstkolonne --
-- samme "menneskelig tolkning ved data-inntasting"-mønster som
-- `poi_type`/`location` allerede er. `NULL` (dagens tilstand for ALT
-- eksisterende data) = enkeltstående punkt, ALDRI slått sammen med
-- noe -- ingen regresjon. Ikke-null = et menneske har eksplisitt
-- bekreftet at to (eller flere) punkter er samme fysiske hindring.
-- Kun meningsfullt unikt INNENFOR ett hull, ikke globalt håndhevet.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE golfapi_course_coordinate ADD COLUMN hazard_group text;
ALTER TABLE teeoff_course_coordinate ADD COLUMN hazard_group text;

View file

@ -1,33 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 081
-- Ny POI-type "creek" (bekk) -- skilt fra "water" (dam/vannhinder)
-- =====================================================================
-- Konkret anledning (ADR-084 del 2): Tjøme Golfklubb sitt koordinat-
-- regneark skiller eksplisitt mellom "vann" (dam) og "fairway bekk"
-- (bekk/creek) som to ulike fysiske hindringstyper -- 8 punkter (4
-- forkant+bakkant-par, hull 2/3/11/12) er i dag lagret som poi_type
-- 'water' siden domenet (migrasjon 079) ikke hadde noen egen verdi for
-- bekk. Bruker har laget og lastet opp et eget bekk-ikon
-- (frontend/public/hole-diagram/hazard-creek.png) og ba eksplisitt om
-- at dette blir en visuelt distinkt hindringstype i hull-diagrammet,
-- ikke fortsatt vist som "Vannhinder".
--
-- `creek` legges til course_poi_type-domenet (migrasjon 079) -- gjelder
-- automatisk begge koordinattabellene (golfapi_course_coordinate og
-- teeoff_course_coordinate) siden begge bruker samme delte domene.
-- De 8 kjente eksisterende bekk-punktene REKLASSIFISERES i en egen,
-- ikke-migrasjonsbasert UPDATE (se CHANGELOG/ADR-084) -- identifisert
-- presist via samme koordinat-regneark som ga hazard_group-dataen i
-- del 2, ikke en heuristikk.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER DOMAIN course_poi_type DROP CONSTRAINT course_poi_type_check;
ALTER DOMAIN course_poi_type ADD CONSTRAINT course_poi_type_check
CHECK (VALUE IN (
'green', 'green_bunker', 'fairway_bunker', 'water', 'creek',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back',
'rock', 'layup', 'landmark'
));

View file

@ -1,25 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 082
-- Nye POI-typer "fairway" og "stone_fence" (voll/steingjerde)
-- =====================================================================
-- Konkret anledning (ADR-084 del 5): full reimport av Tjøme Golfklubb
-- sitt koordinat-regneark (standardisert kolonneformat, se ADR-084 del
-- 2-4) inneholdt to TYPE-verdier uten tilsvarende poi_type: "Fairway"
-- (enkeltpunkt-referanse midt i fairwayen) og "Voll" (jordvoll/
-- steingjerde). Bruker lastet opp egne ikoner (fairway.png,
-- stonefence.png) og ba om at disse legges til som egne typer.
--
-- `fairway` og `stone_fence` legges til course_poi_type-domenet
-- (migrasjon 079) -- gjelder automatisk begge koordinattabellene.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER DOMAIN course_poi_type DROP CONSTRAINT course_poi_type_check;
ALTER DOMAIN course_poi_type ADD CONSTRAINT course_poi_type_check
CHECK (VALUE IN (
'green', 'green_bunker', 'fairway_bunker', 'water', 'creek',
'trees', 'marker_100', 'marker_150', 'marker_200',
'dogleg', 'road', 'tee_front', 'tee_back',
'rock', 'layup', 'landmark', 'fairway', 'stone_fence'
));

View file

@ -1,37 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 083
-- Cut: topp N og delt plass, etter et valgt rundenummer, manuelt anvendt
-- =====================================================================
-- Kun for individuelle turneringer (stroke_gross/stroke_net/stableford
-- -- samme sett som Augusta-resultattavlen, se ADR-*/CHANGELOG #104/112).
-- Bruker bekreftet design 2026-08-17/18: "Topp N og delt plass" (den
-- N-te plassen og alle uavgjort med den slipper gjennom), "etter et
-- bestemt rundenummer" (organisator velger selv hvilken runde cutten
-- gjelder fra), "vises i egen 'Cut'-seksjon, blokkert fra videre
-- scoring" -- og eksplisitt et MANUELT "Anvend cut"-trykk, IKKE
-- automatisk ved fullspilt runde (for å unngå at cutten slår inn for
-- tidlig mens noen fortsatt spiller den runden).
--
-- cut_after_round/cut_size er KONFIGURASJON (satt i Oppsett, kan endres
-- fritt inntil cutten faktisk anvendes). cut_applied_at er null inntil
-- organisator trykker "Anvend cut" -- selve anvendelsen er et eksplisitt
-- API-kall (`app/routers/individual_tournaments.py`) som beregner
-- stillingen etter cut_after_round og setter tournament_participant.cut
-- = true for alle UTENFOR cut_size+uavgjort-grensen. Idempotent: kan
-- trykkes på nytt (f.eks. etter en rettelse) -- regner alt på nytt fra
-- bunnen av, ikke bare inkrementelt.
--
-- tournament_participant.cut er IKKE nullable (false = fortsatt med,
-- samme "ingen regresjon for eksisterende data"-prinsipp som resten av
-- prosjektets nullable/default-valg).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament
ADD COLUMN cut_after_round smallint,
ADD COLUMN cut_size smallint CHECK (cut_size IS NULL OR cut_size > 0),
ADD COLUMN cut_applied_at timestamptz;
ALTER TABLE tournament_participant
ADD COLUMN cut boolean NOT NULL DEFAULT false;

View file

@ -1,71 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 084
-- Utslagsgrupper for individuelle turneringer: hvem spiller med hvem,
-- klokka når
-- =====================================================================
-- Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille fra hvor, med
-- hvem, klokka når?" -- undersøkt FØR bygging: individuelle turneringer
-- hadde INGEN gruppe-/pairing-konsept i det hele tatt, kun ett delt
-- start_hole/scheduled_at/tee_interval_minutes for HELE runden
-- (tournament_round, migrasjon 040) -- ingen måte å si hvem som spiller
-- sammen, eller gi ulike grupper ulikt klokkeslett.
--
-- Design bekreftet med bruker 2026-08-18 (tre spørsmål):
-- - TRADISJONELT utslag: samme starthull for alle (tournament_round sitt
-- allerede eksisterende start_hole -- ingen endring der), staggerte
-- klokkeslett PER GRUPPE med fast intervall -- IKKE shotgun (ulike
-- hull samtidig).
-- - Gruppesammensetning er MANUELL, med et forslag å justere -- ikke
-- helt automatisk, ikke helt fra bunnen av.
-- - FAST gruppestørrelse (organisator velger tallet, f.eks. 4), ikke
-- fleksibelt antall per gruppe.
--
-- `tournament_round_group` speiler `session`/`match` sin allerede
-- etablerte klokkeslett-modell i matches.py (`_compute_tee_time`):
-- gruppens tee-tid er UTLEDET (tournament_round.scheduled_at +
-- (sequence-1)*tournament_round.tee_interval_minutes), med en valgfri
-- `tee_time_override` som vinner hvis satt (samme unntaks-mønster som
-- `match.tee_time_override` -- f.eks. én gruppe forsinket). INGEN nytt
-- starthull-felt her -- alle grupper i en runde bruker rundens EGNE
-- start_hole, per bekreftet "tradisjonelt"-valg.
--
-- `tournament_round_participant.tournament_round_group_id` er nullable
-- (ikke gruppert ennå = null, ingen regresjon for eksisterende runder/
-- deltakere). BEVISST INGEN `ON DELETE SET NULL` på FK-en, selv om det
-- først virket naturlig ("slett en gruppe -> deltakerne blir ugrupperte
-- igjen") -- funnet under egen scratch-testing: en SAMMENSATT FK
-- (organization_id, tournament_round_group_id) med ON DELETE SET NULL
-- setter BEGGE kolonnene til null ved kaskade, IKKE bare fremmednøkkel-
-- kolonnen -- det ville korrumpert organization_id (RLS sin tenant-
-- nøkkel, kan ALDRI være null) på deltaker-rader hver gang en gruppe
-- slettes. Appen (`save_round_groups`, individual_tournaments.py)
-- nullstiller derfor `tournament_round_group_id` EKSPLISITT i Python
-- FØR den sletter de gamle gruppe-radene -- samme "eksplisitt rekkefølge
-- fremfor å stole på kaskade-semantikk"-mønster som ADR-086 sin
-- turneringssletting allerede etablerte for en beslektet RESTRICT-FK-
-- diamant. Standard FK-oppførsel (RESTRICT) er dermed trygg: den
-- utløses aldri fra denne kodestien (kolonnen er allerede null før
-- selve DELETE-en kjører), og ville korrekt stoppet enhver FREMTIDIG
-- kodesti som glemte å nullstille først, i stedet for å stille
-- korrumpere data.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE tournament_round_group (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_id uuid NOT NULL,
sequence smallint NOT NULL,
tee_time_override timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (organization_id, tournament_round_id)
REFERENCES tournament_round(organization_id, id) ON DELETE CASCADE,
UNIQUE (organization_id, id),
UNIQUE (tournament_round_id, sequence)
);
ALTER TABLE tournament_round_participant
ADD COLUMN tournament_round_group_id uuid,
ADD FOREIGN KEY (organization_id, tournament_round_group_id)
REFERENCES tournament_round_group(organization_id, id);

View file

@ -1,36 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 085
-- Tiebreak ved likt resultat i individuelle turneringer (brutto/netto/
-- stableford): countback eller laveste HCP, valgt av organisator
-- =====================================================================
-- Bruker spurte, etter å ha fått Cut-funksjonen (ADR-087): "Ved likt
-- resultat ... Skal laveste hcp plasseres foran, beste siste 18/9/6/3/
-- siste hull plasseres foran, eller deles plasseringene. Her kan svaret
-- være forskjellig for vinner og resten av feltet."
--
-- Design bekreftet med bruker (tre spørsmål):
-- - Metoden er IKKE fastlåst i koden -- "Dette skal defineres av
-- arrangøren ved oppsett av turneringen" -- for BÅDE vinneren og
-- resten av feltet, hver for seg (samme svar begge steder, altså to
-- uavhengige innstillinger, ikke én felles).
-- - Tiebreak påvirker KUN sluttresultatet/plasseringsvisningen, ALDRI
-- selve Cut-grensen (Cut sin "topp N og delt plass" fra ADR-087
-- forblir uendret -- fullt delt plass på grensen, uansett hva
-- tiebreak-innstillingen sier).
--
-- `tiebreak_winner_method`/`tiebreak_field_method`: 'none' (uendret
-- oppførsel -- delt plassering, dagens eneste faktiske adferd) er
-- default, ingen regresjon for eksisterende turneringer. 'countback' =
-- kaskaderende "matching cards" (siste 9 -> siste 6 -> siste 3 -> siste
-- hull i turneringens SISTE runde, standard klubbgolf-metode -- ingen
-- egen dybde-parameter, kaskaden er alltid full). 'lowest_hcp' = laveste
-- handicap-indeks vinner uavgjortheten.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament
ADD COLUMN tiebreak_winner_method text NOT NULL DEFAULT 'none'
CHECK (tiebreak_winner_method IN ('none', 'countback', 'lowest_hcp')),
ADD COLUMN tiebreak_field_method text NOT NULL DEFAULT 'none'
CHECK (tiebreak_field_method IN ('none', 'countback', 'lowest_hcp'));

View file

@ -1,30 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 086
-- Omspill (playoff) som eget tiebreak-valg for VINNEREN + fritekst-
-- regel som skal informeres om før turneringen
-- =====================================================================
-- Bruker, etter tiebreak (ADR-089): "ved lik score for leder; nytt
-- valg: omspill med avgjørelse. Dette bør informeres om før
-- turneringen. (Eks: Omspill på hull 18 til vinner er kåret.)"
--
-- KUN et gyldig valg for `tiebreak_winner_method` (ikke `tiebreak_
-- field_method`) -- bruker sin egen formulering var eksplisitt "ved
-- lik score for LEDER", og et omspill for f.eks. delt 8.-plass er ikke
-- hva som ble bedt om. Appen kan ikke selv AVGJØRE et omspill (det
-- skjer på banen, utenfor scoring-systemet) -- rangeringslogikken
-- behandler derfor `'playoff'` likt som `'none'` (delt plassering,
-- ingen automatisk oppløsning), se `_attach_stroke_play_columns`.
-- Forskjellen er ren INFORMASJON: `tiebreak_playoff_rule` er fritekst
-- organisator setter (f.eks. "Sudden death fra hull 18, til vinner er
-- kåret") -- vist i Oppsett OG på leaderboardet (organisatorens/
-- spillernes felles visningsflate) FØR en eventuell uavgjort faktisk
-- oppstår, ikke bare når den skjer.
-- =====================================================================
\set ON_ERROR_STOP on
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')),
ADD COLUMN tiebreak_playoff_rule text;

View file

@ -1,30 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 087
-- Manuell status per deltaker: DSQ/RTD/DNF/DNS
-- =====================================================================
-- Bruker: "Vi må manuelt kunne sette Dsq, Rtd, Dnf, og fns." ("fns"
-- tolket som en skrivefeil for DNS -- 'd' og 'f' ligger ved siden av
-- hverandre på tastaturet, og ingen standard golf-status starter på
-- "f". Ingen av de fire fantes -- `tournament_participant` hadde
-- ingen statuskolonne i det hele tatt før nå.
--
-- Leaderboardet (`stroke-play-leaderboard.tsx`, V0-eksportert) hadde
-- ALLEREDE en STATUS-mekanisme klar til å vise nøyaktig dette (samme
-- Set som "WD"/"DNS"/"DQ"/"NR"/"CUT" allerede brukte -- "DNS" var der
-- fra før!) -- den manglet bare en vei for organisator til faktisk å
-- SETTE en status på en deltaker. `status` er tournament-BREDT (ikke
-- per runde) -- samme granularitet som `total_label` allerede
-- representerer på leaderboardet (én sum-rad per deltaker).
--
-- 'active' (default) = uendret oppførsel, ingen regresjon. Ikke-aktiv
-- status overstyrer BÅDE det beregnede til-par-tallet OG en ev.
-- Cut-status (satt SIST i _compute_individual_standings, se
-- app/routers/individual_tournaments.py) -- en DSQ-et spiller er DSQ,
-- uansett om de tilfeldigvis også er kuttet.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament_participant
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'dsq', 'rtd', 'dnf', 'dns'));

View file

@ -1,48 +0,0 @@
-- =====================================================================
-- TeeCup — migrasjon 088
-- Shotgun-start: alternativ til den tradisjonelle, staggerte starten
-- =====================================================================
-- Reverserer/utvider EKSPLISITT migrasjon 084 sitt bevisste "IKKE
-- shotgun"-valg fra 2026-08-18 -- se ADR-096 for full begrunnelse for
-- hvorfor dette nå bygges. Samme arkitektoniske hull fantes BEGGE
-- steder rundeplanlegging finnes i appen: individuelle turneringer
-- (tournament_round/tournament_round_group) og lag/Cup-format
-- (session/match) -- ett delt starthull for HELE runden/økten, kun
-- ulikt KLOKKESLETT per gruppe/match, aldri ulikt HULL. Rettet
-- identisk begge steder.
--
-- start_mode = 'consecutive' (standard, uendret oppførsel -- INGEN
-- regresjon): alle grupper/matcher deler samme starthull
-- (tournament_round.start_hole / session.start_hole, uendret), staggert
-- KUN på klokkeslett (eksisterende tee_time_override-mønster, uendret).
--
-- start_mode = 'shotgun': ALLE grupper/matcher i runden/økten går ut
-- SAMTIDIG (intervallet blir meningsløst og ignoreres av app-laget i
-- denne modusen) -- fra HVER SIN starthull i stedet. Nytt, valgfritt
-- per-gruppe/per-match starthull-felt dekker dette -- NULL når runden/
-- økten uansett er 'consecutive' (bruker da forelderens delte
-- starthull som før), settes eksplisitt av organisator per gruppe/
-- match kun i shotgun-modus.
--
-- Samme text+CHECK-mønster som migrasjon 087 (ikke en ny
-- CREATE TYPE ... AS ENUM, konsistent med nyere migrasjoner i denne
-- filserien).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament_round
ADD COLUMN start_mode text NOT NULL DEFAULT 'consecutive'
CHECK (start_mode IN ('consecutive', 'shotgun'));
ALTER TABLE tournament_round_group
ADD COLUMN start_hole smallint
CHECK (start_hole IS NULL OR start_hole BETWEEN 1 AND 18);
ALTER TABLE session
ADD COLUMN start_mode text NOT NULL DEFAULT 'consecutive'
CHECK (start_mode IN ('consecutive', 'shotgun'));
ALTER TABLE match
ADD COLUMN start_hole smallint
CHECK (start_hole IS NULL OR start_hole BETWEEN 1 AND 18);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -108,35 +108,6 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.
`first_name` separat, ingen backend-endring nødvendig). Se
CHANGELOG.md for full detalj.
## Turneringsoppsett — stor skjerm først (ufravikelig, gjelder ALT, eksisterende og fremtidig)
- Brukeren instruerte eksplisitt 2026-08-17: turneringsOPPSETT (opprette
turnering, sette opp lag/tropper, importere/administrere spillere,
runder, klasser, baneoppsett, alt organisator-arbeid FØR/RUNDT selve
spillingen) skal designes "stor skjerm først", IKKE "mobil først" —
organisatoren sitter som regel foran en PC når dette gjøres, ikke på
mobil på banen. Dette er en STÅENDE forventning til alt fremtidig
UI-arbeid på denne typen skjermer, ikke en engangsting.
- Gjelder KUN oppsett-/administrasjonsskjermer. Skjermer SPILLEREN bruker
UNDER selve rundens gang (scorekort/slag-registrering/rangefinder/
leaderboard-under-spilling) forblir mobil-først som før — de brukes på
banen, på mobil, ikke ved en PC. Grensen følger hvem som sitter hvor,
ikke en fast liste — vurder per skjerm hvem som faktisk bruker den og
fra hvilken enhet.
- Praktisk konsekvens: layout/typografi/tett-vs-luftig-avstand
optimaliseres for et bredt skjermbilde som førstevalg (f.eks. tabeller
med flere synlige kolonner samtidig i stedet for kortstabler, sidepanel-
layout i stedet for full-bredde-modaler, hover-tilstander som en reell
interaksjon), men skal FORTSATT fungere og være betjenbart på smal
skjerm (samme STÅENDE tilgjengelighetsregel over gjelder uansett
skjermbredde) — "stor skjerm først" er en prioritering av hvilket
format som designes FOR primært, ikke en unnskyldning for å la mobil
forfalle.
- Gjelder begge retninger: (a) ta dette eksplisitt med som krav når en ny
V0-prompt skrives for et oppsett-/administrasjonsskjermer, og (b) rett
opportunistisk opp eksisterende oppsett-skjermer når de likevel røres i
en annen runde — ingen egen stor retrofit-runde er igangsatt eller bedt
om ennå.
## Arbeidsmåte
- Inkrementelt. Ingenting tas for gitt før det er testet. Bekreft hvert steg før
du går videre.

View file

@ -4,10 +4,6 @@ WORKDIR /srv
COPY app/requirements.txt app/requirements.txt
RUN pip install --no-cache-dir -r app/requirements.txt
# Chromium for print_pdf.py (ADR-097) -- --with-deps henter apt-avhengighetene
# Chromium trenger (fonter/libnss3/libatk osv.), python:3.12-slim har dem ikke.
# Merkbart større image (~300-500MB) -- akseptert kostnad, se ADR-097.
RUN playwright install --with-deps chromium
COPY app/ app/
COPY handicap_engine.py handicap_engine.py

View file

@ -7,9 +7,7 @@
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
>
> Sist oppdatert: 2026-08-21 (funksjonelle hull funnet under utskrift-
> planlegging notert -- hullengde, turneringslogo, bracket-format
> utsatt, shotgun-start under bygging -- se de to nederste seksjonene)
> Sist oppdatert: 2026-07-17
---
@ -475,10 +473,8 @@
team_a/team_b i én match — riktig total og per-økt-delsum likevel.
### Push-varsler
- **Status:** ✅ HELT FERDIG 2026-07-28 — se "Varsler: push til telefon +
in-app varslingssenter" lenger ned i denne filen for full detalj. Denne
linjen var et tidlig notat som aldri ble fjernet da resten ble bygget
(stale-opprydding 2026-08-14).
- **Status:** 📋 planlagt (infrastruktur)
- «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit.
### Sanntid (WebSockets)
- **Status:** ✅ HELT FERDIG, LIVE 2026-07-19 (ADR-025 + ADR-027). Koblet
@ -567,46 +563,12 @@
flagget" der siste slag ble brukt). **Konkret krav fra bruker, eksplisitt
formulert:** en visning som viser GJENSTÅENDE slag for spilleren,
oppdatert etter hvert spilte hull (nedtelling, ikke bare et sluttall).
**Fremtidig idé, opprinnelig betinget av at GPS ble integrert i appen
først:** bruk GPS til å markere/registrere HVOR på banen spilleren
faktisk endte opp når slagene tok slutt, ikke bare hvilket hull.
**Selve Flaggturnering-formatet ER bygget og live** (se "2026-07-30:
alle åtte formater" lenger ned i denne filen — `flag_result`-motoren,
hull-granularitet). **GPS-avhengigheten er også borte** — GPS/
avstandsmåling ble bygget 2026-08-10/12 (ADR-048 slag-for-slag-måling,
ADR-064 rangefinder til grønn).
**✅ GPS-posisjon-ved-siste-slag ER NÅ BYGGET (ADR-066, 2026-08-14),
"Del A" av en tredelt utvidelse** (kartoversikt "Del B"/ADR-067 og
nytt format Eclectic "Del C"/ADR-068 er OGSÅ bygget nå, se egne
punkter -- alle tre deler ferdige): "Plant flagget"-knapp +
guidet sheet (GPS auto-fanget → "Hullet du ut på hull N?" → evt.
"Landet du på green?" + avstand i m/cm som påvirker rekkefølgen mot
andre som gikk tom på samme hull) i BEGGE hjem (frittstående runder OG
org-individuelle turneringer). Full runde 2+-scoreføring bygget for
spillere med uvanlig gunstig slagbudsjett (isolert overflow-tabell,
rører ikke delt `round_hole`/`tournament_round_hole`-infrastruktur).
UI-sheeten er en Claude-skrevet V0-eksport (zip 11), ikke håndkodet.
Migrasjon 069 (frittstående)/070 (org) — se ADR-066 for full detalj,
CHANGELOG.md for byggelogg/verifisering.
**✅ Kartoversikt ER NÅ BYGGET (ADR-067, 2026-08-14), "Del B"** — av/på-
bryter (standard AV, eier/org-medlem) som viser ALLE deltakeres flagg
på et satellittkart, lap 1 vs. lap 2+ skilt med farge+tekstbadge.
Org-individuelle turneringers offentlige tilskuervisning er BEVISST
utenfor omfang her (finnes ikke i det hele tatt ennå — `/t/[id]/live`
er lagturnerings-only — egen, større oppgave hvis/når etterspurt).
Migrasjon 071 — se ADR-067.
**✅ Eclectic ER NÅ BYGGET (ADR-068, 2026-08-14), "Del C" — siste del,
hele den tredelte utvidelsen er dermed ferdig.** Nytt format for
org-individuelle turneringer (`scoring_method = eclectic_gross/
eclectic_net/eclectic_stableford`) -- beste resultat PER HULL på
tvers av turneringens EGNE runder (krever samme bane, avvist tydelig
ellers). IKKE det samme som OOM sin ennå-ubygde "Eclectic på tvers av
lenkede turneringer" (se Order of Merit-seksjonen lenger ned) -- den
motoren kan trolig gjenbrukes derfra senere, men selve OOM-
integrasjonen er fortsatt ugjort. Migrasjon 072 — se ADR-068.
**Fremtidig idé, uttrykkelig betinget av at GPS er integrert i appen
først** (se GPS/avstandsmåling-notatet lenger opp i denne filen, ADR-033-
seksjonen — samme avhengighet): bruk GPS til å markere/registrere HVOR på
banen spilleren faktisk endte opp når slagene tok slutt, ikke bare hvilket
hull. Ingen datamodell eller UI designet ennå for noen del av dette —
rent notat.
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
@ -958,18 +920,12 @@ ikke sirkel/firkant som `round-scorecard.tsx` sin `ScoreMark`) ble også
funnet og rettet samme runde. Se CHANGELOG.md 2026-07-30 for full
verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
**RETTELSE 2026-08-14 — de to linjene under var stale, aldri oppdatert
etter utrulling.** Migrasjon 040 er bekreftet live i ekte `teecup_db`
(`tournament.format_type`/`scoring_method` finnes i produksjonsskjemaet
med begge CHECK-constraints, verifisert direkte 2026-08-14). Individuelle
turneringer må ha vært rullet ut en gang mellom 2026-07-30 og 2026-08-04
— Order of Merit (migrasjon 055, se eget punkt lenger ned) forutsetter at
individuelle turneringer allerede er live, og ble selv bekreftet
"BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04". Denne seksjonen
fikk aldri sitt eget "rullet ut"-notat da det skjedde — historikken under
er beholdt uendret, kun disse to linjene er korrigert:
~~IKKE rullet ut mot ekte systemer ennå~~ / ~~IKKE rullet ut mot ekte
`teecup_db` ennå~~ → **✅ rullet ut, live siden senest 2026-08-04.**
**IKKE rullet ut mot ekte systemer ennå** — venter på brukerens
eksplisitte bekreftelse (se CLAUDE.md for full detalj og utrullingsplan).
**IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens
eksplisitte bekreftelse (migrasjon 040 + redeploy av `teecup_api`), per
CLAUDE.md sin ufravikelige regel.
---
@ -1772,23 +1728,25 @@ er en annen streng, ikke rørt. Ekte typesjekket produksjonsbuild kompilerte
rent. Rullet ut (kun `teecup_frontend`), ingen migrasjon, `teeoff.no`
upåvirket.
## Notat 2026-07-30: per-hull historikk/statistikk for spilleren — ✅ HELT FERDIG, BYGGET OG LIVE (ADR-072 2026-08-15, relokert + full historikk med grafer ADR-079 2026-08-16)
## Notat 2026-07-30: per-hull historikk/statistikk for spilleren — 📋 NOTERT, IKKE bygget
Reist av brukeren: spilleren bør kunne se all historikk/statistikk for
NØYAKTIG det hullet vedkommende skal spille (eller har spilt) — f.eks. "du
har i snitt brukt X slag/Y putter på hull 7 på denne banen, GIR Z% av
gangene" — ikke bare hva som skjedde DENNE runden.
**RETTELSE 2026-08-16 — "📋 NOTERT, IKKE bygget" var stale.** Bygget som
ADR-072 (2026-08-15): `app/hole_history.py`, ny `GET .../holes/{n}/
history`, aggregerer PÅ TVERS AV BÅDE frittstående runder OG
org-turneringer (bredere enn notatets opprinnelige "kun frittstående
runder"-forslag), banebro via `teeoff_facility_slug`/`teeoff_course_id`
(ikke navn-snapshotet notatet foreslo). Siden utvidet videre 2026-08-16
(ADR-079): historikken flyttet UT av selve slagvinduet/-registrerings-
skjermen til skjermen før, og et klikk åpner nå FULL historikk med en
score-fordelingsgraf (samme stolpe-mønster som `round-stats.tsx`), ikke
bare en kort oppsummering.
**Ikke trivielt, verdt å notere hvorfor:** dagens data er scoped PER
RUNDE (`round_hole`), ingen eksisterende spørring aggregerer "alle mine
tidligere runder på DENNE banen, filtrert til DETTE hullnummeret". Banen
identifiseres i dag kun ved navn-snapshot (`round.course_name_snapshot`,
se `PlayedCourses`/`course-rounds.tsx` sin eksisterende gruppering på
akkurat dette navnet) — samme identifikator kan trolig gjenbrukes til en
ny spørring: `GET /rounds/holes/{course_name}/{hole_number}/history` (eller
tilsvarende), som slår sammen `round_hole`-rader på tvers av alle
fullførte runder brukeren eier/har spilt på den banen. Naturlig plassering
i UI-et: en liten utvidbar seksjon i `ScoringWizard` sitt "strokes"-steg
(`round-detail.tsx`), og/eller i `round-scorecard.tsx`/`round-stats.tsx`
sine hull-visninger. Ingen design/ADR skrevet ennå — kun fanget opp her.
## Notat 2026-07-30: lenke til rundestatistikk fra scorekortet, og hvorvidt scorekort/statistikk/live-registrering bør slås sammen til én visning — lenke + prikker ✅ BYGGET, BROWSERVERIFISERT OG LIVE; sammenslåing bevisst UTSATT til brukertesting
@ -2226,26 +2184,13 @@ sammenslåing eller rekkefølge-endring i en senere runde.
---
### Internasjonale baner (utenfor Norge) — ✅ BYGGET OG LIVE 2026-08-12, se ADR-064/ADR-065 (research 2026-08-03)
**RETTELSE 2026-08-14 — denne seksjonen var stale, aldri oppdatert etter
bygging.** Researchen under viste seg presis: GolfAPI.io ble faktisk
valgt (nøyaktig leverandøren anbefalt her) og bygget som **ADR-064**
(GolfAPI.io som tredje banekilde, delt cache-lag, kopier-inn i BEGGE
banemodeller) — Tjøme Golfklubb, Larvik Golfklubb (Seasidebanen) og
Nesbyen/Nesfjellet Golfklubb er alle importert og live i produksjon
(sistnevnte med 124 manuelt registrerte GPS-koordinater, siden GolfAPI
selv manglet koordinatdata for akkurat den banen — se ADR-064-tillegget
2026-08-13). Rangefinder til grønn (avstand front/midt/bak) er koblet inn
for frittstående runder. Researchen under er beholdt uendret som historisk
kontekst for HVORFOR GolfAPI.io ble valgt — les den som bakgrunn, ikke som
en gjenstående plan.
### Internasjonale baner (utenfor Norge) — 📋 NOTERT, IKKE designet/bygget (research 2026-08-03)
Brukeren undersøkte (i en ekstern Gemini-samtale, ikke denne økten — notert
her for å fange retningen før den glemmes, samme rutine som Order of
Merit-notatet over) hvordan TeeCup kan støtte baner utenfor Norge, siden
`teeoff_db` (ADR-004/ADR-019) kun dekker norske baner. Ingen kode skrevet,
ingen ADR-runde startet ennå — dette var et fremtidig, ikke-akutt behov,
ingen ADR-runde startet ennå — dette er et fremtidig, ikke-akutt behov,
relevant når/hvis TeeCup faktisk kommersialiseres og får brukere utenfor
Norge (se `teecup.golf`-domeneplanen, kjøpt med sikte på at TeeCup etter
hvert flytter dit fra `teecup.teeoff.no`).
@ -2284,17 +2229,17 @@ uten koordinater/5 995€ med, «Europa»/«USA»/«Asia-Pacific+Africa» hver
1 995€/2 995€. CSV gir 6 måneders oppdateringstilgang, deretter 20% av
opprinnelig pris per år for videre oppdateringer.
**Opprinnelig "neste steg"-plan, alle tre punkter nå avklart/bygget under
ADR-064/065 (2026-08-12/13):** (1) `course.source='international'` +
`external_course_ref` (org-siden) / `personal_course.external_golfapi_
course_id` (global) — ikke én ny verdi i ett skjema, men et kopier-inn i
BEGGE eksisterende banemodeller (ADR-064 Beslutning B, bekreftet med
bruker). (2) `app/golfapi_cache.py` (ikke `golfapi_client.py` som
opprinnelig navngitt her — client/cache-lagene ble delt i to, se ADR-064)
håndterer enhets-/datakvalitet-defensivt, inkl. en reell tomme-strenger-
i-stedet-for-null-bug funnet og fikset 2026-08-13. (3) Søkefeltet ble ETT
samlet felt akkurat som antatt her — "Fant ikke banen? Søk internasjonalt
(GolfAPI)"-lenke fra det vanlige egen-bane-søket, ikke to synlige knapper.
**Bevisst utsatt, naturlig neste steg NÅR dette skal bygges:** en liten
ADR (samme mønster som ADR-019) som dekker (1) ny `course.source`-verdi +
migrasjon, (2) `app/golfapi_client.py` sitt kontraktsformat + enhets-
konvertering (advarsel fra research: amerikanske kilder returnerer ofte
yards, må konverteres til meter FØR lagring — `hole`/`tee_rating`-
tabellene har ingen enhets-diskriminator i dag), (3) hvordan bane-søket i
frontend (samme to-felts-forvirring som teeoff-søket over, se det åpne
punktet rett over) skal skille "søk norske baner (teeoff)" fra "søk
internasjonale baner (golfapi.io)" — trolig ett samlet søkefelt med
landkode-basert ruting internt, ikke to synlige knapper. Ikke noe hastverk
— ingen brukere utenfor Norge ennå.
---
@ -2941,18 +2886,7 @@ tom-skjermens endelige form kan bestemmes.
---
### 2026-07-25 — avhengigheten er løst, konkret forslag lagt frem — ✅ BYGGET (RETTELSE 2026-08-14)
**RETTELSE 2026-08-14 — "📋 DESIGNET, IKKE BYGGET" var stale.** Blokk-
forslaget under (Hurtighandlinger/Kommende runder/Kommende turneringer/
Statistikk/Spilte baner/Venner/Organisasjoner) er bygget nesten ordrett —
bekreftet direkte i `frontend/components/dashboard.tsx`, som har egne
seksjoner for nøyaktig disse syv blokkene i samme rekkefølge (bygget som
del av ADR-035, samme dag forslaget ble skrevet). Denne seksjonens egen
tidsstempel ga inntrykk av at forslaget fortsatt lå ubygget da resten av
filen (linje ~2830) allerede sa "✅ HELT FERDIG (bygget som ADR-035, live
2026-07-25)" — samme dags rekkefølge i filen var bare forvirrende, ikke
en reell uenighet.
### 2026-07-25 — avhengigheten er løst, konkret forslag lagt frem (📋 DESIGNET, IKKE BYGGET)
Frittstående runder (ADR-033) er nå ferdig bygget (backend+frontend, alle
oppfølgingsrunder), så blokkeringen over er borte. Brukeren reiste samtidig
@ -3637,21 +3571,13 @@ retroaktiv hull-kort-handling. Skjema: ny `round_shot`-tabell (migrasjon
alle endepunkter, delings-flyt med satellitt-bilde-generering) — se
CHANGELOG.md punkt 37 for full detalj. Frontend delvis (Haversine-
hjelper, delt `ClubPicker`, Mapbox-avhengighet + token-plumbing).
**RETTELSE 2026-08-18 — "Gjenstår"-lista under var stale, alt i den er
nå ferdig:** `ShotMeasurementSheet` V0-integrert og LIVE (`shot/map-
point-picker.tsx` er i dag en ferdig, produksjonsbrukt "klikk et punkt
på kartet"-komponent, bekreftet gjenbrukbar i "Manuell koordinat-
editor"-notatet lenger ned i denne filen), ekte Mapbox-token satt,
migrasjon 060 rullet ut mot ekte `teecup_db`. Byggekjeden fortsatte
dessuten videre til rangefinder-avstand til faste banepunkter
(ADR-064/065, ADR-081) og et visuelt hull-diagram (ADR-083/084,
2026-08-17/18) -- hele "avstandsmåling ligger i kortene"-ambisjonen
denne brainstorm-tråden startet er nå bygget og live, ikke lenger et
åpent spørsmål. ~~Opprinnelig "Gjenstår"-liste, beholdt for
sporbarhet:~~ V0-eksport for selve `ShotMeasurementSheet`, kobling av
inngangspunktene i `round-detail.tsx`, reelle Mapbox-token i `.env`,
ekte nettleserverifisering av kostnadskontroll-invarianten (ADR-048
Beslutning B), utrulling til ekte database/containere.
**Gjenstår:** V0-eksport for selve `ShotMeasurementSheet` (prompt sendt
til bruker, venter på svar), kobling av inngangspunktene i
`round-detail.tsx`, reelle Mapbox-token i `.env` (kun tomme
plassholdere satt inn foreløpig), ekte nettleserverifisering av
kostnadskontroll-invarianten (ADR-048 Beslutning B), og til slutt
utrulling til ekte database/containere (krever migrasjon 060 kjørt mot
ekte `teecup_db` — IKKE gjort ennå, venter på eksplisitt bekreftelse).
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
@ -3922,7 +3848,7 @@ om å ikke fjerne historikk:**
---
## Én person, flere e-postadresser — DEL 1 (det enkle tilfellet) ✅ BYGGET OG LIVE 2026-07-21, DEL 2 (kontosammenslåing) ✅ BYGGET OG LIVE 2026-08-17 (ADR-080)
## Én person, flere e-postadresser — DEL 1 (det enkle tilfellet) ✅ BYGGET OG LIVE 2026-07-21, DEL 2 (kontosammenslåing) fortsatt 📋 NOTERT
Reist av brukeren rett etter ADR-032 (verifisert e-postbytte). Et beslektet,
men DISTINKT behov: én person kan ha flere e-postadresser i omløp samtidig
@ -4023,16 +3949,18 @@ kjørt mot ekte `teecup_db` (bekreftet begge nye tabeller finnes,
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
upåvirket.
### Del 2 (ekte kontosammenslåing) — ✅ BYGGET OG LIVE 2026-08-17
### Del 2 (ekte kontosammenslåing) — fortsatt 📋 NOTERT, IKKE designet
**RETTELSE 2026-08-18 — "📋 NOTERT, IKKE designet" var stale.** Bygget
som ADR-080 (se ARCHITECTURE_DECISIONS.md): selvbetjent, "keeper"
(initiativtaker, overlever) vs. "taper" (målet, slås inn og slettes),
lenke sendt til MÅLETS e-post for å bevise eierskap (samme prinsipp
som e-postbytte, ADR-032 Beslutning B), ny `account_merge_token`-
tabell (migrasjon 077). Løser presist spørsmålet under (hva skjer når
adressen som legges til allerede eies av en annen konto) -- avviser
IKKE lenger med 409, tilbyr i stedet ekte sammenslåing.
Uendret fra den opprinnelige analysen: hva skjer hvis adressen som legges
til ALLEREDE er primær- eller sekundæradressen til en ANNEN, eksisterende
konto (spilleren har altså to helt separate kontoer med egen historikk —
ulike org-medlemskap, ulike spillerkoblinger, kanskje ulikt passord/2FA)?
Dagens del 1-løsning avviser dette tydelig (409 DUPLICATE) i stedet for å
gjette — en ekte sammenslåing (slå sammen org-medlemskap uten å bryte
"én rolle per bruker per org", deduplisere spillerkoblinger, avgjøre
hvilken konto som "vinner" for motstridende felt) er en betydelig større
og mer risikofylt operasjon, fortsatt bevisst utsatt til en egen,
dedikert designrunde.
---
@ -4062,7 +3990,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort:
| Del | Status | Notat |
|---|---|---|
| Installerbar app (manifest + ikoner + «Legg til på hjemskjerm») | ✅ bygget | `app/manifest.ts` (Next.js sin innebygde manifest-generator), `components/sw-register.tsx`, `appleWebApp`-metadata for iOS. |
| Ikoner | ✅ **ERSTATTET MED EKTE DESIGN 2026-08-10** | Opprinnelig et enkelt, midlertidig grønt golf-flagg generert programmatisk. Erstattet med et ekte app-ikon (ball/pokal/tee-motiv, full-bleed) — se ADR-055 i ARCHITECTURE_DECISIONS.md (inkl. en korrigeringsrunde etter en reell tegnefeil i første forsøk). Denne raden var stale frem til 2026-08-14-oppryddingen. |
| Ikoner | ✅ bygget, **MIDLERTIDIG** | Enkelt grønt golf-flagg generert programmatisk (`public/icons/*`, `public/apple-icon.png`) — **skal erstattes med ekte design senere.** Erstattet samtidig den gamle v0.app-plassholderlogoen som lå i `apple-icon.png` fra før (var aldri TeeCup-merkevare). |
| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. |
| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). |
| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. |
@ -4354,7 +4282,7 @@ denne veien er plattform-offentlig).
---
## Order of Merit (sesong-sammenlagt rangering) — ✅ HELT FERDIG, ALLE TRE DELER BYGGET OG LIVE (spiller-OOM 2026-08-04 ADR-043/migrasjon 055; lag-OOM + eclectic på tvers av turneringer + offentlig visning 2026-08-15/16, ADR-074/075/076)
## Order of Merit (sesong-sammenlagt rangering) — ✅ SPILLER-OOM BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04 (ADR-043, migrasjon 055). Eclectic + lag-OOM 📋 GJENSTÅR
Brukeren ba om en vurdering av GolfBox sin Order of Merit-funksjon og
tok den til en full designrunde. Bygget denne runden: skjema (fire nye
@ -4368,22 +4296,25 @@ for de fem arkitektoniske beslutningene og CHANGELOG.md 2026-08-04 for
full bygge-/verifiseringsdetalj (63/63 håndregnede API-sjekker + RLS-
isolasjon + ekte nettleser-gjennomgang).
**RETTELSE 2026-08-16 — "Eclectic + lag-OOM 📋 GJENSTÅR" var stale.** Alle
tre punktene under ble tatt i én samlet runde 2026-08-15/16:
- **Eclectic-aggregering PÅ TVERS AV LENKEDE TURNERINGER (OOM-nivå)** --
✅ BYGGET (ADR-075). `eclectic_best_per_hole()` fra `handicap_engine.py`
gjenbrukt UENDRET, som antatt her -- kun datainnsamlingen på tvers av
turneringer var ny. Samme-bane-håndheving lagt til (avviser lenking/
modus-bytte som ville gjort eclectic meningsløst på tvers av ulike
baner).
- **Lag-OOM sin faktiske leaderboard-beregning** -- ✅ BYGGET (ADR-074).
Full CRUD for lag/medlemmer + `GET .../leaderboard`-grenen for
`kind='team'`, nøyaktig prinsippet som var avklart (ETT lagret
aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig).
- **Offentlig/delt visning** -- ✅ BYGGET (ADR-076, migrasjon 076). Ny
SECURITY DEFINER-bro + `/public/order-of-merits/{id}`-endepunkt + ny
uautentisert side, samme anti-enumereringsmønster som resten av appens
offentlige sider.
**Gjenstår fra opprinnelig plan (bevisst utsatt, ikke glemt):**
- **Eclectic-aggregering** ("drømmerunde" -- beste resultat PER HULL på
tvers av alle lenkede turneringer en spiller deltok i, kun for
resultattype Stableford/brutto/netto). Krever egen per-hull-data-
innsamlingslogikk i leaderboard-endepunktet (ikke bare en ny gren i
`order_of_merit_aggregate`, som kun håndterer sum/snitt) -- egen
design-/verifiseringsrunde.
- **Lag-OOM sin faktiske leaderboard-beregning.** Skjema
(`order_of_merit_team`/`_team_member`) og prinsippet (sesong-par satt
opp DIREKTE i OOM-en, summerer/velger-beste-N-av medlemmenes allerede
beregnede individuelle OOM-resultater -- IKKE strengmatching på
turnering-lag, se ADR-043 Beslutning C) er avklart og bygget inn i
skjemaet, men selve `GET .../leaderboard`-grenen for `kind='team'`
finnes ikke ennå (avviser eksplisitt med en tydelig feilmelding).
Trenger CRUD-endepunkter for lag/medlemmer (ingen finnes ennå) pluss
selve aggregering-oppå-aggregering-logikken.
- **Offentlig/delt visning.** `public_visible`-feltet finnes og er
redigerbart i innstillinger, men ingen egen, ikke-innlogget-tilgjengelig
visning er bygget (kun den vanlige org-scopede detaljsiden).
---
@ -4524,350 +4455,3 @@ innlogging/auth-flyten fikk den nye "clubhouse"-paletten. Resten av appen
palett (se DESIGN_SYSTEM.md) inntil videre — retemaes gradvis, skjerm for
skjerm, når de likevel røres i senere runder. Ingen egen stor
retemaings-runde er igangsatt eller bedt om.
## NGF spilleformer vs. det TeeCup faktisk har bygget — 📋 NOTERT, IKKE besluttet hva som skal bygges
Bruker lastet opp NGF sitt offisielle "Spilleformer"-dokument (sist
endret 4. oktober 2023, `Temp-uploads/spilletyper-og-spilleformer-2023
(1).pdf` — identisk med den tidligere opplastede versjonen samme navn
uten "(1)") 2026-08-17 og ba om en sammenligning mot alt TeeCup faktisk
har bygget. Sjekket mot de LIVE CHECK-constraintene i `teecup_db` (ikke
bare dokumentasjon): `round.play_format`, `session.format`,
`tournament.scoring_method`.
**Dekket** (rett frem eller under annet navn): Single/match, Foursome,
Four-Ball, Greensome, Scramble (2-/4-manns + egen solo-variant utover
NGF), Individuell slagspill, Stableford, Eclectic, Flaggkonkurranse,
Københavner, Nassau (bygget som poeng-panel oppå en singel-match, ikke
egen format-verdi), Chapman (= NGFs "Irish Greensome", samme mekanikk
annet navn), High-low-high (= NGFs "Four-ball beste og dårligste").
Skins/Bingo Bango Bongo/Money Ball/Shamble er TeeCup-tillegg UTOVER
NGF-lista (ikke i dokumentet i det hele tatt).
**Ikke dekket -- ekte hull i NGF-lista, ingen omtale andre steder i
denne filen:**
- **Amerikaner** -- 4 spillere, 12 poeng/hull fordelt etter plassering
(8-4-0-0 / 6-6-0-0 / 4-4-4-0 / 3-3-3-3 osv. avhengig av utfall)
- **Four-ball sammenlagt** / **Four-ball besteball og sammenlagt** --
enklere lag-sammenlagt-varianter (kun best-ball-varianten er bygget)
- **Hallington** -- poengformel (hullets par × 2 minus brukte slag)
- **Hidden hole** -- et forhåndsbestemt antall tilfeldige hull trekkes
ut og telles etter at hele runden er spilt
- **Ransome** -- 3×6-hulls rotasjon: fourball → foursome → sammenlagt
- **Mexican Scramble** -- scramble-variant der spilleren hvis slag ble
valgt ikke får slå neste slag
- **Mulligan** -- HCP-baserte omslag, kan tas når som helst
- **Snorkonkurranse** -- spilleren får en snor (lengde = spillehandicap
i meter) til å forbedre ballens leie med
- **Three-ball match / Threesome (match og slagspill)** -- ekte
3-spiller-matchformater (kan i praksis omgås i dag med 3 separate
1v1-matcher manuelt satt opp, men ikke som egen, sammenhengende
turneringsform)
- **Maksimum Score** som egen, distinkt konkurranseform (migrasjon 038
sin generelle "pickup"-mekanikk dekker DELER av dette, men ikke som
eget valgbart format med par+4-cap)
- **Bogey/Par-konkurranse** -- matchspill mot et fast target-resultat,
ikke stableford-poeng
- **Nærmest hullet / Lengste drive** -- vanlige side-konkurranser ved
siden av hovedturneringen, ingen støtte i det hele tatt (verken som
eget "mini-format" eller som en tilleggsmarkering på en runde/økt)
**Vurdert, men trolig ikke aktuelt å bygge:** Pro Am er en deltaker-
sammensetning (profesjonell + amatører), ikke en distinkt scoringsform
-- TeeCup har ingen pro/am-status-modell i dag, og det er uklart om det
gir egenverdi å bygge som eget format fremfor bare la brukeren sette opp
et vanlig lag.
**Neste steg:** ingen bygging igangsatt. Når/hvis bruker vil prioritere
noen av disse, bør de vurderes én om gangen (samme mønster som de åtte
formatene i punkt 14/CHANGELOG.md) -- flere av dem (Nærmest hullet/
Lengste drive spesielt) er trolig enklere å bygge enn et helt nytt
scoringsformat, siden de er sidespill uavhengig av selve rundens
hovedformat.
## Manuell koordinat-editor for rangefinder-punkter — 📋 NOTERT, IKKE besluttet/designet
Etter ADR-081 (migrasjon 079, rangefinder for offisielle baner) kan
koordinater for en offisiell (TeeOff-koblet) bane settes via `PUT /orgs/
{id}/courses/{id}/coordinates` -- men KUN via et rått API-kall (Claude
kjørte dette manuelt for Tjøme, to runder på rad). Bruker ba 2026-08-17
om en ekte selvbetjent vei: "et system for mulighet for å legge inn
koordinater manuelt, uten at jeg må spørre deg."
**Grunnlag allerede på plass, verifisert denne runden:**
- Skrive-endepunktet for OFFISIELLE baner finnes allerede (`courses.py`,
se ADR-081) -- mangler kun et menneskelig grensesnitt foran seg.
- GolfAPI-koblede PERSONLIGE baner (`personal_course`/`golfapi_course_
coordinate`) har derimot INGEN manuell skrive-vei i det hele tatt --
fylles i dag kun automatisk via `golfapi_cache.get_or_fetch_golfapi_
course()`. Bør trolig få en tilsvarende manuell overstyrings-/
tilleggs-vei, ikke bare offisielle baner.
- `MapPointPicker` (`frontend/components/shot/map-point-picker.tsx`,
Mapbox, brukt til slagmåling/ADR-048) er en FERDIG, produksjonsbrukt
"klikk et punkt på kartet og få lat/long tilbake"-komponent -- den
tyngste enkeltbrikken en koordinat-editor trenger finnes allerede,
trolig gjenbrukbar med moderat tilpasning fremfor å bygges fra bunnen.
- Selve TYPE-valget (bunker/vann/tre/landemerke/tee/green osv. +
front/midt/bak + venstre/senter/høyre) må fortsatt gjøres av et
menneske via et skjema/dropdown -- Tjøme-rundens "Voll"/"Bjella"-
erfaring viste konkret at fritekst-til-kategori IKKE kan automatiseres
pålitelig. Editoren bør altså være: velg bane/hull → klikk punkt på
kart (eller tast lat/long manuelt) → velg type/plassering/side fra
dropdown → lagre. Sannsynligvis også en tabell-/listevisning av
allerede lagrede punkter per hull (les via eksisterende `GET .../
coordinates`) med slette-/rediger-mulighet.
**Neste steg:** egen planrunde (ikke påbegynt) -- avklare om `GET`/`PUT`
trenger utvidelse for GolfAPI-baner, konkret UI-flyt (kart vs. rent
skjema, eller begge), og om dette skal være en frittstående admin-side
eller hektes inn i eksisterende baneoppsett-flyt i `courses.py`-relatert
UI (se "Baneoppsett i turneringer"-seksjonen over).
## Massimport av spillere til organisasjon/turnering (CSV) + redigerbar spillertabell — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-17 (ADR-082)
**RETTELSE 2026-08-18 — "🔨 PÅBEGYNT" var stale.** Fullført samme dag:
nytt bulk-endepunkt, CSV-parsing+kolonnegjetting
(`player-import-panel.tsx`, orkestrering) + redigerbar tabell (V0-
eksportert `player-import-view.tsx`, "stor skjerm først") -- første
redigerbar-tabell-UI-mønster i kodebasen. Én reell integreringsbug
funnet og fikset i scratch (V0s kjønn-nedtrekk brukte female/male/
other, rå CSV-tekst måtte normaliseres ved kolonnetilknytningen). Se
ADR-082/CHANGELOG.md 2026-08-17 for full detalj.
Bruker ba om en måte å masseimportere spillere fremfor én-og-én, og
spurte konkret om en tabell der ALLE felt er redigerbare. Research denne
runden bekreftet: ingen bulk-endepunkt finnes (`POST /orgs/{id}/players`
er kun én-om-gangen, frontend gjør det i en løkke i dag), og INGEN
redigerbar-tabell-UI-mønster finnes noe sted i kodebasen (all redigering
i dag er skjema/modal-basert) -- begge deler er reelt nytt arbeid, ikke
gjenbruk av et eksisterende mønster. `player.user_id` kobles automatisk
til en ekte konto ved fremtidig innlogging via e-post (samme mekanisme
som selvregistrering), så importen selv trenger ikke håndtere
invitasjon -- bare opprette rader.
Se ADR (skrives når planen er ferdig) for design/omfang.
## Retro-oversettelse av eksisterende UI (nb/en) — 🔨 PÅBEGYNT 2026-08-19 (ADR-094), resten 📋 planlagt i batcher
Grunnmuren (`next-intl`, cookie-basert språkvalg, ingen URL-endring)
og to genuint nye flater (hjelpeside/FAQ, den sammenslåtte Spillere-
tabellen) er bygget og fylt ut på begge språk, se ADR-094. **De
resterende ~68 eksisterende komponentfilene (~2200 hardkodede norske
UI-strenger, kartlagt 2026-08-19) er IKKE rørt ennå** -- for stort til
å gjøre trygt i én sammenhengende runde. Foreslått batch-rekkefølge for
påfølgende, avgrensede økter (hver batch = egen runde med egen
verifisering, samme inkrementelle disiplin som resten av appen):
1. 📋 Innlogging/dashbord/navigasjon (`logg-inn`, `dashboard.tsx`,
`more-menu.tsx`, `bottom-nav.tsx` osv.) -- mest brukte flater,
høyest synlighet.
2. 📋 Turneringsoppsett-wizarden (ADR-092/093/094, nettopp bygget,
fortsatt fersk i minnet).
3. 📋 Scorekort/leaderboard -- spillerens kjerneopplevelse under selve
rundens gang.
4. 📋 Resten (spillerpool, order of merit, feed/venner,
kontoinnstillinger, presentasjon/sponsorer).
5. 📋 Backend-feilkoder (479 `app_error`-kall) -- dekkes LØPENDE
gjennom batch 1-4 (hver batch oversetter kodene den faktisk støter
på i sine egne skjermer), ikke som en egen runde. **Reell
arkitekturbegrensning, ikke bare rekkefølge:** dagens kodetaksonomi
(`VALIDATION_FAILED`/`OUT_OF_SCOPE`/`NOT_FOUND` osv.) er for
grovkornet -- samme kode brukes til dusinvis av innbyrdes ulike
meldinger på tvers av appen, så kode→tekst-oversettelse alene gir
feil resultat for de fleste faktiske feil. Krever enten finere
koder (egen backend-refaktorering, ikke påbegynt) eller at backend
selv blir locale-bevisst (bryter det bevisste "ingen backend-
endring for oversettelse"-valget i ADR-094). Feilmeldinger forblir
norsk uansett språkvalg inntil en av disse løsningene velges.
## Funksjonelle hull funnet under "design-dokumentasjon"-runden (2026-08-20) — 📋 ikke fikset, kun notert
**Kontekst:** bruker ba om en fullstendig funksjonell spesifikasjon av
ALLE skjermer i appen (dashbord + 13 områdedokumenter, publisert som
artefakter), til bruk som grunnlag for en designer. Målet var å
beskrive hva som FINNES, ikke å lete etter feil -- men research-
prosessen (full gjennomlesning av all frontend-kode, i noen tilfeller
også backend-autorisasjon) avdekket en del reelle, eksisterende
funksjonelle hull underveis. Disse er IKKE fikset -- kun fanget opp og
notert her, som egne, fremtidige, avgrensede runder. Hvert punkt
under er hentet direkte fra research denne runden, ikke gjettet.
- **Rundelisten (`/my-rounds`, `/my-rounds/course/[name]`):**
mislykkes selve hentingen, kan en feilmelding og en tilsynelatende
EVIGVARENDE "Laster runder…"-indikasjon vises SAMTIDIG -- ingen
tydelig avsluttet feiltilstand for brukeren å reagere på.
- **Varsler (`/my-notifications`):** "ingen varsler" og "henting av
varsler feilet" vises i dag med nøyaktig samme tomme tilstand --
brukeren kan ikke skille reell tomhet fra en faktisk feil.
- **Ny-runde-veiviseren, steg 2 (Spilleform):** minimums-spillerantall
for Skins ("minst 3") og Københavner ("nøyaktig 3") vises kun som
hjelpetekst -- IKKE håndhevet. Veiviseren lar brukeren fullføre og
opprette runden uansett faktisk spillerantall.
- **Ny-runde-veiviseren, steg 4 (Lag og rekkefølge):** minimum 2
spillere i "Laget" for scramble mot enkeltspiller er kun en
informativ statusindikator -- ingen faktisk sperre på å gå videre.
- **Ny-runde-veiviseren, steg 5 (Deling):** advarselen om at "0
vennekategorier valgt" betyr at ingen venner faktisk kan se runden,
hindrer IKKE innsending -- kun informativ.
- **Turnering (individuell), Scorekort-fanen -- rettighetsbegrensning
usynlig i UI:** backend (`app/routers/individual_tournaments.py`,
`app/team_authz.py`) begrenser hvem som kan REGISTRERE/ENDRE en
deltakers score/GPS-flagg til (a) deltakeren selv, eller (b)
org-eier/administrator -- et vanlig org-medlem kan altså IKKE
registrere for andre. Dette vises INGEN steder i grensesnittet før
et lagringsforsøk faktisk feiler med en generisk feilmelding.
- **Samme fane -- DSQ/RTD/DNF/DNS-status og Cut:** begge blokkerer ny
score-registrering (for hele turneringen, hhv. for runder etter
cut-grensen), men INGEN av delene vises i selve Scorekort-fanen --
oppdages kun ved mislykket lagringsforsøk.
- **Samme fane -- Bingo Bango Bongo-panelet:** lagringsfeil er i dag
HELT stille -- ingen tilbakemelding overhodet ved mislykket lagring,
til forskjell fra absolutt alle andre lagringsflyter på samme fane.
- **Samme fane -- fjern flaggplantning:** mislykkes selve fjerningen,
får brukeren i dag INGEN tilbakemelding -- grensesnittet antar
alltid at det gikk bra.
- **Samme fane -- brutto/netto-inkonsistens i score-cellen:** hvilken
av de fem resultatkategoriene (eagle/birdie/par/bogey/dobbel bogey+)
en celle havner i regnes ut fra NETTO resultat, men TALLET som
faktisk vises i cellen er alltid BRUTTO -- kan fremstå
selvmotsigende for en spiller med tildelte slag på hullet.
- **Samme fane -- flaggkart-synlighet:** ingen eier/administrator-
unntak når kartoversikten over flagg IKKE er satt offentlig av
organisator -- selv org-eier/administrator ser da kun sine EGNE
plantede flagg, ulikt selve score-/flagg-REGISTRERINGEN (der
eier/administrator har en eksplisitt unntaksrett, se punktet over).
- **Organisasjon -- rolletilpasning ujevn:** kun Medlemmer-siden
henter og håndhever brukerens rolle (Eier/Administrator/Medlem) i
grensesnittet. Spillerpool- og Order of Merit-sidene viser samme
fulle rediger/opprett/slett-handlingssett til ETHVERT
organisasjonsmedlem, uansett rolle -- uklart fra frontend-koden
alene om noe håndheves usynlig server-side. Bør avklares eksplisitt
(enten bevisst full tilgang for alle medlemmer, eller server-side
rollesjekk legges til) fremfor å forbli en tilfeldighet.
- **Organisasjon -- bekreftelse ujevnt fordelt:** fjern medlem/forlat
organisasjon og slett hele Order of Merit-serien krever eksplisitt
bekreftelse; fjern lenket turnering (fra en Order of Merit-serie),
slett Order of Merit-lag, fjern lagmedlem og opphev invitasjon gjør
det IKKE, til tross for at flere av disse også er vanskelige/umulige
å angre.
- **Organisasjon -- Order of Merit "Eclectic"-regelen skjult:**
kravet om at ALLE lenkede turneringer må spilles på samme bane for
at Eclectic-aggregering skal fungere, vises kun i en LUKKET, ikke-
utvidet innstillings-seksjon -- en organisator oppdager regelen
først når et lenkeforsøk blir avvist av serveren.
- **Turnering (individuell), Oppsett -- bekreftelse ujevnt fordelt:**
fjern deltaker og fjern klasse krever ingen bekreftelse i det hele
tatt; slett runde bruker en to-stegs inline-bekreftelse; slett
turnering og anvend cut bruker fullverdige advarselsdialoger --
ingen tydelig, gradert logikk bak forskjellen i dag.
- **Samme sted -- fjern klasse, stille konsekvens:** deltakere satt
til en klasse som fjernes mister klassetilhørigheten sin stille,
uten noen egen advarsel om akkurat DEN konsekvensen (selve
klasse-fjerningen har uansett ingen bekreftelse i det hele tatt, se
punktet over).
**Fullstendig kontekst for hvert punkt** (nøyaktig hvor i koden, og
nøyaktig hvilke andre regler det henger sammen med) finnes i de
respektive funksjonsspesifikasjons-dokumentene sine "Oppsummert:
hva vi ber designeren om"-seksjoner (Egne runder del 1, Sosialt,
Turnering individuell del 2, Organisasjon-administrasjon) --
publisert som Claude-artefakter denne runden, ikke egne filer i
repoet. **Neste steg:** triasjere denne listen i egne, avgrensede
runder (sannsynligvis flere -- de spenner fra rene UX-forbedringer
til det som kan være en reell autorisasjons-/synlighets-beslutning
verdt en ADR, se rolletilpasning-punktet) -- ikke noe som bør fikses
alle på én gang.
## Funksjonelle hull funnet under "utskrift av turneringsdokumenter"-planlegging (2026-08-21) — 📋 delvis notert, delvis under bygging
**Kontekst:** bruker ba om V0-prompter for utskriftsklare startlister/
resultatlister/cart-tags/scorekort (PDF, A4/Letter + A3/A2 for Cup-
startlister). Undersøkt FØR promptene ble skrevet, for å unngå å designe
utskrift for felt/formater som ikke faktisk finnes ennå. Tre reelle hull
funnet; bruker har eksplisitt tatt stilling til alle tre (se hver):
- **Hullengde finnes ikke i datamodellen.** Verken `hole` (org-scopet,
brukt av turneringer), `personal_course_hole` eller
`golfapi_course_hole` har noen lengde-/avstandskolonne -- kun `par`
og `stroke_index`. Verken `golfapi_client.py` eller `teeoff_client.py`
henter/lagrer noe lengdefelt fra kildene i dag heller (uklart om
kildene faktisk TILBYR det uten videre, eller om det må legges inn
manuelt). Trengs for scorekort-utskrift (bruker: "Hullets lengde må
med"). **💤 Utsatt** -- mockes med plausible tall i selve V0-
utskriftsprompten (`v0-prompt-print-scorecard.md`), ekte
datamodell-utvidelse (ny kolonne + evt. import-kilde-sjekk) er en
egen, senere oppgave før feltet kan vise ekte tall.
- **Ingen dedikert turneringslogo finnes.** `tournament.hero_image_key`
(migrasjon 009) er et BREDT banner-bilde til presentasjonsfanen, ikke
egnet som en kompakt logo i en utskrift-topptekst. Sponsor-logoer
finnes (samme migrasjon) men er et annet konsept (sponsor, ikke
arrangør). Trengs på alle fire utskriftstyper (bruker: "Turneringslogo
(om den ikke eksisterer bruker vi TeeCup sin)"). **💤 Utsatt** --
mockes i V0-promptene, ekte `tournament.logo_key`-felt (+ fallback til
TeeCups egen logo) er en egen, senere oppgave.
- **128-spiller individuell utslagsturnering ("Sommercup"-stil,
single-elimination bracket) finnes ikke som turneringsform i det hele
tatt.** Appen har i dag KUN to-lag Ryder Cup-formatet (arkitektur-
invariant, CLAUDE.md: "v1 = nøyaktig to lag ... Match-modellen holdes
generell (to sider) så knockout/flere lag kan komme senere" -- aldri
bygget, kun forberedt for). Ingen runde-for-runde-parring/
vinner-videreføring-logikk finnes. **💤 Utsatt, bevisst.** Bruker
eksplisitt valgt: skip bracket-utskrift denne runden, kun de
formatene som faktisk finnes (individuell + to-lag Cup) dekkes av
utskriftsprompt-runden. Bracket-turneringsformen tas opp som egen,
betydelig oppgave (parring/videreføring/UI) FØR noen utskrift for
den designes.
- **Shotgun-start finnes ikke** (`tournament_round`/`tournament_round_
group` for individuelle turneringer, `session`/`match` for Cup-
format -- SAMME arkitektoniske begrensning begge steder: ett delt
starthull for HELE runden/økten, kun staggerte klokkeslett PER
gruppe/match, ingen forskjellig starthull per gruppe/match).
Individuell-siden ble dette BEVISST valgt bort i migrasjon 084
(2026-08-18, tre-spørsmål-avklaring med bruker den gang). Bruker denne
runden: "Det må komme frem om det er Shotgun eller løpende start...
Sorteringen av startlisten gjøres etter utslagsform." **✅ BYGGET
2026-08-21** (migrasjon 088, ADR-096) -- se ARCHITECTURE_DECISIONS.md
for full detalj, ikke gjentatt her.
**Oppfølging 2026-08-21 -- V0-eksporten koblet til ekte data (ADR-097):**
alle fire utskriftsflater (scorekort/startliste/cart-tags/resultatliste)
er nå kodet mot ekte API-endepunkter for begge turneringsformer, med
server-generert PDF via headless Chromium (Playwright) -- se ADR-097 for
full detalj (PDF-mekanisme-valget, rutestruktur, datajoin-mønster,
inngangspunkter). `tsc --noEmit` og hele `vitest`-suiten grønn.
**⏳ Ikke bygget/utrullet ennå** -- ny Chromium-avhengighet i
produksjons-API-imaget venter på eksplisitt bekreftelse før
`docker compose build && up -d` kjøres (samme disiplin som alltid for
noe som endrer produksjonscontaineren). Ekte scratch-verifisering (klikk
gjennom alle fire med ekte turneringsdata, bekreft PDF-nedlasting) gjenstår
også -- kun kodenivå-verifisering (typecheck/tester) er gjort så langt.
## Idé, ikke spesifisert: påminnelser + tilbakemeldings-"kartotek" under en runde (2026-08-21) — 📋 fanget, ikke designet
Bruker: "Jeg vil brukeren skal få spørsmål om han vil bli påminnet
nødvendige ting som å huske å drikke (og spise), huske rutinene sine og
andre gode poenger gjennom golfrunden. Kanskje vi også skulle
implementert et kartotek med tilbakemeldinger av ymse slag som popper
opp når spilleren registerer en score på et hull eller etter en
fullført runde eller etter en turnering?" To atskilte idéer, ingen av
dem designet i detalj ennå:
1. **Under-runde-påminnelser** -- spørre brukeren (trolig ved
rundestart, eller i profil-/innstillinger) om de vil ha periodiske
varsler under en pågående runde for ting som lett glemmes: drikke
vann, spise, egne faste rutiner/sjekklister. Uavklart: varslings-
mekanisme (push-varsel? in-app-banner?), frekvens/trigger
(tidsintervall? etter hvert hull?), hvor dette konfigureres, om det
er en global brukerinnstilling eller per-runde-valg.
2. **Tilbakemeldings-"kartotek"** -- en samling ferdigskrevne
tips/tilbakemeldinger som kan poppe opp kontekstuelt: ved
score-registrering på et hull, etter fullført runde, eller etter
fullført turnering. Uavklart: hva slags innhold (motiverende?
teknisk/instruktivt? statistikk-baserte observasjoner om spillerens
eget spill?), triggerlogikk (tilfeldig? regelbasert ut fra
score/statistikk?), om det er redigerbart av organisator/klubb eller
fast levert av TeeCup.
**Neste steg:** begge idéene trenger en egen avklaringsrunde med bruker
FØR design/bygging -- ingen beslutninger tatt ennå, kun fanget opp her
for å ikke gå tapt.

View file

@ -1,567 +0,0 @@
"""
Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- to separate
`app_user`-kontoer eid av samme reelle person slås sammen til én.
Selvbetjent (bruker bekreftet eksplisitt): `keeper_id` (initiativtakeren,
den aktive økten) beholdes, `loser_id` (kontoen bekreftet via en lenke sendt
til DENS e-post, se app/routers/account_merge.py) slås INN i keeper og
slettes til slutt.
RLS-grensen avgjør strukturen (verifisert direkte mot skjemaet, ikke
antatt): `organization_membership`/`app_user`/`friendship`/`notification`/
`round`/`round_participant`/`round_message*`/`handicap_history`/
`push_subscription`/`user_secondary_email` m.fl. har INGEN RLS (håndteres
av app-laget, "auth-laget", se 001_initial_schema.sql linje 364-366) --
disse slås sammen i ÉN global `plain_connection()`-transaksjon.
`player`/`team_roster`/`tournament_registration`/`tournament_participant`/
`order_of_merit_team_member`/`message`/`message_reaction`/`message_comment`/
`tournament_class`/`tournament`/`lineup_lock`/`organization_invitation`/
`tournament_round_participant_flag_plant` ER RLS-beskyttet
(`org_isolation`-policy, se ENABLE ROW LEVEL SECURITY-arrayene i 001/003/
012/013/040/055/059/070) -- disse behandles i en EGEN
`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen
atomisk vei tvers (samme N+1-per-org-mønster som allerede er etablert
praksis i `/auth/me`/"Mine runder", se kommentar i app/routers/auth.py sin
`me()`-funksjon). **Denne grensen ble faktisk feil-klassifisert første
gang** (seks tabeller endte opp i _merge_global sin plain_connection()-
løkke) -- symptomet var IKKE en stille RLS-nullstilling som forventet, men
et forsinket "invalid uuid ''"-krasj en GJENBRUKT tilkobling (Postgres
sin current_setting('app.current_org', true) kan returnere tom streng,
ikke NULL, en tilkobling der GUC-en tidligere har vært satt i samme
fysiske forbindelse -- se ADR-080). Rettet ved faktisk å verifisere HVER
tabell mot ENABLE ROW LEVEL SECURITY-arrayene i stedet for å anta ut fra
tabellnavn/-mønster.
Ingen sesjons-tilbakekallingsmekanisme finnes andre steder i appen --
sletting av `app_user`-raden for taperen er derfor det ENESTE som kan tvinge
ut en gjenlevende øktinformasjonskapsel, og skjer derfor alltid SIST, kun
hvis alt foregående lyktes.
"""
from __future__ import annotations
from pydantic import BaseModel
from .db import org_connection, plain_connection
_ROLE_RANK = {"owner": 3, "admin": 2, "member": 1}
# ---------------------------------------------------------------------------
# Forhåndsvisning (les-only) -- gjenbrukt BÅDE ved rå forespørsel (før
# e-post sendes) og på nytt når bekreftelseslenken åpnes (fersk, i tilfelle
# noe endret seg i mellomtiden). Pydantic (ikke dataclass) siden disse
# returneres direkte som response_model, samme mønster som
# app/hole_history.py sin HoleHistoryOut.
# ---------------------------------------------------------------------------
class OrgMembershipPreview(BaseModel):
organization_id: str
organization_name: str
keeper_role: str | None
loser_role: str | None
resulting_role: str
class OrgPlayerPreview(BaseModel):
organization_id: str
organization_name: str
keeper_has_player: bool
loser_has_player: bool
# Antall team_roster/tournament_registration/tournament_participant/
# order_of_merit_team_member-rader som ville kollidert (begge kontoer
# allerede i samme lag/turnering/OOM-lag) -- kun >0 når BEGGE har en
# player-rad i denne org-en.
colliding_registrations: int
class MergePreview(BaseModel):
keeper_id: str
keeper_display_name: str
keeper_email: str | None
loser_id: str
loser_display_name: str
loser_email: str | None
organization_memberships: list[OrgMembershipPreview] = []
player_links: list[OrgPlayerPreview] = []
friend_count: int = 0
shared_friend_count: int = 0
were_friends_with_each_other: bool = False
round_count: int = 0
secondary_email_count: int = 0
async def _affected_org_ids(keeper_id: str, loser_id: str) -> list[str]:
"""Union av org-er der EN AV kontoene har medlemskap ELLER en
spiller-kobling (en bruker kan være spiller i en org uavhengig av
medlemskap, se ADR-031). `player_organizations_for_user` er en
SECURITY DEFINER-bro (migrasjon 015) -- fungerer korrekt selv uten
app.current_org satt, samme som i /auth/me."""
org_ids: set[str] = set()
async with plain_connection() as conn:
rows = await conn.fetch(
"SELECT organization_id::text AS organization_id FROM organization_membership WHERE user_id = ANY($1::uuid[])",
[keeper_id, loser_id],
)
org_ids.update(r["organization_id"] for r in rows)
for uid in (keeper_id, loser_id):
rows = await conn.fetch(
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)", uid
)
org_ids.update(r["organization_id"] for r in rows)
return sorted(org_ids)
async def compute_merge_preview(keeper_id: str, loser_id: str) -> MergePreview:
async with plain_connection() as conn:
keeper_row = await conn.fetchrow(
"SELECT display_name, email::text AS email FROM app_user WHERE id = $1", keeper_id
)
loser_row = await conn.fetchrow(
"SELECT display_name, email::text AS email FROM app_user WHERE id = $1", loser_id
)
preview = MergePreview(
keeper_id=keeper_id,
keeper_display_name=keeper_row["display_name"],
keeper_email=keeper_row["email"],
loser_id=loser_id,
loser_display_name=loser_row["display_name"],
loser_email=loser_row["email"],
)
membership_rows = await conn.fetch(
"SELECT organization_id::text AS organization_id, user_id::text AS user_id, role "
"FROM organization_membership WHERE user_id = ANY($1::uuid[])",
[keeper_id, loser_id],
)
friend_count = await conn.fetchval(
"SELECT count(*) FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)",
loser_id,
)
preview.friend_count = friend_count
keeper_friend_ids = {
r["other"]
for r in await conn.fetch(
"""
SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other
FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
""",
keeper_id,
)
}
loser_friend_ids = {
r["other"]
for r in await conn.fetch(
"""
SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other
FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
""",
loser_id,
)
}
preview.shared_friend_count = len(keeper_friend_ids & loser_friend_ids)
preview.were_friends_with_each_other = loser_id in keeper_friend_ids
preview.round_count = await conn.fetchval(
"SELECT count(*) FROM round_participant WHERE user_id = $1", loser_id
)
preview.secondary_email_count = await conn.fetchval(
"SELECT count(*) FROM user_secondary_email WHERE user_id = $1", loser_id
)
membership_by_org: dict[str, dict[str, str]] = {}
for row in membership_rows:
membership_by_org.setdefault(row["organization_id"], {})[row["user_id"]] = row["role"]
org_ids = await _affected_org_ids(keeper_id, loser_id)
for org_id in org_ids:
async with org_connection(org_id) as conn:
org_name = await conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)
roles = membership_by_org.get(org_id, {})
keeper_role = roles.get(keeper_id)
loser_role = roles.get(loser_id)
if keeper_role or loser_role:
resulting = keeper_role or loser_role
if keeper_role and loser_role:
resulting = keeper_role if _ROLE_RANK[keeper_role] >= _ROLE_RANK[loser_role] else loser_role
preview.organization_memberships.append(
OrgMembershipPreview(
organization_id=org_id,
organization_name=org_name,
keeper_role=keeper_role,
loser_role=loser_role,
resulting_role=resulting,
)
)
keeper_player_id = await conn.fetchval(
"SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id
)
loser_player_id = await conn.fetchval(
"SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id
)
if keeper_player_id or loser_player_id:
colliding = 0
if keeper_player_id and loser_player_id:
colliding += await conn.fetchval(
"""
SELECT count(*) FROM team_roster a
WHERE a.player_id = $1 AND EXISTS (
SELECT 1 FROM team_roster b WHERE b.team_id = a.team_id AND b.player_id = $2
)
""",
loser_player_id,
keeper_player_id,
)
colliding += await conn.fetchval(
"""
SELECT count(*) FROM tournament_registration a
WHERE a.player_id = $1 AND EXISTS (
SELECT 1 FROM tournament_registration b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2
)
""",
loser_player_id,
keeper_player_id,
)
colliding += await conn.fetchval(
"""
SELECT count(*) FROM tournament_participant a
WHERE a.player_id = $1 AND EXISTS (
SELECT 1 FROM tournament_participant b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2
)
""",
loser_player_id,
keeper_player_id,
)
colliding += await conn.fetchval(
"""
SELECT count(*) FROM order_of_merit_team_member a
WHERE a.player_id = $1 AND EXISTS (
SELECT 1 FROM order_of_merit_team_member b
WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2
)
""",
loser_player_id,
keeper_player_id,
)
preview.player_links.append(
OrgPlayerPreview(
organization_id=org_id,
organization_name=org_name,
keeper_has_player=keeper_player_id is not None,
loser_has_player=loser_player_id is not None,
colliding_registrations=colliding,
)
)
return preview
# ---------------------------------------------------------------------------
# Utførelse -- se modul-docstring for transaksjons-strukturen.
# ---------------------------------------------------------------------------
class MergeResult(BaseModel):
keeper_id: str
merged_organization_count: int
merged_player_count: int
async def _merge_org_scoped(conn, org_id: str, keeper_id: str, loser_id: str) -> None:
"""Kjøres inni ÉN org_connection(org_id)-transaksjon -- player-
sammenslåing + dedup av de fire tabellene som har egne UNIQUE(x,
player_id)-constraints. Idempotent: sjekker at taperens player-rad
faktisk fortsatt finnes før den gjør noe."""
keeper_player_id = await conn.fetchval(
"SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id
)
loser_player_id = await conn.fetchval(
"SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id
)
if loser_player_id is None:
return
if keeper_player_id is None:
# Ingen kollisjon mulig -- enkel re-peking.
await conn.execute("UPDATE player SET user_id = $1 WHERE id = $2", keeper_id, loser_player_id)
return
# Begge har en player-rad i denne org-en: re-pek taperens avhengigheter
# til keeperens player-rad, men KUN der det ikke ville brutt en
# UNIQUE(x, player_id) -- i så fall er taperens rad en ren duplikat og
# slettes i stedet (personen var allerede med via keeperens rad).
for table, group_col in (
("team_roster", "team_id"),
("tournament_registration", "tournament_id"),
("tournament_participant", "tournament_id"),
):
await conn.execute(
f"DELETE FROM {table} a WHERE a.player_id = $1 AND EXISTS "
f"(SELECT 1 FROM {table} b WHERE b.{group_col} = a.{group_col} AND b.player_id = $2)",
loser_player_id,
keeper_player_id,
)
await conn.execute(f"UPDATE {table} SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id)
await conn.execute(
"DELETE FROM order_of_merit_team_member a WHERE a.player_id = $1 AND EXISTS "
"(SELECT 1 FROM order_of_merit_team_member b WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2)",
loser_player_id,
keeper_player_id,
)
await conn.execute(
"UPDATE order_of_merit_team_member SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id
)
# Taperens nå-tomme player-rad slettes til slutt (ingenting refererer
# den lenger).
await conn.execute("DELETE FROM player WHERE id = $1", loser_player_id)
async def _repoint_rls_scoped_safe_tables(conn, keeper_id: str, loser_id: str) -> None:
"""Kjøres inni SAMME org_connection(org_id)-transaksjon som
_merge_org_scoped, for den ene org-en av gangen. Disse henger av
app_user DIREKTE (ikke via player), men ER likevel RLS-beskyttet
(org_isolation, se 001/003/012/013-migrasjonenes ENABLE ROW LEVEL
SECURITY-arrayer) -- derfor IKKE stå i _merge_global sin
plain_connection()-løkke (en tidligere feil gjorde nettopp det, se
ADR-080). RLS filtrerer automatisk til akkurat DENNE org-en, ingen
egen WHERE organization_id-betingelse trengs her."""
for table, column in (
("tournament", "created_by"),
("lineup_lock", "locked_by"),
("organization_invitation", "invited_by"),
("message", "author_user_id"),
("message_comment", "author_user_id"),
("tournament_round_participant_flag_plant", "planted_by_user_id"),
):
await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id)
# message_reaction: egen UNIQUE(message_id, user_id) -- samme
# behold-keeperens-ellers-overfør-taperens-regel som round_message_
# reaction (som IKKE er RLS-beskyttet og derfor håndteres i _merge_global).
await conn.execute(
"DELETE FROM message_reaction a WHERE a.user_id = $1 AND EXISTS "
"(SELECT 1 FROM message_reaction b WHERE b.message_id = a.message_id AND b.user_id = $2)",
loser_id,
keeper_id,
)
await conn.execute("UPDATE message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)
async def _merge_global(conn, keeper_id: str, loser_id: str) -> None:
"""Kjøres inni ÉN plain_connection()-transaksjon -- alt som IKKE er
RLS-beskyttet. Idempotent: sjekker taperens app_user-rad fortsatt
finnes før den gjør noe (i praksis alltid sann her, ekstra forsvar mot
dobbel-kjøring)."""
still_exists = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id)
if not still_exists:
return
# organization_membership: behold høyest rolle, aldri nedgrader keeper.
loser_memberships = await conn.fetch(
"SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
loser_id,
)
for row in loser_memberships:
keeper_role = await conn.fetchval(
"SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
row["organization_id"],
keeper_id,
)
if keeper_role is None:
await conn.execute(
"UPDATE organization_membership SET user_id = $1 WHERE organization_id = $2 AND user_id = $3",
keeper_id,
row["organization_id"],
loser_id,
)
elif _ROLE_RANK[row["role"]] > _ROLE_RANK[keeper_role]:
await conn.execute(
"UPDATE organization_membership SET role = $1 WHERE organization_id = $2 AND user_id = $3",
row["role"],
row["organization_id"],
keeper_id,
)
await conn.execute(
"DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
row["organization_id"],
loser_id,
)
else:
await conn.execute(
"DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
row["organization_id"],
loser_id,
)
# friendship: selv-lenke (var venner med hverandre) slettes -- ville
# brutt CHECK(requester <> addressee) hvis re-pekt. Delt tredjepart:
# behold 'accepted' fremfor 'pending', dropp taperens duplikat.
await conn.execute(
"DELETE FROM friendship WHERE (requester_user_id = $1 AND addressee_user_id = $2) "
"OR (requester_user_id = $2 AND addressee_user_id = $1)",
keeper_id,
loser_id,
)
loser_friendships = await conn.fetch(
"SELECT id::text AS id, requester_user_id::text AS requester_user_id, "
"addressee_user_id::text AS addressee_user_id, status "
"FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1",
loser_id,
)
for row in loser_friendships:
other_id = row["addressee_user_id"] if row["requester_user_id"] == loser_id else row["requester_user_id"]
existing = await conn.fetchrow(
"SELECT id::text AS id, status FROM friendship WHERE "
"(requester_user_id = $1 AND addressee_user_id = $2) OR (requester_user_id = $2 AND addressee_user_id = $1)",
keeper_id,
other_id,
)
if existing is None:
new_requester = keeper_id if row["requester_user_id"] == loser_id else other_id
new_addressee = other_id if row["requester_user_id"] == loser_id else keeper_id
await conn.execute(
"UPDATE friendship SET requester_user_id = $1, addressee_user_id = $2 WHERE id = $3",
new_requester,
new_addressee,
row["id"],
)
elif existing["status"] != "accepted" and row["status"] == "accepted":
await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", existing["id"])
await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"])
else:
await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"])
# friend_categorization: samme selvlenke-fjerning, deretter slå sammen
# kategori-sett for delte venner (union, ikke overskriving).
await conn.execute(
"DELETE FROM friend_categorization WHERE (owner_user_id = $1 AND friend_user_id = $2) "
"OR (owner_user_id = $2 AND friend_user_id = $1)",
keeper_id,
loser_id,
)
await conn.execute(
"""
INSERT INTO friend_categorization (owner_user_id, friend_user_id, category)
SELECT $1, friend_user_id, category FROM friend_categorization WHERE owner_user_id = $2
ON CONFLICT DO NOTHING
""",
keeper_id,
loser_id,
)
await conn.execute("DELETE FROM friend_categorization WHERE owner_user_id = $1", loser_id)
await conn.execute(
"""
UPDATE friend_categorization SET friend_user_id = $1
WHERE friend_user_id = $2 AND NOT EXISTS (
SELECT 1 FROM friend_categorization x
WHERE x.owner_user_id = friend_categorization.owner_user_id AND x.friend_user_id = $1 AND x.category = friend_categorization.category
)
""",
keeper_id,
loser_id,
)
await conn.execute("DELETE FROM friend_categorization WHERE friend_user_id = $1", loser_id)
# user_notification_email_pref: behold keeperens, dropp taperens duplikat.
await conn.execute(
"""
INSERT INTO user_notification_email_pref (user_id, type)
SELECT $1, type FROM user_notification_email_pref WHERE user_id = $2
ON CONFLICT DO NOTHING
""",
keeper_id,
loser_id,
)
# round_message_reaction: behold keeperens reaksjon om den finnes,
# ellers overfør taperens. (message_reaction, org-lagchat-varianten, ER
# RLS-beskyttet -- håndteres i _repoint_rls_scoped_safe_tables i stedet.)
await conn.execute(
"DELETE FROM round_message_reaction a WHERE a.user_id = $1 AND EXISTS "
"(SELECT 1 FROM round_message_reaction b WHERE b.round_message_id = a.round_message_id AND b.user_id = $2)",
loser_id,
keeper_id,
)
await conn.execute("UPDATE round_message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)
# user_secondary_email: taperens PRIMÆRE e-post blir en ny sekundær-
# e-post på keeper (global unikhet garanterer ingen kollisjon mulig --
# den kan uansett bare tilhøre nøyaktig én av de to kontoene fra før).
loser_email = await conn.fetchval("SELECT email::text AS email FROM app_user WHERE id = $1", loser_id)
if loser_email is not None:
await conn.execute(
"INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING",
keeper_id,
loser_email,
)
await conn.execute("UPDATE user_secondary_email SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)
# Alt annet med ingen konkurrerende unikhet -- trygt å re-peke direkte.
# KUN tabeller UTEN RLS her -- se _RLS_SCOPED_SAFE_TABLES for de som ser
# trygge ut på samme vis men faktisk ER org_isolation-beskyttet (player/
# course/tournament/message m.fl., se 001/003/012/013-arrayene) og derfor
# MÅ behandles i _merge_org_scoped (org_connection per org) i stedet --
# en tidligere feil her (alle seks re-pekt via plain_connection) ga et
# snodig, forsinket symptom (tom streng vs. NULL på en gjenbrukt
# tilkoblings app.current_org etter en avsluttet org_connection()-
# transaksjon) i stedet for en ren RLS-stillhet, se ADR-080 for
# detaljene -- IKKE anta at en tabell er RLS-fri uten å sjekke arrayene.
for table, column in (
("handicap_history", "user_id"),
("notification", "user_id"),
("push_subscription", "user_id"),
("personal_course", "created_by_user_id"),
("round", "owner_user_id"),
("round_participant", "user_id"),
("round_message", "author_user_id"),
("round_message_comment", "author_user_id"),
("round_shot", "recorded_by_user_id"),
("round_message_tag", "tagged_user_id"),
("round_message_comment_tag", "tagged_user_id"),
("round_participant_flag_plant", "planted_by_user_id"),
):
await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id)
# Ephemere/tokens knyttet til taperen -- ingen verdi å overføre,
# forsvinner uansett via CASCADE ved sletting under, men ryddes
# eksplisitt her for et rent svar fra denne funksjonen alene.
await conn.execute("DELETE FROM two_factor_code WHERE user_id = $1", loser_id)
await conn.execute("DELETE FROM email_change_token WHERE user_id = $1", loser_id)
await conn.execute("DELETE FROM secondary_email_token WHERE user_id = $1", loser_id)
async def execute_merge(keeper_id: str, loser_id: str) -> MergeResult:
org_ids = await _affected_org_ids(keeper_id, loser_id)
async with plain_connection() as conn:
async with conn.transaction():
await _merge_global(conn, keeper_id, loser_id)
merged_player_count = 0
for org_id in org_ids:
async with org_connection(org_id) as conn:
had_loser_player = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM player WHERE organization_id = $1 AND user_id = $2)", org_id, loser_id
)
await _merge_org_scoped(conn, org_id, keeper_id, loser_id)
await _repoint_rls_scoped_safe_tables(conn, keeper_id, loser_id)
if had_loser_player:
merged_player_count += 1
# Siste, irreversible steg -- kun etter at alt over har lyktes.
# CASCADE dekker det gjenværende (account_merge_token selv, samt
# ethvert FK utenfor listen over som ikke er berørt her).
async with plain_connection() as conn:
await conn.execute("DELETE FROM app_user WHERE id = $1", loser_id)
return MergeResult(
keeper_id=keeper_id,
merged_organization_count=len(org_ids),
merged_player_count=merged_player_count,
)

View file

@ -229,23 +229,6 @@ async def user_requires_2fa_enrollment(conn, user_id: str) -> bool:
)
async def resolve_user_id_by_email(conn, email: str) -> str | None:
"""Løser en e-postadresse til eierens app_user.id -- sjekker PRIMÆR
adresse (app_user.email) først, deretter SEKUNDÆR (user_secondary_
email, FEATURE_BACKLOG.md "flere e-postadresser"/ADR-032). Returnerer
None hvis ingen konto eier adressen i det hele tatt -- kalleren
avgjør selv hva det betyr (opprett ny konto ved innlogging, avvis
passord-innlogging, eller "finnes ikke, kan ikke slås sammen med" ved
kontosammenslåing). Faktorert ut 2026-08-16 (kontosammenslåing) fra to
tidligere identiske kopier i verify_magic_link/login_with_password."""
user_id = await conn.fetchval("SELECT id::text AS id FROM app_user WHERE email = $1", email)
if user_id is not None:
return user_id
return await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
)
async def get_superadmin_user(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""ADR-022 Beslutning D: is_super_admin er KUN manuelt DB-tildelt, aldri
settbart via noe API-endepunkt. Denne avhengigheten sjekker bare

View file

@ -114,12 +114,5 @@ class Settings:
# bilde faller tilbake til ren tekst hvis ikke satt.
MAPBOX_SECRET_TOKEN: str | None = os.environ.get("TEECUP_MAPBOX_SECRET_TOKEN", "").strip() or None
# GolfAPI.io (ADR-064) -- tredje banekilde for baner utenfor TeeOffs
# dekning (f.eks. Tjøme Golfklubb). ULIKT teeoff: koster ekte, begrenset
# budsjett per kall -- se golfapi_client.py sitt cache-lag. Valgfritt,
# samme grasiøs-degraderings-mønster: import-/søkeendepunktene svarer
# tydelig "ikke konfigurert" uten å krasje resten av appen.
GOLFAPI_TOKEN: str | None = os.environ.get("TEECUP_GOLFAPI_TOKEN", "").strip() or None
settings = Settings()

View file

@ -14,73 +14,12 @@ vanlig tilkobling (typisk port 587/25).
import smtplib
from asyncio import to_thread
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage
from html import escape as _esc
from zoneinfo import ZoneInfo
from .config import settings
_OSLO = ZoneInfo("Europe/Oslo")
_NORWEGIAN_MONTHS = [
"januar", "februar", "mars", "april", "mai", "juni",
"juli", "august", "september", "oktober", "november", "desember",
]
# Glyffer for de to 5-veis retningsfeltene (2026-08-13, ADR-065 "fullt
# scorekort"-utvidelsen -- bruker ba eksplisitt om at e-postens tabell skal
# se ut som selve Scorekort-siden, ikke bare inneholde samme tall). Samme
# betydning som FairwayIcon/ApproachIcon i frontend/components/round-
# scorecard.tsx, portert til tekst-glyffer + inline farge siden e-post-
# klienter ikke kan antas å vise SVG-er pent. (glyff, farge)-par.
_GREEN = "#1f6b08"
_ORANGE = "#ff5722"
_MUTED = "#9aa39a"
_FAIRWAY_GLYPHS = {"fairway": ("", _GREEN), "left": ("", _ORANGE), "right": ("", _ORANGE)}
_APPROACH_GLYPHS = {
"hit": ("", _GREEN), "long": ("", _ORANGE), "short": ("", _ORANGE),
"left": ("", _ORANGE), "right": ("", _ORANGE),
}
def _signed(value: int) -> str:
if value == 0:
return "E"
return f"+{value}" if value > 0 else str(value)
def _classify(diff: int) -> str:
if diff <= -2:
return "eagle"
if diff == -1:
return "birdie"
if diff == 0:
return "par"
if diff == 1:
return "bogey"
return "double"
def _format_round_datetime(played_at: date, started_at: datetime | None) -> str:
"""`round.played_at` er kun en DATO i skjemaet (ingen klokkeslett lagret
-- "Utslagstid" i ny-runde-veiviseren fanges opp av wizard-state, men
sendes reelt aldri til backend, oppdaget 2026-08-13 under denne
utvidelsen -- egen, separat feil, ikke rettet her). `started_at`
(tidspunktet runden faktisk ble startet, timestamptz) er derfor eneste
ekte klokkeslett å vise -- utelates helt når det mangler (f.eks. en
runde som aldri ble startet ennå) i stedet for å dikte opp et."""
today = datetime.now(_OSLO).date()
date_part = "i dag" if played_at == today else f"{played_at.day}. {_NORWEGIAN_MONTHS[played_at.month - 1]} {played_at.year}"
if started_at is None:
return date_part
return f"{date_part} klokken {started_at.astimezone(_OSLO).strftime('%H:%M')}"
def _format_duration(total_minutes: int) -> str:
hours, minutes = divmod(total_minutes, 60)
return f"{minutes} min" if hours == 0 else f"{hours}t {minutes}min"
def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
msg = EmailMessage()
@ -294,64 +233,6 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local
await to_thread(_send_sync, to_email, template["subject"], body)
_ACCOUNT_MERGE_TEMPLATES = {
"nb": {
"subject": "Bekreft kontosammenslåing på TeeCup",
"body": (
"Hei,\n\n"
"{initiator_name} ({initiator_email}) har bedt om å slå SIN "
"TeeCup-konto sammen med DENNE kontoen. Alt fra denne kontoen "
"(organisasjoner, spiller-koblinger, runder, venner) flyttes "
"over til den andre kontoen, og denne kontoen slettes deretter "
"-- dette kan IKKE angres.\n\n"
"Åpne lenken under innen {minutes} minutter for å se nøyaktig "
"hva som slås sammen og bekrefte:\n\n"
"{link}\n\n"
"Ba du (eller noen andre) ikke om dette selv, kan du se bort "
"fra e-posten -- ingenting slås sammen uten at lenken åpnes OG "
"bekreftes eksplisitt.\n"
),
},
"en": {
"subject": "Confirm account merge on TeeCup",
"body": (
"Hi,\n\n"
"{initiator_name} ({initiator_email}) has requested to merge "
"THEIR TeeCup account with THIS account. Everything from this "
"account (organizations, player links, rounds, friends) will "
"move to the other account, and this account will then be "
"deleted -- this CANNOT be undone.\n\n"
"Open the link below within {minutes} minutes to see exactly "
"what will be merged and confirm:\n\n"
"{link}\n\n"
"If you (or someone else) didn't request this yourself, you "
"can ignore this email -- nothing is merged unless the link is "
"opened AND explicitly confirmed.\n"
),
},
}
async def send_account_merge_confirmation(
to_email: str, raw_token: str, initiator_name: str, initiator_email: str, locale: str = "nb"
) -> None:
"""Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) --
sendes til MÅL-kontoens (taperens) e-post, beviser eierskap av DEN
kontoen (ikke bare at initiativtakeren fortsatt er innlogget), samme
prinsipp som send_email_change_confirmation. Selve utførelsen skjer
IKKE ved lenke-åpning alene -- kun en forhåndsvisning vises, en
eksplisitt bekreftelse kreves i tillegg (se app/account_merge.py)."""
template = _ACCOUNT_MERGE_TEMPLATES.get(locale, _ACCOUNT_MERGE_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/kontosammenslaing/{raw_token}"
body = template["body"].format(
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
link=link,
initiator_name=initiator_name,
initiator_email=initiator_email,
)
await to_thread(_send_sync, to_email, template["subject"], body)
_NOTIFICATION_TEMPLATES = {
"nb": {
"subject": "Nytt varsel fra TeeCup",
@ -470,61 +351,22 @@ async def send_new_account_alert_email(to_email: str, new_user_email: str, displ
class RoundSummaryHole:
hole_number: int
par: int
stroke_index: int
score: int | None
picked_up: bool
net: int | None
# Stableford (2 poeng for netto par, +/-1 per slag, gulvet på 0) --
# samme formel som frontend/components/round-scorecard.tsx sin
# stablefordPoints(), portert til Python siden e-posten bygges
# server-side. None når netto ikke er kjent (samme betingelse som net).
points: int | None
# Feltene under (2026-08-13, ADR-065 "fullt scorekort"-utvidelsen) er
# ALLE valgfrie og graderer seg naturlig -- None for delt-ball-hull
# (ADR-039 Beslutning C, ingen individuell detalj der) og for felt
# spilleren ikke sporet (stat_level). send_round_summary_email viser
# kun en kolonne når MINST ett hull faktisk har data for den, akkurat
# som round-scorecard.tsx sine show*-flagg.
putts: int | None
tee_shot_result: str | None
approach_result: str | None
chip_count: int | None
bunker_shot_count: int | None
penalty_strokes: int | None
anyway_strokes: int | None
async def send_round_summary_email(
to_email: str,
guest_first_name: str,
round_label: str,
played_at: date,
started_at: datetime | None,
duration_minutes: int | None,
other_players_count: int,
start_hole: int,
holes: list[RoundSummaryHole],
stat_lines: list[tuple[str, str]],
raw_token: str | None,
is_linked_account: bool = False,
round_id: str | None = None,
raw_token: str,
) -> None:
"""Rundeoppsummering til en spiller med registrert e-post, sendt
UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin
update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele
rundens fullføring, complete_round, 2026-08-03). FØRSTE HTML-e-post i
appen -- multipart/alternative via _send_sync sin nye html_body-
parameter, med en ekte tekst-fallback.
To mottakertyper (migrasjon 066 utvidet fra kun gjester):
- Gjest (`is_linked_account=False`): `raw_token` er et ekte magic-link-
innloggingstoken (samme mønster som tournaments.py sin
send_scorecard_invitations) -- ingen konto finnes ennå, e-posten
pitcher én.
- Lenket bruker (`is_linked_account=True`): `raw_token` er None,
`round_id` være satt -- lenken peker rett rundesiden (vanlig
innlogging gjelder), ingen konto-pitch (feil for noen som allerede
har en).
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert
e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round,
2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via
_send_sync sin nye html_body-parameter, med en ekte tekst-fallback.
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
@ -534,273 +376,51 @@ async def send_round_summary_email(
send_scorecard_invitations/send_session_result_email) -- graderer seg
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
finnes data for. `holes` graderer seg samme vis kolonne for kolonne
(2026-08-13, ADR-065 "fullt scorekort"-utvidelsen, se RoundSummaryHole).
finnes data for.
`played_at` (dato) + `started_at` (klokkeslett, valgfritt) brukes til
hilsen-linjens dato/klokkeslett, formatert til Europe/Oslo -- appen er
norsk-only, ingen lagret tidssone-preferanse å slå opp her. Se
_format_round_datetime for hvorfor disse er to separate felt.
`duration_minutes` er None når verken denne deltakeren eller runden har
en kjent sluttid ennå (kalleren avgjør -- se
_send_participant_round_summary i rounds.py).
`other_players_count` er resten av flighten, brukt i "sammen med N
andre"-leddet (utelatt helt hvis 0). `start_hole` avgjør Ut/Inn-
rekkefølgen i HTML-tabellen (spillerekkefølge, ikke hole_number --
matcher round-scorecard.tsx sin holeOrder-logikk).
Alltid norsk, samme presedens som send_scorecard_invitations (verken en
gjest eller en spiller midt i en runde har nødvendigvis en lagret
språkpreferanse tilgjengelig her)."""
if is_linked_account:
link = f"{settings.PUBLIC_BASE_URL}/my-rounds/{round_id}"
else:
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten
har ingen lagret språkpreferanse -- ingen konto ennå)."""
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
subject = f"Scorekortet ditt fra {round_label}"
holes_played = sum(1 for h in holes if h.score is not None)
detail_bits = [f"du spilte {holes_played} hull"]
if duration_minutes is not None:
detail_bits[0] += f"{_format_duration(duration_minutes)}"
if other_players_count > 0:
detail_bits.append(f"sammen med {other_players_count} andre")
datetime_phrase = _format_round_datetime(played_at, started_at)
detail_txt = ", ".join(detail_bits)
greeting_line = f"Her er scorekortet ditt fra {round_label}, {datetime_phrase} ({detail_txt}):"
greeting_html = (
f"Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>, {_esc(datetime_phrase)} "
f"({_esc(detail_txt)}):"
)
# Kolonnesettet graderer seg -- en kolonne tas kun med når MINST ett
# hull faktisk har data for den, akkurat som show*-flaggene i
# frontend/components/round-scorecard.tsx. Samme spesifikasjon brukes
# til BÅDE tekst- og HTML-varianten under, så de aldri kan divergere.
def _gir(h: RoundSummaryHole) -> bool:
return h.score is not None and h.putts is not None and h.score - h.putts <= h.par - 2
columns: list[tuple[str, Callable[[RoundSummaryHole], str]]] = [
("Hcp", lambda h: str(h.stroke_index)),
("Par", lambda h: str(h.par)),
("Slag", lambda h: "PU" if h.picked_up else (str(h.score) if h.score is not None else "")),
]
if any(h.net is not None for h in holes):
columns.append(("Netto", lambda h: str(h.net) if h.net is not None else ""))
if any(h.points is not None for h in holes):
columns.append(("Poeng", lambda h: str(h.points) if h.points is not None else ""))
if any(h.tee_shot_result is not None for h in holes):
columns.append(("Fw", lambda h: _FAIRWAY_GLYPHS[h.tee_shot_result][0] if h.par != 3 and h.tee_shot_result is not None else ""))
if any(h.putts is not None for h in holes):
columns.append(("Putt", lambda h: str(h.putts) if h.putts is not None else ""))
if any(h.score is not None and h.putts is not None for h in holes):
columns.append(("GIR", lambda h: "Ja" if _gir(h) else ""))
if any(h.approach_result is not None for h in holes):
columns.append(("Innspill", lambda h: _APPROACH_GLYPHS[h.approach_result][0] if h.approach_result is not None else ""))
if any(h.chip_count is not None for h in holes):
columns.append(("Chip", lambda h: str(h.chip_count) if h.chip_count is not None else ""))
if any(h.bunker_shot_count is not None for h in holes):
columns.append(("Bunker", lambda h: str(h.bunker_shot_count) if h.bunker_shot_count is not None else ""))
if any(h.penalty_strokes is not None for h in holes):
columns.append(("Straff", lambda h: str(h.penalty_strokes) if h.penalty_strokes is not None else ""))
if any(h.anyway_strokes is not None for h in holes):
columns.append(("Anyway", lambda h: str(h.anyway_strokes) if h.anyway_strokes is not None else ""))
text_lines = [
f"Hei {guest_first_name},",
"",
greeting_line,
f"Her er scorekortet ditt fra {round_label}:",
"",
]
for h in holes:
cell_txt = ", ".join(f"{label} {fn(h)}" for label, fn in columns)
text_lines.append(f"Hull {h.hole_number}: {cell_txt}")
score_txt = str(h.score) if h.score is not None else ""
net_txt = str(h.net) if h.net is not None else ""
text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})")
text_lines.append("")
for label, value in stat_lines:
text_lines.append(f"{label}: {value}")
if is_linked_account:
text_lines += [
"",
"Se hele runden:",
link,
]
else:
text_lines += [
"",
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
"du trenger ingen konto for å se dette scorekortet, men med én kan du "
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
"",
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
link,
"",
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
]
text_lines += [
"",
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
"du trenger ingen konto for å se dette scorekortet, men med én kan du "
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
"",
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
link,
"",
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
]
body = "\n".join(text_lines)
# HTML-tabellen (2026-08-13, samme dag som ovenstående -- bruker sendte
# et skjermbilde av selve Scorekort-siden i appen og ba om at e-posten
# skal SE UT SOM DEN, ikke bare inneholde de samme tallene): hull som
# KOLONNER (ikke rader, ulikt tekst-fallbacken over) i to 9-hulls-
# blokker (Ut/Inn) for en 18-hullsrunde, matcher `ScoreBlock` i
# frontend/components/round-scorecard.tsx radrekkefølge/-utvalg
# nøyaktig -- samme farger/glyffer/UT-INN-oppsummeringskolonne.
show_net = any(h.net is not None for h in holes)
show_points = any(h.points is not None for h in holes)
show_fairway = any(h.tee_shot_result is not None for h in holes)
show_putts = any(h.putts is not None for h in holes)
show_gir = any(h.score is not None and h.putts is not None for h in holes)
show_approach = any(h.approach_result is not None for h in holes)
show_chip = any(h.chip_count is not None for h in holes)
show_bunker = any(h.bunker_shot_count is not None for h in holes)
show_penalty = any(h.penalty_strokes is not None for h in holes)
show_anyway = any(h.anyway_strokes is not None for h in holes)
_label_style = (
"padding:5px 6px;border:1px solid #e4ebe3;text-align:left;font-size:10px;"
"font-weight:800;color:#5b665b;white-space:nowrap;background-color:#f6f8f5;"
hole_rows_html = "\n".join(
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else ""}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else ""}</td></tr>'
for h in holes
)
_cell_style = "padding:5px 3px;border:1px solid #e4ebe3;text-align:center;font-size:11px;"
_sum_style = _cell_style + "font-weight:800;background-color:#eef4ec;"
def _row_html(label: str, cell_htmls: list[str], summary_html: str) -> str:
cells = "".join(f'<td style="{_cell_style}">{c}</td>' for c in cell_htmls)
return f'<tr><th style="{_label_style}">{_esc(label)}</th>{cells}<td style="{_sum_style}">{summary_html}</td></tr>'
def _score_mark_html(h: RoundSummaryHole) -> str:
if h.picked_up:
return f'<span style="display:inline-block;min-width:20px;padding:1px 3px;border:2px solid {_ORANGE};border-radius:4px;color:{_ORANGE};font-size:9px;font-weight:800;">PU</span>'
if h.score is None:
return f'<span style="color:{_MUTED};"></span>'
kind = _classify(h.score - h.par)
base = "display:inline-block;min-width:20px;padding:1px 3px;font-weight:800;font-size:12px;"
style = {
"eagle": base + f"border-radius:999px;background-color:{_GREEN};color:#ffffff;",
"birdie": base + f"border-radius:999px;border:2px solid {_GREEN};color:{_GREEN};",
"bogey": base + f"border-radius:4px;border:2px solid {_ORANGE};color:{_ORANGE};",
"double": base + f"border-radius:4px;background-color:{_ORANGE};color:#ffffff;",
}.get(kind, base + "color:#012c11;")
return f'<span style="{style}">{h.score}</span>'
def _fw_html(h: RoundSummaryHole) -> str:
if h.par == 3 or h.tee_shot_result is None:
return f'<span style="color:{_MUTED};"></span>'
glyph, color = _FAIRWAY_GLYPHS[h.tee_shot_result]
return f'<span style="color:{color};font-weight:800;">{glyph}</span>'
def _gir_html(h: RoundSummaryHole) -> str:
if h.score is None or h.putts is None:
return f'<span style="color:{_MUTED};"></span>'
return f'<span style="color:{_GREEN};font-weight:800;">✓</span>' if _gir(h) else f'<span style="color:{_MUTED};"></span>'
def _approach_html(h: RoundSummaryHole) -> str:
if h.approach_result is None:
return f'<span style="color:{_MUTED};"></span>'
glyph, color = _APPROACH_GLYPHS[h.approach_result]
return f'<span style="color:{color};font-weight:800;">{glyph}</span>'
def _count_or_dash(values: list[int]) -> str:
return str(sum(values)) if values else ""
def _build_block_html(block_holes: list[RoundSummaryHole], summary_label: str) -> str:
header_cells = "".join(
f'<td style="padding:5px 3px;border:1px solid #10331a;text-align:center;font-size:11px;font-weight:800;color:#ffffff;background-color:#1a4325;">{h.hole_number}</td>'
for h in block_holes
)
header = (
f'<tr><th style="padding:5px 6px;border:1px solid #10331a;text-align:left;font-size:10px;font-weight:800;color:#ffffff;background-color:#1a4325;">Hull</th>'
f"{header_cells}"
f'<td style="padding:5px 3px;border:1px solid #10331a;text-align:center;font-size:9px;font-weight:800;color:#ffffff;background-color:{_GREEN};text-transform:uppercase;">{_esc(summary_label)}</td></tr>'
)
rows = [header]
rows.append(_row_html("Hcp", [str(h.stroke_index) for h in block_holes], ""))
rows.append(_row_html("Par", [str(h.par) for h in block_holes], str(sum(h.par for h in block_holes))))
played = [h.score for h in block_holes if h.score is not None]
rows.append(_row_html("Scr", [_score_mark_html(h) for h in block_holes], _count_or_dash(played)))
if show_net:
nets = [h.net for h in block_holes if h.net is not None]
rows.append(_row_html("Net", [str(h.net) if h.net is not None else "" for h in block_holes], _count_or_dash(nets)))
if show_points:
points = [h.points for h in block_holes if h.points is not None]
rows.append(_row_html("Pnt", [str(h.points) if h.points is not None else "" for h in block_holes], _count_or_dash(points)))
if show_fairway:
eligible = [h for h in block_holes if h.par != 3 and h.tee_shot_result is not None]
hits = [1 for h in eligible if h.tee_shot_result == "fairway"]
rows.append(_row_html("Fw", [_fw_html(h) for h in block_holes], _count_or_dash(hits) if eligible else ""))
if show_putts:
putts = [h.putts for h in block_holes if h.putts is not None]
rows.append(_row_html("Putt", [str(h.putts) if h.putts is not None else "" for h in block_holes], _count_or_dash(putts)))
if show_gir:
eligible = [h for h in block_holes if h.score is not None and h.putts is not None]
hits = [1 for h in eligible if _gir(h)]
rows.append(_row_html("GIR", [_gir_html(h) for h in block_holes], _count_or_dash(hits) if eligible else ""))
if show_approach:
rows.append(_row_html("Appr.", [_approach_html(h) for h in block_holes], ""))
if show_chip:
vals = [h.chip_count for h in block_holes if h.chip_count is not None]
rows.append(_row_html("Chip", [str(h.chip_count) if h.chip_count is not None else "" for h in block_holes], _count_or_dash(vals)))
if show_bunker:
vals = [h.bunker_shot_count for h in block_holes if h.bunker_shot_count is not None]
rows.append(_row_html("Bnk", [str(h.bunker_shot_count) if h.bunker_shot_count is not None else "" for h in block_holes], _count_or_dash(vals)))
if show_penalty:
vals = [h.penalty_strokes for h in block_holes if h.penalty_strokes is not None]
rows.append(_row_html("Str", [str(h.penalty_strokes) if h.penalty_strokes is not None else "" for h in block_holes], _count_or_dash(vals)))
if show_anyway:
vals = [h.anyway_strokes for h in block_holes if h.anyway_strokes is not None]
rows.append(_row_html("Any", [str(h.anyway_strokes) if h.anyway_strokes is not None else "" for h in block_holes], _count_or_dash(vals)))
return f'<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;width:100%;table-layout:fixed;">{"".join(rows)}</table>'
# Ut/Inn-rekkefølgen følger SPILLEREKKEFØLGEN (start_hole-relativ, samme
# sirkulære logikk som round-scorecard.tsx sin holeOrder), ikke rå
# hole_number -- kun forskjell fra fysisk 1-9/10-18 for en runde som
# ikke starter på hull 1 (uvanlig, men strukturelt mulig).
holes_by_number = {h.hole_number: h for h in holes}
hole_order = [((start_hole - 1 + i) % 18) + 1 for i in range(len(holes))]
ordered_holes = [holes_by_number[n] for n in hole_order if n in holes_by_number]
front, back = ordered_holes[:9], ordered_holes[9:18]
blocks_html = _build_block_html(front, "Tot" if not back else "Ut")
if back:
blocks_html += _build_block_html(back, "Inn")
total_par = sum(h.par for h in holes)
played_holes = [h for h in holes if h.score is not None]
total_gross = sum(h.score for h in played_holes) if played_holes else None
played_par = sum(h.par for h in played_holes) if played_holes else 0
total_points = sum(h.points for h in holes if h.points is not None) if show_points else None
def _tile_html(label: str, value: str, color: str = "#012c11") -> str:
return f"""<td style="padding:10px 2px;text-align:center;border:1px solid #e4ebe3;border-radius:10px;">
<div style="font-size:20px;font-weight:800;color:{color};">{_esc(value)}</div>
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.03em;color:#767f75;margin-top:2px;">{_esc(label)}</div>
</td>"""
tiles = [
_tile_html("Par", str(total_par)),
_tile_html("Score", str(total_gross) if total_gross is not None else ""),
_tile_html("Til par", _signed(total_gross - played_par) if total_gross is not None else "", _ORANGE),
]
if show_points:
tiles.append(_tile_html("Poeng", str(total_points) if total_points is not None else "", _GREEN))
tiles_html = (
'<table role="presentation" cellpadding="0" cellspacing="0" width="100%" '
'style="border-collapse:separate;border-spacing:4px 0;margin:12px 0 20px;"><tr>'
+ "".join(tiles) + "</tr></table>"
stat_rows_html = "\n".join(
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>'
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
for label, value in stat_lines
)
if is_linked_account:
pitch_html = ""
cta_label = "Se hele runden"
footer_html = ""
else:
pitch_html = f"""<p style="margin:0 0 20px;font-size:14px;color:#424941;">
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
statistikk og handicap-utvikling over tid.
</p>"""
cta_label = "Logg inn og se hele runden"
footer_html = f"""<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
e-posten -- den brukes ikke til noe annet.
</p>"""
html_body = f"""\
<!doctype html>
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
@ -812,17 +432,30 @@ e-posten -- den brukes ikke til noe annet.
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
<p style="margin:0 0 20px;font-size:16px;">{greeting_html}</p>
<div style="overflow-x:auto;">
{blocks_html}
</div>
{tiles_html}
{pitch_html}
<p style="margin:0 0 20px;font-size:16px;">Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;">
<tr style="background-color:#edf3ef;font-weight:700;">
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td>
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td>
</tr>
{hole_rows_html}
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;">
{stat_rows_html}
</table>
<p style="margin:0 0 20px;font-size:14px;color:#424941;">
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
statistikk og handicap-utvikling over tid.
</p>
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
font-weight:700;text-decoration:none;">{cta_label}</a>
font-weight:700;text-decoration:none;">Logg inn og se hele runden</a>
</td></tr></table>
{footer_html}
<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
e-posten -- den brukes ikke til noe annet.
</p>
</td></tr>
</table>
</td></tr>

View file

@ -1,129 +0,0 @@
"""
Cache-chokepoint mot GolfAPI.io (ADR-064). ALL import-kode (org-scopet
`course` OG global `personal_course`) via get_or_fetch_golfapi_course()
her -- ALDRI kalle golfapi_client direkte fra en import-endepunkt. Sjekker
`golfapi_course`-cachen FØRST, kaller GolfAPI (course + coordinates, 2 kall)
KUN hvis banen mangler -- kritisk for det begrensede kall-budsjettet
(GolfAPIs avtale tillater eksplisitt permanent caching av hentet banedata,
"no need to call the API to fetch the same course multiple times").
Ren DB-glue rundt den transportlag-rene golfapi_client.py -- samme
ansvarsdeling som courses.py allerede har rundt teeoff_client.py.
"""
from . import golfapi_client
# GolfAPIs rå tallkoder -> TeeCups lesbare tekst-enum (golfapi_course_coordinate.poi_type).
_POI_TYPE_BY_CODE = {
1: "green", 2: "green_bunker", 3: "fairway_bunker", 4: "water",
5: "trees", 6: "marker_100", 7: "marker_150", 8: "marker_200",
9: "dogleg", 10: "road", 11: "tee_front", 12: "tee_back",
}
_LOCATION_BY_CODE = {1: "front", 2: "middle", 3: "back"}
_SIDE_FW_BY_CODE = {1: "left", 2: "center", 3: "right"}
async def get_or_fetch_golfapi_course(conn, golfapi_course_id: str) -> None:
"""Idempotent -- no-op (0 API-kall) hvis banen allerede er cachet fra
en TIDLIGERE import, uansett hvilken organisasjon/bruker som gjorde den."""
already_cached = await conn.fetchval("SELECT 1 FROM golfapi_course WHERE course_id = $1", golfapi_course_id)
if already_cached:
return
course = await golfapi_client.get_course(golfapi_course_id)
coordinates = await golfapi_client.get_coordinates(golfapi_course_id)
num_holes = int(course["numHoles"])
async with conn.transaction():
await conn.execute(
"""
INSERT INTO golfapi_course
(course_id, club_id, club_name, course_name, city, country,
num_holes, has_gps, num_coordinates, golfapi_updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, to_timestamp($10))
""",
golfapi_course_id,
course["clubID"],
course["clubName"],
course.get("courseName") or "Bane",
course.get("city"),
course.get("country"),
num_holes,
bool(int(course.get("hasGPS") or 0)),
len(coordinates),
int(course["timestampUpdated"]) if course.get("timestampUpdated") else None,
)
pars_men = course["parsMen"]
indexes_men = course["indexesMen"]
pars_women = course.get("parsWomen") or [None] * num_holes
indexes_women = course.get("indexesWomen") or [None] * num_holes
for i in range(num_holes):
await conn.execute(
"""
INSERT INTO golfapi_course_hole
(golfapi_course_id, hole_number, par_men, index_men, par_women, index_women)
VALUES ($1, $2, $3, $4, $5, $6)
""",
golfapi_course_id, i + 1, pars_men[i], indexes_men[i], pars_women[i], indexes_women[i],
)
for t in course.get("tees", []):
lengths = [t.get(f"length{n}") for n in range(1, num_holes + 1)]
# Reelt funn (Larvik/Nesbyen, 2026-08-13): GolfAPI returnerer
# tomme STRENGER ("") for manglende rating-felt på enkelte
# baner -- IKKE bare for kvinner (opprinnelig antakelse, speilet
# fra ADR-019s teeoff-import), Nesbyen manglet `slopeMen` også.
# `.get(...) is not None` alene lot int()/numeric-kolonnene
# krasje på disse. Normaliser "" -> None FØR bruk, for BEGGE
# kjønn (migrasjon 068 gjorde course_rating_men/slope_men
# nullable av samme grunn -- kolonnene tåler nå det cachen her
# faktisk kan produsere).
def _num(key: str):
v = t.get(key)
return None if v is None or v == "" else v
slope_men = _num("slopeMen")
slope_women = _num("slopeWomen")
await conn.execute(
"""
INSERT INTO golfapi_course_tee
(golfapi_course_id, golfapi_tee_id, name, color_hex, lengths_meters,
course_rating_men, slope_men, course_rating_women, slope_women,
course_rating_men_front9, course_rating_men_back9,
course_rating_women_front9, course_rating_women_back9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
""",
golfapi_course_id,
t["teeID"],
t.get("teeName") or "Tee",
t.get("teeColor"),
lengths,
_num("courseRatingMen"),
int(slope_men) if slope_men is not None else None,
_num("courseRatingWomen"),
int(slope_women) if slope_women is not None else None,
_num("courseRatingMenFront9"),
_num("courseRatingMenBack9"),
_num("courseRatingWomenFront9"),
_num("courseRatingWomenBack9"),
)
for c in coordinates:
poi_type = _POI_TYPE_BY_CODE.get(int(c["poi"]))
if poi_type is None:
continue # ukjent/fremtidig GolfAPI-POI-kode -- hopp over, ikke feil hele importen
await conn.execute(
"""
INSERT INTO golfapi_course_coordinate
(golfapi_course_id, hole_number, poi_type, location, side_fairway, latitude, longitude)
VALUES ($1, $2, $3, $4, $5, $6, $7)
""",
golfapi_course_id,
int(c["hole"]),
poi_type,
_LOCATION_BY_CODE.get(int(c["location"])) if c.get("location") is not None else None,
_SIDE_FW_BY_CODE.get(int(c["sideFW"])) if c.get("sideFW") is not None else None,
c["latitude"],
c["longitude"],
)

View file

@ -1,85 +0,0 @@
"""
Utgående, lesende HTTP-klient mot GolfAPI.io (ADR-064) -- tredje banekilde
for baner utenfor TeeOffs dekning (f.eks. Tjøme Golfklubb, bekreftet IKKE
i TeeOff 2026-08-12). Ren HTTP-modul -- ingen db/RLS-avhengighet, samme
isolasjonsprinsipp som teeoff_client.py/handicap_engine.py.
ULIKT teeoff: GolfAPI-kall koster ekte, begrenset budsjett (kjøpt 20 kall,
~2/bane -- 1 for /courses/{id}, 1 for /coordinates/{id}, søk koster 0.1).
Denne modulen gjør HTTP-kall og returnerer JSON uendret -- selve
cache-sjekken ("har vi denne banen fra før?") ligger i
_get_or_fetch_golfapi_course() i courses.py, IKKE her, slik at denne
modulen forblir en ren, testbar transportlag uten db-avhengighet.
Alltid measureUnit=m i kallene -- unngår yard/meter-fellen research
advarte om (internasjonale kilder returnerer ofte yards som standard).
"""
import httpx
from .config import settings
_BASE_URL = "https://golfapi.io/api/v2.3"
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
class GolfApiNotConfiguredError(Exception):
"""TEECUP_GOLFAPI_TOKEN er ikke satt i .env."""
class GolfApiUnavailableError(Exception):
"""GolfAPI svarte ikke (nede, timeout, uventet statuskode)."""
class GolfApiNotFoundError(Exception):
"""Banen/klubben finnes ikke hos GolfAPI (404)."""
def _headers() -> dict[str, str]:
if not settings.GOLFAPI_TOKEN:
raise GolfApiNotConfiguredError()
return {"Authorization": f"Bearer {settings.GOLFAPI_TOKEN}"}
async def search_clubs(name: str | None = None, city: str | None = None, country: str | None = None) -> list[dict]:
"""Koster 0.1 API-kall. Returnerer rå `clubs`-array (hver med `courses[]`)."""
params = {k: v for k, v in {"name": name, "city": city, "country": country}.items() if v}
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
res = await client.get(f"{_BASE_URL}/clubs", params=params, headers=_headers())
except httpx.HTTPError as e:
raise GolfApiUnavailableError(str(e)) from e
if res.status_code != 200:
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
return res.json().get("clubs", [])
async def get_course(course_id: str) -> dict:
"""Koster 1 API-kall. Full scorekortdata: par/SI per hull, alle tees
med per-hull-lengde (meter) og rating/slope."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
res = await client.get(f"{_BASE_URL}/courses/{course_id}", params={"measureUnit": "m"}, headers=_headers())
except httpx.HTTPError as e:
raise GolfApiUnavailableError(str(e)) from e
if res.status_code == 404:
raise GolfApiNotFoundError(course_id)
if res.status_code != 200:
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
return res.json()
async def get_coordinates(course_id: str) -> list[dict]:
"""Koster 1 API-kall. Rå `coordinates`-array (poi/location/sideFW/hole/
latitude/longitude, GolfAPI-tallkoder -- oversettes til TeeCups
tekst-enum i courses.py, ikke her)."""
try:
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
res = await client.get(f"{_BASE_URL}/coordinates/{course_id}", headers=_headers())
except httpx.HTTPError as e:
raise GolfApiUnavailableError(str(e)) from e
if res.status_code == 404:
raise GolfApiNotFoundError(course_id)
if res.status_code != 200:
raise GolfApiUnavailableError(f"uventet status {res.status_code}")
return res.json().get("coordinates", [])

View file

@ -1,347 +0,0 @@
"""
Spillerens per-hull-historikk (ADR-072, migrasjon 075/ADR-071-tillegg):
"hvordan spiller jeg egentlig dette hullet?" -- aggregerer ALLE tidligere
ganger en spiller har spilt et gitt hull, tvers av BÅDE frittstående
runder OG org-turneringer ( tvers av ALLE organisasjoner spilleren har
spilt turnering i), begrenset til ordinære (teeoff-/GolfAPI-importerte)
baner. Custom/håndlagde baner har ingen pålitelig felles identitet
tvers av de to systemene og telles BEVISST ikke med (bekreftet med
bruker via AskUserQuestion).
Delt mellom rounds.py (frittstående kontekst) og individual_tournaments.py
(turnering-kontekst) -- begge kaller inn hit og får samme KOMBINERTE
historikk uansett hvilken side spørringen kom fra ("alle gangene" betyr
alle gangene, ikke bare de av typen man akkurat ser ).
Bane-bro mellom de to identitetssystemene (se ADR-072 for utledningen):
- Teeoff: round.teeoff_facility_slug + round.teeoff_course_id <->
course.external_course_ref = f"{facility_slug}:{teeoff_course_id}"
(course.source = 'official') -- eksakt samme streng-format som
import_official_course() i courses.py bygger den med.
- GolfAPI: personal_course.external_golfapi_course_id <->
course.external_course_ref (course.source = 'international') --
begge lagrer samme golfapi_course_id, uten prefiks.
Custom personal_course (external_golfapi_course_id IS NULL) og custom
course (source = 'custom') har INGEN bro her -- ekskluderes (resolve-
funksjonene returnerer None, kallerne skjuler da historikk-panelet).
`CourseKey`/de tre `resolve_*_course_key()`-funksjonene gjenbrukes også av
`app/target_points.py` (migrasjon 079, rangefinder for offisielle baner)
-- samme bane-bro, annet formål (koordinater, ikke historikk). Tredje
søster `resolve_match_course_key()` (lag-matchplay: match->session->
course) har INGEN egen hole-history-bruker ennå (kun ADR-072s to
flyter), men følger identisk mønster for symmetriens skyld.
GIR/fairway-formlene speiler round-stats.tsx/rounds.py sin ETABLERTE
`score - putts <= par - 2`-definisjon (IKKE skjema-kommentaren i
020_personal_rounds.sql, som beskriver en avvikende, ubrukt formel --
se ADR-071/ADR-033 Beslutning B for hvorfor avviket er bevisst).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Literal
from asyncpg import Connection
from pydantic import BaseModel
from .db import org_connection, plain_connection
CourseKey = tuple[Literal["teeoff"], str, str] | tuple[Literal["golfapi"], str]
async def resolve_personal_round_course_key(conn: Connection, round_id: str) -> CourseKey | None:
row = await conn.fetchrow(
"""
SELECT course_source, teeoff_facility_slug, teeoff_course_id, personal_course_id::text AS personal_course_id
FROM round WHERE id = $1
""",
round_id,
)
if row is None:
return None
if row["course_source"] == "teeoff":
return ("teeoff", row["teeoff_facility_slug"], row["teeoff_course_id"])
golfapi_id = await conn.fetchval(
"SELECT external_golfapi_course_id FROM personal_course WHERE id = $1",
row["personal_course_id"],
)
if golfapi_id is None:
return None
return ("golfapi", golfapi_id)
async def resolve_tournament_round_course_key(conn: Connection, tournament_round_id: str) -> CourseKey | None:
row = await conn.fetchrow(
"""
SELECT c.source, c.external_course_ref
FROM tournament_round tr
JOIN course c ON c.id = tr.course_id
WHERE tr.id = $1
""",
tournament_round_id,
)
if row is None or row["source"] == "custom" or row["external_course_ref"] is None:
return None
if row["source"] == "official":
facility_slug, _, teeoff_course_id = row["external_course_ref"].partition(":")
return ("teeoff", facility_slug, teeoff_course_id)
return ("golfapi", row["external_course_ref"])
async def resolve_match_course_key(conn: Connection, match_id: str) -> CourseKey | None:
row = await conn.fetchrow(
"""
SELECT c.source, c.external_course_ref
FROM match m
JOIN session s ON s.id = m.session_id
JOIN course c ON c.id = s.course_id
WHERE m.id = $1
""",
match_id,
)
if row is None or row["source"] == "custom" or row["external_course_ref"] is None:
return None
if row["source"] == "official":
facility_slug, _, teeoff_course_id = row["external_course_ref"].partition(":")
return ("teeoff", facility_slug, teeoff_course_id)
return ("golfapi", row["external_course_ref"])
@dataclass
class HoleInstance:
source: Literal["personal", "tournament"]
played_at: date | None
label: str
score: int
par: int
putts: int | None
tee_shot_result: str | None
approach_result: str | None
chip_count: int | None
bunker_shot_count: int | None
penalty_strokes: int | None
first_putt_distance_bucket: str | None
async def fetch_personal_hole_history(user_id: str, key: CourseKey, hole_number: int) -> list[HoleInstance]:
async with plain_connection() as conn:
if key[0] == "teeoff":
_, facility_slug, teeoff_course_id = key
rows = await conn.fetch(
"""
SELECT r.played_at, r.name, rh.score, rh.par, rh.putts, rh.tee_shot_result,
rh.approach_result, rh.chip_count, rh.bunker_shot_count,
rh.penalty_strokes, rh.first_putt_distance_bucket
FROM round_hole rh
JOIN round_participant rp ON rp.id = rh.round_participant_id
JOIN round r ON r.id = rp.round_id
WHERE rp.user_id = $1 AND rh.hole_number = $2 AND rh.played = true
AND rh.score IS NOT NULL
AND r.course_source = 'teeoff'
AND r.teeoff_facility_slug = $3 AND r.teeoff_course_id = $4
ORDER BY r.played_at DESC
""",
user_id, hole_number, facility_slug, teeoff_course_id,
)
else:
_, golfapi_id = key
rows = await conn.fetch(
"""
SELECT r.played_at, r.name, rh.score, rh.par, rh.putts, rh.tee_shot_result,
rh.approach_result, rh.chip_count, rh.bunker_shot_count,
rh.penalty_strokes, rh.first_putt_distance_bucket
FROM round_hole rh
JOIN round_participant rp ON rp.id = rh.round_participant_id
JOIN round r ON r.id = rp.round_id
JOIN personal_course pc ON pc.id = r.personal_course_id
WHERE rp.user_id = $1 AND rh.hole_number = $2 AND rh.played = true
AND rh.score IS NOT NULL
AND r.course_source = 'custom' AND pc.external_golfapi_course_id = $3
ORDER BY r.played_at DESC
""",
user_id, hole_number, golfapi_id,
)
return [
HoleInstance(
source="personal",
played_at=r["played_at"],
label=r["name"] or "Runde",
score=r["score"],
par=r["par"],
putts=r["putts"],
tee_shot_result=r["tee_shot_result"],
approach_result=r["approach_result"],
chip_count=r["chip_count"],
bunker_shot_count=r["bunker_shot_count"],
penalty_strokes=r["penalty_strokes"],
first_putt_distance_bucket=r["first_putt_distance_bucket"],
)
for r in rows
]
async def fetch_tournament_hole_history(user_id: str, key: CourseKey, hole_number: int) -> list[HoleInstance]:
"""N+1-per-org (samme mønster som /auth/me): tournament_round_hole er
org-scopet/RLS-beskyttet, og spilleren kan ha spilt turnering i flere
organisasjoner -- ett enkelt org_connection()-kall kan derfor aldri
dekke alle. `player_organizations_for_user()` (migrasjon 015, SAMME
SECURITY DEFINER-bro /auth/me allerede bruker) gir org-listen trygt
uten en RLS-omgåelse i selve spørringen."""
if key[0] == "teeoff":
_, facility_slug, teeoff_course_id = key
source_filter = "official"
external_ref = f"{facility_slug}:{teeoff_course_id}"
else:
_, golfapi_id = key
source_filter = "international"
external_ref = golfapi_id
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,
)
instances: list[HoleInstance] = []
for org_row in org_rows:
async with org_connection(org_row["organization_id"]) as conn:
rows = await conn.fetch(
"""
SELECT t.name AS tournament_name, tr.scheduled_at,
trh.gross_strokes AS score, h.par, trh.putts, trh.tee_shot_result,
trh.approach_result, trh.chip_count, trh.bunker_shot_count,
trh.penalty_strokes, trh.first_putt_distance_bucket
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 t ON t.id = tr.tournament_id
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
JOIN course c ON c.id = tr.course_id
JOIN hole h ON h.course_id = tr.course_id AND h.hole_number = trh.hole_number
WHERE p.user_id = $1 AND trh.hole_number = $2
AND c.source = $3 AND c.external_course_ref = $4
ORDER BY tr.scheduled_at DESC NULLS LAST
""",
user_id, hole_number, source_filter, external_ref,
)
for r in rows:
instances.append(
HoleInstance(
source="tournament",
played_at=r["scheduled_at"].date() if r["scheduled_at"] is not None else None,
label=r["tournament_name"] or "Turnering",
score=r["score"],
par=r["par"],
putts=r["putts"],
tee_shot_result=r["tee_shot_result"],
approach_result=r["approach_result"],
chip_count=r["chip_count"],
bunker_shot_count=r["bunker_shot_count"],
penalty_strokes=r["penalty_strokes"],
first_putt_distance_bucket=r["first_putt_distance_bucket"],
)
)
return instances
class HoleHistoryInstanceOut(BaseModel):
source: Literal["personal", "tournament"]
played_at: date | None
label: str
score: int
par: int
putts: int | None
tee_shot_result: str | None
approach_result: str | None
chip_count: int | None
bunker_shot_count: int | None
penalty_strokes: int | None
first_putt_distance_bucket: str | None
class HoleHistoryOut(BaseModel):
times_played: int
times_personal: int
times_tournament: int
average_score: float
average_score_vs_par: float
# None = ingen av instansene har putter registrert (rene strokes_only-
# historikk) -- IKKE det samme som 0%.
gir_percent: float | None
# Kun meningsfullt for par 4/5 -- None hvis ingen slike instanser har
# utslagsretning registrert.
fairway_hit_percent: float | None
average_putts: float | None
best_score: int
worst_score: int
instances: list[HoleHistoryInstanceOut]
def summarize_hole_history(instances: list[HoleInstance]) -> HoleHistoryOut | None:
if not instances:
return None
ordered = sorted(instances, key=lambda i: i.played_at or date.min, reverse=True)
times_played = len(ordered)
times_personal = sum(1 for i in ordered if i.source == "personal")
scores = [i.score for i in ordered]
gir_rows = [i for i in ordered if i.putts is not None]
fairway_rows = [i for i in ordered if i.par != 3 and i.tee_shot_result is not None]
putts_values = [i.putts for i in ordered if i.putts is not None]
return HoleHistoryOut(
times_played=times_played,
times_personal=times_personal,
times_tournament=times_played - times_personal,
average_score=sum(scores) / times_played,
average_score_vs_par=sum(i.score - i.par for i in ordered) / times_played,
gir_percent=(
100 * sum(1 for i in gir_rows if i.score - i.putts <= i.par - 2) / len(gir_rows)
if gir_rows
else None
),
fairway_hit_percent=(
100 * sum(1 for i in fairway_rows if i.tee_shot_result == "fairway") / len(fairway_rows)
if fairway_rows
else None
),
average_putts=sum(putts_values) / len(putts_values) if putts_values else None,
best_score=min(scores),
worst_score=max(scores),
instances=[
HoleHistoryInstanceOut(
source=i.source,
played_at=i.played_at,
label=i.label,
score=i.score,
par=i.par,
putts=i.putts,
tee_shot_result=i.tee_shot_result,
approach_result=i.approach_result,
chip_count=i.chip_count,
bunker_shot_count=i.bunker_shot_count,
penalty_strokes=i.penalty_strokes,
first_putt_distance_bucket=i.first_putt_distance_bucket,
)
for i in ordered
],
)
async def hole_history_for_user(user_id: str, key: CourseKey | None, hole_number: int) -> HoleHistoryOut | None:
"""Toppnivå-inngang brukt av begge routerne: løser INGENTING selv (nøkkel
allerede være resolvet av kalleren, som kjenner sin egen kontekst) --
henter fra begge kilder og slår sammen. Returnerer None både når nøkkelen
er None (custom bane) OG når spilleren ikke har noen historikk ennå --
kalleren skiller ikke mellom disse to, begge betyr "ikke vis panelet".
"""
if key is None:
return None
personal = await fetch_personal_hole_history(user_id, key, hole_number)
tournament = await fetch_tournament_hole_history(user_id, key, hole_number)
return summarize_hole_history(personal + tournament)

View file

@ -16,7 +16,6 @@ from fastapi import FastAPI
from .db import init_pool, close_pool
from .routers import (
account_merge,
auth,
courses,
friends,
@ -27,7 +26,6 @@ from .routers import (
order_of_merit,
organizations,
players,
print_pdf,
registration,
round_messages,
rounds,
@ -50,14 +48,12 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(account_merge.router)
app.include_router(organizations.router)
app.include_router(players.router)
app.include_router(courses.router)
app.include_router(tournaments.router)
app.include_router(individual_tournaments.router)
app.include_router(order_of_merit.router)
app.include_router(order_of_merit.public_router)
app.include_router(matches.router)
app.include_router(scoring.router)
app.include_router(registration.router)
@ -68,7 +64,6 @@ app.include_router(rounds.router)
app.include_router(round_messages.router)
app.include_router(friends.router)
app.include_router(notifications.router)
app.include_router(print_pdf.router)
@app.get("/health")

View file

@ -12,4 +12,3 @@ argon2-cffi>=23.1
pyotp>=2.9
qrcode>=7.4
pywebpush>=2.0
playwright>=1.45

View file

@ -1,135 +0,0 @@
"""
Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- selvbetjent:
initiativtakeren (en aktiv økt) ber om å slå en ANNEN, egen konto sammen med
den de er logget inn . Krever bevis for eierskap av BEGGE kontoer: den
aktive økten beviser eierskap av initiativtakerens konto, en lenke sendt til
MÅL-kontoens e-post (samme token-i-lenke-mønster som ADR-032 sin e-postbytte)
beviser eierskap av den andre.
Selve beregningen/utførelsen ligger i app/account_merge.py (ren, uten
FastAPI-avhengigheter) -- denne filen er kun HTTP-laget: validering,
token-håndtering, e-postutsending.
"""
import secrets
import traceback
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr
from ..account_merge import MergePreview, MergeResult, compute_merge_preview, execute_merge
from ..auth import CurrentUser, get_current_user, resolve_user_id_by_email
from ..config import settings
from ..db import plain_connection
from ..email import send_account_merge_confirmation
from ..errors import app_error
from .auth import _hash_secret
router = APIRouter(prefix="/auth/account-merge", tags=["account-merge"])
class AccountMergeEmailRequest(BaseModel):
email: EmailStr
async def _resolve_target_or_error(conn, requester_id: str, email: str) -> str:
target_id = await resolve_user_id_by_email(conn, email)
if target_id is None:
raise app_error(404, "NOT_FOUND", "Fant ingen konto med denne e-posten.")
if target_id == requester_id:
raise app_error(400, "VALIDATION_FAILED", "Dette er allerede din egen konto.")
return target_id
@router.post("/preview", response_model=MergePreview)
async def preview_account_merge(
body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user)
) -> MergePreview:
async with plain_connection() as conn:
target_id = await _resolve_target_or_error(conn, user.user_id, body.email.lower())
return await compute_merge_preview(user.user_id, target_id)
@router.post("/request")
async def request_account_merge(
body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
email = body.email.lower()
async with plain_connection() as conn:
target_id = await _resolve_target_or_error(conn, user.user_id, email)
target_row = await conn.fetchrow(
"SELECT email::text AS email, display_name FROM app_user WHERE id = $1", target_id
)
initiator_row = await conn.fetchrow(
"SELECT display_name, email::text AS email FROM app_user WHERE id = $1", user.user_id
)
raw_token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
await conn.execute(
"INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) "
"VALUES ($1, $2, $3, $4)",
user.user_id,
target_id,
_hash_secret(raw_token),
expires_at,
)
# Lenken sendes til MÅL-e-posten som faktisk ble oppgitt (kan være
# taperens primær- ELLER en av dens sekundæradresser) -- beviser
# eierskap av akkurat DEN adressen, tilstrekkelig siden begge allerede
# er verifiserte adresser på taper-kontoen.
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Kontosammenslåing-lenke for {email}: {raw_token}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_account_merge_confirmation(
email, raw_token, initiator_row["display_name"], initiator_row["email"] or ""
)
except Exception:
traceback.print_exc()
return {"status": "ok", "detail": "Hvis e-posten tilhører en annen konto, er en bekreftelseslenke sendt."}
@router.get("/token/{token}", response_model=MergePreview)
async def get_account_merge_token(token: str) -> MergePreview:
"""Validerer token (IKKE konsumert/utløpt) og returnerer en FERSK
forhåndsvisning -- konsumerer IKKE token, kun /confirm gjør det. Ingen
sesjon påkrevd (samme som e-postbytte-bekreftelse, kan åpnes en
annen enhet enn den som ba om sammenslåingen)."""
token_hash = _hash_secret(token)
async with plain_connection() as conn:
token_row = await conn.fetchrow(
"SELECT initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id "
"FROM account_merge_token WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()",
token_hash,
)
if token_row is None:
raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
return await compute_merge_preview(token_row["initiator_user_id"], token_row["target_user_id"])
class AccountMergeConfirm(BaseModel):
token: str
@router.post("/confirm", response_model=MergeResult)
async def confirm_account_merge(body: AccountMergeConfirm) -> MergeResult:
token_hash = _hash_secret(body.token)
async with plain_connection() as conn:
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
# samtidige bekreftelser av samme lenke kan da ikke begge lykkes
# (samme prinsipp som magic-link/e-postbytte).
token_row = await conn.fetchrow(
"""
UPDATE account_merge_token
SET consumed_at = now()
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
RETURNING initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id
""",
token_hash,
)
if token_row is None:
raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
return await execute_merge(token_row["initiator_user_id"], token_row["target_user_id"])

View file

@ -57,7 +57,6 @@ from ..auth import (
get_current_user,
get_pending_user,
hash_password,
resolve_user_id_by_email,
should_use_secure_cookies,
totp_provisioning_uri,
user_requires_2fa_enrollment,
@ -313,13 +312,14 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
email = token_row["email"]
# Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
# e-postadresser") løses til EIERENS eksisterende konto -- må
# sjekkes FØR en eventuell ny konto opprettes under, ellers ville
# en innlogging på en sekundær adresse (som med vilje ALDRI står i
# app_user.email) stille opprettet en helt ny, separat, feilaktig
# konto.
resolved_user_id = await resolve_user_id_by_email(conn, email)
# Sekundær e-post (FEATURE_BACKLOG.md "Én person, flere e-post-
# adresser") løses til EIERENS eksisterende konto -- må sjekkes FØR
# app_user-oppslaget under, ellers ville en innlogging på en
# sekundær adresse (som med vilje ALDRI står i app_user.email)
# stille opprettet en helt ny, separat, feilaktig konto.
secondary_owner_id = await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
)
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
# kontoens navn trengs ETTER at connection-blokken er lukket
# (e-postutsending skal aldri skje mens en transaksjon/tilkobling
@ -327,8 +327,8 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
is_new_account = False
new_account_display_name = None
if resolved_user_id is not None:
user_id = resolved_user_id
if secondary_owner_id is not None:
user_id = secondary_owner_id
else:
placeholder_name = email.split("@")[0]
new_account_display_name = body.display_name or placeholder_name
@ -397,17 +397,20 @@ async def login_with_password(
) -> LoginResult:
email = body.email.lower()
async with plain_connection() as conn:
# Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
# e-postadresser") -- passordet ligger uansett på selve kontoen,
# ikke adressen, så slå opp eieren og fortsett normalt.
resolved_user_id = await resolve_user_id_by_email(conn, email)
user_row = (
await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id
)
if resolved_user_id is not None
else None
user_row = await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
)
if user_row is None:
# Kan være en sekundær adresse (FEATURE_BACKLOG.md "Én person,
# flere e-postadresser") -- passordet ligger uansett på selve
# kontoen, ikke adressen, så slå opp eieren og fortsett normalt.
secondary_owner_id = await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
)
if secondary_owner_id is not None:
user_row = await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", secondary_owner_id
)
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
if (
@ -926,12 +929,6 @@ class ProfileUpdate(BaseModel):
# Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke
# en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen.
bag_clubs: list[BagClub] | None = Field(default=None, max_length=14)
# i18n (ADR-094) -- preferred_locale kunne FØR kun settes ved
# førstegangs-signup (ADR-015, magic_link_token.locale), aldri endres
# etterpå. language-switcher.tsx PATCHer dette feltet slik at valget
# følger brukeren til neste enhet/økt, ikke bare gjeldende nettleser
# (som en cookie alene ville gitt).
preferred_locale: str | None = Field(default=None, pattern="^(nb|en)$")
@router.patch("/profile", response_model=Me)
@ -1188,9 +1185,8 @@ async def request_secondary_email(
raise app_error(
409,
"DUPLICATE",
"Denne adressen er allerede en konto sin hovedadresse. Bruk "
"\"Slå sammen med en annen konto\" lenger ned for å slå de to "
"kontoene sammen i stedet.",
"Denne adressen er allerede en konto sin hovedadresse. Ekte "
"konto-sammenslåing støttes ikke ennå.",
)
existing_secondary = await conn.fetchval(
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email

View file

@ -13,9 +13,8 @@ bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
from fastapi import APIRouter, Depends, Query
from .. import golfapi_cache, golfapi_client, teeoff_client
from .. import teeoff_client
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 is_org_member, user_is_org_player
@ -609,271 +608,3 @@ async def import_official_course(
)
return Course(**dict(course_row))
# --- Rangefinder-koordinater for offisielle baner (migrasjon 079) -----------
#
# Frem til nå har rangefinder-koordinater (grønn/hindringer, GPS-basert) KUN
# eksistert for GolfAPI-importerte baner. Offisielle (teeoff-koblede) baner
# har ALDRI hatt noen koordinatkilde -- teeoff selv har ingen slik data, og
# det finnes ingen automatisk importvei (i motsetning til GolfAPI). Dette er
# derfor en RENT MANUELL skrive-vei: selve tolkningsarbeidet (fritekst-navn
# på feltbefarte punkter -> poi_type/location/side_fairway) krever
# dømmekraft og skjer FØR kallet hit, ikke automatisert her (bekreftet
# konkret av Tjøme-runden -- "Voll"/"Bjella" kunne ikke vært gjettet av et
# script). Full erstatning (ikke delta) -- speiler hvordan koordinatsett
# faktisk oppstår i praksis (en ny feltbefaring erstatter den forrige, ikke
# et lite tillegg til den). Lagres GLOBALT (teeoff_course_coordinate, nøkkel
# course.external_course_ref) -- IKKE per organisasjon, samme begrunnelse
# som golfapi_course_coordinate: flere organisasjoner kan importere samme
# fysiske bane fra teeoff hver sin course-rad, koordinatene skal deles.
class CoursePointIn(BaseModel):
hole_number: int = Field(ge=1, le=18)
poi_type: str
location: str | None = None
side_fairway: str | None = None
latitude: float = Field(ge=-90, le=90)
longitude: float = Field(ge=-180, le=180)
# Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME
# fysiske hindring (migrasjon 080/ADR-084) -- se hazard_group-
# kommentaren i migrasjonsfilen. Frivillig, ingen validering utover
# tekst -- kun meningsfullt unikt innenfor ett hull.
hazard_group: str | None = None
class CoursePointOut(BaseModel):
hole_number: int
poi_type: str
location: str | None
side_fairway: str | None
latitude: float
longitude: float
hazard_group: str | None
_COORDINATE_COLUMNS = "hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group"
@router.get("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut])
async def list_course_coordinates(
course_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[CoursePointOut]:
async with org_connection(organization_id) as conn:
course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id)
if course_row is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
if course_row["source"] != "official" or course_row["external_course_ref"] is None:
return []
rows = await conn.fetch(
f"""
SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate
WHERE external_course_ref = $1 ORDER BY hole_number
""",
course_row["external_course_ref"],
)
return [CoursePointOut(**dict(r)) for r in rows]
@router.put("/orgs/{organization_id}/courses/{course_id}/coordinates", response_model=list[CoursePointOut])
async def replace_course_coordinates(
course_id: str,
body: list[CoursePointIn],
organization_id: str = Depends(get_authorized_org),
) -> list[CoursePointOut]:
async with org_connection(organization_id) as conn, translate_db_errors():
course_row = await conn.fetchrow("SELECT source, external_course_ref FROM course WHERE id = $1", course_id)
if course_row is None:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke.")
if course_row["source"] != "official":
raise app_error(
400,
"VALIDATION_FAILED",
"Kun offisielle (TeeOff-koblede) baner kan få rangefinder-koordinater satt manuelt.",
)
external_ref = course_row["external_course_ref"]
await conn.execute("DELETE FROM teeoff_course_coordinate WHERE external_course_ref = $1", external_ref)
for p in body:
await conn.execute(
"""
INSERT INTO teeoff_course_coordinate
(external_course_ref, hole_number, poi_type, location, side_fairway, latitude, longitude, hazard_group)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""",
external_ref, p.hole_number, p.poi_type, p.location, p.side_fairway, p.latitude, p.longitude, p.hazard_group,
)
rows = await conn.fetch(
f"""
SELECT {_COORDINATE_COLUMNS} FROM teeoff_course_coordinate
WHERE external_course_ref = $1 ORDER BY hole_number
""",
external_ref,
)
return [CoursePointOut(**dict(r)) for r in rows]
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
#
# Tredje banekilde, for baner utenfor TeeOffs dekning -- Tjøme Golfklubb var
# den konkrete anledningen (bekreftet IKKE i TeeOff 2026-08-12). ULIKT
# TeeOff-importen over: GolfAPI-kall koster ekte, begrenset budsjett, derfor
# går ALL henting via golfapi_cache.get_or_fetch_golfapi_course() -- aldri
# et direkte golfapi_client-kall herfra. Samme "kopier inn, aldri live
# oppslag"-filosofi og samme "fail loudly on ufullstendige data"-prinsipp
# som ADR-019, men fra en annen kilde.
class InternationalCourseOption(BaseModel):
course_id: str
course_name: str
num_holes: int
has_gps: bool
class InternationalClub(BaseModel):
club_id: str
club_name: str
city: str | None = None
country: str | None = None
courses: list[InternationalCourseOption]
class InternationalCourseImport(BaseModel):
golfapi_course_id: str
@router.get("/orgs/{organization_id}/courses/international-search", response_model=list[InternationalClub])
async def search_international_courses(
q: str = Query(default=""),
organization_id: str = Depends(get_authorized_org),
) -> list[InternationalClub]:
if not q.strip():
return []
try:
clubs = await golfapi_client.search_clubs(name=q)
except golfapi_client.GolfApiNotConfiguredError:
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
except golfapi_client.GolfApiUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
return [
InternationalClub(
club_id=c["clubID"],
club_name=c["clubName"],
city=c.get("city"),
country=c.get("country"),
courses=[
InternationalCourseOption(
course_id=course["courseID"],
course_name=course.get("courseName") or "Bane",
num_holes=int(course["numHoles"]),
has_gps=bool(int(course.get("hasGPS") or 0)),
)
for course in c.get("courses", [])
],
)
for c in clubs
]
@router.post("/orgs/{organization_id}/courses/international-import", response_model=Course, status_code=201)
async def import_international_course(
body: InternationalCourseImport,
organization_id: str = Depends(get_authorized_org),
) -> Course:
if not settings.GOLFAPI_TOKEN:
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
# Idempotent, samme begrunnelse som import_official_course over: samme
# organisasjon importerer ofte samme bane til flere økter.
async with org_connection(organization_id) as conn:
existing = await conn.fetchrow(
f"SELECT {_COURSE_COLUMNS} FROM course WHERE external_course_ref = $1 AND source = 'international'",
body.golfapi_course_id,
)
if existing is not None:
return Course(**dict(existing))
async with org_connection(organization_id) as conn, translate_db_errors():
try:
await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
except golfapi_client.GolfApiNotFoundError:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
except golfapi_client.GolfApiUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
gc = await conn.fetchrow("SELECT club_name, course_name, city FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id)
holes = await conn.fetch(
"SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
body.golfapi_course_id,
)
tees = await conn.fetch(
"SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
body.golfapi_course_id,
)
# ADR-064 (speiler ADR-019 Beslutning C): ufullstendige data feiler
# importen tydelig FØR noe skrives til course/hole/tee.
if len(holes) != 18:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
if not tees:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
if not any(
(t["course_rating_men"] is not None and t["slope_men"] is not None)
or (t["course_rating_women"] is not None and t["slope_women"] is not None)
for t in tees
):
raise app_error(
400, "EXTERNAL_DATA_INCOMPLETE", "Ingen av utslagene har en brukbar rating/slope hos GolfAPI."
)
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_row = await conn.fetchrow(
f"""
INSERT INTO course (organization_id, name, source, external_course_ref)
VALUES ($1, $2, 'international', $3)
RETURNING {_COURSE_COLUMNS}
""",
organization_id,
course_name,
body.golfapi_course_id,
)
course_id = course_row["id"]
for h in holes:
await conn.execute(
"INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4, $5)",
organization_id, course_id, h["hole_number"], h["par_men"], h["index_men"],
)
for t in tees:
# Reelt funn (Nesbyen, 2026-08-13): GolfAPI kan mangle RATING
# FOR BEGGE kjønn på et utslag (opprinnelig antatt at menns alltid
# finnes, speilet fra ADR-019s teeoff-import) -- begge sjekkes nå
# individuelt, samme mønster som teeoff-importen allerede bruker.
# Utslag uten NOEN brukbar rating hoppes over (usable_tees under).
ratings: list[tuple[str, float, int]] = []
if t["course_rating_men"] is not None and t["slope_men"] is not None:
ratings.append(("m", float(t["course_rating_men"]), int(t["slope_men"])))
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"])))
if not ratings:
continue
tee_row = await conn.fetchrow(
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
organization_id, course_id, t["name"],
)
for gender, course_rating, slope_rating in ratings:
await conn.execute(
"""
INSERT INTO tee_rating (organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
""",
organization_id, tee_row["id"], gender, course_rating, slope_rating, par_total,
)
return Course(**dict(course_row))

File diff suppressed because it is too large Load diff

View file

@ -40,15 +40,9 @@ def _compute_tee_time(
tee_interval_minutes: int | None,
sequence: int,
override: datetime | None,
start_mode: str = "consecutive",
) -> datetime | None:
"""Shotgun (migrasjon 088, ADR-096): ALLE matcher i økten går ut
SAMTIDIG -- intervallet/sequence er meningsløst (ulikt starthull,
ikke ulikt klokkeslett), se match.start_hole i stedet."""
if override is not None:
return override
if start_mode == "shotgun":
return scheduled_at
if scheduled_at is None or tee_interval_minutes is None:
return None
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
@ -70,10 +64,6 @@ class MatchCreate(BaseModel):
sequence: int
team_a_id: str
team_b_id: str
# Kun meningsfullt (og satt av organisator) når økten er 'shotgun' --
# se _compute_tee_time. `None` for en 'consecutive'-økt, som fortsatt
# bruker øktens EGET delte start_hole (uendret).
start_hole: int | None = Field(default=None, ge=1, le=18)
class MatchOut(BaseModel):
@ -86,7 +76,6 @@ class MatchOut(BaseModel):
points_side_b: float | None
leading_side: str | None
tee_time: datetime | None
start_hole: int | None
participants: list[MatchParticipantOut]
@ -103,7 +92,7 @@ async def create_match(
async with org_connection(organization_id) as conn, translate_db_errors():
session = await conn.fetchrow(
"""
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes, start_mode
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
FROM session WHERE id = $1
""",
session_id,
@ -124,25 +113,20 @@ async def create_match(
row = await conn.fetchrow(
"""
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id, start_hole)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b, leading_side, tee_time_override, start_hole
points_side_b::float AS points_side_b, leading_side, tee_time_override
""",
organization_id,
session_id,
body.sequence,
body.team_a_id,
body.team_b_id,
body.start_hole,
)
tee_time = _compute_tee_time(
session["scheduled_at"],
session["tee_interval_minutes"],
row["sequence"],
row["tee_time_override"],
session["start_mode"],
session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"]
)
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
@ -157,7 +141,7 @@ async def fetch_matches(
avslørte matcher, se blind_draw.py)."""
session = await conn.fetchrow(
"""
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes, start_mode
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
FROM session WHERE id = $1
""",
session_id,
@ -169,7 +153,7 @@ async def fetch_matches(
"""
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
status_text, points_side_a::float AS points_side_a,
points_side_b::float AS points_side_b, leading_side, tee_time_override, start_hole
points_side_b::float AS points_side_b, leading_side, tee_time_override
FROM match
WHERE session_id = $1
ORDER BY sequence
@ -219,7 +203,7 @@ async def fetch_matches(
)
)
out = [
return [
MatchOut(
id=m["id"],
sequence=m["sequence"],
@ -230,23 +214,12 @@ async def fetch_matches(
points_side_b=m["points_side_b"],
leading_side=m["leading_side"],
tee_time=_compute_tee_time(
session["scheduled_at"],
session["tee_interval_minutes"],
m["sequence"],
m["tee_time_override"],
session["start_mode"],
session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"]
),
start_hole=m["start_hole"],
participants=by_match[m["id"]],
)
for m in matches
]
# Visningsrekkefølge (bruker, 2026-08-21, ADR-096): løpende sorteres
# etter klokkeslett (allerede = sequence-rekkefølgen for 'consecutive'),
# shotgun sorteres etter starthull -- ikke tildelt (None) sist.
if session["start_mode"] == "shotgun":
out.sort(key=lambda mo: (mo.start_hole is None, mo.start_hole))
return out
@router.get(

View file

@ -33,16 +33,9 @@ from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from ..auth import get_authorized_org
from ..db import org_connection, plain_connection
from ..db import org_connection
from ..errors import app_error, translate_db_errors
from handicap_engine import (
allocate_strokes_by_index,
EclecticHoleValue,
eclectic_best_per_hole,
order_of_merit_aggregate,
order_of_merit_points_for_position,
stableford_points_for_hole,
)
from handicap_engine import order_of_merit_aggregate, order_of_merit_points_for_position
from .individual_tournaments import _compute_individual_standings
@ -172,24 +165,6 @@ async def update_order_of_merit(
raise app_error(
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
)
if effective_mode == "eclectic":
# Slår PÅ eclectic mens flere turneringer med ulik bane
# allerede er lenket -- avvis her også, ikke bare ved lenking
# av en NY turnering (ADR-075).
links = await conn.fetch(
"SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
)
course_ids = set()
for link in links:
cid = await _course_id_for_tournament(conn, link["tournament_id"])
if cid is not None:
course_ids.add(cid)
if len(course_ids) > 1:
raise app_error(
400,
"VALIDATION_FAILED",
"Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
)
row = await conn.fetchrow(
f"""
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
@ -273,39 +248,6 @@ def _parse_link_row(r) -> dict:
return d
async def _course_id_for_tournament(conn, tournament_id: str) -> str | None:
"""Samme mønster som _attach_eclectic_totals sin egen bane-oppslag
(individual_tournaments.py) -- LIMIT 1 antar (bevisst, håndhevet av
resten av appen ved rundeopprettelse) at en turnerings runder deler
samme bane."""
return await conn.fetchval(
"SELECT course_id::text FROM tournament_round WHERE tournament_id = $1 LIMIT 1", tournament_id
)
async def _validate_same_course_for_eclectic(conn, oom_id: str, new_tournament_id: str) -> None:
"""Eclectic-aggregering (ADR-075) plukker beste resultat PER HULL på
tvers av alle lenkede turneringer -- meningsløst hvis banene ikke er
de samme (hull 7 én bane er ikke hull 7 en annen). Håndhevet
her (lenke-tidspunkt) OG i update_order_of_merit (modus-endring),
samme "fail loudly, ikke stille feil tall"-filosofi som ADR-068/
ADR-019 Beslutning C."""
new_course_id = await _course_id_for_tournament(conn, new_tournament_id)
if new_course_id is None:
return # ingen runder ennå på den nye turneringen -- ingenting å sammenligne mot
links = await conn.fetch(
"SELECT tournament_id::text FROM order_of_merit_link WHERE order_of_merit_id = $1", oom_id
)
for link in links:
existing_course_id = await _course_id_for_tournament(conn, link["tournament_id"])
if existing_course_id is not None and existing_course_id != new_course_id:
raise app_error(
400,
"VALIDATION_FAILED",
"Eclectic-aggregering krever at alle lenkede turneringer spilles på samme bane.",
)
@router.post(
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
response_model=OrderOfMeritLinkOut,
@ -317,7 +259,7 @@ async def link_tournament_to_order_of_merit(
organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritLinkOut:
async with org_connection(organization_id) as conn, translate_db_errors():
oom = await conn.fetchrow("SELECT result_type, aggregation_mode FROM order_of_merit WHERE id = $1", oom_id)
oom = await conn.fetchrow("SELECT result_type FROM order_of_merit WHERE id = $1", oom_id)
if oom is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
tournament = await conn.fetchrow(
@ -329,8 +271,6 @@ async def link_tournament_to_order_of_merit(
raise app_error(
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
)
if oom["aggregation_mode"] == "eclectic":
await _validate_same_course_for_eclectic(conn, oom_id, body.tournament_id)
result_type = oom["result_type"]
if result_type == "points" and not body.points_table:
@ -385,288 +325,6 @@ async def unlink_tournament_from_order_of_merit(
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
# ---------------------------------------------------------------------------
# Lag-OOM (ADR-043 Beslutning C, bygget ADR-074): sesong-par satt opp
# DIREKTE i OOM-en (IKKE strengmatching mot turnering-lag, ADR-011-lag er
# strukturelt urelatert -- se migrasjon 055 sin kommentar). Kun gyldig for
# `kind='team'`. Selve leaderboard-beregningen (summerer/velger-beste-N-av
# medlemmenes allerede beregnede individuelle OOM-resultater) står lenger
# ned, sammen med spiller-leaderboardet den gjenbruker.
# ---------------------------------------------------------------------------
class OrderOfMeritTeamCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class OrderOfMeritTeamMemberOut(BaseModel):
id: str
player_id: str
player_name: str
class OrderOfMeritTeamOut(BaseModel):
id: str
name: str
members: list[OrderOfMeritTeamMemberOut]
class OrderOfMeritTeamMemberCreate(BaseModel):
player_id: str
async def _require_team_kind(conn, oom_id: str) -> None:
kind = await conn.fetchval("SELECT kind FROM order_of_merit WHERE id = $1", oom_id)
if kind is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
if kind != "team":
raise app_error(400, "VALIDATION_FAILED", "Lag kan kun settes opp for en lag-OOM.")
@router.get(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
response_model=list[OrderOfMeritTeamOut],
)
async def list_order_of_merit_teams(
oom_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[OrderOfMeritTeamOut]:
async with org_connection(organization_id) as conn:
team_rows = await conn.fetch(
"SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1 ORDER BY created_at",
oom_id,
)
member_rows = await conn.fetch(
"""
SELECT m.id::text, m.order_of_merit_team_id::text AS team_id,
m.player_id::text AS player_id, p.display_name AS player_name
FROM order_of_merit_team_member m
JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
JOIN player p ON p.id = m.player_id
WHERE t.order_of_merit_id = $1
ORDER BY p.display_name
""",
oom_id,
)
members_by_team: dict[str, list[OrderOfMeritTeamMemberOut]] = {}
for m in member_rows:
members_by_team.setdefault(m["team_id"], []).append(
OrderOfMeritTeamMemberOut(id=m["id"], player_id=m["player_id"], player_name=m["player_name"])
)
return [
OrderOfMeritTeamOut(id=t["id"], name=t["name"], members=members_by_team.get(t["id"], []))
for t in team_rows
]
@router.post(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams",
response_model=OrderOfMeritTeamOut,
status_code=201,
)
async def create_order_of_merit_team(
oom_id: str,
body: OrderOfMeritTeamCreate,
organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritTeamOut:
async with org_connection(organization_id) as conn, translate_db_errors():
await _require_team_kind(conn, oom_id)
row = await conn.fetchrow(
"""
INSERT INTO order_of_merit_team (organization_id, order_of_merit_id, name)
VALUES ($1, $2, $3)
RETURNING id::text, name
""",
organization_id, oom_id, body.name,
)
return OrderOfMeritTeamOut(id=row["id"], name=row["name"], members=[])
@router.delete(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}",
status_code=204,
)
async def delete_order_of_merit_team(
oom_id: str,
team_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
async with org_connection(organization_id) as conn:
result = await conn.execute(
"DELETE FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
)
if result == "DELETE 0":
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
@router.post(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members",
response_model=OrderOfMeritTeamMemberOut,
status_code=201,
)
async def add_order_of_merit_team_member(
oom_id: str,
team_id: str,
body: OrderOfMeritTeamMemberCreate,
organization_id: str = Depends(get_authorized_org),
) -> OrderOfMeritTeamMemberOut:
async with org_connection(organization_id) as conn, translate_db_errors():
team = await conn.fetchval(
"SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
)
if team is None:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
# player.id er IKKE org-scopet i sin FK (migrasjon 055) -- egen
# eksplisitt sjekk her, samme forsiktighet som andre steder i
# appen som tar imot en player_id fra klienten.
player = await conn.fetchrow("SELECT display_name FROM player WHERE id = $1", body.player_id)
if player is None:
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
row = await conn.fetchrow(
"""
INSERT INTO order_of_merit_team_member (organization_id, order_of_merit_team_id, player_id)
VALUES ($1, $2, $3)
RETURNING id::text
""",
organization_id, team_id, body.player_id,
)
return OrderOfMeritTeamMemberOut(id=row["id"], player_id=body.player_id, player_name=player["display_name"])
@router.delete(
"/orgs/{organization_id}/order-of-merits/{oom_id}/teams/{team_id}/members/{member_id}",
status_code=204,
)
async def remove_order_of_merit_team_member(
oom_id: str,
team_id: str,
member_id: str,
organization_id: str = Depends(get_authorized_org),
) -> None:
async with org_connection(organization_id) as conn:
team = await conn.fetchval(
"SELECT id FROM order_of_merit_team WHERE id = $1 AND order_of_merit_id = $2", team_id, oom_id
)
if team is None:
raise app_error(404, "NOT_FOUND", "Laget finnes ikke.")
result = await conn.execute(
"DELETE FROM order_of_merit_team_member WHERE id = $1 AND order_of_merit_team_id = $2",
member_id, team_id,
)
if result == "DELETE 0":
raise app_error(404, "NOT_FOUND", "Medlemmet finnes ikke.")
# ---------------------------------------------------------------------------
# Eclectic-aggregering på tvers av lenkede turneringer (ADR-075) -- sesong-
# "drømmerunde": beste resultat PER HULL på tvers av ALLE en spillers
# lenkede turneringer (bekreftet samme bane av _validate_same_course_for_
# eclectic ved lenking/modus-endring). IKKE det samme som ADR-068 sitt
# Eclectic-format (scoping til RUNDENE innenfor ÉN turnering) -- samme
# motorfunksjon (`eclectic_best_per_hole`) gjenbrukes UENDRET, men
# datainnsamlingen her spenner over FLERE turneringers `tournament_round_
# hole`, ikke bare én. Nøkkelen er `player_id` (samme spiller har ulik
# `tournament_participant_id` per turnering), ikke `tournament_
# participant_id` slik innad-i-én-turnering-varianten (`_attach_eclectic_
# totals` i individual_tournaments.py) bruker.
# ---------------------------------------------------------------------------
async def _compute_eclectic_oom_values(
conn, tournament_ids: list[str], result_type: Literal["gross", "net", "stableford"]
) -> tuple[dict[str, float], dict[str, str], dict[str, int]]:
"""Returnerer (totaler, navn, antall-kilde-turneringer) per spiller.
Eclectic-aggregering ER selve tvers-av-turneringer-sammenslåingen --
det finnes ingen ekstra "sum/snitt av N eclectic-totaler" ovenpå
(`order_of_merit_aggregate` er derfor bevisst IKKE brukt her, se
kalleren)."""
if not tournament_ids:
return {}, {}, {}
course_id = None
for tid in tournament_ids:
course_id = await _course_id_for_tournament(conn, tid)
if course_id is not None:
break
if course_id is None:
return {}, {}, {}
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id
)
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
si_by_hole = [r["stroke_index"] for r in hole_rows]
rows = await conn.fetch(
"""
SELECT tr.tournament_id::text AS tournament_id, tr.sequence,
tp.player_id::text AS player_id, p.display_name AS player_name,
trp.course_handicap, trh.hole_number, trh.gross_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
WHERE tr.tournament_id = ANY($1::uuid[]) AND trh.gross_strokes IS NOT NULL
ORDER BY tr.tournament_id, tr.sequence
""",
tournament_ids,
)
# Sammensatt kilde-indeks: (turnering-id, sekvens) -> global indeks,
# siden `sequence` kun er unik INNENFOR én turnering (speiler
# _attach_eclectic_totals sin sequence_by_index, men på tvers av flere
# turneringer i stedet for kun én).
index_by_source: dict[tuple[str, int], int] = {}
source_by_index: dict[int, tuple[str, int]] = {}
for r in rows:
key = (r["tournament_id"], r["sequence"])
if key not in index_by_source:
idx = len(index_by_source)
index_by_source[key] = idx
source_by_index[idx] = key
allocation_cache: dict[int, list[int]] = {}
values_by_player: dict[str, list[list[EclecticHoleValue]]] = {}
names_by_player: dict[str, str] = {}
for r in rows:
if r["course_handicap"] is None:
continue
pid = r["player_id"]
names_by_player[pid] = r["player_name"]
if pid not in values_by_player:
values_by_player[pid] = [[] for _ in range(18)]
strokes_received = 0
if result_type != "gross":
hcp = r["course_handicap"]
if hcp not in allocation_cache:
allocation_cache[hcp] = allocate_strokes_by_index(hcp, si_by_hole)
strokes_received = allocation_cache[hcp][r["hole_number"] - 1]
if result_type == "gross":
value = r["gross_strokes"]
elif result_type == "net":
value = r["gross_strokes"] - strokes_received
else:
value = stableford_points_for_hole(par_by_hole[r["hole_number"]], r["gross_strokes"], strokes_received)
source_index = index_by_source[(r["tournament_id"], r["sequence"])]
values_by_player[pid][r["hole_number"] - 1].append(
EclecticHoleValue(round_index=source_index, value=value)
)
totals_by_player: dict[str, float] = {}
tournament_count_by_player: dict[str, int] = {}
for pid, values_by_hole in values_by_player.items():
result = eclectic_best_per_hole(values_by_hole, result_type)
if not result.holes:
continue
totals_by_player[pid] = float(result.total)
tournament_count_by_player[pid] = len({source_by_index[h.round_index][0] for h in result.holes})
return totals_by_player, names_by_player, tournament_count_by_player
# ---------------------------------------------------------------------------
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
# som Nassau/High-low-high/individual_leaderboard selv).
@ -695,38 +353,6 @@ def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
return 0.0
def _player_eligibility(results_available: int, oom, birth_year: int | None) -> tuple[bool, str | None]:
"""Delt mellom spiller- og lag-leaderboardet (et lags medlem er
eligibility-sjekket EKSAKT som en spiller ville vært alene), OG mellom
sum/snitt (`results_available = len(raw_values)`) og eclectic
(`results_available` = antall KILDE-turneringer som faktisk bidro et
hull til sesong-drømmerunden, se _compute_eclectic_oom_values) --
tallet betyr det samme ("hvor mange turneringer talte") uansett hvilken
modus som produserte det."""
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
if birth_year is None:
return False, "Ingen registrert fødselsdato."
if oom["birth_year_from"] is not None and birth_year < oom["birth_year_from"]:
return False, "Utenfor aldersgrensen."
if oom["birth_year_to"] is not None and birth_year > oom["birth_year_to"]:
return False, "Utenfor aldersgrensen."
if oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
return False, f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
return True, None
def _aggregate_value(raw_values: list[float], oom, lower_is_better: bool) -> float | None:
"""Negerer FØR order_of_merit_aggregate (som alltid regner «størst er
best») og negerer tilbake etterpå for brutto/netto -- delt av spiller-
OG lag-nivået. Trygt å kalle EN GANG TIL lag-nivå med allerede
korrekt-signerte medlemsverdier: samme frem-og-tilbake-negering gir
riktig resultat begge ganger (ADR-074 Beslutning: ett lagret
aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig)."""
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
return -aggregated if lower_is_better and aggregated is not None else aggregated
@router.get(
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
response_model=list[OrderOfMeritLeaderboardEntry],
@ -746,10 +372,13 @@ async def order_of_merit_leaderboard(
)
if oom is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
if oom["kind"] != "player":
raise app_error(400, "VALIDATION_FAILED", "Lag-OOM-leaderboard er ikke bygget ennå.")
if oom["aggregation_mode"] == "eclectic":
raise app_error(400, "VALIDATION_FAILED", "Eclectic-aggregering er ikke bygget ennå.")
result_type = oom["result_type"]
lower_is_better = result_type in ("gross", "net")
is_eclectic = oom["aggregation_mode"] == "eclectic"
links = await conn.fetch(
"""
@ -761,40 +390,19 @@ async def order_of_merit_leaderboard(
)
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
# (eclectic-modus: nøyaktig ÉN verdi per spiller -- sesongens
# ferdig-sammenslåtte drømmerunde-total, se _compute_eclectic_oom_
# values. "results_available" betyr da antall KILDE-turneringer
# som bidro et hull, ikke antall verdier i denne listen.)
values_by_player: dict[str, list[float]] = {}
names_by_player: dict[str, str] = {}
results_available_by_player: dict[str, int] = {}
if is_eclectic:
# ADR-075: krever result_type IN (stableford,gross,net) og
# kind='player', begge håndhevet ved opprettelse/PATCH --
# ingen ekstra sjekk nødvendig her.
tournament_ids = [link["tournament_id"] for link in links]
totals, names_by_player, tournament_counts = await _compute_eclectic_oom_values(
conn, tournament_ids, result_type
)
for player_id, total in totals.items():
values_by_player[player_id] = [total]
results_available_by_player[player_id] = tournament_counts[player_id]
else:
for link in links:
tournament_row, entries = await _compute_individual_standings(conn, link["tournament_id"])
points_table = _decode_json(link["points_table"])
money_payout_table = _decode_json(link["money_payout_table"])
for e in entries:
contribution = _contribution_for_entry(
e, result_type, points_table, money_payout_table, tournament_row["scoring_method"]
)
if contribution is None:
continue
values_by_player.setdefault(e.player_id, []).append(contribution)
names_by_player[e.player_id] = e.player_name
for player_id, raw_values in values_by_player.items():
results_available_by_player[player_id] = len(raw_values)
for link in links:
_, entries = await _compute_individual_standings(conn, link["tournament_id"])
points_table = _decode_json(link["points_table"])
money_payout_table = _decode_json(link["money_payout_table"])
for e in entries:
contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table)
if contribution is None:
continue
values_by_player.setdefault(e.player_id, []).append(contribution)
names_by_player[e.player_id] = e.player_name
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
@ -810,107 +418,44 @@ async def order_of_merit_leaderboard(
birth_years = {r["id"]: r["birth_year"] for r in rows}
results: list[OrderOfMeritLeaderboardEntry] = []
for player_id, raw_values in values_by_player.items():
results_available = len(raw_values)
eligible = True
ineligible_reason = None
if oom["kind"] == "player":
for player_id, raw_values in values_by_player.items():
results_available = results_available_by_player[player_id]
eligible, ineligible_reason = _player_eligibility(results_available, oom, birth_years.get(player_id))
# Eclectic: raw_values er allerede ÉN ferdig-sammenslått
# sesongtotal (se _compute_eclectic_oom_values) -- ingen
# order_of_merit_aggregate-kall (som uansett kun kjenner
# sum/snitt, ikke eclectic, se motoren), og count_best_n
# gir ingen mening ("beste N av ÉN verdi").
if is_eclectic:
value = raw_values[0]
counted = results_available
else:
value = _aggregate_value(raw_values, oom, lower_is_better)
counted = (
min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
)
if oom["birth_year_from"] is not None or oom["birth_year_to"] is not None:
by = birth_years.get(player_id)
if by is None:
eligible = False
ineligible_reason = "Ingen registrert fødselsdato."
elif oom["birth_year_from"] is not None and by < oom["birth_year_from"]:
eligible = False
ineligible_reason = "Utenfor aldersgrensen."
elif oom["birth_year_to"] is not None and by > oom["birth_year_to"]:
eligible = False
ineligible_reason = "Utenfor aldersgrensen."
results.append(
OrderOfMeritLeaderboardEntry(
player_id=player_id,
player_name=names_by_player[player_id],
results_counted=counted,
results_available=results_available,
value=value,
position=None,
eligible=eligible,
ineligible_reason=ineligible_reason,
)
if eligible and oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
eligible = False
ineligible_reason = f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
value = (-aggregated if lower_is_better and aggregated is not None else aggregated)
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
results.append(
OrderOfMeritLeaderboardEntry(
player_id=player_id,
player_name=names_by_player[player_id],
results_counted=counted,
results_available=results_available,
value=value,
position=None,
eligible=eligible,
ineligible_reason=ineligible_reason,
)
else:
# Lag-OOM (ADR-043 Beslutning C, ADR-074): summerer/velger-
# beste-N-av medlemmenes allerede beregnede individuelle OOM-
# resultater -- IKKE strengmatching mot turnering-lag.
# `player_id`/`player_name` på OrderOfMeritLeaderboardEntry
# bærer her lagets EGEN id/navn (samme uttrykk, uendret
# skjema -- kalleren vet allerede OOM-ens `kind`).
team_rows = await conn.fetch(
"SELECT id::text, name FROM order_of_merit_team WHERE order_of_merit_id = $1", oom_id
)
member_rows = await conn.fetch(
"""
SELECT m.order_of_merit_team_id::text AS team_id, m.player_id::text AS player_id
FROM order_of_merit_team_member m
JOIN order_of_merit_team t ON t.id = m.order_of_merit_team_id
WHERE t.order_of_merit_id = $1
""",
oom_id,
)
member_ids_by_team: dict[str, list[str]] = {}
for m in member_rows:
member_ids_by_team.setdefault(m["team_id"], []).append(m["player_id"])
for team in team_rows:
member_ids = member_ids_by_team.get(team["id"], [])
member_values: list[float] = []
for pid in member_ids:
raw_values = values_by_player.get(pid)
if not raw_values:
continue
member_eligible, _ = _player_eligibility(
results_available_by_player[pid], oom, birth_years.get(pid)
)
if not member_eligible:
continue
member_value = _aggregate_value(raw_values, oom, lower_is_better)
if member_value is not None:
member_values.append(member_value)
eligible_members = len(member_values)
team_eligible = eligible_members > 0
team_reason = None if team_eligible else "Ingen av lagets medlemmer har tellende resultater."
if (
team_eligible
and oom["min_results_required"] is not None
and eligible_members < oom["min_results_required"]
):
team_eligible = False
team_reason = (
f"Trenger minst {oom['min_results_required']} medlemmer med tellende resultater, "
f"har {eligible_members}."
)
value = _aggregate_value(member_values, oom, lower_is_better) if team_eligible else None
counted = (
min(oom["count_best_n"], eligible_members) if oom["count_best_n"] is not None else eligible_members
)
results.append(
OrderOfMeritLeaderboardEntry(
player_id=team["id"],
player_name=team["name"],
results_counted=counted,
results_available=len(member_ids),
value=value,
position=None,
eligible=team_eligible,
ineligible_reason=team_reason,
)
)
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
@ -938,12 +483,7 @@ def _decode_json(value):
return json.loads(value) if value else None
_ECLECTIC_METHODS = ("eclectic_gross", "eclectic_net", "eclectic_stableford")
def _contribution_for_entry(
entry, result_type: str, points_table, money_payout_table, scoring_method: str | None = None
) -> float | None:
def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | None:
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
@ -955,23 +495,13 @@ def _contribution_for_entry(
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt.
Bug fikset 2026-08-15 (ADR-074): for en lenket turnering med
scoring_method IN eclectic_gross/net/stableford, skal
stableford/gross/net-resultattype-OOM-er lese `entry.eclectic_total`
(turneringens FAKTISKE "drømmerunde"-resultat, det som avgjorde DEN
turneringens egen vinner) -- IKKE `entry.stableford_total`/
`gross_total`/`net_total` ( sum tvers av rundene, et tall som
aldri var det som ble kåret som resultat i den turneringen)."""
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt."""
if entry.rounds_played == 0:
return None
if result_type == "points":
return float(order_of_merit_points_for_position(entry.position, points_table)) if entry.position and points_table else 0.0
if result_type == "money":
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
if scoring_method in _ECLECTIC_METHODS and result_type in ("stableford", "gross", "net"):
return float(entry.eclectic_total) if entry.eclectic_total is not None else 0.0
if result_type == "stableford":
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
if result_type == "gross":
@ -979,50 +509,3 @@ def _contribution_for_entry(
if result_type == "net":
return float(entry.net_total) if entry.net_total is not None else 0.0
return 0.0
# ---------------------------------------------------------------------------
# Offentlig/delt visning (ADR-076, migrasjon 076) -- uautentisert, samme
# SECURITY DEFINER-bro-mønster som public_org_by_slug/public_tournament_
# by_code (registration.py): NULL for BÅDE "finnes ikke" og "finnes, men
# er ikke offentlig" -- anti-enumerering. Egen APIRouter i DENNE filen
# (ikke registration.py) fordi den gjenbruker order_of_merit_leaderboard
# direkte -- unngår å duplisere hele beregningen for en uautentisert
# variant, samme triks som testene i test_order_of_merit.py (kaller
# router-funksjonen med organization_id som et vanlig argument, utenom
# FastAPI sin Depends-injeksjon).
# ---------------------------------------------------------------------------
public_router = APIRouter(prefix="/public/order-of-merits", tags=["public-order-of-merit"])
class PublicOrderOfMeritOut(BaseModel):
name: str
kind: str
result_type: str
aggregation_mode: str
entries: list[OrderOfMeritLeaderboardEntry]
@public_router.get("/{oom_id}", response_model=PublicOrderOfMeritOut)
async def get_public_order_of_merit(oom_id: str) -> PublicOrderOfMeritOut:
async with plain_connection() as conn:
organization_id = await conn.fetchval("SELECT public_order_of_merit_by_id($1)", oom_id)
if organization_id is None:
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
organization_id = str(organization_id)
async with org_connection(organization_id) as conn:
oom = await conn.fetchrow(
"SELECT name, kind, result_type, aggregation_mode FROM order_of_merit WHERE id = $1",
oom_id,
)
entries = await order_of_merit_leaderboard(oom_id, organization_id=organization_id)
return PublicOrderOfMeritOut(
name=oom["name"],
kind=oom["kind"],
result_type=oom["result_type"],
aggregation_mode=oom["aggregation_mode"],
entries=entries,
)

View file

@ -34,14 +34,6 @@ class PlayerCreate(BaseModel):
country: str | None = Field(default=None, max_length=100)
club: str | None = Field(default=None, max_length=200)
club_member_number: str | None = Field(default=None, max_length=50)
# Utvidet registreringsskjema (2026-08-14, migrasjon 074) -- for-/
# etternavn er en VALGFRI, separat kilde ved siden av display_name
# (som fortsatt er det som faktisk vises overalt ellers, uendret) --
# se migrasjonens header. paid/comment er ren registreringsadministrasjon.
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
paid: bool = False
comment: str | None = Field(default=None, max_length=2000)
class Player(BaseModel):
@ -56,16 +48,11 @@ class Player(BaseModel):
country: str | None
club: str | None
club_member_number: str | None
first_name: str | None
last_name: str | None
paid: bool
comment: str | None
_PLAYER_COLUMNS = """
id::text, display_name, handicap_index::float AS handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number,
first_name, last_name, paid, comment
mobile, email, birth_date, nickname, country, club, club_member_number
"""
@ -90,9 +77,8 @@ async def create_player(
f"""
INSERT INTO player
(organization_id, display_name, handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number,
first_name, last_name, paid, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
mobile, email, birth_date, nickname, country, club, club_member_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING {_PLAYER_COLUMNS}
""",
organization_id,
@ -106,171 +92,10 @@ async def create_player(
body.country,
body.club,
body.club_member_number,
body.first_name,
body.last_name,
body.paid,
body.comment,
)
return Player(**dict(row))
class PlayerBulkResult(BaseModel):
player: Player
status: str # "created" | "updated" | "overwritten"
class PlayerBulkRow(BaseModel):
"""Én rad i en masseimport. `data` er selve spillerfeltene (uendret
`PlayerCreate`). `match_player_id`/`duplicate_action` er NYE
(2026-08-18) -- se docstringen `create_players_bulk` for hvorfor."""
data: PlayerCreate
# Satt av frontend NÅR organisatoren har blitt bedt om, og svart på, et
# "dette ser ut som en duplikat"-varsel for denne raden (ingen e-post på
# raden, men navnet traff en eksisterende spiller i poolen) -- IKKE satt
# for rader uten et slikt varsel, som fortsatt går via den opprinnelige
# e-post-matchingen under.
match_player_id: str | None = None
duplicate_action: str | None = Field(default=None, pattern="^(merge|overwrite|create_new)$")
@router.post("/orgs/{organization_id}/players/bulk", response_model=list[PlayerBulkResult], status_code=201)
async def create_players_bulk(
body: list[PlayerBulkRow],
organization_id: str = Depends(get_authorized_org),
) -> list[PlayerBulkResult]:
"""Massimport (CSV-basert, se FEATURE_BACKLOG.md). Samme "match på
e-post, fyll KUN tomme felt, ellers opprett ny"-mønster som
`registration.py` sin selvregistrering (ADR-017 Beslutning B) -- her
utvidet til ALLE `PlayerCreate`-felt (registration.py sin variant
mangler first_name/last_name/paid/comment, som ikke fantes da den ble
skrevet). `display_name` og `paid` røres ALDRI en eksisterende
match via COALESCE-stien -- førstnevnte er organisators kanoniske navn,
sistnevnte er en ikke-nullbar boolean (COALESCE mot NULL er da alltid
en no-op, som er riktig oppførsel: en import skal ikke stille kunne
endre en allerede satt betalt-status). Hele lista i én transaksjon
(org_connection sin egen) -- ingen delvis-suksess-håndtering, brukeren
har allerede validert/rettet radene i tabellen før "Lagre" trykkes.
`match_player_id`/`duplicate_action` (2026-08-18) -- ekte bug funnet i
produksjon: rader UTEN e-post har ingen matchingsnøkkel i det hele
tatt, reimport av samme CSV la dem inn nytt hver gang i stedet
for å oppdatere (ni spillere dublert i org 8db22cb9 ved reimport).
Fikset ved å la FRONTEND oppdage mulige duplikater (ingen e-post +
navnetreff mot en eksisterende spiller i poolen) og SPØRRE organisator
eksplisitt om hva som skal skje -- IKKE en stille automatisk
sammenslåing navn alene (for risikabelt: to ulike ekte personer kan
dele navn). Svaret sendes tilbake her som `match_player_id` (hvilken
eksisterende spiller raden gjelder) + `duplicate_action`:
- "merge": samme COALESCE-oppførsel som e-post-matching allerede har
(fyll kun tomme felt den eksisterende raden).
- "overwrite": erstatt ALLE feltene den eksisterende raden med
verdiene fra denne importraden, også der den eksisterende raden
allerede hadde en verdi -- eneste sted i denne funksjonen som gjør
det, og KUN når organisator eksplisitt har bedt om det for akkurat
denne raden.
- "create_new" (eller `match_player_id` ikke satt i det hele tatt):
uendret -- opprett en ny rad, som før.
Rader MED e-post er upåvirket -- e-post-matching er entydig nok til at
den fortsatt skjer automatisk, ingen spørring nødvendig der."""
results: list[PlayerBulkResult] = []
async with org_connection(organization_id) as conn, translate_db_errors():
for item in body:
p = item.data
existing = None
if p.email:
existing = await conn.fetchrow(
"SELECT id FROM player WHERE lower(email) = lower($1)", p.email
)
elif item.match_player_id and item.duplicate_action in ("merge", "overwrite"):
existing = {"id": item.match_player_id}
if existing is not None and item.duplicate_action == "overwrite":
row = await conn.fetchrow(
f"""
UPDATE player SET
handicap_index = $2, gender = $3, mobile = $4, birth_date = $5,
nickname = $6, country = $7, club = $8, club_member_number = $9,
first_name = $10, last_name = $11, comment = $12
WHERE id = $1
RETURNING {_PLAYER_COLUMNS}
""",
existing["id"],
p.handicap_index,
p.gender,
p.mobile,
p.birth_date,
p.nickname,
p.country,
p.club,
p.club_member_number,
p.first_name,
p.last_name,
p.comment,
)
results.append(PlayerBulkResult(player=Player(**dict(row)), status="overwritten"))
elif existing is not None:
row = await conn.fetchrow(
f"""
UPDATE player SET
handicap_index = COALESCE(handicap_index, $2),
gender = COALESCE(gender, $3),
mobile = COALESCE(mobile, $4),
birth_date = COALESCE(birth_date, $5),
nickname = COALESCE(nickname, $6),
country = COALESCE(country, $7),
club = COALESCE(club, $8),
club_member_number = COALESCE(club_member_number, $9),
first_name = COALESCE(first_name, $10),
last_name = COALESCE(last_name, $11),
comment = COALESCE(comment, $12)
WHERE id = $1
RETURNING {_PLAYER_COLUMNS}
""",
existing["id"],
p.handicap_index,
p.gender,
p.mobile,
p.birth_date,
p.nickname,
p.country,
p.club,
p.club_member_number,
p.first_name,
p.last_name,
p.comment,
)
results.append(PlayerBulkResult(player=Player(**dict(row)), status="updated"))
else:
row = await conn.fetchrow(
f"""
INSERT INTO player
(organization_id, display_name, handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number,
first_name, last_name, paid, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING {_PLAYER_COLUMNS}
""",
organization_id,
p.display_name,
p.handicap_index,
p.gender,
p.mobile,
p.email.lower() if p.email else None,
p.birth_date,
p.nickname,
p.country,
p.club,
p.club_member_number,
p.first_name,
p.last_name,
p.paid,
p.comment,
)
results.append(PlayerBulkResult(player=Player(**dict(row)), status="created"))
return results
class PlayerUpdate(BaseModel):
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).
@ -293,10 +118,6 @@ class PlayerUpdate(BaseModel):
country: str | None = Field(default=None, max_length=100)
club: str | None = Field(default=None, max_length=200)
club_member_number: str | None = Field(default=None, max_length=50)
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
paid: bool | None = None
comment: str | None = Field(default=None, max_length=2000)
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)

View file

@ -1,152 +0,0 @@
"""
Server-generert PDF for utskriftsklare turneringsdokumenter (scorekort,
startliste, cart-tags, resultatliste). Se ARCHITECTURE_DECISIONS.md ADR-097.
Rendrer den EKTE Next.js-utskriftssiden i en headless Chromium (Playwright)
i stedet for å duplisere layouten i en egen server-side malmotor -- én kilde
til sannhet for visuell layout (samme React-komponent brukeren allerede ser
i forhåndsvisningen). Siden selv setter @page-CSS-regelen ut fra valgt
papirstørrelse/retning (styrt av URL-parametrene denne ruten konstruerer);
Playwright sin prefer_css_page_size=True bruker den regelen direkte -- ingen
egen mm-utregning her, dupliseres ikke.
Sesjonscookien fra DENNE forespørselen videreføres til Playwright sin
nettleserkontekst før navigering, slik at siden gjør sine egne fetch()-kall
til API-et akkurat som om en ekte innlogget nettleser lastet den -- ingen
egen auth-vei for renderingen.
`path` konstrueres ALDRI fra klient-input direkte (SSRF-hensyn) -- kun
`kind` + verifiserte IDer, malen for hver `kind` er hardkodet her.
"""
from typing import Literal
from fastapi import APIRouter, Depends, Request, Response
from playwright.async_api import async_playwright
from pydantic import BaseModel
from ..auth import SESSION_COOKIE_NAME, get_authorized_org
from ..db import org_connection
from ..errors import app_error
router = APIRouter()
# Samme Docker-nettverk som teecup_api (docker-compose.yml) -- internt
# tjenestenavn, aldri en offentlig URL.
_FRONTEND_ORIGIN = "http://teecup_frontend:3000"
_FRONTEND_HOST = "teecup_frontend"
PrintKind = Literal["scorecard", "startlist", "cart-tags", "resultlist"]
Paper = Literal["A4", "Letter", "A5", "A3", "A2"]
Orientation = Literal["portrait", "landscape"]
# resultlist er turneringsomfattende (leaderboard-endepunktene), trenger
# verken runde eller økt. De tre andre trenger ETT av (runde ELLER økt) --
# hvilket avhenger av om turneringen er individuell eller Cup-format, ikke
# noe denne ruten selv vet -- frontend sender riktig ID.
_SCOPED_KINDS: set[PrintKind] = {"scorecard", "startlist", "cart-tags"}
class PrintPdfRequest(BaseModel):
kind: PrintKind
tournament_id: str
round_id: str | None = None
session_id: str | None = None
paper: Paper = "A4"
orientation: Orientation = "portrait"
async def _verify_scope(conn, tournament_id: str, round_id: str | None, session_id: str | None) -> None:
# org_connection har allerede satt app.current_org -- RLS gjør at disse
# EXISTS-sjekkene stille returnerer usann for en rad i en ANNEN
# organisasjon, selv om raden faktisk finnes. Samme mønster som resten
# av API-et stoler på (ADR-001/003), ikke en egen sjekk her.
tournament_exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM tournament WHERE id = $1)", tournament_id
)
if not tournament_exists:
raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne IDen i organisasjonen.")
if round_id is not None:
exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM tournament_round WHERE id = $1 AND tournament_id = $2)",
round_id,
tournament_id,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Fant ingen runde med denne IDen i turneringen.")
if session_id is not None:
exists = await conn.fetchval(
'SELECT EXISTS (SELECT 1 FROM session WHERE id = $1 AND tournament_id = $2)',
session_id,
tournament_id,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Fant ingen økt med denne IDen i turneringen.")
def _build_path(organization_id: str, body: PrintPdfRequest) -> str:
params = [f"org={organization_id}", f"paper={body.paper}", f"orientation={body.orientation}"]
if body.round_id:
params.append(f"roundId={body.round_id}")
if body.session_id:
params.append(f"sessionId={body.session_id}")
return f"/tournaments/{body.tournament_id}/print/{body.kind}?{'&'.join(params)}"
@router.post("/orgs/{organization_id}/print/pdf")
async def render_print_pdf(
body: PrintPdfRequest,
request: Request,
organization_id: str = Depends(get_authorized_org),
) -> Response:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
if body.kind in _SCOPED_KINDS and not body.round_id and not body.session_id:
raise app_error(400, "MISSING_SCOPE", "Mangler runde- eller økt-ID for denne utskriftstypen.")
async with org_connection(organization_id) as conn:
await _verify_scope(conn, body.tournament_id, body.round_id, body.session_id)
url = f"{_FRONTEND_ORIGIN}{_build_path(organization_id, body)}"
async with async_playwright() as pw:
browser = await pw.chromium.launch()
try:
context = await browser.new_context()
await context.add_cookies(
[
{
"name": SESSION_COOKIE_NAME,
"value": token,
"domain": _FRONTEND_HOST,
"path": "/",
"httpOnly": True,
"secure": False,
}
]
)
page = await context.new_page()
await page.goto(url, wait_until="networkidle")
try:
# Hver utskriftsside setter dette attributtet på <body> først
# NÅR all data er hentet OG lagt inn i DOM-en -- printer vi
# før det, får vi enten "Laster..."-tilstanden eller mock-
# dataene siden startet med, ikke ekte innhold.
await page.wait_for_selector("[data-print-ready='true']", timeout=15000)
except Exception:
raise app_error(
504,
"PRINT_RENDER_TIMEOUT",
"Utskriftssiden ble ikke klar i tide. Prøv igjen.",
)
pdf_bytes = await page.pdf(print_background=True, prefer_css_page_size=True)
finally:
await browser.close()
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="teecup-{body.kind}.pdf"'},
)

View file

@ -318,12 +318,6 @@ async def post_round_message(
# siden dette endepunktet er multipart (bilde-opplasting), ikke en ren
# JSON-body som CommentIn under kan bruke direkte.
tags: str | None = Form(default=None),
# Idempotens for offline-kø-synk (ADR-028-tillegget, migrasjon 073,
# 2026-08-14) -- klient-generert (crypto.randomUUID()), valgfri for
# bakoverkompatible kallere. Et gjentatt kall med SAMME id (f.eks. en
# avbrutt synk som prøves på nytt) returnerer den allerede opprettede
# meldingen fremfor å opprette en duplikat -- se migrasjonens header.
client_message_id: str | None = Form(default=None),
user: CurrentUser = Depends(get_current_user),
) -> RoundMessageOut:
if not body and image is None:
@ -339,20 +333,6 @@ async def post_round_message(
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
await _get_viewable_round_or_404(conn, round_id, user.user_id)
if client_message_id is not None:
existing = await conn.fetchrow(
f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 AND client_message_id = $2",
round_id, client_message_id,
)
if existing is not None:
reactions = await _round_message_reactions(conn, [existing["id"]], user.user_id)
comment_counts = await _round_message_comment_counts(conn, [existing["id"]])
tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", [existing["id"]])
return _row_to_round_message(
existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0),
tags_by_id.get(existing["id"]),
)
image_key = None
if raw is not None:
try:
@ -365,9 +345,8 @@ async def post_round_message(
row = await conn.fetchrow(
f"""
INSERT INTO round_message
(round_id, author_user_id, author_display_name, body, image_key, client_message_id)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
VALUES ($1, $2, $3, $4, $5)
RETURNING {_ROUND_MESSAGE_COLUMNS}
""",
round_id,
@ -375,7 +354,6 @@ async def post_round_message(
display_name,
body,
image_key,
client_message_id,
)
await _insert_tags_and_notify(
conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"],

File diff suppressed because it is too large Load diff

View file

@ -60,8 +60,6 @@ from ..db import org_connection
from ..errors import app_error, translate_db_errors
from ..handicap import parse_allowance_config, relative_strokes_for_match
from ..realtime import broadcast_live_update
from ..hole_history import resolve_match_course_key
from ..target_points import TargetPoint, get_target_points
from ..team_authz import (
is_org_member,
user_is_match_participant,
@ -854,39 +852,6 @@ async def get_scorecard(
return await fetch_scorecard(conn, match_id)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/holes/{hole_number}/target-points",
response_model=list[TargetPoint],
)
async def get_match_hole_target_points(
match_id: str,
hole_number: int,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> list[TargetPoint]:
"""Rangefinder-data for hullet (migrasjon 079) -- samme kilde/prinsipp
som frittstående runders og individuell-turnerings target-points, her
for lag-matchplay (Ryder Cup-format). Samme tilgangsnivå/-mønster som
`get_scorecard` over (org-medlem ELLER deltaker i turneringen)."""
async with org_connection(organization_id) as conn:
tournament_id = await conn.fetchval(
"""
SELECT s.tournament_id::text FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if tournament_id is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, tournament_id, user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
course_key = await resolve_match_course_key(conn, match_id)
points = await get_target_points(conn, course_key, hole_number)
return points
# ---------------------------------------------------------------------------
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-

View file

@ -26,7 +26,7 @@ from ..email import send_session_result_email
from ..errors import app_error, translate_db_errors
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
from .auth import _hash_secret
from ..team_authz import is_org_admin, is_org_member, user_is_team_captain, user_is_tournament_participant
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
from .. import storage
@ -47,9 +47,7 @@ class TournamentCreate(BaseModel):
# for individual, håndhevet i app-laget når runder/score bygges på den.
format_type: str = Field(default="team", pattern="^(team|individual)$")
scoring_method: str | None = Field(
default=None,
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
"eclectic_gross|eclectic_net|eclectic_stableford)$",
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
)
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
# organisator-innstilling (samme som frittstående runders round.
@ -61,9 +59,7 @@ _TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval, hero_image_key,
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible,
cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method,
tiebreak_playoff_rule
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled
"""
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
@ -92,32 +88,6 @@ class Tournament(BaseModel):
format_type: str
scoring_method: str | None
bbb_sweep_bonus_enabled: bool
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: bool
# Cut (migrasjon 083, 2026-08-18) -- KUN meningsfullt for individuelle
# stroke play/net/stableford-turneringer, se app/routers/
# individual_tournaments.py sin apply_cut. cut_applied_at er null inntil
# organisator faktisk trykker "Anvend cut" -- cut_after_round/cut_size
# alene er bare KONFIGURASJON, ikke en aktiv cut.
cut_after_round: int | None
cut_size: int | None
cut_applied_at: datetime | None
# Tiebreak (migrasjon 085, 2026-08-18) -- KUN meningsfullt for
# individuelle stroke play/net/stableford-turneringer, se
# individual_tournaments.py sin _attach_stroke_play_columns. Påvirker
# ALDRI Cut-grensen (ADR-087/088) -- kun sluttresultatets
# plasseringsvisning. 'none' (default) = delt plassering, uendret
# oppførsel. To UAVHENGIGE innstillinger -- vinneren og resten av
# feltet kan ha ulik metode.
tiebreak_winner_method: str
tiebreak_field_method: str
# Omspill (migrasjon 086, 2026-08-18) -- 'playoff' er KUN gyldig for
# tiebreak_winner_method (ikke felt-metoden), se individual_
# tournaments.py sin kommentar for hvorfor rangeringen behandler den
# likt som 'none'. tiebreak_playoff_rule er fritekst organisator
# setter (f.eks. "Sudden death fra hull 18") -- vist proaktivt i
# Oppsett OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
tiebreak_playoff_rule: str | None
def _tournament_from_row(row) -> Tournament:
@ -149,9 +119,7 @@ async def list_tournaments(
t.visibility, t.description, t.registration_deadline,
t.registration_capacity, t.registration_overflow_policy,
t.registration_requires_approval, t.hero_image_key,
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at,
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled
FROM tournament t
LEFT JOIN (
SELECT tournament_id,
@ -209,23 +177,11 @@ class TournamentUpdate(BaseModel):
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
(PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""
# name/start_date/end_date (2026-08-18) -- kolonnene har eksistert
# siden 001/006, men INGEN UI har noensinne hatt en vei til å SETTE
# dem (ADR-030 sin kommentar i list_tournaments dokumenterer nettopp
# dette -- "ingen UI noensinne har hatt en vei til å SETTE" start_date/
# end_date, alltid utledet fra økter i stedet). Lagt til her etter at
# bruker påpekte at org-oppsettet generelt mangler nesten alle
# turnering-parametrene en organisator faktisk trenger.
name: str | None = Field(default=None, min_length=1, max_length=200)
start_date: date | None = None
end_date: date | None = None
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
scoring_method: str | None = Field(
default=None,
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
"eclectic_gross|eclectic_net|eclectic_stableford)$",
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
)
description: str | None = None
registration_deadline: datetime | None = None
@ -233,23 +189,6 @@ class TournamentUpdate(BaseModel):
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
registration_requires_approval: bool | None = None
bbb_sweep_bonus_enabled: bool | None = None
# Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067)
# -- ethvert org-medlem (samme tilgang som resten av denne PATCH-en, se
# get_authorized_org under). Standard AV, samme "skjult til avslørt"-
# filosofi som visibility.
flag_map_visible: bool | None = None
# Cut-KONFIGURASJON (migrasjon 083, 2026-08-18) -- fritt endre/nullstille
# helt til organisator faktisk trykker "Anvend cut" (se apply_cut i
# individual_tournaments.py, som setter cut_applied_at -- IKKE satt her,
# denne PATCH-en er kun for selve innstillingene).
cut_after_round: int | None = Field(default=None, ge=1)
cut_size: int | None = Field(default=None, gt=0)
# Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator,
# se Tournament sin kommentar. 'playoff' (migrasjon 086) er KUN gyldig
# for vinner-metoden, ikke felt-metoden.
tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp|playoff)$")
tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$")
tiebreak_playoff_rule: str | None = Field(default=None, max_length=2000)
# hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
@ -295,84 +234,6 @@ async def update_tournament(
return _tournament_from_row(row)
@router.delete("/orgs/{organization_id}/tournaments/{tournament_id}", status_code=204)
async def delete_tournament(
tournament_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Sletter HELE turneringen -- permanent, ingen angre (ADR-086,
2026-08-18). Strengere sperre enn resten av denne fila (kun org-
medlemskap for oppsett) -- kun org-eier/admin, siden dette er
kategorisk mer ødeleggende enn noe annet i turneringsoppsettet
(fjerner ALL score-/matchdata permanent, ikke bare tomt oppsett).
Eksplisitt, RIKTIG REKKEFØLGE-sletting i stedet for å stole blindt
én kaskaderende `DELETE FROM tournament` -- to steder i skjemaet har
en RESTRICT-FK midt INNI kaskade-grafen (`match_participant.
team_roster_id` og `tournament_round_participant.tournament_
participant_id`, begge migrasjon 001/040), som kan avvise en
enkelt-setnings-kaskade avhengig av Postgres sin interne
kaskade-rekkefølge når det faktisk finnes ekte kamper/runder. Fjerner
derfor RESTRICT-blokkerende rader FØR det de peker slettes.
`org_connection` sin egen transaksjon gjør alt atomisk uansett --
feiler ett steg, ruller alt tilbake."""
async with org_connection(organization_id) as conn, translate_db_errors():
if not await is_org_admin(conn, organization_id, user.user_id):
raise app_error(
403,
"NOT_ORG_ADMIN",
"Kun organisasjonseier/administrator kan slette en turnering.",
)
exists = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM tournament WHERE id = $1)", tournament_id
)
if not exists:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
# Lag-format: fjern match_participant FØR team_roster forsvinner
# (RESTRICT-FK-en over) -- kaskaderer selv videre til hole_score.
await conn.execute(
"""
DELETE FROM match_participant
WHERE match_id IN (
SELECT m.id FROM match m
JOIN session s ON m.session_id = s.id
WHERE s.tournament_id = $1
)
""",
tournament_id,
)
await conn.execute(
"DELETE FROM match WHERE session_id IN (SELECT id FROM session WHERE tournament_id = $1)",
tournament_id,
)
await conn.execute("DELETE FROM session WHERE tournament_id = $1", tournament_id)
await conn.execute(
"DELETE FROM team_roster WHERE team_id IN (SELECT id FROM team WHERE tournament_id = $1)",
tournament_id,
)
await conn.execute("DELETE FROM team WHERE tournament_id = $1", tournament_id)
# Individuell-format: samme prinsipp -- tournament_round_participant
# FØR tournament_participant/tournament_round forsvinner.
await conn.execute(
"""
DELETE FROM tournament_round_participant
WHERE tournament_round_id IN (SELECT id FROM tournament_round WHERE tournament_id = $1)
OR tournament_participant_id IN (SELECT id FROM tournament_participant WHERE tournament_id = $1)
""",
tournament_id,
)
await conn.execute("DELETE FROM tournament_round WHERE tournament_id = $1", tournament_id)
await conn.execute("DELETE FROM tournament_participant WHERE tournament_id = $1", tournament_id)
# Resten (klasser, sponsorer, påmelding, feed/chat-meldinger,
# OoM-lenke) kaskaderer trygt fra selve tournament-raden -- ingen
# RESTRICT-FK-er igjen på dette punktet.
await conn.execute("DELETE FROM tournament WHERE id = $1", tournament_id)
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
response_model=Tournament,
@ -899,12 +760,6 @@ class SessionCreate(BaseModel):
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = None
start_hole: int = 1
# Shotgun-start (migrasjon 088, ADR-096) -- samme mønster som
# tournament_round for individuelle turneringer. 'consecutive'
# (standard) = uendret oppførsel. 'shotgun' = alle matcher i økten
# går ut SAMTIDIG, fra hvert sitt starthull (match.start_hole) i
# stedet for staggert klokkeslett.
start_mode: str = Field(default="consecutive", pattern="^(consecutive|shotgun)$")
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
# sidens individuelle resultater som teller per hull ("beste N av M").
shamble_best_n: int | None = Field(default=None, ge=1)
@ -923,7 +778,6 @@ class SessionOut(BaseModel):
scheduled_at: datetime | None
tee_interval_minutes: int | None
start_hole: int
start_mode: str
shamble_best_n: int | None
locked_team_ids: list[str]
revealed: bool
@ -943,7 +797,6 @@ def _session_out(row, locked: set[str]) -> SessionOut:
scheduled_at=row["scheduled_at"],
tee_interval_minutes=row["tee_interval_minutes"],
start_hole=row["start_hole"],
start_mode=row["start_mode"],
shamble_best_n=row["shamble_best_n"],
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
@ -959,7 +812,7 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
FROM session
WHERE tournament_id = $1
ORDER BY sequence
@ -1016,12 +869,12 @@ async def create_session(
INSERT INTO session
(organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14, $15)
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
""",
organization_id,
tournament_id,
@ -1036,7 +889,6 @@ async def create_session(
body.scheduled_at,
body.tee_interval_minutes,
body.start_hole,
body.start_mode,
body.shamble_best_n,
)
return _session_out(row, set())
@ -1054,7 +906,6 @@ class SessionUpdate(BaseModel):
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = None
start_hole: int | None = Field(default=None, ge=1, le=18)
start_mode: str | None = Field(default=None, pattern="^(consecutive|shotgun)$")
points_per_match: float | None = None
allowance_override: dict | None = None
course_id: str | None = None
@ -1198,7 +1049,7 @@ async def update_session(
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
FROM session WHERE id = $1
""",
session_id,

View file

@ -1,61 +0,0 @@
"""
Rangefinder-koordinater (migrasjon 065/079) -- avstand til green/hindringer,
GPS-basert. Delt mellom rounds.py (frittstående runder), individual_
tournaments.py (org-turnering, individuell slagspill) og scoring.py
(org-turnering, lag-matchplay) -- alle tre løser sin egen kontekst om til
samme `CourseKey` (se hole_history.py, som denne modulen gjenbruker banen-
bro-abstraksjonen fra) og kaller `get_target_points()` her.
To kildetabeller, ingen RLS noen av dem (globale, delt tvers av
organisasjoner/brukere som tilfeldigvis bruker samme fysiske bane -- samme
begrunnelse som golfapi_course_coordinate alltid har hatt): `golfapi_
course_coordinate` (GolfAPI-importerte baner) og `teeoff_course_coordinate`
(ekte teeoff-baner, migrasjon 079). Fordi ingen av dem er RLS-beskyttet,
virker `get_target_points()` uendret uansett om `conn` kommer fra
`plain_connection()` eller `org_connection()`.
"""
from __future__ import annotations
from asyncpg import Connection
from pydantic import BaseModel
from .hole_history import CourseKey
class TargetPoint(BaseModel):
poi_type: str
location: str | None
side_fairway: str | None
latitude: float
longitude: float
hazard_group: str | None
async def get_target_points(conn: Connection, course_key: CourseKey | None, hole_number: int) -> list[TargetPoint]:
if course_key is None:
return []
kind = course_key[0]
if kind == "teeoff":
_, facility_slug, teeoff_course_id = course_key
rows = await conn.fetch(
"""
SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group
FROM teeoff_course_coordinate
WHERE external_course_ref = $1 AND hole_number = $2
""",
f"{facility_slug}:{teeoff_course_id}",
hole_number,
)
else:
_, golfapi_course_id = course_key
rows = await conn.fetch(
"""
SELECT poi_type, location, side_fairway, latitude, longitude, hazard_group
FROM golfapi_course_coordinate
WHERE golfapi_course_id = $1 AND hole_number = $2
""",
golfapi_course_id,
hole_number,
)
return [TargetPoint(**dict(r)) for r in rows]

View file

@ -38,7 +38,6 @@ services:
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
TEECUP_GOLFAPI_TOKEN: ${TEECUP_GOLFAPI_TOKEN}
networks:
- teeoff_default
restart: unless-stopped

View file

@ -1,5 +0,0 @@
import { HelpPage } from "@/components/help-page"
export default function Hjelp() {
return <HelpPage />
}

View file

@ -1,21 +0,0 @@
import { Wordmark } from "@/components/wordmark"
import { AccountMergeConfirmForm } from "@/components/account-merge-confirm-form"
export default async function AccountMergeConfirmPage({
params,
}: {
params: Promise<{ token: string }>
}) {
const { token } = await params
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
<div className="flex w-full max-w-sm flex-col gap-8">
<header className="flex flex-col items-center gap-3 text-center">
<Wordmark />
</header>
<AccountMergeConfirmForm token={token} />
</div>
</main>
)
}

View file

@ -1,9 +1,6 @@
import type { Metadata, Viewport } from 'next'
import { Nunito } from 'next/font/google'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import { SwRegister } from '@/components/sw-register'
import { LocaleBootstrap } from '@/components/locale-bootstrap'
import './globals.css'
const nunito = Nunito({
@ -55,24 +52,16 @@ export const viewport: Viewport = {
],
}
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
// i18n (ADR-094) -- locale kommer fra en cookie (se i18n/request.ts),
// ALDRI fra URL-en. LocaleBootstrap synker inn brukerens preferred_locale
// fra /auth/me på klienten dersom ingen cookie er satt ennå.
const locale = await getLocale()
return (
<html lang={locale} className={`${nunito.variable} bg-background`}>
<html lang="no" className={`${nunito.variable} bg-background`}>
<body className="font-sans antialiased">
<NextIntlClientProvider>
<SwRegister />
<LocaleBootstrap />
{children}
</NextIntlClientProvider>
<SwRegister />
{children}
</body>
</html>
)

View file

@ -1,44 +0,0 @@
import type { Metadata } from "next"
import { PublicOrderOfMerit } from "@/components/public-order-of-merit"
// Server-side -- går IKKE gjennom next.config.mjs sin rewrites() (kun for
// nettleser-trafikk inn til Next.js-serveren). Peker direkte på API-et.
const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
type PublicOomInfo = {
name: string
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
try {
const res = await fetch(`${API_ORIGIN}/public/order-of-merits/${id}`, { cache: "no-store" })
if (!res.ok) {
return { title: "Order of Merit | TeeCup" }
}
const data: PublicOomInfo = await res.json()
const title = `${data.name} | TeeCup`
const description = `Se resultatlisten for ${data.name} på TeeCup.`
return {
title,
description,
openGraph: { title, description, siteName: "TeeCup", type: "website" },
twitter: { card: "summary", title, description },
}
} catch {
return { title: "TeeCup" }
}
}
export default async function PublicOrderOfMeritPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <PublicOrderOfMerit oomId={id} />
}

View file

@ -1,13 +0,0 @@
import { OrgPlayers } from "@/components/org-players"
export default async function OrgPlayersPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ name?: string; highlight?: string }>
}) {
const { id } = await params
const { name, highlight } = await searchParams
return <OrgPlayers organizationId={id} orgName={name ?? "Organisasjon"} highlightPlayerId={highlight ?? null} />
}

View file

@ -1,33 +0,0 @@
import { PrintCartTags } from "@/components/print-cart-tags"
export default async function PrintCartTagsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, roundId, sessionId, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<PrintCartTags
organizationId={org}
tournamentId={id}
roundId={roundId}
sessionId={sessionId}
initialPaper={paper === "Letter" ? "letter" : "a4"}
initialOrientation={orientation === "landscape" ? "landscape" : "portrait"}
/>
)
}

View file

@ -1,31 +0,0 @@
import { PrintResultlist } from "@/components/print-resultlist"
export default async function PrintResultlistPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<PrintResultlist
organizationId={org}
tournamentId={id}
initialSize={paper === "Letter" ? "Letter" : "A4"}
initialOrientation={orientation === "landscape" ? "landscape" : "portrait"}
/>
)
}

View file

@ -1,33 +0,0 @@
import { PrintScorecard } from "@/components/print-scorecard"
export default async function PrintScorecardPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, roundId, sessionId, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<PrintScorecard
organizationId={org}
tournamentId={id}
roundId={roundId}
sessionId={sessionId}
initialPaper={paper === "Letter" || paper === "A5" ? paper : "A4"}
initialOrientation={orientation === "portrait" ? "portrait" : "landscape"}
/>
)
}

View file

@ -1,36 +0,0 @@
import { PrintStartlist } from "@/components/print-startlist"
export default async function PrintStartlistPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, roundId, sessionId, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
const validSizes = ["A4", "Letter", "A3", "A2"] as const
const initialSize = (validSizes as readonly string[]).includes(paper ?? "") ? (paper as (typeof validSizes)[number]) : "A4"
return (
<PrintStartlist
organizationId={org}
tournamentId={id}
roundId={roundId}
sessionId={sessionId}
initialSize={initialSize}
initialOrientation={orientation === "landscape" ? "landscape" : "portrait"}
/>
)
}

View file

@ -1,225 +0,0 @@
"use client"
// Kontosammenslåing ("Del 2", ADR-080) -- bekreftelsessiden lenken sendt til
// MÅL-kontoens (taperens) e-post peker til. INGEN sesjon kreves (kan åpnes
// på en annen enhet enn den som ba om sammenslåingen, samme prinsipp som
// e-postbytte-bekreftelse) -- selve token-en ER beviset på eierskap.
//
// To steg, ALDRI automatisk: 1) hent en FERSK forhåndsvisning ved lasting
// (les-only, konsumerer ikke token), 2) krev et eksplisitt "Bekreft
// sammenslåing"-trykk før selve utførelsen -- en lenke skal aldri kunne
// utløse en irreversibel handling bare ved å bli åpnet.
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, CheckCircle2, Merge, XCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
type Status = "loading" | "ready" | "confirming" | "success" | "error"
type ApiOrgMembershipPreview = {
organization_id: string
organization_name: string
keeper_role: string | null
loser_role: string | null
resulting_role: string
}
type ApiOrgPlayerPreview = {
organization_id: string
organization_name: string
keeper_has_player: boolean
loser_has_player: boolean
colliding_registrations: number
}
type ApiMergePreview = {
keeper_id: string
keeper_display_name: string
keeper_email: string | null
loser_id: string
loser_display_name: string
loser_email: string | null
organization_memberships: ApiOrgMembershipPreview[]
player_links: ApiOrgPlayerPreview[]
friend_count: number
shared_friend_count: number
were_friends_with_each_other: boolean
round_count: number
secondary_email_count: number
}
const ROLE_LABELS: Record<string, string> = { owner: "eier", admin: "administrator", member: "medlem" }
export function AccountMergeConfirmForm({ token }: { token: string }) {
const [status, setStatus] = useState<Status>("loading")
const [preview, setPreview] = useState<ApiMergePreview | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/auth/account-merge/token/${token}`, { credentials: "include" })
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Lenken er ugyldig, brukt eller utløpt.")
}
const data: ApiMergePreview = await res.json()
if (!cancelled) {
setPreview(data)
setStatus("ready")
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
setStatus("error")
}
}
}
void load()
return () => {
cancelled = true
}
}, [token])
async function handleConfirm() {
setStatus("confirming")
setError(null)
try {
const res = await fetch("/auth/account-merge/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ token }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å slå sammen kontoene.")
}
setStatus("success")
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
setStatus("ready")
}
}
if (status === "loading") {
return (
<div className="flex w-full flex-col items-center gap-4 rounded-3xl border border-border bg-card p-8 text-center shadow-lg shadow-black/5">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<p className="text-sm text-muted-foreground" role="status">
Henter forhåndsvisning
</p>
</div>
)
}
if (status === "success") {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kontoene er slått sammen</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alt er flyttet over. Denne kontoen finnes ikke lenger -- logg inn med den kontoen du
slo sammen til.
</p>
</div>
<Link href="/logg-inn" className="text-sm font-semibold text-primary transition-colors hover:text-primary/80">
Til innlogging
</Link>
</div>
)
}
if (status === "error" || !preview) {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-destructive/15">
<XCircle aria-hidden="true" className="size-8 text-destructive" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kunne ikke bekrefte</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{error}</p>
</div>
<Link href="/account" className="text-sm font-semibold text-primary transition-colors hover:text-primary/80">
Til kontoinnstillinger
</Link>
</div>
)
}
return (
<div className="flex w-full flex-col gap-5 rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Merge aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-extrabold tracking-tight text-balance">Bekreft kontosammenslåing</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{preview.keeper_display_name}
{preview.keeper_email ? ` (${preview.keeper_email})` : ""} har bedt om å slå denne kontoen (
{preview.loser_display_name}
{preview.loser_email ? `, ${preview.loser_email}` : ""}) sammen med sin.
</p>
</div>
<ul className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background p-3 text-sm text-foreground">
{preview.organization_memberships.map((m) => (
<li key={m.organization_id}>
{m.organization_name}: blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role}
</li>
))}
{preview.player_links.map((p) => (
<li key={p.organization_id}>
{p.organization_name}: spiller-koblingen slås sammen
{p.colliding_registrations > 0
? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)`
: ""}
</li>
))}
{preview.round_count > 0 && <li>{preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over</li>}
{preview.friend_count > 0 && (
<li>
{preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over
{preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""}
</li>
)}
{preview.were_friends_with_each_other && <li>Dere var venner med hverandre -- fjernes ved sammenslåing</li>}
{preview.secondary_email_count > 0 && (
<li>
{preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over
</li>
)}
</ul>
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 p-4">
<div className="flex items-start gap-2">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-destructive" />
<p className="text-sm font-semibold text-foreground text-pretty">
Dette kan ikke angres. Denne kontoen ({preview.loser_display_name}) slettes helt snart
du bekrefter.
</p>
</div>
<Button
type="button"
variant="destructive"
disabled={status === "confirming"}
onClick={handleConfirm}
className="min-h-12 w-full justify-center gap-2 rounded-xl text-base font-bold"
>
<Merge aria-hidden="true" className="size-5" />
{status === "confirming" ? "Slår sammen …" : "Bekreft sammenslåing"}
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive text-pretty">{error}</p>}
</div>
)
}

View file

@ -20,7 +20,6 @@ import {
Lock,
LogOut,
Mail,
Merge,
Pencil,
Plus,
ShieldCheck,
@ -299,8 +298,6 @@ export function AccountSettings() {
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
<AccountMergeSection />
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
<NotificationEmailPrefsSection />
@ -1135,11 +1132,11 @@ function EmailSection({ email }: { email: string }) {
)
}
// Én person, flere e-postadresser (FEATURE_BACKLOG.md "Del 1") -- kun det
// enkle tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
// deretter brukes til innlogging i tillegg til hovedadressen. Tilhører
// adressen ALLEREDE en annen konto, avviser backend med 409 DUPLICATE og
// henviser til AccountMergeSection under ("Del 2", ADR-080).
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte
// konto-sammenslåing (adressen tilhører allerede en annen konto) er
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE.
function SecondaryEmailSection({
secondaryEmails,
onChanged,
@ -1281,217 +1278,6 @@ function SecondaryEmailSection({
)
}
// --- Kontosammenslåing ("Del 2", ADR-080, 2026-08-16) -----------------------
// Selvbetjent: bruker taster inn e-posten til en ANNEN, egen konto, ser en
// forhåndsvisning av hva som vil bli slått sammen, og ber om en
// bekreftelseslenke som sendes til DEN kontoens e-post (beviser eierskap av
// den, samme prinsipp som e-postbytte). Selve bekreftelsen/utførelsen skjer
// på en egen side (/kontosammenslaing/[token]) når lenken åpnes -- ALDRI her,
// denne seksjonen sender kun forespørselen.
type ApiOrgMembershipPreview = {
organization_id: string
organization_name: string
keeper_role: string | null
loser_role: string | null
resulting_role: string
}
type ApiOrgPlayerPreview = {
organization_id: string
organization_name: string
keeper_has_player: boolean
loser_has_player: boolean
colliding_registrations: number
}
type ApiMergePreview = {
keeper_id: string
keeper_display_name: string
keeper_email: string | null
loser_id: string
loser_display_name: string
loser_email: string | null
organization_memberships: ApiOrgMembershipPreview[]
player_links: ApiOrgPlayerPreview[]
friend_count: number
shared_friend_count: number
were_friends_with_each_other: boolean
round_count: number
secondary_email_count: number
}
const ROLE_LABELS: Record<string, string> = { owner: "eier", admin: "administrator", member: "medlem" }
function AccountMergeSection() {
const [email, setEmail] = useState("")
const [preview, setPreview] = useState<ApiMergePreview | null>(null)
const [loadingPreview, setLoadingPreview] = useState(false)
const [requesting, setRequesting] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handlePreview(e: React.FormEvent) {
e.preventDefault()
setError(null)
setLoadingPreview(true)
setPreview(null)
try {
const res = await fetch("/auth/account-merge/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Fant ingen konto med denne e-posten.")
}
setPreview(await res.json())
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setLoadingPreview(false)
}
}
async function handleRequest() {
setRequesting(true)
setError(null)
try {
const res = await fetch("/auth/account-merge/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.")
}
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setRequesting(false)
}
}
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Merge aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Slå sammen med en annen konto</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Har du endt opp med to TeeCup-kontoer (f.eks. logget inn med to ulike
adresser)? Slå dem sammen til én -- alt fra den andre kontoen
(organisasjoner, spiller-koblinger, runder, venner) flyttes over hit.
</p>
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {email.trim()} -- åpne lenken der for å se
nøyaktig hva som slås sammen og bekrefte. Ingenting slås sammen før
det er bekreftet der.
</p>
) : preview ? (
<div className="flex flex-col gap-3">
<p className="text-sm font-semibold text-foreground">
{preview.loser_display_name}
{preview.loser_email ? ` (${preview.loser_email})` : ""} slås inn i denne kontoen:
</p>
<ul className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background p-3 text-sm text-foreground">
{preview.organization_memberships.map((m) => (
<li key={m.organization_id}>
{m.organization_name}: du blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role}
{m.keeper_role && m.loser_role && m.keeper_role !== m.resulting_role
? ` (oppgradert fra ${ROLE_LABELS[m.keeper_role] ?? m.keeper_role})`
: ""}
</li>
))}
{preview.player_links.map((p) => (
<li key={p.organization_id}>
{p.organization_name}: spiller-koblingen slås sammen
{p.colliding_registrations > 0
? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)`
: ""}
</li>
))}
{preview.round_count > 0 && <li>{preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over</li>}
{preview.friend_count > 0 && (
<li>
{preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over
{preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""}
</li>
)}
{preview.were_friends_with_each_other && <li>Dere var venner med hverandre -- fjernes ved sammenslåing</li>}
{preview.secondary_email_count > 0 && (
<li>
{preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over
</li>
)}
</ul>
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-semibold text-foreground text-pretty">
Dette kan ikke angres. Den andre kontoen slettes helt snart
sammenslåingen er bekreftet.
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreview(null)}
className="min-h-11 flex-1 justify-center rounded-xl font-bold"
>
Avbryt
</Button>
<Button
type="button"
variant="destructive"
disabled={requesting}
onClick={handleRequest}
className="min-h-11 flex-1 justify-center gap-1.5 rounded-xl font-bold"
>
<Merge aria-hidden="true" className="size-4" />
{requesting ? "Sender …" : "Send bekreftelse"}
</Button>
</div>
</div>
</div>
) : (
<form onSubmit={handlePreview} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="merge-email" className="text-sm font-semibold">
E-post til den andre kontoen
</Label>
<Input
id="merge-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<Button
type="submit"
disabled={loadingPreview || email.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{loadingPreview ? "Ser etter …" : "Se hva som slås sammen"}
</Button>
</form>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</section>
)
}
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)

View file

@ -1,157 +0,0 @@
"use client"
import { useEffect, useRef, useState } from "react"
import mapboxgl from "mapbox-gl"
import "mapbox-gl/dist/mapbox-gl.css"
export type FlagMapEntry = {
participantId: string
displayName: string
lap: number
holeNumber: number
lat: number
lng: number
onGreen: boolean
distanceToPinCm: number | null
}
export type FlagMapOverviewProps = {
flags: FlagMapEntry[]
visibleToAll: boolean
}
// ADR-067-flagg-tillegget ("Del B", 2026-08-14) -- MIDLERTIDIG, enkel
// kartkomponent inntil V0-eksporten (prompt sendt til bruker) er klar.
// Samme props-kontrakt -- når eksporten kommer, byttes kun komponentens
// INNMAT, kalleren skal ikke måtte endres. Re-initialiserer hele kartet
// når `flags` endrer referanse (enkel, men ikke det mest effisiente --
// akseptert forenkling for den midlertidige versjonen).
export function FlagMapOverview({ flags, visibleToAll }: FlagMapOverviewProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (flags.length === 0) return
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
const container = containerRef.current
if (!container) return
if (!token) {
setError("Kartet er ikke tilgjengelig akkurat nå.")
return
}
setLoaded(false)
setError(null)
mapboxgl.accessToken = token
const map = new mapboxgl.Map({
container,
style: "mapbox://styles/mapbox/satellite-v9",
center: [flags[0].lng, flags[0].lat],
zoom: 16,
attributionControl: false,
})
map.on("load", () => {
setLoaded(true)
if (flags.length > 1) {
const bounds = new mapboxgl.LngLatBounds([flags[0].lng, flags[0].lat], [flags[0].lng, flags[0].lat])
for (const f of flags) bounds.extend([f.lng, f.lat])
map.fitBounds(bounds, { padding: 80, maxZoom: 18, duration: 0 })
}
})
map.on("error", () => setError("Kunne ikke laste kartet."))
const markers: mapboxgl.Marker[] = []
for (const f of flags) {
const el = document.createElement("div")
el.className =
"flex size-7 items-center justify-center rounded-full border-2 border-white text-[10px] font-extrabold text-white shadow-md"
el.style.backgroundColor = f.lap > 1 ? "#7c3aed" : "#ff5a1f"
el.textContent = f.lap > 1 ? `R${f.lap}` : "⚑"
const popupHtml = `
<div style="font-size:13px; line-height:1.4;">
<strong>${f.displayName}</strong><br/>
Hull ${f.holeNumber}${f.lap > 1 ? ` · runde ${f.lap}` : ""}
${
f.onGreen && f.distanceToPinCm !== null
? `<br/>${Math.floor(f.distanceToPinCm / 100)} m ${f.distanceToPinCm % 100} cm fra hullet`
: ""
}
</div>
`
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([f.lng, f.lat])
.setPopup(new mapboxgl.Popup({ offset: 16 }).setHTML(popupHtml))
.addTo(map)
markers.push(marker)
}
return () => {
markers.forEach((m) => m.remove())
map.remove()
}
}, [flags])
if (flags.length === 0) {
return (
<div className="flex h-full items-center justify-center p-6 text-center">
<p className="text-base text-muted-foreground">
{visibleToAll ? "Ingen flagg plantet ennå" : "Du har ikke plantet flagget ennå"}
</p>
</div>
)
}
return (
<div className="relative h-full w-full">
<div
ref={containerRef}
className="absolute inset-0 h-full w-full"
aria-label="Kart over plantede flagg"
role="application"
/>
{!loaded && !error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Laster kart </p>
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<p role="alert" className="max-w-sm text-center text-base text-destructive">
{error}
</p>
</div>
) : null}
{!visibleToAll ? (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
<p className="rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
Kun ditt eget flagg er synlig
</p>
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="flex items-center gap-3 rounded-full bg-card/95 px-4 py-2 text-xs font-semibold text-foreground shadow-md">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-full" style={{ backgroundColor: "#ff5a1f" }} aria-hidden="true" />
Runde 1
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-full" style={{ backgroundColor: "#7c3aed" }} aria-hidden="true" />
Runde 2+
</span>
</div>
</div>
</div>
)
}
export default FlagMapOverview

View file

@ -1,99 +0,0 @@
"use client"
// Hjelpeside (ADR-094) -- oversiktside for turneringsflyten + søkbar FAQ.
// Innholdet ligger i messages/{nb,en}.json under "help" (t.raw(), siden
// dette er strukturert innhold -- arrays av {title,body}/{question,answer}
// -- ikke enkeltstrenger). Startet med to konkrete spørsmål brukeren
// faktisk stilte i en samtale ("hvordan fungerer turneringsmodulen" /
// "hvordan legger jeg til spillere i spillerpoolen") -- ment å vokse
// organisk etter hvert som flere spørsmål dukker opp, samme mønster som
// resten av appen er bygget etter denne økten.
import { useState } from "react"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { ArrowLeft, Search } from "lucide-react"
type OverviewSection = { title: string; body: string }
type FaqEntry = { question: string; answer: string }
export function HelpPage() {
const t = useTranslations("help")
const [query, setQuery] = useState("")
const sections = t.raw("overview.sections") as OverviewSection[]
const faq = t.raw("faq") as FaqEntry[]
const filteredFaq =
query.trim() === ""
? faq
: faq.filter(
(entry) =>
entry.question.toLowerCase().includes(query.toLowerCase()) ||
entry.answer.toLowerCase().includes(query.toLowerCase()),
)
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/90 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href="/more"
aria-label={t("backLabel")}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<h1 className="text-xl font-extrabold tracking-tight text-foreground">{t("pageTitle")}</h1>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-foreground">{t("overview.title")}</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{t("overview.intro")}</p>
</div>
<div className="flex flex-col gap-4">
{sections.map((section, i) => (
<div key={i} className="flex flex-col gap-1">
<h3 className="text-base font-bold text-foreground">{section.title}</h3>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{section.body}</p>
</div>
))}
</div>
</section>
<section className="mt-6 flex flex-col gap-3">
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("searchPlaceholder")}
aria-label={t("searchLabel")}
className="h-11 w-full rounded-xl border border-border bg-card pl-11 pr-3 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
/>
</div>
<div className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{filteredFaq.length === 0 && (
<p className="p-5 text-center text-sm text-muted-foreground">{t("noResults")}</p>
)}
{filteredFaq.map((entry, i) => (
<details key={i} className="group p-4 sm:p-5">
<summary className="cursor-pointer list-none text-base font-bold text-foreground marker:content-none">
{entry.question}
</summary>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground text-pretty">{entry.answer}</p>
</details>
))}
</div>
</section>
</main>
</div>
)
}

View file

@ -1,413 +0,0 @@
"use client"
// Hull-diagram v2 (2026-08-17, ADR-084) -- kategoriske venstre/senter/
// høyre-akser i stedet for kontinuerlig sideveis-forskyvning (ADR-083).
// Se ADR-084: kontinuerlig `crossMeters` ga for liten synlig forskjell
// på ekte banedata (Tjøme hull 18 -- bunker+vann havnet begge nesten på
// senterlinjen selv om de faktisk ligger på ulik side). Banevalget
// kommer nå fra `hazards[].lane`, satt av datalaget i
// hole-target-distance.tsx basert på `side_fairway` -- IKKE utledet her.
// Ren kontrollert komponent, ingen egen GPS-/geometrilogikk.
import { MapPin, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
export type HazardKind =
| "sand"
| "water"
| "creek"
| "rock"
| "tree"
| "dogleg"
| "layup"
| "landmark"
| "road"
| "fairway"
| "stone_fence"
| "generic"
export type DiagramHazard = {
key: string
kind: HazardKind
label: string
lane: "left" | "center" | "right"
/** 0 = ved tee, 100 = ved green. */
alongPercent: number
/** Avstand FRA SPILLEREN, vises rett under ikonet. Alltid satt. */
below: number
/** Avstand til bakkant, vises rett over ikonet -- KUN satt når
* hindringen er et ekte forkant+bakkant-par (se hazard_group i
* datalaget). `null` = enkeltpunkt, ingen tekst over ikonet. */
above: number | null
/** Kun meningsfullt når `above` er `null` -- brukes i listen under
* diagrammet til å si "forkant"/"senter"/"bakkant" om et
* enkeltstående punkt. */
location: "front" | "middle" | "back" | null
}
const _LANE_LABEL: Record<DiagramHazard["lane"], string> = {
left: "Venstre",
center: "Senter",
right: "Høyre",
}
const _LOCATION_LABEL: Record<"front" | "middle" | "back", string> = {
front: "forkant",
middle: "midtpunkt",
back: "bakkant",
}
type Props = {
distances: { front: number; middle: number; back: number }
playerPosition: { alongPercent: number }
hazards: DiagramHazard[]
size?: "compact" | "full"
className?: string
}
const TRACK_TOP = 12 // where alongPercent === 100 lands (leaves room for the flag)
const TRACK_BOTTOM = 88 // where alongPercent === 0 lands (leaves room for the tee dot)
// Midtbanen har i tillegg faste green-/tee-ankre tegnet OVENPÅ selve
// sporet (green-ikon+etikett øverst, tee-prikk+etikett nederst) -- en
// hindring plassert etter samme skala som venstre/høyre-banen kunne
// dermed havne visuelt oppå green-ikonet. Reservert sone er banens
// egen skala, kun for midtbanen.
const CENTER_TRACK_TOP = 29
const CENTER_TRACK_BOTTOM = 80
const LABEL_MIN_GAP_PCT = 16 // hindringer i samme bane trenger mer luft nå (ikon + to tall)
// En hindring KAN ligge bak selve greenen (alongPercent > 100 -- f.eks.
// en bakre greenbunker) -- projectOntoAxis() klipper bevisst IKKE dette
// bort (se hole-geometry.ts). Uten særbehandling ville en slik hindring
// blitt klippet til samme posisjon som "helt inntil green" og dermed
// visuelt smeltet sammen med front-of-green-hindringer, selv om den
// fysisk ligger på MOTSATT side av green-ikonet. Løsning: når minst én
// senterbane-hindring er bak green, flyttes green-ankeret lenger ned
// (fra faste 3% til GREEN_TOP_PCT_PUSHED), og en egen, liten sone
// ovenfor det flyttede ankeret reserveres til disse hindringene --
// nærmest green kaskaderer OPPOVER (motsatt retning av layoutLane) ved
// kollisjon. Ingen tilsvarende sone finnes (ennå) for hindringer bak
// TEE -- ingen kjent forekomst i dagens data, men samme mønster kan
// gjenbrukes symmetrisk om det trengs.
const GREEN_TOP_PCT_DEFAULT = 3
const GREEN_TOP_PCT_PUSHED = 22
const CENTER_TRACK_TOP_PUSHED = 40
const BEHIND_GREEN_ZONE_TOP = 3
const BEHIND_GREEN_ZONE_BOTTOM = 19
const BEHIND_GREEN_MAX_OVERSHOOT = 40 // % av hull-lengden bak green før visuelt klippet til toppen
function clamp(n: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, n))
}
function alongToTopPct(alongPercent: number, top = TRACK_TOP, bottom = TRACK_BOTTOM) {
const a = clamp(alongPercent, 0, 100)
return top + ((100 - a) / 100) * (bottom - top)
}
function formatMeters(m: number) {
return `${Math.round(m)} m`
}
const _ICON_SRC: Partial<Record<HazardKind, string>> = {
sand: "/hole-diagram/hazard-sand.png",
water: "/hole-diagram/hazard-water.png",
creek: "/hole-diagram/hazard-creek.png",
rock: "/hole-diagram/hazard-rock.png",
tree: "/hole-diagram/hazard-tree.png",
dogleg: "/hole-diagram/hazard-dogleg.png",
layup: "/hole-diagram/hazard-layup.png",
landmark: "/hole-diagram/hazard-landmark.png",
road: "/hole-diagram/hazard-road.png",
fairway: "/hole-diagram/hazard-fairway.png",
stone_fence: "/hole-diagram/hazard-stonefence.png",
}
function HazardIcon({ kind, size = 32 }: { kind: HazardKind; size?: number }) {
const src = _ICON_SRC[kind]
if (src) {
return <img src={src} width={size} height={size} alt="" className="block" style={{ height: "auto" }} />
}
return <TriangleAlert className="size-7 text-cup-strong" strokeWidth={2.5} />
}
/** Nudger ikoner i SAMME bane fra øverst til nederst hvis de er for
* nære vertikalt -- samme kollisjons-mønster som før, men kjørt per
* bane siden hindringer er spredt over tre baner. `top`/`bottom`
* lar midtbanen reservere plass til de faste green-/tee-ankrene. */
function layoutLane(hazards: DiagramHazard[], top = TRACK_TOP, bottom = TRACK_BOTTOM) {
const sorted = [...hazards].sort(
(a, b) => alongToTopPct(a.alongPercent, top, bottom) - alongToTopPct(b.alongPercent, top, bottom),
)
const placed: (DiagramHazard & { topPct: number })[] = []
let lastTop = Number.NEGATIVE_INFINITY
for (const h of sorted) {
const desired = alongToTopPct(h.alongPercent, top, bottom)
const topPct = Math.max(desired, lastTop + LABEL_MIN_GAP_PCT)
placed.push({ ...h, topPct })
lastTop = topPct
}
return placed
}
function behindGreenDesiredTopPct(alongPercent: number) {
const overshoot = clamp(alongPercent - 100, 0, BEHIND_GREEN_MAX_OVERSHOOT)
// 0 overshoot (rett bak green) -> bunnen av sonen (nærmest green-ikonet).
// Maks overshoot -> toppen av sonen (lengst unna, øverst i diagrammet).
return (
BEHIND_GREEN_ZONE_BOTTOM -
(overshoot / BEHIND_GREEN_MAX_OVERSHOOT) * (BEHIND_GREEN_ZONE_BOTTOM - BEHIND_GREEN_ZONE_TOP)
)
}
/** Speilvendt av layoutLane() -- kaskaderer OPPOVER (mot diagrammets
* toppkant) ved kollisjon, siden disse hindringene ligger bak green,
* ikke foran den. */
function layoutBehindGreenLane(hazards: DiagramHazard[]) {
const sorted = [...hazards].sort(
(a, b) => behindGreenDesiredTopPct(b.alongPercent) - behindGreenDesiredTopPct(a.alongPercent),
)
const placed: (DiagramHazard & { topPct: number })[] = []
let lastTop = Number.POSITIVE_INFINITY
for (const h of sorted) {
const desired = behindGreenDesiredTopPct(h.alongPercent)
const topPct = Math.min(desired, lastTop - LABEL_MIN_GAP_PCT)
placed.push({ ...h, topPct })
lastTop = topPct
}
return placed
}
function HazardMarker({ hazard, topPct }: { hazard: DiagramHazard; topPct: number }) {
return (
<div
aria-hidden="true"
className="absolute left-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5"
style={{ top: `${topPct}%` }}
>
{hazard.above !== null && (
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold tabular-nums text-clubhouse-ink shadow-sm ring-1 ring-clubhouse-border">
{formatMeters(hazard.above)}
</span>
)}
<HazardIcon kind={hazard.kind} />
<span className="whitespace-nowrap rounded-md bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold tabular-nums text-clubhouse-ink shadow-sm ring-1 ring-clubhouse-border">
{formatMeters(hazard.below)}
</span>
</div>
)
}
export function HoleDiagram({ distances, playerPosition, hazards, size = "full", className }: Props) {
if (size === "compact") {
return (
<div
className={cn(
"flex flex-col gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card p-3 text-clubhouse-ink",
className,
)}
>
<div className="flex items-baseline justify-center gap-3 tabular-nums">
<CompactNumber label="Front" value={distances.front} />
<CompactNumber label="Senter" value={distances.middle} emphasized />
<CompactNumber label="Bak" value={distances.back} />
</div>
{hazards.length > 0 && (
<div className="flex flex-col gap-2 border-t border-clubhouse-border pt-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-clubhouse-muted">Hindringer</h3>
<ul className="flex flex-col divide-y divide-clubhouse-border">
{hazards.map((h) => (
<HazardRow key={h.key} hazard={h} />
))}
</ul>
</div>
)}
</div>
)
}
const leftHazards = layoutLane(hazards.filter((h) => h.lane === "left"))
const centerAll = hazards.filter((h) => h.lane === "center")
const centerBehindGreen = centerAll.filter((h) => h.alongPercent > 100)
const centerNormal = centerAll.filter((h) => h.alongPercent <= 100)
const hasBehindGreen = centerBehindGreen.length > 0
const greenTopPct = hasBehindGreen ? GREEN_TOP_PCT_PUSHED : GREEN_TOP_PCT_DEFAULT
const centerTrackTop = hasBehindGreen ? CENTER_TRACK_TOP_PUSHED : CENTER_TRACK_TOP
const centerHazards = layoutLane(centerNormal, centerTrackTop, CENTER_TRACK_BOTTOM)
const behindGreenHazards = layoutBehindGreenLane(centerBehindGreen)
const rightHazards = layoutLane(hazards.filter((h) => h.lane === "right"))
const playerTop = alongToTopPct(playerPosition.alongPercent)
return (
<div className={cn("flex w-full flex-col gap-4 text-clubhouse-ink", className)}>
<div className="flex items-end justify-center gap-6 tabular-nums">
<BigNumber label="Front" value={distances.front} />
<BigNumber label="Senter" value={distances.middle} emphasized />
<BigNumber label="Bak" value={distances.back} />
</div>
<div
className="relative grid h-[24rem] w-full grid-cols-3 overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-field"
role="img"
aria-label={buildDiagramAria(distances, hazards, playerPosition)}
>
{/* Venstre bane */}
<div className="relative">
{leftHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
</div>
{/* Midtbane -- eneste bane med green/tee/spiller, alltid faste ankre her */}
<div className="relative border-x border-clubhouse-border/60">
<div
aria-hidden="true"
className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5"
style={{ top: `${greenTopPct}%` }}
>
<img src="/hole-diagram/green-40.png" width={40} height={40} alt="" className="block" />
<span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong">
Green
</span>
</div>
{behindGreenHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
<div
aria-hidden="true"
className="absolute bottom-[3%] left-1/2 flex -translate-x-1/2 flex-col items-center gap-0.5"
>
<span className="rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-clubhouse-muted">
Tee
</span>
<span className="size-3 rounded-full border-2 border-clubhouse-muted bg-clubhouse-card" />
</div>
<div
aria-hidden="true"
className="absolute left-1/2 z-30 flex -translate-x-1/2 -translate-y-full flex-col items-center"
style={{ top: `${playerTop}%` }}
>
<span className="mb-0.5 rounded bg-clubhouse-card px-1.5 py-0.5 text-xs font-bold text-tee-strong ring-1 ring-clubhouse-border">
Deg
</span>
<MapPin className="size-6 fill-tee-strong text-clubhouse-card" strokeWidth={2} />
</div>
{centerHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
</div>
{/* Høyre bane */}
<div className="relative">
{rightHazards.map((h) => (
<HazardMarker key={h.key} hazard={h} topPct={h.topPct} />
))}
</div>
</div>
{hazards.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-bold uppercase tracking-wide text-clubhouse-muted">
Hindringer
</h3>
<ul className="flex flex-col divide-y divide-clubhouse-border rounded-xl border border-clubhouse-border bg-clubhouse-card">
{hazards.map((h) => (
<HazardRow key={h.key} hazard={h} />
))}
</ul>
</div>
)}
</div>
)
}
function HazardRow({ hazard }: { hazard: DiagramHazard }) {
const meta = [
_LANE_LABEL[hazard.lane],
hazard.above === null && hazard.location ? _LOCATION_LABEL[hazard.location] : null,
]
.filter(Boolean)
.join(" · ")
return (
<li className="flex items-center gap-3 px-3 py-2.5">
<HazardIcon kind={hazard.kind} size={36} />
<div className="min-w-0 flex-1">
<div className="text-pretty text-base text-clubhouse-ink">{hazard.label}</div>
<div className="text-sm text-clubhouse-muted">{meta}</div>
</div>
{hazard.above !== null ? (
<div className="flex shrink-0 flex-col items-end text-clubhouse-ink">
<span className="text-sm font-bold tabular-nums">Forkant {formatMeters(hazard.below)}</span>
<span className="text-sm font-bold tabular-nums">Bakkant {formatMeters(hazard.above)}</span>
</div>
) : (
<span className="shrink-0 text-lg font-bold tabular-nums text-clubhouse-ink">
{formatMeters(hazard.below)}
</span>
)}
</li>
)
}
function BigNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
return (
<div className="flex flex-col items-center">
<span
className={cn(
"font-black leading-none tabular-nums",
emphasized ? "text-6xl text-tee-strong" : "text-4xl text-clubhouse-ink",
)}
>
{Math.round(value)}
</span>
<span
className={cn(
"mt-1 font-bold uppercase tracking-wide",
emphasized ? "text-sm text-tee-strong" : "text-xs text-clubhouse-muted",
)}
>
{label}
</span>
</div>
)
}
function CompactNumber({ label, value, emphasized }: { label: string; value: number; emphasized?: boolean }) {
return (
<div className="flex flex-col items-center">
<span
className={cn(
"font-black leading-none tabular-nums",
emphasized ? "text-3xl text-tee-strong" : "text-xl text-clubhouse-ink",
)}
>
{Math.round(value)}
</span>
<span className="text-[0.65rem] font-bold uppercase tracking-wide text-clubhouse-muted">{label}</span>
</div>
)
}
function buildDiagramAria(
distances: Props["distances"],
hazards: DiagramHazard[],
player: Props["playerPosition"],
) {
void player
const base = `Baneskisse. Senter ${Math.round(distances.middle)} meter til green. Din posisjon markert.`
if (hazards.length === 0) return `${base} Ingen hindringer.`
const list = hazards
.map((h) => {
const lane = _LANE_LABEL[h.lane].toLowerCase()
if (h.above !== null) {
return `${h.label}, ${lane}, forkant ${Math.round(h.below)} meter, bakkant ${Math.round(h.above)} meter`
}
const location = h.location ? `, ${_LOCATION_LABEL[h.location]}` : ""
return `${h.label}, ${lane}${location}, ${Math.round(h.below)} meter`
})
.join("; ")
return `${base} Hindringer: ${list}.`
}

View file

@ -1,242 +0,0 @@
"use client"
// Full historikk på ett hull, inkl. grafer (ADR-079, 2026-08-16) -- åpnes
// ved klikk fra HoleHistoryPanel sin kompakte oppsummering (hole-stat-
// inputs.tsx), som selv sitter i "skjermen før" slagvinduet/-arket (se
// round-detail.tsx sin PlayerHoleCards og individual-tournament-detail.tsx
// sin HoleStatsSheet-"Oversikt"-steg). INGEN backend-endring -- samme
// GET .../holes/{n}/history-endepunkt som panelet selv allerede brukte
// (app/hole_history.py), som allerede returnerer alt som trengs her.
//
// Grafene gjenbruker EKSAKT samme mønster/fargelogikk som round-stats.tsx
// sin CategoryBar/DeviationBar (bekreftet med bruker: "grafer" betyr dette
// etablerte stolpe-mønsteret, ikke et nytt graf-bibliotek) -- egne lokale
// kopier her, samme "ikke delt kode på tvers av filer"-mønster som resten
// av dette området (se DESIGN_SYSTEM.md).
import { useEffect, useState } from "react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
type HoleHistoryInstance = {
source: "personal" | "tournament"
played_at: string | null
label: string
score: number
par: number
putts: number | null
tee_shot_result: string | null
approach_result: string | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: string | null
}
type HoleHistory = {
times_played: number
times_personal: number
times_tournament: number
average_score: number
average_score_vs_par: number
gir_percent: number | null
fairway_hit_percent: number | null
average_putts: number | null
best_score: number
worst_score: number
instances: HoleHistoryInstance[]
}
// Samme fargeskala/kategori-inndeling som round-stats.tsx (chart-1..6,
// grønn->rød golf-resultat-skala).
const C = {
eagle: "var(--chart-1)",
birdie: "var(--chart-2)",
par: "var(--chart-3)",
bogey: "var(--chart-4)",
dobbel: "var(--chart-5)",
verre: "var(--chart-6)",
}
function signed(n: number): string {
const sign = n > 0 ? "+" : n < 0 ? "" : "±"
return `${sign}${Math.abs(n).toFixed(2).replace(".", ",")}`
}
function pct(n: number): string {
return `${Math.round(n)} %`
}
export function HoleHistoryDetail({
url,
holeNumber,
onClose,
}: {
url: string
holeNumber: number
onClose: () => void
}) {
const [history, setHistory] = useState<HoleHistory | null | undefined>(undefined)
useEffect(() => {
let cancelled = false
fetch(url, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled) setHistory(data)
})
.catch(() => {
if (!cancelled) setHistory(null)
})
return () => {
cancelled = true
}
}, [url])
const par = history?.instances[0]?.par ?? null
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
>
<X aria-hidden="true" className="size-5" />
</button>
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
<span className="truncate text-base font-extrabold text-foreground">
Hull {holeNumber}
{par !== null ? ` · Par ${par}` : ""}
</span>
<span className="truncate text-sm font-semibold text-muted-foreground">Din historikk</span>
</div>
<div className="size-11 shrink-0" aria-hidden="true" />
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
{history === undefined ? (
<div className="flex flex-1 items-center justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : history === null || history.times_played === 0 ? (
<p className="rounded-3xl border border-border bg-card p-6 text-center text-base font-semibold text-muted-foreground">
Ingen historikk å vise dette hullet ennå.
</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<StatTile label="Ganger spilt" value={String(history.times_played)} />
<StatTile label="Snitt slag" value={history.average_score.toFixed(1)} />
<StatTile label="Beste" value={String(history.best_score)} />
<StatTile label="Dårligste" value={String(history.worst_score)} />
</div>
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-muted/40 p-4">
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">
Snitt til par
</span>
<span className="text-5xl font-extrabold tabular-nums text-brand-orange">
{signed(history.average_score_vs_par)}
</span>
</div>
{(history.gir_percent !== null || history.fairway_hit_percent !== null) && (
<div className="grid grid-cols-2 gap-3">
{history.gir_percent !== null && <StatTile label="GIR" value={pct(history.gir_percent)} />}
{history.fairway_hit_percent !== null && (
<StatTile label="Fairway truffet" value={pct(history.fairway_hit_percent)} />
)}
</div>
)}
<ScoreDistribution instances={history.instances} />
<div className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Alle gangene</h3>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{history.instances.map((inst, i) => (
<li key={i} className="flex items-center justify-between gap-3 bg-card px-4 py-3">
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{inst.label}</span>
<span className="text-xs text-muted-foreground">
{inst.played_at ?? "Ukjent dato"} · {inst.source === "personal" ? "Frittstående" : "Turnering"}
</span>
</span>
<span className="shrink-0 text-sm font-extrabold tabular-nums text-foreground">
{inst.score} slag
{inst.putts !== null ? `, ${inst.putts} putt${inst.putts === 1 ? "" : "er"}` : ""}
</span>
</li>
))}
</ul>
</div>
</>
)}
</div>
</main>
</div>
)
}
function StatTile({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-4">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-foreground">{value}</span>
</div>
)
}
// Samme stolpe-mønster som round-stats.tsx sin CategoryBar -- fargede,
// prosent-bredde rektangler med store tall og etiketter.
function ScoreDistribution({ instances }: { instances: HoleHistoryInstance[] }) {
const diff = (inst: HoleHistoryInstance) => inst.score - inst.par
const categories = [
{ label: "Eagle+", test: (d: number) => d <= -2, color: C.eagle },
{ label: "Birdie", test: (d: number) => d === -1, color: C.birdie },
{ label: "Par", test: (d: number) => d === 0, color: C.par },
{ label: "Bogey", test: (d: number) => d === 1, color: C.bogey },
{ label: "Dobbel bogey", test: (d: number) => d === 2, color: C.dobbel },
{ label: "Verre", test: (d: number) => d >= 3, color: C.verre },
]
.map((c) => {
const count = instances.filter((inst) => c.test(diff(inst))).length
const percentage = instances.length > 0 ? (count / instances.length) * 100 : 0
return { ...c, count, percentage }
})
.filter((c) => c.count > 0)
const maxPct = Math.max(...categories.map((c) => c.percentage), 1)
return (
<div className="flex flex-col gap-3">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Score-fordeling</h3>
{categories.map((c) => {
const width = maxPct > 0 ? (c.percentage / maxPct) * 100 : 0
return (
<div key={c.label} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-base font-semibold text-foreground">{c.label}</span>
<span className="text-sm font-bold tabular-nums text-muted-foreground">
{pct(c.percentage)} <span className="text-foreground">· {c.count}</span>
</span>
</div>
<div className="h-6 overflow-hidden rounded-lg bg-muted">
<div
className={cn("h-full rounded-lg")}
style={{ width: `${Math.max(width, 6)}%`, backgroundColor: c.color }}
/>
</div>
</div>
)
})}
</div>
)
}

View file

@ -1,460 +0,0 @@
"use client"
// Delte, utelukkende trykkbaserte input-primitiver for hull-for-hull-
// statistikkregistrering (ALDRI dropdowns) -- trukket ut av round-detail.tsx
// sin ScoringWizard (frittstående runder) 2026-08-15 (ADR-071) for gjenbruk
// i org-turneringers nye HoleStatsSheet, som nå har samme statistikkdybde
// (migrasjon 075). Ren UI, ingen datahenting/state utover det som sendes inn.
//
// HoleHistoryPanel (ADR-072) er unntaket -- den HENTER selv (GET .../history),
// siden begge scoringsflytene bruker nøyaktig samme henting/visning og det
// ville vært ren duplisering å bygge den to ganger.
import { useEffect, useState, type ReactNode } from "react"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronRight, Flag, Minus, Plus, Target } from "lucide-react"
import { cn } from "@/lib/utils"
export function golfTermForScore(n: number, par: number): string | null {
const diff = n - par
if (diff <= -3) return "Albatross"
if (diff === -2) return "Eagle"
if (diff === -1) return "Birdie"
if (diff === 0) return "Par"
if (diff === 1) return "Bogey"
if (diff === 2) return "Dobbel bogey"
return null
}
export function NumberPicker({
label,
value,
directValues,
expandValues,
expandLabel,
parValue,
showGolfTerms,
maxValue,
strokesReceived,
accent,
onChange,
readOnly,
}: {
label: string
value: number | null
directValues: number[]
expandValues: number[]
expandLabel: string
// Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt.
parValue?: number
// Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til
// parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt
// for Slag-velgeren, ikke Putter/Anywayslag.
showGolfTerms?: boolean
// Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som
// uansett ville vært selvmotsigende.
maxValue?: number
// Spillerens tildelte handicap-slag PÅ DETTE hullet. Når satt (og
// showGolfTerms er på, dvs. Slag-velgeren), markeres knappen som
// tilsvarer spillerens PERSONLIGE netto par (rå par + mottatte slag) med
// en liten, diskret prikk -- tydelig forskjellig fra "valgt"-tilstanden,
// og de to kan opptre samtidig på samme knapp.
strokesReceived?: number | null
// Valgfri egen aksent for seksjonen (brukes til å gi Putter-velgeren en
// distinkt visuell identitet så den ikke forveksles med Slag-velgeren).
// Rører IKKE selve tallknappene -- kun tittelrad/ikon.
accent?: "primary" | "orange"
onChange: (value: number) => void
readOnly: boolean
}) {
const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue))
const cappedDirect = cap(directValues)
const cappedExpand = cap(expandValues)
const valueInExpand = value !== null && cappedExpand.includes(value)
const [expanded, setExpanded] = useState(false)
const showExpanded = expanded || valueInExpand
const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect
// Spillerens netto par for hullet = rå par + tildelte slag. Kun beregnet
// når vi faktisk har begge (Slag-velgeren med kjent strokesReceived).
const netPar =
parValue !== undefined && strokesReceived !== null && strokesReceived !== undefined
? parValue + strokesReceived
: null
// Putter-velgeren får en egen aksent (oransje) og et eget ikon på
// tittelraden, så den ikke er til å forveksle med Slag-velgeren -- som
// ellers er nøyaktig samme rutenett. Kun tittelraden/venstre aksentbånd
// endres; selve tallknappene beholder den etablerte grønne "valgt"-stilen.
const isOrange = accent === "orange"
return (
<div className={cn("flex flex-col gap-2", isOrange && "rounded-2xl border-l-4 border-brand-orange bg-brand-orange/5 py-3 pl-3 pr-1")}>
<span className="flex items-center gap-2 text-base font-semibold text-foreground">
{isOrange && (
<span aria-hidden="true" className="flex size-6 items-center justify-center rounded-lg bg-brand-orange text-brand-orange-foreground">
<Flag className="size-4" strokeWidth={2.5} />
</span>
)}
{label}
</span>
{/* Netto par-forklaring: gjør prikken selvforklarende uten å stole på
fargesyn alene (tilgjengelighet -- sol/utendørs). Vises kun når det
faktisk finnes en netto par-markør å forklare. */}
{netPar !== null && (
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span aria-hidden="true" className="size-2 rounded-full bg-brand-orange ring-2 ring-brand-orange/25" />
Ditt netto par ({netPar})
</span>
)}
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
for bruk utendørs, ofte med sol skjermen. */}
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{visibleValues.map((n) => {
const selected = value === n
const isNetPar = netPar !== null && n === netPar
const term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
return (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={term ? `${label} ${n}, ${term}${isNetPar ? ", ditt netto par" : ""}` : `${label} ${n}${isNetPar ? ", ditt netto par" : ""}`}
aria-pressed={selected}
className={cn(
"relative flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
// Netto par-knapp som IKKE er valgt: en subtil oransje ring +
// hint-bakgrunn, tydelig annerledes enn den fylte grønne
// "valgt"-tilstanden. Prikken øverst til høyre er signalet som
// holder også når knappen ER valgt (grønn fyll under).
isNetPar && !selected && "border-brand-orange/60 bg-brand-orange/5",
readOnly && !selected && "opacity-40",
)}
>
{isNetPar && (
<span
aria-hidden="true"
className={cn(
"absolute right-1.5 top-1.5 size-2.5 rounded-full bg-brand-orange",
// Kontrast-ring så prikken leses uansett om knappen er
// grønn (valgt) eller lys (uvalgt).
selected ? "ring-2 ring-primary" : "ring-2 ring-card",
)}
/>
)}
<span>{n}</span>
{term && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
{term}
</span>
)}
</button>
)
})}
{!showExpanded && !readOnly && cappedExpand.length > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
>
{expandLabel}
</button>
)}
</div>
</div>
)
}
// --- Retningskors (D-pad for Utslag / Innspill) -----------------------------
function DirButton({
icon: Icon,
label,
selected,
onClick,
readOnly,
}: {
icon: typeof ArrowUp
label: string
selected: boolean
onClick: () => void
readOnly: boolean
}) {
return (
<button
type="button"
disabled={readOnly}
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<Icon aria-hidden="true" className="size-6" />
<span className="text-sm font-bold">{label}</span>
</button>
)
}
export function DirectionCross({
label,
value,
onChange,
readOnly,
variant,
centerLabel,
}: {
label: string
value: string | null
onChange: (value: string) => void
readOnly: boolean
// "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill)
variant: "horizontal" | "full"
centerLabel: string
}) {
const spacer = <div aria-hidden="true" />
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
{spacer}
</>
)}
<DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
<DirButton
icon={Target}
label={centerLabel}
selected={value === (variant === "full" ? "hit" : "fairway")}
onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
readOnly={readOnly}
/>
<DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
{spacer}
</>
)}
</div>
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
export function ChoiceRow({
label,
options,
value,
onChange,
readOnly,
}: {
label: string
options: { value: string; label: string }[]
value: string | null
onChange: (value: string) => void
readOnly: boolean
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid grid-cols-3 gap-2">
{options.map((opt) => {
const selected = value === opt.value
return (
<button
key={opt.value}
type="button"
disabled={readOnly}
onClick={() => onChange(opt.value)}
aria-pressed={selected}
className={cn(
"flex min-h-12 items-center justify-center rounded-2xl border px-2 text-base font-bold transition-colors disabled:opacity-100",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
// --- Stepper (+/-) ---------------------------------------------------------
export function Stepper({
label,
value,
onChange,
readOnly,
max,
}: {
label: string
value: number
onChange: (value: number) => void
readOnly: boolean
// Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
// umulig å ha chippet flere ganger enn totalt antall slag.
max?: number
}) {
const atMax = max !== undefined && value >= max
const atMin = value <= 0
// Teller-FLIS (ADR-078, 2026-08-16, V0-designet) -- selvstendig kort med
// etikett + stort tall + -/+ på bunnen, ment for et 2x2-rutenett (se
// holeDetails-steget i round-detail.tsx/individual-tournament-detail.tsx).
// Interaksjonskontrakten er uendret: -/tall/+, minus disabled på 0, pluss
// disabled på max, alle trykkflater >=44px. Kun app-tokens.
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-border bg-card p-4">
<span className="text-center text-sm font-bold leading-tight text-foreground">{label}</span>
{/* Tallet bærer selve informasjonen (aldri farge alene); nullverdi
dempes kun visuelt via muted-foreground, som fortsatt er AA
kort. */}
<span
className={cn(
"text-4xl font-extrabold leading-none tabular-nums",
atMin ? "text-muted-foreground" : "text-foreground",
)}
>
{value}
</span>
<div className="flex w-full items-center gap-2">
<button
type="button"
disabled={readOnly || atMin}
onClick={() => onChange(Math.max(0, value - 1))}
aria-label={`Færre ${label}`}
className="flex h-11 flex-1 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-5" />
</button>
<button
type="button"
disabled={readOnly || atMax}
onClick={() => onChange(value + 1)}
aria-label={`Flere ${label}`}
className="flex h-11 flex-1 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-5" />
</button>
</div>
</div>
)
}
export function WizardSection({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="flex flex-col gap-4 border-t border-border pt-5 first:border-t-0 first:pt-0">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
// --- Spillerens per-hull-historikk (ADR-072) --------------------------------
// "Hvordan spiller jeg egentlig dette hullet?" -- aggregert på tvers av ALLE
// tidligere frittstående runder OG turneringsrunder (custom/håndlagde baner
// og gjestespillere er allerede filtrert bort server-side, se
// app/hole_history.py). Panelet skjuler seg selv (viser ingenting) når
// endepunktet svarer null -- ikke en feil, bare "ingen historikk å vise
// ennå/denne banen støtter det ikke".
type HoleHistoryInstance = {
source: "personal" | "tournament"
played_at: string | null
label: string
score: number
par: number
putts: number | null
tee_shot_result: string | null
approach_result: string | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: string | null
}
type HoleHistory = {
times_played: number
times_personal: number
times_tournament: number
average_score: number
average_score_vs_par: number
gir_percent: number | null
fairway_hit_percent: number | null
average_putts: number | null
best_score: number
worst_score: number
instances: HoleHistoryInstance[]
}
// ADR-079 (2026-08-16): panelet åpner nå den FULLE historikk-skjermen
// (components/hole-history-detail.tsx) ved klikk i stedet for å ekspandere
// en enkel liste inline -- flyttet HIT ut av selve slagvinduet/-arket til
// "skjermen før" (PlayerHoleCards for frittstående runder, "Oversikt"-
// steget for org-turneringer), se round-detail.tsx/individual-tournament-
// detail.tsx for de to bruksstedene.
export function HoleHistoryPanel({ url, onOpenFull }: { url: string; onOpenFull: () => void }) {
// undefined = laster ennå, null = ingen historikk å vise (custom bane,
// gjest, eller genuint aldri spilt før).
const [history, setHistory] = useState<HoleHistory | null | undefined>(undefined)
useEffect(() => {
let cancelled = false
setHistory(undefined)
fetch(url, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled) setHistory(data)
})
.catch(() => {
if (!cancelled) setHistory(null)
})
return () => {
cancelled = true
}
}, [url])
if (!history) return null
const parSign = history.average_score_vs_par >= 0 ? "+" : ""
return (
<button
type="button"
onClick={onOpenFull}
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent/50"
>
<span className="flex flex-col gap-1">
<span className="text-sm font-bold text-foreground">Din historikk dette hullet</span>
<span className="text-xs leading-relaxed text-muted-foreground text-pretty">
Spilt {history.times_played} {history.times_played === 1 ? "gang" : "ganger"} før
{" "}({history.times_personal} frittstående, {history.times_tournament} turnering) -- snitt{" "}
{history.average_score.toFixed(1)} slag ({parSign}
{history.average_score_vs_par.toFixed(1)} til par)
{history.gir_percent !== null && `, GIR ${Math.round(history.gir_percent)}%`}
{history.average_putts !== null && `, ${history.average_putts.toFixed(1)} putter i snitt`}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</button>
)
}

View file

@ -1,381 +0,0 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { TargetDistance } from "@/components/target-distance"
import { HoleDiagram } from "@/components/hole-diagram-view"
import { haversineMeters, type LatLng } from "@/lib/geo"
import { midpoint, projectOntoAxis } from "@/lib/hole-geometry"
// Rangefinder-datalag (ADR-064) -- henter rundens rå banepunkter (green
// front/senter/bak, hindringer) og kombinerer dem klient-side med spillerens
// LIVE GPS-posisjon via samme watchPosition-mønster som map-point-picker.tsx
// allerede bruker for slagmåling (ADR-048 tillegg 2026-08-08 del 2) --
// `enableHighAccuracy`, grasiøs degradering ved avslått tillatelse,
// `clearWatch` ved avmontering. `TargetDistance` selv forblir "dum"
// (V0-eksportert, ren visning) -- all logikk bor her. Ingen egen
// rad-wrapper (border/padding) her -- kalleren styrer plassering/bredde.
// Montert i PlayerHoleCards sin hull-navigator (round-detail.tsx),
// size="full" -- IKKE i ScoringWizard-headeren (der den satt først,
// 2026-08-12): brukeren pekte ut hovedvisningen som riktig sted og ba
// om mer bredde enn den opprinnelige kompaktvarianten, 2026-08-13.
//
// `baseUrl` (ikke `roundId`) siden migrasjon 079 -- komponenten gjenbrukes
// nå av tre kallesteder med tre ulike URL-familier (frittstående runde
// `/rounds/{id}`, individuell org-turnering `/orgs/{org}/tournaments/{t}/
// rounds/{r}`, lag-matchplay `/orgs/{org}/matches/{m}`) -- kalleren bygger
// selv `${baseUrl}/holes/{n}/target-points`, komponenten trenger ikke vite
// hvilken flyt den sitter i.
//
// Strømsparing (2026-08-13, brukerrapport om høyt batteriforbruk):
// (1) `maximumAge` satt til 4000ms (var 1000ms) -- en golfer beveger seg
// ~1,5 m/s, oppdatering hvert 4. sekund er umerkelig i praksis men ber
// GPS-brikken om et ferskt fix mye sjeldnere. (2) `watchPosition` pauses
// helt når fanen/appen ikke er synlig (`document.visibilitychange`) --
// ingen vits å holde GPS-en aktiv for en skjerm ingen ser på (skjerm låst,
// annen app i forgrunnen), og gjenopptas automatisk når brukeren kommer
// tilbake. `enableHighAccuracy` beholdt UENDRET -- lav nøyaktighet ville
// gitt titalls meters feilmargin, for upresist til at front/midt/bak-
// tallene gir mening.
type ApiTargetPoint = {
poi_type: string
location: "front" | "middle" | "back" | null
side_fairway: "left" | "center" | "right" | null
latitude: number
longitude: number
// Eksplisitt, menneske-satt paring av forkant/bakkant til SAMME
// fysiske hindring (migrasjon 080/ADR-084). `null` = enkeltstående
// punkt, ALDRI slått sammen med noe -- se groupDiagramHazards().
hazard_group: string | null
}
const _HAZARD_LABELS: Record<string, string> = {
green_bunker: "Bunker (green)",
fairway_bunker: "Bunker (fairway)",
water: "Vannhinder",
creek: "Bekk",
rock: "Fjellknaus",
trees: "Trær",
dogleg: "Dogleg",
layup: "Layup-punkt",
landmark: "Landemerke",
road: "Vei",
fairway: "Fairway",
stone_fence: "Voll",
}
// ADR-084: hvilket ikon en hindringstype får i diagrammet. "generic"
// (typer uten dedikert ikon -- i dag ingen) faller tilbake til lucide
// sin TriangleAlert -- se hole-diagram-view.tsx.
type HazardKind =
| "sand"
| "water"
| "creek"
| "rock"
| "tree"
| "dogleg"
| "layup"
| "landmark"
| "road"
| "fairway"
| "stone_fence"
| "generic"
const _HAZARD_KIND: Record<string, HazardKind> = {
green_bunker: "sand",
fairway_bunker: "sand",
water: "water",
creek: "creek",
rock: "rock",
trees: "tree",
dogleg: "dogleg",
layup: "layup",
landmark: "landmark",
road: "road",
fairway: "fairway",
stone_fence: "stone_fence",
}
// Hvor mange prosent av hull-lengden en hindring får "henge igjen" etter at
// spilleren i teorien har passert den (ADR-085, brukerønske 2026-08-20:
// hindringer bak spilleren er unødvendig informasjon -- skal ikke lenger
// vises). Ren GPS-støy-margin, ikke en spilleregel -- uten denne ville en
// hindring rett ved spillerens fot kunne blinke inn/ut av listen mellom to
// GPS-oppdateringer idet spilleren går forbi.
const HAZARD_PASSED_TOLERANCE_PCT = 1.5
// Grupperer rå hindringspunkter til hindrings-ENTITETER for diagrammet.
// VIKTIG (bruker-rettelse, ADR-084): to punkter er KUN samme fysiske
// hindring hvis de deler samme, ikke-null hazard_group -- ALDRI basert
// på poi_type+side_fairway alene (ville feilaktig slått sammen to
// atskilte hindringer av samme type på samme side). Punkter med
// hazard_group=null forblir alltid enkeltstående.
// `playerAlongPercent` (ADR-085) -- en hindring vises KUN hvis den fortsatt
// ligger MELLOM spilleren og greenen (brukerønske: passerte hindringer er
// unødvendig informasjon). For et parret forkant+bakkant-hinder brukes
// bakkanten (den ytterste kanten, nærmest green) -- hele hindringen regnes
// som passert først når spilleren er forbi BEGGE kantene, ikke bare
// forkanten.
function groupDiagramHazards(
hazardPoints: ApiTargetPoint[],
livePosition: LatLng,
teeRef: LatLng,
greenRef: LatLng,
playerAlongPercent: number,
) {
const grouped = new Map<string, ApiTargetPoint[]>()
const standalone: ApiTargetPoint[] = []
for (const p of hazardPoints) {
if (p.hazard_group) {
const list = grouped.get(p.hazard_group) ?? []
list.push(p)
grouped.set(p.hazard_group, list)
} else {
standalone.push(p)
}
}
type Entity = {
key: string
kind: HazardKind
label: string
lane: "left" | "center" | "right"
alongPercent: number
below: number
above: number | null
// Kun meningsfullt for enkeltstående punkt (above === null) --
// et parret forkant+bakkant-hinder (above !== null) trenger ikke
// dette, UI-en viser da alltid eksplisitt "forkant"/"bakkant".
location: "front" | "middle" | "back" | null
}
const entities: Entity[] = []
function distanceTo(p: ApiTargetPoint) {
return Math.round(haversineMeters(livePosition, { lat: p.latitude, lng: p.longitude }))
}
function along(p: ApiTargetPoint) {
return projectOntoAxis(teeRef, greenRef, { lat: p.latitude, lng: p.longitude }).alongPercent
}
for (const [groupKey, pts] of grouped) {
const front = pts.find((p) => p.location === "front")
const back = pts.find((p) => p.location === "back")
const primary = front ?? back ?? pts[0]
const kind = _HAZARD_KIND[primary.poi_type]
if (!kind) continue
const alongValues = [front, back].filter((p): p is ApiTargetPoint => !!p).map(along)
const farEdgeAlong = Math.max(...alongValues)
if (farEdgeAlong < playerAlongPercent - HAZARD_PASSED_TOLERANCE_PCT) continue
entities.push({
key: `group-${groupKey}`,
kind,
label: _HAZARD_LABELS[primary.poi_type],
lane: primary.side_fairway ?? "center",
alongPercent: alongValues.reduce((a, b) => a + b, 0) / alongValues.length,
below: front ? distanceTo(front) : distanceTo(primary),
above: front && back ? distanceTo(back) : null,
location: front && back ? null : primary.location,
})
}
for (const p of standalone) {
const kind = _HAZARD_KIND[p.poi_type]
if (!kind) continue
if (along(p) < playerAlongPercent - HAZARD_PASSED_TOLERANCE_PCT) continue
entities.push({
key: `point-${p.latitude}-${p.longitude}-${p.poi_type}`,
kind,
label: _HAZARD_LABELS[p.poi_type],
lane: p.side_fairway ?? "center",
alongPercent: along(p),
below: distanceTo(p),
above: null,
location: p.location,
})
}
// Rettet 2026-08-21 (brukerfunn, skjermdump): listen skal lese som
// kartet den står under -- holdt loddrett med greenen øverst (lengst
// unna) og utslaget nederst (nærmest), skal hindringene imellom følge
// SAMME rekkefølge, ikke motsatt. Sorterer derfor synkende på
// BAKKANTEN når en finnes (`above` -- den ferneste kanten av en parret
// hindring, eksplisitt bedt om av bruker: "avstanden til bakkant [som]
// vi hensyntar når vi skal sortere"), ellers enkeltpunktets egen
// avstand (`below`, kun eksisterende verdi for et upar ret punkt).
return entities.sort((a, b) => (b.above ?? b.below) - (a.above ?? a.below))
}
export function HoleTargetDistance({
baseUrl,
holeNumber,
size = "compact",
className,
onAvailabilityChange,
}: {
baseUrl: string
holeNumber: number
size?: "compact" | "full"
className?: string
/** Kalles hver gang det blir klart om banen faktisk har koordinatdata for
* hullet eller ikke -- lar en kaller (f.eks. en utvid/skjul-knapp rundt
* komponenten) vite om det er noe å vise, uten selv å duplisere fetch-en. */
onAvailabilityChange?: (available: boolean) => void
}) {
const [points, setPoints] = useState<ApiTargetPoint[] | null>(null)
const [livePosition, setLivePosition] = useState<LatLng | null>(null)
const watchIdRef = useRef<number | null>(null)
useEffect(() => {
let cancelled = false
setPoints(null)
fetch(`${baseUrl}/holes/${holeNumber}/target-points`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTargetPoint[]) => {
if (!cancelled) setPoints(data)
})
.catch(() => {
if (!cancelled) setPoints([])
})
return () => {
cancelled = true
}
}, [baseUrl, holeNumber])
useEffect(() => {
if (points === null) return
onAvailabilityChange?.(points.length > 0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [points])
useEffect(() => {
// Ingen vits å spore GPS når banen uansett ikke har koordinatdata for
// dette hullet -- unngår unødvendig batteribruk/tillatelses-prompt.
if (!points || points.length === 0) return
if (typeof navigator === "undefined" || !navigator.geolocation) return
if (typeof document === "undefined") return
function startWatch() {
watchIdRef.current = navigator.geolocation.watchPosition(
(pos) => {
setLivePosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
},
() => {
// Avslått/feilet -- degraderer grasiøst, komponenten viser bare "ingen data".
},
{ enableHighAccuracy: true, maximumAge: 4000 },
)
}
function stopWatch() {
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
navigator.geolocation.clearWatch(watchIdRef.current)
}
watchIdRef.current = null
}
// Pauser GPS-sporingen helt når fanen/appen ikke er synlig (skjerm
// låst, annen app i forgrunnen) -- gjenopptas automatisk når brukeren
// kommer tilbake.
function handleVisibilityChange() {
if (document.hidden) {
stopWatch()
} else {
startWatch()
}
}
if (!document.hidden) startWatch()
document.addEventListener("visibilitychange", handleVisibilityChange)
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange)
stopWatch()
}
}, [points])
// Fortsatt laster, ELLER banen har rett og slett ingen koordinatdata
// (det store flertallet av runder, TeeOff-baner har ingen GolfAPI-kobling)
// -- ingen synlig rad i det hele tatt, ikke en "ingen data"-lapp på HVER
// eneste runde. Lappen (TargetDistance sin egen null-tilstand) er forbeholdt
// "banen HAR data, men venter på GPS-posisjon ennå" -- da vet vi det er
// verdt å vise noe.
if (points === null || points.length === 0) return null
if (!livePosition) {
return <TargetDistance size={size} distances={null} className={className} />
}
const greenPoints = points.filter((p) => p.poi_type === "green" && p.location)
const front = greenPoints.find((p) => p.location === "front")
const middle = greenPoints.find((p) => p.location === "middle")
const back = greenPoints.find((p) => p.location === "back")
if (!front || !middle || !back) {
return <TargetDistance size={size} distances={null} className={className} />
}
const distances = {
front: Math.round(haversineMeters(livePosition, { lat: front.latitude, lng: front.longitude })),
middle: Math.round(haversineMeters(livePosition, { lat: middle.latitude, lng: middle.longitude })),
back: Math.round(haversineMeters(livePosition, { lat: back.latitude, lng: back.longitude })),
}
// ADR-083: ALLE hindringer, ikke bare nærmeste (tidligere reduserte denne
// løkken til ett enkelt nearestHazard-objekt). Avstandene er ALLTID live
// fra spilleren, uavhengig av om et geometrisk diagram kan tegnes under.
// Sortert lengst unna FØRST (2026-08-21, samme retting/begrunnelse som
// groupDiagramHazards under -- listen leses loddrett med greenen
// øverst) -- denne grenen (ingen tee-koordinater) har ingen forkant/
// bakkant-parring i det hele tatt, kun ett enkelt avstandstall per
// punkt, så ingen "bakkant"-nyanse å ta hensyn til her.
const hazardPoints = points.filter((p) => p.poi_type in _HAZARD_LABELS)
// ADR-085 (brukerønske 2026-08-20): passerte hindringer skal ikke vises.
// Denne (tee-referanse mangler) grenen har ingen akse å projisere langs,
// så "mellom spiller og green" tilnærmes med "nærmere green enn
// spilleren selv" -- mindre presist enn along-percent-varianten under
// (tar ikke hensyn til sideveis avstand), men fungerer som en fornuftig
// proxy for det sjeldne tilfellet en bane mangler tee-koordinater.
const hazardPointsAhead = hazardPoints.filter(
(h) => haversineMeters({ lat: h.latitude, lng: h.longitude }, { lat: middle.latitude, lng: middle.longitude }) < distances.middle,
)
const hazards = hazardPointsAhead
.map((h) => {
const base = _HAZARD_LABELS[h.poi_type]
const label =
h.location === "front" || h.location === "back"
? `${base} (${h.location === "front" ? "front" : "bak"})`
: base
return { label, distanceMeters: Math.round(haversineMeters(livePosition, { lat: h.latitude, lng: h.longitude })), lat: h.latitude, lng: h.longitude }
})
.sort((a, b) => b.distanceMeters - a.distanceMeters)
// Fast tee-referanse (IKKE spillerens bevegelige livePosition) for selve
// hull-diagrammet -- se ADR-083. Midtpunkt hvis begge tee_front/tee_back
// finnes, ellers den ene som finnes. Mangler begge (forekommer, ikke alle
// baner har tee-koordinater): faller tilbake til den flate listen under,
// ingen regresjon.
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
if (teeRef) {
const greenRef = { lat: middle.latitude, lng: middle.longitude }
const player = projectOntoAxis(teeRef, greenRef, livePosition)
const diagramHazards = groupDiagramHazards(hazardPoints, livePosition, teeRef, greenRef, player.alongPercent)
return (
<HoleDiagram
size={size}
className={className}
distances={distances}
playerPosition={player}
hazards={diagramHazards}
/>
)
}
return (
<TargetDistance size={size} distances={distances} hazards={hazards} className={className} />
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,71 +0,0 @@
"use client"
// Språkbryter (ADR-094) -- setter locale-cookien direkte (samme cookie
// i18n/request.ts leser) og PATCHer /profile sin preferred_locale når
// innlogget, slik at valget følger brukeren til neste enhet/økt også
// (ikke bare denne nettleseren). cookie alene er nok til at selve
// visningen endres umiddelbart -- PATCH-en er en "husk til neste gang"-
// bonus, feiler stille (utlogget bruker har ingenting å PATCHe mot).
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useLocale, useTranslations } from "next-intl"
import { cn } from "@/lib/utils"
import { LOCALE_COOKIE, SUPPORTED_LOCALES, type Locale } from "@/lib/locale"
export function LanguageSwitcher() {
const locale = useLocale()
const t = useTranslations("common.languageSwitcher")
const router = useRouter()
const [pending, setPending] = useState(false)
async function setLocale(next: Locale) {
if (next === locale || pending) return
setPending(true)
document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=31536000; SameSite=Lax`
try {
await fetch("/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ preferred_locale: next }),
})
} catch {
// Stille -- cookien er allerede satt, selve visningen fungerer uansett.
}
router.refresh()
setPending(false)
}
const labels: Record<Locale, string> = { nb: t("norwegian"), en: t("english") }
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-bold text-foreground">{t("label")}</span>
<div role="radiogroup" aria-label={t("label")} className="flex gap-2">
{SUPPORTED_LOCALES.map((code) => {
const selected = code === locale
return (
<button
key={code}
type="button"
role="radio"
aria-checked={selected}
disabled={pending}
onClick={() => setLocale(code)}
className={cn(
"min-h-11 flex-1 rounded-xl border px-4 text-sm font-bold transition-all duration-200 ease-in-out",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{labels[code]}
</button>
)
})}
</div>
</div>
)
}

View file

@ -1,45 +0,0 @@
"use client"
// i18n (ADR-094) -- monteres én gang i rot-layouten. Selve locale-
// oppløsningen på serveren (i18n/request.ts) leser KUN en cookie, uten
// noen fetch-avhengighet (rask, synkron). Denne komponenten er stedet
// der brukerens preferred_locale (allerede på backend siden ADR-015,
// aldri lest av frontend før nå) faktisk får virkning: FØRSTE gang en
// innlogget bruker besøker appen uten at cookien er satt ennå, hentes
// /auth/me og cookien settes deretter -- kun én gang, ikke ved hvert
// besøk (cookien vinner etter det, selv om brukeren senere endrer
// preferred_locale et annet sted, inntil de bruker språkbryteren igjen).
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { DEFAULT_LOCALE, isLocale, LOCALE_COOKIE } from "@/lib/locale"
function hasLocaleCookie(): boolean {
return document.cookie.split("; ").some((c) => c.startsWith(`${LOCALE_COOKIE}=`))
}
export function LocaleBootstrap() {
const router = useRouter()
useEffect(() => {
if (hasLocaleCookie()) return
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((me: { preferred_locale?: string } | null) => {
if (cancelled || !me) return
const locale = isLocale(me.preferred_locale) ? me.preferred_locale : DEFAULT_LOCALE
if (locale === DEFAULT_LOCALE) return // allerede det serveren rendret med
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`
router.refresh()
})
.catch(() => {
// Stille -- utlogget/nettverksfeil betyr bare at nb-standarden
// (allerede rendret) beholdes.
})
return () => {
cancelled = true
}
}, [router])
return null
}

View file

@ -10,9 +10,8 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Bell, Building2, ChevronRight, CircleHelp, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
import { BottomNav } from "@/components/teecup/bottom-nav"
import { LanguageSwitcher } from "@/components/language-switcher"
const C = {
card: "#ffffff",
@ -67,8 +66,7 @@ export function MoreMenu() {
<MenuLink href="/account" icon={UserCircle} label="Konto" />
<MenuLink href="/my-friends" icon={UserPlus} label="Venner" />
<MenuLink href="/my-feed" icon={MessageSquare} label="Feed" />
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" />
<MenuLink href="/hjelp" icon={CircleHelp} label="Hjelp" last={me?.organizations.length === 0} />
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" last={me?.organizations.length === 0} />
{me?.organizations.map((o, i) => (
<MenuLink
key={o.organization_id}
@ -80,10 +78,6 @@ export function MoreMenu() {
))}
</ul>
<div className="mt-6 rounded-2xl p-4" style={{ border: `1px solid ${C.border}`, backgroundColor: C.card }}>
<LanguageSwitcher />
</div>
<button
type="button"
onClick={handleLogout}

View file

@ -216,10 +216,10 @@ export function Segmented<T extends string>({
aria-checked={active}
onClick={() => onChange(o.value)}
className={cn(
"min-h-9 flex-1 rounded-md border px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
"min-h-9 flex-1 rounded-md px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
active
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
: "border-transparent text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm"
: "text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
)}
>
{o.label}

View file

@ -5,13 +5,10 @@ import { useEffect, useMemo, useRef, useState } from "react"
import {
fetchFacilityCourses,
fetchOwnCourse,
importInternationalCourse,
nearbyOfficialFacilities,
searchInternationalClubs,
searchOfficialFacilities,
searchOwnCourses,
} from "@/lib/ny-runde/api"
import type { ApiInternationalClub, ApiInternationalCourseOption } from "@/lib/ny-runde/api"
import type { Course, Facility } from "@/lib/ny-runde/types"
import { CreateCourseForm } from "./create-course-form"
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
@ -28,16 +25,19 @@ export function Step1CourseTime() {
}
const compatible = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
const tee = compatible[0]
// Eierens EGET spillerkort (steg 3) sitt teeId-felt synkroniseres nå
// løpende fra state.teeId av en egen effekt i wizard-context.tsx
// (fanger også senere endringer i "Bane & tid"-bekreftelsen, ikke bare
// dette førstevalget) -- ingen manuell synk nødvendig her lenger.
patch({
course,
courseMeta: meta,
courseSource: meta.source === "teeoff" ? "official" : "own",
teeId: tee?.id,
s1Sub: "fields",
// Eierens EGET spillerkort (steg 3) har sitt eget teeId-felt, atskilt
// fra state.teeId over -- uten denne synkroniseringen viser kortet
// "Ikke valgt" selv om et utslag faktisk er valgt (kun kosmetisk, selve
// innsendingen leser state.teeId direkte for eieren -- men samme
// synkronisering som den opprinnelige new-round.tsx sin chooseCourse()
// gjorde, så oppførselen matcher eksakt).
players: state.players.map((p) => (p.kind === "owner" ? { ...p, teeId: tee?.id } : p)),
})
}
@ -86,14 +86,6 @@ export function Step1CourseTime() {
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
onInternational={() => patch({ s1Sub: "international-search" })}
/>
)
case "international-search":
return (
<InternationalSearch
onBack={() => patch({ s1Sub: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
/>
)
case "own-create":
@ -378,13 +370,11 @@ function OwnSearch({
onBack,
onCreate,
onPick,
onInternational,
}: {
templateMode: boolean
onBack: () => void
onCreate: () => void
onPick: (c: Course) => void
onInternational: () => void
}) {
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
@ -474,171 +464,6 @@ function OwnSearch({
</p>
)}
</div>
<button
type="button"
onClick={onInternational}
className="mt-4 flex min-h-11 w-full items-center justify-center gap-2 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Fant ikke banen? Søk internasjonalt (GolfAPI)
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
/* international-search (GolfAPI.io, ADR-064) -- for baner utenfor */
/* teeoffs dekning (Tjøme Golfklubb var den konkrete anledningen). */
/* Importerte baner havner i samme personal_course-katalog som "egen */
/* bane" -- onPick gir tilbake en helt vanlig Course, ingen egen */
/* CourseMeta-variant trengs (speiler OwnSearch/tournament-program.tsx). */
function InternationalSearch({
onBack,
onPick,
}: {
onBack: () => void
onPick: (c: Course) => void
}) {
const [query, setQuery] = useState("")
const [clubs, setClubs] = useState<ApiInternationalClub[] | null>(null)
const [selectedClub, setSelectedClub] = useState<ApiInternationalClub | null>(null)
const [searching, setSearching] = useState(false)
const [importingId, setImportingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
async function runSearch() {
if (!query.trim()) return
setSearching(true)
setError(null)
try {
setClubs(await searchInternationalClubs(query.trim()))
} finally {
setSearching(false)
}
}
async function pick(course: ApiInternationalCourseOption) {
setImportingId(course.course_id)
setError(null)
try {
onPick(await importInternationalCourse(course.course_id))
} catch {
setError("Klarte ikke å importere banen fra GolfAPI. Prøv igjen.")
} finally {
setImportingId(null)
}
}
return (
<div>
<SubHeader
title={selectedClub ? selectedClub.club_name : "Søk internasjonalt"}
subtitle={selectedClub ? "Velg en bane hos klubben." : "For baner utenfor TeeOffs dekning, via GolfAPI."}
onBack={selectedClub ? () => setSelectedClub(null) : onBack}
/>
{error ? (
<p role="alert" className="mb-3 rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-4 py-3 text-sm text-[var(--nr-danger)]">
{error}
</p>
) : null}
{!selectedClub ? (
<>
<Field label="Klubbnavn" htmlFor="intl-q">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="intl-q"
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
void runSearch()
}
}}
placeholder="Søk klubbnavn…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
<button
type="button"
onClick={() => void runSearch()}
disabled={searching}
className="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-xl bg-[var(--nr-accent)] px-4 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-95 disabled:opacity-50"
>
{searching ? "Søker…" : "Søk"}
</button>
<div className="mt-4">
{clubs === null ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Begynn å skrive for å søke etter en klubb.
</p>
) : clubs.length > 0 ? (
<ul className="flex flex-col gap-2">
{clubs.map((c) => (
<li key={c.club_id}>
<button
type="button"
onClick={() => setSelectedClub(c)}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Building2 className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{c.club_name}</span>
<span className="block text-xs text-[var(--nr-muted)]">{[c.city, c.country].filter(Boolean).join(", ") || " "}</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
</button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Ingen klubber matcher «{query}».
</p>
)}
</div>
</>
) : (
<ul className="flex flex-col gap-2">
{selectedClub.courses.map((c) => (
<li key={c.course_id}>
<button
type="button"
disabled={importingId !== null}
onClick={() => void pick(c)}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:opacity-50"
>
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{c.course_name}</span>
<span className="block text-xs text-[var(--nr-muted)]">
{c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
</span>
</span>
{importingId === c.course_id ? (
<span className="text-xs font-medium text-[var(--nr-muted)]">Importerer</span>
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
)}
</button>
</li>
))}
{selectedClub.courses.length === 0 ? (
<li className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Ingen baner registrert hos denne klubben hos GolfAPI ennå.
</li>
) : null}
</ul>
)}
</div>
)
}

View file

@ -80,13 +80,7 @@ export function Step3Players() {
{adding ? (
<AddPlayerPanel
course={state.course}
// Standard for EN ANNEN spiller enn den som fører score er "Kun
// slag", uansett hva scorefører selv har valgt for seg selv
// (2026-08-20 brukerfunn -- speilet tidligere feilaktig
// state.statLevel, som normalt er scoreførerens EGET, ofte
// høyere nivå). Organisatoren kan fortsatt sette et høyere nivå
// manuelt per medspiller.
defaultStat="score"
defaultStat={state.statLevel}
existing={state.players}
onCancel={() => setAdding(false)}
onAdd={addPlayer}
@ -236,30 +230,10 @@ function PlayerCard({
/>
</Field>
) : player.hcpFromProfile && !isOwner ? (
// Redigerbar fra FØRSTE stund (2026-08-20 brukerfunn) -- var
// tidligere en read-only visning ("hentes fra profilen"), som
// tvang organisatoren til å legge til spilleren FØRST og
// overstyre HCP-en senere via "Runde og spillere" (der
// overstyring allerede fungerte, samme PATCH-felt). Forhånds-
// utfylt med profilverdien; urørt betyr fortsatt "bruk
// profilen", akkurat som før.
<Field
label="HCP"
optional
htmlFor={`${player.key}-hcp`}
hint="Hentes fra spillerens profil dersom du lar feltet stå urørt."
>
<TextInput
id={`${player.key}-hcp`}
inputMode="decimal"
placeholder={player.hcp == null ? "Henter fra profil…" : undefined}
value={player.hcp != null ? String(player.hcp) : ""}
onChange={(e) => {
const v = e.target.value
onChange({ hcp: v.trim() === "" ? null : Number.parseFloat(v.replace(",", ".")) })
}}
className="max-w-[10rem]"
/>
<Field label="HCP">
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
{player.hcp != null ? `${player.hcp} · hentes fra profilen` : "Hentes fra profilen…"}
</div>
</Field>
) : null}

View file

@ -228,31 +228,6 @@ export function WizardProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Eierens EGET teeId/statLevel-felt i players[] (lest av steg 3 sitt
// spillerkort, OG -- for statLevel -- av submit() sin faktiske innsending,
// se linje ~317) er egne, atskilte felt fra state.teeId/state.statLevel
// (steg 1/steg 2 sine felt). De ble tidligere kun synkronisert ÉN gang,
// idet banen først velges (pickCourse i step1-course-time.tsx) -- endrer
// brukeren utslag i "Bane & tid"-bekreftelsen ETTERPÅ, eller statistikknivå
// i "Spilleform", flyter de to fra hverandre. For utslag var dette rent
// kosmetisk (steg 3 viste et gammelt utslag, men submit() leser
// state.teeId direkte for eieren) -- for statistikknivå var det en REELL
// datafeil: "Statistikk for deg selv"-valget i steg 2 hadde INGEN effekt
// på selve innsendingen for eieren (kun på default for NYE medspillere via
// defaultStat-proppen), siden owner.statLevel forble hardkodet "score" fra
// loadMe() over. Bruker-rapportert regresjon 2026-08-15 (video vedlagt) --
// holder nå de to i kontinuerlig synk fremfor kun ved første valg.
useEffect(() => {
setState((s) => {
const owner = s.players.find((p) => p.kind === "owner")
if (!owner || (owner.teeId === s.teeId && owner.statLevel === s.statLevel)) return s
return {
...s,
players: s.players.map((p) => (p.kind === "owner" ? { ...p, teeId: s.teeId, statLevel: s.statLevel } : p)),
}
})
}, [state.teeId, state.statLevel])
const hasTeamStep = showsTeamStep(state.format)
const visibleSteps = useMemo(() => (hasTeamStep ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]), [hasTeamStep])
@ -285,8 +260,6 @@ export function WizardProvider({
return { ...s, s1Sub: "official-search" }
case "own-search":
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
case "international-search":
return { ...s, s1Sub: "own-search" }
case "own-create":
return { ...s, s1Sub: s.ownCreateOrigin }
case "fields":
@ -424,13 +397,6 @@ export function WizardProvider({
}
if (p.accountId) {
participantBody.user_id = p.accountId
// Overstyring fra FØRSTE stund (2026-08-20 brukerfunn) -- backend
// sin add_participant faller tilbake til spillerens profil-HCP
// når dette utelates/er null, akkurat som PATCH-en "Runde og
// runde"-panelet allerede bruker for samme overstyring senere.
// Uendret (`p.hcp` = profilverdien) sendes derfor trygt som
// no-op når organisatoren ikke har rørt feltet.
participantBody.handicap_index = p.hcp ?? null
} else {
participantBody.guest_first_name = p.firstName.trim()
participantBody.guest_last_name = p.lastName.trim() || null

View file

@ -9,7 +9,7 @@
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Check, ChevronDown, Link2, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
import { ArrowLeft, ChevronDown, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -18,16 +18,7 @@ import { cn } from "@/lib/utils"
type OomKind = "player" | "team"
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
type OomAggregationMode = "sum" | "average" | "eclectic"
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype med
// per-hull-granularitet (stableford/brutto/netto) -- håndhevet server-side
// (order_of_merit.py). Kind/resultattype er låst etter opprettelse her, så
// ingen reset-logikk trengs (i motsetning til order-of-merit-list.tsx sitt
// opprettelsesskjema).
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
}
type OomAggregationMode = "sum" | "average"
type ApiOrderOfMerit = {
id: string
@ -64,12 +55,6 @@ type ApiLeaderboardEntry = {
type ApiTournament = { id: string; name: string; format_type: string }
// Lag-OOM (ADR-043 Beslutning C, ADR-074) -- sesong-par satt opp DIREKTE i
// OOM-en, ikke turnering-lag. Kun relevant når oom.kind === "team".
type ApiOomTeamMember = { id: string; player_id: string; player_name: string }
type ApiOomTeam = { id: string; name: string; members: ApiOomTeamMember[] }
type ApiPlayer = { id: string; display_name: string }
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
points: "Poeng etter plassering",
@ -98,8 +83,6 @@ export function OrderOfMeritDetail({
const [links, setLinks] = useState<ApiLink[] | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
const [teams, setTeams] = useState<ApiOomTeam[] | null>(null)
const [players, setPlayers] = useState<ApiPlayer[]>([])
const [error, setError] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false)
@ -131,16 +114,6 @@ export function OrderOfMeritDetail({
if (res.ok) setAllTournaments(await res.json())
}
async function loadTeams() {
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, { credentials: "include" })
if (res.ok) setTeams(await res.json())
}
async function loadPlayers() {
const res = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
if (res.ok) setPlayers(await res.json())
}
useEffect(() => {
void loadOom()
void loadLinks()
@ -149,12 +122,7 @@ export function OrderOfMeritDetail({
}, [organizationId, oomId])
useEffect(() => {
if (!oom) return
void loadLeaderboard()
if (oom.kind === "team") {
void loadTeams()
void loadPlayers()
}
if (oom?.kind === "player") void loadLeaderboard()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [oom?.kind, links])
@ -192,65 +160,6 @@ export function OrderOfMeritDetail({
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
}
async function createTeam(name: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) {
setError("Klarte ikke å opprette laget.")
return
}
await loadTeams()
}
async function deleteTeam(teamId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) {
setError("Klarte ikke å slette laget.")
return
}
setTeams((prev) => (prev ?? []).filter((t) => t.id !== teamId))
await loadLeaderboard()
}
async function addMember(teamId: string, playerId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: playerId }),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren.")
return
}
await loadTeams()
await loadLeaderboard()
}
async function removeMember(teamId: string, memberId: string) {
setError(null)
const res = await fetch(
`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members/${memberId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) {
setError("Klarte ikke å fjerne spilleren.")
return
}
await loadTeams()
await loadLeaderboard()
}
async function saveSettings(patch: Record<string, unknown>) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
@ -348,18 +257,7 @@ export function OrderOfMeritDetail({
<SettingsSection oom={oom} onSave={saveSettings} />
{oom.kind === "team" && (
<TeamsSection
teams={teams}
players={players}
onCreateTeam={createTeam}
onDeleteTeam={deleteTeam}
onAddMember={addMember}
onRemoveMember={removeMember}
/>
)}
<LeaderboardSection resultType={oom.result_type} entries={leaderboard} />
<LeaderboardSection kind={oom.kind} resultType={oom.result_type} entries={leaderboard} />
</main>
</div>
)
@ -665,25 +563,14 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Aggregering</span>
<div className={`grid grid-cols-2 gap-2 ${eclecticAllowed(oom.kind, oom.result_type) ? "sm:grid-cols-3" : ""}`}>
<div className="grid grid-cols-2 gap-2">
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum
</SettingsSegment>
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt
</SettingsSegment>
{eclecticAllowed(oom.kind, oom.result_type) && (
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
Eclectic
</SettingsSegment>
)}
</div>
{aggregationMode === "eclectic" && (
<p className="text-xs text-muted-foreground">
Eclectic krever at alle lenkede turneringer spilles samme bane -- lenking av en turnering en annen
bane avvises lenge denne modusen er valgt.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
@ -736,12 +623,6 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
</label>
{oom.public_visible ? (
<PublicLinkChip oomId={oom.id} />
) : publicVisible ? (
<p className="text-xs text-muted-foreground">Lenken blir aktiv når du lagrer.</p>
) : null}
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
{saving ? "Lagrer…" : "Lagre"}
</Button>
@ -751,34 +632,6 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
)
}
function PublicLinkChip({ oomId }: { oomId: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
const url = `${window.location.origin}/order-of-merit/${oomId}`
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// stille -- ikke kritisk
}
}
return (
<button
type="button"
onClick={handleCopy}
className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2.5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
{copied ? (
<Check aria-hidden="true" className="size-4 text-primary" />
) : (
<Link2 aria-hidden="true" className="size-4 text-muted-foreground" />
)}
{copied ? "Lenke kopiert" : "Del offentlig lenke"}
</button>
)
}
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
@ -798,9 +651,11 @@ function SettingsSegment({ selected, onClick, children }: { selected: boolean; o
// --- D. Resultatliste ----------------------------------------------------------
function LeaderboardSection({
kind,
resultType,
entries,
}: {
kind: OomKind
resultType: OomResultType
entries: ApiLeaderboardEntry[] | null
}) {
@ -813,7 +668,12 @@ function LeaderboardSection({
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
</div>
{entries === null ? (
{kind === "team" ? (
<div className="rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-6 text-center">
<Users aria-hidden="true" className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Lag-resultatliste er ikke bygget ennå.</p>
</div>
) : entries === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : (
<LeaderboardTable entries={entries} resultType={resultType} />
@ -822,193 +682,6 @@ function LeaderboardSection({
)
}
// --- C.5 Lag (kun kind === "team", ADR-043 Beslutning C, ADR-074) ----------
// Sesong-par satt opp DIREKTE i OOM-en -- IKKE turnering-lag. Samme
// liste+skjema-mønster som "Lenkede turneringer" over.
function TeamsSection({
teams,
players,
onCreateTeam,
onDeleteTeam,
onAddMember,
onRemoveMember,
}: {
teams: ApiOomTeam[] | null
players: ApiPlayer[]
onCreateTeam: (name: string) => Promise<void>
onDeleteTeam: (teamId: string) => Promise<void>
onAddMember: (teamId: string, playerId: string) => Promise<void>
onRemoveMember: (teamId: string, memberId: string) => Promise<void>
}) {
const [adding, setAdding] = useState(false)
const [name, setName] = useState("")
const [submitting, setSubmitting] = useState(false)
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!name.trim() || submitting) return
setSubmitting(true)
try {
await onCreateTeam(name.trim())
setName("")
setAdding(false)
} finally {
setSubmitting(false)
}
}
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Lag</h2>
</div>
{teams === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : teams.length === 0 ? (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen lag opprettet ennå -- lag er sesong-par satt opp direkte her, ikke koblet til noe turnering-lag.
</p>
) : (
<ul className="flex flex-col gap-3">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
players={players}
onDelete={() => onDeleteTeam(t.id)}
onAddMember={(playerId) => onAddMember(t.id, playerId)}
onRemoveMember={(memberId) => onRemoveMember(t.id, memberId)}
/>
))}
</ul>
)}
{!adding ? (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
className="h-11 self-start rounded-xl text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Nytt lag
</Button>
) : (
<form onSubmit={handleCreate} className="flex items-center gap-2 rounded-2xl border border-border bg-background p-3">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Lagnavn"
className="h-10 flex-1 rounded-lg text-sm"
/>
<Button type="submit" disabled={!name.trim() || submitting} className="h-10 rounded-lg text-sm font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdding(false)
setName("")
}}
className="h-10 rounded-lg text-sm font-semibold text-muted-foreground"
>
Avbryt
</Button>
</form>
)}
</section>
)
}
function TeamCard({
team,
players,
onDelete,
onAddMember,
onRemoveMember,
}: {
team: ApiOomTeam
players: ApiPlayer[]
onDelete: () => Promise<void>
onAddMember: (playerId: string) => Promise<void>
onRemoveMember: (memberId: string) => Promise<void>
}) {
const memberPlayerIds = new Set(team.members.map((m) => m.player_id))
const availablePlayers = players.filter((p) => !memberPlayerIds.has(p.id))
const [selectedPlayerId, setSelectedPlayerId] = useState("")
return (
<li className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-foreground">{team.name}</span>
<Button
type="button"
variant="ghost"
onClick={onDelete}
className="h-9 shrink-0 rounded-lg px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett lag
</Button>
</div>
{team.members.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-xl border border-border">
{team.members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-sm text-foreground">{m.player_name}</span>
<Button
type="button"
variant="ghost"
onClick={() => onRemoveMember(m.id)}
className="h-8 shrink-0 rounded-lg px-2 text-xs font-semibold text-muted-foreground hover:text-destructive"
>
Fjern
</Button>
</li>
))}
</ul>
)}
{availablePlayers.length > 0 && (
<div className="flex items-center gap-2">
<select
value={selectedPlayerId}
onChange={(e) => setSelectedPlayerId(e.target.value)}
className="h-10 flex-1 rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Velg spiller</option>
{availablePlayers.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
</option>
))}
</select>
<Button
type="button"
variant="outline"
disabled={!selectedPlayerId}
onClick={() => {
void onAddMember(selectedPlayerId)
setSelectedPlayerId("")
}}
className="h-10 shrink-0 rounded-lg text-sm font-semibold"
>
Legg til
</Button>
</div>
)}
</li>
)
}
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
const eligible = entries.filter((e) => e.eligible)
const ineligible = entries.filter((e) => !e.eligible)

View file

@ -19,15 +19,7 @@ import { cn } from "@/lib/utils"
type OomKind = "player" | "team"
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
type OomAggregationMode = "sum" | "average" | "eclectic"
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype
// med per-hull-granularitet (stableford/brutto/netto) -- håndhevet
// server-side (order_of_merit.py), speilet her for å skjule valget når
// det uansett ville blitt avvist.
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
}
type OomAggregationMode = "sum" | "average"
type ApiOrderOfMerit = {
id: string
@ -50,11 +42,7 @@ const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
net: "Nettoscore-sum",
money: "Pengeliste",
}
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = {
sum: "Sum",
average: "Snitt",
eclectic: "Eclectic (drømmerunde)",
}
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = { sum: "Sum", average: "Snitt" }
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
const router = useRouter()
@ -241,17 +229,6 @@ function CreateForm({
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
const [submitting, setSubmitting] = useState(false)
const showEclectic = eclecticAllowed(kind, resultType)
function selectKind(k: OomKind) {
setKind(k)
if (aggregationMode === "eclectic" && !eclecticAllowed(k, resultType)) setAggregationMode("sum")
}
function selectResultType(rt: OomResultType) {
setResultType(rt)
if (aggregationMode === "eclectic" && !eclecticAllowed(kind, rt)) setAggregationMode("sum")
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim() || submitting) return
@ -284,10 +261,10 @@ function CreateForm({
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Type</span>
<div className="grid grid-cols-2 gap-2">
<SegmentButton selected={kind === "player"} onClick={() => selectKind("player")}>
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}>
Spiller
</SegmentButton>
<SegmentButton selected={kind === "team"} onClick={() => selectKind("team")}>
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}>
Lag
</SegmentButton>
</div>
@ -297,7 +274,7 @@ function CreateForm({
<span className="text-sm font-semibold text-foreground">Resultattype</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
<SegmentButton key={rt} selected={resultType === rt} onClick={() => selectResultType(rt)}>
<SegmentButton key={rt} selected={resultType === rt} onClick={() => setResultType(rt)}>
{RESULT_TYPE_LABELS[rt]}
</SegmentButton>
))}
@ -306,18 +283,13 @@ function CreateForm({
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Aggregering</span>
<div className={`grid grid-cols-2 gap-2 ${showEclectic ? "sm:grid-cols-3" : ""}`}>
<div className="grid grid-cols-2 gap-2">
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum
</SegmentButton>
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt
</SegmentButton>
{showEclectic && (
<SegmentButton selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
Eclectic
</SegmentButton>
)}
</div>
</div>

View file

@ -8,7 +8,7 @@
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users, UsersRound } from "lucide-react"
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -181,22 +181,9 @@ export function OrgMembers({ organizationId, orgName }: { organizationId: string
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
<Link
href={`/organizations/${organizationId}/players?name=${encodeURIComponent(orgName)}`}
className="mt-6 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<UsersRound aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-foreground">Spillerpool</span>
<span className="text-xs text-muted-foreground">Se og rediger alle spillere organisasjonen har registrert</span>
</div>
</Link>
<Link
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
className="mt-3 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
className="mt-6 flex min-h-11 items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />

View file

@ -1,294 +0,0 @@
"use client"
// Rediger HELE organisasjonens spillerpool, når som helst -- ikke bare de
// spillerne som tilfeldigvis dukker opp i en turnerings deltakerliste
// (2026-08-18, "lettere krise": brukeren kunne ikke redigere spillere i
// org-turneringer, ba eksplisitt om at tabellen fra spillerimporten (ADR-082)
// skal være tilgjengelig for redigering til enhver tid, ikke bare under en
// fersk CSV-import).
//
// Gjenbruker `PlayerImportView` (V0-eksportert, se player-import-view.tsx)
// UENDRET som selve tabell-UI-et via en ny `mode.kind === "roster"` --
// ORKESTRERINGEN her er ny og annerledes enn player-import-panel.tsx sin:
// radene starter FYLT UT fra `GET /orgs/{id}/players` (ikke en opplastet
// CSV), og "Lagre" gjør ekte PATCH-per-endret-felt mot eksisterende
// spillere (IKKE `/players/bulk`, som kun fyller TOMME felt og aldri
// overskriver et allerede satt navn/verdi -- feil oppførsel for en ekte
// "rett dette feltet"-redigeringsskjerm, se app/routers/players.py sin
// egen COALESCE-forklaring). Nye rader (lagt til i tabellen, uten kjent
// spiller-id) opprettes med vanlig POST, én om gangen.
import { useEffect, useState } from "react"
import { PlayerImportView, type DraftRow, type FieldKey } from "@/components/player-import-view"
const EMPTY_ROW: Omit<DraftRow, "key"> = {
display_name: "",
email: "",
handicap_index: "",
gender: "",
birth_date: "",
mobile: "",
club: "",
nickname: "",
country: "",
club_member_number: "",
first_name: "",
last_name: "",
comment: "",
team_name: "",
class_name: "",
}
// Speiler player-import-panel.tsx sin gjeng-konvertering, men begge veier
// -- radene her starter fra EKTE backend-data (m/f/x), ikke rå CSV-tekst.
function fromBackendGender(value: string | null): string {
if (value === "f") return "female"
if (value === "m") return "male"
if (value === "x") return "other"
return ""
}
function toBackendGender(value: string): string | null {
if (value === "female") return "f"
if (value === "male") return "m"
if (value === "other") return "x"
return null
}
type ApiPlayer = {
id: string
display_name: string
handicap_index: number | null
gender: string | null
mobile: string | null
email: string | null
birth_date: string | null
nickname: string | null
country: string | null
club: string | null
club_member_number: string | null
first_name: string | null
last_name: string | null
paid: boolean
comment: string | null
}
// De feltene et rad-objekt kan avvike fra originalen på -- team_name/
// class_name finnes ikke i roster-modus (ingen turneringskontekst), og
// display_name/email/gender/birth_date/handicap_index håndteres med egen
// konvertering ved diff, ikke rå strenglikhet.
const PLAYER_FIELDS: FieldKey[] = [
"display_name",
"email",
"handicap_index",
"gender",
"birth_date",
"mobile",
"club",
"nickname",
"country",
"club_member_number",
"first_name",
"last_name",
"comment",
]
function playerToRow(p: ApiPlayer): DraftRow {
return {
key: p.id,
...EMPTY_ROW,
display_name: p.display_name,
email: p.email ?? "",
handicap_index: p.handicap_index !== null ? String(p.handicap_index) : "",
gender: fromBackendGender(p.gender),
birth_date: p.birth_date ?? "",
mobile: p.mobile ?? "",
club: p.club ?? "",
nickname: p.nickname ?? "",
country: p.country ?? "",
club_member_number: p.club_member_number ?? "",
first_name: p.first_name ?? "",
last_name: p.last_name ?? "",
comment: p.comment ?? "",
}
}
export function OrgPlayerRosterPanel({
organizationId,
onClose,
highlightPlayerId,
}: {
organizationId: string
onClose: () => void
// Direkte kobling fra en turneringsside (2026-08-18, "det bør dessuten
// være kobling direkte mellom spillerpool og turneringsside") -- når
// satt, scroller panelet til nettopp denne spilleren sin rad og gir
// den et kort visuelt blink, i stedet for at organisator må lete den
// opp manuelt i en potensielt lang tabell.
highlightPlayerId?: string | null
}) {
const [step, setStep] = useState<"loading" | "edit" | "saving" | "done">("loading")
const [rows, setRows] = useState<DraftRow[]>([])
// Nøkkelen for en rad som ALLEREDE var en ekte spiller er selve
// spiller-id-en (satt i playerToRow over) -- originalById lar handleSave
// vite hvilke felt som faktisk endret seg, slik at PATCH-en kun sender
// det som er rørt (samme prinsipp som backend sin `exclude_unset`).
const [originalById, setOriginalById] = useState<Record<string, DraftRow>>({})
const [error, setError] = useState<string | null>(null)
const [saveWarnings, setSaveWarnings] = useState<string[]>([])
useEffect(() => {
let cancelled = false
fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`players: ${res.status}`))))
.then((players: ApiPlayer[]) => {
if (cancelled) return
const mapped = players.map(playerToRow)
setRows(mapped)
setOriginalById(Object.fromEntries(mapped.map((r) => [r.key, r])))
setStep("edit")
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente spillerne. Prøv å laste siden på nytt.")
})
return () => {
cancelled = true
}
}, [organizationId])
// Scroll-til-spiller (2026-08-18) -- bruker `data-row-key` (satt i
// player-import-view.tsx sin EditTable, et rent orkestrerings-anker,
// se dens egen kommentar) i stedet for å endre selve V0-komponentens
// props-kontrakt. Kjøres når raden faktisk er i DOM-en (step "edit").
useEffect(() => {
if (step !== "edit" || !highlightPlayerId) return
const el = document.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(highlightPlayerId)}"]`)
if (!el) return
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-inset")
const timeout = setTimeout(() => el.classList.remove("ring-2", "ring-primary", "ring-inset"), 2500)
return () => clearTimeout(timeout)
}, [step, highlightPlayerId, rows])
function updateCell(key: string, field: FieldKey, value: string) {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r)))
}
function addBlankRow() {
setRows((prev) => [...prev, { key: `new-${Date.now()}`, ...EMPTY_ROW }])
}
function removeRow(key: string) {
// Fjerner KUN raden fra denne redigeringsøkten -- sletter ikke
// spilleren (ingen slette-endepunkt finnes ennå, se kommentar i
// player-import-view.tsx sin EditTable-beskrivelse for roster-modus).
setRows((prev) => prev.filter((r) => r.key !== key))
}
async function handleSave() {
setStep("saving")
setError(null)
setSaveWarnings([])
const warnings: string[] = []
try {
for (const row of rows) {
const isExisting = row.key in originalById
if (isExisting) {
const original = originalById[row.key]
const patch: Record<string, unknown> = {}
for (const field of PLAYER_FIELDS) {
if (row[field] === original[field]) continue
if (field === "gender") {
patch.gender = toBackendGender(row.gender)
} else if (field === "handicap_index") {
// Komma-desimal skal fungere likt som punktum (2026-08-19).
patch.handicap_index = row.handicap_index.trim()
? Number(row.handicap_index.replace(",", "."))
: null
} else if (field === "birth_date") {
patch.birth_date = row.birth_date.trim() || null
} else {
patch[field] = row[field].trim() || null
}
}
// display_name er required (min_length=1) på backend -- aldri
// send en tom streng, selv om raden ble tømt ved et uhell.
if ("display_name" in patch && !String(patch.display_name ?? "").trim()) {
delete patch.display_name
}
if (Object.keys(patch).length === 0) continue
const res = await fetch(`/orgs/${organizationId}/players/${row.key}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) {
warnings.push(`${row.display_name || "(uten navn)"}: klarte ikke å lagre endringen.`)
}
} else if (row.display_name.trim()) {
// Ny rad (lagt til i tabellen denne økten) -- ekte oppretting,
// én om gangen (samme mønster som player-import-panel.tsx).
const res = await fetch(`/orgs/${organizationId}/players`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
display_name: row.display_name.trim(),
email: row.email.trim() || null,
handicap_index: row.handicap_index.trim() ? Number(row.handicap_index.replace(",", ".")) : null,
gender: toBackendGender(row.gender),
birth_date: row.birth_date.trim() || null,
mobile: row.mobile.trim() || null,
club: row.club.trim() || null,
nickname: row.nickname.trim() || null,
country: row.country.trim() || null,
club_member_number: row.club_member_number.trim() || null,
first_name: row.first_name.trim() || null,
last_name: row.last_name.trim() || null,
comment: row.comment.trim() || null,
}),
})
if (!res.ok) {
warnings.push(`${row.display_name}: klarte ikke å opprette spilleren.`)
}
}
}
setSaveWarnings(warnings)
setStep("done")
} catch {
setError("Lagringen feilet. Prøv igjen.")
setStep("edit")
}
}
if (step === "loading") {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
return (
<PlayerImportView
mode={{ kind: "roster" }}
step={step}
csvHeaders={[]}
columnMapping={{}}
rows={rows}
errorMessage={error}
saveWarnings={saveWarnings}
onFileSelected={() => {}}
onColumnMappingChange={() => {}}
onConfirmMapping={() => {}}
onCellChange={updateCell}
onDeleteRow={removeRow}
onAddBlankRow={addBlankRow}
onSave={() => void handleSave()}
onClose={onClose}
/>
)
}

View file

@ -1,78 +0,0 @@
"use client"
// Organisasjonens spillerpool -- egen side (2026-08-18), lenket fra
// Medlemmer-siden (org-members.tsx). Se org-player-roster-panel.tsx for
// selve redigeringslogikken; denne filen er kun sideskallet (samme mønster
// som org-members.tsx: sticky header med tilbake-lenke + tittel).
import { useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil } from "lucide-react"
import { OrgPlayerRosterPanel } from "@/components/org-player-roster-panel"
export function OrgPlayers({
organizationId,
orgName,
highlightPlayerId,
}: {
organizationId: string
orgName: string
// Direkte kobling fra en turneringsside (2026-08-18) -- når satt,
// åpner redigerings-panelet AUTOMATISK (ingen ekstra "Rediger
// spillere"-trykk nødvendig) og scroller/blinker til nettopp denne
// spilleren, se org-player-roster-panel.tsx.
highlightPlayerId?: string | null
}) {
const [editing, setEditing] = useState(Boolean(highlightPlayerId))
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href={`/organizations/${organizationId}/members?name=${encodeURIComponent(orgName)}`}
aria-label="Tilbake til medlemmer"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Spillerpool
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{orgName}</h1>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle spillere organisasjonen har registrert, tvers av turneringer. Rediger navn, HCP,
kontaktinfo og andre felt direkte i tabellen.
</p>
<button
type="button"
onClick={() => setEditing(true)}
className="mt-6 flex min-h-11 w-full items-center gap-3 rounded-2xl border border-border bg-card p-4 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
<Pencil aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-sm font-bold text-foreground">Rediger spillere</span>
<span className="text-xs text-muted-foreground">Åpne tabellen -- samme visning som ved spillerimport</span>
</div>
</button>
</main>
{editing && (
<OrgPlayerRosterPanel
organizationId={organizationId}
onClose={() => setEditing(false)}
highlightPlayerId={highlightPlayerId}
/>
)}
</div>
)
}

View file

@ -1,505 +0,0 @@
"use client"
// Massimport av spillere via CSV (2026-08-17, ADR-082). Denne filen er
// ORKESTRERINGSLAGET (CSV-parsing, kolonnegjetting, lagre-orkestrering
// mot ekte API-endepunkter) -- selve VISNINGEN er V0-eksportert
// (player-import-view.tsx, "stor skjerm først"-prompt), en ren
// kontrollert komponent uten egne API-kall. Speiler FlagPlantSheet-
// mønsteret: Claude skriver/eier logikken, V0 eier presentasjonen.
import { useState } from "react"
import Papa from "papaparse"
import { TriangleAlert } from "lucide-react"
import {
PlayerImportView,
type DraftRow,
type FieldKey,
type Mode,
} from "@/components/player-import-view"
const EMPTY_ROW: Omit<DraftRow, "key"> = {
display_name: "",
email: "",
handicap_index: "",
gender: "",
birth_date: "",
mobile: "",
club: "",
nickname: "",
country: "",
club_member_number: "",
first_name: "",
last_name: "",
comment: "",
team_name: "",
class_name: "",
}
// Fuzzy gjetting av kolonnetilknytning -- små bokstaver, uten mellomrom/
// bindestrek/norske spesialtegn, sammenlignet mot vanlige norske/
// engelske varianter. Diakritisk normalisering (æøå -- fikset 2026-08-17,
// se ADR-082 sin "kjent begrensning"-note) lar f.eks. "Kjønn" treffe
// "kjonn" i tabellen under uten at brukeren må skrive om headeren.
const HEADER_GUESSES: Record<string, FieldKey> = {
navn: "display_name",
name: "display_name",
fulltnavn: "display_name",
spillernavn: "display_name",
epost: "email",
email: "email",
mail: "email",
hcp: "handicap_index",
handicap: "handicap_index",
spillehandicap: "handicap_index",
kjonn: "gender",
gender: "gender",
fodselsdato: "birth_date",
fodtdato: "birth_date",
birthdate: "birth_date",
fodt: "birth_date",
mobil: "mobile",
mobile: "mobile",
telefon: "mobile",
phone: "mobile",
klubb: "club",
club: "club",
medlemsnr: "club_member_number",
medlemsnummer: "club_member_number",
fornavn: "first_name",
firstname: "first_name",
etternavn: "last_name",
lastname: "last_name",
lag: "team_name",
lagnavn: "team_name",
team: "team_name",
klasse: "class_name",
class: "class_name",
kommentar: "comment",
comment: "comment",
land: "country",
country: "country",
kallenavn: "nickname",
nickname: "nickname",
}
function normalizeHeader(h: string): string {
return h
.trim()
.toLowerCase()
.replace(/[æä]/g, "a")
.replace(/[øö]/g, "o")
.replace(/å/g, "a")
.replace(/[\s_-]+/g, "")
}
function guessField(header: string): FieldKey | "" {
return HEADER_GUESSES[normalizeHeader(header)] ?? ""
}
// V0-visningens Kjønn-nedtrekk forventer nøyaktig "female"/"male"/"other"
// (se player-import-view.tsx GENDER_OPTIONS) -- rå CSV-tekst ("m"/"f"/
// "Mann"/"Kvinne"/osv.) normaliseres til det vokabularet HER, ved selve
// kolonnetilknytningen, IKKE bare kopiert inn rått. Ukjent verdi -> ""
// (vises som "—", brukeren kan velge manuelt -- ikke tapt data siden det
// bare skjer ved selve mappingen, aldri ved lagring).
const GENDER_VALUE_GUESSES: Record<string, string> = {
m: "male",
mann: "male",
male: "male",
herre: "male",
h: "male",
f: "female",
female: "female",
kvinne: "female",
dame: "female",
k: "female",
x: "other",
annet: "other",
other: "other",
}
function guessGenderValue(raw: string): string {
return GENDER_VALUE_GUESSES[raw.trim().toLowerCase()] ?? ""
}
// V0s visning bruker lesbare engelske verdier for kjønn-nedtrekket
// (female/male/other) -- backend (app/routers/players.py) forventer
// PlayerCreate.gender = ett tegn (^[mfx]$). Oversettes her, i
// orkestreringslaget, IKKE ved å endre V0-visningen.
function toBackendGender(value: string): string | null {
if (value === "female") return "f"
if (value === "male") return "m"
if (value === "other") return "x"
return null
}
type ApiPlayer = {
id: string
display_name: string
handicap_index: number | null
gender: string | null
mobile: string | null
email: string | null
birth_date: string | null
nickname: string | null
country: string | null
club: string | null
club_member_number: string | null
first_name: string | null
last_name: string | null
paid: boolean
comment: string | null
}
type DuplicateAction = "merge" | "overwrite" | "create_new"
// Mulig duplikat (2026-08-18, ekte produksjonsbug): en importrad UTEN
// e-post har ingen pålitelig matchingsnøkkel i seg selv (se players.py
// sin create_players_bulk-docstring for hele historikken), så et
// navnetreff mot en spiller som allerede finnes i poolen blir aldri
// slått sammen automatisk -- for risikabelt, to ulike ekte personer kan
// dele navn. Organisator spørres eksplisitt i stedet, se DuplicateStep.
type DuplicateCandidate = {
row: DraftRow
existing: ApiPlayer
action: DuplicateAction
}
export function PlayerImportPanel({
organizationId,
tournamentId,
mode,
onClose,
onImportComplete,
}: {
organizationId: string
tournamentId: string
mode: Mode
onClose: () => void
onImportComplete: (createdOrUpdated: { player: ApiPlayer; status: string }[]) => void
}) {
const [step, setStep] = useState<"upload" | "map" | "edit" | "duplicates" | "saving" | "done">("upload")
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [csvRows, setCsvRows] = useState<string[][]>([])
const [mapping, setMapping] = useState<Record<number, FieldKey | "">>({})
const [rows, setRows] = useState<DraftRow[]>([])
const [error, setError] = useState<string | null>(null)
const [saveWarnings, setSaveWarnings] = useState<string[]>([])
const [duplicates, setDuplicates] = useState<DuplicateCandidate[]>([])
function handleFile(file: File) {
setError(null)
Papa.parse<string[]>(file, {
skipEmptyLines: true,
complete: (result) => {
const data = result.data
if (data.length === 0) {
setError("Fant ingen rader i filen.")
return
}
const headers = data[0]
const body = data.slice(1)
setCsvHeaders(headers)
setCsvRows(body)
const guessed: Record<number, FieldKey | ""> = {}
headers.forEach((h, i) => {
guessed[i] = guessField(h)
})
setMapping(guessed)
setStep("map")
},
error: () => setError("Klarte ikke å lese filen. Er det en gyldig CSV?"),
})
}
function confirmMapping() {
const mapped: DraftRow[] = csvRows.map((cells, i) => {
const row: DraftRow = { key: `csv-${i}`, ...EMPTY_ROW }
csvHeaders.forEach((_, colIdx) => {
const field = mapping[colIdx]
if (!field) return
const raw = (cells[colIdx] ?? "").trim()
row[field] = field === "gender" ? guessGenderValue(raw) : raw
})
return row
})
setRows(mapped)
setStep("edit")
}
function updateCell(key: string, field: FieldKey, value: string) {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r)))
}
function addBlankRow() {
setRows((prev) => [...prev, { key: `manual-${Date.now()}`, ...EMPTY_ROW }])
}
function removeRow(key: string) {
setRows((prev) => prev.filter((r) => r.key !== key))
}
function teamIdForName(name: string): string | null {
if (mode.kind !== "team") return null
const match = mode.teams.find((t) => t.name.trim().toLowerCase() === name.trim().toLowerCase())
return match?.id ?? null
}
function classIdForName(name: string): string | null {
if (mode.kind !== "individual") return null
const match = mode.classes.find((c) => c.name.trim().toLowerCase() === name.trim().toLowerCase())
return match?.id ?? null
}
// Trykk på "Lagre" i redigeringstabellen -- sjekker FØRST om noen av
// radene ser ut som mulige duplikater (ingen e-post + navnetreff mot en
// spiller som allerede finnes i poolen). Finnes det slike, stopper vi
// her og spør organisator eksplisitt (DuplicateStep) i stedet for å
// stille velge -- se DuplicateCandidate-kommentaren over. Ellers går vi
// rett til doSave().
async function handleSave() {
const validRows = rows.filter((r) => r.display_name.trim().length > 0)
if (validRows.length === 0) {
setError("Ingen rader med navn å lagre.")
return
}
setError(null)
const noEmailRows = validRows.filter((r) => !r.email.trim())
if (noEmailRows.length > 0) {
const existingRes = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
if (existingRes.ok) {
const existingPlayers: ApiPlayer[] = await existingRes.json()
const byName = new Map<string, ApiPlayer>()
for (const p of existingPlayers) byName.set(p.display_name.trim().toLowerCase(), p)
const candidates: DuplicateCandidate[] = noEmailRows
.map((row) => {
const existing = byName.get(row.display_name.trim().toLowerCase())
return existing ? { row, existing, action: "merge" as DuplicateAction } : null
})
.filter((c): c is DuplicateCandidate => c !== null)
if (candidates.length > 0) {
setDuplicates(candidates)
setStep("duplicates")
return
}
}
}
await doSave(validRows, [])
}
async function doSave(validRows: DraftRow[], resolvedDuplicates: DuplicateCandidate[]) {
setStep("saving")
setSaveWarnings([])
try {
const resolutionByRowKey = new Map(resolvedDuplicates.map((d) => [d.row.key, d]))
const payload = validRows.map((r) => {
const resolution = resolutionByRowKey.get(r.key)
return {
data: {
display_name: r.display_name.trim(),
email: r.email.trim() || null,
handicap_index: r.handicap_index.trim() ? Number(r.handicap_index.replace(",", ".")) : null,
gender: toBackendGender(r.gender),
birth_date: r.birth_date.trim() || null,
mobile: r.mobile.trim() || null,
club: r.club.trim() || null,
nickname: r.nickname.trim() || null,
country: r.country.trim() || null,
club_member_number: r.club_member_number.trim() || null,
first_name: r.first_name.trim() || null,
last_name: r.last_name.trim() || null,
comment: r.comment.trim() || null,
},
match_player_id: resolution && resolution.action !== "create_new" ? resolution.existing.id : null,
duplicate_action: resolution?.action ?? null,
}
})
const bulkRes = await fetch(`/orgs/${organizationId}/players/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
})
if (!bulkRes.ok) throw new Error(`bulk: ${bulkRes.status}`)
const results: { player: ApiPlayer; status: string }[] = await bulkRes.json()
const warnings: string[] = []
for (let i = 0; i < validRows.length; i++) {
const row = validRows[i]
const player = results[i]?.player
if (!player) continue
if (mode.kind === "team") {
const teamId = teamIdForName(row.team_name)
if (!teamId) {
if (row.team_name.trim()) {
warnings.push(`${row.display_name}: fant ikke laget "${row.team_name}" -- lagt i spillerpoolen, ikke på lag.`)
}
continue
}
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: player.id }),
})
if (!res.ok && res.status !== 409) {
warnings.push(`${row.display_name}: klarte ikke å legge til på laget.`)
}
} else {
const classId = row.class_name.trim() ? classIdForName(row.class_name) : null
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: player.id, class_id: classId }),
})
if (!res.ok && res.status !== 409) {
warnings.push(`${row.display_name}: klarte ikke å melde på turneringen.`)
}
}
}
setSaveWarnings(warnings)
setStep("done")
onImportComplete(results)
} catch {
setError("Importen feilet. Prøv igjen.")
setStep("edit")
}
}
if (step === "duplicates") {
return (
<DuplicateStep
duplicates={duplicates}
onChangeAction={(rowKey, action) =>
setDuplicates((prev) => prev.map((d) => (d.row.key === rowKey ? { ...d, action } : d)))
}
onCancel={() => setStep("edit")}
onContinue={() => void doSave(rows.filter((r) => r.display_name.trim().length > 0), duplicates)}
/>
)
}
return (
<PlayerImportView
mode={mode}
step={step}
csvHeaders={csvHeaders}
columnMapping={mapping}
rows={rows}
errorMessage={error}
saveWarnings={saveWarnings}
onFileSelected={handleFile}
onColumnMappingChange={(colIdx, field) => setMapping((prev) => ({ ...prev, [colIdx]: field }))}
onConfirmMapping={confirmMapping}
onCellChange={updateCell}
onDeleteRow={removeRow}
onAddBlankRow={addBlankRow}
onSave={() => void handleSave()}
onClose={onClose}
/>
)
}
// Duplikat-varsel (2026-08-18, se DuplicateCandidate-kommentaren over) --
// funksjonell, håndkodet av Claude (ikke V0-eksportert som resten av
// import-visningen): en liten, avgrenset spørre-skjerm satt inn FØR selve
// lagringen, ikke en ny selvstendig UI-flate. Bruker de samme globale
// design-tokenene (border/bg-card/bg-primary osv.) som resten av appen.
function DuplicateStep({
duplicates,
onChangeAction,
onCancel,
onContinue,
}: {
duplicates: DuplicateCandidate[]
onChangeAction: (rowKey: string, action: DuplicateAction) => void
onCancel: () => void
onContinue: () => void
}) {
const ACTIONS: { value: DuplicateAction; label: string; hint: string }[] = [
{ value: "merge", label: "Slå sammen", hint: "Fyll kun inn tomme felt på den eksisterende spilleren" },
{ value: "overwrite", label: "Overskriv", hint: "Erstatt ALLE felt på den eksisterende spilleren" },
{ value: "create_new", label: "Importer som ny", hint: "Behold begge -- bevisst dobbeloppføring" },
]
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
<div className="flex items-start gap-3 rounded-xl border border-border bg-card p-4 sm:p-5">
<TriangleAlert aria-hidden="true" className="mt-0.5 size-6 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold text-foreground">
{duplicates.length === 1 ? "1 mulig duplikat funnet" : `${duplicates.length} mulige duplikater funnet`}
</h2>
<p className="text-sm font-medium text-muted-foreground text-pretty">
Disse radene mangler e-post og har samme navn som en spiller som allerede finnes i
spillerpoolen. Velg hva som skal skje med hver av dem før import fortsetter.
</p>
</div>
</div>
<ul className="flex flex-col gap-4">
{duplicates.map((d) => (
<li key={d.row.key} className="rounded-xl border border-border bg-card p-4 sm:p-5">
<p className="text-base font-bold text-foreground">{d.row.display_name.trim()}</p>
<p className="text-sm font-medium text-muted-foreground">
Finnes allerede i spillerpoolen
{d.existing.club ? ` -- ${d.existing.club}` : ""}
{d.existing.handicap_index != null ? `, HCP ${d.existing.handicap_index}` : ""}
</p>
<fieldset className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<legend className="sr-only">Hva skal skje med {d.row.display_name.trim()}?</legend>
{ACTIONS.map((a) => {
const id = `dup-${d.row.key}-${a.value}`
const checked = d.action === a.value
return (
<label
key={a.value}
htmlFor={id}
className={`flex min-h-11 flex-1 cursor-pointer items-start gap-2 rounded-lg border p-3 transition-colors ${
checked
? "border-primary bg-primary/10"
: "border-border bg-background hover:border-primary/50"
}`}
>
<input
id={id}
type="radio"
name={`dup-${d.row.key}`}
value={a.value}
checked={checked}
onChange={() => onChangeAction(d.row.key, a.value)}
className="mt-1 size-4 shrink-0 accent-primary"
/>
<span className="flex flex-col">
<span className="text-sm font-bold text-foreground">{a.label}</span>
<span className="text-xs font-medium text-muted-foreground">{a.hint}</span>
</span>
</label>
)
})}
</fieldset>
</li>
))}
</ul>
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-card px-5 font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Tilbake til tabellen
</button>
<button
type="button"
onClick={onContinue}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Fortsett importen
</button>
</div>
</div>
)
}

View file

@ -1,646 +0,0 @@
"use client"
// V0-eksportert visning (2026-08-17, "stor skjerm først"-prompt, se
// ARCHITECTURE_DECISIONS.md ADR-082) -- ren kontrollert visning, ingen
// egne API-kall. All logikk (CSV-parsing/kolonnegjetting/lagre-
// orkestrering) bor i player-import-panel.tsx, som importerer OG
// wrapper denne. IKKE endre denne filen for å tilpasse den til
// backend-detaljer (f.eks. kjønn-verdier) -- oversett i orkestrerings-
// laget i stedet, se player-import-panel.tsx sin handleSave.
import type React from "react"
import { useRef } from "react"
import {
AlertCircle,
CheckCircle2,
FileUp,
Plus,
Trash2,
TriangleAlert,
UploadCloud,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
export type FieldKey =
| "display_name"
| "email"
| "handicap_index"
| "gender"
| "birth_date"
| "mobile"
| "club"
| "nickname"
| "country"
| "club_member_number"
| "first_name"
| "last_name"
| "comment"
| "team_name"
| "class_name"
export type DraftRow = { key: string } & Record<FieldKey, string>
export type Mode =
| { kind: "team"; teams: { id: string; name: string }[] }
| { kind: "individual"; classes: { id: string; name: string }[] }
// Organisasjonens hele spillerpool, utenfor enhver turneringskontekst
// (2026-08-18, se org-player-roster-panel.tsx) -- ingen lag/klasse-
// kolonner (de gir ikke mening uten en turnering), og radene starter
// FYLT UT fra eksisterende spillere i stedet for en opplastet CSV.
| { kind: "roster" }
export type PlayerImportStep = "upload" | "map" | "edit" | "saving" | "done"
export type PlayerImportViewProps = {
mode: Mode
step: PlayerImportStep
csvHeaders: string[]
columnMapping: Record<number, FieldKey | "">
rows: DraftRow[]
errorMessage: string | null
saveWarnings: string[]
onFileSelected: (file: File) => void
onColumnMappingChange: (columnIndex: number, field: FieldKey | "") => void
onConfirmMapping: () => void
onCellChange: (rowKey: string, field: FieldKey, value: string) => void
onDeleteRow: (rowKey: string) => void
onAddBlankRow: () => void
onSave: () => void
onClose: () => void
}
// Norwegian labels for every field. team_name / class_name are filtered by mode.
const FIELD_LABELS: Record<FieldKey, string> = {
display_name: "Navn",
email: "E-post",
handicap_index: "HCP",
gender: "Kjønn",
birth_date: "Fødselsdato",
mobile: "Mobil",
club: "Klubb",
nickname: "Kallenavn",
country: "Land",
club_member_number: "Medlemsnr.",
first_name: "Fornavn",
last_name: "Etternavn",
comment: "Kommentar",
team_name: "Lag",
class_name: "Klasse",
}
// Field order for the mapping dropdown and edit table columns.
const FIELD_ORDER: FieldKey[] = [
"display_name",
"first_name",
"last_name",
"nickname",
"email",
"mobile",
"handicap_index",
"gender",
"birth_date",
"club",
"club_member_number",
"country",
"team_name",
"class_name",
"comment",
]
const GENDER_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "—" },
{ value: "female", label: "Kvinne" },
{ value: "male", label: "Mann" },
{ value: "other", label: "Annet" },
]
// Shared control classes. Desktop-first: compact but comfortable (mouse is
// precise) with a clear keyboard focus ring that works in light and dark mode.
const CONTROL_BASE =
"h-9 rounded-lg border border-border bg-card text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
const PRIMARY_BTN =
"inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 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 disabled:pointer-events-none disabled:opacity-50"
const SECONDARY_BTN =
"inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-border bg-card px-4 font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 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 disabled:pointer-events-none disabled:opacity-50"
export function PlayerImportView({
mode,
step,
csvHeaders,
columnMapping,
rows,
errorMessage,
saveWarnings,
onFileSelected,
onColumnMappingChange,
onConfirmMapping,
onCellChange,
onDeleteRow,
onAddBlankRow,
onSave,
onClose,
}: PlayerImportViewProps) {
// Fields selectable in the mapping step, filtered so mode-specific fields
// only appear for the matching mode.
const selectableFields = FIELD_ORDER.filter((f) => {
if (f === "team_name") return mode.kind === "team"
if (f === "class_name") return mode.kind === "individual"
return true
})
// In the edit step, only render columns that some row actually uses (i.e.
// fields that were mapped), keeping mode-specific columns filtered too.
// Roster mode never goes through the upload/map steps (rows start pre-
// filled from existing players, see org-player-roster-panel.tsx), so
// `columnMapping` is always empty there -- show every selectable field
// instead of falling back to just the name column.
const activeFields =
mode.kind === "roster"
? selectableFields
: selectableFields.filter((field) => Object.values(columnMapping).includes(field))
// Guarantee at least the name column so a blank table is still usable.
const editFields = activeFields.length > 0 ? activeFields : ["display_name" as FieldKey]
const isSaving = step === "saving"
const isEditLike = step === "edit" || step === "saving"
return (
<div
role="dialog"
aria-modal="true"
aria-label={mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
>
{/* Header: title left, large close button right */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3.5 lg:px-8">
<div className="flex min-w-0 flex-col">
<h1 className="truncate text-lg font-extrabold lg:text-xl">
{mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}
</h1>
<p className="truncate text-sm text-muted-foreground">
{mode.kind === "team" ? "Lagturnering" : mode.kind === "individual" ? "Individuell turnering" : "Organisasjonens spillerpool"}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={mode.kind === "roster" ? "Lukk redigering" : "Lukk import"}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<X aria-hidden="true" className="size-6" />
</button>
</header>
{/* Scrollable body */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className={cn(
"mx-auto flex w-full flex-col gap-5 px-5 py-6 lg:px-8",
// Wide steps use most of the screen; narrow steps stay readable.
isEditLike ? "max-w-[110rem]" : "max-w-4xl",
)}
>
{/* Global error box — shown in every step */}
{errorMessage && (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-destructive"
>
<AlertCircle aria-hidden="true" className="mt-0.5 size-5 shrink-0" />
<p className="text-sm font-semibold leading-relaxed">{errorMessage}</p>
</div>
)}
{step === "upload" && <UploadStep onFileSelected={onFileSelected} />}
{step === "map" && (
<MapStep
csvHeaders={csvHeaders}
columnMapping={columnMapping}
selectableFields={selectableFields}
rowCount={rows.length}
onColumnMappingChange={onColumnMappingChange}
onConfirmMapping={onConfirmMapping}
/>
)}
{isEditLike && (
<EditTable
mode={mode}
rows={rows}
editFields={editFields}
onCellChange={onCellChange}
onDeleteRow={onDeleteRow}
/>
)}
{step === "done" && <DoneStep mode={mode} saveWarnings={saveWarnings} onClose={onClose} />}
</div>
</div>
{/* Sticky action bar always visible for the edit/saving step so the
organizer never has to scroll to reach "Lagre" with many rows. */}
{isEditLike && (
<footer className="shrink-0 border-t border-border bg-card/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-[110rem] flex-wrap items-center justify-between gap-3 px-5 py-3 lg:px-8">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onAddBlankRow}
disabled={isSaving}
className={SECONDARY_BTN}
>
<Plus aria-hidden="true" className="size-5" />
Legg til rad
</button>
<span className="text-sm font-semibold tabular-nums text-muted-foreground">
{rows.length} {rows.length === 1 ? "rad" : "rader"}
</span>
</div>
<button
type="button"
onClick={onSave}
disabled={isSaving || rows.length === 0}
aria-busy={isSaving}
className={cn(PRIMARY_BTN, "px-6")}
>
{isSaving ? (
<>
<span
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"
/>
Lagrer
</>
) : (
<>
Lagre {rows.length} {rows.length === 1 ? "spiller" : "spillere"}
</>
)}
</button>
</div>
</footer>
)}
</div>
)
}
function UploadStep({ onFileSelected }: { onFileSelected: (file: File) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
function handleFiles(files: FileList | null) {
const file = files?.[0]
if (file) onFileSelected(file)
}
return (
<label
htmlFor="csv-file-input"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
handleFiles(e.dataTransfer.files)
}}
className={cn(
"mx-auto flex w-full max-w-3xl cursor-pointer flex-col items-center gap-5 rounded-3xl border-2 border-dashed border-border bg-card/50 px-8 py-16 text-center transition-colors",
"hover:border-primary/60 hover:bg-accent/30",
"focus-within:border-primary focus-within:ring-2 focus-within:ring-ring",
)}
>
<span className="flex size-20 items-center justify-center rounded-2xl bg-primary/15 text-primary">
<UploadCloud aria-hidden="true" className="size-10" />
</span>
<span className="flex flex-col gap-1.5">
<span className="text-xl font-extrabold text-foreground">Dra CSV-filen hit</span>
<span className="text-base leading-relaxed text-muted-foreground text-pretty">
eller trykk for å velge en fil fra maskinen. Første rad inneholde kolonnenavn.
</span>
</span>
<span className={cn(PRIMARY_BTN, "px-6")}>
<FileUp aria-hidden="true" className="size-5" />
Velg fil
</span>
<input
ref={inputRef}
id="csv-file-input"
type="file"
accept=".csv,text/csv"
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
</label>
)
}
function MapStep({
csvHeaders,
columnMapping,
selectableFields,
rowCount,
onColumnMappingChange,
onConfirmMapping,
}: {
csvHeaders: string[]
columnMapping: Record<number, FieldKey | "">
selectableFields: FieldKey[]
rowCount: number
onColumnMappingChange: (columnIndex: number, field: FieldKey | "") => void
onConfirmMapping: () => void
}) {
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold text-foreground">Koble kolonner</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Velg hvilket felt hver kolonne i filen tilsvarer. Kolonner du ikke trenger settes til{" "}
<span className="font-semibold text-foreground">Ignorer</span>.
</p>
</div>
{/* Multi-column grid so many mappings are visible at once on a wide
screen without scrolling. CSV header left, field dropdown right. */}
<ul className="grid grid-cols-1 gap-x-8 gap-y-3 lg:grid-cols-2 2xl:grid-cols-3">
{csvHeaders.map((header, index) => {
const selectId = `col-map-${index}`
return (
<li
key={index}
className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-4 py-2.5"
>
<div className="flex min-w-0 flex-col">
<span className="text-[0.7rem] font-bold uppercase tracking-wide text-muted-foreground">
Kolonne {index + 1}
</span>
<label htmlFor={selectId} className="truncate text-sm font-bold text-foreground">
{header || <span className="italic text-muted-foreground">(uten navn)</span>}
</label>
</div>
<select
id={selectId}
value={columnMapping[index] ?? ""}
onChange={(e) => onColumnMappingChange(index, e.target.value as FieldKey | "")}
className={cn(CONTROL_BASE, "w-44 shrink-0 px-2.5 text-sm font-semibold")}
>
<option value="">Ignorer</option>
{selectableFields.map((field) => (
<option key={field} value={field}>
{FIELD_LABELS[field]}
</option>
))}
</select>
</li>
)
})}
</ul>
<div className="flex justify-end">
<button type="button" onClick={onConfirmMapping} className={cn(PRIMARY_BTN, "px-6")}>
Neste: forhåndsvis {rowCount} {rowCount === 1 ? "rad" : "rader"}
</button>
</div>
</div>
)
}
// Realistisk, innholds-tilpasset kolonnebredde (px), ikke en rundhåndet
// min-bredde -- med table-fixed + w-full fordeler nettleseren overskytende
// plass proporsjonalt, så alle ~14 felt (spillerpool-modus) får plass uten
// sidescroll på en bred skjerm (bruker eksplisitt: "jeg ønsker at tabellen
// skal være så bred at jeg slipper å scrolle sidelengs", 2026-08-19).
// overflow-x-auto-wrapperen står igjen som fallback for smal skjerm.
const COL_W: Partial<Record<FieldKey, number>> = {
display_name: 150,
first_name: 110,
last_name: 110,
nickname: 100,
email: 190,
mobile: 120,
handicap_index: 70,
gender: 92,
birth_date: 130,
club: 140,
club_member_number: 104,
country: 90,
team_name: 128,
class_name: 128,
comment: 180,
}
// Rutenett-cellekontroll (2026-08-19): flat, fyller hele cellen, ingen egen
// kant -- rutenett-linjene definerer strukturen, kontrollen lyser kun opp
// ved hover/fokus. Samme mønster som tournament-players-table.tsx.
const CELL =
"h-full w-full border-0 bg-transparent px-2 py-1.5 text-[13px] leading-tight text-foreground outline-none transition-colors hover:bg-accent/40 focus-visible:bg-background focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
const TD = "border-b border-r border-border p-0 align-middle"
const TH =
"border-b border-r border-border bg-muted px-2 py-2 text-left text-[11px] font-bold uppercase tracking-wide text-muted-foreground"
function EditTable({
mode,
rows,
editFields,
onCellChange,
onDeleteRow,
}: {
mode: Mode
rows: DraftRow[]
editFields: FieldKey[]
onCellChange: (rowKey: string, field: FieldKey, value: string) => void
onDeleteRow: (rowKey: string) => void
}) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold text-foreground">Kontroller spillerne</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{mode.kind === "roster"
? "Rediger cellene direkte, og trykk Lagre. \"Slett\" fjerner raden herfra, men sletter IKKE spilleren fra organisasjonen -- det er ikke bygget ennå."
: "Rediger cellene direkte før du lagrer. Du kan legge til eller slette rader."}
</p>
</div>
{rows.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
<p className="text-base font-bold text-foreground">Ingen rader ennå</p>
<p className="text-sm text-muted-foreground">Legg til en rad for å komme i gang.</p>
</div>
) : (
// Horizontal scroll is isolated to this wrapper as a fallback for
// narrow screens; on a wide screen every column is visible. `relative`
// gives absolutely-positioned descendants (sr-only) a clipped block.
<div className="relative min-w-0 overflow-x-auto rounded-2xl border border-border">
<table className="w-full table-fixed border-collapse text-left">
<colgroup>
{editFields.map((field) => (
<col key={field} style={{ width: COL_W[field] ?? 120 }} />
))}
<col style={{ width: 72 }} />
</colgroup>
<thead className="sticky top-0 z-20">
<tr className="h-9">
{editFields.map((field, i) => (
<th key={field} scope="col" className={cn(TH, "truncate", i === 0 && "sticky left-0 z-30")}>
{FIELD_LABELS[field]}
</th>
))}
<th scope="col" className={cn(TH, "border-r-0 text-right")}>
<span className="sr-only">Handlinger</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
// Annenhver rad-bakgrunn (2026-08-19, bruker: "hvit og lys
// grå" -- presisert etter en første runde: "Lyse GRÅ striper,
// ikke lyse grønne", siden bg-muted er en grønntonet token i
// denne paletten (klubbhus-fargene), ikke nøytral grå).
// Løst med en translucent svart/hvit-overlegg i stedet for en
// fargtonet token -- svart/hvitt bærer ingen fargetone, så
// det leses som ekte nøytral grå i BEGGE temaer automatisk,
// uten å måtte velge to separate faste gråtoner. Ren
// pekepinn for å lese på tvers av en rad i en bred tabell,
// samme rutenett-linjer som ellers. Den sticky første cellen
// må få SAMME bakgrunn som resten av raden (ikke alltid
// bg-card), ellers ser den frakoblet ut fra stripen bak den
// ved sidescroll.
const rowBg = rowIndex % 2 === 1 ? "bg-black/[0.03] dark:bg-white/[0.04]" : "bg-card"
return (
<tr
key={row.key}
// data-row-key (2026-08-18): rent orkestrerings-anker, IKKE
// et visuelt/atferdsmessig felt -- lar en ekstern "hopp til
// denne spilleren"-lenke (org-player-roster-panel.tsx)
// scrolle til riktig rad uten å måtte utvide selve
// props-kontrakten eller endre noe synlig her.
data-row-key={row.key}
className={cn("group h-9 hover:bg-accent/20", rowBg)}
>
{editFields.map((field, i) => (
<td key={field} className={cn(TD, i === 0 && `sticky left-0 z-10 ${rowBg} group-hover:bg-accent/20`)}>
{field === "gender" ? (
<select
aria-label={`${FIELD_LABELS[field]} for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, "cursor-pointer font-medium")}
>
{GENDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field === "team_name" && mode.kind === "team" ? (
<select
aria-label={`Lag for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, "cursor-pointer font-medium")}
>
<option value=""></option>
{mode.teams.map((t) => (
<option key={t.id} value={t.name}>
{t.name}
</option>
))}
</select>
) : field === "class_name" && mode.kind === "individual" ? (
<select
aria-label={`Klasse for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, "cursor-pointer font-medium")}
>
<option value=""></option>
{mode.classes.map((c) => (
<option key={c.id} value={c.name}>
{c.name}
</option>
))}
</select>
) : (
<input
aria-label={`${FIELD_LABELS[field]} for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
inputMode={field === "email" ? "email" : field === "mobile" ? "tel" : "text"}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, field === "handicap_index" && "text-right tabular-nums")}
/>
)}
</td>
))}
<td className={cn(TD, "border-r-0 px-1 text-center")}>
<button
type="button"
onClick={() => onDeleteRow(row.key)}
aria-label={`Slett rad ${rowIndex + 1}${row.display_name ? ` for ${row.display_name}` : ""}`}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-bold text-muted-foreground transition-colors",
"hover:bg-destructive/10 hover:text-destructive active:scale-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
)}
>
<Trash2 aria-hidden="true" className="size-3.5" />
Slett
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
function DoneStep({ mode, saveWarnings, onClose }: { mode: Mode; saveWarnings: string[]; onClose: () => void }) {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-6 py-10 text-center">
<span className="flex size-20 items-center justify-center rounded-full bg-primary/15 text-primary">
<CheckCircle2 aria-hidden="true" className="size-11" />
</span>
<div className="flex flex-col gap-1.5">
<h2 className="text-2xl font-extrabold text-foreground">
{mode.kind === "roster" ? "Endringene er lagret" : "Importen er fullført"}
</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{mode.kind === "roster" ? "Spillerpoolen er oppdatert." : "Spillerne er lagret i turneringen."}
</p>
</div>
{saveWarnings.length > 0 && (
// Warnings are informational, not errors — amber, not red.
<div className="w-full rounded-2xl border border-brand-orange/40 bg-brand-orange/10 p-4 text-left">
<div className="flex items-center gap-2 text-brand-orange">
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
<h3 className="text-sm font-extrabold">
{saveWarnings.length} {saveWarnings.length === 1 ? "rad trenger" : "rader trenger"}{" "}
oppfølging
</h3>
</div>
<ul className="mt-2 flex flex-col gap-1.5 pl-1">
{saveWarnings.map((warning, i) => (
<li key={i} className="flex gap-2 text-sm leading-relaxed text-foreground">
<span aria-hidden="true" className="mt-2 size-1.5 shrink-0 rounded-full bg-brand-orange" />
<span>{warning}</span>
</li>
))}
</ul>
</div>
)}
<button type="button" onClick={onClose} className={cn(PRIMARY_BTN, "w-full sm:w-56")}>
Lukk
</button>
</div>
)
}

View file

@ -1,459 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
import { Download, Printer, Scissors } from "lucide-react"
import { cn } from "@/lib/utils"
import {
fetchMatches,
fetchRoundGroups,
fetchRoundParticipants,
fetchRounds,
fetchSessions,
fetchTeams,
fetchTournament,
fetchTournamentParticipants,
type ApiMatch,
type ApiRound,
type ApiRoundParticipant,
type ApiSession,
type ApiTeam,
type ApiTournamentParticipant,
} from "@/lib/print-data"
/**
* Print-friendly CART TAGS -- ekte data (ADR-097). Videreført fra
* V0-mockupen: 6 tags per ark (2×3 portrett / 3×2 landskap), stiplede
* klippelinjer. Individuell: én tag per utslagsgruppe. Cup: én tag per
* match (begge lags spillere samme tag, lagfarge som markør).
*/
type PaperSize = "a4" | "letter"
type Orientation = "portrait" | "landscape"
const PAPER_MM: Record<PaperSize, { w: number; h: number; label: string }> = {
a4: { w: 210, h: 297, label: "A4" },
letter: { w: 216, h: 279, label: "Letter" },
}
const PX_PER_MM = 3.7795
type TagPlayer = { name: string; hcp: string; className: string; teamName?: string; teamColor?: string }
type TagGroup = { cart: number; teeTime: string; startHole: number; players: TagPlayer[] }
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</p>
{children}
{hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
</div>
)
}
function Segmented({
ariaLabel,
value,
onChange,
options,
}: {
ariaLabel: string
value: string
onChange: (v: string) => void
options: { value: string; label: string; disabled?: boolean }[]
}) {
return (
<div role="radiogroup" aria-label={ariaLabel} className="flex rounded-xl border border-border bg-muted/40 p-1">
{options.map((opt) => {
const active = opt.value === value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
disabled={opt.disabled}
onClick={() => onChange(opt.value)}
className={cn(
"flex-1 rounded-lg px-2 py-1.5 text-sm font-bold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
opt.disabled && "cursor-not-allowed opacity-40 hover:text-muted-foreground",
)}
>
{opt.label}
</button>
)
})}
</div>
)
}
function Toggle({ label, hint, checked, onChange, disabled }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<label className={cn("flex cursor-pointer items-center justify-between gap-3 py-3", disabled && "cursor-not-allowed opacity-50")}>
<span className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{label}</span>
{hint && <span className="text-xs leading-relaxed text-muted-foreground">{hint}</span>}
</span>
<button
type="button"
role="switch"
aria-checked={checked && !disabled}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn("relative h-6 w-11 shrink-0 rounded-full transition-colors", checked && !disabled ? "bg-primary" : "bg-muted-foreground/30")}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform", checked && !disabled ? "translate-x-[22px]" : "translate-x-0.5")} />
</button>
</label>
)
}
function CartTag({
group,
tournamentName,
showHcp,
showStartHole,
showClass,
showTeams,
showHeader,
}: {
group: TagGroup
tournamentName: string
showHcp: boolean
showStartHole: boolean
showClass: boolean
showTeams: boolean
showHeader: boolean
}) {
const startElsewhere = showStartHole && group.startHole !== 1
return (
<div className="flex h-full flex-col" style={{ padding: 6 * PX_PER_MM }}>
{showHeader && (
<p className="truncate font-semibold uppercase tracking-wide text-neutral-500" style={{ fontSize: 11 }}>
{tournamentName}
</p>
)}
<div className="flex items-start justify-between gap-2" style={{ marginTop: showHeader ? 2 : 0 }}>
<div className="flex items-baseline gap-2 leading-none">
<span className="font-semibold uppercase text-neutral-500" style={{ fontSize: 13 }}>
Cart
</span>
<span className="font-black leading-none" style={{ fontSize: 68 }}>
{group.cart}
</span>
</div>
<div className="flex flex-col items-end leading-none">
<span className="font-semibold uppercase text-neutral-500" style={{ fontSize: 11 }}>
Utslag
</span>
<span className="font-black tabular-nums" style={{ fontSize: 26 }}>
{group.teeTime}
</span>
{startElsewhere && (
<span className="mt-1 inline-flex items-center rounded font-bold uppercase" style={{ fontSize: 11, padding: "1px 6px", border: "2px solid #111", letterSpacing: "0.02em" }}>
Start hull {group.startHole}
</span>
)}
</div>
</div>
<div style={{ height: 2, background: "#111", margin: "8px 0" }} />
<ul className="flex flex-1 flex-col justify-center" style={{ gap: 6 }}>
{group.players.map((p, i) => (
<li key={p.name + i} className="flex items-center gap-2">
{showTeams && p.teamColor && <span aria-hidden="true" className="shrink-0 rounded-sm" style={{ width: 12, height: 22, background: p.teamColor }} />}
<div className="min-w-0 flex-1">
<p className="truncate font-bold leading-tight" style={{ fontSize: 19 }}>
{p.name}
</p>
{(showTeams || showClass) && (
<p className="truncate font-medium leading-tight text-neutral-500" style={{ fontSize: 12 }}>
{[showTeams && p.teamName, showClass && p.className && `Klasse ${p.className}`].filter(Boolean).join(" · ")}
</p>
)}
</div>
{showHcp && (
<span className="shrink-0 rounded font-bold tabular-nums" style={{ fontSize: 14, padding: "1px 7px", background: "#f3f4f6", border: "1px solid #d1d5db" }}>
{p.hcp}
</span>
)}
</li>
))}
</ul>
</div>
)
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
export function PrintCartTags({
organizationId,
tournamentId,
roundId,
sessionId,
initialPaper = "a4",
initialOrientation = "portrait",
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
initialPaper?: PaperSize
initialOrientation?: Orientation
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tournamentName, setTournamentName] = useState("Turnering")
const [groups, setGroups] = useState<TagGroup[]>([])
const [isCup, setIsCup] = useState(false)
const [paper, setPaper] = useState<PaperSize>(initialPaper)
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
const [showHcp, setShowHcp] = useState(true)
const [showStartHole, setShowStartHole] = useState(true)
const [showClass, setShowClass] = useState(false)
const [showTeams, setShowTeams] = useState(true)
const [showHeader, setShowHeader] = useState(true)
const [downloading, setDownloading] = useState(false)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const t = await fetchTournament(organizationId, tournamentId)
if (cancelled) return
setTournamentName(t?.name ?? "Turnering")
if (roundId) {
const [rounds, groupsRes, roundParticipants, tournamentParticipants] = await Promise.all([
fetchRounds(organizationId, tournamentId),
fetchRoundGroups(organizationId, tournamentId, roundId),
fetchRoundParticipants(organizationId, tournamentId, roundId),
fetchTournamentParticipants(organizationId, tournamentId),
])
const r = rounds.find((x: ApiRound) => x.id === roundId) ?? null
if (!r) throw new Error("Fant ikke runden.")
const rpById = new Map<string, ApiRoundParticipant>(roundParticipants.map((rp) => [rp.id, rp]))
const tpById = new Map<string, ApiTournamentParticipant>(tournamentParticipants.map((tp) => [tp.id, tp]))
const built: TagGroup[] = groupsRes.groups.map((g, i) => ({
cart: g.sequence ?? i + 1,
teeTime: r.start_mode === "shotgun" ? "Shotgun" : g.tee_time ? new Date(g.tee_time).toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) : "",
startHole: g.start_hole ?? r.start_hole,
players: g.participants.map((gp) => {
const rp = rpById.get(gp.round_participant_id)
const tp = rp ? tpById.get(rp.tournament_participant_id) : undefined
return {
name: gp.player_name,
hcp: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "",
className: tp?.class_name ?? "",
}
}),
}))
if (cancelled) return
setIsCup(false)
setGroups(built)
} else if (sessionId) {
const [sessions, matches, teams] = await Promise.all([
fetchSessions(organizationId, tournamentId),
fetchMatches(organizationId, sessionId),
fetchTeams(organizationId, tournamentId),
])
const s = sessions.find((x: ApiSession) => x.id === sessionId) ?? null
if (!s) throw new Error("Fant ikke økten.")
const teamById = new Map<string, ApiTeam>(teams.map((tm) => [tm.id, tm]))
const built: TagGroup[] = matches.map((m: ApiMatch) => ({
cart: m.sequence,
teeTime: s.start_mode === "shotgun" ? "Shotgun" : m.tee_time ? new Date(m.tee_time).toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) : "",
startHole: m.start_hole ?? s.start_hole,
players: m.participants.map((mp) => {
const team = teamById.get(mp.team_side === "a" ? m.team_a_id : m.team_b_id)
return {
name: mp.player_name,
hcp: mp.playing_handicap != null ? String(mp.playing_handicap) : "",
className: "",
teamName: team?.name,
teamColor: team?.color ?? undefined,
}
}),
}))
if (cancelled) return
setIsCup(true)
setGroups(built)
} else {
throw new Error("Mangler runde- eller økt-ID.")
}
} catch {
if (!cancelled) setError("Klarte ikke å laste data for cart-tagsene. Prøv igjen.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, roundId, sessionId])
const sheetMm = useMemo(() => {
const base = PAPER_MM[paper]
return orientation === "portrait" ? { w: base.w, h: base.h } : { w: base.h, h: base.w }
}, [paper, orientation])
const grid = orientation === "portrait" ? { cols: 2, rows: 3 } : { cols: 3, rows: 2 }
const perSheet = grid.cols * grid.rows
const frameRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
useEffect(() => {
const el = frameRef.current
if (!el) return
const compute = () => {
const avail = el.clientWidth - 32
const target = sheetMm.w * PX_PER_MM
setScale(Math.min(1, avail / target))
}
compute()
const ro = new ResizeObserver(compute)
ro.observe(el)
return () => ro.disconnect()
}, [sheetMm.w])
const sheetStyle: CSSProperties = { width: sheetMm.w * PX_PER_MM, height: sheetMm.h * PX_PER_MM, transform: `scale(${scale})`, transformOrigin: "top center" }
const pages = chunk(groups, perSheet)
async function downloadPdf() {
setDownloading(true)
try {
const res = await fetch(`/orgs/${organizationId}/print/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
kind: "cart-tags",
tournament_id: tournamentId,
round_id: roundId ?? null,
session_id: sessionId ?? null,
paper: paper === "a4" ? "A4" : "Letter",
orientation,
}),
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "teecup-cart-tags.pdf"
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch {
setError("Klarte ikke å generere PDF-en. Prøv igjen.")
} finally {
setDownloading(false)
}
}
const ready = !loading
return (
<div className="min-h-dvh bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style>{`@page { size: ${PAPER_MM[paper].label} ${orientation}; margin: 8mm; }`}</style>
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-4 lg:flex-row lg:p-6 print:block print:p-0">
<aside className="lg:w-80 lg:shrink-0 print:hidden">
<div className="flex flex-col gap-6 rounded-2xl border border-border bg-card p-5 lg:sticky lg:top-6">
<div className="flex flex-col gap-1">
<h1 className="flex items-center gap-2 text-lg font-extrabold">
<Printer aria-hidden="true" className="size-5 text-primary" />
Skriv ut cart-tags
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">Flere tags per ark. Klipp langs de stiplede linjene etter utskrift.</p>
</div>
{error ? <p className="text-sm font-medium text-destructive">{error}</p> : null}
<Field label="Papirstørrelse">
<Segmented ariaLabel="Papirstørrelse" value={paper} onChange={(v) => setPaper(v as PaperSize)} options={[{ value: "a4", label: "A4" }, { value: "letter", label: "Letter" }]} />
</Field>
<Field label="Retning" hint="Styrer hvordan tag-ene plasseres på arket.">
<Segmented ariaLabel="Retning" value={orientation} onChange={(v) => setOrientation(v as Orientation)} options={[{ value: "portrait", label: "Portrett" }, { value: "landscape", label: "Landskap" }]} />
</Field>
<div className="flex flex-col gap-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Valgfrie felt</p>
<div className="flex flex-col divide-y divide-border">
<Toggle label="HCP per spiller" checked={showHcp} onChange={setShowHcp} />
<Toggle label="Starthull" hint="Vises kun når gruppen ikke starter på hull 1." checked={showStartHole} onChange={setShowStartHole} />
<Toggle label="Klasse per spiller" checked={showClass} onChange={setShowClass} disabled={isCup} />
<Toggle label="Lagnavn + farge" hint={isCup ? undefined : "Kun for Cup-format."} checked={showTeams} onChange={setShowTeams} disabled={!isCup} />
<Toggle label="Turneringsnavn på hver tag" checked={showHeader} onChange={setShowHeader} />
</div>
</div>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading || groups.length === 0}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60"
>
<Download aria-hidden="true" className="size-4" />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
<p className="-mt-3 text-center text-xs text-muted-foreground">{groups.length > 0 ? `${groups.length} tags, ${pages.length} ark.` : loading ? "Laster …" : "Ingen data ennå."}</p>
</div>
</aside>
<main className="min-w-0 flex-1 print:w-full">
<div ref={frameRef} className="flex flex-col items-center gap-6 rounded-2xl bg-muted/40 p-4 print:gap-0 print:rounded-none print:bg-transparent print:p-0">
{loading ? (
<p className="p-8 text-sm text-muted-foreground">Laster cart-tags </p>
) : pages.length === 0 ? (
<p className="p-8 text-sm text-muted-foreground">Ingen grupper eller matcher funnet ennå.</p>
) : (
pages.map((pageGroups, pageIndex) => (
<div key={pageIndex} style={{ height: sheetMm.h * PX_PER_MM * scale }} className="print:h-auto print:break-after-page">
<div style={sheetStyle} className="flex flex-col overflow-hidden bg-white text-neutral-900 shadow-lg ring-1 ring-black/10 print:!scale-100 print:shadow-none print:ring-0">
<div
className="grid flex-1"
style={{ padding: 10 * PX_PER_MM, gap: 0, gridTemplateColumns: `repeat(${grid.cols}, 1fr)`, gridTemplateRows: `repeat(${grid.rows}, 1fr)` }}
>
{pageGroups.map((group, i) => {
const col = i % grid.cols
const row = Math.floor(i / grid.cols)
return (
<div
key={group.cart}
className="relative"
style={{
borderRight: col < grid.cols - 1 ? "1.5px dashed #9ca3af" : undefined,
borderBottom: row < grid.rows - 1 ? "1.5px dashed #9ca3af" : undefined,
}}
>
{col < grid.cols - 1 && (
<Scissors aria-hidden="true" className="absolute top-1/2 -translate-y-1/2 text-neutral-400" style={{ right: -8, width: 14, height: 14, background: "white" }} />
)}
{row < grid.rows - 1 && (
<Scissors aria-hidden="true" className="absolute left-1/2 -translate-x-1/2 rotate-90 text-neutral-400" style={{ bottom: -8, width: 14, height: 14, background: "white" }} />
)}
<CartTag group={group} tournamentName={tournamentName} showHcp={showHcp} showStartHole={showStartHole} showClass={showClass} showTeams={showTeams} showHeader={showHeader} />
</div>
)
})}
</div>
</div>
</div>
))
)}
</div>
</main>
</div>
</div>
)
}

View file

@ -1,49 +0,0 @@
"use client"
// Delt "Skriv ut"-meny -- lenker til de tre rundekoblede utskriftssidene
// (ADR-097). Brukt fra RoundGroupsPanel (individuell) og økt-visningene
// (Cup) -- ekte gjenbruk, ikke en tidlig abstraksjon.
import Link from "next/link"
import { Printer } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function PrintMenu({
organizationId,
tournamentId,
roundId,
sessionId,
includeCartTags = true,
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
includeCartTags?: boolean
}) {
const scope = roundId ? `roundId=${roundId}` : `sessionId=${sessionId}`
const href = (kind: "scorecard" | "startlist" | "cart-tags") =>
`/tournaments/${tournamentId}/print/${kind}?org=${organizationId}&${scope}`
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Skriv ut"
className="inline-flex min-h-11 items-center gap-2 rounded-xl border border-border bg-card px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Printer aria-hidden="true" className="size-5" />
<span className="hidden sm:inline">Skriv ut</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link href={href("scorecard")}>Scorekort</Link>} />
<DropdownMenuItem render={<Link href={href("startlist")}>Startliste</Link>} />
{includeCartTags && <DropdownMenuItem render={<Link href={href("cart-tags")}>Cart-tags</Link>} />}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -1,704 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Download, Printer, Crown } from "lucide-react"
import { cn } from "@/lib/utils"
import {
fetchCupLeaderboard,
fetchIndividualLeaderboard,
fetchMatches,
fetchPlayers,
fetchSessions,
fetchTeams,
fetchTournament,
fetchTournamentParticipants,
type ApiLeaderboardEntry,
type ApiMatch,
type ApiPlayer,
type ApiSession,
type ApiTeam,
type ApiTournament,
type ApiTournamentParticipant,
} from "@/lib/print-data"
/**
* Print-friendly RESULTATLISTE -- ekte data (ADR-097). Videreført fra
* V0-mockupen: individuell rankert leaderboard, eller Cup-sluttresultat
* (poeng per økt + match for match). Resultater vises med FORM (sirkel =
* under par, firkant = over par) arket er lesbart i sort/hvitt.
*/
type SizeKey = "A4" | "Letter"
type Orientation = "portrait" | "landscape"
const PAPER: Record<SizeKey, { label: string; sub: string; w: number; h: number }> = {
A4: { label: "A4", sub: "210 × 297 mm", w: 210, h: 297 },
Letter: { label: "Letter", sub: "216 × 279 mm", w: 216, h: 279 },
}
const MM_TO_PX = 96 / 25.4
const INK = "#171717"
const INK_MUTED = "#5b5b5b"
const HAIRLINE = "#d9d9d9"
const HAIRLINE_STRONG = "#a3a3a3"
const BAND = "#f2f2f0"
function Segmented<T extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: { key: T; label: string; sub?: string }[]
value: T
onChange: (v: T) => void
ariaLabel: string
}) {
return (
<div role="radiogroup" aria-label={ariaLabel} className="flex flex-wrap gap-1.5">
{options.map((o) => {
const active = o.key === value
return (
<button
key={o.key}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(o.key)}
className={cn(
"flex min-h-11 flex-1 flex-col items-start justify-center rounded-xl border px-3 py-2 text-left transition-colors",
active ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span className="text-sm font-bold">{o.label}</span>
{o.sub ? <span className="text-[11px] font-medium opacity-70">{o.sub}</span> : null}
</button>
)
})}
</div>
)
}
function ToggleRow({ label, hint, checked, onCheckedChange }: { label: string; hint?: string; checked: boolean; onCheckedChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl px-1 py-1.5">
<span className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{label}</span>
{hint ? <span className="text-[11px] text-muted-foreground">{hint}</span> : null}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange(!checked)}
className={cn("relative h-6 w-11 shrink-0 rounded-full transition-colors", checked ? "bg-primary" : "bg-muted-foreground/30")}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[22px]" : "translate-x-0.5")} />
</button>
</label>
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
// Form bærer betydning (sirkel/firkant/tekst), ikke farge -- leselig i sort/hvitt.
function Shape({ label, tone, size = "md" }: { label: string; tone: "under" | "even" | "over" | null; size?: "md" | "sm" }) {
const dim = size === "md" ? "h-[24px] min-w-[32px] text-[13px]" : "h-[19px] min-w-[26px] text-[11px]"
if (tone === "under") {
return (
<span style={{ borderColor: INK, color: INK }} className={cn("inline-flex items-center justify-center rounded-full border-[1.5px] px-1 font-bold tabular-nums", dim)}>
{label}
</span>
)
}
if (tone === "over") {
return (
<span style={{ borderColor: INK, color: INK }} className={cn("inline-flex items-center justify-center rounded-[4px] border-[1.5px] px-1 font-bold tabular-nums", dim)}>
{label}
</span>
)
}
return (
<span style={{ color: INK }} className={cn("inline-flex items-center justify-center px-1 font-bold tabular-nums", dim)}>
{label}
</span>
)
}
function StatusPill({ text }: { text: string }) {
return (
<span style={{ borderColor: HAIRLINE_STRONG, color: INK }} className="inline-flex h-[24px] items-center justify-center rounded-[3px] border border-dashed px-2 text-[11px] font-extrabold uppercase tracking-wide">
{text}
</span>
)
}
const STATUS_LABEL: Record<string, string> = { dsq: "DSQ", dns: "DNS", rtd: "RTD", dnf: "DNF" }
type EnrichedEntry = ApiLeaderboardEntry & { club: string; hcpLabel: string }
function SheetIndividual({
tournamentName,
period,
method,
entries,
fields,
}: {
tournamentName: string
period: string
method: string
entries: EnrichedEntry[]
fields: { hcp: boolean; rounds: boolean; club: boolean; classes: boolean; cut: boolean }
}) {
const maxRounds = Math.max(0, ...entries.map((e) => e.rounds.length))
const firstCutIndex = entries.findIndex((e) => e.cut)
const gridCols = ["36px", "minmax(0,1fr)", fields.club ? "120px" : null, fields.hcp ? "48px" : null, ...(fields.rounds ? Array(maxRounds).fill("40px") : []), "56px"]
.filter(Boolean)
.join(" ")
const totalCell = (e: EnrichedEntry) => {
if (e.status !== "active") return <StatusPill text={STATUS_LABEL[e.status] ?? e.status.toUpperCase()} />
if (e.cut) return <StatusPill text="Kuttet" />
const label = e.total_label ?? ""
const tone = label.startsWith("-") || label.startsWith("") ? "under" : label.startsWith("+") ? "over" : "even"
return <Shape label={label} tone={tone} />
}
const HeaderRow = () => (
<div style={{ gridTemplateColumns: gridCols, color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="grid items-end gap-x-2 border-b-2 px-2 pb-1 text-[10px] font-bold uppercase tracking-wide">
<span className="text-right">Plass</span>
<span>Spiller</span>
{fields.club && <span>Hjemmeklubb</span>}
{fields.hcp && <span className="text-right">HCP</span>}
{fields.rounds && Array.from({ length: maxRounds }).map((_, i) => (
<span key={i} className="text-center">
R{i + 1}
</span>
))}
<span className="text-center">Total</span>
</div>
)
const Row = ({ e }: { e: EnrichedEntry }) => (
<div style={{ gridTemplateColumns: gridCols, borderColor: HAIRLINE }} className="grid items-center gap-x-2 border-b px-2 py-[5px] text-[12px]">
<span className="text-right text-[13px] font-extrabold tabular-nums" style={{ color: e.position ? INK : INK_MUTED }}>
{e.position ?? ""}
</span>
<span className="truncate font-bold" style={{ color: INK }}>
{e.player_name}
</span>
{fields.club && (
<span className="truncate" style={{ color: INK_MUTED }}>
{e.club}
</span>
)}
{fields.hcp && (
<span className="text-right tabular-nums" style={{ color: INK_MUTED }}>
{e.hcpLabel}
</span>
)}
{fields.rounds &&
Array.from({ length: maxRounds }).map((_, i) => {
const r = e.rounds[i]
return (
<span key={i} className="flex justify-center">
{r ? <Shape label={r.label ?? ""} tone={r.tone} size="sm" /> : <span style={{ color: INK_MUTED }}></span>}
</span>
)
})}
<span className="flex justify-center">{totalCell(e)}</span>
</div>
)
let body: React.ReactNode
if (!fields.classes) {
body = (
<div className="flex flex-col">
<HeaderRow />
{entries.map((e, idx) => (
<div key={e.tournament_participant_id} className="contents">
{fields.cut && idx === firstCutIndex && firstCutIndex >= 0 && (
<div className="my-1 flex items-center gap-2 px-2">
<span style={{ backgroundColor: INK }} className="h-[2px] flex-1" />
<span style={{ color: INK }} className="text-[10px] font-extrabold uppercase tracking-wide">
Cut
</span>
<span style={{ backgroundColor: INK }} className="h-[2px] flex-1" />
</div>
)}
<Row e={e} />
</div>
))}
</div>
)
} else {
const classNames = Array.from(new Set(entries.map((e) => e.class_name ?? "Ingen klasse")))
body = (
<div className="flex flex-col gap-2.5">
{classNames.map((cls) => (
<div key={cls} className="break-inside-avoid">
<div style={{ backgroundColor: BAND, borderColor: HAIRLINE, color: INK }} className="mb-0.5 rounded-[2px] border px-2 py-1 text-[12px] font-extrabold">
{cls}
</div>
<HeaderRow />
{entries.filter((e) => (e.class_name ?? "Ingen klasse") === cls).map((e) => (
<Row key={e.tournament_participant_id} e={e} />
))}
</div>
))}
</div>
)
}
return (
<div style={{ color: INK }} className="flex h-full flex-col">
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
<div className="flex items-baseline justify-between gap-4">
<h1 className="text-[22px] font-extrabold leading-tight">{tournamentName}</h1>
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
Resultatliste
</span>
</div>
<div style={{ color: INK_MUTED }} className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] font-medium">
<span style={{ color: INK }} className="font-bold">
{method}
</span>
<span>{period}</span>
</div>
</header>
<div className="mt-3 flex-1">{body}</div>
<div style={{ color: INK_MUTED }} className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] font-medium">
<span className="flex items-center gap-1.5">
<Shape label="-1" tone="under" size="sm" /> Under par
</span>
<span className="flex items-center gap-1.5">
<Shape label="E" tone="even" size="sm" /> Par
</span>
<span className="flex items-center gap-1.5">
<Shape label="+1" tone="over" size="sm" /> Over par
</span>
<span className="flex items-center gap-1.5">
<StatusPill text="DSQ" /> Spesialstatus
</span>
</div>
<SheetFooter />
</div>
)
}
type CupMatchRow = { no: number; session: string; nord: string; sor: string; result: string; winner: "nord" | "sor" | null }
function SheetCup({
tournamentName,
meta,
teamNord,
teamSor,
sessions,
matches,
fields,
}: {
tournamentName: string
meta: string
teamNord: { name: string; color: string; points: number }
teamSor: { name: string; color: string; points: number }
sessions: { name: string; points: { nord: number; sor: number } }[]
matches: CupMatchRow[]
fields: { sessions: boolean; matches: boolean }
}) {
const nordLeads = teamNord.points > teamSor.points
const sorLeads = teamSor.points > teamNord.points
const fmt = (n: number) => n.toLocaleString("nb-NO", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
const TeamScore = ({ name, color, pts, leads }: { name: string; color: string; pts: number; leads: boolean }) => (
<div style={{ borderColor: leads ? INK : HAIRLINE }} className={cn("relative flex flex-1 flex-col items-center rounded-[4px] border-2 px-4 py-3", leads && "border-[2.5px]")}>
{leads && (
<span style={{ borderColor: INK, color: INK }} className="absolute -top-2.5 flex items-center gap-1 rounded-full border bg-white px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide">
<Crown aria-hidden className="size-3" fill="currentColor" />
Leder
</span>
)}
<span className="flex items-center gap-2">
<span aria-hidden style={{ backgroundColor: color, borderColor: "rgba(0,0,0,0.25)" }} className="h-3.5 w-3.5 rounded-[3px] border" />
<span className="text-[15px] font-extrabold">{name}</span>
</span>
<span className="mt-1 text-[40px] font-extrabold leading-none tabular-nums">{fmt(pts)}</span>
<span style={{ color: INK_MUTED }} className="text-[10px] font-bold uppercase tracking-wide">
poeng
</span>
</div>
)
return (
<div style={{ color: INK }} className="flex h-full flex-col">
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
<div className="flex items-baseline justify-between gap-4">
<h1 className="text-[22px] font-extrabold leading-tight">{tournamentName}</h1>
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
Sluttresultat
</span>
</div>
<div style={{ color: INK_MUTED }} className="mt-1 text-[12px] font-medium">
{meta}
</div>
</header>
<div className="mt-4 flex items-stretch gap-3">
<TeamScore name={teamNord.name} color={teamNord.color} pts={teamNord.points} leads={nordLeads} />
<div className="flex items-center">
<span style={{ color: INK_MUTED }} className="text-[15px] font-extrabold">
</span>
</div>
<TeamScore name={teamSor.name} color={teamSor.color} pts={teamSor.points} leads={sorLeads} />
</div>
{fields.sessions && (
<div className="mt-4">
<h2 style={{ color: INK }} className="mb-1 text-[12px] font-extrabold uppercase tracking-wide">
Poeng per økt
</h2>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="border-b">
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Økt</th>
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamNord.name}</th>
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamSor.name}</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.name} style={{ borderColor: HAIRLINE }} className="border-b">
<td className="px-2 py-1 font-bold">{s.name}</td>
<td className="px-2 py-1 text-right font-bold tabular-nums">{fmt(s.points.nord)}</td>
<td className="px-2 py-1 text-right font-bold tabular-nums">{fmt(s.points.sor)}</td>
</tr>
))}
<tr style={{ borderColor: INK }} className="border-t-2">
<td className="px-2 py-1 font-extrabold">Totalt</td>
<td className="px-2 py-1 text-right font-extrabold tabular-nums">{fmt(teamNord.points)}</td>
<td className="px-2 py-1 text-right font-extrabold tabular-nums">{fmt(teamSor.points)}</td>
</tr>
</tbody>
</table>
</div>
)}
{fields.matches && (
<div className="mt-4 flex-1">
<h2 style={{ color: INK }} className="mb-1 text-[12px] font-extrabold uppercase tracking-wide">
Match for match
</h2>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="border-b">
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">#</th>
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">{teamNord.name}</th>
<th className="px-2 py-1 text-center font-semibold uppercase tracking-wide">Resultat</th>
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamSor.name}</th>
</tr>
</thead>
<tbody>
{matches.map((m) => (
<tr key={m.no} style={{ borderColor: HAIRLINE }} className="border-b">
<td className="px-2 py-1 font-bold tabular-nums" style={{ color: INK_MUTED }}>
{m.no}
</td>
<td className={cn("px-2 py-1", m.winner === "nord" ? "font-extrabold" : "font-medium")}>{m.nord}</td>
<td className="px-2 py-1 text-center font-bold tabular-nums whitespace-nowrap">{m.result}</td>
<td className={cn("px-2 py-1 text-right", m.winner === "sor" ? "font-extrabold" : "font-medium")}>{m.sor}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<SheetFooter />
</div>
)
}
function SheetFooter() {
return (
<footer style={{ borderColor: HAIRLINE, color: INK_MUTED }} className="mt-3 flex items-center justify-between border-t pt-2 text-[10px] font-medium">
<span>Side 1 av 1</span>
<span>Generert {new Date().toLocaleString("no-NO")}</span>
<span className="font-bold" style={{ color: INK }}>
TeeCup
</span>
</footer>
)
}
const SCORING_METHOD_LABEL: Record<string, string> = {
stroke_gross: "Bruttoslagspill",
stroke_net: "Nettoslagspill",
stableford: "Stableford",
copenhagen: "Københavner",
bbb: "High-low-high",
}
export function PrintResultlist({
organizationId,
tournamentId,
initialSize = "A4",
initialOrientation = "portrait",
}: {
organizationId: string
tournamentId: string
initialSize?: SizeKey
initialOrientation?: Orientation
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tournament, setTournament] = useState<ApiTournament | null>(null)
const [entries, setEntries] = useState<EnrichedEntry[]>([])
const [cupData, setCupData] = useState<{ teamNord: { name: string; color: string; points: number }; teamSor: { name: string; color: string; points: number }; sessions: { name: string; points: { nord: number; sor: number } }[]; matches: CupMatchRow[] } | null>(null)
const [downloading, setDownloading] = useState(false)
const [size, setSize] = useState<SizeKey>(initialSize)
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
const [aFields, setAFields] = useState({ hcp: false, rounds: true, club: true, classes: false, cut: true })
const [bFields, setBFields] = useState({ sessions: true, matches: true })
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const t = await fetchTournament(organizationId, tournamentId)
if (cancelled) return
setTournament(t)
if (!t) throw new Error("Fant ikke turneringen.")
if (t.format_type === "individual") {
const [leaderboard, tournamentParticipants, players] = await Promise.all([
fetchIndividualLeaderboard(organizationId, tournamentId),
fetchTournamentParticipants(organizationId, tournamentId),
fetchPlayers(organizationId),
])
const tpById = new Map<string, ApiTournamentParticipant>(tournamentParticipants.map((tp) => [tp.id, tp]))
const playerById = new Map<string, ApiPlayer>(players.map((p) => [p.id, p]))
const enriched: EnrichedEntry[] = leaderboard.map((e) => {
const tp = tpById.get(e.tournament_participant_id)
const player = tp ? playerById.get(tp.player_id) : undefined
return {
...e,
club: player?.club ?? "—",
hcpLabel: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "",
}
})
if (cancelled) return
setEntries(enriched)
} else {
const [cup, sessions, teams] = await Promise.all([
fetchCupLeaderboard(organizationId, tournamentId),
fetchSessions(organizationId, tournamentId),
fetchTeams(organizationId, tournamentId),
])
const [teamA, teamB] = cup.teams
const teamById = new Map<string, ApiTeam>(teams.map((tm) => [tm.id, tm]))
const allMatches = (
await Promise.all(sessions.map(async (s: ApiSession) => ({ session: s, matches: await fetchMatches(organizationId, s.id) })))
).flatMap(({ session, matches }) =>
matches.map((m: ApiMatch) => ({ session, m })),
)
const matchRows: CupMatchRow[] = allMatches.map(({ session, m }, i) => {
const nordNames = m.participants.filter((p) => p.team_side === "a").map((p) => p.player_name).join(" / ")
const sorNames = m.participants.filter((p) => p.team_side === "b").map((p) => p.player_name).join(" / ")
return {
no: i + 1,
session: session.name,
nord: nordNames || (teamById.get(m.team_a_id)?.name ?? "Lag A"),
sor: sorNames || (teamById.get(m.team_b_id)?.name ?? "Lag B"),
result: m.status_text ?? "Ikke spilt",
winner: m.leading_side === "a" ? "nord" : m.leading_side === "b" ? "sor" : null,
}
})
if (cancelled) return
setCupData({
teamNord: { name: teamA?.team_name ?? "Lag A", color: teamA?.color ?? "#1f5fbf", points: teamA?.points ?? 0 },
teamSor: { name: teamB?.team_name ?? "Lag B", color: teamB?.color ?? "#d1561b", points: teamB?.points ?? 0 },
sessions: cup.sessions.map((s) => ({
name: s.name ?? `Økt ${s.sequence}`,
points: { nord: teamA ? (s.points_by_team[teamA.team_id] ?? 0) : 0, sor: teamB ? (s.points_by_team[teamB.team_id] ?? 0) : 0 },
})),
matches: matchRows,
})
}
} catch {
if (!cancelled) setError("Klarte ikke å laste resultatlisten. Prøv igjen.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId])
const paper = PAPER[size]
const natW = (orientation === "portrait" ? paper.w : paper.h) * MM_TO_PX
const natH = (orientation === "portrait" ? paper.h : paper.w) * MM_TO_PX
const areaRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(0.4)
useEffect(() => {
const el = areaRef.current
if (!el) return
const compute = () => {
const pad = 56
const availW = el.clientWidth - pad
const availH = el.clientHeight - pad
if (availW <= 0 || availH <= 0) return
setScale(Math.max(0.12, Math.min(availW / natW, availH / natH, 1.6)))
}
compute()
const ro = new ResizeObserver(compute)
ro.observe(el)
return () => ro.disconnect()
}, [natW, natH])
const pageCss = useMemo(
() => `@page { size: ${orientation === "portrait" ? `${paper.w}mm ${paper.h}mm` : `${paper.h}mm ${paper.w}mm`}; margin: 0; }`,
[paper.w, paper.h, orientation],
)
async function downloadPdf() {
setDownloading(true)
try {
const res = await fetch(`/orgs/${organizationId}/print/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ kind: "resultlist", tournament_id: tournamentId, paper: size, orientation }),
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "teecup-resultatliste.pdf"
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch {
setError("Klarte ikke å generere PDF-en. Prøv igjen.")
} finally {
setDownloading(false)
}
}
const ready = !loading
return (
<div className="flex min-h-screen flex-col bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style>{pageCss}</style>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3 lg:px-6 print:hidden">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Printer className="size-5" aria-hidden />
</div>
<div>
<h1 className="text-base font-extrabold leading-tight">Skriv ut resultatliste</h1>
<p className="text-[12px] text-muted-foreground">{error ?? (loading ? "Laster …" : "Klar")}</p>
</div>
</div>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading}
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-primary px-4 text-sm font-extrabold text-primary-foreground shadow-sm transition-transform hover:brightness-105 active:scale-[0.99] disabled:opacity-60"
>
<Download className="size-4" aria-hidden />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
</header>
<div className="flex flex-1 flex-col lg:flex-row">
<aside className="w-full shrink-0 border-b border-border bg-card p-4 lg:w-80 lg:border-b-0 lg:border-r lg:p-5 print:hidden">
<div className="flex flex-col gap-6">
<PanelSection title="Papirstørrelse">
<Segmented
ariaLabel="Papirstørrelse"
options={(Object.keys(PAPER) as SizeKey[]).map((k) => ({ key: k, label: PAPER[k].label, sub: PAPER[k].sub }))}
value={size}
onChange={setSize}
/>
</PanelSection>
<PanelSection title="Orientering">
<Segmented
ariaLabel="Orientering"
options={[
{ key: "portrait" as Orientation, label: "Portrett" },
{ key: "landscape" as Orientation, label: "Landskap" },
]}
value={orientation}
onChange={setOrientation}
/>
</PanelSection>
<PanelSection title={tournament?.format_type === "individual" ? "Valgfrie felt" : "Vis på arket"}>
{tournament?.format_type === "individual" ? (
<div className="flex flex-col divide-y divide-border/70">
<ToggleRow label="HCP" checked={aFields.hcp} onCheckedChange={(v) => setAFields((s) => ({ ...s, hcp: v }))} />
<ToggleRow label="Delresultat per runde" checked={aFields.rounds} onCheckedChange={(v) => setAFields((s) => ({ ...s, rounds: v }))} />
<ToggleRow label="Hjemmeklubb" checked={aFields.club} onCheckedChange={(v) => setAFields((s) => ({ ...s, club: v }))} />
<ToggleRow label="Del inn i klasser" checked={aFields.classes} onCheckedChange={(v) => setAFields((s) => ({ ...s, classes: v }))} />
<ToggleRow label="Vis cut-grense" checked={aFields.cut} onCheckedChange={(v) => setAFields((s) => ({ ...s, cut: v }))} />
</div>
) : (
<div className="flex flex-col divide-y divide-border/70">
<ToggleRow label="Poeng per økt" checked={bFields.sessions} onCheckedChange={(v) => setBFields((s) => ({ ...s, sessions: v }))} />
<ToggleRow label="Match for match" checked={bFields.matches} onCheckedChange={(v) => setBFields((s) => ({ ...s, matches: v }))} />
</div>
)}
</PanelSection>
</div>
</aside>
<main ref={areaRef} className="grid flex-1 place-items-center overflow-auto bg-[oklch(0.9_0.01_130)] p-7 dark:bg-[oklch(0.15_0.01_150)] print:block print:bg-transparent print:p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Laster resultatliste </p>
) : tournament?.format_type === "individual" ? (
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
<div
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
className="overflow-hidden print:!scale-100 print:shadow-none"
>
<SheetIndividual
tournamentName={tournament.name}
period=""
method={SCORING_METHOD_LABEL[tournament.scoring_method ?? ""] ?? tournament.scoring_method ?? ""}
entries={entries}
fields={aFields}
/>
</div>
</div>
) : cupData && tournament ? (
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
<div
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
className="overflow-hidden print:!scale-100 print:shadow-none"
>
<SheetCup tournamentName={tournament.name} meta="" teamNord={cupData.teamNord} teamSor={cupData.teamSor} sessions={cupData.sessions} matches={cupData.matches} fields={bFields} />
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{error ?? "Ingen data funnet."}</p>
)}
</main>
</div>
</div>
)
}

View file

@ -1,779 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useId, useRef, useState } from "react"
import { Download, Printer } from "lucide-react"
import { cn } from "@/lib/utils"
import {
fetchCourseHoles,
fetchMatches,
fetchRoundGroups,
fetchRoundParticipants,
fetchRounds,
fetchSessions,
fetchTeams,
fetchTournament,
fetchTournamentParticipants,
mockHoleLength,
type ApiCourseHole,
type ApiMatch,
type ApiRound,
type ApiRoundGroup,
type ApiRoundParticipant,
type ApiSession,
type ApiTeam,
type ApiTournament,
type ApiTournamentParticipant,
} from "@/lib/print-data"
/**
* Print-friendly, tradisjonelt golf-SCOREKORT -- ekte data (ADR-097).
*
* Videreført fra V0-mockupen (samme sheet-mekanikk: A4/Letter/A5,
* portrett/landskap, per-felt-toggles, én sheet PER UTSLAGSGRUPPE
* (individuell) eller PER MATCH (Cup) -- en organisator skriver ut kort for
* HELE feltet én gang, ikke ett enkelt eksempelkort.
*
* Hullengde finnes ikke i datamodellen ennå (bevisst utsatt, se
* FEATURE_BACKLOG.md) -- mockHoleLength() brukes akkurat som i selve
* V0-utskriftspromptene, ikke lenger her.
*/
const PAPER = {
A4: { w: 210, h: 297 },
Letter: { w: 216, h: 279 },
A5: { w: 148, h: 210 },
} as const
type PaperKey = keyof typeof PAPER
type Orientation = "portrait" | "landscape"
type PerSheet = 1 | 2
type SheetPlayer = {
name: string
hcpLabel: string
phcp: number | null
teeName: string | null
className: string | null
teamName?: string
teamColor?: string | null
}
type Sheet = { title: string; sublabel: string; players: SheetPlayer[] }
type FieldOpts = { netto: boolean; strokes: boolean; putts: boolean; stableford: boolean; matchplay: boolean }
function strokesOnHole(phcp: number, idx: number): number {
const base = Math.floor(phcp / 18)
const extra = phcp % 18
return base + (idx <= extra ? 1 : 0)
}
function ReceivedDots({ count }: { count: number }) {
if (count <= 0) return null
if (count >= 3) return <span className="text-[7px] font-bold leading-none text-neutral-500">{count}</span>
return (
<span className="flex gap-0.5">
{Array.from({ length: count }).map((_, i) => (
<span key={i} className="size-[3px] rounded-full bg-neutral-600" />
))}
</span>
)
}
function GridCell({
children,
wide,
shaded,
header,
className,
}: {
children?: React.ReactNode
wide?: boolean
shaded?: boolean
header?: boolean
className?: string
}) {
return (
<td
className={cn(
"border border-neutral-400 text-center align-middle",
shaded && "bg-neutral-100",
header ? "font-bold" : "",
className,
)}
>
{children}
</td>
)
}
function LabelCell({ children, shaded }: { children: React.ReactNode; shaded?: boolean }) {
return (
<td className={cn("border border-neutral-400 px-2 text-left align-middle text-[11px]", shaded && "bg-neutral-100")}>
{children}
</td>
)
}
// -------------------------- selve sheet-rendering --------------------------
function ScorecardSheet({ sheet, holes, fields, dense }: { sheet: Sheet; holes: ApiCourseHole[]; fields: FieldOpts; dense: boolean }) {
const splitInOut = holes.length > 9
const out = splitInOut ? holes.slice(0, 9) : holes
const inn = splitInOut ? holes.slice(9) : []
const outPar = out.reduce((s, h) => s + h.par, 0)
const inPar = inn.reduce((s, h) => s + h.par, 0)
const totalPar = outPar + inPar
const W_LABEL = 150
const W_HOLE = 42
const W_SUM = 50
const sumCols = splitInOut ? 3 : 1
const colUnits = W_LABEL + holes.length * W_HOLE + sumCols * W_SUM
const pctLabel = `${(W_LABEL / colUnits) * 100}%`
const pctHole = `${(W_HOLE / colUnits) * 100}%`
const pctSum = `${(W_SUM / colUnits) * 100}%`
function HoleCells({ render }: { render: (kind: "hole" | "out" | "in" | "total", hole?: ApiCourseHole) => React.ReactNode }) {
if (!splitInOut) {
return (
<>
{holes.map((h) => (
<GridCell key={`h-${h.hole_number}`}>{render("hole", h)}</GridCell>
))}
<GridCell wide shaded>
{render("total")}
</GridCell>
</>
)
}
return (
<>
{out.map((h) => (
<GridCell key={`o-${h.hole_number}`}>{render("hole", h)}</GridCell>
))}
<GridCell wide shaded>
{render("out")}
</GridCell>
{inn.map((h) => (
<GridCell key={`i-${h.hole_number}`}>{render("hole", h)}</GridCell>
))}
<GridCell wide shaded>
{render("in")}
</GridCell>
<GridCell wide shaded>
{render("total")}
</GridCell>
</>
)
}
function PlayerRow({ player }: { player: SheetPlayer }) {
const sub = (
<>
HCP {player.hcpLabel} · {player.teeName ?? "—"}
{player.className ? <> · Kl. {player.className}</> : null}
{player.teamName ? (
<>
{" · "}
<span className="inline-flex items-center gap-1">
<span className="inline-block size-2 rounded-sm" style={{ backgroundColor: player.teamColor ?? "#a3a3a3" }} />
{player.teamName}
</span>
</>
) : null}
</>
)
return (
<>
<tr>
<LabelCell>
<div className="flex flex-col leading-tight">
<span className="font-bold text-neutral-900">{player.name}</span>
<span className="text-[9px] text-neutral-500">{sub}</span>
</div>
</LabelCell>
<HoleCells
render={(kind, hole) => {
if (kind !== "hole") return null
if (fields.strokes && hole && player.phcp != null) {
const s = strokesOnHole(player.phcp, hole.stroke_index)
return (
<div className="relative h-full w-full" style={{ minHeight: 24 }}>
<span className="absolute left-0.5 top-0.5">
<ReceivedDots count={s} />
</span>
</div>
)
}
return <div style={{ minHeight: 24 }} />
}}
/>
</tr>
{fields.netto ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] italic text-neutral-600">Netto</span>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 16 }} />} />
</tr>
) : null}
{fields.putts ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] italic text-neutral-600">Putts</span>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 16 }} />} />
</tr>
) : null}
{fields.stableford ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] italic text-neutral-600">Poeng</span>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 16 }} />} />
</tr>
) : null}
</>
)
}
return (
<div className={cn("flex h-full flex-col text-neutral-900", dense ? "gap-2 p-4" : "gap-3 p-6")}>
<header className="flex items-end justify-between border-b-2 border-neutral-800 pb-2">
<div className="flex flex-col">
<span className={cn("font-extrabold leading-none", dense ? "text-sm" : "text-lg")}>{sheet.title}</span>
<span className={cn("text-neutral-600", dense ? "text-[11px]" : "text-sm")}>{sheet.sublabel}</span>
</div>
<div className="text-right text-[11px] text-neutral-600">
<div>Offisielt scorekort fylles ut for hånd</div>
</div>
</header>
<table className="w-full border-collapse text-[11px]" style={{ tableLayout: "fixed" }}>
<colgroup>
<col style={{ width: pctLabel }} />
{holes.map((h) => (
<col key={`c-${h.hole_number}`} style={{ width: pctHole }} />
))}
{splitInOut ? <col style={{ width: pctSum }} /> : null}
{splitInOut ? <col style={{ width: pctSum }} /> : null}
<col style={{ width: pctSum }} />
</colgroup>
<tbody>
<tr>
<LabelCell shaded>
<span className="font-bold uppercase tracking-wide text-neutral-700">Hull</span>
</LabelCell>
<HoleCells
render={(kind, hole) => {
if (kind === "hole") return <span className="font-bold">{hole!.hole_number}</span>
if (kind === "out") return <span className="font-extrabold">Ut</span>
if (kind === "in") return <span className="font-extrabold">Inn</span>
return <span className="font-extrabold">{splitInOut ? "Tot" : "Sum"}</span>
}}
/>
</tr>
<tr>
<LabelCell shaded>
<span className="font-bold text-neutral-700">Par</span>
</LabelCell>
<HoleCells
render={(kind, hole) => {
if (kind === "hole") return hole!.par
if (kind === "out") return <span className="font-bold">{outPar}</span>
if (kind === "in") return <span className="font-bold">{inPar}</span>
return <span className="font-bold">{totalPar}</span>
}}
/>
</tr>
<tr>
<LabelCell shaded>
<span className="text-[10px] text-neutral-600">Indeks</span>
</LabelCell>
<HoleCells render={(kind, hole) => (kind === "hole" ? <span className="text-neutral-500">{hole!.stroke_index}</span> : null)} />
</tr>
<tr>
<LabelCell shaded>
<span className="text-[9px] text-neutral-500">Meter</span>
</LabelCell>
<HoleCells
render={(kind, hole) => (kind === "hole" ? <span className="text-[9px] text-neutral-400">{mockHoleLength(hole!.par, hole!.hole_number)}</span> : null)}
/>
</tr>
{sheet.players.map((p, i) => (
<PlayerRow key={i} player={p} />
))}
{fields.matchplay ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] font-bold text-neutral-700">Match-status</span>
<div className="text-[8px] text-neutral-500">f.eks. «2 opp», «AS»</div>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 20 }} />} />
</tr>
) : null}
</tbody>
</table>
{fields.strokes ? (
<p className="text-[9px] text-neutral-500">
Mottatte slag: <span className="mx-0.5 inline-block size-[3px] rounded-full bg-neutral-600 align-middle" /> = ett slag,{" "}
<span className="mx-0.5 inline-block size-[3px] rounded-full bg-neutral-600 align-middle" />
<span className="mr-0.5 inline-block size-[3px] rounded-full bg-neutral-600 align-middle" /> = to slag (tall ved 3+).
</p>
) : null}
<div className="mt-auto grid grid-cols-2 gap-x-8 gap-y-3 border-t border-neutral-300 pt-3">
{sheet.players.map((p, i) => (
<div key={i} className="flex items-end gap-2">
<div className="flex-1">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Spillerens signatur {p.name}</span>
</div>
<div className="w-24">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Dato</span>
</div>
</div>
))}
<div className="col-span-2 flex items-end gap-2">
<div className="flex-1">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Markørens signatur</span>
</div>
<div className="w-24">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Dato</span>
</div>
</div>
</div>
</div>
)
}
function ScaledSheet({ paper, orientation, children }: { paper: PaperKey; orientation: Orientation; children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
const base = PAPER[paper]
const wMm = orientation === "landscape" ? base.h : base.w
const hMm = orientation === "landscape" ? base.w : base.h
const PX_PER_MM = 3.7795
const sheetWpx = wMm * PX_PER_MM
const sheetHpx = hMm * PX_PER_MM
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => setScale(Math.min(1, el.clientWidth / sheetWpx)))
ro.observe(el)
return () => ro.disconnect()
}, [sheetWpx])
return (
<div ref={containerRef} className="w-full print:contents">
<div style={{ height: sheetHpx * scale }} className="mx-auto print:hidden">
<div
className="origin-top-left overflow-hidden bg-white shadow-lg ring-1 ring-neutral-300"
style={{ width: sheetWpx, minHeight: sheetHpx, transform: `scale(${scale})` }}
>
{children}
</div>
</div>
<div className="hidden print:block print:break-after-page" style={{ width: `${wMm}mm`, minHeight: `${hMm}mm` }}>
{children}
</div>
</div>
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
function Segmented<T extends string>({
label,
value,
onChange,
options,
}: {
label: string
value: T
onChange: (v: T) => void
options: { value: T; label: string; disabled?: boolean }[]
}) {
return (
<div role="radiogroup" aria-label={label} className="flex flex-wrap gap-1 rounded-xl bg-muted p-1">
{options.map((o) => {
const active = o.value === value
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
disabled={o.disabled}
onClick={() => onChange(o.value)}
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 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
active ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
o.disabled && "cursor-not-allowed opacity-40 hover:text-muted-foreground",
)}
>
{o.label}
</button>
)
})}
</div>
)
}
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card px-3 py-2.5">
<span className="flex flex-col">
<span className="text-sm font-bold text-foreground">{label}</span>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-card shadow transition-all", checked ? "left-[22px]" : "left-0.5")} />
</button>
</label>
)
}
// -------------------------- data-lasting + hovedkomponent --------------------------
export function PrintScorecard({
organizationId,
tournamentId,
roundId,
sessionId,
initialPaper = "A4",
initialOrientation = "landscape",
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
initialPaper?: PaperKey
initialOrientation?: Orientation
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tournament, setTournament] = useState<ApiTournament | null>(null)
const [holes, setHoles] = useState<ApiCourseHole[]>([])
const [sheets, setSheets] = useState<Sheet[]>([])
const [contextLabel, setContextLabel] = useState("")
const [paper, setPaper] = useState<PaperKey>(initialPaper)
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
const [perSheet, setPerSheet] = useState<PerSheet>(1)
const [fields, setFields] = useState<FieldOpts>({
netto: false,
strokes: true,
putts: false,
stableford: false,
matchplay: sessionId != null,
})
const [downloading, setDownloading] = useState(false)
const styleId = useId()
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const t = await fetchTournament(organizationId, tournamentId)
if (cancelled) return
setTournament(t)
if (roundId) {
const [rounds, groupsRes, roundParticipants, tournamentParticipants] = await Promise.all([
fetchRounds(organizationId, tournamentId),
fetchRoundGroups(organizationId, tournamentId, roundId),
fetchRoundParticipants(organizationId, tournamentId, roundId),
fetchTournamentParticipants(organizationId, tournamentId),
])
const round = rounds.find((r: ApiRound) => r.id === roundId) ?? null
if (!round) throw new Error("Fant ikke runden.")
const courseHoles = await fetchCourseHoles(organizationId, round.course_id)
const playedHoles = filterHolesByConfig(courseHoles, round.hole_config)
const rpById = new Map<string, ApiRoundParticipant>(roundParticipants.map((rp) => [rp.id, rp]))
const tpById = new Map<string, ApiTournamentParticipant>(tournamentParticipants.map((tp) => [tp.id, tp]))
const builtSheets: Sheet[] = groupsRes.groups.map((g: ApiRoundGroup, i: number) => ({
title: t?.name ?? "Turnering",
sublabel: `${round.name} · ${round.course_name} · Gruppe ${g.sequence ?? i + 1}`,
players: g.participants.map((gp) => {
const rp = rpById.get(gp.round_participant_id)
const tp = rp ? tpById.get(rp.tournament_participant_id) : undefined
return {
name: gp.player_name,
hcpLabel: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "",
phcp: rp?.playing_handicap ?? null,
teeName: rp?.tee_name ?? null,
className: tp?.class_name ?? null,
}
}),
}))
if (cancelled) return
setHoles(playedHoles)
setContextLabel(`${round.name} · Individuell slagspill`)
setSheets(builtSheets)
} else if (sessionId) {
const [sessions, matches, teams] = await Promise.all([
fetchSessions(organizationId, tournamentId),
fetchMatches(organizationId, sessionId),
fetchTeams(organizationId, tournamentId),
])
const session = sessions.find((s: ApiSession) => s.id === sessionId) ?? null
if (!session) throw new Error("Fant ikke økten.")
const courseHoles = await fetchCourseHoles(organizationId, session.course_id)
const playedHoles = filterHolesByConfig(courseHoles, session.hole_config)
const teamById = new Map<string, ApiTeam>(teams.map((tm) => [tm.id, tm]))
const builtSheets: Sheet[] = matches.map((m: ApiMatch) => {
const teamA = teamById.get(m.team_a_id)
const teamB = teamById.get(m.team_b_id)
const players: SheetPlayer[] = m.participants.map((mp) => ({
name: mp.player_name,
hcpLabel: mp.playing_handicap != null ? String(mp.playing_handicap) : "",
phcp: mp.playing_handicap,
teeName: mp.tee_name,
className: null,
teamName: mp.team_side === "a" ? teamA?.name : teamB?.name,
teamColor: mp.team_side === "a" ? teamA?.color : teamB?.color,
}))
return {
title: t?.name ?? "Turnering",
sublabel: `${session.name} · ${session.format} · Match ${m.sequence}`,
players,
}
})
if (cancelled) return
setHoles(playedHoles)
setContextLabel(`${session.name} · Cup-format · Matchplay`)
setSheets(builtSheets)
} else {
throw new Error("Mangler runde- eller økt-ID.")
}
} catch {
if (!cancelled) setError("Klarte ikke å laste data for scorekortet. Prøv igjen.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, roundId, sessionId])
function choosePaper(p: PaperKey) {
setPaper(p)
if (p === "A5") {
setPerSheet(1)
setOrientation("landscape")
}
}
function choosePerSheet(n: PerSheet) {
setPerSheet(n)
if (n === 2) setOrientation("portrait")
}
const twoUp = perSheet === 2
const dense = twoUp || paper === "A5"
const base = PAPER[paper]
const pageSize = `${base.w}mm ${base.h}mm ${orientation}`
async function downloadPdf() {
setDownloading(true)
try {
const res = await fetch(`/orgs/${organizationId}/print/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
kind: "scorecard",
tournament_id: tournamentId,
round_id: roundId ?? null,
session_id: sessionId ?? null,
paper,
orientation,
}),
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "teecup-scorekort.pdf"
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch {
setError("Klarte ikke å generere PDF-en. Prøv igjen.")
} finally {
setDownloading(false)
}
}
const ready = !loading
return (
<div className="min-h-screen bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style>{`@media print { @page { size: ${pageSize}; margin: 8mm; } }`}</style>
<div id={styleId} className="mx-auto flex max-w-[1400px] flex-col gap-6 p-4 lg:flex-row lg:p-6 print:block print:p-0">
<aside className="flex w-full shrink-0 flex-col gap-5 rounded-2xl border border-border bg-card p-4 lg:w-80 print:hidden">
<div className="flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Printer aria-hidden="true" className="size-5" />
</span>
<div className="flex flex-col">
<h2 className="text-base font-extrabold leading-none text-foreground">Skriv ut scorekort</h2>
<span className="text-xs text-muted-foreground">{contextLabel || "Fysisk kort til bruk på banen"}</span>
</div>
</div>
{error ? <p className="text-sm font-medium text-destructive">{error}</p> : null}
<PanelSection title="Papir">
<Segmented label="Papirstørrelse" value={paper} onChange={choosePaper} options={[
{ value: "A4", label: "A4" },
{ value: "Letter", label: "Letter" },
{ value: "A5", label: "A5" },
]} />
<Segmented
label="Arkoppsett"
value={String(perSheet) as "1" | "2"}
onChange={(v) => choosePerSheet(Number(v) as PerSheet)}
options={[
{ value: "1", label: "Ett kort" },
{ value: "2", label: "To kort per ark", disabled: paper === "A5" },
]}
/>
<Segmented label="Retning" value={orientation} onChange={setOrientation} options={[
{ value: "landscape", label: "Landskap" },
{ value: "portrait", label: "Portrett" },
]} />
</PanelSection>
<PanelSection title="Valgfrie felt">
<div className="flex flex-col gap-2">
<Toggle label="Nettoscore-rad" hint="Tom rad under bruttoraden" checked={fields.netto} onChange={(v) => setFields((f) => ({ ...f, netto: v }))} />
<Toggle label="Mottatte slag (prikker)" hint="Prikker over hull-cellen" checked={fields.strokes} onChange={(v) => setFields((f) => ({ ...f, strokes: v }))} />
<Toggle label="Putts-rad" checked={fields.putts} onChange={(v) => setFields((f) => ({ ...f, putts: v }))} />
<Toggle label="Stableford-poeng" checked={fields.stableford} onChange={(v) => setFields((f) => ({ ...f, stableford: v }))} />
{sessionId ? (
<Toggle label="Matchplay-status" hint="Statusrad per hull" checked={fields.matchplay} onChange={(v) => setFields((f) => ({ ...f, matchplay: v }))} />
) : null}
</div>
</PanelSection>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading || sheets.length === 0}
className={cn(
"mt-1 flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-primary-foreground shadow-sm transition-colors",
"hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-60",
)}
>
<Download aria-hidden="true" className="size-4" />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
<p className="text-center text-[11px] text-muted-foreground">
{sheets.length > 0 ? `${sheets.length} kort i denne utskriften.` : loading ? "Laster …" : "Ingen grupper/matcher å skrive ut ennå."}
</p>
</aside>
<main className="min-w-0 flex-1 print:w-full">
<div className="rounded-2xl border border-border bg-muted/40 p-4 lg:p-8 print:border-0 print:bg-transparent print:p-0">
{loading ? (
<p className="p-8 text-center text-sm text-muted-foreground">Laster scorekort </p>
) : sheets.length === 0 ? (
<p className="p-8 text-center text-sm text-muted-foreground">Ingen grupper eller matcher funnet for denne runden/økten ennå.</p>
) : twoUp ? (
chunk(sheets, 2).map((pair, i) => (
<div key={i} className="mb-6 last:mb-0">
<ScaledSheet paper={paper} orientation={orientation}>
<TwoUp orientation={orientation}>
<ScorecardSheet sheet={pair[0]} holes={holes} fields={fields} dense />
{pair[1] ? <ScorecardSheet sheet={pair[1]} holes={holes} fields={fields} dense /> : <div />}
</TwoUp>
</ScaledSheet>
</div>
))
) : (
sheets.map((sheet, i) => (
<div key={i} className="mb-6 last:mb-0">
<ScaledSheet paper={paper} orientation={orientation}>
<ScorecardSheet sheet={sheet} holes={holes} fields={fields} dense={dense} />
</ScaledSheet>
</div>
))
)}
</div>
</main>
</div>
</div>
)
}
function TwoUp({ orientation, children }: { orientation: Orientation; children: React.ReactNode[] | React.ReactNode }) {
const stackVertical = orientation === "portrait"
const kids = Array.isArray(children) ? children : [children]
return (
<div className={cn("flex h-full w-full", stackVertical ? "flex-col" : "flex-row")}>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">{kids[0]}</div>
<div
className={cn(
"relative flex shrink-0 items-center justify-center",
stackVertical ? "h-0 w-full border-t border-dashed border-neutral-400" : "h-full w-0 border-l border-dashed border-neutral-400",
)}
>
<span className={cn("absolute bg-white px-1 text-[9px] text-neutral-400", stackVertical ? "left-2" : "top-2 -rotate-90")}>
{"✂"} klipp/brett her
</span>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">{kids[1]}</div>
</div>
)
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
function filterHolesByConfig(holes: ApiCourseHole[], config: "full_18" | "front_9" | "back_9"): ApiCourseHole[] {
const sorted = [...holes].sort((a, b) => a.hole_number - b.hole_number)
if (config === "front_9") return sorted.filter((h) => h.hole_number <= 9)
if (config === "back_9") return sorted.filter((h) => h.hole_number > 9)
return sorted
}

View file

@ -1,615 +0,0 @@
"use client"
import type React from "react"
import { useEffect, useId, useMemo, useRef, useState } from "react"
import { Download, Printer, Star } from "lucide-react"
import { cn } from "@/lib/utils"
import {
fetchMatches,
fetchPlayers,
fetchRoundGroups,
fetchRoundParticipants,
fetchRounds,
fetchSessions,
fetchTeams,
fetchTournament,
fetchTournamentParticipants,
formatDate,
formatTeeTime,
type ApiMatch,
type ApiPlayer,
type ApiRound,
type ApiRoundParticipant,
type ApiSession,
type ApiTeam,
type ApiTournamentParticipant,
} from "@/lib/print-data"
/**
* Print-friendly START-LISTE -- ekte data (ADR-097). Videreført fra
* V0-mockupen: Variant A (individuell, gruppevis) / Variant B (Cup,
* match-oppstilling m/ lagfarger). A3/A2-plakatformat kun for Cup.
*/
type SizeKey = "A4" | "Letter" | "A3" | "A2"
type Orientation = "portrait" | "landscape"
const PAPER: Record<SizeKey, { label: string; sub: string; w: number; h: number; large: boolean }> = {
A4: { label: "A4", sub: "210 × 297 mm", w: 210, h: 297, large: false },
Letter: { label: "Letter", sub: "216 × 279 mm", w: 216, h: 279, large: false },
A3: { label: "A3 / Tabloid", sub: "297 × 420 mm", w: 297, h: 420, large: true },
A2: { label: "A2 / ANSI C", sub: "420 × 594 mm", w: 420, h: 594, large: true },
}
const MM_TO_PX = 96 / 25.4
const INK = "#171717"
const INK_MUTED = "#5b5b5b"
const HAIRLINE = "#d9d9d9"
const HAIRLINE_STRONG = "#a3a3a3"
type GroupRow = { seq: number; time: string; players: { name: string; tee: string; hcp: string; klasse: string; gender: string; club: string }[] }
type MatchRow = {
no: number
time: string
points: string
nord: { name: string; tee: string; hcp: string; klasse: string; captain?: boolean }[]
sor: { name: string; tee: string; hcp: string; klasse: string; captain?: boolean }[]
}
function Segmented<T extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: { key: T; label: string; sub?: string; disabled?: boolean }[]
value: T
onChange: (v: T) => void
ariaLabel: string
}) {
return (
<div role="radiogroup" aria-label={ariaLabel} className="flex flex-wrap gap-1.5">
{options.map((o) => {
const active = o.key === value
return (
<button
key={o.key}
type="button"
role="radio"
aria-checked={active}
disabled={o.disabled}
onClick={() => onChange(o.key)}
className={cn(
"flex min-h-11 flex-1 flex-col items-start justify-center rounded-xl border px-3 py-2 text-left transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
active ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
o.disabled && "cursor-not-allowed opacity-40 hover:bg-card hover:text-muted-foreground",
)}
>
<span className="text-sm font-bold">{o.label}</span>
{o.sub ? <span className="text-[11px] font-medium opacity-70">{o.sub}</span> : null}
</button>
)
})}
</div>
)
}
function ToggleRow({ label, hint, checked, onCheckedChange }: { label: string; hint?: string; checked: boolean; onCheckedChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl px-1 py-1.5">
<span className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{label}</span>
{hint ? <span className="text-[11px] text-muted-foreground">{hint}</span> : null}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange(!checked)}
className={cn("relative h-6 w-11 shrink-0 rounded-full transition-colors", checked ? "bg-primary" : "bg-muted-foreground/30")}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[22px]" : "translate-x-0.5")} />
</button>
</label>
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
function TeamChip({ color, name }: { color: string; name: string }) {
return (
<span className="inline-flex items-center gap-2">
<span aria-hidden style={{ backgroundColor: color, borderColor: "rgba(0,0,0,0.25)" }} className="inline-block h-3 w-3 shrink-0 rounded-[3px] border" />
<span style={{ color: INK }} className="font-bold">
{name}
</span>
</span>
)
}
function SheetIndividual({
tournamentName,
round,
groups,
fields,
}: {
tournamentName: string
round: ApiRound
groups: GroupRow[]
fields: { hcp: boolean; klasse: boolean; gender: boolean; club: boolean }
}) {
return (
<div style={{ color: INK }} className="flex h-full flex-col">
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
<div className="flex items-baseline justify-between gap-4">
<h1 className="text-[22px] font-extrabold leading-tight">{tournamentName}</h1>
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
{round.start_mode === "shotgun" ? "Startliste — shotgun" : "Startliste"}
</span>
</div>
<div style={{ color: INK_MUTED }} className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] font-medium">
<span style={{ color: INK }} className="font-bold">
{round.name}
</span>
<span>{round.course_name}</span>
<span>{formatDate(round.scheduled_at)}</span>
</div>
</header>
<div className="mt-3 flex flex-1 flex-col gap-3">
{groups.map((g) => (
<div key={g.seq} style={{ borderColor: HAIRLINE }} className="break-inside-avoid rounded-[2px] border">
<div style={{ backgroundColor: "#f2f2f0", borderColor: HAIRLINE }} className="flex items-center justify-between border-b px-2.5 py-1.5">
<span className="text-[13px] font-extrabold">Gruppe {g.seq}</span>
<span className="text-[13px] font-bold tabular-nums">
{round.start_mode === "shotgun" ? `Starthull ${g.time}` : `Utslag ${g.time}`}
</span>
</div>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE }} className="border-b">
<th className="px-2.5 py-1 text-left font-semibold uppercase tracking-wide">Spiller</th>
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Tee</th>
{fields.hcp && <th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">HCP</th>}
{fields.klasse && <th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Klasse</th>}
{fields.gender && <th className="px-2 py-1 text-center font-semibold uppercase tracking-wide">Kjønn</th>}
{fields.club && <th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Hjemmeklubb</th>}
</tr>
</thead>
<tbody>
{g.players.map((p, i) => (
<tr key={p.name + i} style={{ borderColor: HAIRLINE }} className={cn(i < g.players.length - 1 && "border-b")}>
<td className="px-2.5 py-1 font-bold">{p.name}</td>
<td className="px-2 py-1">{p.tee}</td>
{fields.hcp && <td className="px-2 py-1 text-right tabular-nums">{p.hcp}</td>}
{fields.klasse && <td className="px-2 py-1">{p.klasse}</td>}
{fields.gender && <td className="px-2 py-1 text-center">{p.gender}</td>}
{fields.club && <td className="px-2 py-1">{p.club}</td>}
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
<SheetFooter />
</div>
)
}
function SheetCup({
tournamentName,
session,
teamNord,
teamSor,
matches,
fields,
poster,
}: {
tournamentName: string
session: ApiSession
teamNord: ApiTeam
teamSor: ApiTeam
matches: MatchRow[]
fields: { tee: boolean; hcp: boolean; klasse: boolean; points: boolean; captain: boolean }
poster: boolean
}) {
const meta = [`${session.name}${session.format}`, formatDate(session.scheduled_at)]
return (
<div style={{ color: INK }} className="flex h-full flex-col">
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
<div className="flex items-baseline justify-between gap-4">
<h1 className={cn("font-extrabold leading-tight", poster ? "text-[30px]" : "text-[22px]")}>{tournamentName}</h1>
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
Oppstilling
</span>
</div>
<div style={{ color: INK_MUTED }} className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] font-medium">
{meta.map((m, i) => (
<span key={m} style={i === 0 ? { color: INK } : undefined} className={i === 0 ? "font-bold" : undefined}>
{m}
</span>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-1 text-[13px]">
<TeamChip color={teamNord.color ?? "#1f5fbf"} name={teamNord.name} />
<span style={{ color: INK_MUTED }} className="font-bold">
vs
</span>
<TeamChip color={teamSor.color ?? "#d1561b"} name={teamSor.name} />
</div>
</header>
<div className={cn("mt-3 flex flex-1 flex-col", poster ? "gap-4" : "gap-2.5")}>
{matches.map((m) => (
<div key={m.no} style={{ borderColor: HAIRLINE_STRONG }} className="grid break-inside-avoid grid-cols-[auto_1fr_auto_1fr] items-stretch overflow-hidden rounded-[3px] border">
<div style={{ backgroundColor: "#f2f2f0", borderColor: HAIRLINE }} className={cn("flex flex-col items-center justify-center border-r px-3", poster ? "min-w-[92px]" : "min-w-[64px]")}>
<span style={{ color: INK_MUTED }} className="text-[10px] font-bold uppercase tracking-wide">
Match
</span>
<span className={cn("font-extrabold leading-none", poster ? "text-[34px]" : "text-[22px]")}>{m.no}</span>
<span className="mt-0.5 text-[12px] font-bold tabular-nums">{m.time}</span>
{fields.points && (
<span style={{ color: INK_MUTED }} className="mt-0.5 text-[10px] font-semibold">
{m.points}
</span>
)}
</div>
<MatchSide accent={teamNord.color ?? "#1f5fbf"} players={m.nord} fields={fields} poster={poster} align="left" />
<div style={{ borderColor: HAIRLINE }} className="flex items-center justify-center border-x px-2">
<span style={{ color: INK_MUTED }} className={cn("font-extrabold", poster ? "text-[16px]" : "text-[13px]")}>
vs
</span>
</div>
<MatchSide accent={teamSor.color ?? "#d1561b"} players={m.sor} fields={fields} poster={poster} align="right" />
</div>
))}
</div>
<SheetFooter />
</div>
)
}
function MatchSide({
accent,
players,
fields,
poster,
align,
}: {
accent: string
players: MatchRow["nord"]
fields: { tee: boolean; hcp: boolean; klasse: boolean; points: boolean; captain: boolean }
poster: boolean
align: "left" | "right"
}) {
return (
<div className={cn("flex flex-col justify-center gap-1 px-3 py-2", align === "right" && "items-end text-right")}>
<span aria-hidden style={{ backgroundColor: accent }} className="h-1 w-8 rounded-full" />
{players.map((p, i) => (
<div key={p.name + i} className={cn("flex flex-col", align === "right" && "items-end")}>
<span className={cn("font-bold leading-tight", poster ? "text-[16px]" : "text-[13px]")}>
{p.name}
{fields.captain && p.captain && (
<span style={{ color: INK_MUTED }} className="ml-1 inline-flex items-center align-middle text-[11px] font-semibold">
<Star aria-hidden className="mr-0.5 size-3" fill="currentColor" />
<span>Kaptein</span>
</span>
)}
</span>
{(fields.tee || fields.hcp || fields.klasse) && (
<span style={{ color: INK_MUTED }} className="text-[11px] font-medium">
{[fields.tee && p.tee, fields.hcp && `HCP ${p.hcp}`, fields.klasse && `Kl. ${p.klasse}`].filter(Boolean).join(" · ")}
</span>
)}
</div>
))}
</div>
)
}
function SheetFooter() {
return (
<footer style={{ borderColor: HAIRLINE, color: INK_MUTED }} className="mt-3 flex items-center justify-between border-t pt-2 text-[10px] font-medium">
<span>Side 1 av 1</span>
<span>Generert {new Date().toLocaleString("no-NO")}</span>
<span className="font-bold" style={{ color: INK }}>
TeeCup
</span>
</footer>
)
}
export function PrintStartlist({
organizationId,
tournamentId,
roundId,
sessionId,
initialSize = "A4",
initialOrientation = "portrait",
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
initialSize?: SizeKey
initialOrientation?: Orientation
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tournamentName, setTournamentName] = useState("Turnering")
const [round, setRound] = useState<ApiRound | null>(null)
const [groups, setGroups] = useState<GroupRow[]>([])
const [session, setSession] = useState<ApiSession | null>(null)
const [matches, setMatches] = useState<MatchRow[]>([])
const [teamNord, setTeamNord] = useState<ApiTeam | null>(null)
const [teamSor, setTeamSor] = useState<ApiTeam | null>(null)
const [downloading, setDownloading] = useState(false)
const [size, setSize] = useState<SizeKey>(initialSize)
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
const [aFields, setAFields] = useState({ hcp: true, klasse: true, gender: false, club: true })
const [bFields, setBFields] = useState({ tee: true, hcp: false, klasse: false, points: true, captain: true })
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const t = await fetchTournament(organizationId, tournamentId)
if (cancelled) return
setTournamentName(t?.name ?? "Turnering")
if (roundId) {
const [rounds, groupsRes, roundParticipants, tournamentParticipants, players] = await Promise.all([
fetchRounds(organizationId, tournamentId),
fetchRoundGroups(organizationId, tournamentId, roundId),
fetchRoundParticipants(organizationId, tournamentId, roundId),
fetchTournamentParticipants(organizationId, tournamentId),
fetchPlayers(organizationId),
])
const r = rounds.find((x: ApiRound) => x.id === roundId) ?? null
if (!r) throw new Error("Fant ikke runden.")
const rpById = new Map<string, ApiRoundParticipant>(roundParticipants.map((rp) => [rp.id, rp]))
const tpById = new Map<string, ApiTournamentParticipant>(tournamentParticipants.map((tp) => [tp.id, tp]))
const playerById = new Map<string, ApiPlayer>(players.map((p) => [p.id, p]))
const genderLabel = (g: ApiPlayer["gender"]) => (g === "m" ? "M" : g === "f" ? "K" : g === "x" ? "A" : "")
const built: GroupRow[] = groupsRes.groups.map((g, i) => ({
seq: g.sequence ?? i + 1,
time: r.start_mode === "shotgun" ? String(g.start_hole ?? "") : formatTeeTime(g.tee_time),
players: g.participants.map((gp) => {
const rp = rpById.get(gp.round_participant_id)
const tp = rp ? tpById.get(rp.tournament_participant_id) : undefined
const player = tp ? playerById.get(tp.player_id) : undefined
return {
name: gp.player_name,
tee: rp?.tee_name ?? "—",
hcp: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "",
klasse: tp?.class_name ?? "—",
gender: genderLabel(player?.gender ?? null),
club: player?.club ?? "—",
}
}),
}))
if (cancelled) return
setRound(r)
setGroups(built)
} else if (sessionId) {
const [sessions, matchesRaw, teams] = await Promise.all([
fetchSessions(organizationId, tournamentId),
fetchMatches(organizationId, sessionId),
fetchTeams(organizationId, tournamentId),
])
const s = sessions.find((x: ApiSession) => x.id === sessionId) ?? null
if (!s) throw new Error("Fant ikke økten.")
const [teamA, teamB] = teams
const built: MatchRow[] = matchesRaw.map((m: ApiMatch) => {
const side = (sideKey: "a" | "b") =>
m.participants
.filter((p) => p.team_side === sideKey)
.map((p) => ({ name: p.player_name, tee: p.tee_name ?? "—", hcp: p.playing_handicap != null ? String(p.playing_handicap) : "", klasse: "—" }))
return {
no: m.sequence,
time: s.start_mode === "shotgun" ? String(m.start_hole ?? "") : formatTeeTime(m.tee_time),
points: `${s.points_per_match.toLocaleString("nb-NO", { maximumFractionDigits: 1 })} poeng`,
nord: side("a"),
sor: side("b"),
}
})
if (cancelled) return
setSession(s)
setTeamNord(teamA ?? { id: "a", name: "Lag A", color: "#1f5fbf" })
setTeamSor(teamB ?? { id: "b", name: "Lag B", color: "#d1561b" })
setMatches(built)
} else {
throw new Error("Mangler runde- eller økt-ID.")
}
} catch {
if (!cancelled) setError("Klarte ikke å laste data for startlisten. Prøv igjen.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, roundId, sessionId])
const paper = PAPER[size]
const natW = (orientation === "portrait" ? paper.w : paper.h) * MM_TO_PX
const natH = (orientation === "portrait" ? paper.h : paper.w) * MM_TO_PX
const areaRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(0.4)
useEffect(() => {
const el = areaRef.current
if (!el) return
const compute = () => {
const pad = 56
const availW = el.clientWidth - pad
const availH = el.clientHeight - pad
if (availW <= 0 || availH <= 0) return
setScale(Math.max(0.12, Math.min(availW / natW, availH / natH, 1.6)))
}
compute()
const ro = new ResizeObserver(compute)
ro.observe(el)
return () => ro.disconnect()
}, [natW, natH])
const poster = sessionId != null && paper.large
const pageCss = useMemo(
() => `@page { size: ${orientation === "portrait" ? `${paper.w}mm ${paper.h}mm` : `${paper.h}mm ${paper.w}mm`}; margin: 0; }`,
[paper.w, paper.h, orientation],
)
const sizeOptions = (Object.keys(PAPER) as SizeKey[]).map((k) => ({
key: k,
label: PAPER[k].label,
sub: PAPER[k].sub,
disabled: PAPER[k].large && roundId != null,
}))
async function downloadPdf() {
setDownloading(true)
try {
const res = await fetch(`/orgs/${organizationId}/print/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
kind: "startlist",
tournament_id: tournamentId,
round_id: roundId ?? null,
session_id: sessionId ?? null,
paper: size === "A3" || size === "A2" ? "A4" : size,
orientation,
}),
})
if (!res.ok) throw new Error()
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "teecup-startliste.pdf"
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch {
setError("Klarte ikke å generere PDF-en. Prøv igjen.")
} finally {
setDownloading(false)
}
}
const ready = !loading
const styleId = useId()
return (
<div className="flex min-h-screen flex-col bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style key={styleId}>{pageCss}</style>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3 lg:px-6 print:hidden">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Printer className="size-5" aria-hidden />
</div>
<div>
<h1 className="text-base font-extrabold leading-tight">Skriv ut startliste</h1>
<p className="text-[12px] text-muted-foreground">{error ?? (loading ? "Laster …" : `${groups.length + matches.length} rad(er) klare`)}</p>
</div>
</div>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading}
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-primary px-4 text-sm font-extrabold text-primary-foreground shadow-sm transition-transform hover:brightness-105 active:scale-[0.99] disabled:opacity-60"
>
<Download className="size-4" aria-hidden />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
</header>
<div className="flex flex-1 flex-col lg:flex-row">
<aside className="w-full shrink-0 border-b border-border bg-card p-4 lg:w-80 lg:border-b-0 lg:border-r lg:p-5 print:hidden">
<div className="flex flex-col gap-6">
<PanelSection title="Papirstørrelse">
<Segmented ariaLabel="Papirstørrelse" options={sizeOptions} value={size} onChange={setSize} />
{sessionId && <p className="text-[11px] leading-relaxed text-muted-foreground">A3 og A2 gir en plakat for oppslagstavla.</p>}
</PanelSection>
<PanelSection title="Orientering">
<Segmented
ariaLabel="Orientering"
options={[
{ key: "portrait" as Orientation, label: "Portrett" },
{ key: "landscape" as Orientation, label: "Landskap" },
]}
value={orientation}
onChange={setOrientation}
/>
</PanelSection>
<PanelSection title="Valgfrie felt">
{roundId ? (
<div className="flex flex-col divide-y divide-border/70">
<ToggleRow label="HCP" checked={aFields.hcp} onCheckedChange={(v) => setAFields((s) => ({ ...s, hcp: v }))} />
<ToggleRow label="Klasse" checked={aFields.klasse} onCheckedChange={(v) => setAFields((s) => ({ ...s, klasse: v }))} />
<ToggleRow label="Kjønn" checked={aFields.gender} onCheckedChange={(v) => setAFields((s) => ({ ...s, gender: v }))} />
<ToggleRow label="Hjemmeklubb" checked={aFields.club} onCheckedChange={(v) => setAFields((s) => ({ ...s, club: v }))} />
</div>
) : (
<div className="flex flex-col divide-y divide-border/70">
<ToggleRow label="Utslag / tee" checked={bFields.tee} onCheckedChange={(v) => setBFields((s) => ({ ...s, tee: v }))} />
<ToggleRow label="HCP" checked={bFields.hcp} onCheckedChange={(v) => setBFields((s) => ({ ...s, hcp: v }))} />
<ToggleRow label="Poeng for matchen" checked={bFields.points} onCheckedChange={(v) => setBFields((s) => ({ ...s, points: v }))} />
<ToggleRow label="Kapteinmerking" checked={bFields.captain} onCheckedChange={(v) => setBFields((s) => ({ ...s, captain: v }))} />
</div>
)}
</PanelSection>
</div>
</aside>
<main ref={areaRef} className="grid flex-1 place-items-center overflow-auto bg-[oklch(0.9_0.01_130)] p-7 dark:bg-[oklch(0.15_0.01_150)] print:block print:bg-transparent print:p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Laster startliste </p>
) : round ? (
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
<div
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
className="overflow-hidden print:!scale-100 print:shadow-none"
>
<SheetIndividual tournamentName={tournamentName} round={round} groups={groups} fields={aFields} />
</div>
</div>
) : session && teamNord && teamSor ? (
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
<div
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
className="overflow-hidden print:!scale-100 print:shadow-none"
>
<SheetCup tournamentName={tournamentName} session={session} teamNord={teamNord} teamSor={teamSor} matches={matches} fields={bFields} poster={poster} />
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{error ?? "Ingen data funnet."}</p>
)}
</main>
</div>
</div>
)
}

View file

@ -1,220 +0,0 @@
"use client"
import { useEffect, useState } from "react"
import { Medal, ShieldAlert, Users } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- API-typer (matcher app/routers/order_of_merit.py sin
// get_public_order_of_merit/PublicOrderOfMeritOut) ---------------------
type ApiKind = "player" | "team"
type ApiResultType = "points" | "stableford" | "gross" | "net" | "money"
type ApiLeaderboardEntry = {
player_id: string
player_name: string
results_counted: number
results_available: number
value: number | null
position: string | null
eligible: boolean
ineligible_reason: string | null
}
type ApiPublicOom = {
name: string
kind: ApiKind
result_type: ApiResultType
aggregation_mode: "sum" | "average" | "eclectic"
entries: ApiLeaderboardEntry[]
}
const RESULT_TYPE_LABELS: Record<ApiResultType, string> = {
points: "Poeng etter plassering",
stableford: "Stableford-sum",
gross: "Bruttoscore-sum",
net: "Nettoscore-sum",
money: "Pengeliste",
}
function formatValue(value: number | null, resultType: ApiResultType): string {
if (value === null) return ""
if (resultType === "money") return `${Math.round(value).toLocaleString("nb-NO")} kr`
return value.toLocaleString("nb-NO", { maximumFractionDigits: 1 })
}
// --- Root --------------------------------------------------------------
export function PublicOrderOfMerit({ oomId }: { oomId: string }) {
const [oom, setOom] = useState<ApiPublicOom | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/public/order-of-merits/${oomId}`, { credentials: "include" })
if (res.status === 404) {
if (!cancelled) setError("Order of Merit finnes ikke.")
return
}
if (!res.ok) throw new Error(`oom: ${res.status}`)
const data: ApiPublicOom = await res.json()
if (!cancelled) setOom(data)
} catch {
if (!cancelled) setError("Klarte ikke å laste resultatlisten. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [oomId])
if (loading) {
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 || !oom) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldAlert aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
{error ?? "Order of Merit finnes ikke."}
</p>
</div>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center justify-center px-5 py-4">
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="flex flex-col gap-6">
<Banner name={oom.name} kind={oom.kind} resultType={oom.result_type} />
<section aria-label="Resultatliste" className="flex flex-col gap-4">
<h2 className="text-lg font-bold tracking-tight text-foreground">Resultatliste</h2>
<LeaderboardTable entries={oom.entries} resultType={oom.result_type} />
</section>
</div>
</main>
</div>
)
}
// --- Banner --------------------------------------------------------------
function Banner({ name, kind, resultType }: { name: string; kind: ApiKind; resultType: ApiResultType }) {
return (
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-primary to-primary/80 px-6 py-10 shadow-lg shadow-primary/20 sm:px-8 sm:py-12">
<div
aria-hidden="true"
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
/>
<div className="relative flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
<Users aria-hidden="true" className="size-4" />
Order of Merit
</span>
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-primary-foreground text-balance sm:text-4xl">
{name}
</h1>
<span className="text-sm font-semibold text-primary-foreground/80">
{kind === "team" ? "Lag" : "Spiller"} · {RESULT_TYPE_LABELS[resultType]}
</span>
</div>
</div>
)
}
// --- Resultatliste ---------------------------------------------------------
// Samme visuelle mønster som order-of-merit-detail.tsx sin LeaderboardTable
// (gull-fremhevet førsteplass, "ikke kvalifisert"-seksjon) -- bevisst
// duplisert (ikke delt) i tråd med hvordan list/detail-komponentene i
// dette området allerede holder egne lokale kopier av samme mønster.
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: ApiResultType }) {
const eligible = entries.filter((e) => e.eligible)
const ineligible = entries.filter((e) => !e.eligible)
if (eligible.length === 0 && ineligible.length === 0) {
return (
<div className="flex flex-col items-center gap-4 rounded-3xl border border-dashed border-border bg-card px-6 py-12 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Medal aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">Ingen resultater ennå.</p>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{eligible.map((e) => {
const isLeader = e.position === "1"
return (
<li
key={e.player_id}
className={cn("flex items-center gap-3 px-4 py-3", isLeader ? "bg-gold text-gold-foreground" : "bg-card")}
>
<span className={cn("w-9 shrink-0 text-center text-sm font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
{e.position}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className={cn("truncate text-sm font-bold", isLeader ? "text-gold-foreground" : "text-foreground")}>{e.player_name}</span>
{e.results_counted !== e.results_available && (
<span className={cn("text-xs", isLeader ? "text-gold-foreground/80" : "text-muted-foreground")}>
{e.results_counted} av {e.results_available} resultater talt
</span>
)}
</div>
<span className={cn("shrink-0 text-base font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
{formatValue(e.value, resultType)}
</span>
</li>
)
})}
</ul>
{ineligible.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Ikke kvalifisert</h3>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border opacity-70">
{ineligible.map((e) => (
<li key={e.player_id} className="flex items-center gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-muted-foreground">{e.player_name}</span>
{e.ineligible_reason && <span className="text-xs text-muted-foreground">{e.ineligible_reason}</span>}
</div>
</li>
))}
</ul>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,583 +0,0 @@
"use client"
// Utslagsgrupper (2026-08-18) -- hvem spiller med hvem, klokka når, for
// én runde i en individuell turnering. Bruker: "Jeg må jo kunne
// bulksjekke hvem som skal spille fra hvor, med hvem, klokka når?"
// Design bekreftet: TRADISJONELT utslag (samme starthull for alle,
// rundens eget start_hole -- uendret her), staggerte klokkeslett per
// gruppe med fast intervall, MANUELL gruppesammensetning med et forslag
// å justere, FAST gruppestørrelse.
//
// Håndkodet direkte (ikke sendt via V0 først) -- bruker ba eksplisitt om
// funksjonen NÅ ("Jeg vil ha den nå, takk"), samme avveining som
// DuplicateStep i player-import-panel.tsx: en avgrenset, funksjonell
// arbeidsflate satt inn i en eksisterende flyt, bygget med de samme
// globale design-tokenene som resten av appen (border/bg-card/bg-primær
// osv.), ikke en forsøkt "ferdig polert" ny flaggskip-skjerm. Kan
// poleres videre via V0 senere om ønskelig.
import { useEffect, useState } from "react"
import { Clock, Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { PrintMenu } from "@/components/print-menu"
type ApiGroupParticipant = {
round_participant_id: string
player_name: string
}
type ApiRoundGroup = {
id: string | null
sequence: number
tee_time: string | null
tee_time_override: string | null
// Kun meningsfullt når runden er 'shotgun' (migrasjon 088, ADR-096) --
// `null` for en 'consecutive'-runde, som fortsatt bruker rundens EGET
// delte start_hole (uendret).
start_hole: number | null
participants: ApiGroupParticipant[]
}
type ApiRoundGroups = {
groups: ApiRoundGroup[]
ungrouped: ApiGroupParticipant[]
}
type ApiRoundInfo = {
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
start_mode: string
}
function formatTeeTime(iso: string | null) {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
// datetime-local-inputet gir "YYYY-MM-DDTHH:mm" (ingen tidssone) -- server
// (Pydantic datetime-felt) godtar dette direkte, tolkes som lokal tid.
function toDatetimeLocalValue(iso: string | null) {
if (!iso) return ""
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return ""
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
const CONTROL =
"h-10 rounded-lg border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
export function RoundGroupsPanel({
base,
organizationId,
tournamentId,
roundId,
roundLabel,
onClose,
}: {
base: string
organizationId: string
tournamentId: string
roundId: string
roundLabel: string
onClose: () => void
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [roundInfo, setRoundInfo] = useState<ApiRoundInfo | null>(null)
const [scheduledAtInput, setScheduledAtInput] = useState("")
const [intervalInput, setIntervalInput] = useState("")
// Shotgun-start (migrasjon 088, ADR-096) -- bruker: "Det må komme frem
// om det er Shotgun eller løpende start... Sorteringen av startlisten
// gjøres etter utslagsform." 'consecutive' = dagens oppførsel uendret.
const [startModeInput, setStartModeInput] = useState<"consecutive" | "shotgun">("consecutive")
const [groups, setGroups] = useState<ApiRoundGroup[]>([])
const [ungrouped, setUngrouped] = useState<ApiGroupParticipant[]>([])
const [groupSize, setGroupSize] = useState("4")
async function loadAll() {
setLoading(true)
setError(null)
try {
const [roundRes, groupsRes] = await Promise.all([
fetch(`${base}/rounds`, { credentials: "include" }),
fetch(`${base}/rounds/${roundId}/groups`, { credentials: "include" }),
])
if (!roundRes.ok || !groupsRes.ok) throw new Error()
const rounds: (ApiRoundInfo & { id: string })[] = await roundRes.json()
const thisRound = rounds.find((r) => r.id === roundId) ?? null
const groupsData: ApiRoundGroups = await groupsRes.json()
setRoundInfo(thisRound)
setScheduledAtInput(toDatetimeLocalValue(thisRound?.scheduled_at ?? null))
setIntervalInput(thisRound?.tee_interval_minutes?.toString() ?? "")
setStartModeInput(thisRound?.start_mode === "shotgun" ? "shotgun" : "consecutive")
setGroups(groupsData.groups)
setUngrouped(groupsData.ungrouped)
} catch {
setError("Klarte ikke å laste utslagsgruppene. Prøv igjen.")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
async function saveSchedule() {
setError(null)
// <input type="datetime-local"> gir en TIDSSONELØS streng som betyr
// "organisatorens lokale klokkeslett" (f.eks. "10:00" i Norge). new
// Date(...) på en slik streng tolkes av nettleseren som LOKAL tid --
// .toISOString() konverterer deretter til riktig UTC-tidspunkt FØR
// sending. Uten dette steget ville "10:00" blitt lagret som 10:00 UTC
// (feil med hele tidssoneavviket, f.eks. 2 timer for norsk sommertid)
// -- funnet under egen scratch-verifisering av akkurat denne skjermen.
const scheduledAtIso = scheduledAtInput ? new Date(scheduledAtInput).toISOString() : null
const res = await fetch(`${base}/rounds/${roundId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
scheduled_at: scheduledAtIso,
tee_interval_minutes: intervalInput.trim() ? Number(intervalInput) : null,
start_mode: startModeInput,
}),
})
if (!res.ok) {
setError("Klarte ikke å lagre utslagstidspunktet.")
return
}
await loadAll()
}
async function suggest() {
const size = Number(groupSize)
if (!size || size < 1) return
const hasExistingWork = groups.some((g) => g.participants.length > 0)
if (hasExistingWork && !confirm("Dette erstatter det du eventuelt allerede har justert her (ikke lagret ennå). Fortsette?")) {
return
}
setError(null)
const res = await fetch(`${base}/rounds/${roundId}/groups/suggest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ group_size: size }),
})
if (!res.ok) {
setError("Klarte ikke å beregne et forslag.")
return
}
const data: ApiRoundGroups = await res.json()
setGroups(data.groups)
setUngrouped(data.ungrouped)
setSavedMessage(null)
}
function addEmptyGroup() {
const nextSequence = (groups.reduce((m, g) => Math.max(m, g.sequence), 0) || 0) + 1
setGroups((prev) => [
...prev,
{ id: null, sequence: nextSequence, tee_time: null, tee_time_override: null, start_hole: null, participants: [] },
])
}
function setGroupStartHole(sequence: number, value: string) {
const startHole = value.trim() ? Number(value) : null
setGroups((prev) => prev.map((g) => (g.sequence === sequence ? { ...g, start_hole: startHole } : g)))
}
function removeGroup(sequence: number) {
const group = groups.find((g) => g.sequence === sequence)
if (!group) return
setUngrouped((prev) => [...prev, ...group.participants])
setGroups((prev) => prev.filter((g) => g.sequence !== sequence))
}
// VIKTIG: `moved` må leses SYNKRONT fra det gjeldende (allerede rendrede)
// state-et, IKKE inne i en setState-oppdateringsfunksjon -- React kjører
// ikke oppdateringsfunksjoner umiddelbart, så en `let moved` satt inni én
// slik funksjon er fortsatt `null` når den leses rett etterpå i samme
// kall. Funnet under egen scratch-verifisering: en flyttet spiller
// forsvant sporløst (fjernet fra den gamle gruppen, aldri lagt til den
// nye) fordi nettopp dette skjedde.
function moveParticipant(participantId: string, toSequence: number | "ungrouped") {
const moved =
groups.flatMap((g) => g.participants).find((p) => p.round_participant_id === participantId) ??
ungrouped.find((p) => p.round_participant_id === participantId)
if (!moved) return
setGroups((prev) =>
prev.map((g) => {
if (g.sequence === toSequence) {
const withoutMoved = g.participants.filter((p) => p.round_participant_id !== participantId)
return { ...g, participants: [...withoutMoved, moved] }
}
return { ...g, participants: g.participants.filter((p) => p.round_participant_id !== participantId) }
}),
)
setUngrouped((prev) => {
const withoutMoved = prev.filter((p) => p.round_participant_id !== participantId)
return toSequence === "ungrouped" ? [...withoutMoved, moved] : withoutMoved
})
}
async function save() {
setSaving(true)
setError(null)
setSavedMessage(null)
try {
const res = await fetch(`${base}/rounds/${roundId}/groups`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
groups: groups.map((g) => ({
sequence: g.sequence,
tee_time_override: null,
start_hole: g.start_hole,
round_participant_ids: g.participants.map((p) => p.round_participant_id),
})),
}),
})
if (!res.ok) throw new Error()
const data: ApiRoundGroups = await res.json()
setGroups(data.groups)
setUngrouped(data.ungrouped)
setSavedMessage("Lagret.")
} catch {
setError("Klarte ikke å lagre utslagsgruppene. Prøv igjen.")
} finally {
setSaving(false)
}
}
const allSequences = groups.map((g) => g.sequence).sort((a, b) => a - b)
// Skiller "ingen deltakere i runden i det hele tatt" fra "alle er
// gruppert" -- begge ga tidligere identisk ungrouped.length===0, som
// gjorde "Alle deltakere er i en gruppe" misvisende når det egentlig
// betydde at ingen var lagt til runden ennå (egen tildeling under
// "Runder og baner", adskilt fra å være med i selve turneringen).
const totalRoundParticipants =
ungrouped.length + groups.reduce((sum, g) => sum + g.participants.length, 0)
function GroupSelect({ participantId, currentSequence }: { participantId: string; currentSequence: number | "ungrouped" }) {
return (
<label className="flex items-center gap-2">
<span className="sr-only">Gruppe</span>
<select
value={currentSequence}
onChange={(e) => {
const v = e.target.value
moveParticipant(participantId, v === "ungrouped" ? "ungrouped" : Number(v))
}}
className={cn(CONTROL, "h-9 px-2 text-xs")}
>
<option value="ungrouped">Ugruppert</option>
{allSequences.map((seq) => (
<option key={seq} value={seq}>
Gruppe {seq}
</option>
))}
</select>
</label>
)
}
return (
<div className="fixed inset-0 z-50 flex flex-col overflow-y-auto bg-background">
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background px-5 py-4 sm:px-8">
<div className="flex flex-col">
<h1 className="text-lg font-black text-foreground">Utslagsgrupper</h1>
<p className="text-sm font-semibold text-muted-foreground">{roundLabel}</p>
</div>
<div className="flex items-center gap-2">
<PrintMenu organizationId={organizationId} tournamentId={tournamentId} roundId={roundId} />
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="inline-flex size-11 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-5" />
</button>
</div>
</div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 sm:px-8">
{error && (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
)}
{loading ? (
<p className="text-sm font-medium text-muted-foreground">Laster</p>
) : (
<>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<h2 className="text-base font-bold text-foreground">Utslagsform</h2>
<div
role="radiogroup"
aria-label="Utslagsform"
className="inline-flex w-fit rounded-xl border border-border bg-background p-1"
>
{(
[
{ value: "consecutive" as const, label: "Løpende start" },
{ value: "shotgun" as const, label: "Shotgun" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={startModeInput === opt.value}
onClick={() => setStartModeInput(opt.value)}
className={cn(
"h-10 rounded-lg px-4 text-sm font-bold transition-colors",
startModeInput === opt.value
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
{startModeInput === "consecutive" ? (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle grupper starter fra samme hull (hull {roundInfo?.start_hole ?? 1}, satt for hele runden),
med staggerte klokkeslett ut fra intervallet under. Gruppe 1 slår ut først, gruppe 2 dette
antall minutter senere, og videre.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Første utslag</span>
<input
type="datetime-local"
value={scheduledAtInput}
onChange={(e) => setScheduledAtInput(e.target.value)}
className={CONTROL}
/>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Intervall (minutter)</span>
<input
type="number"
min={1}
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="f.eks. 10"
className={CONTROL}
/>
</label>
<button
type="button"
onClick={() => void saveSchedule()}
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Lagre tidspunkt
</button>
</div>
</>
) : (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alle grupper går ut SAMTIDIG, hver fra sitt eget starthull -- sett starthull per
gruppe nedenfor. Intervallet gjelder ikke i denne modusen.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Felles utslagstidspunkt</span>
<input
type="datetime-local"
value={scheduledAtInput}
onChange={(e) => setScheduledAtInput(e.target.value)}
className={CONTROL}
/>
</label>
<button
type="button"
onClick={() => void saveSchedule()}
className="inline-flex h-10 shrink-0 items-center justify-center rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Lagre tidspunkt
</button>
</div>
</>
)}
</section>
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:p-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Grupper</h2>
<p className="text-sm text-muted-foreground">
Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
</p>
</div>
{totalRoundParticipants === 0 && (
<p className="w-full rounded-xl border border-dashed border-border bg-background px-4 py-3 text-sm font-medium text-muted-foreground">
Ingen deltakere er lagt til i DENNE runden ennå -- det er et eget steg fra å
være med i turneringen. til "Runder og baner", utvid runden og legg til
deltakere der, dukker de opp her.
</p>
)}
<div className="flex items-end gap-2">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
<input
type="number"
min={1}
max={12}
value={groupSize}
onChange={(e) => setGroupSize(e.target.value)}
className={cn(CONTROL, "w-20")}
/>
</label>
<button
type="button"
onClick={() => void suggest()}
className="inline-flex h-10 items-center justify-center rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Foreslå grupper
</button>
</div>
</div>
<div className="flex flex-col gap-3">
{groups.length === 0 && (
<p className="text-sm font-medium text-muted-foreground">
Ingen grupper ennå -- bruk "Foreslå grupper" eller "Ny gruppe".
</p>
)}
{groups
.slice()
.sort((a, b) =>
// Shotgun (migrasjon 088, ADR-096): vis sortert etter
// STARTHULL, ikke sequence -- speiler backend sin
// tilsvarende sortering (_fetch_round_groups), men må
// også gjelde HER siden lokale, ulagrede endringer
// (f.eks. et nettopp justert starthull) ellers ikke
// ville flyttet gruppen i visningen før neste lagring/
// reload.
startModeInput === "shotgun"
? (a.start_hole === null ? 1 : 0) - (b.start_hole === null ? 1 : 0) ||
(a.start_hole ?? 0) - (b.start_hole ?? 0)
: a.sequence - b.sequence,
)
.map((g) => (
<div key={g.sequence} className="rounded-xl border border-border bg-background p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="flex size-8 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
{g.sequence}
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
{formatTeeTime(g.tee_time) ?? "Ingen klokkeslett satt"}
</span>
{startModeInput === "shotgun" && (
<label className="flex items-center gap-1.5">
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
<input
type="number"
min={1}
max={18}
value={g.start_hole ?? ""}
onChange={(e) => setGroupStartHole(g.sequence, e.target.value)}
placeholder="Hull"
className={cn(CONTROL, "h-9 w-20 px-2 text-xs")}
/>
</label>
)}
</div>
<button
type="button"
onClick={() => removeGroup(g.sequence)}
aria-label={`Fjern gruppe ${g.sequence}`}
className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</div>
{g.participants.length === 0 ? (
<p className="text-xs font-medium text-muted-foreground">Ingen spillere i denne gruppen ennå.</p>
) : (
<ul className="flex flex-col gap-2">
{g.participants.map((p) => (
<li key={p.round_participant_id} className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<GroupSelect participantId={p.round_participant_id} currentSequence={g.sequence} />
</li>
))}
</ul>
)}
</div>
))}
<button
type="button"
onClick={addEmptyGroup}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dashed border-border text-sm font-bold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Ny gruppe
</button>
</div>
<div className="border-t border-border pt-3">
<h3 className="mb-2 text-sm font-bold text-foreground">
Ugrupperte ({ungrouped.length})
</h3>
{ungrouped.length === 0 ? (
<p className="text-xs font-medium text-muted-foreground">
{totalRoundParticipants === 0
? "Ingen deltakere lagt til i runden ennå."
: "Alle deltakere er i en gruppe."}
</p>
) : (
<ul className="flex flex-col gap-2">
{ungrouped.map((p) => (
<li key={p.round_participant_id} className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
{allSequences.length > 0 ? (
<GroupSelect participantId={p.round_participant_id} currentSequence="ungrouped" />
) : (
<span className="text-xs text-muted-foreground">Opprett en gruppe først</span>
)}
</li>
))}
</ul>
)}
</div>
</section>
<div className="flex items-center justify-end gap-3 pb-6">
{savedMessage && <span className="text-sm font-bold text-tee-strong">{savedMessage}</span>}
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="inline-flex h-12 items-center justify-center rounded-xl bg-primary px-6 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre utslagsgrupper"}
</button>
</div>
</>
)}
</div>
</div>
)
}

View file

@ -1,66 +0,0 @@
"use client"
import { Clock } from "lucide-react"
export type RoundSummary = {
id: string
name: string
sequence: number
courseName: string | null
dateLabel: string | null // already formatted, e.g. "20. sep 2026", or null if unscheduled
}
export type RoundGroupsStepListProps = {
rounds: RoundSummary[]
onManageGroups: (roundId: string) => void
}
export function RoundGroupsStepList({ rounds, onManageGroups }: RoundGroupsStepListProps) {
if (rounds.length === 0) {
return (
<div className="rounded-2xl border border-border bg-card p-6 shadow-md shadow-black/8">
<p className="text-pretty text-center text-sm leading-relaxed text-muted-foreground">
Ingen runder er lagt til ennå legg til runder under «Runder og baner» først.
</p>
</div>
)
}
return (
// Connected list: one container, rows divided by hairlines — not separate
// shadowed cards with gaps between them.
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
<ul className="divide-y divide-border">
{rounds.map((round) => {
// Build the secondary "course · date" line from whatever is set.
const meta = [round.courseName, round.dateLabel].filter(Boolean).join(" · ")
return (
<li
key={round.id}
className="flex flex-wrap items-center justify-between gap-3 p-4 sm:p-5"
>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Runde {round.sequence}
</span>
<span className="truncate text-base font-bold text-foreground">{round.name}</span>
<span className="truncate text-sm text-muted-foreground tabular-nums">
{meta === "" ? "Ingen bane eller dato satt ennå" : meta}
</span>
</div>
<button
type="button"
onClick={() => onManageGroups(round.id)}
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-xl border border-border bg-card px-4 py-2.5 text-sm font-bold text-foreground transition-all duration-200 ease-in-out hover:bg-accent/50 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"
>
<Clock aria-hidden="true" className="size-5 shrink-0" />
<span>Rediger grupper</span>
</button>
</li>
)
})}
</ul>
</div>
)
}

View file

@ -303,14 +303,9 @@ function ManageRoundDialog({
</button>
</div>
{/* Reach the roster / team setup screen. `onClick={onClose}` (2026-08-20
brukerfunn): denne lenken navigerer ofte til KUN en søkeparameter-
endring samme rute (?tab=manage, se round-detail.tsx), som ikke
remounter RoundHeader -- uten dette ble dialogen stående åpen over
innholdet under, og måtte lukkes manuelt. */}
{/* Reach the roster / team setup screen */}
<Link
href={manageHref}
onClick={onClose}
className="flex min-h-14 items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">

View file

@ -1,12 +1,10 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react"
import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react"
import { MentionTextarea, TaggedText } from "./mention-input"
import type { Tag } from "@/lib/mentions"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { cn } from "@/lib/utils"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
/** Rå meldingsform fra backend. */
export type ApiRoundMessage = {
@ -22,13 +20,6 @@ export type ApiRoundMessage = {
tags: Tag[]
}
// Et lokalt, ikke-synkronisert innlegg (offline-kø-utvidelse, 2026-08-14,
// se ADR-028-tillegget). `id` er en midlertidig klient-id (aldri sendt til
// serveren) -- brukes kun som React-key inntil `load()` erstatter hele
// listen med serverens fasit etter vellykket synk. `image_url` er da en
// lokal object-URL (blob-forhåndsvisning), ikke en ekte MinIO-URL.
type PendingRoundMessage = ApiRoundMessage & { _clientMessageId: string }
export type RoundMessagesProps = {
roundId: string
currentUserId: string
@ -117,112 +108,34 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
const [postError, setPostError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
// Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
// egen kø-navnerom (`${roundId}:messages`, ikke bare roundId) slik at
// denne komponentens kø ALDRI blandes med round-detail.tsx sin
// hull-score-kø, selv om begge deler samme IndexedDB-database. De to
// køene flushes og telles helt uavhengig av hverandre.
const messagesMatchId = `${roundId}:messages`
const [isOnline, setIsOnline] = useState(true)
const [pendingCount, setPendingCount] = useState(0)
const [syncingMessages, setSyncingMessages] = useState(false)
// Ikke-synkroniserte innlegg, vist FØRST i listen inntil `load()` (kalt
// etter en vellykket synk) erstatter dem med serverens fasit. Kan IKKE
// legges inn i `messages` direkte -- de har ingen ekte server-id ennå.
const [pendingMessages, setPendingMessages] = useState<PendingRoundMessage[]>([])
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(`/rounds/${roundId}/messages`, { credentials: "include" })
if (!res.ok) throw new Error(`Status ${res.status}`)
const data: ApiRoundMessage[] = await res.json()
setMessages(data)
} catch {
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}, [roundId])
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
// (WS-signal fra round-detail.tsx).
useEffect(() => {
void load()
}, [load, refreshKey])
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 controller = new AbortController()
async function load() {
setLoading(true)
setLoadError(null)
try {
const res = await fetch(`/rounds/${roundId}/messages`, {
credentials: "include",
signal: controller.signal,
})
if (!res.ok) throw new Error(`Status ${res.status}`)
const data: ApiRoundMessage[] = await res.json()
setMessages(data)
} catch (err) {
if ((err as Error).name === "AbortError") return
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
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))
// Serverens fasit -- fanger opp bl.a. tagger som ble forkastet
// server-side (f.eks. mot noen som ikke lenger er medspiller/venn).
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
}, [roundId])
// Køede skrivinger fra en tidligere økt (f.eks. nettleseren ble lukket
// mens offline) -- samme engangs-sjekk-ved-mount-mønster som
// round-detail.tsx. MERK: en optimistisk visning av DISSE gjenopprettes
// bevisst IKKE (bilde-forhåndsvisningens object-URL overlever uansett
// ikke en reload) -- de dukker opp i selve listen først når `load()`
// kjører etter vellykket synk, en akseptert forenkling.
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
}, [roundId])
load()
return () => controller.abort()
}, [roundId, refreshKey])
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
useEffect(() => {
@ -260,72 +173,24 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
const trimmedBody = body.trim()
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
// Lagrer skrivingen i offline-køen og speiler den optimistisk øverst i
// listen (ADR-028-tillegget, 2026-08-14). `client_message_id` sendes med
// BÅDE her og i det direkte (online) forsøket under -- serveren
// dedupliserer på den ved en evt. gjentatt synk (migrasjon 073).
async function queueMessageOffline(fields: Record<string, string | Blob>, clientMessageId: string) {
await enqueueWrite({
url: `/rounds/${roundId}/messages`,
method: "POST",
body: fields,
matchId: messagesMatchId,
isMultipart: true,
})
// Egen, LOKAL object-URL for den optimistiske visningen -- må IKKE
// gjenbruke composerens `imagePreview` (den revokes av
// clearSelectedImage() rett under, som ville ugyldiggjort et delt bilde).
const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null
setPendingMessages((prev) => [
{
id: `pending-${clientMessageId}`,
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,
tags,
_clientMessageId: clientMessageId,
},
...prev,
])
setPendingCount((c) => c + 1)
setBody("")
setTags([])
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 (tags.length > 0) fields.tags = JSON.stringify(tags)
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)
if (trimmedBody.length > 0) formData.append("body", trimmedBody)
if (imageFile) formData.append("image", imageFile)
if (tags.length > 0) formData.append("tags", JSON.stringify(tags))
const res = await fetch(`/rounds/${roundId}/messages`, {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) {
if (res.status !== 201) {
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
return
}
@ -338,9 +203,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
setTags([])
clearSelectedImage()
} catch {
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
// for å tape kommentaren, samme prinsipp som hull-scoreføring (ADR-028).
await queueMessageOffline(fields, clientMessageId)
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
} finally {
setPosting(false)
}
@ -481,35 +344,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
)}
</form>
{/* Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
samme banner-mønster som round-detail.tsx sin hull-scoreføring. */}
{!isOnline && (
<div className="flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
<span>Du er offline. Kommentaren lagres lokalt og postes automatisk når du er tilbake nett.</span>
</div>
)}
{pendingCount > 0 && (
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
<span>
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} synkronisering.
</span>
{isOnline && (
<button
type="button"
onClick={() => void flushPendingMessages()}
disabled={syncingMessages}
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncingMessages && "animate-spin")} />
Synkroniser
</button>
)}
</div>
)}
{/* Meldingsliste -- ikke-synkroniserte innlegg ligger ALLTID først,
uavhengig av created_at (de er per definisjon de ferskeste). */}
{/* Meldingsliste */}
{loading ? (
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
<span
@ -522,15 +357,14 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
<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 ? (
) : 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 || roundOwnerUserId === currentUserId)
{messages.map((m) => {
const canDelete = m.author_user_id === currentUserId || roundOwnerUserId === currentUserId
const isDeleting = deletingId === m.id
const { label, title } = formatRelativeTime(m.created_at)
return (
@ -540,20 +374,13 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
<span className="truncate text-base font-semibold text-foreground">
{m.author_display_name}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
<Clock aria-hidden="true" className="size-3.5 shrink-0" />
Venter synk
</span>
) : (
<time
dateTime={m.created_at}
title={title}
className="text-sm font-medium text-muted-foreground"
>
{label}
</time>
)}
<time
dateTime={m.created_at}
title={title}
className="text-sm font-medium text-muted-foreground"
>
{label}
</time>
</div>
{canDelete && (
@ -592,16 +419,14 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
</p>
)}
{!isPending && (
<PostEngagement
apiBase={`/rounds/${roundId}/messages/${m.id}`}
roundId={roundId}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={roundOwnerUserId === currentUserId}
/>
)}
<PostEngagement
apiBase={`/rounds/${roundId}/messages/${m.id}`}
roundId={roundId}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={roundOwnerUserId === currentUserId}
/>
</li>
)
})}

View file

@ -483,7 +483,6 @@ function BarTrendChart({
formatSlope = formatValue,
goodDirection,
slopeSuffix,
yAxisLabel,
}: {
points: TrendPoint[]
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
@ -501,10 +500,6 @@ function BarTrendChart({
goodDirection: "up" | "down"
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
slopeSuffix: string
// Enhet for y-aksen (f.eks. "%", "Slag til par", "Antall") -- ekte HTML-
// tekst over grafen, ikke roterte SVG-glyffer, så den skalerer med
// brukerens skriftstørrelse (tilgjengelighetsregelen i CLAUDE.md).
yAxisLabel: string
}) {
if (points.length < 2) return null
@ -532,7 +527,6 @@ function BarTrendChart({
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{yAxisLabel}</span>
<div className="overflow-x-auto">
<svg
role="img"
@ -548,7 +542,6 @@ function BarTrendChart({
const cx = barCenterX(i)
const overPar = mode === "diverging" && point.value > 0
const isLast = i === points.length - 1
const isFirst = i === 0
return (
<g key={point.played_at + i}>
<path
@ -559,20 +552,14 @@ function BarTrendChart({
{formatShortDate(point.played_at)}: {formatValue(point.value)}
</title>
</path>
{/* Direkte etikett på FØRSTE og SISTE stolpe -- verdi i begge
ender grafen kan leses uten en egen y-akse-skala (samme
prinsipp som CumulativeTrendLine allerede bruker). Første er
dempet (utgangspunkt), siste er uthevet (nåværende nivå). */}
{(isFirst || isLast) && !(isFirst && isLast) && (
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
"merk endepunktet, ikke hvert punkt". */}
{isLast && (
<text
x={cx}
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
textAnchor="middle"
className={
isLast
? "fill-foreground text-[11px] font-bold tabular-nums"
: "fill-muted-foreground text-[10px] font-semibold tabular-nums"
}
className="fill-foreground text-[11px] font-bold tabular-nums"
>
{formatValue(point.value)}
</text>
@ -608,7 +595,7 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
if (points.length < 2) return null
return (
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Slag til par" />
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" />
</Card>
)
}
@ -626,15 +613,7 @@ function seriesFor(series: ApiRoundSeriesPoint[], field: keyof ApiRoundSeriesPoi
// AskUserQuestion-svar 2026-08-12: for få kvalifiserte hull per runde
// til at et per-runde-tall er meningsfullt, så her vises kun raten
// "til og med denne runden" som en enkel linje, ingen stolper.
function CumulativeTrendLine({
points,
label,
yAxisLabel,
}: {
points: TrendPoint[]
label: string
yAxisLabel: string
}) {
function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: string }) {
if (points.length < 2) return null
const values = points.map((p) => p.value)
const maxVal = Math.max(...values, 1)
@ -647,7 +626,6 @@ function CumulativeTrendLine({
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{yAxisLabel}</span>
<div className="overflow-x-auto">
<svg
role="img"
@ -887,7 +865,6 @@ export function RoundsStatsSummary() {
formatSlope={roundedNoUnit}
goodDirection="up"
slopeSuffix="pp per runde"
yAxisLabel="%"
/>
</div>
)}
@ -902,7 +879,7 @@ export function RoundsStatsSummary() {
{girTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Greentreff over tid</p>
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" yAxisLabel="%" />
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
</div>
)}
</Card>
@ -939,13 +916,13 @@ export function RoundsStatsSummary() {
{puttsTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Putt / 18 hull over tid</p>
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" yAxisLabel="Putt" />
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" />
</div>
)}
{onePuttTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Én-putt over tid</p>
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" yAxisLabel="%" />
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" />
</div>
)}
</Card>
@ -973,19 +950,19 @@ export function RoundsStatsSummary() {
{chipTrend.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Chip / runde</p>
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
{bunkerTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Bunkerslag / runde</p>
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
{penaltyTrend.length >= 2 && (
<div className="border-t border-border pt-3">
<p className="mb-1 text-sm font-bold text-foreground">Straffeslag / runde</p>
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</div>
)}
</Card>
@ -1003,7 +980,7 @@ export function RoundsStatsSummary() {
{anywayTrend.length >= 2 && (
<Card title="Anywayslag over tid">
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
</Card>
)}
@ -1039,13 +1016,13 @@ export function RoundsStatsSummary() {
{scramblingCumulative.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" yAxisLabel="%" />
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" />
</div>
)}
{sandSaveCumulative.length >= 2 && (
<div>
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" yAxisLabel="%" />
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" />
</div>
)}
</div>

View file

@ -3,7 +3,7 @@
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Clock, EyeOff, Flag, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
import { ArrowLeft, Clock, EyeOff, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@ -61,10 +61,6 @@ type ApiSession = {
course_id: string
points_per_match: number
scoring_mode: ScoringMode
// Shotgun-start (migrasjon 088, ADR-096) -- 'shotgun': matchene under
// legges til med eget starthull hver (se addMatch), ikke felles
// klokkeslett-staggering.
start_mode: "consecutive" | "shotgun"
locked_team_ids: string[]
}
@ -89,9 +85,6 @@ type ApiMatch = {
points_side_b: number | null
leading_side: "a" | "b" | null
tee_time: string | null
// Kun meningsfullt (og satt av organisator ved opprettelse) når økten
// er 'shotgun' (migrasjon 088, ADR-096).
start_hole: number | null
participants: ApiParticipant[]
}
@ -197,20 +190,7 @@ export function SessionBlindDraw({
}
}, [organizationId, tournamentId, sessionId])
// Shotgun (migrasjon 088, ADR-096): vis sortert etter STARTHULL, ikke
// sequence -- speiler backend sin tilsvarende sortering (fetch_matches
// i matches.py), men må også gjelde HER siden lokale, nettopp lagt-til
// matcher ellers ikke ville vist i riktig rekkefølge før neste reload.
const sortedMatches = useMemo(
() =>
[...matches].sort((a, b) =>
session?.start_mode === "shotgun"
? (a.start_hole === null ? 1 : 0) - (b.start_hole === null ? 1 : 0) ||
(a.start_hole ?? 0) - (b.start_hole ?? 0)
: a.sequence - b.sequence,
),
[matches, session?.start_mode],
)
const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches])
const nextSequence = useMemo(
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
[matches],
@ -223,7 +203,7 @@ export function SessionBlindDraw({
if (res.ok) setMatches(await res.json())
}
async function addMatch(startHole?: number) {
async function addMatch() {
if (!teams) return
setError(null)
try {
@ -231,12 +211,7 @@ export function SessionBlindDraw({
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sequence: nextSequence,
team_a_id: teams[0].id,
team_b_id: teams[1].id,
start_hole: startHole ?? null,
}),
body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }),
})
if (!res.ok) throw new Error(`create match: ${res.status}`)
const created: ApiMatch = await res.json()
@ -438,7 +413,6 @@ export function SessionBlindDraw({
<RevealedView
teams={teams}
matches={sortedMatches}
startMode={session.start_mode}
scorecardHrefFor={(matchId) =>
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
}
@ -456,7 +430,6 @@ export function SessionBlindDraw({
matches={sortedMatches}
perSide={maxSlotsPerSide(session.format)}
format={session.format}
startMode={session.start_mode}
locked={session.locked_team_ids.includes(team.id)}
usedPlayerIds={usedPlayerIds}
onAddParticipant={addParticipant}
@ -550,7 +523,6 @@ function TeamColumn({
matches,
perSide,
format,
startMode,
locked,
usedPlayerIds,
onAddParticipant,
@ -570,9 +542,6 @@ function TeamColumn({
matches: ApiMatch[]
perSide: number
format: Format
// Shotgun-start (migrasjon 088, ADR-096) -- styrer om "Legg til match"
// spør om starthull (shotgun) eller ikke (consecutive, som før).
startMode: "consecutive" | "shotgun"
locked: boolean
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
onAddParticipant: (
@ -583,7 +552,7 @@ function TeamColumn({
lineupOrder?: number | null,
) => void
onRemoveParticipant: (matchId: string, participantId: string) => void
onAddMatch: (startHole?: number) => void
onAddMatch: () => void
complete: boolean
confirming: boolean
onRequestLock: () => void
@ -591,7 +560,6 @@ function TeamColumn({
onConfirmLock: () => void
}) {
const color = team.color ?? "#64748b"
const [newMatchStartHole, setNewMatchStartHole] = useState("")
return (
<section
@ -627,17 +595,10 @@ function TeamColumn({
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
{startMode === "shotgun" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4" />
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
)}
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
@ -697,31 +658,10 @@ function TeamColumn({
})}
</div>
{!locked && startMode === "shotgun" && (
<div className="flex items-center gap-2">
<label className="flex flex-1 items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
<input
type="number"
min={1}
max={18}
value={newMatchStartHole}
onChange={(e) => setNewMatchStartHole(e.target.value)}
placeholder="Hull"
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
</div>
)}
{!locked && (
<button
type="button"
onClick={() => {
const hole = newMatchStartHole.trim() ? Number(newMatchStartHole) : undefined
onAddMatch(hole)
setNewMatchStartHole("")
}}
onClick={onAddMatch}
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
@ -887,12 +827,10 @@ function AddSlotForm({
function RevealedView({
teams,
matches,
startMode,
scorecardHrefFor,
}: {
teams: [ApiTeam, ApiTeam]
matches: ApiMatch[]
startMode: "consecutive" | "shotgun"
scorecardHrefFor: (matchId: string) => string
}) {
return (
@ -927,11 +865,6 @@ function RevealedView({
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
{match.status_text}
</span>
) : startMode === "shotgun" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4" />
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />

View file

@ -14,7 +14,6 @@ import {
WifiOff,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { HoleTargetDistance } from "@/components/hole-target-distance"
import { cn } from "@/lib/utils"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
@ -856,18 +855,6 @@ export function SessionScorecard({
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
</div>
{/* Rangefinder (migrasjon 079) -- samme selvskjulende
komponent som frittstående runder/individuell
org-turnering, ingen synlig rad for baner uten
koordinatdata. */}
<div className="mb-4">
<HoleTargetDistance
baseUrl={`/orgs/${organizationId}/matches/${matchId}`}
holeNumber={currentHole}
size="full"
/>
</div>
<div className="flex flex-col gap-3">
{units.map((unit) => {
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
@ -942,18 +929,6 @@ export function SessionScorecard({
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
</div>
{/* Rangefinder (migrasjon 079) -- samme selvskjulende
komponent som frittstående runder/individuell
org-turnering, ingen synlig rad for baner uten
koordinatdata. */}
<div className="mb-4">
<HoleTargetDistance
baseUrl={`/orgs/${organizationId}/matches/${matchId}`}
holeNumber={currentHole}
size="full"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{(
[

Some files were not shown because too many files have changed in this diff Show more