Compare commits
117 commits
12504343ec
...
546900c2d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 546900c2d6 | |||
| 64d57f56ba | |||
| 30d009b490 | |||
| 9f81797324 | |||
| f1575c9b42 | |||
| 0792205c64 | |||
| a3f306c388 | |||
| e940e932c1 | |||
| a0163eaa16 | |||
| 6603e94fd3 | |||
| 544c952cab | |||
| 7e41daf1de | |||
| dbb589d9f6 | |||
| ecb3362058 | |||
| 116600cd3c | |||
| 76b306cb29 | |||
| 200d00cf5f | |||
| ee31b2d00b | |||
| 7426984e67 | |||
| c949259c77 | |||
| d5f49c1c5b | |||
| a52ff91ff1 | |||
| f10121beaf | |||
| 839bed4dd9 | |||
| 49530ffd40 | |||
| 2e4cb9733c | |||
| 05599aafe6 | |||
| 6765d17109 | |||
| 724983fe03 | |||
| 24eda54d10 | |||
| 7d5726cf4d | |||
| 95b05ab7f5 | |||
| 9b97a3bffb | |||
| 48f238d158 | |||
| 5dca409270 | |||
| 7632f53702 | |||
| 47e3874198 | |||
| 072638dc37 | |||
| 17589e1707 | |||
| 5355aba75d | |||
| 59080731f4 | |||
| 5daf2a764a | |||
| 1867c65143 | |||
| 6f25b57f90 | |||
| 396fe40b7c | |||
| 23f464def1 | |||
| 50e0ba902b | |||
| 5f1c73a917 | |||
| 0c864f0705 | |||
| fb00c65430 | |||
| c5ab6a18f2 | |||
| 18bfe555a0 | |||
| 300cb8aeaf | |||
| d57f4e5478 | |||
| 9a9fcc84c0 | |||
| 986e27ccd9 | |||
| 19c527f834 | |||
| 9f81258ef7 | |||
| accf5efd41 | |||
| 54795065ef | |||
| 985dada178 | |||
| eeb7e9fb29 | |||
| 207750acba | |||
| 82343e522b | |||
| 4ecd9ad196 | |||
| 0a9320991b | |||
| f5c79e8767 | |||
| 1bc6b0b74d | |||
| 8a1f1f83fa | |||
| eba8616438 | |||
| f3b3d30e4e | |||
| 1bfac02944 | |||
| 84f11e1a45 | |||
| d4f635fcfd | |||
| dabf3478ec | |||
| 6f79df6eab | |||
| 206aaa6217 | |||
| ced4bf869b | |||
| d83bfef7c4 | |||
| 00809d6817 | |||
| cc793339e4 | |||
| 366582d3ef | |||
| 93af7c5bef | |||
| f2ae68b30d | |||
| a9168f26d0 | |||
| 7d28727cb7 | |||
| cdd3719ab4 | |||
| d22b52c11c | |||
| 9f2711e4b3 | |||
| 46288c4b5f | |||
| b292401fe4 | |||
| 2ea4c26990 | |||
| c498bfca8f | |||
| f740814833 | |||
| fe13a82009 | |||
| 5d502f305a | |||
| 3770509c46 | |||
| bf6955bff6 | |||
| bb087b4437 | |||
| 60ee6a7d16 | |||
| 41ae0afa15 | |||
| 467cbd1ec2 | |||
| a8a2c602ce | |||
| 25b50d4e5b | |||
| dd8a8c133d | |||
| 4286f9eee3 | |||
| 9b7ff8e2ef | |||
| 9b47e5da05 | |||
| 25bdabe3ea | |||
| 25c6056e76 | |||
| 0f0ddca7f4 | |||
| d12af07ba7 | |||
| baa1c42d24 | |||
| 648293e1f8 | |||
| ac11da7b87 | |||
| 410eaa60ae | |||
| a9abe86116 |
161 changed files with 35562 additions and 1775 deletions
|
|
@ -466,7 +466,80 @@
|
||||||
"Bash(echo \"EXIT CODE: $?\")",
|
"Bash(echo \"EXIT CODE: $?\")",
|
||||||
"Bash(python3 -m json.tool /tmp/adapted.json)",
|
"Bash(python3 -m json.tool /tmp/adapted.json)",
|
||||||
"Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)",
|
"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(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)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
145
065_golfapi_courses.sql
Normal file
145
065_golfapi_courses.sql
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
24
066_round_participant_completion.sql
Normal file
24
066_round_participant_completion.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
26
067_golfapi_coordinate_more_poi_types.sql
Normal file
26
067_golfapi_coordinate_more_poi_types.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
18
068_golfapi_tee_men_rating_nullable.sql
Normal file
18
068_golfapi_tee_men_rating_nullable.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
66
069_flag_plant.sql
Normal file
66
069_flag_plant.sql
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
64
070_flag_plant_individual_tournament.sql
Normal file
64
070_flag_plant_individual_tournament.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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 $$;
|
||||||
21
071_flag_map_visibility.sql
Normal file
21
071_flag_map_visibility.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
24
072_eclectic_individual_tournament.sql
Normal file
24
072_eclectic_individual_tournament.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
25
073_round_message_client_id.sql
Normal file
25
073_round_message_client_id.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
38
074_player_extended_fields.sql
Normal file
38
074_player_extended_fields.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
52
075_tournament_hole_stats_parity.sql
Normal file
52
075_tournament_hole_stats_parity.sql
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'));
|
||||||
30
076_public_order_of_merit.sql
Normal file
30
076_public_order_of_merit.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
42
077_account_merge.sql
Normal file
42
077_account_merge.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
30
078_golfapi_coordinate_landmark_poi_type.sql
Normal file
30
078_golfapi_coordinate_landmark_poi_type.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
76
079_teeoff_course_coordinates.sql
Normal file
76
079_teeoff_course_coordinates.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
32
080_target_point_hazard_group.sql
Normal file
32
080_target_point_hazard_group.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
33
081_course_poi_type_creek.sql
Normal file
33
081_course_poi_type_creek.sql
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
25
082_course_poi_type_fairway_stonefence.sql
Normal file
25
082_course_poi_type_fairway_stonefence.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'
|
||||||
|
));
|
||||||
37
083_tournament_cut.sql
Normal file
37
083_tournament_cut.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
71
084_tournament_round_groups.sql
Normal file
71
084_tournament_round_groups.sql
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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);
|
||||||
36
085_tournament_tiebreak.sql
Normal file
36
085_tournament_tiebreak.sql
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'));
|
||||||
30
086_tournament_playoff.sql
Normal file
30
086_tournament_playoff.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
30
087_tournament_participant_status.sql
Normal file
30
087_tournament_participant_status.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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'));
|
||||||
48
088_shotgun_start.sql
Normal file
48
088_shotgun_start.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- 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,6 +108,35 @@ 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
|
`first_name` separat, ingen backend-endring nødvendig). Se
|
||||||
CHANGELOG.md for full detalj.
|
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
|
## Arbeidsmåte
|
||||||
- Inkrementelt. Ingenting tas for gitt før det er testet. Bekreft hvert steg før
|
- Inkrementelt. Ingenting tas for gitt før det er testet. Bekreft hvert steg før
|
||||||
du går videre.
|
du går videre.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ WORKDIR /srv
|
||||||
|
|
||||||
COPY app/requirements.txt app/requirements.txt
|
COPY app/requirements.txt app/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r 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 app/ app/
|
||||||
COPY handicap_engine.py handicap_engine.py
|
COPY handicap_engine.py handicap_engine.py
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
|
> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning
|
||||||
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
|
> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst)
|
||||||
>
|
>
|
||||||
> Sist oppdatert: 2026-07-17
|
> 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -473,8 +475,10 @@
|
||||||
team_a/team_b i én match — riktig total og per-økt-delsum likevel.
|
team_a/team_b i én match — riktig total og per-økt-delsum likevel.
|
||||||
|
|
||||||
### Push-varsler
|
### Push-varsler
|
||||||
- **Status:** 📋 planlagt (infrastruktur)
|
- **Status:** ✅ HELT FERDIG 2026-07-28 — se "Varsler: push til telefon +
|
||||||
- «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit.
|
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).
|
||||||
|
|
||||||
### Sanntid (WebSockets)
|
### Sanntid (WebSockets)
|
||||||
- **Status:** ✅ HELT FERDIG, LIVE 2026-07-19 (ADR-025 + ADR-027). Koblet
|
- **Status:** ✅ HELT FERDIG, LIVE 2026-07-19 (ADR-025 + ADR-027). Koblet
|
||||||
|
|
@ -563,12 +567,46 @@
|
||||||
flagget" der siste slag ble brukt). **Konkret krav fra bruker, eksplisitt
|
flagget" der siste slag ble brukt). **Konkret krav fra bruker, eksplisitt
|
||||||
formulert:** en visning som viser GJENSTÅENDE slag for spilleren,
|
formulert:** en visning som viser GJENSTÅENDE slag for spilleren,
|
||||||
oppdatert etter hvert spilte hull (nedtelling, ikke bare et sluttall).
|
oppdatert etter hvert spilte hull (nedtelling, ikke bare et sluttall).
|
||||||
**Fremtidig idé, uttrykkelig betinget av at GPS er integrert i appen
|
**Fremtidig idé, opprinnelig betinget av at GPS ble integrert i appen
|
||||||
først** (se GPS/avstandsmåling-notatet lenger opp i denne filen, ADR-033-
|
først:** bruk GPS til å markere/registrere HVOR på banen spilleren
|
||||||
seksjonen — samme avhengighet): bruk GPS til å markere/registrere HVOR på
|
faktisk endte opp når slagene tok slutt, ikke bare hvilket hull.
|
||||||
banen spilleren faktisk endte opp når slagene tok slutt, ikke bare hvilket
|
**Selve Flaggturnering-formatet ER bygget og live** (se "2026-07-30:
|
||||||
hull. Ingen datamodell eller UI designet ennå for noen del av dette —
|
alle åtte formater" lenger ned i denne filen — `flag_result`-motoren,
|
||||||
rent notat.
|
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.
|
||||||
|
|
||||||
- **Ingenting av dette er designet eller bygget ennå** — kun fanget her slik
|
- **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å:
|
at det ikke går i glemmeboken. Naturlig neste steg når dette tas fatt på:
|
||||||
|
|
@ -920,12 +958,18 @@ ikke sirkel/firkant som `round-scorecard.tsx` sin `ScoreMark`) ble også
|
||||||
funnet og rettet samme runde. Se CHANGELOG.md 2026-07-30 for full
|
funnet og rettet samme runde. Se CHANGELOG.md 2026-07-30 for full
|
||||||
verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet
|
verifiseringsdetalj (håndregnet HCP-kryssjekk, databasebekreftet
|
||||||
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
|
persistens, full opprett-ny-turnering-flyt testet fra bunnen).
|
||||||
**IKKE rullet ut mot ekte systemer ennå** — venter på brukerens
|
**RETTELSE 2026-08-14 — de to linjene under var stale, aldri oppdatert
|
||||||
eksplisitte bekreftelse (se CLAUDE.md for full detalj og utrullingsplan).
|
etter utrulling.** Migrasjon 040 er bekreftet live i ekte `teecup_db`
|
||||||
|
(`tournament.format_type`/`scoring_method` finnes i produksjonsskjemaet
|
||||||
**IKKE rullet ut mot ekte `teecup_db` ennå** -- venter på brukerens
|
med begge CHECK-constraints, verifisert direkte 2026-08-14). Individuelle
|
||||||
eksplisitte bekreftelse (migrasjon 040 + redeploy av `teecup_api`), per
|
turneringer må ha vært rullet ut en gang mellom 2026-07-30 og 2026-08-04
|
||||||
CLAUDE.md sin ufravikelige regel.
|
— 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.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1728,25 +1772,23 @@ er en annen streng, ikke rørt. Ekte typesjekket produksjonsbuild kompilerte
|
||||||
rent. Rullet ut (kun `teecup_frontend`), ingen migrasjon, `teeoff.no`
|
rent. Rullet ut (kun `teecup_frontend`), ingen migrasjon, `teeoff.no`
|
||||||
upåvirket.
|
upåvirket.
|
||||||
|
|
||||||
## Notat 2026-07-30: per-hull historikk/statistikk for spilleren — 📋 NOTERT, IKKE bygget
|
## 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)
|
||||||
|
|
||||||
Reist av brukeren: spilleren bør kunne se all historikk/statistikk for
|
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
|
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
|
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.
|
gangene" — ikke bare hva som skjedde DENNE runden.
|
||||||
|
|
||||||
**Ikke trivielt, verdt å notere hvorfor:** dagens data er scoped PER
|
**RETTELSE 2026-08-16 — "📋 NOTERT, IKKE bygget" var stale.** Bygget som
|
||||||
RUNDE (`round_hole`), ingen eksisterende spørring aggregerer "alle mine
|
ADR-072 (2026-08-15): `app/hole_history.py`, ny `GET .../holes/{n}/
|
||||||
tidligere runder på DENNE banen, filtrert til DETTE hullnummeret". Banen
|
history`, aggregerer PÅ TVERS AV BÅDE frittstående runder OG
|
||||||
identifiseres i dag kun ved navn-snapshot (`round.course_name_snapshot`,
|
org-turneringer (bredere enn notatets opprinnelige "kun frittstående
|
||||||
se `PlayedCourses`/`course-rounds.tsx` sin eksisterende gruppering på
|
runder"-forslag), banebro via `teeoff_facility_slug`/`teeoff_course_id`
|
||||||
akkurat dette navnet) — samme identifikator kan trolig gjenbrukes til en
|
(ikke navn-snapshotet notatet foreslo). Siden utvidet videre 2026-08-16
|
||||||
ny spørring: `GET /rounds/holes/{course_name}/{hole_number}/history` (eller
|
(ADR-079): historikken flyttet UT av selve slagvinduet/-registrerings-
|
||||||
tilsvarende), som slår sammen `round_hole`-rader på tvers av alle
|
skjermen til skjermen før, og et klikk åpner nå FULL historikk med en
|
||||||
fullførte runder brukeren eier/har spilt på den banen. Naturlig plassering
|
score-fordelingsgraf (samme stolpe-mønster som `round-stats.tsx`), ikke
|
||||||
i UI-et: en liten utvidbar seksjon i `ScoringWizard` sitt "strokes"-steg
|
bare en kort oppsummering.
|
||||||
(`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
|
## 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
|
||||||
|
|
||||||
|
|
@ -2184,13 +2226,26 @@ sammenslåing eller rekkefølge-endring i en senere runde.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Internasjonale baner (utenfor Norge) — 📋 NOTERT, IKKE designet/bygget (research 2026-08-03)
|
### 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.
|
||||||
|
|
||||||
Brukeren undersøkte (i en ekstern Gemini-samtale, ikke denne økten — notert
|
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
|
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
|
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,
|
`teeoff_db` (ADR-004/ADR-019) kun dekker norske baner. Ingen kode skrevet,
|
||||||
ingen ADR-runde startet ennå — dette er et fremtidig, ikke-akutt behov,
|
ingen ADR-runde startet ennå — dette var et fremtidig, ikke-akutt behov,
|
||||||
relevant når/hvis TeeCup faktisk kommersialiseres og får brukere utenfor
|
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
|
Norge (se `teecup.golf`-domeneplanen, kjøpt med sikte på at TeeCup etter
|
||||||
hvert flytter dit fra `teecup.teeoff.no`).
|
hvert flytter dit fra `teecup.teeoff.no`).
|
||||||
|
|
@ -2229,17 +2284,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
|
1 995€/2 995€. CSV gir 6 måneders oppdateringstilgang, deretter 20% av
|
||||||
opprinnelig pris per år for videre oppdateringer.
|
opprinnelig pris per år for videre oppdateringer.
|
||||||
|
|
||||||
**Bevisst utsatt, naturlig neste steg NÅR dette skal bygges:** en liten
|
**Opprinnelig "neste steg"-plan, alle tre punkter nå avklart/bygget under
|
||||||
ADR (samme mønster som ADR-019) som dekker (1) ny `course.source`-verdi +
|
ADR-064/065 (2026-08-12/13):** (1) `course.source='international'` +
|
||||||
migrasjon, (2) `app/golfapi_client.py` sitt kontraktsformat + enhets-
|
`external_course_ref` (org-siden) / `personal_course.external_golfapi_
|
||||||
konvertering (advarsel fra research: amerikanske kilder returnerer ofte
|
course_id` (global) — ikke én ny verdi i ett skjema, men et kopier-inn i
|
||||||
yards, må konverteres til meter FØR lagring — `hole`/`tee_rating`-
|
BEGGE eksisterende banemodeller (ADR-064 Beslutning B, bekreftet med
|
||||||
tabellene har ingen enhets-diskriminator i dag), (3) hvordan bane-søket i
|
bruker). (2) `app/golfapi_cache.py` (ikke `golfapi_client.py` som
|
||||||
frontend (samme to-felts-forvirring som teeoff-søket over, se det åpne
|
opprinnelig navngitt her — client/cache-lagene ble delt i to, se ADR-064)
|
||||||
punktet rett over) skal skille "søk norske baner (teeoff)" fra "søk
|
håndterer enhets-/datakvalitet-defensivt, inkl. en reell tomme-strenger-
|
||||||
internasjonale baner (golfapi.io)" — trolig ett samlet søkefelt med
|
i-stedet-for-null-bug funnet og fikset 2026-08-13. (3) Søkefeltet ble ETT
|
||||||
landkode-basert ruting internt, ikke to synlige knapper. Ikke noe hastverk
|
samlet felt akkurat som antatt her — "Fant ikke banen? Søk internasjonalt
|
||||||
— ingen brukere utenfor Norge ennå.
|
(GolfAPI)"-lenke fra det vanlige egen-bane-søket, ikke to synlige knapper.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -2886,7 +2941,18 @@ tom-skjermens endelige form kan bestemmes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2026-07-25 — avhengigheten er løst, konkret forslag lagt frem (📋 DESIGNET, IKKE BYGGET)
|
### 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.
|
||||||
|
|
||||||
Frittstående runder (ADR-033) er nå ferdig bygget (backend+frontend, alle
|
Frittstående runder (ADR-033) er nå ferdig bygget (backend+frontend, alle
|
||||||
oppfølgingsrunder), så blokkeringen over er borte. Brukeren reiste samtidig
|
oppfølgingsrunder), så blokkeringen over er borte. Brukeren reiste samtidig
|
||||||
|
|
@ -3571,13 +3637,21 @@ retroaktiv hull-kort-handling. Skjema: ny `round_shot`-tabell (migrasjon
|
||||||
alle endepunkter, delings-flyt med satellitt-bilde-generering) — se
|
alle endepunkter, delings-flyt med satellitt-bilde-generering) — se
|
||||||
CHANGELOG.md punkt 37 for full detalj. Frontend delvis (Haversine-
|
CHANGELOG.md punkt 37 for full detalj. Frontend delvis (Haversine-
|
||||||
hjelper, delt `ClubPicker`, Mapbox-avhengighet + token-plumbing).
|
hjelper, delt `ClubPicker`, Mapbox-avhengighet + token-plumbing).
|
||||||
**Gjenstår:** V0-eksport for selve `ShotMeasurementSheet` (prompt sendt
|
**RETTELSE 2026-08-18 — "Gjenstår"-lista under var stale, alt i den er
|
||||||
til bruker, venter på svar), kobling av inngangspunktene i
|
nå ferdig:** `ShotMeasurementSheet` V0-integrert og LIVE (`shot/map-
|
||||||
`round-detail.tsx`, reelle Mapbox-token i `.env` (kun tomme
|
point-picker.tsx` er i dag en ferdig, produksjonsbrukt "klikk et punkt
|
||||||
plassholdere satt inn foreløpig), ekte nettleserverifisering av
|
på kartet"-komponent, bekreftet gjenbrukbar i "Manuell koordinat-
|
||||||
kostnadskontroll-invarianten (ADR-048 Beslutning B), og til slutt
|
editor"-notatet lenger ned i denne filen), ekte Mapbox-token satt,
|
||||||
utrulling til ekte database/containere (krever migrasjon 060 kjørt mot
|
migrasjon 060 rullet ut mot ekte `teecup_db`. Byggekjeden fortsatte
|
||||||
ekte `teecup_db` — IKKE gjort ennå, venter på eksplisitt bekreftelse).
|
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.
|
||||||
|
|
||||||
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
|
**Se ADR-033 i ARCHITECTURE_DECISIONS.md for den fulle, besluttede
|
||||||
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
arkitekturen** (eierskapsmønster, statistikk-datamodell, HCP-indeksmotor).
|
||||||
|
|
@ -3848,7 +3922,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) fortsatt 📋 NOTERT
|
## É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)
|
||||||
|
|
||||||
Reist av brukeren rett etter ADR-032 (verifisert e-postbytte). Et beslektet,
|
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
|
men DISTINKT behov: én person kan ha flere e-postadresser i omløp samtidig
|
||||||
|
|
@ -3949,18 +4023,16 @@ kjørt mot ekte `teecup_db` (bekreftet begge nye tabeller finnes,
|
||||||
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
|
`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no`
|
||||||
upåvirket.
|
upåvirket.
|
||||||
|
|
||||||
### Del 2 (ekte kontosammenslåing) — fortsatt 📋 NOTERT, IKKE designet
|
### Del 2 (ekte kontosammenslåing) — ✅ BYGGET OG LIVE 2026-08-17
|
||||||
|
|
||||||
Uendret fra den opprinnelige analysen: hva skjer hvis adressen som legges
|
**RETTELSE 2026-08-18 — "📋 NOTERT, IKKE designet" var stale.** Bygget
|
||||||
til ALLEREDE er primær- eller sekundæradressen til en ANNEN, eksisterende
|
som ADR-080 (se ARCHITECTURE_DECISIONS.md): selvbetjent, "keeper"
|
||||||
konto (spilleren har altså to helt separate kontoer med egen historikk —
|
(initiativtaker, overlever) vs. "taper" (målet, slås inn og slettes),
|
||||||
ulike org-medlemskap, ulike spillerkoblinger, kanskje ulikt passord/2FA)?
|
lenke sendt til MÅLETS e-post for å bevise eierskap (samme prinsipp
|
||||||
Dagens del 1-løsning avviser dette tydelig (409 DUPLICATE) i stedet for å
|
som e-postbytte, ADR-032 Beslutning B), ny `account_merge_token`-
|
||||||
gjette — en ekte sammenslåing (slå sammen org-medlemskap uten å bryte
|
tabell (migrasjon 077). Løser presist spørsmålet under (hva skjer når
|
||||||
"én rolle per bruker per org", deduplisere spillerkoblinger, avgjøre
|
adressen som legges til allerede eies av en annen konto) -- avviser
|
||||||
hvilken konto som "vinner" for motstridende felt) er en betydelig større
|
IKKE lenger med 409, tilbyr i stedet ekte sammenslåing.
|
||||||
og mer risikofylt operasjon, fortsatt bevisst utsatt til en egen,
|
|
||||||
dedikert designrunde.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -3990,7 +4062,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort:
|
||||||
| Del | Status | Notat |
|
| 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. |
|
| 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 | ✅ 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). |
|
| 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. |
|
||||||
| 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. |
|
| 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). |
|
| 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. |
|
| 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. |
|
||||||
|
|
@ -4282,7 +4354,7 @@ denne veien er plattform-offentlig).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
## 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)
|
||||||
|
|
||||||
Brukeren ba om en vurdering av GolfBox sin Order of Merit-funksjon og
|
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
|
tok den til en full designrunde. Bygget denne runden: skjema (fire nye
|
||||||
|
|
@ -4296,25 +4368,22 @@ for de fem arkitektoniske beslutningene og CHANGELOG.md 2026-08-04 for
|
||||||
full bygge-/verifiseringsdetalj (63/63 håndregnede API-sjekker + RLS-
|
full bygge-/verifiseringsdetalj (63/63 håndregnede API-sjekker + RLS-
|
||||||
isolasjon + ekte nettleser-gjennomgang).
|
isolasjon + ekte nettleser-gjennomgang).
|
||||||
|
|
||||||
**Gjenstår fra opprinnelig plan (bevisst utsatt, ikke glemt):**
|
**RETTELSE 2026-08-16 — "Eclectic + lag-OOM 📋 GJENSTÅR" var stale.** Alle
|
||||||
- **Eclectic-aggregering** ("drømmerunde" -- beste resultat PER HULL på
|
tre punktene under ble tatt i én samlet runde 2026-08-15/16:
|
||||||
tvers av alle lenkede turneringer en spiller deltok i, kun for
|
- **Eclectic-aggregering PÅ TVERS AV LENKEDE TURNERINGER (OOM-nivå)** --
|
||||||
resultattype Stableford/brutto/netto). Krever egen per-hull-data-
|
✅ BYGGET (ADR-075). `eclectic_best_per_hole()` fra `handicap_engine.py`
|
||||||
innsamlingslogikk i leaderboard-endepunktet (ikke bare en ny gren i
|
gjenbrukt UENDRET, som antatt her -- kun datainnsamlingen på tvers av
|
||||||
`order_of_merit_aggregate`, som kun håndterer sum/snitt) -- egen
|
turneringer var ny. Samme-bane-håndheving lagt til (avviser lenking/
|
||||||
design-/verifiseringsrunde.
|
modus-bytte som ville gjort eclectic meningsløst på tvers av ulike
|
||||||
- **Lag-OOM sin faktiske leaderboard-beregning.** Skjema
|
baner).
|
||||||
(`order_of_merit_team`/`_team_member`) og prinsippet (sesong-par satt
|
- **Lag-OOM sin faktiske leaderboard-beregning** -- ✅ BYGGET (ADR-074).
|
||||||
opp DIREKTE i OOM-en, summerer/velger-beste-N-av medlemmenes allerede
|
Full CRUD for lag/medlemmer + `GET .../leaderboard`-grenen for
|
||||||
beregnede individuelle OOM-resultater -- IKKE strengmatching på
|
`kind='team'`, nøyaktig prinsippet som var avklart (ETT lagret
|
||||||
turnering-lag, se ADR-043 Beslutning C) er avklart og bygget inn i
|
aggregeringsvalg styrer begge nivåer, ingen egen lag-konfig).
|
||||||
skjemaet, men selve `GET .../leaderboard`-grenen for `kind='team'`
|
- **Offentlig/delt visning** -- ✅ BYGGET (ADR-076, migrasjon 076). Ny
|
||||||
finnes ikke ennå (avviser eksplisitt med en tydelig feilmelding).
|
SECURITY DEFINER-bro + `/public/order-of-merits/{id}`-endepunkt + ny
|
||||||
Trenger CRUD-endepunkter for lag/medlemmer (ingen finnes ennå) pluss
|
uautentisert side, samme anti-enumereringsmønster som resten av appens
|
||||||
selve aggregering-oppå-aggregering-logikken.
|
offentlige sider.
|
||||||
- **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).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -4455,3 +4524,350 @@ innlogging/auth-flyten fikk den nye "clubhouse"-paletten. Resten av appen
|
||||||
palett (se DESIGN_SYSTEM.md) inntil videre — retemaes gradvis, skjerm for
|
palett (se DESIGN_SYSTEM.md) inntil videre — retemaes gradvis, skjerm for
|
||||||
skjerm, når de likevel røres i senere runder. Ingen egen stor
|
skjerm, når de likevel røres i senere runder. Ingen egen stor
|
||||||
retemaings-runde er igangsatt eller bedt om.
|
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.
567
app/account_merge.py
Normal file
567
app/account_merge.py
Normal file
|
|
@ -0,0 +1,567 @@
|
||||||
|
"""
|
||||||
|
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,6 +229,23 @@ 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:
|
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
|
"""ADR-022 Beslutning D: is_super_admin er KUN manuelt DB-tildelt, aldri
|
||||||
settbart via noe API-endepunkt. Denne avhengigheten sjekker bare
|
settbart via noe API-endepunkt. Denne avhengigheten sjekker bare
|
||||||
|
|
|
||||||
|
|
@ -114,5 +114,12 @@ class Settings:
|
||||||
# bilde faller tilbake til ren tekst hvis ikke satt.
|
# bilde faller tilbake til ren tekst hvis ikke satt.
|
||||||
MAPBOX_SECRET_TOKEN: str | None = os.environ.get("TEECUP_MAPBOX_SECRET_TOKEN", "").strip() or None
|
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()
|
settings = Settings()
|
||||||
|
|
|
||||||
477
app/email.py
477
app/email.py
|
|
@ -14,12 +14,73 @@ vanlig tilkobling (typisk port 587/25).
|
||||||
|
|
||||||
import smtplib
|
import smtplib
|
||||||
from asyncio import to_thread
|
from asyncio import to_thread
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from html import escape as _esc
|
from html import escape as _esc
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from .config import settings
|
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:
|
def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
|
|
@ -233,6 +294,64 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local
|
||||||
await to_thread(_send_sync, to_email, template["subject"], body)
|
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 = {
|
_NOTIFICATION_TEMPLATES = {
|
||||||
"nb": {
|
"nb": {
|
||||||
"subject": "Nytt varsel fra TeeCup",
|
"subject": "Nytt varsel fra TeeCup",
|
||||||
|
|
@ -351,22 +470,61 @@ async def send_new_account_alert_email(to_email: str, new_user_email: str, displ
|
||||||
class RoundSummaryHole:
|
class RoundSummaryHole:
|
||||||
hole_number: int
|
hole_number: int
|
||||||
par: int
|
par: int
|
||||||
|
stroke_index: int
|
||||||
score: int | None
|
score: int | None
|
||||||
|
picked_up: bool
|
||||||
net: int | None
|
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(
|
async def send_round_summary_email(
|
||||||
to_email: str,
|
to_email: str,
|
||||||
guest_first_name: str,
|
guest_first_name: str,
|
||||||
round_label: 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],
|
holes: list[RoundSummaryHole],
|
||||||
stat_lines: list[tuple[str, str]],
|
stat_lines: list[tuple[str, str]],
|
||||||
raw_token: str,
|
raw_token: str | None,
|
||||||
|
is_linked_account: bool = False,
|
||||||
|
round_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert
|
"""Rundeoppsummering til en spiller med registrert e-post, sendt
|
||||||
e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round,
|
UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin
|
||||||
2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via
|
update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele
|
||||||
_send_sync sin nye html_body-parameter, med en ekte tekst-fallback.
|
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).
|
||||||
|
|
||||||
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
|
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
|
||||||
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
|
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
|
||||||
|
|
@ -376,51 +534,273 @@ async def send_round_summary_email(
|
||||||
send_scorecard_invitations/send_session_result_email) -- graderer seg
|
send_scorecard_invitations/send_session_result_email) -- graderer seg
|
||||||
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
|
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
|
||||||
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
|
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
|
||||||
finnes data for.
|
finnes data for. `holes` graderer seg på samme vis kolonne for kolonne
|
||||||
|
(2026-08-13, ADR-065 "fullt scorekort"-utvidelsen, se RoundSummaryHole).
|
||||||
|
|
||||||
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten
|
`played_at` (dato) + `started_at` (klokkeslett, valgfritt) brukes til
|
||||||
har ingen lagret språkpreferanse -- ingen konto ennå)."""
|
hilsen-linjens dato/klokkeslett, formatert til Europe/Oslo -- appen er
|
||||||
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
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}"
|
||||||
subject = f"Scorekortet ditt fra {round_label}"
|
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 = [
|
text_lines = [
|
||||||
f"Hei {guest_first_name},",
|
f"Hei {guest_first_name},",
|
||||||
"",
|
"",
|
||||||
f"Her er scorekortet ditt fra {round_label}:",
|
greeting_line,
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
for h in holes:
|
for h in holes:
|
||||||
score_txt = str(h.score) if h.score is not None else "–"
|
cell_txt = ", ".join(f"{label} {fn(h)}" for label, fn in columns)
|
||||||
net_txt = str(h.net) if h.net is not None else "–"
|
text_lines.append(f"Hull {h.hole_number}: {cell_txt}")
|
||||||
text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})")
|
|
||||||
text_lines.append("")
|
text_lines.append("")
|
||||||
for label, value in stat_lines:
|
for label, value in stat_lines:
|
||||||
text_lines.append(f"{label}: {value}")
|
text_lines.append(f"{label}: {value}")
|
||||||
text_lines += [
|
if is_linked_account:
|
||||||
"",
|
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 "
|
"Se hele runden:",
|
||||||
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
|
link,
|
||||||
"",
|
]
|
||||||
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
|
else:
|
||||||
link,
|
text_lines += [
|
||||||
"",
|
"",
|
||||||
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
|
"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)
|
body = "\n".join(text_lines)
|
||||||
|
|
||||||
hole_rows_html = "\n".join(
|
# HTML-tabellen (2026-08-13, samme dag som ovenstående -- bruker sendte
|
||||||
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>'
|
# et skjermbilde av selve Scorekort-siden i appen og ba om at e-posten
|
||||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>'
|
# skal SE UT SOM DEN, ikke bare inneholde de samme tallene): hull som
|
||||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else "–"}</td>'
|
# KOLONNER (ikke rader, ulikt tekst-fallbacken over) i to 9-hulls-
|
||||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else "–"}</td></tr>'
|
# blokker (Ut/Inn) for en 18-hullsrunde, matcher `ScoreBlock` i
|
||||||
for h in holes
|
# 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;"
|
||||||
)
|
)
|
||||||
stat_rows_html = "\n".join(
|
_cell_style = "padding:5px 3px;border:1px solid #e4ebe3;text-align:center;font-size:11px;"
|
||||||
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>'
|
_sum_style = _cell_style + "font-weight:800;background-color:#eef4ec;"
|
||||||
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
|
|
||||||
for label, value in stat_lines
|
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>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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"""\
|
html_body = f"""\
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
|
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
|
||||||
|
|
@ -432,30 +812,17 @@ async def send_round_summary_email(
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td style="padding:24px;">
|
<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 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
|
||||||
<p style="margin:0 0 20px;font-size:16px;">Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>:</p>
|
<p style="margin:0 0 20px;font-size:16px;">{greeting_html}</p>
|
||||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;">
|
<div style="overflow-x:auto;">
|
||||||
<tr style="background-color:#edf3ef;font-weight:700;">
|
{blocks_html}
|
||||||
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td>
|
</div>
|
||||||
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td>
|
{tiles_html}
|
||||||
</tr>
|
{pitch_html}
|
||||||
{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;">
|
<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;
|
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
|
||||||
font-weight:700;text-decoration:none;">Logg inn og se hele runden</a>
|
font-weight:700;text-decoration:none;">{cta_label}</a>
|
||||||
</td></tr></table>
|
</td></tr></table>
|
||||||
<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
|
{footer_html}
|
||||||
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>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
|
||||||
129
app/golfapi_cache.py
Normal file
129
app/golfapi_cache.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""
|
||||||
|
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"],
|
||||||
|
)
|
||||||
85
app/golfapi_client.py
Normal file
85
app/golfapi_client.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""
|
||||||
|
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", [])
|
||||||
347
app/hole_history.py
Normal file
347
app/hole_history.py
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
"""
|
||||||
|
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,6 +16,7 @@ from fastapi import FastAPI
|
||||||
|
|
||||||
from .db import init_pool, close_pool
|
from .db import init_pool, close_pool
|
||||||
from .routers import (
|
from .routers import (
|
||||||
|
account_merge,
|
||||||
auth,
|
auth,
|
||||||
courses,
|
courses,
|
||||||
friends,
|
friends,
|
||||||
|
|
@ -26,6 +27,7 @@ from .routers import (
|
||||||
order_of_merit,
|
order_of_merit,
|
||||||
organizations,
|
organizations,
|
||||||
players,
|
players,
|
||||||
|
print_pdf,
|
||||||
registration,
|
registration,
|
||||||
round_messages,
|
round_messages,
|
||||||
rounds,
|
rounds,
|
||||||
|
|
@ -48,12 +50,14 @@ async def lifespan(app: FastAPI):
|
||||||
app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
|
app.include_router(account_merge.router)
|
||||||
app.include_router(organizations.router)
|
app.include_router(organizations.router)
|
||||||
app.include_router(players.router)
|
app.include_router(players.router)
|
||||||
app.include_router(courses.router)
|
app.include_router(courses.router)
|
||||||
app.include_router(tournaments.router)
|
app.include_router(tournaments.router)
|
||||||
app.include_router(individual_tournaments.router)
|
app.include_router(individual_tournaments.router)
|
||||||
app.include_router(order_of_merit.router)
|
app.include_router(order_of_merit.router)
|
||||||
|
app.include_router(order_of_merit.public_router)
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
app.include_router(scoring.router)
|
app.include_router(scoring.router)
|
||||||
app.include_router(registration.router)
|
app.include_router(registration.router)
|
||||||
|
|
@ -64,6 +68,7 @@ app.include_router(rounds.router)
|
||||||
app.include_router(round_messages.router)
|
app.include_router(round_messages.router)
|
||||||
app.include_router(friends.router)
|
app.include_router(friends.router)
|
||||||
app.include_router(notifications.router)
|
app.include_router(notifications.router)
|
||||||
|
app.include_router(print_pdf.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,4 @@ argon2-cffi>=23.1
|
||||||
pyotp>=2.9
|
pyotp>=2.9
|
||||||
qrcode>=7.4
|
qrcode>=7.4
|
||||||
pywebpush>=2.0
|
pywebpush>=2.0
|
||||||
|
playwright>=1.45
|
||||||
|
|
|
||||||
135
app/routers/account_merge.py
Normal file
135
app/routers/account_merge.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""
|
||||||
|
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,6 +57,7 @@ from ..auth import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_pending_user,
|
get_pending_user,
|
||||||
hash_password,
|
hash_password,
|
||||||
|
resolve_user_id_by_email,
|
||||||
should_use_secure_cookies,
|
should_use_secure_cookies,
|
||||||
totp_provisioning_uri,
|
totp_provisioning_uri,
|
||||||
user_requires_2fa_enrollment,
|
user_requires_2fa_enrollment,
|
||||||
|
|
@ -312,14 +313,13 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
||||||
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||||||
email = token_row["email"]
|
email = token_row["email"]
|
||||||
|
|
||||||
# Sekundær e-post (FEATURE_BACKLOG.md "Én person, flere e-post-
|
# Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
|
||||||
# adresser") løses til EIERENS eksisterende konto -- må sjekkes FØR
|
# e-postadresser") løses til EIERENS eksisterende konto -- må
|
||||||
# app_user-oppslaget under, ellers ville en innlogging på en
|
# sjekkes FØR en eventuell ny konto opprettes under, ellers ville
|
||||||
# sekundær adresse (som med vilje ALDRI står i app_user.email)
|
# en innlogging på en sekundær adresse (som med vilje ALDRI står i
|
||||||
# stille opprettet en helt ny, separat, feilaktig konto.
|
# app_user.email) stille opprettet en helt ny, separat, feilaktig
|
||||||
secondary_owner_id = await conn.fetchval(
|
# konto.
|
||||||
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
|
resolved_user_id = await resolve_user_id_by_email(conn, email)
|
||||||
)
|
|
||||||
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
|
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
|
||||||
# kontoens navn trengs ETTER at connection-blokken er lukket
|
# kontoens navn trengs ETTER at connection-blokken er lukket
|
||||||
# (e-postutsending skal aldri skje mens en transaksjon/tilkobling
|
# (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
|
is_new_account = False
|
||||||
new_account_display_name = None
|
new_account_display_name = None
|
||||||
|
|
||||||
if secondary_owner_id is not None:
|
if resolved_user_id is not None:
|
||||||
user_id = secondary_owner_id
|
user_id = resolved_user_id
|
||||||
else:
|
else:
|
||||||
placeholder_name = email.split("@")[0]
|
placeholder_name = email.split("@")[0]
|
||||||
new_account_display_name = body.display_name or placeholder_name
|
new_account_display_name = body.display_name or placeholder_name
|
||||||
|
|
@ -397,20 +397,17 @@ async def login_with_password(
|
||||||
) -> LoginResult:
|
) -> LoginResult:
|
||||||
email = body.email.lower()
|
email = body.email.lower()
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
user_row = await conn.fetchrow(
|
# Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
|
||||||
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
|
# e-postadresser") -- passordet ligger uansett på selve kontoen,
|
||||||
)
|
# ikke adressen, så slå opp eieren og fortsett normalt.
|
||||||
if user_row is None:
|
resolved_user_id = await resolve_user_id_by_email(conn, email)
|
||||||
# Kan være en sekundær adresse (FEATURE_BACKLOG.md "Én person,
|
user_row = (
|
||||||
# flere e-postadresser") -- passordet ligger uansett på selve
|
await conn.fetchrow(
|
||||||
# kontoen, ikke adressen, så slå opp eieren og fortsett normalt.
|
"SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id
|
||||||
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:
|
if resolved_user_id is not None
|
||||||
user_row = await conn.fetchrow(
|
else None
|
||||||
"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 /
|
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
|
||||||
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
|
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
|
||||||
if (
|
if (
|
||||||
|
|
@ -929,6 +926,12 @@ class ProfileUpdate(BaseModel):
|
||||||
# Kølle-bag (2026-07-24): maks 14 køller -- den ekte golfregelen, ikke
|
# 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.
|
# en vilkårlig UI-begrensning. Se BAG_CLUBS for den fulle, faste listen.
|
||||||
bag_clubs: list[BagClub] | None = Field(default=None, max_length=14)
|
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)
|
@router.patch("/profile", response_model=Me)
|
||||||
|
|
@ -1185,8 +1188,9 @@ async def request_secondary_email(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
409,
|
409,
|
||||||
"DUPLICATE",
|
"DUPLICATE",
|
||||||
"Denne adressen er allerede en konto sin hovedadresse. Ekte "
|
"Denne adressen er allerede en konto sin hovedadresse. Bruk "
|
||||||
"konto-sammenslåing støttes ikke ennå.",
|
"\"Slå sammen med en annen konto\" lenger ned for å slå de to "
|
||||||
|
"kontoene sammen i stedet.",
|
||||||
)
|
)
|
||||||
existing_secondary = await conn.fetchval(
|
existing_secondary = await conn.fetchval(
|
||||||
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
|
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,9 @@ bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
from .. import teeoff_client
|
from .. import golfapi_cache, golfapi_client, teeoff_client
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
|
from ..config import settings
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..team_authz import is_org_member, user_is_org_player
|
from ..team_authz import is_org_member, user_is_org_player
|
||||||
|
|
@ -608,3 +609,271 @@ async def import_official_course(
|
||||||
)
|
)
|
||||||
|
|
||||||
return Course(**dict(course_row))
|
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,9 +40,15 @@ def _compute_tee_time(
|
||||||
tee_interval_minutes: int | None,
|
tee_interval_minutes: int | None,
|
||||||
sequence: int,
|
sequence: int,
|
||||||
override: datetime | None,
|
override: datetime | None,
|
||||||
|
start_mode: str = "consecutive",
|
||||||
) -> datetime | None:
|
) -> 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:
|
if override is not None:
|
||||||
return override
|
return override
|
||||||
|
if start_mode == "shotgun":
|
||||||
|
return scheduled_at
|
||||||
if scheduled_at is None or tee_interval_minutes is None:
|
if scheduled_at is None or tee_interval_minutes is None:
|
||||||
return None
|
return None
|
||||||
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes)
|
||||||
|
|
@ -64,6 +70,10 @@ class MatchCreate(BaseModel):
|
||||||
sequence: int
|
sequence: int
|
||||||
team_a_id: str
|
team_a_id: str
|
||||||
team_b_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):
|
class MatchOut(BaseModel):
|
||||||
|
|
@ -76,6 +86,7 @@ class MatchOut(BaseModel):
|
||||||
points_side_b: float | None
|
points_side_b: float | None
|
||||||
leading_side: str | None
|
leading_side: str | None
|
||||||
tee_time: datetime | None
|
tee_time: datetime | None
|
||||||
|
start_hole: int | None
|
||||||
participants: list[MatchParticipantOut]
|
participants: list[MatchParticipantOut]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,7 +103,7 @@ async def create_match(
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes, start_mode
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -113,20 +124,25 @@ async def create_match(
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id)
|
INSERT INTO match (organization_id, session_id, sequence, team_a_id, team_b_id, start_hole)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
|
RETURNING id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
status_text, points_side_a::float AS points_side_a,
|
||||||
points_side_b::float AS points_side_b, leading_side, tee_time_override
|
points_side_b::float AS points_side_b, leading_side, tee_time_override, start_hole
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
session_id,
|
session_id,
|
||||||
body.sequence,
|
body.sequence,
|
||||||
body.team_a_id,
|
body.team_a_id,
|
||||||
body.team_b_id,
|
body.team_b_id,
|
||||||
|
body.start_hole,
|
||||||
)
|
)
|
||||||
tee_time = _compute_tee_time(
|
tee_time = _compute_tee_time(
|
||||||
session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"]
|
session["scheduled_at"],
|
||||||
|
session["tee_interval_minutes"],
|
||||||
|
row["sequence"],
|
||||||
|
row["tee_time_override"],
|
||||||
|
session["start_mode"],
|
||||||
)
|
)
|
||||||
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
return MatchOut(**dict(row), tee_time=tee_time, participants=[])
|
||||||
|
|
||||||
|
|
@ -141,7 +157,7 @@ async def fetch_matches(
|
||||||
avslørte matcher, se blind_draw.py)."""
|
avslørte matcher, se blind_draw.py)."""
|
||||||
session = await conn.fetchrow(
|
session = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes
|
SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes, start_mode
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -153,7 +169,7 @@ async def fetch_matches(
|
||||||
"""
|
"""
|
||||||
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
SELECT id::text, sequence, team_a_id::text, team_b_id::text,
|
||||||
status_text, points_side_a::float AS points_side_a,
|
status_text, points_side_a::float AS points_side_a,
|
||||||
points_side_b::float AS points_side_b, leading_side, tee_time_override
|
points_side_b::float AS points_side_b, leading_side, tee_time_override, start_hole
|
||||||
FROM match
|
FROM match
|
||||||
WHERE session_id = $1
|
WHERE session_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -203,7 +219,7 @@ async def fetch_matches(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
out = [
|
||||||
MatchOut(
|
MatchOut(
|
||||||
id=m["id"],
|
id=m["id"],
|
||||||
sequence=m["sequence"],
|
sequence=m["sequence"],
|
||||||
|
|
@ -214,12 +230,23 @@ async def fetch_matches(
|
||||||
points_side_b=m["points_side_b"],
|
points_side_b=m["points_side_b"],
|
||||||
leading_side=m["leading_side"],
|
leading_side=m["leading_side"],
|
||||||
tee_time=_compute_tee_time(
|
tee_time=_compute_tee_time(
|
||||||
session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"]
|
session["scheduled_at"],
|
||||||
|
session["tee_interval_minutes"],
|
||||||
|
m["sequence"],
|
||||||
|
m["tee_time_override"],
|
||||||
|
session["start_mode"],
|
||||||
),
|
),
|
||||||
|
start_hole=m["start_hole"],
|
||||||
participants=by_match[m["id"]],
|
participants=by_match[m["id"]],
|
||||||
)
|
)
|
||||||
for m in matches
|
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(
|
@router.get(
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,16 @@ from fastapi import APIRouter, Depends
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import get_authorized_org
|
from ..auth import get_authorized_org
|
||||||
from ..db import org_connection
|
from ..db import org_connection, plain_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from handicap_engine import order_of_merit_aggregate, order_of_merit_points_for_position
|
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 .individual_tournaments import _compute_individual_standings
|
from .individual_tournaments import _compute_individual_standings
|
||||||
|
|
||||||
|
|
@ -165,6 +172,24 @@ async def update_order_of_merit(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED", "Eclectic krever resultattype Stableford, brutto eller netto."
|
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(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
UPDATE order_of_merit SET {', '.join(set_clauses)}, updated_at = now()
|
||||||
|
|
@ -248,6 +273,39 @@ def _parse_link_row(r) -> dict:
|
||||||
return d
|
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(
|
@router.post(
|
||||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/tournaments",
|
||||||
response_model=OrderOfMeritLinkOut,
|
response_model=OrderOfMeritLinkOut,
|
||||||
|
|
@ -259,7 +317,7 @@ async def link_tournament_to_order_of_merit(
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
) -> OrderOfMeritLinkOut:
|
) -> OrderOfMeritLinkOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
oom = await conn.fetchrow("SELECT result_type FROM order_of_merit WHERE id = $1", oom_id)
|
oom = await conn.fetchrow("SELECT result_type, aggregation_mode FROM order_of_merit WHERE id = $1", oom_id)
|
||||||
if oom is None:
|
if oom is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
||||||
tournament = await conn.fetchrow(
|
tournament = await conn.fetchrow(
|
||||||
|
|
@ -271,6 +329,8 @@ async def link_tournament_to_order_of_merit(
|
||||||
raise app_error(
|
raise app_error(
|
||||||
400, "VALIDATION_FAILED", "Kun individuelle turneringer kan lenkes til en Order of Merit."
|
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"]
|
result_type = oom["result_type"]
|
||||||
if result_type == "points" and not body.points_table:
|
if result_type == "points" and not body.points_table:
|
||||||
|
|
@ -325,6 +385,288 @@ async def unlink_tournament_from_order_of_merit(
|
||||||
raise app_error(404, "NOT_FOUND", "Lenken finnes ikke.")
|
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
|
# Leaderboard -- "regn ut ved lesing", ingen cache-tabell (samme filosofi
|
||||||
# som Nassau/High-low-high/individual_leaderboard selv).
|
# som Nassau/High-low-high/individual_leaderboard selv).
|
||||||
|
|
@ -353,6 +695,38 @@ def _money_for_position(position_label: str, payout_table: list[dict]) -> float:
|
||||||
return 0.0
|
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(
|
@router.get(
|
||||||
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
|
"/orgs/{organization_id}/order-of-merits/{oom_id}/leaderboard",
|
||||||
response_model=list[OrderOfMeritLeaderboardEntry],
|
response_model=list[OrderOfMeritLeaderboardEntry],
|
||||||
|
|
@ -372,13 +746,10 @@ async def order_of_merit_leaderboard(
|
||||||
)
|
)
|
||||||
if oom is None:
|
if oom is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Order of Merit finnes ikke.")
|
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"]
|
result_type = oom["result_type"]
|
||||||
lower_is_better = result_type in ("gross", "net")
|
lower_is_better = result_type in ("gross", "net")
|
||||||
|
is_eclectic = oom["aggregation_mode"] == "eclectic"
|
||||||
|
|
||||||
links = await conn.fetch(
|
links = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
|
|
@ -390,19 +761,40 @@ async def order_of_merit_leaderboard(
|
||||||
)
|
)
|
||||||
|
|
||||||
# player_id -> [rå bidrags-verdier, ett per lenket turnering spilleren har deltatt i]
|
# 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]] = {}
|
values_by_player: dict[str, list[float]] = {}
|
||||||
names_by_player: dict[str, str] = {}
|
names_by_player: dict[str, str] = {}
|
||||||
|
results_available_by_player: dict[str, int] = {}
|
||||||
|
|
||||||
for link in links:
|
if is_eclectic:
|
||||||
_, entries = await _compute_individual_standings(conn, link["tournament_id"])
|
# ADR-075: krever result_type IN (stableford,gross,net) og
|
||||||
points_table = _decode_json(link["points_table"])
|
# kind='player', begge håndhevet ved opprettelse/PATCH --
|
||||||
money_payout_table = _decode_json(link["money_payout_table"])
|
# ingen ekstra sjekk nødvendig her.
|
||||||
for e in entries:
|
tournament_ids = [link["tournament_id"] for link in links]
|
||||||
contribution = _contribution_for_entry(e, result_type, points_table, money_payout_table)
|
totals, names_by_player, tournament_counts = await _compute_eclectic_oom_values(
|
||||||
if contribution is None:
|
conn, tournament_ids, result_type
|
||||||
continue
|
)
|
||||||
values_by_player.setdefault(e.player_id, []).append(contribution)
|
for player_id, total in totals.items():
|
||||||
names_by_player[e.player_id] = e.player_name
|
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)
|
||||||
|
|
||||||
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
# Aldersgrense: filtrert FØR aggregering (spillere utenfor
|
||||||
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
# fødselsårs-intervallet er ikke rangert i det hele tatt, ikke
|
||||||
|
|
@ -418,44 +810,107 @@ async def order_of_merit_leaderboard(
|
||||||
birth_years = {r["id"]: r["birth_year"] for r in rows}
|
birth_years = {r["id"]: r["birth_year"] for r in rows}
|
||||||
|
|
||||||
results: list[OrderOfMeritLeaderboardEntry] = []
|
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["birth_year_from"] is not None or oom["birth_year_to"] is not None:
|
if oom["kind"] == "player":
|
||||||
by = birth_years.get(player_id)
|
for player_id, raw_values in values_by_player.items():
|
||||||
if by is None:
|
results_available = results_available_by_player[player_id]
|
||||||
eligible = False
|
eligible, ineligible_reason = _player_eligibility(results_available, oom, birth_years.get(player_id))
|
||||||
ineligible_reason = "Ingen registrert fødselsdato."
|
# Eclectic: raw_values er allerede ÉN ferdig-sammenslått
|
||||||
elif oom["birth_year_from"] is not None and by < oom["birth_year_from"]:
|
# sesongtotal (se _compute_eclectic_oom_values) -- ingen
|
||||||
eligible = False
|
# order_of_merit_aggregate-kall (som uansett kun kjenner
|
||||||
ineligible_reason = "Utenfor aldersgrensen."
|
# sum/snitt, ikke eclectic, se motoren), og count_best_n
|
||||||
elif oom["birth_year_to"] is not None and by > oom["birth_year_to"]:
|
# gir ingen mening ("beste N av ÉN verdi").
|
||||||
eligible = False
|
if is_eclectic:
|
||||||
ineligible_reason = "Utenfor aldersgrensen."
|
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 eligible and oom["min_results_required"] is not None and results_available < oom["min_results_required"]:
|
results.append(
|
||||||
eligible = False
|
OrderOfMeritLeaderboardEntry(
|
||||||
ineligible_reason = f"Trenger minst {oom['min_results_required']} tellende resultater, har {results_available}."
|
player_id=player_id,
|
||||||
|
player_name=names_by_player[player_id],
|
||||||
values_for_aggregate = [(-v if lower_is_better else v) for v in raw_values]
|
results_counted=counted,
|
||||||
aggregated = order_of_merit_aggregate(values_for_aggregate, oom["aggregation_mode"], oom["count_best_n"])
|
results_available=results_available,
|
||||||
value = (-aggregated if lower_is_better and aggregated is not None else aggregated)
|
value=value,
|
||||||
counted = min(oom["count_best_n"], results_available) if oom["count_best_n"] is not None else results_available
|
position=None,
|
||||||
|
eligible=eligible,
|
||||||
results.append(
|
ineligible_reason=ineligible_reason,
|
||||||
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
|
# Rangering: kun blant de kvalifiserte, høyest verdi øverst for
|
||||||
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
|
# poeng/stableford/pengeliste, LAVEST for brutto/netto -- men
|
||||||
|
|
@ -483,7 +938,12 @@ def _decode_json(value):
|
||||||
return json.loads(value) if value else None
|
return json.loads(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
def _contribution_for_entry(entry, result_type: str, points_table, money_payout_table) -> float | 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:
|
||||||
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
|
"""Utleder ÉN spillers bidrags-tall fra én lenket turnerings egen,
|
||||||
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
|
allerede beregnede leaderboard-entry -- `None` betyr "spilte ikke
|
||||||
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
|
denne turneringen" (skal ikke telle som et 0-resultat, kalleren
|
||||||
|
|
@ -495,13 +955,23 @@ def _contribution_for_entry(entry, result_type: str, points_table, money_payout_
|
||||||
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
|
poeng- eller pengeliste-basert og den lenkede turneringen bruker en
|
||||||
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
|
annen scoring_method (København/BBB/Flagg), er det bevisst ingen
|
||||||
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
|
posisjon å hente poeng/penger fra -- spilleren telles fortsatt som
|
||||||
"deltok" (rounds_played > 0) men bidrar med 0, ikke ekskludert helt."""
|
"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)."""
|
||||||
if entry.rounds_played == 0:
|
if entry.rounds_played == 0:
|
||||||
return None
|
return None
|
||||||
if result_type == "points":
|
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
|
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":
|
if result_type == "money":
|
||||||
return _money_for_position(entry.position, money_payout_table) if entry.position and money_payout_table else 0.0
|
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":
|
if result_type == "stableford":
|
||||||
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
|
return float(entry.stableford_total) if entry.stableford_total is not None else 0.0
|
||||||
if result_type == "gross":
|
if result_type == "gross":
|
||||||
|
|
@ -509,3 +979,50 @@ def _contribution_for_entry(entry, result_type: str, points_table, money_payout_
|
||||||
if result_type == "net":
|
if result_type == "net":
|
||||||
return float(entry.net_total) if entry.net_total is not None else 0.0
|
return float(entry.net_total) if entry.net_total is not None else 0.0
|
||||||
return 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,6 +34,14 @@ class PlayerCreate(BaseModel):
|
||||||
country: str | None = Field(default=None, max_length=100)
|
country: str | None = Field(default=None, max_length=100)
|
||||||
club: str | None = Field(default=None, max_length=200)
|
club: str | None = Field(default=None, max_length=200)
|
||||||
club_member_number: str | None = Field(default=None, max_length=50)
|
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):
|
class Player(BaseModel):
|
||||||
|
|
@ -48,11 +56,16 @@ class Player(BaseModel):
|
||||||
country: str | None
|
country: str | None
|
||||||
club: str | None
|
club: str | None
|
||||||
club_member_number: str | None
|
club_member_number: str | None
|
||||||
|
first_name: str | None
|
||||||
|
last_name: str | None
|
||||||
|
paid: bool
|
||||||
|
comment: str | None
|
||||||
|
|
||||||
|
|
||||||
_PLAYER_COLUMNS = """
|
_PLAYER_COLUMNS = """
|
||||||
id::text, display_name, handicap_index::float AS handicap_index, gender,
|
id::text, display_name, handicap_index::float AS handicap_index, gender,
|
||||||
mobile, email, birth_date, nickname, country, club, club_member_number
|
mobile, email, birth_date, nickname, country, club, club_member_number,
|
||||||
|
first_name, last_name, paid, comment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -77,8 +90,9 @@ async def create_player(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO player
|
INSERT INTO player
|
||||||
(organization_id, display_name, handicap_index, gender,
|
(organization_id, display_name, handicap_index, gender,
|
||||||
mobile, email, birth_date, nickname, country, club, club_member_number)
|
mobile, email, birth_date, nickname, country, club, club_member_number,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
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}
|
RETURNING {_PLAYER_COLUMNS}
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
|
|
@ -92,10 +106,171 @@ async def create_player(
|
||||||
body.country,
|
body.country,
|
||||||
body.club,
|
body.club,
|
||||||
body.club_member_number,
|
body.club_member_number,
|
||||||
|
body.first_name,
|
||||||
|
body.last_name,
|
||||||
|
body.paid,
|
||||||
|
body.comment,
|
||||||
)
|
)
|
||||||
return Player(**dict(row))
|
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):
|
class PlayerUpdate(BaseModel):
|
||||||
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
||||||
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).
|
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).
|
||||||
|
|
@ -118,6 +293,10 @@ class PlayerUpdate(BaseModel):
|
||||||
country: str | None = Field(default=None, max_length=100)
|
country: str | None = Field(default=None, max_length=100)
|
||||||
club: str | None = Field(default=None, max_length=200)
|
club: str | None = Field(default=None, max_length=200)
|
||||||
club_member_number: str | None = Field(default=None, max_length=50)
|
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)
|
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)
|
||||||
|
|
|
||||||
152
app/routers/print_pdf.py
Normal file
152
app/routers/print_pdf.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
"""
|
||||||
|
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,6 +318,12 @@ async def post_round_message(
|
||||||
# siden dette endepunktet er multipart (bilde-opplasting), ikke en ren
|
# siden dette endepunktet er multipart (bilde-opplasting), ikke en ren
|
||||||
# JSON-body som CommentIn under kan bruke direkte.
|
# JSON-body som CommentIn under kan bruke direkte.
|
||||||
tags: str | None = Form(default=None),
|
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),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundMessageOut:
|
) -> RoundMessageOut:
|
||||||
if not body and image is None:
|
if not body and image is None:
|
||||||
|
|
@ -333,6 +339,20 @@ async def post_round_message(
|
||||||
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
|
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
|
||||||
await _get_viewable_round_or_404(conn, round_id, user.user_id)
|
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
|
image_key = None
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -345,8 +365,9 @@ async def post_round_message(
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
|
INSERT INTO round_message
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
(round_id, author_user_id, author_display_name, body, image_key, client_message_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING {_ROUND_MESSAGE_COLUMNS}
|
RETURNING {_ROUND_MESSAGE_COLUMNS}
|
||||||
""",
|
""",
|
||||||
round_id,
|
round_id,
|
||||||
|
|
@ -354,6 +375,7 @@ async def post_round_message(
|
||||||
display_name,
|
display_name,
|
||||||
body,
|
body,
|
||||||
image_key,
|
image_key,
|
||||||
|
client_message_id,
|
||||||
)
|
)
|
||||||
await _insert_tags_and_notify(
|
await _insert_tags_and_notify(
|
||||||
conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"],
|
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,6 +60,8 @@ from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
||||||
from ..realtime import broadcast_live_update
|
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 (
|
from ..team_authz import (
|
||||||
is_org_member,
|
is_org_member,
|
||||||
user_is_match_participant,
|
user_is_match_participant,
|
||||||
|
|
@ -852,6 +854,39 @@ async def get_scorecard(
|
||||||
return await fetch_scorecard(conn, match_id)
|
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
|
# 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-
|
# 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 ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
from .auth import _hash_secret
|
from .auth import _hash_secret
|
||||||
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
|
from ..team_authz import is_org_admin, 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 .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
|
||||||
from .. import storage
|
from .. import storage
|
||||||
|
|
||||||
|
|
@ -47,7 +47,9 @@ class TournamentCreate(BaseModel):
|
||||||
# for individual, håndhevet i app-laget når runder/score bygges på den.
|
# for individual, håndhevet i app-laget når runder/score bygges på den.
|
||||||
format_type: str = Field(default="team", pattern="^(team|individual)$")
|
format_type: str = Field(default="team", pattern="^(team|individual)$")
|
||||||
scoring_method: str | None = Field(
|
scoring_method: str | None = Field(
|
||||||
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
|
default=None,
|
||||||
|
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
|
||||||
|
"eclectic_gross|eclectic_net|eclectic_stableford)$",
|
||||||
)
|
)
|
||||||
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
|
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
|
||||||
# organisator-innstilling (samme som frittstående runders round.
|
# organisator-innstilling (samme som frittstående runders round.
|
||||||
|
|
@ -59,7 +61,9 @@ _TOURNAMENT_COLUMNS = """
|
||||||
id::text, name, status::text, start_date, end_date,
|
id::text, name, status::text, start_date, end_date,
|
||||||
visibility, description, registration_deadline, registration_capacity,
|
visibility, description, registration_deadline, registration_capacity,
|
||||||
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
registration_overflow_policy, registration_requires_approval, hero_image_key,
|
||||||
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled
|
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
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
|
||||||
|
|
@ -88,6 +92,32 @@ class Tournament(BaseModel):
|
||||||
format_type: str
|
format_type: str
|
||||||
scoring_method: str | None
|
scoring_method: str | None
|
||||||
bbb_sweep_bonus_enabled: bool
|
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:
|
def _tournament_from_row(row) -> Tournament:
|
||||||
|
|
@ -119,7 +149,9 @@ async def list_tournaments(
|
||||||
t.visibility, t.description, t.registration_deadline,
|
t.visibility, t.description, t.registration_deadline,
|
||||||
t.registration_capacity, t.registration_overflow_policy,
|
t.registration_capacity, t.registration_overflow_policy,
|
||||||
t.registration_requires_approval, t.hero_image_key,
|
t.registration_requires_approval, t.hero_image_key,
|
||||||
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled
|
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled,
|
||||||
|
t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at,
|
||||||
|
t.tiebreak_winner_method, t.tiebreak_field_method, t.tiebreak_playoff_rule
|
||||||
FROM tournament t
|
FROM tournament t
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT tournament_id,
|
SELECT tournament_id,
|
||||||
|
|
@ -177,11 +209,23 @@ class TournamentUpdate(BaseModel):
|
||||||
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
||||||
(PATCH-semantikk via Pydantic sin exclude_unset, se update_tournament)."""
|
(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)$")
|
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
|
||||||
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
|
||||||
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
|
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
|
||||||
scoring_method: str | None = Field(
|
scoring_method: str | None = Field(
|
||||||
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
|
default=None,
|
||||||
|
pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag|"
|
||||||
|
"eclectic_gross|eclectic_net|eclectic_stableford)$",
|
||||||
)
|
)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
registration_deadline: datetime | None = None
|
registration_deadline: datetime | None = None
|
||||||
|
|
@ -189,6 +233,23 @@ class TournamentUpdate(BaseModel):
|
||||||
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
|
||||||
registration_requires_approval: bool | None = None
|
registration_requires_approval: bool | None = None
|
||||||
bbb_sweep_bonus_enabled: 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
|
# hero_image_key er BEVISST IKKE med her -- den settes kun via
|
||||||
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
|
||||||
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
|
||||||
|
|
@ -234,6 +295,84 @@ async def update_tournament(
|
||||||
return _tournament_from_row(row)
|
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(
|
@router.post(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
"/orgs/{organization_id}/tournaments/{tournament_id}/hero-image",
|
||||||
response_model=Tournament,
|
response_model=Tournament,
|
||||||
|
|
@ -760,6 +899,12 @@ class SessionCreate(BaseModel):
|
||||||
scheduled_at: datetime | None = None
|
scheduled_at: datetime | None = None
|
||||||
tee_interval_minutes: int | None = None
|
tee_interval_minutes: int | None = None
|
||||||
start_hole: int = 1
|
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
|
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
|
||||||
# sidens individuelle resultater som teller per hull ("beste N av M").
|
# sidens individuelle resultater som teller per hull ("beste N av M").
|
||||||
shamble_best_n: int | None = Field(default=None, ge=1)
|
shamble_best_n: int | None = Field(default=None, ge=1)
|
||||||
|
|
@ -778,6 +923,7 @@ class SessionOut(BaseModel):
|
||||||
scheduled_at: datetime | None
|
scheduled_at: datetime | None
|
||||||
tee_interval_minutes: int | None
|
tee_interval_minutes: int | None
|
||||||
start_hole: int
|
start_hole: int
|
||||||
|
start_mode: str
|
||||||
shamble_best_n: int | None
|
shamble_best_n: int | None
|
||||||
locked_team_ids: list[str]
|
locked_team_ids: list[str]
|
||||||
revealed: bool
|
revealed: bool
|
||||||
|
|
@ -797,6 +943,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
|
||||||
scheduled_at=row["scheduled_at"],
|
scheduled_at=row["scheduled_at"],
|
||||||
tee_interval_minutes=row["tee_interval_minutes"],
|
tee_interval_minutes=row["tee_interval_minutes"],
|
||||||
start_hole=row["start_hole"],
|
start_hole=row["start_hole"],
|
||||||
|
start_mode=row["start_mode"],
|
||||||
shamble_best_n=row["shamble_best_n"],
|
shamble_best_n=row["shamble_best_n"],
|
||||||
locked_team_ids=sorted(locked),
|
locked_team_ids=sorted(locked),
|
||||||
revealed=len(locked) >= 2,
|
revealed=len(locked) >= 2,
|
||||||
|
|
@ -812,7 +959,7 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
FROM session
|
FROM session
|
||||||
WHERE tournament_id = $1
|
WHERE tournament_id = $1
|
||||||
ORDER BY sequence
|
ORDER BY sequence
|
||||||
|
|
@ -869,12 +1016,12 @@ async def create_session(
|
||||||
INSERT INTO session
|
INSERT INTO session
|
||||||
(organization_id, tournament_id, sequence, name, format,
|
(organization_id, tournament_id, sequence, name, format,
|
||||||
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
|
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14, $15)
|
||||||
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
""",
|
""",
|
||||||
organization_id,
|
organization_id,
|
||||||
tournament_id,
|
tournament_id,
|
||||||
|
|
@ -889,6 +1036,7 @@ async def create_session(
|
||||||
body.scheduled_at,
|
body.scheduled_at,
|
||||||
body.tee_interval_minutes,
|
body.tee_interval_minutes,
|
||||||
body.start_hole,
|
body.start_hole,
|
||||||
|
body.start_mode,
|
||||||
body.shamble_best_n,
|
body.shamble_best_n,
|
||||||
)
|
)
|
||||||
return _session_out(row, set())
|
return _session_out(row, set())
|
||||||
|
|
@ -906,6 +1054,7 @@ class SessionUpdate(BaseModel):
|
||||||
scheduled_at: datetime | None = None
|
scheduled_at: datetime | None = None
|
||||||
tee_interval_minutes: int | None = None
|
tee_interval_minutes: int | None = None
|
||||||
start_hole: int | None = Field(default=None, ge=1, le=18)
|
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
|
points_per_match: float | None = None
|
||||||
allowance_override: dict | None = None
|
allowance_override: dict | None = None
|
||||||
course_id: str | None = None
|
course_id: str | None = None
|
||||||
|
|
@ -1049,7 +1198,7 @@ async def update_session(
|
||||||
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
|
||||||
course_id::text, points_per_match::float AS points_per_match,
|
course_id::text, points_per_match::float AS points_per_match,
|
||||||
allowance_override::text AS allowance_override, scoring_mode,
|
allowance_override::text AS allowance_override, scoring_mode,
|
||||||
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
|
scheduled_at, tee_interval_minutes, start_hole, start_mode, shamble_best_n
|
||||||
FROM session WHERE id = $1
|
FROM session WHERE id = $1
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
|
||||||
61
app/target_points.py
Normal file
61
app/target_points.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""
|
||||||
|
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,6 +38,7 @@ services:
|
||||||
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
|
TEECUP_VAPID_PUBLIC_KEY: ${TEECUP_VAPID_PUBLIC_KEY}
|
||||||
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
|
TEECUP_VAPID_SUBJECT: ${TEECUP_VAPID_SUBJECT}
|
||||||
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
|
TEECUP_MAPBOX_SECRET_TOKEN: ${TEECUP_MAPBOX_SECRET_TOKEN}
|
||||||
|
TEECUP_GOLFAPI_TOKEN: ${TEECUP_GOLFAPI_TOKEN}
|
||||||
networks:
|
networks:
|
||||||
- teeoff_default
|
- teeoff_default
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
|
||||||
5
frontend/app/hjelp/page.tsx
Normal file
5
frontend/app/hjelp/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { HelpPage } from "@/components/help-page"
|
||||||
|
|
||||||
|
export default function Hjelp() {
|
||||||
|
return <HelpPage />
|
||||||
|
}
|
||||||
21
frontend/app/kontosammenslaing/[token]/page.tsx
Normal file
21
frontend/app/kontosammenslaing/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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,6 +1,9 @@
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { Nunito } from 'next/font/google'
|
import { Nunito } from 'next/font/google'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import { getLocale } from 'next-intl/server'
|
||||||
import { SwRegister } from '@/components/sw-register'
|
import { SwRegister } from '@/components/sw-register'
|
||||||
|
import { LocaleBootstrap } from '@/components/locale-bootstrap'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const nunito = Nunito({
|
const nunito = Nunito({
|
||||||
|
|
@ -52,16 +55,24 @@ export const viewport: Viewport = {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode
|
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 (
|
return (
|
||||||
<html lang="no" className={`${nunito.variable} bg-background`}>
|
<html lang={locale} className={`${nunito.variable} bg-background`}>
|
||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
<SwRegister />
|
<NextIntlClientProvider>
|
||||||
{children}
|
<SwRegister />
|
||||||
|
<LocaleBootstrap />
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
44
frontend/app/order-of-merit/[id]/page.tsx
Normal file
44
frontend/app/order-of-merit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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} />
|
||||||
|
}
|
||||||
13
frontend/app/organizations/[id]/players/page.tsx
Normal file
13
frontend/app/organizations/[id]/players/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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} />
|
||||||
|
}
|
||||||
33
frontend/app/tournaments/[id]/print/cart-tags/page.tsx
Normal file
33
frontend/app/tournaments/[id]/print/cart-tags/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
frontend/app/tournaments/[id]/print/resultlist/page.tsx
Normal file
31
frontend/app/tournaments/[id]/print/resultlist/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/app/tournaments/[id]/print/scorecard/page.tsx
Normal file
33
frontend/app/tournaments/[id]/print/scorecard/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
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"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
frontend/app/tournaments/[id]/print/startlist/page.tsx
Normal file
36
frontend/app/tournaments/[id]/print/startlist/page.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
225
frontend/components/account-merge-confirm-form.tsx
Normal file
225
frontend/components/account-merge-confirm-form.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"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,6 +20,7 @@ import {
|
||||||
Lock,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
|
Merge,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
|
@ -298,6 +299,8 @@ export function AccountSettings() {
|
||||||
|
|
||||||
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
|
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
|
||||||
|
|
||||||
|
<AccountMergeSection />
|
||||||
|
|
||||||
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
||||||
|
|
||||||
<NotificationEmailPrefsSection />
|
<NotificationEmailPrefsSection />
|
||||||
|
|
@ -1132,11 +1135,11 @@ function EmailSection({ email }: { email: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle
|
// Én person, flere e-postadresser (FEATURE_BACKLOG.md "Del 1") -- kun det
|
||||||
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
|
// enkle tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
|
||||||
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte
|
// deretter brukes til innlogging i tillegg til hovedadressen. Tilhører
|
||||||
// konto-sammenslåing (adressen tilhører allerede en annen konto) er
|
// adressen ALLEREDE en annen konto, avviser backend med 409 DUPLICATE og
|
||||||
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE.
|
// henviser til AccountMergeSection under ("Del 2", ADR-080).
|
||||||
function SecondaryEmailSection({
|
function SecondaryEmailSection({
|
||||||
secondaryEmails,
|
secondaryEmails,
|
||||||
onChanged,
|
onChanged,
|
||||||
|
|
@ -1278,6 +1281,217 @@ 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 }) {
|
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
|
||||||
157
frontend/components/flag-map-overview.tsx
Normal file
157
frontend/components/flag-map-overview.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"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
|
||||||
99
frontend/components/help-page.tsx
Normal file
99
frontend/components/help-page.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
413
frontend/components/hole-diagram-view.tsx
Normal file
413
frontend/components/hole-diagram-view.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
"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}.`
|
||||||
|
}
|
||||||
242
frontend/components/hole-history-detail.tsx
Normal file
242
frontend/components/hole-history-detail.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
460
frontend/components/hole-stat-inputs.tsx
Normal file
460
frontend/components/hole-stat-inputs.tsx
Normal file
|
|
@ -0,0 +1,460 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
381
frontend/components/hole-target-distance.tsx
Normal file
381
frontend/components/hole-target-distance.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"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
71
frontend/components/language-switcher.tsx
Normal file
71
frontend/components/language-switcher.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
frontend/components/locale-bootstrap.tsx
Normal file
45
frontend/components/locale-bootstrap.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"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,8 +10,9 @@
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Bell, Building2, ChevronRight, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
|
import { Bell, Building2, ChevronRight, CircleHelp, LogOut, MessageSquare, UserCircle, UserPlus } from "lucide-react"
|
||||||
import { BottomNav } from "@/components/teecup/bottom-nav"
|
import { BottomNav } from "@/components/teecup/bottom-nav"
|
||||||
|
import { LanguageSwitcher } from "@/components/language-switcher"
|
||||||
|
|
||||||
const C = {
|
const C = {
|
||||||
card: "#ffffff",
|
card: "#ffffff",
|
||||||
|
|
@ -66,7 +67,8 @@ export function MoreMenu() {
|
||||||
<MenuLink href="/account" icon={UserCircle} label="Konto" />
|
<MenuLink href="/account" icon={UserCircle} label="Konto" />
|
||||||
<MenuLink href="/my-friends" icon={UserPlus} label="Venner" />
|
<MenuLink href="/my-friends" icon={UserPlus} label="Venner" />
|
||||||
<MenuLink href="/my-feed" icon={MessageSquare} label="Feed" />
|
<MenuLink href="/my-feed" icon={MessageSquare} label="Feed" />
|
||||||
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" last={me?.organizations.length === 0} />
|
<MenuLink href="/my-notifications" icon={Bell} label="Varsler" />
|
||||||
|
<MenuLink href="/hjelp" icon={CircleHelp} label="Hjelp" last={me?.organizations.length === 0} />
|
||||||
{me?.organizations.map((o, i) => (
|
{me?.organizations.map((o, i) => (
|
||||||
<MenuLink
|
<MenuLink
|
||||||
key={o.organization_id}
|
key={o.organization_id}
|
||||||
|
|
@ -78,6 +80,10 @@ export function MoreMenu() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-2xl p-4" style={{ border: `1px solid ${C.border}`, backgroundColor: C.card }}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|
|
||||||
|
|
@ -216,10 +216,10 @@ export function Segmented<T extends string>({
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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)]",
|
"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)]",
|
||||||
active
|
active
|
||||||
? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm"
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
|
||||||
: "text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
|
: "border-transparent text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import {
|
import {
|
||||||
fetchFacilityCourses,
|
fetchFacilityCourses,
|
||||||
fetchOwnCourse,
|
fetchOwnCourse,
|
||||||
|
importInternationalCourse,
|
||||||
nearbyOfficialFacilities,
|
nearbyOfficialFacilities,
|
||||||
|
searchInternationalClubs,
|
||||||
searchOfficialFacilities,
|
searchOfficialFacilities,
|
||||||
searchOwnCourses,
|
searchOwnCourses,
|
||||||
} from "@/lib/ny-runde/api"
|
} from "@/lib/ny-runde/api"
|
||||||
|
import type { ApiInternationalClub, ApiInternationalCourseOption } from "@/lib/ny-runde/api"
|
||||||
import type { Course, Facility } from "@/lib/ny-runde/types"
|
import type { Course, Facility } from "@/lib/ny-runde/types"
|
||||||
import { CreateCourseForm } from "./create-course-form"
|
import { CreateCourseForm } from "./create-course-form"
|
||||||
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
|
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
|
||||||
|
|
@ -25,19 +28,16 @@ export function Step1CourseTime() {
|
||||||
}
|
}
|
||||||
const compatible = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
|
const compatible = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
|
||||||
const tee = compatible[0]
|
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({
|
patch({
|
||||||
course,
|
course,
|
||||||
courseMeta: meta,
|
courseMeta: meta,
|
||||||
courseSource: meta.source === "teeoff" ? "official" : "own",
|
courseSource: meta.source === "teeoff" ? "official" : "own",
|
||||||
teeId: tee?.id,
|
teeId: tee?.id,
|
||||||
s1Sub: "fields",
|
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,6 +86,14 @@ export function Step1CourseTime() {
|
||||||
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
|
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
|
||||||
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
|
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
|
||||||
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
|
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":
|
case "own-create":
|
||||||
|
|
@ -370,11 +378,13 @@ function OwnSearch({
|
||||||
onBack,
|
onBack,
|
||||||
onCreate,
|
onCreate,
|
||||||
onPick,
|
onPick,
|
||||||
|
onInternational,
|
||||||
}: {
|
}: {
|
||||||
templateMode: boolean
|
templateMode: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onCreate: () => void
|
onCreate: () => void
|
||||||
onPick: (c: Course) => void
|
onPick: (c: Course) => void
|
||||||
|
onInternational: () => void
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const debounced = useDebouncedValue(query, 250)
|
const debounced = useDebouncedValue(query, 250)
|
||||||
|
|
@ -464,6 +474,171 @@ function OwnSearch({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,13 @@ export function Step3Players() {
|
||||||
{adding ? (
|
{adding ? (
|
||||||
<AddPlayerPanel
|
<AddPlayerPanel
|
||||||
course={state.course}
|
course={state.course}
|
||||||
defaultStat={state.statLevel}
|
// 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"
|
||||||
existing={state.players}
|
existing={state.players}
|
||||||
onCancel={() => setAdding(false)}
|
onCancel={() => setAdding(false)}
|
||||||
onAdd={addPlayer}
|
onAdd={addPlayer}
|
||||||
|
|
@ -230,10 +236,30 @@ function PlayerCard({
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
) : player.hcpFromProfile && !isOwner ? (
|
) : player.hcpFromProfile && !isOwner ? (
|
||||||
<Field label="HCP">
|
// Redigerbar fra FØRSTE stund (2026-08-20 brukerfunn) -- var
|
||||||
<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)]">
|
// tidligere en read-only visning ("hentes fra profilen"), som
|
||||||
{player.hcp != null ? `${player.hcp} · hentes fra profilen` : "Hentes fra profilen…"}
|
// tvang organisatoren til å legge til spilleren FØRST og
|
||||||
</div>
|
// 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>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,31 @@ export function WizardProvider({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 hasTeamStep = showsTeamStep(state.format)
|
||||||
const visibleSteps = useMemo(() => (hasTeamStep ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]), [hasTeamStep])
|
const visibleSteps = useMemo(() => (hasTeamStep ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]), [hasTeamStep])
|
||||||
|
|
||||||
|
|
@ -260,6 +285,8 @@ export function WizardProvider({
|
||||||
return { ...s, s1Sub: "official-search" }
|
return { ...s, s1Sub: "official-search" }
|
||||||
case "own-search":
|
case "own-search":
|
||||||
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
|
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
|
||||||
|
case "international-search":
|
||||||
|
return { ...s, s1Sub: "own-search" }
|
||||||
case "own-create":
|
case "own-create":
|
||||||
return { ...s, s1Sub: s.ownCreateOrigin }
|
return { ...s, s1Sub: s.ownCreateOrigin }
|
||||||
case "fields":
|
case "fields":
|
||||||
|
|
@ -397,6 +424,13 @@ export function WizardProvider({
|
||||||
}
|
}
|
||||||
if (p.accountId) {
|
if (p.accountId) {
|
||||||
participantBody.user_id = 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 {
|
} else {
|
||||||
participantBody.guest_first_name = p.firstName.trim()
|
participantBody.guest_first_name = p.firstName.trim()
|
||||||
participantBody.guest_last_name = p.lastName.trim() || null
|
participantBody.guest_last_name = p.lastName.trim() || null
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, ChevronDown, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
|
import { ArrowLeft, Check, ChevronDown, Link2, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -18,7 +18,16 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type OomKind = "player" | "team"
|
type OomKind = "player" | "team"
|
||||||
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
type OomAggregationMode = "sum" | "average"
|
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 ApiOrderOfMerit = {
|
type ApiOrderOfMerit = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -55,6 +64,12 @@ type ApiLeaderboardEntry = {
|
||||||
|
|
||||||
type ApiTournament = { id: string; name: string; format_type: string }
|
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 KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
|
||||||
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||||
points: "Poeng etter plassering",
|
points: "Poeng etter plassering",
|
||||||
|
|
@ -83,6 +98,8 @@ export function OrderOfMeritDetail({
|
||||||
const [links, setLinks] = useState<ApiLink[] | null>(null)
|
const [links, setLinks] = useState<ApiLink[] | null>(null)
|
||||||
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
|
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
|
||||||
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
|
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 [error, setError] = useState<string | null>(null)
|
||||||
const [notFound, setNotFound] = useState(false)
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
|
||||||
|
|
@ -114,6 +131,16 @@ export function OrderOfMeritDetail({
|
||||||
if (res.ok) setAllTournaments(await res.json())
|
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(() => {
|
useEffect(() => {
|
||||||
void loadOom()
|
void loadOom()
|
||||||
void loadLinks()
|
void loadLinks()
|
||||||
|
|
@ -122,7 +149,12 @@ export function OrderOfMeritDetail({
|
||||||
}, [organizationId, oomId])
|
}, [organizationId, oomId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (oom?.kind === "player") void loadLeaderboard()
|
if (!oom) return
|
||||||
|
void loadLeaderboard()
|
||||||
|
if (oom.kind === "team") {
|
||||||
|
void loadTeams()
|
||||||
|
void loadPlayers()
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [oom?.kind, links])
|
}, [oom?.kind, links])
|
||||||
|
|
||||||
|
|
@ -160,6 +192,65 @@ export function OrderOfMeritDetail({
|
||||||
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
|
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>) {
|
async function saveSettings(patch: Record<string, unknown>) {
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
|
||||||
|
|
@ -257,7 +348,18 @@ export function OrderOfMeritDetail({
|
||||||
|
|
||||||
<SettingsSection oom={oom} onSave={saveSettings} />
|
<SettingsSection oom={oom} onSave={saveSettings} />
|
||||||
|
|
||||||
<LeaderboardSection kind={oom.kind} resultType={oom.result_type} entries={leaderboard} />
|
{oom.kind === "team" && (
|
||||||
|
<TeamsSection
|
||||||
|
teams={teams}
|
||||||
|
players={players}
|
||||||
|
onCreateTeam={createTeam}
|
||||||
|
onDeleteTeam={deleteTeam}
|
||||||
|
onAddMember={addMember}
|
||||||
|
onRemoveMember={removeMember}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LeaderboardSection resultType={oom.result_type} entries={leaderboard} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -563,14 +665,25 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className={`grid grid-cols-2 gap-2 ${eclecticAllowed(oom.kind, oom.result_type) ? "sm:grid-cols-3" : ""}`}>
|
||||||
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||||
Sum
|
Sum
|
||||||
</SettingsSegment>
|
</SettingsSegment>
|
||||||
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||||
Snitt
|
Snitt
|
||||||
</SettingsSegment>
|
</SettingsSegment>
|
||||||
|
{eclecticAllowed(oom.kind, oom.result_type) && (
|
||||||
|
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
||||||
|
Eclectic
|
||||||
|
</SettingsSegment>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
@ -623,6 +736,12 @@ function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch
|
||||||
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
|
||||||
</label>
|
</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">
|
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
|
||||||
{saving ? "Lagrer…" : "Lagre"}
|
{saving ? "Lagrer…" : "Lagre"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -632,6 +751,34 @@ 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 }) {
|
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -651,11 +798,9 @@ function SettingsSegment({ selected, onClick, children }: { selected: boolean; o
|
||||||
// --- D. Resultatliste ----------------------------------------------------------
|
// --- D. Resultatliste ----------------------------------------------------------
|
||||||
|
|
||||||
function LeaderboardSection({
|
function LeaderboardSection({
|
||||||
kind,
|
|
||||||
resultType,
|
resultType,
|
||||||
entries,
|
entries,
|
||||||
}: {
|
}: {
|
||||||
kind: OomKind
|
|
||||||
resultType: OomResultType
|
resultType: OomResultType
|
||||||
entries: ApiLeaderboardEntry[] | null
|
entries: ApiLeaderboardEntry[] | null
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -668,12 +813,7 @@ function LeaderboardSection({
|
||||||
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
|
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{kind === "team" ? (
|
{entries === null ? (
|
||||||
<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>
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||||
) : (
|
) : (
|
||||||
<LeaderboardTable entries={entries} resultType={resultType} />
|
<LeaderboardTable entries={entries} resultType={resultType} />
|
||||||
|
|
@ -682,6 +822,193 @@ 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 }) {
|
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
|
||||||
const eligible = entries.filter((e) => e.eligible)
|
const eligible = entries.filter((e) => e.eligible)
|
||||||
const ineligible = entries.filter((e) => !e.eligible)
|
const ineligible = entries.filter((e) => !e.eligible)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,15 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type OomKind = "player" | "team"
|
type OomKind = "player" | "team"
|
||||||
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
|
||||||
type OomAggregationMode = "sum" | "average"
|
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 ApiOrderOfMerit = {
|
type ApiOrderOfMerit = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -42,7 +50,11 @@ const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
|
||||||
net: "Nettoscore-sum",
|
net: "Nettoscore-sum",
|
||||||
money: "Pengeliste",
|
money: "Pengeliste",
|
||||||
}
|
}
|
||||||
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = { sum: "Sum", average: "Snitt" }
|
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = {
|
||||||
|
sum: "Sum",
|
||||||
|
average: "Snitt",
|
||||||
|
eclectic: "Eclectic (drømmerunde)",
|
||||||
|
}
|
||||||
|
|
||||||
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -229,6 +241,17 @@ function CreateForm({
|
||||||
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name.trim() || submitting) return
|
if (!name.trim() || submitting) return
|
||||||
|
|
@ -261,10 +284,10 @@ function CreateForm({
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">Type</span>
|
<span className="text-sm font-semibold text-foreground">Type</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<SegmentButton selected={kind === "player"} onClick={() => setKind("player")}>
|
<SegmentButton selected={kind === "player"} onClick={() => selectKind("player")}>
|
||||||
Spiller
|
Spiller
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
<SegmentButton selected={kind === "team"} onClick={() => setKind("team")}>
|
<SegmentButton selected={kind === "team"} onClick={() => selectKind("team")}>
|
||||||
Lag
|
Lag
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,7 +297,7 @@ function CreateForm({
|
||||||
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
||||||
<SegmentButton key={rt} selected={resultType === rt} onClick={() => setResultType(rt)}>
|
<SegmentButton key={rt} selected={resultType === rt} onClick={() => selectResultType(rt)}>
|
||||||
{RESULT_TYPE_LABELS[rt]}
|
{RESULT_TYPE_LABELS[rt]}
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
))}
|
))}
|
||||||
|
|
@ -283,13 +306,18 @@ function CreateForm({
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
<span className="text-sm font-semibold text-foreground">Aggregering</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className={`grid grid-cols-2 gap-2 ${showEclectic ? "sm:grid-cols-3" : ""}`}>
|
||||||
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
||||||
Sum
|
Sum
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
||||||
Snitt
|
Snitt
|
||||||
</SegmentButton>
|
</SegmentButton>
|
||||||
|
{showEclectic && (
|
||||||
|
<SegmentButton selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
||||||
|
Eclectic
|
||||||
|
</SegmentButton>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users } from "lucide-react"
|
import { ArrowLeft, Mail, Trash2, Trophy, UserPlus, Users, UsersRound } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -182,8 +182,21 @@ export function OrgMembers({ organizationId, orgName }: { organizationId: string
|
||||||
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted">
|
<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" />
|
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
|
|
||||||
294
frontend/components/org-player-roster-panel.tsx
Normal file
294
frontend/components/org-player-roster-panel.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
"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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/components/org-players.tsx
Normal file
78
frontend/components/org-players.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
505
frontend/components/player-import-panel.tsx
Normal file
505
frontend/components/player-import-panel.tsx
Normal file
|
|
@ -0,0 +1,505 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
646
frontend/components/player-import-view.tsx
Normal file
646
frontend/components/player-import-view.tsx
Normal file
|
|
@ -0,0 +1,646 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
459
frontend/components/print-cart-tags.tsx
Normal file
459
frontend/components/print-cart-tags.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
49
frontend/components/print-menu.tsx
Normal file
49
frontend/components/print-menu.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
704
frontend/components/print-resultlist.tsx
Normal file
704
frontend/components/print-resultlist.tsx
Normal file
|
|
@ -0,0 +1,704 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
779
frontend/components/print-scorecard.tsx
Normal file
779
frontend/components/print-scorecard.tsx
Normal file
|
|
@ -0,0 +1,779 @@
|
||||||
|
"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
|
||||||
|
}
|
||||||
615
frontend/components/print-startlist.tsx
Normal file
615
frontend/components/print-startlist.tsx
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
220
frontend/components/public-order-of-merit.tsx
Normal file
220
frontend/components/public-order-of-merit.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"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
583
frontend/components/round-groups-panel.tsx
Normal file
583
frontend/components/round-groups-panel.tsx
Normal file
|
|
@ -0,0 +1,583 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/components/round-groups-step-list.tsx
Normal file
66
frontend/components/round-groups-step-list.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"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,9 +303,14 @@ function ManageRoundDialog({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reach the roster / team setup screen */}
|
{/* 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. */}
|
||||||
<Link
|
<Link
|
||||||
href={manageHref}
|
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"
|
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">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react"
|
import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react"
|
||||||
import { MentionTextarea, TaggedText } from "./mention-input"
|
import { MentionTextarea, TaggedText } from "./mention-input"
|
||||||
import type { Tag } from "@/lib/mentions"
|
import type { Tag } from "@/lib/mentions"
|
||||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
/** Rå meldingsform fra backend. */
|
/** Rå meldingsform fra backend. */
|
||||||
export type ApiRoundMessage = {
|
export type ApiRoundMessage = {
|
||||||
|
|
@ -20,6 +22,13 @@ export type ApiRoundMessage = {
|
||||||
tags: Tag[]
|
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 = {
|
export type RoundMessagesProps = {
|
||||||
roundId: string
|
roundId: string
|
||||||
currentUserId: string
|
currentUserId: string
|
||||||
|
|
@ -108,34 +117,112 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
const [postError, setPostError] = useState<string | null>(null)
|
const [postError, setPostError] = useState<string | null>(null)
|
||||||
const [deletingId, setDeletingId] = 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 fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const cameraInputRef = 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
|
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
|
||||||
// (WS-signal fra round-detail.tsx).
|
// (WS-signal fra round-detail.tsx).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
void load()
|
||||||
async function load() {
|
}, [load, refreshKey])
|
||||||
setLoading(true)
|
|
||||||
setLoadError(null)
|
const flushPendingMessages = useCallback(async () => {
|
||||||
try {
|
if (syncingMessages) return
|
||||||
const res = await fetch(`/rounds/${roundId}/messages`, {
|
setSyncingMessages(true)
|
||||||
credentials: "include",
|
try {
|
||||||
signal: controller.signal,
|
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)
|
||||||
})
|
})
|
||||||
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)
|
||||||
}
|
}
|
||||||
load()
|
}, [messagesMatchId, syncingMessages, load])
|
||||||
return () => controller.abort()
|
|
||||||
}, [roundId, refreshKey])
|
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])
|
||||||
|
|
||||||
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
|
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -173,24 +260,72 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
const trimmedBody = body.trim()
|
const trimmedBody = body.trim()
|
||||||
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
|
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) {
|
async function handlePost(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!canPost) return
|
if (!canPost) return
|
||||||
|
|
||||||
setPosting(true)
|
setPosting(true)
|
||||||
setPostError(null)
|
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 {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
if (trimmedBody.length > 0) formData.append("body", trimmedBody)
|
for (const [k, v] of Object.entries(fields)) formData.append(k, v)
|
||||||
if (imageFile) formData.append("image", imageFile)
|
|
||||||
if (tags.length > 0) formData.append("tags", JSON.stringify(tags))
|
|
||||||
|
|
||||||
const res = await fetch(`/rounds/${roundId}/messages`, {
|
const res = await fetch(`/rounds/${roundId}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
if (res.status !== 201) {
|
if (!res.ok) {
|
||||||
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
|
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +338,9 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
setTags([])
|
setTags([])
|
||||||
clearSelectedImage()
|
clearSelectedImage()
|
||||||
} catch {
|
} catch {
|
||||||
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
|
// 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)
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false)
|
setPosting(false)
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +481,35 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Meldingsliste */}
|
{/* 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). */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
|
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
|
||||||
<span
|
<span
|
||||||
|
|
@ -357,14 +522,15 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
|
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
|
||||||
{loadError}
|
{loadError}
|
||||||
</p>
|
</p>
|
||||||
) : messages.length === 0 ? (
|
) : pendingMessages.length === 0 && messages.length === 0 ? (
|
||||||
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
|
<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.
|
Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
{messages.map((m) => {
|
{[...pendingMessages, ...messages].map((m) => {
|
||||||
const canDelete = m.author_user_id === currentUserId || roundOwnerUserId === currentUserId
|
const isPending = "_clientMessageId" in m
|
||||||
|
const canDelete = !isPending && (m.author_user_id === currentUserId || roundOwnerUserId === currentUserId)
|
||||||
const isDeleting = deletingId === m.id
|
const isDeleting = deletingId === m.id
|
||||||
const { label, title } = formatRelativeTime(m.created_at)
|
const { label, title } = formatRelativeTime(m.created_at)
|
||||||
return (
|
return (
|
||||||
|
|
@ -374,13 +540,20 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
<span className="truncate text-base font-semibold text-foreground">
|
<span className="truncate text-base font-semibold text-foreground">
|
||||||
{m.author_display_name}
|
{m.author_display_name}
|
||||||
</span>
|
</span>
|
||||||
<time
|
{isPending ? (
|
||||||
dateTime={m.created_at}
|
<span className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
|
||||||
title={title}
|
<Clock aria-hidden="true" className="size-3.5 shrink-0" />
|
||||||
className="text-sm font-medium text-muted-foreground"
|
Venter på synk
|
||||||
>
|
</span>
|
||||||
{label}
|
) : (
|
||||||
</time>
|
<time
|
||||||
|
dateTime={m.created_at}
|
||||||
|
title={title}
|
||||||
|
className="text-sm font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
|
|
@ -419,14 +592,16 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PostEngagement
|
{!isPending && (
|
||||||
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
<PostEngagement
|
||||||
roundId={roundId}
|
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
||||||
currentUserId={currentUserId || null}
|
roundId={roundId}
|
||||||
initialReactions={m.reactions}
|
currentUserId={currentUserId || null}
|
||||||
initialCommentCount={m.comment_count}
|
initialReactions={m.reactions}
|
||||||
canModerate={roundOwnerUserId === currentUserId}
|
initialCommentCount={m.comment_count}
|
||||||
/>
|
canModerate={roundOwnerUserId === currentUserId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,7 @@ function BarTrendChart({
|
||||||
formatSlope = formatValue,
|
formatSlope = formatValue,
|
||||||
goodDirection,
|
goodDirection,
|
||||||
slopeSuffix,
|
slopeSuffix,
|
||||||
|
yAxisLabel,
|
||||||
}: {
|
}: {
|
||||||
points: TrendPoint[]
|
points: TrendPoint[]
|
||||||
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
|
// "diverging": symmetrisk 0-linje midt i grafen, farge følger fortegn
|
||||||
|
|
@ -500,6 +501,10 @@ function BarTrendChart({
|
||||||
goodDirection: "up" | "down"
|
goodDirection: "up" | "down"
|
||||||
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
|
// Enhet på stigningstallet i bildeteksten, f.eks. "per runde" eller "pp per runde".
|
||||||
slopeSuffix: string
|
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
|
if (points.length < 2) return null
|
||||||
|
|
||||||
|
|
@ -527,6 +532,7 @@ function BarTrendChart({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
|
|
@ -542,6 +548,7 @@ function BarTrendChart({
|
||||||
const cx = barCenterX(i)
|
const cx = barCenterX(i)
|
||||||
const overPar = mode === "diverging" && point.value > 0
|
const overPar = mode === "diverging" && point.value > 0
|
||||||
const isLast = i === points.length - 1
|
const isLast = i === points.length - 1
|
||||||
|
const isFirst = i === 0
|
||||||
return (
|
return (
|
||||||
<g key={point.played_at + i}>
|
<g key={point.played_at + i}>
|
||||||
<path
|
<path
|
||||||
|
|
@ -552,14 +559,20 @@ function BarTrendChart({
|
||||||
{formatShortDate(point.played_at)}: {formatValue(point.value)}
|
{formatShortDate(point.played_at)}: {formatValue(point.value)}
|
||||||
</title>
|
</title>
|
||||||
</path>
|
</path>
|
||||||
{/* Direkte etikett kun på siste (nyeste) stolpe -- dataviz-prinsipp
|
{/* Direkte etikett på FØRSTE og SISTE stolpe -- verdi i begge
|
||||||
"merk endepunktet, ikke hvert punkt". */}
|
ender så grafen kan leses uten en egen y-akse-skala (samme
|
||||||
{isLast && (
|
prinsipp som CumulativeTrendLine allerede bruker). Første er
|
||||||
|
dempet (utgangspunkt), siste er uthevet (nåværende nivå). */}
|
||||||
|
{(isFirst || isLast) && !(isFirst && isLast) && (
|
||||||
<text
|
<text
|
||||||
x={cx}
|
x={cx}
|
||||||
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
|
y={valueToY(point.value) + (mode === "diverging" && !overPar ? 14 : -6)}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-foreground text-[11px] font-bold tabular-nums"
|
className={
|
||||||
|
isLast
|
||||||
|
? "fill-foreground text-[11px] font-bold tabular-nums"
|
||||||
|
: "fill-muted-foreground text-[10px] font-semibold tabular-nums"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{formatValue(point.value)}
|
{formatValue(point.value)}
|
||||||
</text>
|
</text>
|
||||||
|
|
@ -595,7 +608,7 @@ function ScoreTrendChart({ series }: { series: ApiRoundSeriesPoint[] }) {
|
||||||
if (points.length < 2) return null
|
if (points.length < 2) return null
|
||||||
return (
|
return (
|
||||||
<Card title="Utvikling" subtitle="Snitt til par per runde, eldste runde til venstre.">
|
<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" />
|
<BarTrendChart points={points} mode="diverging" formatValue={signed} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Slag til par" />
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -613,7 +626,15 @@ function seriesFor(series: ApiRoundSeriesPoint[], field: keyof ApiRoundSeriesPoi
|
||||||
// AskUserQuestion-svar 2026-08-12: for få kvalifiserte hull per runde
|
// 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 at et per-runde-tall er meningsfullt, så her vises kun raten
|
||||||
// "til og med denne runden" som en enkel linje, ingen stolper.
|
// "til og med denne runden" som en enkel linje, ingen stolper.
|
||||||
function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: string }) {
|
function CumulativeTrendLine({
|
||||||
|
points,
|
||||||
|
label,
|
||||||
|
yAxisLabel,
|
||||||
|
}: {
|
||||||
|
points: TrendPoint[]
|
||||||
|
label: string
|
||||||
|
yAxisLabel: string
|
||||||
|
}) {
|
||||||
if (points.length < 2) return null
|
if (points.length < 2) return null
|
||||||
const values = points.map((p) => p.value)
|
const values = points.map((p) => p.value)
|
||||||
const maxVal = Math.max(...values, 1)
|
const maxVal = Math.max(...values, 1)
|
||||||
|
|
@ -626,6 +647,7 @@ function CumulativeTrendLine({ points, label }: { points: TrendPoint[]; label: s
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
|
|
@ -865,6 +887,7 @@ export function RoundsStatsSummary() {
|
||||||
formatSlope={roundedNoUnit}
|
formatSlope={roundedNoUnit}
|
||||||
goodDirection="up"
|
goodDirection="up"
|
||||||
slopeSuffix="pp per runde"
|
slopeSuffix="pp per runde"
|
||||||
|
yAxisLabel="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -879,7 +902,7 @@ export function RoundsStatsSummary() {
|
||||||
{girTrend.length >= 2 && (
|
{girTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Greentreff over tid</p>
|
<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" />
|
<BarTrendChart points={girTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -916,13 +939,13 @@ export function RoundsStatsSummary() {
|
||||||
{puttsTrend.length >= 2 && (
|
{puttsTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Putt / 18 hull over tid</p>
|
<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" />
|
<BarTrendChart points={puttsTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="putt per runde" yAxisLabel="Putt" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onePuttTrend.length >= 2 && (
|
{onePuttTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Én-putt over tid</p>
|
<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" />
|
<BarTrendChart points={onePuttTrend} mode="magnitude" formatValue={pct} formatSlope={roundedNoUnit} goodDirection="up" slopeSuffix="pp per runde" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -950,19 +973,19 @@ export function RoundsStatsSummary() {
|
||||||
{chipTrend.length >= 2 && (
|
{chipTrend.length >= 2 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Chip / runde</p>
|
<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" />
|
<BarTrendChart points={chipTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{bunkerTrend.length >= 2 && (
|
{bunkerTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Bunkerslag / runde</p>
|
<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" />
|
<BarTrendChart points={bunkerTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{penaltyTrend.length >= 2 && (
|
{penaltyTrend.length >= 2 && (
|
||||||
<div className="border-t border-border pt-3">
|
<div className="border-t border-border pt-3">
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Straffeslag / runde</p>
|
<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" />
|
<BarTrendChart points={penaltyTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -980,7 +1003,7 @@ export function RoundsStatsSummary() {
|
||||||
|
|
||||||
{anywayTrend.length >= 2 && (
|
{anywayTrend.length >= 2 && (
|
||||||
<Card title="Anywayslag over tid">
|
<Card title="Anywayslag over tid">
|
||||||
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" />
|
<BarTrendChart points={anywayTrend} mode="magnitude" formatValue={oneDecimal} goodDirection="down" slopeSuffix="per runde" yAxisLabel="Antall" />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1016,13 +1039,13 @@ export function RoundsStatsSummary() {
|
||||||
{scramblingCumulative.length >= 2 && (
|
{scramblingCumulative.length >= 2 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Scrambling over tid</p>
|
||||||
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" />
|
<CumulativeTrendLine points={scramblingCumulative} label="Scrambling" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sandSaveCumulative.length >= 2 && (
|
{sandSaveCumulative.length >= 2 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
|
<p className="mb-1 text-sm font-bold text-foreground">Sand save over tid</p>
|
||||||
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" />
|
<CumulativeTrendLine points={sandSaveCumulative} label="Sand save" yAxisLabel="%" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Clock, EyeOff, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
|
import { ArrowLeft, Clock, EyeOff, Flag, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -61,6 +61,10 @@ type ApiSession = {
|
||||||
course_id: string
|
course_id: string
|
||||||
points_per_match: number
|
points_per_match: number
|
||||||
scoring_mode: ScoringMode
|
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[]
|
locked_team_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,6 +89,9 @@ type ApiMatch = {
|
||||||
points_side_b: number | null
|
points_side_b: number | null
|
||||||
leading_side: "a" | "b" | null
|
leading_side: "a" | "b" | null
|
||||||
tee_time: string | 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[]
|
participants: ApiParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +197,20 @@ export function SessionBlindDraw({
|
||||||
}
|
}
|
||||||
}, [organizationId, tournamentId, sessionId])
|
}, [organizationId, tournamentId, sessionId])
|
||||||
|
|
||||||
const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches])
|
// 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 nextSequence = useMemo(
|
const nextSequence = useMemo(
|
||||||
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
|
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
|
||||||
[matches],
|
[matches],
|
||||||
|
|
@ -203,7 +223,7 @@ export function SessionBlindDraw({
|
||||||
if (res.ok) setMatches(await res.json())
|
if (res.ok) setMatches(await res.json())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMatch() {
|
async function addMatch(startHole?: number) {
|
||||||
if (!teams) return
|
if (!teams) return
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -211,7 +231,12 @@ export function SessionBlindDraw({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ sequence: nextSequence, team_a_id: teams[0].id, team_b_id: teams[1].id }),
|
body: JSON.stringify({
|
||||||
|
sequence: nextSequence,
|
||||||
|
team_a_id: teams[0].id,
|
||||||
|
team_b_id: teams[1].id,
|
||||||
|
start_hole: startHole ?? null,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`create match: ${res.status}`)
|
if (!res.ok) throw new Error(`create match: ${res.status}`)
|
||||||
const created: ApiMatch = await res.json()
|
const created: ApiMatch = await res.json()
|
||||||
|
|
@ -413,6 +438,7 @@ export function SessionBlindDraw({
|
||||||
<RevealedView
|
<RevealedView
|
||||||
teams={teams}
|
teams={teams}
|
||||||
matches={sortedMatches}
|
matches={sortedMatches}
|
||||||
|
startMode={session.start_mode}
|
||||||
scorecardHrefFor={(matchId) =>
|
scorecardHrefFor={(matchId) =>
|
||||||
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +456,7 @@ export function SessionBlindDraw({
|
||||||
matches={sortedMatches}
|
matches={sortedMatches}
|
||||||
perSide={maxSlotsPerSide(session.format)}
|
perSide={maxSlotsPerSide(session.format)}
|
||||||
format={session.format}
|
format={session.format}
|
||||||
|
startMode={session.start_mode}
|
||||||
locked={session.locked_team_ids.includes(team.id)}
|
locked={session.locked_team_ids.includes(team.id)}
|
||||||
usedPlayerIds={usedPlayerIds}
|
usedPlayerIds={usedPlayerIds}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
|
|
@ -523,6 +550,7 @@ function TeamColumn({
|
||||||
matches,
|
matches,
|
||||||
perSide,
|
perSide,
|
||||||
format,
|
format,
|
||||||
|
startMode,
|
||||||
locked,
|
locked,
|
||||||
usedPlayerIds,
|
usedPlayerIds,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
|
|
@ -542,6 +570,9 @@ function TeamColumn({
|
||||||
matches: ApiMatch[]
|
matches: ApiMatch[]
|
||||||
perSide: number
|
perSide: number
|
||||||
format: Format
|
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
|
locked: boolean
|
||||||
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
|
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
|
||||||
onAddParticipant: (
|
onAddParticipant: (
|
||||||
|
|
@ -552,7 +583,7 @@ function TeamColumn({
|
||||||
lineupOrder?: number | null,
|
lineupOrder?: number | null,
|
||||||
) => void
|
) => void
|
||||||
onRemoveParticipant: (matchId: string, participantId: string) => void
|
onRemoveParticipant: (matchId: string, participantId: string) => void
|
||||||
onAddMatch: () => void
|
onAddMatch: (startHole?: number) => void
|
||||||
complete: boolean
|
complete: boolean
|
||||||
confirming: boolean
|
confirming: boolean
|
||||||
onRequestLock: () => void
|
onRequestLock: () => void
|
||||||
|
|
@ -560,6 +591,7 @@ function TeamColumn({
|
||||||
onConfirmLock: () => void
|
onConfirmLock: () => void
|
||||||
}) {
|
}) {
|
||||||
const color = team.color ?? "#64748b"
|
const color = team.color ?? "#64748b"
|
||||||
|
const [newMatchStartHole, setNewMatchStartHole] = useState("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -595,10 +627,17 @@ function TeamColumn({
|
||||||
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
|
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
|
||||||
{match.sequence}
|
{match.sequence}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
{startMode === "shotgun" ? (
|
||||||
<Clock aria-hidden="true" className="size-4" />
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
|
<Flag aria-hidden="true" className="size-4" />
|
||||||
</span>
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
|
|
@ -658,10 +697,31 @@ function TeamColumn({
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{!locked && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAddMatch}
|
onClick={() => {
|
||||||
|
const hole = newMatchStartHole.trim() ? Number(newMatchStartHole) : undefined
|
||||||
|
onAddMatch(hole)
|
||||||
|
setNewMatchStartHole("")
|
||||||
|
}}
|
||||||
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"
|
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" />
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
|
@ -827,10 +887,12 @@ function AddSlotForm({
|
||||||
function RevealedView({
|
function RevealedView({
|
||||||
teams,
|
teams,
|
||||||
matches,
|
matches,
|
||||||
|
startMode,
|
||||||
scorecardHrefFor,
|
scorecardHrefFor,
|
||||||
}: {
|
}: {
|
||||||
teams: [ApiTeam, ApiTeam]
|
teams: [ApiTeam, ApiTeam]
|
||||||
matches: ApiMatch[]
|
matches: ApiMatch[]
|
||||||
|
startMode: "consecutive" | "shotgun"
|
||||||
scorecardHrefFor: (matchId: string) => string
|
scorecardHrefFor: (matchId: string) => string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -865,6 +927,11 @@ function RevealedView({
|
||||||
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
|
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
|
||||||
{match.status_text}
|
{match.status_text}
|
||||||
</span>
|
</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">
|
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Clock aria-hidden="true" className="size-4" />
|
<Clock aria-hidden="true" className="size-4" />
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
WifiOff,
|
WifiOff,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { HoleTargetDistance } from "@/components/hole-target-distance"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||||||
|
|
||||||
|
|
@ -855,6 +856,18 @@ export function SessionScorecard({
|
||||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
<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>
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-3">
|
||||||
{units.map((unit) => {
|
{units.map((unit) => {
|
||||||
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b"
|
||||||
|
|
@ -929,6 +942,18 @@ export function SessionScorecard({
|
||||||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {currentHole}</h2>
|
<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>
|
<span className="text-lg font-bold text-muted-foreground">· Par {par}</span>
|
||||||
</div>
|
</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">
|
<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