Compare commits
No commits in common. "546900c2d6e6c6e640454dc033cfd31daeb332fc" and "12504343ec71d8a0be1df0aab2369e01a7b9b9ae" have entirely different histories.
546900c2d6
...
12504343ec
161 changed files with 1769 additions and 35556 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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
3388
CHANGELOG.md
3388
CHANGELOG.md
File diff suppressed because it is too large
Load diff
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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 MÅ behandles i en EGEN
|
||||
`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen
|
||||
atomisk vei på 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 på en GJENBRUKT tilkobling (Postgres
|
||||
sin current_setting('app.current_org', true) kan returnere tom streng,
|
||||
ikke NULL, på 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) -- MÅ 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, så 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,
|
||||
)
|
||||
17
app/auth.py
17
app/auth.py
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
477
app/email.py
477
app/email.py
|
|
@ -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` MÅ være satt -- lenken peker rett på 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 på 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 rå 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" på {_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>
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
"""
|
||||
Cache-chokepoint mot GolfAPI.io (ADR-064). ALL import-kode (org-scopet
|
||||
`course` OG global `personal_course`) MÅ gå 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"],
|
||||
)
|
||||
|
|
@ -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 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, rå 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", [])
|
||||
|
|
@ -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, på tvers av BÅDE frittstående
|
||||
runder OG org-turneringer (på 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 på
|
||||
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 nå ser på).
|
||||
|
||||
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 rå 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
|
||||
må 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -12,4 +12,3 @@ argon2-cffi>=23.1
|
|||
pyotp>=2.9
|
||||
qrcode>=7.4
|
||||
pywebpush>=2.0
|
||||
playwright>=1.45
|
||||
|
|
|
|||
|
|
@ -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 på. 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 på 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"])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 på én bane er ikke hull 7 på 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 på 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` (rå sum på 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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 på `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 på 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, så reimport av samme CSV la dem inn på 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 på 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 på den eksisterende raden).
|
||||
- "overwrite": erstatt ALLE feltene på 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)
|
||||
|
|
|
|||
|
|
@ -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"'},
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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-
|
||||
|
|
|
|||
|
|
@ -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 på
|
||||
é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 på 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,
|
||||
|
|
|
|||
|
|
@ -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 på noen av dem (globale, delt på 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]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { HelpPage } from "@/components/help-page"
|
||||
|
||||
export default function Hjelp() {
|
||||
return <HelpPage />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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. Gå 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"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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. Gå 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"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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. Gå 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"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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. Gå 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"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 nå 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 så 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 så 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 nå 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}.`
|
||||
}
|
||||
|
|
@ -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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 på 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 på
|
||||
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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 på samme bane -- lenking av en turnering på en annen
|
||||
bane avvises så 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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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, på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 må 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 på 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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) så 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 på é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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 så 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. Gå til "Runder og baner", utvid runden og legg til
|
||||
deltakere der, så 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 på 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">
|
||||
|
|
|
|||
|
|
@ -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 på nett.</span>
|
||||
</div>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
||||
<span>
|
||||
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} på synkronisering.
|
||||
</span>
|
||||
{isOnline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void flushPendingMessages()}
|
||||
disabled={syncingMessages}
|
||||
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncingMessages && "animate-spin")} />
|
||||
Synkroniser nå
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 på 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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 så 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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue