mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
Compare commits
316 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4009588078 | |||
| ef583ede18 | |||
| 9bc543a387 | |||
| 7291555e95 | |||
| 57f0e2f263 | |||
| de043cfb58 | |||
| 69df85bfa2 | |||
| be6702f3ab | |||
| 164a3633ac | |||
| 204ee2ef86 | |||
| baba2de344 | |||
| 49dd2db040 | |||
| 49e306ea48 | |||
| 1d7c8a63e2 | |||
| fb208a88e2 | |||
| 5ae8ac8e70 | |||
| 20bd384feb | |||
| 58302dc9e4 | |||
| b460cfd9a7 | |||
| 2b8d2e790f | |||
| 2fc9b1991a | |||
| 4d81180fc1 | |||
| 0f600d04f5 | |||
| b53257deaf | |||
| b284095ee0 | |||
| e40e576e39 | |||
| 4d9b84f681 | |||
| 223b056de2 | |||
| 981c372640 | |||
| 839d736905 | |||
| b9391a9921 | |||
| 5e0f216b0c | |||
| a249c861e6 | |||
| 2a40b884a1 | |||
| f5be556c27 | |||
| 6b1488994f | |||
| 2ee4ea1e65 | |||
| a1d683d7bc | |||
| ae726ed370 | |||
| f4750352e6 | |||
| 65ee472d66 | |||
| 73fa7aeb19 | |||
| fbba4fd73b | |||
| 2e1dca9fe9 | |||
| aacc7f2c8d | |||
| f833a05461 | |||
| 65fe8cd6e5 | |||
| 3fe34061fd | |||
| 3659297af1 | |||
| f57a0dbd31 | |||
| 3748bdb815 | |||
| 8fa75b3c4e | |||
| 6ec06c90f4 | |||
| 2449616ee9 | |||
| acc294dd8e | |||
| bde2ad2ca8 | |||
| ae79ab8dad | |||
| 6a0c54cb2f | |||
| a8be1c5b45 | |||
| d0cc6fc2f4 | |||
| f016972a4e | |||
| c80152f41d | |||
| f745ca13c9 | |||
| 4f68029b2c | |||
| 64445a5204 | |||
| bff9bcde92 | |||
| 3572f4b8a3 | |||
| ea9e997338 | |||
| ad2d653e9a | |||
| b177daea3c | |||
| 63a7a4d397 | |||
| f4533fc69b | |||
| 1993ed4d28 | |||
| 1969ca57d9 | |||
| b826ecd425 | |||
| 19946039ad | |||
| 6577595058 | |||
| 78366a9024 | |||
| 0331d9950f | |||
| dfe11e9c4c | |||
| fcdb7e48cd | |||
| 008aaeb408 | |||
| 5ff7e54e09 | |||
| 0169be3d79 | |||
| 68907ec811 | |||
| 51bfb5abd1 | |||
| 1d9b9bf838 | |||
| c83eb42730 | |||
| cd407cf80c | |||
| 71fe69c1f1 | |||
| 54baf85a2a | |||
| 3882feec5c | |||
| 6477bf5e39 | |||
| 971133a1eb | |||
| 0344db6d0c | |||
| 5d265c98de | |||
| 554d6f1b9f | |||
| f79b2020d6 | |||
| 9f0caf8539 | |||
| 7470a2c6d4 | |||
| 4ca5728072 | |||
| 2203ff3a2e | |||
| 0d7e3ceb3d | |||
| 93a508a0e6 | |||
| 0b92dcc86b | |||
| c836ee3988 | |||
| 188953158a | |||
| 20f8266439 | |||
| e5f01e570a | |||
| 2a8dbf91a8 | |||
| ec9e1a7533 | |||
| 3c823e07f4 | |||
| 9ed944c1e6 | |||
| 2955c61c86 | |||
| c97105ce61 | |||
| 290961cced | |||
| 4c594876ef | |||
| d5d2d6b25e | |||
| e3268735f2 | |||
| 9a59703a34 | |||
| 200ce58e98 | |||
| a965b2c75e | |||
| 91a834010b | |||
| a8d06193bd | |||
| 97b748555f | |||
| 187c4e2fc8 | |||
| dfd02940b5 | |||
| da0f79e285 | |||
| e877d35e0b | |||
| f3a39d9452 | |||
| b374fdc989 | |||
| b8eb2d909e | |||
| e429b49ef6 | |||
| 73237ffedb | |||
| adcaca3c5f | |||
| c3229afcba | |||
| 12e7bdf6e8 | |||
| 9382fa6dc6 | |||
| 3332495b7a | |||
| 75d8351fec | |||
| ce07c4dc72 | |||
| c5c88691bc | |||
| 827e682684 | |||
| 11a8d1b4d1 | |||
| 692373c8b8 | |||
| f6fd2722d0 | |||
| 9cd227fd81 | |||
| e4b49e1148 | |||
| a87ce65899 | |||
| 06e6e25b25 | |||
| d322363e53 | |||
| a20b682087 | |||
| ad86713fdc | |||
| 66525c66b7 | |||
| 7a484a09d4 | |||
| d72352911e | |||
| 134ab64bce | |||
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 | |||
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 | |||
| e725d4f0a9 | |||
| 36f1b5b7d9 | |||
| acce063dbf | |||
| d229613e0e | |||
| 5622113348 | |||
| cd2ecf897b | |||
| f5af9d1259 | |||
| cecfdf2dcc | |||
| 23b28a1f3f | |||
| f7f6b83d33 | |||
| 097c1da0af | |||
| a74a2cf9b5 | |||
| dc1ff06573 | |||
| fc631d36b6 | |||
| 6463060f67 | |||
| 593f839592 | |||
| a9c5cf6d9a | |||
| f901982903 | |||
| 8f64431ff1 | |||
| c378722d63 | |||
| bfec874d04 | |||
| a36019b7e2 | |||
| bda24ec639 | |||
| 09caf4bc3f | |||
| 9272151c07 | |||
| 0105891355 | |||
| a7f5a114e6 | |||
| 2a4b71b3d9 | |||
| d46b6fb646 | |||
| 4a64fc9d21 | |||
| 5ee8d450a9 | |||
| de86e6383a | |||
| 14b917ee97 | |||
| 7fffd4300f | |||
| fa8272c522 | |||
| c40d00b830 | |||
| 86976aef10 | |||
| c246877f30 | |||
| 1e2ba0f842 | |||
| 64e413330e | |||
| 2e3fd4fc4c | |||
| 1c38d34699 | |||
| ccfb610aab | |||
| 03d6457bde | |||
| 89ad4ec56a | |||
| 2c3b20bb57 | |||
| d705bd9dd0 | |||
| 704621d0c2 | |||
| 9494521ac7 | |||
| 33b006014b | |||
| 52ed63715c | |||
| 1d7758b819 | |||
| b33727ec75 | |||
| 97b5f36ef0 | |||
| 845979902c | |||
| a0884685ef | |||
| 485e360c64 | |||
| 356df90095 | |||
| cd1b1410bd | |||
| 3d9095ccde | |||
| 018d2d1425 | |||
| cab742e641 | |||
| 11f23997e8 | |||
| f1c437b8b8 | |||
| f690065de8 | |||
| 782e613506 | |||
| 31ffcf1228 | |||
| 309bef4db7 | |||
| 6e59240328 | |||
| bdb082d490 | |||
| 8957503dbe | |||
| 0a50119c52 | |||
| 1d090e0ac9 | |||
| d118c3cee9 | |||
| e3f3b0458b | |||
| 976a612351 | |||
| d87e276831 | |||
| 62b50e3f9b | |||
| 84d637f5fe | |||
| 77c4785a9d | |||
| 6d43f3a4e6 | |||
| a805b4b882 | |||
| e3983ef857 | |||
| 160bc89e78 | |||
| a9882a6415 | |||
| 6b7461ee03 | |||
| 0eca20f2a4 | |||
| 796bbb065d | |||
| 20d8bae46b | |||
| 98cc57942b | |||
| e7f2e17954 | |||
| f746c22a83 | |||
| 723bfa507b | |||
| 3c8fa07f44 | |||
| 4ceade9d8b | |||
| dae20c166a | |||
| 0d4ddb2089 | |||
| 775b35d5af | |||
| 76b7b305b2 | |||
| eb4e74b5c3 | |||
| 10a987fec1 | |||
| 7c6b7622fc | |||
| 341520f98b | |||
| 2fe8590c0b | |||
| b3ef13616f | |||
| db531d2bef | |||
| f463c44d03 | |||
| 07a82be64e | |||
| 4efc50c012 | |||
| 7a2846b3fa | |||
| e87c68b811 | |||
| 26833f374c | |||
| 7795061630 | |||
| 09227ec0d1 | |||
| dbee1664af | |||
| 4c487ceefa | |||
| dd43999f1f | |||
| 9a84890142 | |||
| eb13d9f785 | |||
| 57ea219a43 | |||
| af9f013c5e | |||
| f870dfc6d1 | |||
| bf26175724 |
+339
-144
@@ -9,23 +9,73 @@ permissions:
|
||||
# analysis; pull requests get PR analysis with decoration on the PR itself
|
||||
# (the scanner picks the context up from the Actions environment).
|
||||
#
|
||||
# Job layout — three tiers:
|
||||
# - Preflight: plugin-facts, i18n-parity and the quality jobs
|
||||
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
|
||||
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
|
||||
# of them, so a gate failure stops the run before the expensive coverage
|
||||
# fan-out spends ~15 runner-minutes on a result the gates already sank.
|
||||
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
|
||||
# runs the MinIO contract suite. Coverage jobs run tests only — with the
|
||||
# gates cleared in preflight, they can only fail because tests failed.
|
||||
# client-e2e drives the real app in Chromium and WebKit, client-bundle
|
||||
# builds the client and checks each map engine keeps a chunk of its own,
|
||||
# and postgres-probe runs the dialect helpers and the repository
|
||||
# statements it reaches against a Postgres service. None of them feeds the scan.
|
||||
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
|
||||
# must stop the scan — a missing lcov file would make every line of that
|
||||
# package report as uncovered, and the dashboard would show a collapse
|
||||
# that did not happen. (The preflight gates stop it transitively.)
|
||||
# Job names read "<package>: <what>", so the checks list on a PR sorts by
|
||||
# package. What each job holds:
|
||||
#
|
||||
# Repo: Changed Areas decides what the change needs to run (below)
|
||||
# Shared: i18n Parity every locale has en's files and keys
|
||||
# Shared: Coverage shared's tests with coverage
|
||||
# Server: Types builds shared and the server, typechecks shared,
|
||||
# the server's src, tests and scripts, smokes the
|
||||
# production require chain, the strict ratchet,
|
||||
# the generated entities and Kysely types
|
||||
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
|
||||
# Prettier, the plugin facts, the DB call graph,
|
||||
# the shared ratchet code, the source ratchets
|
||||
# (size, boundaries, dialect, tx, test SQL, test
|
||||
# mocks, hand-built services, query API, MCP zod,
|
||||
# error envelopes, response contracts), open
|
||||
# request schemas, knip
|
||||
# Server: Tests n/7 one slice of the server suite, with coverage
|
||||
# Server: Coverage merges the slices, checks the per-domain
|
||||
# thresholds, uploads lcov
|
||||
# Server: S3 Contract (MinIO)
|
||||
# the storage driver against MinIO
|
||||
# Server: Postgres Probe the repository SQL against Postgres
|
||||
# Client: Types & Checks typecheck and the strict ratchet; then the page
|
||||
# pattern and the source ratchets (RTL, size,
|
||||
# Prettier, layers, offline path, duplication,
|
||||
# desktop/phone pairs, skips, i18n keys, theme
|
||||
# tokens)
|
||||
# Client: Lint ESLint (typed), errors and warning counts
|
||||
# Client: Tests n/7 one slice of the client suite, with coverage
|
||||
# Client: Coverage merges the slices, checks the thresholds,
|
||||
# uploads lcov
|
||||
# Client: Bundle the production build, one chunk per map engine
|
||||
# Client: End-to-End the app in Chromium and WebKit
|
||||
# Plugin SDK: Types & Coverage
|
||||
# Repo: Docker Image builds the image and boots it until ready
|
||||
# Repo: Helm Chart & Compose
|
||||
# Repo: Sonar Scan SonarCloud analysis, not required
|
||||
# ci-ok the one required check (below)
|
||||
#
|
||||
# Job layout:
|
||||
# - Everything starts right after changes: the gates and the tests run side
|
||||
# by side, so a run takes as long as its slowest job and not the gates
|
||||
# plus the tests. The tests used to wait for the gates, which put ~9
|
||||
# minutes in front of every test result. The gates were one job per
|
||||
# package (~8-9 minutes each); each is now two jobs of ~4-5 minutes,
|
||||
# split where the time is (the server's typechecks, the client's lint),
|
||||
# so neither outlasts a test shard.
|
||||
# - The client and server suites run in shards (client-tests,
|
||||
# server-tests), each running a slice with coverage and uploading a blob
|
||||
# report; client-coverage and server-coverage merge the blobs, check the
|
||||
# thresholds against the whole and upload lcov. A red shard is usually a
|
||||
# failing test, a red merge job usually a threshold. None of the other
|
||||
# test jobs feeds the scan.
|
||||
# - Job count: GitHub runs at most 20 jobs of the organisation at once and
|
||||
# does not say in which order it starts queued ones. A code change starts
|
||||
# the 14 shards and about a dozen other jobs; seven of those (parity, the
|
||||
# SDK, the bundle, S3, Postgres, shared coverage, the chart) are done in
|
||||
# a minute or two, so what queues at the start waits about that long. The
|
||||
# jobs that run for minutes (shards, gates, e2e, the image) must not
|
||||
# outnumber the 20 slots, or one of them waits for another to finish and
|
||||
# the run grows by a whole job: a layout with six gate jobs did exactly
|
||||
# that. Check the count before adding a long job or a shard.
|
||||
# - Scan `needs:` the gates and the four coverage jobs. A coverage job
|
||||
# failing must stop the scan — a missing lcov file would make every line
|
||||
# of that package report as uncovered, and the dashboard would show a
|
||||
# collapse that did not happen.
|
||||
# - Fork PRs and runs Dependabot triggers don't receive the repo's Actions
|
||||
# secrets, so the two jobs that need them skip themselves: s3-contract
|
||||
# (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). ci-ok counts a skip as
|
||||
@@ -38,7 +88,7 @@ permissions:
|
||||
# required check that never reports blocks the PR for good.
|
||||
# - docker-image builds the Dockerfile and boots the result, and
|
||||
# deploy-manifests lints and renders the Helm chart and checks the
|
||||
# compose files. Both run beside the preflight rather than after it.
|
||||
# compose files. Both run beside the gates rather than after them.
|
||||
# - ci-ok `needs:` every gate, test, image and chart job, and is the one
|
||||
# check the branch ruleset requires. It fails when any of them failed or
|
||||
# was cancelled and passes when they passed or were skipped (by changes,
|
||||
@@ -69,7 +119,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Changed Areas
|
||||
name: 'Repo: Changed Areas'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
@@ -144,36 +194,8 @@ jobs:
|
||||
node scripts/ci/changed-areas.mjs --all
|
||||
fi
|
||||
|
||||
plugin-facts:
|
||||
name: Plugin Permission Facts
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace server
|
||||
|
||||
# The plugin permission facts live once, in
|
||||
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
|
||||
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
|
||||
# generated from it and checked in, because publish-plugin-sdk builds
|
||||
# from a standalone checkout with no server present. This fails the run
|
||||
# if a copy drifts, if a new permission has no admin.plugins.perm.*
|
||||
# translation, or if the CLI has no hint for it.
|
||||
- name: Check the generated plugin facts are in sync
|
||||
run: cd server && npm run check:plugin-facts
|
||||
|
||||
i18n-parity:
|
||||
name: i18n Key Parity
|
||||
name: 'Shared: i18n Parity'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -189,8 +211,8 @@ jobs:
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
server-quality:
|
||||
name: Server Types & Lint
|
||||
server-types:
|
||||
name: 'Server: Types'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -239,8 +261,11 @@ jobs:
|
||||
# constructor parameter does not break its hand-wired call sites at
|
||||
# compile time: the argument arrives as undefined and throws at
|
||||
# runtime, if anything reads it at all. This is the step that
|
||||
# catches it.
|
||||
# catches it. src and tests together are past 570,000 lines and need
|
||||
# about 4.3 GB, over Node's default heap.
|
||||
run: cd server && npm run typecheck:tests
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
- name: Typecheck entity generator
|
||||
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
|
||||
@@ -257,6 +282,36 @@ jobs:
|
||||
# migration that changes a column regenerates it in the same change.
|
||||
run: cd server && npm run check:db-types
|
||||
|
||||
server-lint-checks:
|
||||
name: 'Server: Lint & Checks'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Errors fail, and the warnings per rule may not pass
|
||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||
@@ -264,8 +319,19 @@ jobs:
|
||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd server && npm run lint:warnings
|
||||
|
||||
# The plugin permission facts live once, in
|
||||
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
|
||||
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
|
||||
# generated from it and checked in, because publish-plugin-sdk builds
|
||||
# from a standalone checkout with no server present. This fails the run
|
||||
# if a copy drifts, if a new permission has no admin.plugins.perm.*
|
||||
# translation, or if the CLI has no hint for it.
|
||||
- name: Check the generated plugin facts are in sync
|
||||
run: cd server && npm run check:plugin-facts
|
||||
|
||||
- name: Formatting check
|
||||
# Prettier over src/ and tests/: a file outside
|
||||
# scripts/format-baseline.json must be formatted, and the list only
|
||||
@@ -359,12 +425,12 @@ jobs:
|
||||
# knip (knip.jsonc) over client, server and shared: unused files,
|
||||
# exports, types and dependencies per file may not rise above
|
||||
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
|
||||
# rather than in client-quality because it needs every workspace's
|
||||
# dependencies, which only this job installs.
|
||||
# rather than in client-types-checks because it needs every workspace's
|
||||
# dependencies, which the client jobs do not install.
|
||||
run: npm run lint:knip
|
||||
|
||||
client-quality:
|
||||
name: Client Types & Lint
|
||||
client-types-checks:
|
||||
name: 'Client: Types & Checks'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -380,7 +446,7 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -395,15 +461,6 @@ jobs:
|
||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||
run: cd client && npm run lint:strict
|
||||
|
||||
- name: Lint
|
||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||
# whose warning count rises above scripts/eslint-baseline.json.
|
||||
# Typed linting (no-floating-promises, no-misused-promises) loads the
|
||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: cd client && npm run lint:warnings
|
||||
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
@@ -467,36 +524,13 @@ jobs:
|
||||
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
|
||||
run: cd client && npm run theme:lint
|
||||
|
||||
plugin-sdk-quality:
|
||||
name: Plugin SDK Types
|
||||
client-lint:
|
||||
name: 'Client: Lint'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
|
||||
# installs from its own directory.
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
client-bundle:
|
||||
name: Client Bundle
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -507,7 +541,47 @@ jobs:
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||
# whose warning count rises above scripts/eslint-baseline.json.
|
||||
# Typed linting (no-floating-promises, no-misused-promises) loads the
|
||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd client && npm run lint:warnings
|
||||
|
||||
client-bundle:
|
||||
name: 'Client: Bundle'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -523,10 +597,11 @@ jobs:
|
||||
run: cd client && npm run check:gl-split
|
||||
|
||||
client-e2e:
|
||||
name: Client End-to-End
|
||||
name: 'Client: End-to-End'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -580,17 +655,17 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
s3-contract:
|
||||
name: S3 Driver Contract (MinIO)
|
||||
name: 'Server: S3 Contract (MinIO)'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
needs: changes
|
||||
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
|
||||
# the AIStor container would fail to boot. A run Dependabot triggers gets
|
||||
# only Dependabot secrets, so its PRs (same repo, not a fork) hit the same
|
||||
# wall. Pushes and manual dispatches by anyone else do have secrets, so
|
||||
# only those two are filtered out. (The implicit success() on this
|
||||
# expression also keeps the job skipped when a preflight fails.)
|
||||
# only those two are filtered out.
|
||||
if: >-
|
||||
needs.changes.outputs.code == 'true' &&
|
||||
github.actor != 'dependabot[bot]' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
|
||||
@@ -606,14 +681,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
- name: Check @swc/core's native binding loads
|
||||
# The server's SWC transform (server/vitest.config.ts) needs it. The
|
||||
# lockfile carries the Linux binary, so npm ci installs it; this only
|
||||
# names the cause if a lockfile regenerated without it ever lands. (It
|
||||
# used to be installed here with --legacy-peer-deps, which pruned
|
||||
# eight peer packages and left the tests on a tree the lockfile does
|
||||
# not describe.)
|
||||
run: node -e "require('@swc/core').transformSync('let a = 1')"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
@@ -671,10 +746,10 @@ jobs:
|
||||
# artifact seeds the file and a change that fixes a statement can lower it
|
||||
# without a local Postgres.
|
||||
postgres-probe:
|
||||
name: Postgres Probe
|
||||
name: 'Server: Postgres Probe'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
services:
|
||||
postgres:
|
||||
@@ -727,11 +802,20 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
server-coverage:
|
||||
name: Server Coverage
|
||||
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
|
||||
# without the thresholds and the coverage reports: a slice's blob carries
|
||||
# its coverage to server-coverage, which checks the whole. fail-fast is off,
|
||||
# so one red run shows every failing slice and not just the first.
|
||||
server-tests:
|
||||
name: 'Server: Tests ${{ matrix.shard }}/7'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -744,20 +828,63 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
||||
# optional native binary, so npm ci/install skips it on the runner.
|
||||
# Install the matching version explicitly so the server's SWC transform
|
||||
# (server/vitest.config.ts) can load.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
- name: Check @swc/core's native binding loads
|
||||
# The server's SWC transform (server/vitest.config.ts) needs it. The
|
||||
# lockfile carries the Linux binary, so npm ci installs it; this only
|
||||
# names the cause if a lockfile regenerated without it ever lands. (It
|
||||
# used to be installed here with --legacy-peer-deps, which pruned
|
||||
# eight peer packages and left the tests on a tree the lockfile does
|
||||
# not describe.)
|
||||
run: node -e "require('@swc/core').transformSync('let a = 1')"
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd server && npm run test:coverage
|
||||
- name: Run a slice of the tests with coverage
|
||||
run: >-
|
||||
cd server && npx vitest run --config vitest.shard.config.ts --coverage
|
||||
--shard=${{ matrix.shard }}/7
|
||||
--reporter=default --reporter=github-actions --reporter=blob
|
||||
|
||||
- name: Upload the blob report
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: server-blob-${{ matrix.shard }}
|
||||
path: server/.vitest-reports/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
server-coverage:
|
||||
name: 'Server: Coverage'
|
||||
needs: server-tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download the blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: server-blob-*
|
||||
path: server/.vitest-reports/
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge the slices and check the thresholds
|
||||
# Every blob holds a whole coverage map, and the merge reads them all
|
||||
# into one process.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: cd server && npx vitest run --merge-reports --coverage
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -766,11 +893,18 @@ jobs:
|
||||
path: server/coverage/
|
||||
retention-days: 1
|
||||
|
||||
client-coverage:
|
||||
name: Client Coverage
|
||||
# The client suite in seven slices, the same way as server-tests. The two
|
||||
# suites cost about the same per slice at seven each (~4-6 minutes).
|
||||
client-tests:
|
||||
name: 'Client: Tests ${{ matrix.shard }}/7'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -785,13 +919,56 @@ jobs:
|
||||
# @testing-library/react) in the tree without the re-resolution dance
|
||||
# the folded coverage job used to need.
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared && npm ci --workspace client
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd client && npm run test:coverage
|
||||
- name: Run a slice of the tests with coverage
|
||||
run: >-
|
||||
cd client && npx vitest run --config vitest.shard.config.ts --coverage
|
||||
--shard=${{ matrix.shard }}/7
|
||||
--reporter=default --reporter=github-actions --reporter=blob
|
||||
|
||||
- name: Upload the blob report
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: client-blob-${{ matrix.shard }}
|
||||
path: client/.vitest-reports/
|
||||
include-hidden-files: true
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
client-coverage:
|
||||
name: 'Client: Coverage'
|
||||
needs: client-tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace shared --workspace client
|
||||
|
||||
- name: Download the blob reports
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: client-blob-*
|
||||
path: client/.vitest-reports/
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge the slices and check the thresholds
|
||||
# Every blob holds a whole coverage map, and the merge reads them all
|
||||
# into one process.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: cd client && npx vitest run --merge-reports --coverage
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -801,10 +978,11 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
shared-coverage:
|
||||
name: Shared Coverage
|
||||
name: 'Shared: Coverage'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -827,11 +1005,14 @@ jobs:
|
||||
path: shared/coverage/
|
||||
retention-days: 1
|
||||
|
||||
plugin-sdk-coverage:
|
||||
name: Plugin SDK Coverage
|
||||
# Typecheck and tests in one job: each takes seconds, and a runner of its
|
||||
# own would cost more in setup than it saves.
|
||||
plugin-sdk:
|
||||
name: 'Plugin SDK: Types & Coverage'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.code == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
@@ -850,6 +1031,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
@@ -861,7 +1045,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
scan:
|
||||
name: Scan
|
||||
name: 'Repo: Sonar Scan'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
|
||||
@@ -871,9 +1055,18 @@ jobs:
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
# Coverage jobs failing must stop the scan: a missing lcov file would make
|
||||
# every line of that package report as uncovered, and the dashboard would
|
||||
# show a collapse that did not happen. The preflight gates already stop it
|
||||
# transitively through these four, so they aren't repeated here.
|
||||
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
|
||||
# show a collapse that did not happen. The gates run beside the tests now,
|
||||
# so they are listed here too, and a failed gate still stops the scan.
|
||||
needs:
|
||||
- i18n-parity
|
||||
- server-types
|
||||
- server-lint-checks
|
||||
- client-types-checks
|
||||
- client-lint
|
||||
- server-coverage
|
||||
- client-coverage
|
||||
- shared-coverage
|
||||
- plugin-sdk
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -934,7 +1127,7 @@ jobs:
|
||||
# reached dev unseen. It is separate from the vulnerability scan on purpose,
|
||||
# so a newly published CVE cannot hide a broken build or the other way round.
|
||||
docker-image:
|
||||
name: Docker Image
|
||||
name: 'Repo: Docker Image'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.image == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1011,7 +1204,7 @@ jobs:
|
||||
# the output checked against the Kubernetes API schemas. Both tools come
|
||||
# from their release pages, pinned by version and checksum.
|
||||
deploy-manifests:
|
||||
name: Helm Chart & Compose
|
||||
name: 'Repo: Helm Chart & Compose'
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deploy == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -1082,19 +1275,21 @@ jobs:
|
||||
ci-ok:
|
||||
needs:
|
||||
- changes
|
||||
- plugin-facts
|
||||
- i18n-parity
|
||||
- server-quality
|
||||
- client-quality
|
||||
- plugin-sdk-quality
|
||||
- server-types
|
||||
- server-lint-checks
|
||||
- client-types-checks
|
||||
- client-lint
|
||||
- client-bundle
|
||||
- client-e2e
|
||||
- s3-contract
|
||||
- postgres-probe
|
||||
- server-tests
|
||||
- server-coverage
|
||||
- client-tests
|
||||
- client-coverage
|
||||
- shared-coverage
|
||||
- plugin-sdk-coverage
|
||||
- plugin-sdk
|
||||
- docker-image
|
||||
- deploy-manifests
|
||||
# always(): without it a failed dependency would skip this job, and the
|
||||
|
||||
@@ -55,6 +55,8 @@ coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
# Blob reports of a sharded vitest run (test.yml)
|
||||
.vitest-reports
|
||||
|
||||
# Cache
|
||||
.npm
|
||||
|
||||
@@ -134,8 +134,10 @@ These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-quality), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-lint-checks), `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
- **Job names read "package: what"** (`Server: Types`, `Client: Tests 3/7`), and the list at the top of `test.yml` says what each job holds. A new job follows the scheme and joins that list, and the job-count note there says when a long job needs a slot freed first.
|
||||
- **Tests run beside the gates, in shards.** The client and server suites run in slices on parallel runners (`client-tests`, `server-tests`, through each package's `vitest.shard.config.ts`), and `client-coverage`/`server-coverage` merge their blob reports and check the thresholds against the whole. A red slice is usually a failing test, a red merge job usually a threshold.
|
||||
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
|
||||
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
|
||||
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The chart then
|
||||
renders no Secret of its own and reads every secret env var from that secret: `ENCRYPTION_KEY`
|
||||
(renamable via `existingSecretKey`), plus `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `OIDC_CLIENT_SECRET`,
|
||||
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET` and
|
||||
`VAPID_PRIVATE_KEY`. Missing keys are simply left unset, so only provide the ones you use.
|
||||
`UNSPLASH_ACCESS_KEY`, `PLACES_API_KEY`, `AMAP_API_KEY`, `AMAP_API_SECRET`,
|
||||
`VAPID_PRIVATE_KEY`, `SMTP_PASS`, `MAPBOX_ACCESS_TOKEN`, `CARTO_API_KEY` and
|
||||
`DEMO_ADMIN_PASS`. Missing keys are simply left unset, so only provide the ones you use.
|
||||
- If left empty, the server resolves the key automatically: existing installs fall back to
|
||||
data/.jwt_secret (encrypted data stays readable with no manual action); fresh installs
|
||||
auto-generate a key persisted to the data PVC.
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 876,
|
||||
"@typescript-eslint/no-misused-promises": 874,
|
||||
"no-console": 88,
|
||||
"complexity": 231,
|
||||
"@typescript-eslint/no-explicit-any": 148,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 561,
|
||||
"react-hooks/refs": 558,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
"@typescript-eslint/no-unused-vars": 155,
|
||||
"@typescript-eslint/no-unused-vars": 129,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 42,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 46,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1949,
|
||||
"@typescript-eslint/no-non-null-assertion": 1946,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -571,6 +571,12 @@
|
||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
|
||||
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
@@ -717,12 +723,17 @@
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
"desktop": [
|
||||
"src/components/Tours/TourDetailDialog.tsx",
|
||||
"src/components/Tours/ToursSidebar.tsx",
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx",
|
||||
"src/components/Tours/tourParts.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list facets",
|
||||
"hooks": ["src/components/Tours/useTourListFilters.ts"],
|
||||
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking detail view: reservationFiles",
|
||||
"hooks": ["src/utils/reservationFiles.ts"],
|
||||
|
||||
@@ -1028,7 +1028,6 @@
|
||||
"src/components/Todo/useTodoList.ts",
|
||||
"src/components/Tours/PlacesToursModeSwitch.tsx",
|
||||
"src/components/Tours/TourDetailDialog.test.tsx",
|
||||
"src/components/Tours/TourDetailDialog.tsx",
|
||||
"src/components/Tours/TourListRow.tsx",
|
||||
"src/components/Tours/ToursSidebar.test.tsx",
|
||||
"src/components/Tours/ToursSidebar.tsx",
|
||||
|
||||
@@ -139,11 +139,6 @@ export const DYNAMIC_ALLOWED = [
|
||||
'memories.* key in en',
|
||||
}))
|
||||
),
|
||||
{
|
||||
file: 'components/Tours/planner/TourPlannerPanels.tsx',
|
||||
template: 'tours.planner.${role}',
|
||||
because: "roleLabel takes role: 'start' | 'via' | 'end', and tours.planner.start/via/end exist",
|
||||
},
|
||||
];
|
||||
|
||||
const KEY_RE = /^[a-z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_:-]+)+$/;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"collection.ts": 35,
|
||||
"common.ts": 7,
|
||||
"dashboard.ts": 65,
|
||||
"dawarich.ts": 16,
|
||||
"dawarich.ts": 15,
|
||||
"dayplan.ts": 18,
|
||||
"docsync.ts": 25,
|
||||
"files.ts": 4,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||
*/
|
||||
import { ESLint } from 'eslint';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -137,6 +138,90 @@ export async function lintClient(root) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||
|
||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||
function gitIn(root, args) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 1 << 28,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The messages of one rule in one area of a single file's lint result. */
|
||||
function ofRule(results, rule, area) {
|
||||
const result = results[0];
|
||||
if (!result) return [];
|
||||
const pool =
|
||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||
return pool.filter((m) => ruleOf(m) === rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||
* The count alone names no file, and the files holding the most are old ones;
|
||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||
* without it), and the report then keeps to the files holding the most.
|
||||
*/
|
||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||
if (changed === null) return null;
|
||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||
const found = await Promise.all(
|
||||
keys
|
||||
.filter((key) => differs.has(key))
|
||||
.map(async (key) => {
|
||||
const path = join(root, key);
|
||||
// A typed rule can trip over a text that is not the file on disk (the
|
||||
// base version); then the file's own warnings are told, marked so.
|
||||
const lint = async (text) => {
|
||||
try {
|
||||
return { messages: ofRule(await lintText(text, path), rule, area) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const now = await lint(readText(path));
|
||||
if (!now) return [];
|
||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||
const was = before === null ? { messages: [] } : await lint(before);
|
||||
if (was && now.messages.length <= was.messages.length) return [];
|
||||
const note = was ? '' : ' (the base version could not be linted)';
|
||||
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
|
||||
})
|
||||
);
|
||||
return found.flat();
|
||||
}
|
||||
|
||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||
async function reportNew(grown, { root, files, lintText, error }) {
|
||||
if (!grown.length || !lintText) return;
|
||||
const found = await Promise.all(
|
||||
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
||||
);
|
||||
grown.forEach(({ area, rule }, i) => {
|
||||
const lines = found[i];
|
||||
if (!lines?.length) return;
|
||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||
for (const line of lines) error(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
||||
function lintTextClient(root) {
|
||||
const eslint = new ESLint({ cwd: root });
|
||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
@@ -148,6 +233,7 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint = lintClient,
|
||||
lintText = lint === lintClient ? lintTextClient : null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -160,13 +246,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -182,7 +268,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -191,5 +278,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
|
||||
"src/pages/JourneyDetailPage.tsx",
|
||||
"src/pages/OAuthAuthorizePage.tsx",
|
||||
"src/pages/TripPlannerPage.tsx",
|
||||
"src/pages/admin/AdminSettingsTab.tsx",
|
||||
"src/pages/admin/useAdmin.ts",
|
||||
"src/pages/admin/useNotificationDefaults.ts",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1916,
|
||||
"src/api/client.ts": 1910,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1461,
|
||||
"src/components/Map/MapView.tsx": 1401,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2425,
|
||||
"src/components/Map/MapViewGL.tsx": 2293,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5380,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2978,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2969,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,8 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1168,
|
||||
"src/pages/TripPlannerPage.wiring.test.tsx": 2041,
|
||||
"src/pages/TripPlannerPage.tsx": 1141,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
"src/components/Tours/tourParts.tsx": 11,
|
||||
"src/components/Trips/TripFormModal.tsx": 3,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/Vacay/VacayCalendar.tsx": 1,
|
||||
|
||||
@@ -55,14 +55,14 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 23,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 10,
|
||||
"components/Map/MapView.tsx": 8,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
@@ -269,7 +269,7 @@
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import { postProviderPhotosInBatches } from './providerPhotoBatches'
|
||||
import {
|
||||
@@ -525,11 +525,6 @@ export const toursApi = {
|
||||
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
|
||||
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
|
||||
},
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
|
||||
//
|
||||
// LIB-03: every method pins its verb, its path and its body, and hands back what
|
||||
// the server answered.
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { collectionToursApi } from './collectionTours';
|
||||
|
||||
const BASE = '/api/addons/collections/3/tours';
|
||||
|
||||
let calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
|
||||
function record(data: JsonBodyType | null) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
const text = await request.text();
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
|
||||
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
|
||||
};
|
||||
}
|
||||
|
||||
describe('collectionToursApi', () => {
|
||||
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
|
||||
server.use(
|
||||
http.get(BASE, record({ tours: [] })),
|
||||
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
|
||||
http.post(BASE, record({ tour: { id: 5 } })),
|
||||
http.delete(`${BASE}/4`, record({ success: true }))
|
||||
);
|
||||
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
|
||||
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
|
||||
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
|
||||
tour: { id: 5 },
|
||||
});
|
||||
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', path: BASE, body: null },
|
||||
{ method: 'GET', path: `${BASE}/4`, body: null },
|
||||
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
|
||||
{ method: 'DELETE', path: `${BASE}/4`, body: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
|
||||
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
|
||||
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
|
||||
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
|
||||
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
CollectionTourDetailResponse,
|
||||
CollectionTourListResponse,
|
||||
CollectionTourSaveRequest,
|
||||
CollectionTourSaveResponse,
|
||||
CollectionTourTakeOverRequest,
|
||||
TourCreateResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
|
||||
|
||||
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
|
||||
export const collectionToursApi = {
|
||||
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
|
||||
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
|
||||
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
|
||||
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
|
||||
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
|
||||
apiClient.post(base(collectionId), body).then((r) => r.data),
|
||||
remove: (collectionId: number, tourId: number): Promise<void> =>
|
||||
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
|
||||
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
// FE-API-FERRATAS-001 to FE-API-FERRATAS-002
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { ferratasApi } from './ferratas';
|
||||
|
||||
const reply = { ferratas: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
|
||||
|
||||
describe('ferratasApi.list', () => {
|
||||
it('FE-API-FERRATAS-001: asks GET /api/trips/:tripId/ferratas with the bbox and returns the body', async () => {
|
||||
let url = '';
|
||||
server.use(
|
||||
http.get('/api/trips/7/ferratas', ({ request }) => {
|
||||
url = request.url;
|
||||
return HttpResponse.json(reply);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).resolves.toEqual(reply);
|
||||
expect(new URL(url).searchParams.get('bbox')).toBe('46.500,11.700,46.600,11.900');
|
||||
});
|
||||
|
||||
it('FE-API-FERRATAS-002: an unreachable Overpass (502) rejects', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/7/ferratas', () =>
|
||||
HttpResponse.json({ error: 'Overpass unavailable', code: 'ferratas_unavailable' }, { status: 502 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(ferratasApi.list(7, '46.500,11.700,46.600,11.900')).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for GET /api/trips/:tripId/ferratas.
|
||||
*
|
||||
* The server races the configured Overpass mirrors under one OVERPASS_TIMEOUT_MS
|
||||
* (25 s by default) and answers 502 when none of them made it. The shared 8 s
|
||||
* on `apiClient` would give up on a slow but healthy mirror and show an error
|
||||
* the server was about to resolve, so this sits just above the server's own
|
||||
* ceiling.
|
||||
*/
|
||||
const FERRATAS_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* GET /api/trips/:tripId/ferratas: the via ferratas OpenStreetMap knows inside
|
||||
* one map area. In its own file rather than in the `client.ts` god module. The
|
||||
* answer comes back unparsed; repo/ferrataRepo.ts checks it against the shared
|
||||
* contract before anything draws it.
|
||||
*/
|
||||
export const ferratasApi = {
|
||||
list: (tripId: number, bbox: string, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/ferratas`, { params: { bbox }, signal, timeout: FERRATAS_TIMEOUT_MS })
|
||||
.then((response) => response.data as unknown),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-API-HORSE-001 to FE-API-HORSE-002
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { horseRoutesApi } from './horseRoutes';
|
||||
|
||||
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
|
||||
const BBOX = '47.10000,11.20000,47.30000,11.50000';
|
||||
|
||||
describe('horseRoutesApi.list', () => {
|
||||
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
|
||||
let url = '';
|
||||
server.use(
|
||||
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
|
||||
url = request.url;
|
||||
return HttpResponse.json(reply);
|
||||
})
|
||||
);
|
||||
|
||||
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
|
||||
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
|
||||
});
|
||||
|
||||
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/7/tours/horse-routes', () =>
|
||||
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
|
||||
)
|
||||
);
|
||||
|
||||
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
|
||||
* The server races the Overpass mirrors under its own deadline (25 s by
|
||||
* default) and answers 502 when none made it, so this sits just above it, as
|
||||
* the via ferrata layer's wait does.
|
||||
*/
|
||||
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
|
||||
* routes OpenStreetMap knows inside one map view. The answer comes back
|
||||
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
|
||||
*/
|
||||
export const horseRoutesApi = {
|
||||
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
|
||||
.then((response) => response.data as unknown),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { paddleObstaclesResponseSchema, type PaddleObstaclesResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-05A: the paddle objects of a map area and how far TREK's data covers it. */
|
||||
export const paddleObstaclesApi = {
|
||||
inBox: (
|
||||
box: { south: number; west: number; north: number; east: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleObstaclesResponse> =>
|
||||
apiClient
|
||||
.get('/paddle/obstacles', {
|
||||
params: { bbox: [box.south, box.west, box.north, box.east].map((v) => v.toFixed(5)).join(',') },
|
||||
signal,
|
||||
})
|
||||
.then((r) => paddleObstaclesResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
|
||||
export const paddleRegionLoadApi = {
|
||||
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
|
||||
apiClient
|
||||
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionSchema,
|
||||
paddleRegionSettingsSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegionRequest,
|
||||
type PaddleRegionSettings,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionCatalogResponseSchema.parse(r.data).regions),
|
||||
add: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions', { id } satisfies PaddleRegionRequest, GEOFABRIK)
|
||||
.then((r) => paddleRegionSchema.parse(r.data)),
|
||||
refresh: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/refresh', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => paddleRegionSchema.parse(r.data)),
|
||||
remove: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
decline: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
saveSettings: (settings: PaddleRegionSettings) =>
|
||||
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
||||
export const paddleRouteApi = {
|
||||
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
||||
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TourDetailResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** An import that was planned over in place keeps its recorded track; this brings it back. */
|
||||
export const recordedTrackApi = {
|
||||
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TourActivityAssignRequest, TourDetailResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** ACT-01: the recorded tracks of a tour, assigned and taken off; both answer the tour's detail. */
|
||||
export const tourActivitiesApi = {
|
||||
assign: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourActivityAssignRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}/activities`, body).then((r) => r.data),
|
||||
unassign: (tripId: number | string, placeId: number | string, activityId: number): Promise<TourDetailResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/tours/${placeId}/activities/${activityId}`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
|
||||
export const tourActivityStatsApi = {
|
||||
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
|
||||
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
|
||||
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
|
||||
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
|
||||
function tourBaseMapAdminApi(path: string) {
|
||||
return {
|
||||
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
|
||||
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
|
||||
apiClient.put(path, { enabled }).then((r) => r.data),
|
||||
};
|
||||
}
|
||||
|
||||
/** MAP-01: OpenHikingMap for a hike. */
|
||||
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
|
||||
/** BIK-06: CyclOSM for a bike tour. */
|
||||
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
|
||||
export const tourCopiesApi = {
|
||||
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) fd.append(key, value);
|
||||
return fd;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
|
||||
* first (PAD-02), then imported as the type chosen.
|
||||
*/
|
||||
export const tourGpxApi = {
|
||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
||||
postMultipart(`/trips/${tripId}/tours/import/gpx/preview`, gpxForm(file)),
|
||||
import: (tripId: number | string, file: File, fields?: TourGpxImportFields): Promise<TourImportGpxResponse> =>
|
||||
postMultipart(`/trips/${tripId}/tours/import/gpx`, gpxForm(file, fields)),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* The file name a download's Content-Disposition gives: the UTF-8 `filename*`
|
||||
* first, since it keeps the tour's own script, the plain `filename` after.
|
||||
*/
|
||||
export function attachmentFilename(header: unknown): string | null {
|
||||
if (typeof header !== 'string') return null;
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded.trim());
|
||||
} catch {
|
||||
// A malformed escape falls through to the plain name.
|
||||
}
|
||||
}
|
||||
return /filename="([^"]+)"/i.exec(header)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** PR1-06A: one saved tour as the GPX file the server writes. */
|
||||
export const tourGpxExportApi = {
|
||||
download: async (
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ blob: Blob; filename: string | null }> => {
|
||||
const response = await apiClient.get<Blob>(`/trips/${tripId}/tours/${placeId}/export.gpx`, {
|
||||
responseType: 'blob',
|
||||
signal,
|
||||
});
|
||||
return { blob: response.data, filename: attachmentFilename(response.headers['content-disposition']) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
TourCreateResponse,
|
||||
TourLibraryDetailResponse,
|
||||
TourLibraryQuery,
|
||||
TourLibraryResponse,
|
||||
TourTakeOverRequest,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** LIB-02: the tours of the user's other trips, one's preview, and taking one over into a trip. */
|
||||
export const tourLibraryApi = {
|
||||
list: (query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> =>
|
||||
apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data),
|
||||
detail: (placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> =>
|
||||
apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data),
|
||||
takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { tourDetailResponseSchema, type TourDetailResponse, type TourObstacleReviewsRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-06: what the paddlers decided about the obstacles of a paddle tour; answers the detail with its checklist. */
|
||||
export const tourObstacleReviewsApi = {
|
||||
save: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourObstacleReviewsRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient
|
||||
.put(`/trips/${tripId}/tours/${placeId}/obstacle-reviews`, body)
|
||||
.then((r) => tourDetailResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* Where a tour's packing link lives; a write queued offline replays to the
|
||||
* same path, `{id}` standing for a tour that has not synced yet.
|
||||
*/
|
||||
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
|
||||
return `/trips/${tripId}/tours/${placeId}/packing-link`;
|
||||
}
|
||||
|
||||
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
|
||||
export const tourPackingLinkApi = {
|
||||
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
|
||||
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
|
||||
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
|
||||
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
|
||||
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
|
||||
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TourDetailResponse, TourPaddlePlanRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-03/PAD-07: the portages, locks and speeds laid onto a recorded paddle track. */
|
||||
export const tourPaddlePlanApi = {
|
||||
update: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourPaddlePlanRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TourPoiSearchRequest, TourPoiSearchResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* PLN-02: places along a tour. A search runs several stretches against the map
|
||||
* search on the server, each allowed its own timeout there, so it may take
|
||||
* longer than the shared default.
|
||||
*/
|
||||
const POI_SEARCH_TIMEOUT_MS = 60_000;
|
||||
|
||||
export const tourPoiSearchApi = {
|
||||
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/poi-search`, body, { signal, timeout: POI_SEARCH_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
|
||||
export const tourSkiApi = {
|
||||
classify: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourSkiClassificationRequest
|
||||
): Promise<TourDetailResponse> => apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* BIK-05: the trail grades of the ways a mountain bike tour runs on. The
|
||||
* server asks Overpass in parts, one after another, so a long tour may take
|
||||
* longer than the shared default.
|
||||
*/
|
||||
const TRAIL_GRADE_TIMEOUT_MS = 60_000;
|
||||
|
||||
export const tourTrailGradeApi = {
|
||||
grades: (
|
||||
tripId: number | string,
|
||||
body: TourTrailGradeRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<TourTrailGradeResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
|
||||
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
|
||||
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
|
||||
}
|
||||
|
||||
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
|
||||
export const tourVisitsApi = {
|
||||
list: (tripId: number | string): Promise<TourVisitListResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
|
||||
update: (
|
||||
tripId: number | string,
|
||||
assignmentId: number,
|
||||
stationKey: string,
|
||||
body: TourVisitUpdateRequest
|
||||
): Promise<TourVisitListResponse> =>
|
||||
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* CW-07: a Google Maps walking route read into its stops. The server may
|
||||
* follow a short link a few hops within its own deadline, so the call waits
|
||||
* a little longer than that before it gives up.
|
||||
*/
|
||||
const WALK_IMPORT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export const tourWalkImportApi = {
|
||||
preview: (
|
||||
tripId: number | string,
|
||||
body: WalkImportPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WalkImportPreviewResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
TripJournalCandidatesResponse,
|
||||
TripJournalLinkMemberRequest,
|
||||
TripJournalLinkResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
||||
* and who it is open to. In its own file rather than in the `client.ts` god
|
||||
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
||||
*/
|
||||
export const tripJournalApi = {
|
||||
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
||||
link: (
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
||||
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
/** The journals this person can open; the dialog offers the ones they own. */
|
||||
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
||||
apiClient.get('/journeys').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AdminFeatureToggleResponse,
|
||||
TourSourceCheckResponse,
|
||||
TourSourceHintResponse,
|
||||
WandererConnection,
|
||||
WandererImportPreview,
|
||||
WandererImportRequest,
|
||||
WandererImportResponse,
|
||||
WandererSaveResponse,
|
||||
WandererSearchRequest,
|
||||
WandererSearchResponse,
|
||||
WandererSettingsRequest,
|
||||
WandererTestRequest,
|
||||
WandererTestResponse,
|
||||
WandererWatch,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for a call that makes the server ask wanderer.
|
||||
* A preview reads the trail's index entry, its record and its GPX one after
|
||||
* another, each allowed 15 s on the server, so the shared 8 s would give up
|
||||
* while the server carries on.
|
||||
*/
|
||||
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
|
||||
|
||||
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
|
||||
|
||||
export const wandererApi = {
|
||||
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
|
||||
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
|
||||
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
|
||||
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
||||
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
||||
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
|
||||
/** WAN-02: the optional background watch of the connection, with the open hints. */
|
||||
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
|
||||
setWatch: (enabled: boolean): Promise<WandererWatch> =>
|
||||
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
|
||||
|
||||
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
|
||||
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
|
||||
.then((r) => r.data),
|
||||
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
|
||||
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
|
||||
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
|
||||
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
|
||||
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** The admin's switch for wanderer under the Tours addon. */
|
||||
export const wandererAdminApi = {
|
||||
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
|
||||
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
|
||||
apiClient.put('/admin/tours-wanderer', { enabled }).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { windForecastResponseSchema, type WindForecastResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-08: the wind forecast for one place and one day. */
|
||||
export const windApi = {
|
||||
forecast: (lat: number, lng: number, date: string, signal?: AbortSignal): Promise<WindForecastResponse> =>
|
||||
apiClient
|
||||
.get('/weather/wind', { params: { lat, lng, date }, signal })
|
||||
.then((r) => windForecastResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||
'tours:changed',
|
||||
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
|
||||
'tours:visits',
|
||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
@@ -93,6 +95,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// files it moved arrive as file:* events.
|
||||
'docsync:changed',
|
||||
// Linked journal: components/Trips/useTripJournalLink asks again which journal
|
||||
// the trip shows and who it is open to. Content-free, because that answer is
|
||||
// per person (a member left off the link must not learn its title).
|
||||
'trip:journal:changed',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
|
||||
async function unfoldShelves(): Promise<void> {
|
||||
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
for (const bar of bars) await userEvent.click(bar);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -72,7 +77,8 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -228,6 +234,7 @@ describe('AddonManager', () => {
|
||||
)
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await unfoldShelves();
|
||||
|
||||
// Provider sub-rows are visible under Journey addon
|
||||
await screen.findByText('Unsplash');
|
||||
@@ -293,6 +300,7 @@ describe('AddonManager', () => {
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
@@ -427,6 +435,8 @@ describe('AddonManager', () => {
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Paperless-ngx');
|
||||
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
|
||||
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
|
||||
@@ -524,6 +534,8 @@ describe('AddonManager', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Chat');
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Polls')).toBeInTheDocument();
|
||||
@@ -551,8 +563,9 @@ describe('AddonManager', () => {
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
await unfoldShelves();
|
||||
|
||||
const links = await screen.findByText('Links');
|
||||
expect(links).toHaveAttribute('title');
|
||||
@@ -646,6 +659,8 @@ describe('AddonManager', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
|
||||
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
|
||||
// A gateway can stand in for Anthropic too, so the field stays (#2655).
|
||||
expect(screen.getByPlaceholderText('https://api.anthropic.com')).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -837,4 +852,89 @@ describe('AddonManager', () => {
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
|
||||
expect(bar).toHaveTextContent('2 on');
|
||||
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(bar);
|
||||
expect(bar).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('Chat')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(bar);
|
||||
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
|
||||
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
|
||||
const puts: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
http.put('/api/admin/tours-wanderer', async ({ request }) => {
|
||||
puts.push(await request.json());
|
||||
return HttpResponse.json({ enabled: true });
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<AddonManager />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
|
||||
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<AddonManager />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
|
||||
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
|
||||
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
|
||||
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -14,16 +14,20 @@ import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import { SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY } from '../Settings/settingsKit'
|
||||
import { type Addon, type CollabFeatures, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, getAddonLabel, MASKED, RECOMMENDED_MODELS } from './addons/addonModel'
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
Mountain,
|
||||
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
|
||||
Route: CarFront,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
@@ -71,6 +75,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
|
||||
if (loading) {
|
||||
@@ -147,6 +154,29 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
<>
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
@@ -265,7 +295,7 @@ const SMALL_BUTTON = 'inline-flex flex-none items-center rounded-[9px] px-2.5 py
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, apiKey, setApiKey, vision, setVision, saving,
|
||||
provider, setProvider, model, setModel, baseUrl, setBaseUrl, baseUrlPlaceholder, apiKey, setApiKey, vision, setVision, saving,
|
||||
installed, modelsErr, loadingModels, pulling, pullPct, pullStatus, isInstalled, loadModels, pull, save,
|
||||
} = useLlmParsingConfig(addon)
|
||||
const fieldId = useId()
|
||||
@@ -288,15 +318,13 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<EditorField label="Provider">
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</EditorField>
|
||||
{provider !== 'anthropic' && (
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</EditorField>
|
||||
)}
|
||||
<EditorField label="Base URL" htmlFor={`${fieldId}-url`}>
|
||||
<input id={`${fieldId}-url`} type="url" autoComplete="off" className={INPUT} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={baseUrlPlaceholder} />
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label="API key"
|
||||
htmlFor={`${fieldId}-key`}
|
||||
hint={provider === 'anthropic' ? 'Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.' : undefined}
|
||||
hint={provider === 'anthropic' ? ANTHROPIC_HINT : undefined}
|
||||
>
|
||||
<input id={`${fieldId}-key`} type="password" className={INPUT} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</EditorField>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
|
||||
function enabledRows(rows: ReactNode[]): number {
|
||||
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
|
||||
}
|
||||
|
||||
/**
|
||||
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
|
||||
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell'
|
||||
*
|
||||
* The row is not a click target, only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*
|
||||
* A shelf of more than one row starts folded behind a bar that says how many
|
||||
* options there are and how many are on, so a page of addons stays short and
|
||||
* the options are still plain to see.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
icon,
|
||||
@@ -31,6 +42,13 @@ export default function AddonTile({
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const shelfId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const rows = Children.toArray(children)
|
||||
const foldable = rows.length > 1
|
||||
const active = enabledRows(rows)
|
||||
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -62,11 +80,40 @@ export default function AddonTile({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
{foldable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-controls={shelfId}
|
||||
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
|
||||
>
|
||||
{/* The same slot and type as a row below, so the bar reads as one more row. */}
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<ChevronDown
|
||||
size={15}
|
||||
strokeWidth={2.2}
|
||||
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.subOptions', { count: rows.length })}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
|
||||
style={fs(10.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.subOptionsOn', { count: active })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it: no rail, no indent guides. */}
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
{(!foldable || open) && (
|
||||
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -64,6 +64,19 @@ export function getAddonLabel(t: (key: string) => string, addon: Addon): { name:
|
||||
export const MASKED = '••••••••';
|
||||
export const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1';
|
||||
|
||||
/** What an empty Base URL stands for with each provider, shown as its placeholder. */
|
||||
export const LLM_BASE_URL_PLACEHOLDER: Record<string, string> = {
|
||||
local: DEFAULT_OLLAMA_URL,
|
||||
openai: 'https://api.openai.com/v1',
|
||||
// The Anthropic client appends /v1/messages itself, so a gateway is given without /v1.
|
||||
anthropic: 'https://api.anthropic.com',
|
||||
};
|
||||
|
||||
/** Under the Anthropic key: what only Anthropic can read, and what a gateway in its place needs (#2655). */
|
||||
export const ANTHROPIC_HINT =
|
||||
'Anthropic reads PDFs (including scans) natively. Local and OpenAI models receive extracted text, so scanned PDFs need Anthropic. ' +
|
||||
'A gateway in place of api.anthropic.com goes in Base URL without /v1 and has to accept PDF document blocks and forced tool calls.';
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
|
||||
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import type { Addon } from './addonModel';
|
||||
import { useAddonManager } from './useAddonManager';
|
||||
|
||||
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006
|
||||
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
@@ -35,6 +37,9 @@ beforeEach(() => {
|
||||
useAddonStore.setState({ loadAddons });
|
||||
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
|
||||
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
|
||||
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
|
||||
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -46,6 +51,13 @@ async function loaded() {
|
||||
|
||||
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
|
||||
|
||||
/** One of the map switches under Tours, by its key. */
|
||||
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
|
||||
const found = manager.mapSwitches.find((entry) => entry.key === key);
|
||||
if (!found) throw new Error(`no ${key} map switch`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('useAddonManager', () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
||||
const { result, rerender } = await loaded();
|
||||
@@ -115,4 +127,33 @@ describe('useAddonManager', () => {
|
||||
await act(() => result.current.documentProviderOptions[0].toggle());
|
||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
|
||||
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
|
||||
await act(() => mapSwitch(result.current, 'hiking').toggle());
|
||||
expect(update).toHaveBeenCalledWith(false);
|
||||
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
|
||||
expect(loadAddons).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
|
||||
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
|
||||
await act(() => mapSwitch(result.current, 'cycle').toggle());
|
||||
expect(update).toHaveBeenCalledWith(false);
|
||||
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
|
||||
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
|
||||
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
|
||||
const { result } = await loaded();
|
||||
await act(() => result.current.toggleToursWanderer());
|
||||
expect(result.current.toursWanderer).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Bike, Footprints } from 'lucide-react';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
@@ -18,6 +21,9 @@ export function useAddonManager() {
|
||||
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||
|
||||
const loadAddons = async () => {
|
||||
try {
|
||||
@@ -30,6 +36,18 @@ export function useAddonManager() {
|
||||
|
||||
const loadOnMount = useEffectEvent(() => {
|
||||
void loadAddons().finally(() => setLoading(false));
|
||||
wandererAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursWanderer(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
hikingMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursHikingMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
cycleMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursCycleMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOnMount();
|
||||
@@ -57,6 +75,45 @@ export function useAddonManager() {
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
|
||||
const flipToursSwitch = async (
|
||||
current: boolean,
|
||||
set: (enabled: boolean) => void,
|
||||
save: (enabled: boolean) => Promise<unknown>
|
||||
) => {
|
||||
const next = !current;
|
||||
set(next);
|
||||
try {
|
||||
await save(next);
|
||||
} catch {
|
||||
set(!next);
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
return;
|
||||
}
|
||||
void refreshGlobalAddons();
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
|
||||
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
|
||||
const mapSwitches = [
|
||||
{
|
||||
key: 'hiking',
|
||||
Icon: Footprints,
|
||||
titleKey: 'admin.toursHikingMap.title',
|
||||
subtitleKey: 'admin.toursHikingMap.subtitle',
|
||||
enabled: toursHikingMap,
|
||||
toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update),
|
||||
},
|
||||
{
|
||||
key: 'cycle',
|
||||
Icon: Bike,
|
||||
titleKey: 'admin.toursCycleMap.title',
|
||||
subtitleKey: 'admin.toursCycleMap.subtitle',
|
||||
enabled: toursCycleMap,
|
||||
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const asOption = (provider: Addon): ProviderOption => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
@@ -83,5 +140,8 @@ export function useAddonManager() {
|
||||
integrationAddons,
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,15 +52,19 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(result.current.isInstalled('llama3')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing until switched to local, then uses the typed URL', async () => {
|
||||
it('FE-HOOK-LLMCFG-003: a cloud provider lists nothing; switched to local it starts from the default, then uses the typed URL', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'openai', baseUrl: ' http://box:11434/v1 ' }))
|
||||
);
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
await act(() => result.current.loadModels());
|
||||
expect(adminApi.llmLocalModels).not.toHaveBeenCalled();
|
||||
// The OpenAI address does not follow to another provider (#2655).
|
||||
act(() => result.current.setProvider('local'));
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith('http://box:11434/v1'));
|
||||
await waitFor(() => expect(adminApi.llmLocalModels).toHaveBeenCalledWith(DEFAULT_OLLAMA_URL));
|
||||
act(() => result.current.setBaseUrl(' http://box:11434/v1 '));
|
||||
await act(() => result.current.loadModels());
|
||||
expect(adminApi.llmLocalModels).toHaveBeenLastCalledWith('http://box:11434/v1');
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-004: an unreachable server shows its message and empties the list', async () => {
|
||||
@@ -148,7 +152,7 @@ describe('useLlmParsingConfig', () => {
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009: Anthropic is saved with an empty base URL, whatever was typed before', async () => {
|
||||
it("FE-HOOK-LLMCFG-009: switching to Anthropic drops the previous provider's base URL", async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai', baseUrl: 'https://o/v1' })));
|
||||
act(() => {
|
||||
result.current.setProvider('anthropic');
|
||||
@@ -160,6 +164,50 @@ describe('useLlmParsingConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009b: an Anthropic gateway URL survives a save (#2655)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', model: 'gateway-model', baseUrl: 'http://gateway.lan:4000' }))
|
||||
);
|
||||
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({
|
||||
provider: 'anthropic',
|
||||
baseUrl: 'http://gateway.lan:4000',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009c: a URL typed after picking Anthropic is saved, trimmed', async () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
act(() => result.current.setBaseUrl(' http://gw:4000 '));
|
||||
await act(() => result.current.save());
|
||||
expect(vi.mocked(adminApi.updateAddon).mock.calls[0][1].config).toMatchObject({ baseUrl: 'http://gw:4000' });
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009d: away and back again brings the saved URL back; staying put keeps a typed one', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLlmParsingConfig(llm({ provider: 'anthropic', baseUrl: 'http://gateway.lan:4000' }))
|
||||
);
|
||||
act(() => result.current.setProvider('local'));
|
||||
expect(result.current.baseUrl).toBe('');
|
||||
act(() => result.current.setProvider('openai'));
|
||||
expect(result.current.baseUrl).toBe('');
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
expect(result.current.baseUrl).toBe('http://gateway.lan:4000');
|
||||
act(() => result.current.setBaseUrl('http://typed:4000'));
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
expect(result.current.baseUrl).toBe('http://typed:4000');
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-009e: the placeholder names the address an empty field stands for', () => {
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'local' })));
|
||||
expect(result.current.baseUrlPlaceholder).toBe('http://localhost:11434/v1');
|
||||
act(() => result.current.setProvider('openai'));
|
||||
expect(result.current.baseUrlPlaceholder).toBe('https://api.openai.com/v1');
|
||||
act(() => result.current.setProvider('anthropic'));
|
||||
expect(result.current.baseUrlPlaceholder).toBe('https://api.anthropic.com');
|
||||
});
|
||||
|
||||
it('FE-HOOK-LLMCFG-010: a failed save toasts and clears saving', async () => {
|
||||
vi.mocked(adminApi.updateAddon).mockRejectedValue(new Error('x'));
|
||||
const { result } = renderHook(() => useLlmParsingConfig(llm({ provider: 'openai' })));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
|
||||
import { type Addon, DEFAULT_OLLAMA_URL, LLM_BASE_URL_PLACEHOLDER } from './addonModel';
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config behind both admin shells. When set, it applies to the
|
||||
@@ -15,7 +15,7 @@ import { type Addon, DEFAULT_OLLAMA_URL } from './addonModel';
|
||||
export function useLlmParsingConfig(addon: Addon) {
|
||||
const toast = useToast();
|
||||
const cfg = addon.config ?? {};
|
||||
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [provider, setProviderState] = useState<string>((cfg.provider as string) ?? 'local');
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '');
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '');
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '');
|
||||
@@ -30,6 +30,18 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
const [pullPct, setPullPct] = useState(0);
|
||||
const [pullStatus, setPullStatus] = useState('');
|
||||
|
||||
/**
|
||||
* A different provider never inherits the last one's endpoint: an Ollama address
|
||||
* left in the field would quietly receive the OpenAI or Anthropic key. Back on the
|
||||
* saved provider, its saved address returns. Any provider, Anthropic included, may
|
||||
* keep an address the admin typed for it (#2655); empty means the provider default.
|
||||
*/
|
||||
const setProvider = (next: string) => {
|
||||
if (next !== provider) setBaseUrl(next === cfg.provider ? ((cfg.baseUrl as string) ?? '') : '');
|
||||
setProviderState(next);
|
||||
};
|
||||
const baseUrlPlaceholder = LLM_BASE_URL_PLACEHOLDER[provider] ?? LLM_BASE_URL_PLACEHOLDER.openai;
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL;
|
||||
const isInstalled = (id: string) => installed.some((n) => n === id || n.startsWith(id + ':') || n.startsWith(id));
|
||||
|
||||
@@ -89,7 +101,7 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
config: {
|
||||
provider,
|
||||
model: model.trim(),
|
||||
baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey,
|
||||
vision,
|
||||
},
|
||||
@@ -109,6 +121,7 @@ export function useLlmParsingConfig(addon: Addon) {
|
||||
setModel,
|
||||
baseUrl,
|
||||
setBaseUrl,
|
||||
baseUrlPlaceholder,
|
||||
apiKey,
|
||||
setApiKey,
|
||||
vision,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
|
||||
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
function useMegabytes() {
|
||||
const { language } = useTranslation();
|
||||
return (mb: number) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
|
||||
* mode, and in the automatic one the largest region it loads without asking.
|
||||
*/
|
||||
export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||
? SIZES
|
||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||
return (
|
||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<Segmented<PaddleRegionAutoMode>
|
||||
value={settings.mode}
|
||||
onChange={(mode) => void save({ mode })}
|
||||
label={t('admin.paddleRegions.auto.title')}
|
||||
fill
|
||||
options={MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
return {
|
||||
value: mode,
|
||||
label: t(`admin.paddleRegions.auto.mode.${mode}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
|
||||
</div>
|
||||
{settings.mode === 'auto' && (
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
||||
<ChoiceChips<string>
|
||||
value={String(settings.max_mb)}
|
||||
label={t('admin.paddleRegions.auto.maxSize')}
|
||||
onChange={(value) => void save({ max_mb: Number(value) })}
|
||||
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const busy = admin.busyId === request.id;
|
||||
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-request"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{request.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{request.declined ? (
|
||||
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
|
||||
{t('admin.paddleRegions.requests.declined')}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
|
||||
)}
|
||||
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
|
||||
{request.requested_by && (
|
||||
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
|
||||
)}
|
||||
<StatusPill>{date}</StatusPill>
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
{!request.declined && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.decline(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.paddleRegions.requests.decline')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.approve(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
||||
{t('admin.paddleRegions.requests.approve')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The regions paddlers asked for that wait for an admin, the open ones first. */
|
||||
export function RequestList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.requests.length === 0) return null;
|
||||
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
||||
const open = admin.requests.filter((request) => !request.declined).length;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
|
||||
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
|
||||
{t('admin.paddleRegions.requests.title')}
|
||||
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
|
||||
</span>
|
||||
<SettingRows>
|
||||
{ordered.map((request) => (
|
||||
<RequestRow key={request.id} request={request} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
|
||||
export function AutoOriginPill() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
|
||||
<span className="inline-flex" data-testid="paddle-region-auto">
|
||||
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
|
||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import PaddleRegionsPanel from './PaddleRegionsPanel';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() }));
|
||||
vi.mock('./usePaddleRegionsAdmin', () => ({ usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin }));
|
||||
|
||||
const HINT = 'Weirs, locks and paddling rules from OpenStreetMap, read per region and again every month.';
|
||||
const ATTRIBUTION =
|
||||
'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.';
|
||||
const EMPTY = 'No region yet. Paddle tours then show that no obstacle data is known.';
|
||||
const CATALOG_DOWN = 'Geofabrik cannot be reached right now. Try again later.';
|
||||
|
||||
/** A region read once, its data current to the 3rd of October (midday, so every time zone keeps the day). */
|
||||
function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
return {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-10-03T12:00:00Z',
|
||||
imported_at: '2026-10-04T08:00:00Z',
|
||||
feature_count: 1234,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A region before its first read: no data and no date yet. */
|
||||
const unread = (overrides: Partial<PaddleRegion>): PaddleRegion =>
|
||||
region({ source_timestamp: null, imported_at: null, feature_count: 0, ...overrides });
|
||||
|
||||
const READY = region();
|
||||
const WAITING = unread({ id: 'donau', name: 'Donau', status: 'queued' });
|
||||
const FAILED_AGAIN = region({ id: 'saale', name: 'Saale', status: 'failed', error: 'Download broke off.' });
|
||||
const FAILED_FIRST = unread({ id: 'mosel', name: 'Mosel', status: 'failed', error: 'Not a PBF file.' });
|
||||
|
||||
/** Geofabrik's answers for "Limburg": the plain names told apart by the regions they lie in. */
|
||||
const HITS: PaddleRegionCatalogEntry[] = [
|
||||
{ id: 'germany/hessen/limburg-weilburg', name: 'Limburg-Weilburg', path: ['Europe', 'Germany', 'Hessen'] },
|
||||
{ id: 'netherlands/limburg', name: 'Limburg', path: ['Europe', 'Netherlands'] },
|
||||
{ id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] },
|
||||
{ id: 'limburg', name: 'Limburg', path: [] },
|
||||
];
|
||||
|
||||
function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}): Admin {
|
||||
return {
|
||||
toursOn: true,
|
||||
offline: false,
|
||||
regions: [],
|
||||
requests: [],
|
||||
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
added: new Set<string>(),
|
||||
busyId: null,
|
||||
add: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
approve: vi.fn(async () => {}),
|
||||
decline: vi.fn(async () => {}),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
confirmRemove: vi.fn(),
|
||||
...overrides,
|
||||
search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search },
|
||||
};
|
||||
}
|
||||
|
||||
function show(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}) {
|
||||
const state = adminState(overrides, search);
|
||||
hook.usePaddleRegionsAdmin.mockReturnValue(state);
|
||||
return { state, ...render(<PaddleRegionsPanel />) };
|
||||
}
|
||||
|
||||
/** The card's title row, where the number of regions sits beside the title. */
|
||||
function titleRow(): HTMLElement {
|
||||
const row = screen.getByRole('heading', { name: 'Paddle water data' }).parentElement;
|
||||
if (!row) throw new Error('no title row');
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowOf(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest<HTMLElement>('[data-testid="paddle-region-row"]');
|
||||
if (!row) throw new Error(`no row for ${name}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe('PaddleRegionsPanel', () => {
|
||||
it('FE-ADMIN-PADDLEREG-001: renders nothing while the Tours addon is off', () => {
|
||||
const { container } = show({ toursOn: false, regions: [READY] });
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-002: shows the card with its hint, the attribution and the empty state', () => {
|
||||
show();
|
||||
expect(screen.getByRole('heading', { name: 'Paddle water data' })).toBeInTheDocument();
|
||||
expect(screen.getByText(HINT)).toBeInTheDocument();
|
||||
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||
expect(screen.getByText(ATTRIBUTION)).toBeInTheDocument();
|
||||
// No count beside the title while there is no region.
|
||||
expect(titleRow()).toHaveTextContent(/^Paddle water data$/);
|
||||
const search = screen.getByRole('searchbox', { name: 'Add a region' });
|
||||
expect(search).toBeEnabled();
|
||||
expect(search).toHaveAttribute('placeholder', 'Search Geofabrik, for example Oberpfalz');
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-region-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-003: says the list is loading instead of calling it empty', () => {
|
||||
show({ loading: true });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
|
||||
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-004: offline it says why and locks the search and every row, without the list error', () => {
|
||||
show({ offline: true, error: 'Network Error', regions: [READY] });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Regions can only be managed with a connection.');
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Error')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('searchbox', { name: 'Add a region' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Read again Oberpfalz' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Remove region Oberpfalz' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-005: a failed list read shows its error with a retry that asks the hook again', () => {
|
||||
const { state } = show({ error: 'Server unreachable' });
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveTextContent('Server unreachable');
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Try again' }));
|
||||
expect(state.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-006: the search shows the query and hands every change to the hook', () => {
|
||||
const { state } = show({}, { query: 'Lim' });
|
||||
const search = screen.getByRole('searchbox', { name: 'Add a region' });
|
||||
expect(search).toHaveValue('Lim');
|
||||
fireEvent.change(search, { target: { value: 'Limb' } });
|
||||
expect(state.search.setQuery).toHaveBeenCalledWith('Limb');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-007: while Geofabrik is searched it says so and shows no verdict yet', () => {
|
||||
show({}, { query: 'Limburg', searching: true });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
|
||||
expect(screen.queryByText('No region by that name')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-008: a failed search shows its error under the field, without the list retry', () => {
|
||||
show({}, { query: 'Limburg', error: CATALOG_DOWN });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(CATALOG_DOWN);
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-009: a search without hits says no region has that name', () => {
|
||||
show({}, { query: 'Atlantis', hits: [] });
|
||||
expect(screen.getByText('No region by that name')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-010: hits name the regions they lie in, outermost first, and mark the ones added', () => {
|
||||
show({ added: new Set(['germany/hessen/limburg-weilburg']) }, { query: 'Limburg', hits: HITS });
|
||||
const [weilburg, dutch, belgian, bare] = screen.getAllByTestId('paddle-region-hit');
|
||||
const path = within(weilburg).getAllByText(/^(Europe|Germany|Hessen)$/);
|
||||
expect(path.map((part) => part.textContent)).toEqual(['Europe', 'Germany', 'Hessen']);
|
||||
expect(within(weilburg).getByText('Added')).toBeInTheDocument();
|
||||
expect(within(weilburg).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
|
||||
expect(within(dutch).getByText('Netherlands')).toBeInTheDocument();
|
||||
expect(within(dutch).getByRole('button', { name: 'Add' })).toBeEnabled();
|
||||
expect(within(belgian).getByText('Belgium')).toBeInTheDocument();
|
||||
// A region at the top of Geofabrik's tree has nothing above it to name.
|
||||
expect(within(bare).getByText('Limburg').nextElementSibling).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-011: adding a hit hands its Geofabrik id to the hook', () => {
|
||||
const { state } = show({}, { query: 'Limburg', hits: HITS });
|
||||
const dutch = screen.getAllByTestId('paddle-region-hit')[1];
|
||||
fireEvent.click(within(dutch).getByRole('button', { name: 'Add' }));
|
||||
expect(state.add).toHaveBeenCalledWith('netherlands/limburg');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-012: while one region is added every add waits, and the one under way spins', () => {
|
||||
show({ busyId: 'netherlands/limburg' }, { query: 'Limburg', hits: HITS });
|
||||
const [, dutch, belgian] = screen.getAllByTestId('paddle-region-hit');
|
||||
const adding = within(dutch).getByRole('button', { name: 'Add' });
|
||||
const waiting = within(belgian).getByRole('button', { name: 'Add' });
|
||||
expect(adding).toBeDisabled();
|
||||
expect(waiting).toBeDisabled();
|
||||
expect(adding.querySelector('.animate-spin')).not.toBeNull();
|
||||
expect(waiting.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-013: a waiting region reads Waiting, has no data yet and cannot be read again', () => {
|
||||
show({ regions: [WAITING] });
|
||||
const row = rowOf('Donau');
|
||||
expect(within(row).getByText('Waiting')).toHaveClass('border-edge-faint');
|
||||
expect(within(row).queryByText(/objects?$/)).not.toBeInTheDocument();
|
||||
expect(within(row).queryByText(/^As of/)).not.toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Read again Donau' })).toBeDisabled();
|
||||
expect(within(row).getByRole('button', { name: 'Remove region Donau' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-014: a region being read shows its phase and share, the phase alone while the size is unknown', () => {
|
||||
show({
|
||||
regions: [
|
||||
unread({ id: 'elbe', name: 'Elbe', status: 'importing', progress: { phase: 'download', fraction: 0.416 } }),
|
||||
unread({ id: 'main', name: 'Main', status: 'importing', progress: { phase: 'read', fraction: 0 } }),
|
||||
unread({ id: 'isar', name: 'Isar', status: 'importing', progress: { phase: 'download', fraction: null } }),
|
||||
unread({ id: 'naab', name: 'Naab', status: 'importing' }),
|
||||
],
|
||||
});
|
||||
const elbe = rowOf('Elbe');
|
||||
expect(within(elbe).getByText('Downloading 42%')).toHaveClass('bg-accent');
|
||||
expect(within(elbe).getByRole('button', { name: 'Read again Elbe' })).toBeDisabled();
|
||||
expect(within(elbe).getByRole('button', { name: 'Remove region Elbe' })).toBeEnabled();
|
||||
// A read just begun says 0 %, not nothing.
|
||||
expect(within(rowOf('Main')).getByText('Reading 0%')).toBeInTheDocument();
|
||||
expect(within(rowOf('Isar')).getByText('Downloading')).toBeInTheDocument();
|
||||
expect(within(rowOf('Naab')).getByText('Reading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-015: a ready region shows its object count and the date its data is current to', () => {
|
||||
show({ regions: [READY, region({ id: 'regen', name: 'Regen', feature_count: 1, source_timestamp: null })] });
|
||||
const row = rowOf('Oberpfalz');
|
||||
expect(within(row).getByText('Ready')).toHaveClass('bg-success-soft');
|
||||
expect(within(row).getByText('1234 objects')).toBeInTheDocument();
|
||||
expect(within(row).getByText('As of Oct 3, 2026')).toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Read again Oberpfalz' })).toBeEnabled();
|
||||
// One object reads singular; without a source date there is no date to show.
|
||||
const regen = rowOf('Regen');
|
||||
expect(within(regen).getByText('1 object')).toBeInTheDocument();
|
||||
expect(within(regen).queryByText(/^As of/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-016: a failed read shows its error, and that the earlier data stays in use when there is some', () => {
|
||||
show({ regions: [FAILED_AGAIN, FAILED_FIRST] });
|
||||
const again = rowOf('Saale');
|
||||
expect(within(again).getByText('Failed')).toHaveClass('bg-danger-soft');
|
||||
const error = within(again).getByText('Download broke off. The data read before stays in use.');
|
||||
expect(error).toHaveAttribute('title', 'Download broke off.');
|
||||
const first = rowOf('Mosel');
|
||||
expect(within(first).getByText('Not a PBF file.')).toBeInTheDocument();
|
||||
expect(within(first).queryByText(/stays in use/)).not.toBeInTheDocument();
|
||||
expect(within(first).queryByText(/objects?$/)).not.toBeInTheDocument();
|
||||
expect(within(first).getByRole('button', { name: 'Read again Mosel' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-017: Read again and Remove hand the region to the hook', () => {
|
||||
const { state } = show({ regions: [READY] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Read again Oberpfalz' }));
|
||||
expect(state.refresh).toHaveBeenCalledWith('oberpfalz');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove region Oberpfalz' }));
|
||||
expect(state.askRemove).toHaveBeenCalledWith(READY);
|
||||
// The question opens only once the hook holds the region to remove.
|
||||
expect(screen.queryByRole('heading', { name: 'Remove region' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-018: while one region is busy its row dims and no row can be read again or removed', () => {
|
||||
show({ regions: [READY, FAILED_AGAIN], busyId: 'oberpfalz' });
|
||||
expect(rowOf('Oberpfalz')).toHaveClass('opacity-60');
|
||||
expect(rowOf('Saale')).not.toHaveClass('opacity-60');
|
||||
for (const name of ['Oberpfalz', 'Saale']) {
|
||||
expect(screen.getByRole('button', { name: `Read again ${name}` })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: `Remove region ${name}` })).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-019: the title carries the number of regions', () => {
|
||||
show({ regions: [READY, WAITING, FAILED_FIRST] });
|
||||
expect(within(titleRow()).getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('paddle-region-row')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-020: removing asks first, naming the region, and confirming hands it to the hook', () => {
|
||||
const { state } = show({ regions: [READY], removing: READY });
|
||||
const message = 'Oberpfalz and its obstacle data are removed. Paddle tours there then show no data.';
|
||||
expect(screen.getByRole('heading', { name: 'Remove region' })).toBeInTheDocument();
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
expect(state.confirmRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-021: Cancel and Escape take the question back without removing', () => {
|
||||
const { state } = show({ regions: [READY], removing: READY });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(state.cancelRemove).toHaveBeenCalledTimes(2);
|
||||
expect(state.confirmRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsPanel water network (PAD-11)', () => {
|
||||
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
|
||||
const network = {
|
||||
water_km: 1364.5,
|
||||
lakes: 266,
|
||||
portages: 14,
|
||||
inferred_portages: 232,
|
||||
gaps: 63,
|
||||
locks: 17,
|
||||
built_at: '2026-10-17T15:30:20Z',
|
||||
bytes: 1496816,
|
||||
};
|
||||
show({ regions: [region({ network })] });
|
||||
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
|
||||
expect(pill).toHaveTextContent('1,365 km of water');
|
||||
fireEvent.mouseEnter(pill);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
|
||||
show({
|
||||
regions: [
|
||||
region({ id: 'old', name: 'Old read', network: null }),
|
||||
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
|
||||
],
|
||||
});
|
||||
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
|
||||
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
|
||||
const save = vi.fn(async () => {});
|
||||
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
|
||||
const auto = screen.getByTestId('paddle-auto-load');
|
||||
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
|
||||
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
|
||||
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
||||
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
|
||||
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
|
||||
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
|
||||
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
|
||||
const approve = vi.fn(async () => {});
|
||||
const decline = vi.fn(async () => {});
|
||||
const base = {
|
||||
bytes: 734_003_200,
|
||||
requested_by: 'elmo',
|
||||
requested_at: '2026-10-18T09:00:00Z',
|
||||
};
|
||||
show({
|
||||
approve,
|
||||
decline,
|
||||
requests: [
|
||||
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
|
||||
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
|
||||
],
|
||||
});
|
||||
const rows = screen.getAllByTestId('paddle-region-request');
|
||||
expect(rows[0]).toHaveTextContent('Brandenburg');
|
||||
expect(rows[0]).toHaveTextContent('Daily limit');
|
||||
expect(rows[0]).toHaveTextContent('by elmo');
|
||||
expect(rows[1]).toHaveTextContent('Declined');
|
||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
|
||||
expect(decline).toHaveBeenCalledWith('brandenburg');
|
||||
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
|
||||
expect(approve).toHaveBeenCalledWith('norway');
|
||||
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
|
||||
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
|
||||
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
|
||||
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddleRegionsModel', () => {
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
||||
expect(isReading({ status: 'queued' })).toBe(true);
|
||||
expect(isReading({ status: 'importing' })).toBe(true);
|
||||
expect(isReading({ status: 'ready' })).toBe(false);
|
||||
expect(isReading({ status: 'failed' })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-002: the share of a read is a whole percent, null while its size is unknown', () => {
|
||||
expect(progressPercent({ progress: null })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'download', fraction: 0 } })).toBe(0);
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 0.416 } })).toBe(42);
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-003: the badge tone follows the state of the read, never the water', () => {
|
||||
expect(regionStatusTone('queued')).toBe('neutral');
|
||||
expect(regionStatusTone('importing')).toBe('accent');
|
||||
expect(regionStatusTone('ready')).toBe('success');
|
||||
expect(regionStatusTone('failed')).toBe('danger');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import {
|
||||
SettingRows,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingsCard,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
|
||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
/**
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||
* Both the desktop and the phone admin show this one card under the addons,
|
||||
* while the Tours addon is on.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
<ErrorBoundary boundaryId="paddle-regions">
|
||||
<Panel />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
const { t } = useTranslation();
|
||||
const admin = usePaddleRegionsAdmin();
|
||||
if (!admin.toursOn) return null;
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Waves}
|
||||
title={t('admin.paddleRegions.title')}
|
||||
hint={t('admin.paddleRegions.hint')}
|
||||
badge={admin.regions.length > 0 ? <StatusPill>{admin.regions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{admin.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<WifiOff size={14} className="flex-none" />
|
||||
{t('admin.paddleRegions.offline')}
|
||||
</p>
|
||||
)}
|
||||
{admin.error && !admin.offline && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
|
||||
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
{t('admin.paddleRegions.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||
<AutoLoadSettings admin={admin} />
|
||||
<RequestList admin={admin} />
|
||||
<CatalogSearch admin={admin} />
|
||||
<RegionList admin={admin} />
|
||||
</fieldset>
|
||||
<SettingsHint>{t('admin.paddleRegions.attribution')}</SettingsHint>
|
||||
<ConfirmDialog
|
||||
isOpen={admin.removing !== null}
|
||||
onClose={admin.cancelRemove}
|
||||
onConfirm={admin.confirmRemove}
|
||||
title={t('admin.paddleRegions.remove')}
|
||||
message={t('admin.paddleRegions.removeMessage', { name: admin.removing?.name ?? '' })}
|
||||
confirmLabel={t('common.remove')}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A Geofabrik region with the regions it lies in, outermost first, so two of one name tell apart. */
|
||||
function RegionPath({ hit }: { hit: PaddleRegionCatalogEntry }) {
|
||||
if (hit.path.length === 0) return null;
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-0.5 text-content-faint" style={fs(11.5)}>
|
||||
{hit.path.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="inline-flex items-center gap-0.5">
|
||||
{index > 0 && <ChevronRight size={11} className="flex-none rtl:rotate-180" aria-hidden="true" />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const { search } = admin;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EditorField label={t('admin.paddleRegions.search')} htmlFor={id}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
type="search"
|
||||
className={`${INPUT} ps-9`}
|
||||
value={search.query}
|
||||
onChange={(event) => search.setQuery(event.target.value)}
|
||||
placeholder={t('admin.paddleRegions.searchPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{search.searching && (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={14} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{search.error && (
|
||||
<p role="alert" className="m-0 text-danger" style={fs(12.5, 'body')}>
|
||||
{search.error}
|
||||
</p>
|
||||
)}
|
||||
{search.hits && search.hits.length === 0 && <SettingsHint>{t('admin.paddleRegions.noHits')}</SettingsHint>}
|
||||
{search.hits && search.hits.length > 0 && (
|
||||
<SettingRows>
|
||||
{search.hits.map((hit) => (
|
||||
<div key={hit.id} className="flex items-center gap-3 py-2 pe-2 ps-3.5" data-testid="paddle-region-hit">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{hit.name}
|
||||
</span>
|
||||
<RegionPath hit={hit} />
|
||||
</div>
|
||||
{admin.added.has(hit.id) ? (
|
||||
<StatusPill icon={<Check size={11} strokeWidth={2.4} />}>{t('admin.paddleRegions.added')}</StatusPill>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.add(hit.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{admin.busyId === hit.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
)}
|
||||
{t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.loading) {
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={14} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (admin.regions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center">
|
||||
<SettingsHint>{t('admin.paddleRegions.empty')}</SettingsHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingRows>
|
||||
{admin.regions.map((region) => (
|
||||
<RegionRow key={region.id} region={region} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionStatus({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const percent = progressPercent(region);
|
||||
const label =
|
||||
region.status === 'importing' && region.progress
|
||||
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
|
||||
: t(`admin.paddleRegions.status.${region.status}`);
|
||||
const share =
|
||||
percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`;
|
||||
return (
|
||||
<StatusPill
|
||||
tone={regionStatusTone(region.status)}
|
||||
icon={region.status === 'importing' ? <Loader2 size={11} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{label}
|
||||
{share}
|
||||
</StatusPill>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: the water network the router follows in a region, as one figure
|
||||
* with the rest in its tooltip. A region read before routing existed says it
|
||||
* needs reading again; a network is never claimed where none was built.
|
||||
*/
|
||||
function RegionNetwork({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const { network } = region;
|
||||
if (!network) {
|
||||
if (!region.imported_at || region.status === 'importing') return null;
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
|
||||
const details = [
|
||||
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
|
||||
t('admin.paddleRegions.network.portages', { count: network.portages }),
|
||||
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
|
||||
t('admin.paddleRegions.network.locks', { count: network.locks }),
|
||||
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
|
||||
].join(', ');
|
||||
return (
|
||||
<Tooltip label={details}>
|
||||
<span className="inline-flex" data-testid="paddle-region-network">
|
||||
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
const date = region.source_timestamp
|
||||
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-row"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"
|
||||
>
|
||||
<Waves size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{region.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<RegionStatus region={region} />
|
||||
{region.origin === 'auto' && <AutoOriginPill />}
|
||||
{region.imported_at && (
|
||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||
)}
|
||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||
<RegionNetwork region={region} />
|
||||
</span>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||
{region.error}
|
||||
{region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip label={t('admin.paddleRegions.refresh')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.refresh')} ${region.name}`}
|
||||
disabled={admin.busyId !== null || region.status === 'queued' || region.status === 'importing'}
|
||||
onClick={() => void admin.refresh(region.id)}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('admin.paddleRegions.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.remove')} ${region.name}`}
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => admin.askRemove(region)}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaddleRegion } from '@trek/shared';
|
||||
|
||||
/** A region waiting for its read or being read: the list asks again until none is. */
|
||||
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
|
||||
return region.status === 'queued' || region.status === 'importing';
|
||||
}
|
||||
|
||||
/** The share of a read under way as a whole percent, or null while its size is unknown. */
|
||||
export function progressPercent(region: Pick<PaddleRegion, 'progress'>): number | null {
|
||||
const fraction = region.progress?.fraction;
|
||||
return fraction === null || fraction === undefined ? null : Math.round(fraction * 100);
|
||||
}
|
||||
|
||||
/** The status badge's tone: what state the read is in, never whether the water is safe. */
|
||||
export function regionStatusTone(status: PaddleRegion['status']): 'neutral' | 'accent' | 'success' | 'danger' {
|
||||
if (status === 'importing') return 'accent';
|
||||
if (status === 'ready') return 'success';
|
||||
if (status === 'failed') return 'danger';
|
||||
return 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
|
||||
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||
|
||||
describe('usePaddleAutoLoad', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
|
||||
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
|
||||
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
|
||||
expect(repo.settings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ mode: 'ask' });
|
||||
});
|
||||
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
|
||||
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
|
||||
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ max_mb: 2000 });
|
||||
});
|
||||
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PaddleRegionSettings } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/**
|
||||
* PAD-11C in the admin area: how TREK loads the water data of a place a
|
||||
* paddler plans in, read once the card shows and saved on every change. A
|
||||
* change shows at once and goes back, with a toast, when the server refuses
|
||||
* it. While one is saved the controls rest, so two saves never cross.
|
||||
*/
|
||||
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.settings().then(
|
||||
(loaded) => {
|
||||
if (!cancelled) setSettings(loaded);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, toast]);
|
||||
|
||||
const save = useCallback(
|
||||
async (change: Partial<PaddleRegionSettings>) => {
|
||||
if (!settings || pending.current) return;
|
||||
const before = settings;
|
||||
const next = { ...settings, ...change };
|
||||
pending.current = true;
|
||||
setSaving(true);
|
||||
setSettings(next);
|
||||
try {
|
||||
setSettings(await paddleRegionRepo.saveSettings(next));
|
||||
} catch (err) {
|
||||
setSettings(before);
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[settings, t, toast]
|
||||
);
|
||||
|
||||
return { settings, saving, save };
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* FE-HOOK-PADDLEREGIONS-001 to FE-HOOK-PADDLEREGIONS-014: the paddle regions in
|
||||
* the admin area (PAD-05A), the one hook the desktop and the phone admin render
|
||||
* through PaddleRegionsPanel. The repo is mocked; its error codes and its
|
||||
* offline error stay the real ones, and the strings are the English ones.
|
||||
* FE-PADDLEREGIONSMODEL-001: the model's helpers for the list and its badges.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { PaddleRegionsOfflineError } from '../../../repo/paddleRegionRepo';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { setForcedOffline } from '../../../sync/networkMode';
|
||||
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { CATALOG_SEARCH_DEBOUNCE_MS, PADDLE_REGIONS_POLL_MS, usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
list: vi.fn<() => Promise<PaddleRegion[]>>(),
|
||||
catalog: vi.fn<(q: string, signal?: AbortSignal) => Promise<PaddleRegionCatalogEntry[]>>(),
|
||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
|
||||
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
||||
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const OFFLINE = 'Regions can only be managed with a connection.';
|
||||
|
||||
/** A region as the server lists it. */
|
||||
function region(id: string, status: PaddleRegion['status'], over: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
status,
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A region in Geofabrik's list. */
|
||||
function hit(id: string, name: string, ...path: string[]): PaddleRegionCatalogEntry {
|
||||
return { id, name, path };
|
||||
}
|
||||
|
||||
const READY = region('oberbayern', 'ready', { source_timestamp: '2026-10-01T20:21:00Z', feature_count: 412 });
|
||||
const FAILED = region('niederbayern', 'failed', { error: 'Download failed' });
|
||||
const QUEUED = region('oberpfalz', 'queued');
|
||||
const IMPORTING = region('oberpfalz', 'importing', { progress: { phase: 'read', fraction: 0.4 } });
|
||||
const DONE = region('oberpfalz', 'ready', { source_timestamp: '2026-10-15T20:21:00Z', feature_count: 87 });
|
||||
const HIT = hit('oberpfalz', 'Oberpfalz', 'Europe', 'Germany', 'Bayern');
|
||||
const OBERBAYERN_HIT = hit('oberbayern', 'Oberbayern', 'Europe', 'Germany', 'Bayern');
|
||||
|
||||
/** An axios error as the server answers it: its message and, for some, a code. */
|
||||
function apiError(error: string, code?: string) {
|
||||
return { response: { data: { error, code } } };
|
||||
}
|
||||
|
||||
/** A promise the test settles itself, to look at the hook while a call is out. */
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Lets the clock run on and every answer due by then arrive. */
|
||||
async function settle(ms = 0) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||
|
||||
async function mount() {
|
||||
const hook = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
|
||||
await settle();
|
||||
return hook;
|
||||
}
|
||||
|
||||
function seedTours() {
|
||||
seedStore(useAddonStore, { addons: [{ id: 'tours', enabled: true }] });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetAllStores();
|
||||
seedTours();
|
||||
toast.success.mockReset();
|
||||
toast.error.mockReset();
|
||||
repo.list.mockReset().mockResolvedValue([READY]);
|
||||
repo.catalog.mockReset().mockResolvedValue([HIT]);
|
||||
repo.add.mockReset().mockResolvedValue(QUEUED);
|
||||
repo.refresh.mockReset().mockResolvedValue(READY);
|
||||
repo.remove.mockReset().mockResolvedValue({ success: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => setForcedOffline(false));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('usePaddleRegionsAdmin', () => {
|
||||
it('FE-HOOK-PADDLEREGIONS-001: asks nothing while the Tours addon is off, and loads once it is switched on', async () => {
|
||||
seedStore(useAddonStore, { addons: [] });
|
||||
const { result } = await mount();
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 2);
|
||||
expect(result.current.toursOn).toBe(false);
|
||||
expect(repo.list).not.toHaveBeenCalled();
|
||||
|
||||
act(() => seedTours());
|
||||
await settle();
|
||||
expect(result.current.toursOn).toBe(true);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.regions).toEqual([READY]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-002: loads the list, knows which regions are added and asks no more while none is read', async () => {
|
||||
repo.list.mockResolvedValue([READY, FAILED]);
|
||||
const { result } = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
|
||||
expect(result.current).toMatchObject({ toursOn: true, offline: false, loading: true, regions: [], error: null });
|
||||
|
||||
await settle();
|
||||
expect(result.current).toMatchObject({ loading: false, regions: [READY, FAILED], error: null, busyId: null });
|
||||
expect([...result.current.added]).toEqual(['oberbayern', 'niederbayern']);
|
||||
expect(result.current.removing).toBeNull();
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 3);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-003: asks again on every poll while a region waits or is read, and stops once all are ready', async () => {
|
||||
repo.list
|
||||
.mockResolvedValueOnce([READY, QUEUED])
|
||||
.mockResolvedValueOnce([READY, IMPORTING])
|
||||
.mockResolvedValue([READY, DONE]);
|
||||
const { result } = await mount();
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
|
||||
await settle(PADDLE_REGIONS_POLL_MS - 1);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
await settle(1);
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.regions).toEqual([READY, IMPORTING]);
|
||||
|
||||
await settle(PADDLE_REGIONS_POLL_MS);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
expect(result.current.regions).toEqual([READY, DONE]);
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 5);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-004: a failed poll shows the error and keeps the regions; the next answer clears it', async () => {
|
||||
repo.list
|
||||
.mockResolvedValueOnce([QUEUED])
|
||||
.mockRejectedValueOnce(apiError('Database is locked'))
|
||||
.mockResolvedValue([DONE]);
|
||||
const { result } = await mount();
|
||||
await settle(PADDLE_REGIONS_POLL_MS);
|
||||
expect(result.current).toMatchObject({ error: 'Database is locked', regions: [QUEUED], loading: false });
|
||||
|
||||
await settle(PADDLE_REGIONS_POLL_MS);
|
||||
expect(result.current).toMatchObject({ error: null, regions: [DONE] });
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 3);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-005: names why the list failed, offline or unknown, and a retry clears it', async () => {
|
||||
repo.list
|
||||
.mockRejectedValueOnce(new PaddleRegionsOfflineError())
|
||||
.mockRejectedValueOnce('socket hang up')
|
||||
.mockResolvedValue([READY]);
|
||||
const { result } = await mount();
|
||||
expect(result.current).toMatchObject({ error: OFFLINE, loading: false, regions: [] });
|
||||
|
||||
await act(() => result.current.retry());
|
||||
expect(result.current.error).toBe('Error');
|
||||
|
||||
await act(() => result.current.retry());
|
||||
expect(result.current).toMatchObject({ error: null, regions: [READY] });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-006: searches Geofabrik only from two characters and once the typing rests', async () => {
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery(' o '));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
|
||||
expect(repo.catalog).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toMatchObject({ query: ' o ', hits: null, searching: false, error: null });
|
||||
|
||||
act(() => result.current.search.setQuery(' ob'));
|
||||
expect(result.current.search.searching).toBe(true);
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
|
||||
act(() => result.current.search.setQuery(' obe'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
|
||||
expect(repo.catalog).not.toHaveBeenCalled();
|
||||
|
||||
await settle(1);
|
||||
expect(repo.catalog).toHaveBeenCalledTimes(1);
|
||||
expect(repo.catalog).toHaveBeenCalledWith('obe', expect.any(AbortSignal));
|
||||
expect(result.current.search).toMatchObject({ query: ' obe', hits: [HIT], searching: false, error: null });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-007: the answer for the words typed last wins, an older one arriving late is dropped', async () => {
|
||||
const first = deferred<PaddleRegionCatalogEntry[]>();
|
||||
const second = deferred<PaddleRegionCatalogEntry[]>();
|
||||
repo.catalog.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('ober'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
act(() => result.current.search.setQuery('oberpf'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(repo.catalog.mock.calls.map(([q]) => q)).toEqual(['ober', 'oberpf']);
|
||||
expect(repo.catalog.mock.calls[0][1]?.aborted).toBe(true);
|
||||
|
||||
first.resolve([OBERBAYERN_HIT, HIT]);
|
||||
await settle();
|
||||
expect(result.current.search).toMatchObject({ hits: null, searching: true });
|
||||
|
||||
second.resolve([HIT]);
|
||||
await settle();
|
||||
expect(result.current.search).toMatchObject({ query: 'oberpf', hits: [HIT], searching: false });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-008: says when Geofabrik cannot be reached, until the words are too short to search', async () => {
|
||||
repo.catalog.mockRejectedValueOnce(apiError('Geofabrik did not answer', 'catalog_unavailable'));
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('oberpfalz'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(result.current.search).toMatchObject({
|
||||
hits: null,
|
||||
searching: false,
|
||||
error: 'Geofabrik cannot be reached right now. Try again later.',
|
||||
});
|
||||
|
||||
act(() => result.current.search.setQuery('o'));
|
||||
expect(result.current.search.error).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-009: adding a region clears the search, says so and lists it', async () => {
|
||||
repo.list.mockResolvedValueOnce([READY]).mockResolvedValue([READY, QUEUED]);
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('oberpfalz'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(result.current.added.has('oberpfalz')).toBe(false);
|
||||
|
||||
await act(() => result.current.add('oberpfalz'));
|
||||
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
|
||||
expect(toast.success).toHaveBeenCalledWith('Region added. TREK reads it in the background.');
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toMatchObject({ query: '', hits: null, searching: false });
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.regions).toEqual([READY, QUEUED]);
|
||||
expect(result.current.added.has('oberpfalz')).toBe(true);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-010: a refused add shows the server message and keeps the words typed', async () => {
|
||||
repo.add.mockRejectedValueOnce(apiError('Geofabrik has no region oberpfalz-nord', 'unknown_region'));
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('oberpfalz-nord'));
|
||||
await act(() => result.current.add('oberpfalz-nord'));
|
||||
expect(toast.error).toHaveBeenCalledWith('Geofabrik has no region oberpfalz-nord');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(result.current.search.query).toBe('oberpfalz-nord');
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-011: reading a region again asks quietly and reloads the list; a failure says why', async () => {
|
||||
const { result } = await mount();
|
||||
await act(() => result.current.refresh('oberbayern'));
|
||||
expect(repo.refresh).toHaveBeenCalledWith('oberbayern');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
|
||||
repo.refresh.mockRejectedValueOnce(new PaddleRegionsOfflineError());
|
||||
await act(() => result.current.refresh('oberbayern'));
|
||||
expect(toast.error).toHaveBeenCalledWith(OFFLINE);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-012: removes a region only once the removal is confirmed', async () => {
|
||||
const { result } = await mount();
|
||||
act(() => result.current.askRemove(READY));
|
||||
expect(result.current.removing).toEqual(READY);
|
||||
act(() => result.current.cancelRemove());
|
||||
expect(result.current.removing).toBeNull();
|
||||
act(() => result.current.confirmRemove());
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
|
||||
repo.list.mockResolvedValue([]);
|
||||
act(() => result.current.askRemove(READY));
|
||||
act(() => result.current.confirmRemove());
|
||||
expect(repo.remove).toHaveBeenCalledWith('oberbayern');
|
||||
expect(result.current).toMatchObject({ removing: null, busyId: 'oberbayern' });
|
||||
|
||||
await settle();
|
||||
expect(toast.success).toHaveBeenCalledWith('Region removed');
|
||||
expect(result.current).toMatchObject({ busyId: null, regions: [] });
|
||||
expect(result.current.added.size).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-013: while one action runs, a second one is ignored', async () => {
|
||||
const reading = deferred<PaddleRegion>();
|
||||
repo.refresh.mockReturnValueOnce(reading.promise);
|
||||
const { result } = await mount();
|
||||
act(() => {
|
||||
void result.current.refresh('oberbayern');
|
||||
});
|
||||
expect(result.current.busyId).toBe('oberbayern');
|
||||
|
||||
await act(() => result.current.add('oberpfalz'));
|
||||
act(() => result.current.askRemove(READY));
|
||||
act(() => result.current.confirmRemove());
|
||||
expect(repo.add).not.toHaveBeenCalled();
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
expect(repo.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.busyId).toBe('oberbayern');
|
||||
|
||||
reading.resolve(READY);
|
||||
await settle();
|
||||
expect(result.current.busyId).toBeNull();
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(() => result.current.add('oberpfalz'));
|
||||
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-014: offline it asks nothing and shows no spinner, and picks up again with the connection', async () => {
|
||||
repo.list.mockResolvedValue([QUEUED]);
|
||||
setForcedOffline(true);
|
||||
const { result } = await mount();
|
||||
expect(result.current).toMatchObject({ offline: true, loading: false, regions: [], error: null });
|
||||
act(() => result.current.search.setQuery('oberpfalz'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
|
||||
expect(repo.list).not.toHaveBeenCalled();
|
||||
expect(repo.catalog).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toMatchObject({ hits: null, searching: false, error: null });
|
||||
|
||||
act(() => setForcedOffline(false));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(result.current.offline).toBe(false);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
expect(repo.catalog).toHaveBeenCalledWith('oberpfalz', expect.any(AbortSignal));
|
||||
expect(result.current.search.hits).toEqual([HIT]);
|
||||
|
||||
act(() => setForcedOffline(true));
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 4);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.search.hits).toBeNull();
|
||||
|
||||
act(() => setForcedOffline(false));
|
||||
await settle();
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddleRegionsModel', () => {
|
||||
it('FE-PADDLEREGIONSMODEL-001: waits on queued and importing reads, tones the badge by status and rounds the progress', () => {
|
||||
const statuses: PaddleRegion['status'][] = ['queued', 'importing', 'ready', 'failed'];
|
||||
expect(statuses.map((status) => isReading({ status }))).toEqual([true, true, false, false]);
|
||||
expect(statuses.map((status) => regionStatusTone(status))).toEqual(['neutral', 'accent', 'success', 'danger']);
|
||||
expect(progressPercent({ progress: null })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 0.456 } })).toBe(46);
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionErrorCode, paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { isReading } from './paddleRegionsModel';
|
||||
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
|
||||
|
||||
/** How often the list is asked again while a region waits or is read. */
|
||||
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
||||
/** How long typing rests before Geofabrik's list is searched. */
|
||||
export const CATALOG_SEARCH_DEBOUNCE_MS = import.meta.env.MODE === 'test' ? 10 : 350;
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
function errorText(t: Translate, err: unknown): string {
|
||||
const code = paddleRegionErrorCode(err);
|
||||
if (code === 'offline') return t('admin.paddleRegions.offline');
|
||||
if (code === 'catalog_unavailable') return t('admin.paddleRegions.catalogUnavailable');
|
||||
return getApiErrorMessage(err, t('common.error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The regions as the server holds them, with those paddlers asked for, asked
|
||||
* again every few seconds while any of them is read, and on `reload` after a
|
||||
* change. Every answer is set
|
||||
* from the request's own callback, so a slow one never overwrites a newer.
|
||||
*/
|
||||
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
||||
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [asked, setAsked] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list.regions);
|
||||
setRequests(list.requests);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(errorText(t, err));
|
||||
setLoaded(true);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, asked]);
|
||||
const reading = regions.some(isReading);
|
||||
useEffect(() => {
|
||||
if (!active || offline || !reading) return;
|
||||
const timer = window.setInterval(() => setAsked((n) => n + 1), PADDLE_REGIONS_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, offline, reading]);
|
||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
function useCatalogSearch(offline: boolean, t: Translate) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [result, setResult] = useState<{
|
||||
needle: string;
|
||||
hits: PaddleRegionCatalogEntry[] | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const needle = query.trim();
|
||||
const wanted = !offline && needle.length >= 2;
|
||||
useEffect(() => {
|
||||
if (!wanted) return;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
paddleRegionRepo
|
||||
.catalog(needle, controller.signal)
|
||||
.then((hits) => {
|
||||
if (!controller.signal.aborted) setResult({ needle, hits, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setResult({ needle, hits: null, error: errorText(t, err) });
|
||||
});
|
||||
}, CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [needle, wanted, t]);
|
||||
// An answer counts only for the words it was asked for.
|
||||
const current = wanted && result?.needle === needle ? result : null;
|
||||
return { query, setQuery, hits: current?.hits ?? null, searching: wanted && !current, error: current?.error ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
||||
* in Geofabrik's list to add one, reading a region again and removing it
|
||||
* after a confirmation. Shown only while the Tours addon is on; the status of
|
||||
* a read follows without reloading the page. PAD-11C: how TREK loads regions
|
||||
* for paddlers by itself, and the regions they asked for, to load or decline.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { offline } = useNetworkMode();
|
||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||
const list = useRegionList(toursOn, offline, t);
|
||||
const autoLoad = usePaddleAutoLoad(toursOn, offline);
|
||||
const search = useCatalogSearch(offline, t);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||
const pending = useRef(false);
|
||||
const { reload } = list;
|
||||
|
||||
const run = useCallback(
|
||||
async (id: string, action: () => Promise<unknown>, done?: string) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await action();
|
||||
if (done) toast.success(done);
|
||||
} catch (err) {
|
||||
toast.error(errorText(t, err));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusyId(null);
|
||||
reload();
|
||||
}
|
||||
},
|
||||
[reload, t, toast]
|
||||
);
|
||||
|
||||
const { setQuery } = search;
|
||||
const add = useCallback(
|
||||
(id: string) =>
|
||||
run(
|
||||
id,
|
||||
async () => {
|
||||
await paddleRegionRepo.add(id);
|
||||
setQuery('');
|
||||
},
|
||||
t('admin.paddleRegions.toast.added')
|
||||
),
|
||||
[run, setQuery, t]
|
||||
);
|
||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||
/** A paddler's request let through: the region is added like any other. */
|
||||
const approve = useCallback(
|
||||
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
|
||||
[run, t]
|
||||
);
|
||||
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
|
||||
const confirmRemove = useCallback(() => {
|
||||
const region = removing;
|
||||
setRemoving(null);
|
||||
if (region) void run(region.id, () => paddleRegionRepo.remove(region.id), t('admin.paddleRegions.toast.removed'));
|
||||
}, [removing, run, t]);
|
||||
const added = useMemo(() => new Set(list.regions.map((region) => region.id)), [list.regions]);
|
||||
|
||||
return {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
requests: list.requests,
|
||||
autoLoad,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
search,
|
||||
added,
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
approve,
|
||||
decline,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
confirmRemove,
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export const CATEGORIES = [
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// FE-STA2-PANE-001 to FE-STA2-PANE-006
|
||||
// STA-02: the Atlas tab of the user's own tour activities: the distance large
|
||||
// with how many tours at its corner, the climb, the types, what the numbers
|
||||
// rest on, and the full list in a dialog.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
const hook = vi.hoisted(() => ({ current: null as unknown, active: [] as boolean[] }));
|
||||
vi.mock('./useTourActivityStats', () => ({
|
||||
useTourActivityStats: (active: boolean) => {
|
||||
hook.active.push(active);
|
||||
return hook.current;
|
||||
},
|
||||
}));
|
||||
|
||||
const TODAY = '2026-10-20';
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 12,
|
||||
ascent_m: 800,
|
||||
descent_m: 790,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statsOf(data: TourActivityStatsResponse | null, overrides: Partial<TourActivityStats> = {}) {
|
||||
const stats: TourActivityStats = {
|
||||
status: data ? 'ready' : 'loading',
|
||||
data,
|
||||
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
|
||||
period: 'all',
|
||||
setPeriod: vi.fn(),
|
||||
periods: ['all', 'thisYear', 'last12'],
|
||||
type: 'all',
|
||||
setType: vi.fn(),
|
||||
types: ['all', 'hike', 'kayak'],
|
||||
pending: new Set(),
|
||||
setExcluded: vi.fn().mockResolvedValue(undefined),
|
||||
retry: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
hook.current = stats;
|
||||
return stats;
|
||||
}
|
||||
|
||||
const data: TourActivityStatsResponse = {
|
||||
entries: [
|
||||
entry(),
|
||||
entry({ place_id: 2, name: 'Summit', date: '2026-08-01', distance_km: 20.5, ascent_m: 1200, descent_m: 1200 }),
|
||||
entry({
|
||||
place_id: 3,
|
||||
name: 'Lake',
|
||||
tour_type: 'kayak',
|
||||
basis: 'track',
|
||||
track: { place_id: 9, name: 'Watch' },
|
||||
distance_km: 8,
|
||||
ascent_m: null,
|
||||
descent_m: null,
|
||||
}),
|
||||
],
|
||||
uncounted: { unassigned: 0, undated: 1 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
hook.active = [];
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||
});
|
||||
|
||||
describe('AtlasTourActivityPane', () => {
|
||||
it('FE-STA2-PANE-001: follows whether the tab is shown, silent before, loading until the read is there', () => {
|
||||
statsOf(null, { status: 'idle' });
|
||||
const { rerender } = render(<AtlasTourActivityPane active={false} />);
|
||||
expect(hook.active).toEqual([false]);
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
statsOf(null);
|
||||
rerender(<AtlasTourActivityPane active />);
|
||||
expect(hook.active).toEqual([false, true]);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-002: shows the distance large with the tour count at its corner, and the climb', () => {
|
||||
statsOf(data);
|
||||
render(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByTestId('atlas-tour-activity-distance')).toHaveTextContent('40.5km');
|
||||
expect(screen.getByTestId('atlas-tour-activity-count')).toHaveTextContent('3');
|
||||
expect(screen.getByTestId('atlas-tour-activity-ascent')).toHaveTextContent('2000m');
|
||||
expect(screen.getByTestId('atlas-tour-activity-type-hike')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('atlas-tour-activity-type-kayak')).toHaveTextContent('8 km');
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-003: says what the numbers rest on and what does not count', () => {
|
||||
statsOf(data);
|
||||
render(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('Planned2');
|
||||
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('Recorded1');
|
||||
expect(screen.getByTestId('tour-activity-undated')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-004: one type chosen leaves the type columns out; nothing done says so', () => {
|
||||
statsOf(data, { type: 'hike', summary: summarizeTourActivities(data.entries, { today: TODAY, type: 'hike' }) });
|
||||
const { rerender } = render(<AtlasTourActivityPane active />);
|
||||
expect(screen.queryByTestId('atlas-tour-activity-type-hike')).not.toBeInTheDocument();
|
||||
|
||||
statsOf(data, { summary: summarizeTourActivities(data.entries, { today: TODAY, from: '2027-01-01' }) });
|
||||
rerender(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByText('No completed tours in this period')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show tours' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-005: the full list opens in the dialog', async () => {
|
||||
statsOf(data);
|
||||
const user = userEvent.setup();
|
||||
render(<AtlasTourActivityPane active />);
|
||||
await user.click(screen.getByRole('button', { name: 'Show tours' }));
|
||||
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-PANE-006: offline and failed reads say so, a failed one with a retry', async () => {
|
||||
statsOf(null, { status: 'offline' });
|
||||
const { rerender } = render(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByText(/needs a connection/i)).toBeInTheDocument();
|
||||
|
||||
const failed = statsOf(null, { status: 'error' });
|
||||
rerender(<AtlasTourActivityPane active />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Tour activities could not be loaded');
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(failed.retry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { TourActivitySummary } from '@trek/shared';
|
||||
import { Footprints, ListOrdered } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { CardAction, InfoDot } from '../../Tours/tourParts';
|
||||
import { ActivityFilters, ActivityStatusLine, BasisBadges, UncountedBadges } from './tourActivityParts';
|
||||
import { distanceReading, heightReading, typeIcon, typeLabel, useDistanceUnit } from './tourActivityReading';
|
||||
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||
import { splitMeasure } from './tourActivityStatsModel';
|
||||
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
|
||||
const HERO_TINT = 'color-mix(in srgb, var(--text-primary) 7%, transparent)';
|
||||
/** How many types the strip names before the dialog takes over. */
|
||||
const STRIP_TYPES = 3;
|
||||
const EYEBROW = 'mt-1.5 whitespace-nowrap font-semibold uppercase tracking-wide text-content-faint';
|
||||
|
||||
function Divider() {
|
||||
return <span aria-hidden="true" className="mx-3 my-3 w-0.5 flex-none rounded-full bg-edge-faint" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The distance of the period, large, with how many tours at its corner; the
|
||||
* tour count stands in when no tour knows its distance.
|
||||
*/
|
||||
function Hero({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
const reading = distanceReading(summary.totals, unit);
|
||||
const shown = reading ? splitMeasure(reading.text) : { value: String(summary.totals.count), unit: '' };
|
||||
const countLabel = `${t('tours.mode.tours')}: ${summary.totals.count}`;
|
||||
return (
|
||||
<div
|
||||
className="relative mx-2 my-2 flex flex-col justify-center rounded-xl px-5 py-4"
|
||||
style={{ background: HERO_TINT }}
|
||||
>
|
||||
<div className="flex items-baseline gap-1.5" data-testid="atlas-tour-activity-distance">
|
||||
<span className="font-black tabular-nums leading-none text-content" style={fs(40, 'title')}>
|
||||
{shown.value}
|
||||
</span>
|
||||
{shown.unit && (
|
||||
<span className="font-medium text-content-muted" style={fs(14, 'body')}>
|
||||
{shown.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={EYEBROW} style={fs(9)}>
|
||||
{reading ? t('tours.detail.distance') : t('tours.mode.tours')}
|
||||
</span>
|
||||
{reading && (
|
||||
<Tooltip label={countLabel}>
|
||||
<span
|
||||
aria-label={countLabel}
|
||||
data-testid="atlas-tour-activity-count"
|
||||
className="absolute -end-2 -top-2 inline-flex h-[22px] items-center gap-1 rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
style={fs(11)}
|
||||
>
|
||||
<Footprints size={11} strokeWidth={2.2} className="flex-none text-content-muted" />
|
||||
{summary.totals.count}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A height sum as the Atlas numbers stand: the value, its unit, and what it is below. */
|
||||
function Figure({ label, text, testId }: { label: string; text: string; testId: string }) {
|
||||
const { value, unit } = splitMeasure(text);
|
||||
return (
|
||||
<div className="flex flex-none flex-col items-center justify-center px-3 py-4" data-testid={testId}>
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="font-black tabular-nums leading-none text-content" style={fs(24, 'subtitle')}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="font-medium text-content-muted" style={fs(11, 'body')}>
|
||||
{unit}
|
||||
</span>
|
||||
</span>
|
||||
<span className={EYEBROW} style={fs(9)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The types of the period, the most tours first: each an icon with its count at the corner, its distance below. */
|
||||
function TypeColumns({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-4 px-2 py-3">
|
||||
{summary.types.slice(0, STRIP_TYPES).map((scope) => {
|
||||
const Icon = typeIcon(scope.type);
|
||||
const distance = distanceReading(scope, unit);
|
||||
const label = `${typeLabel(scope.type, t)}: ${scope.count}${distance ? `, ${distance.text}` : ''}`;
|
||||
return (
|
||||
<Tooltip key={scope.type} label={label}>
|
||||
<span
|
||||
aria-label={label}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
data-testid={`atlas-tour-activity-type-${scope.type}`}
|
||||
>
|
||||
<span className="relative">
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<Icon size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span
|
||||
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
|
||||
style={fs(9)}
|
||||
>
|
||||
{scope.count}
|
||||
</span>
|
||||
</span>
|
||||
{distance && (
|
||||
<span
|
||||
className="whitespace-nowrap font-semibold tabular-nums text-content-secondary"
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{distance.text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the sums rest on, what does not count, and the way into the full list. */
|
||||
interface StripProps {
|
||||
stats: TourActivityStats;
|
||||
summary: TourActivitySummary;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
function SideColumn({ stats, summary, onOpen }: StripProps) {
|
||||
const { t } = useTranslation();
|
||||
const uncounted = stats.data?.uncounted ?? { unassigned: 0, undated: 0 };
|
||||
return (
|
||||
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<BasisBadges byBasis={summary.totals.byBasis} />
|
||||
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<UncountedBadges upcoming={summary.upcoming} {...uncounted} />
|
||||
</div>
|
||||
{summary.done.length > 0 && (
|
||||
<CardAction icon={<ListOrdered size={13} strokeWidth={2.2} />} onClick={onOpen}>
|
||||
{t('tours.stats.showTours')}
|
||||
</CardAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The numbers of the period, or why there are none, with the filters before them. */
|
||||
function Strip({ stats, summary, onOpen }: StripProps) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useDistanceUnit();
|
||||
const ascent = heightReading(summary.totals.ascentM, unit);
|
||||
const descent = heightReading(summary.totals.descentM, unit);
|
||||
const counted = summary.totals.count > 0;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
|
||||
<ActivityFilters stats={stats} quiet />
|
||||
</div>
|
||||
{counted ? (
|
||||
<>
|
||||
<Hero summary={summary} unit={unit} />
|
||||
{ascent && <Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />}
|
||||
{descent && (
|
||||
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
|
||||
)}
|
||||
{stats.type === 'all' && summary.types.length > 1 && (
|
||||
<>
|
||||
<Divider />
|
||||
<TypeColumns summary={summary} unit={unit} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="m-0 flex max-w-[260px] items-center px-4 py-5 text-content-muted" style={fs(12, 'body')}>
|
||||
{t(stats.data?.entries.length ? 'tours.stats.none' : 'tours.stats.empty')}
|
||||
</p>
|
||||
)}
|
||||
<Divider />
|
||||
<SideColumn stats={stats} summary={summary} onOpen={onOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: the Atlas tab of the user's own tour activities: the period and type,
|
||||
* the distance done with how many tours at its corner, the climb, the types,
|
||||
* and what the numbers rest on (the trip planning, or recorded tracks where
|
||||
* linked). The full list, where a tour can be left out, opens in a dialog. The
|
||||
* read waits until the tab is first shown.
|
||||
*/
|
||||
export function AtlasTourActivityPane({ active, maxWidth }: { active: boolean; maxWidth?: number }) {
|
||||
const stats = useTourActivityStats(active);
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-stretch justify-center overflow-x-auto px-2"
|
||||
style={{ maxWidth }}
|
||||
data-testid="atlas-tour-activity"
|
||||
>
|
||||
{stats.summary ? (
|
||||
<Strip stats={stats} summary={stats.summary} onOpen={() => setOpen(true)} />
|
||||
) : (
|
||||
<div className="flex items-center px-6 py-5">
|
||||
<ActivityStatusLine stats={stats} />
|
||||
</div>
|
||||
)}
|
||||
<TourActivityStatsDialog stats={stats} open={open} onClose={() => setOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { TourActivityStatsEntry } from '@trek/shared';
|
||||
import { Ban, Briefcase, CalendarDays, Footprints, Loader2, Repeat, RotateCcw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { EntryTile, MeasurePills } from './tourActivityParts';
|
||||
import { useDistanceUnit } from './tourActivityReading';
|
||||
|
||||
const ROW_BADGE_ICON = 'flex-none text-content-faint';
|
||||
|
||||
/** What the entry's values rest on, as a pill: the plan, or the track that replaced it, named in the tooltip. */
|
||||
function BasisPill({ entry }: { entry: TourActivityStatsEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const track = entry.basis === 'track';
|
||||
const hint = track
|
||||
? t('tours.stats.basisTrackHint', { name: entry.track?.name ?? '' })
|
||||
: t('tours.stats.basisPlanHint');
|
||||
return (
|
||||
<Tooltip label={hint}>
|
||||
<span
|
||||
className="inline-flex"
|
||||
aria-label={hint}
|
||||
data-testid={`tour-activity-basis-${entry.basis}-${entry.place_id}`}
|
||||
>
|
||||
<SoftPill
|
||||
tone={track ? 'success' : 'neutral'}
|
||||
icon={
|
||||
track ? (
|
||||
<Footprints size={10} strokeWidth={2.2} className="flex-none" />
|
||||
) : (
|
||||
<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(track ? 'tours.stats.basisTrack' : 'tours.stats.basisPlan')}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The day it counts on, the trip it is in (a way into that trip), and how many days the plan has it on. */
|
||||
function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
|
||||
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
|
||||
{formatDate(entry.date, locale) ?? entry.date}
|
||||
</WhiteBadge>
|
||||
<Tooltip label={t('tours.stats.openTrip')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate(`/trips/${entry.trip.id}`)}
|
||||
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<WhiteBadge
|
||||
className="min-w-0 max-w-full"
|
||||
icon={<Briefcase size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}
|
||||
>
|
||||
<span className="truncate">{entry.trip.title}</span>
|
||||
</WhiteBadge>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{entry.day_count > 1 && (
|
||||
<Tooltip label={t('tours.stats.multiDayHint')}>
|
||||
<span className="inline-flex" aria-label={t('tours.stats.multiDayHint')}>
|
||||
<WhiteBadge icon={<Repeat size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
|
||||
{entry.day_count}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<BasisPill entry={entry} />
|
||||
{entry.excluded && <SoftPill caps>{t('tours.stats.excluded')}</SoftPill>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The round button that leaves the tour out, or counts it again; named by its tooltip. */
|
||||
function ExcludeToggle({
|
||||
entry,
|
||||
busy,
|
||||
onToggle,
|
||||
}: {
|
||||
entry: TourActivityStatsEntry;
|
||||
busy: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = entry.excluded ? t('tours.stats.include') : t('tours.stats.exclude');
|
||||
const hint = entry.excluded ? label : `${label}: ${t('tours.stats.excludeHint')}`;
|
||||
let icon = <Ban size={14} strokeWidth={2} />;
|
||||
if (busy) icon = <Loader2 size={14} strokeWidth={2} className="animate-spin" />;
|
||||
else if (entry.excluded) icon = <RotateCcw size={14} strokeWidth={2} />;
|
||||
return (
|
||||
<Tooltip label={hint}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={busy}
|
||||
aria-label={label}
|
||||
aria-pressed={entry.excluded}
|
||||
data-testid={`tour-activity-toggle-${entry.place_id}`}
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: one tour of the user's activities: its type, name and where it
|
||||
* counts, its distance and climb, and the switch to leave it out. A tour left
|
||||
* out stays in the list, dimmed, so it can be counted again in place.
|
||||
*/
|
||||
export function TourActivityEntryRow({
|
||||
entry,
|
||||
busy,
|
||||
onToggleExcluded,
|
||||
}: {
|
||||
entry: TourActivityStatsEntry;
|
||||
busy: boolean;
|
||||
onToggleExcluded: (entry: TourActivityStatsEntry) => void;
|
||||
}) {
|
||||
const unit = useDistanceUnit();
|
||||
const key = entry.track ? `${entry.place_id}-${entry.track.place_id}` : String(entry.place_id);
|
||||
return (
|
||||
<li
|
||||
data-testid={`tour-activity-row-${key}`}
|
||||
className="flex min-w-0 items-center gap-3 rounded-[12px] px-2.5 py-2 transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 transition-opacity ${entry.excluded ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<EntryTile entry={entry} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{entry.name}
|
||||
</span>
|
||||
<EntryFacts entry={entry} />
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end gap-1">
|
||||
<MeasurePills
|
||||
distance={entry.distance_km === null ? null : formatDistance(entry.distance_km, unit)}
|
||||
ascent={entry.ascent_m === null ? null : formatElevation(entry.ascent_m, unit)}
|
||||
testId={`tour-activity-${key}`}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<ExcludeToggle entry={entry} busy={busy} onToggle={() => onToggleExcluded(entry)} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// FE-STA2-DIALOG-001 to FE-STA2-DIALOG-008
|
||||
// STA-02: the full list of the user's tour activities: the sums with what they
|
||||
// rest on, the split by type, each tour with its basis and a switch to leave it
|
||||
// out (dimmed, never hidden), and the way into its trip.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
const navigate = vi.hoisted(() => vi.fn());
|
||||
vi.mock('react-router', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-router')>()),
|
||||
useNavigate: () => navigate,
|
||||
}));
|
||||
|
||||
const TODAY = '2026-10-20';
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 12,
|
||||
ascent_m: 800,
|
||||
descent_m: 790,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function statsOf(
|
||||
data: TourActivityStatsResponse | null,
|
||||
overrides: Partial<TourActivityStats> = {}
|
||||
): TourActivityStats {
|
||||
return {
|
||||
status: data ? 'ready' : 'loading',
|
||||
data,
|
||||
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
|
||||
period: 'all',
|
||||
setPeriod: vi.fn(),
|
||||
periods: ['all', 'thisYear', 'last12'],
|
||||
type: 'all',
|
||||
setType: vi.fn(),
|
||||
types: ['all', 'hike', 'kayak'],
|
||||
pending: new Set(),
|
||||
setExcluded: vi.fn().mockResolvedValue(undefined),
|
||||
retry: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const data: TourActivityStatsResponse = {
|
||||
entries: [
|
||||
entry(),
|
||||
entry({
|
||||
place_id: 2,
|
||||
name: 'Lake',
|
||||
tour_type: 'kayak',
|
||||
date: '2026-08-01',
|
||||
basis: 'track',
|
||||
track: { place_id: 9, name: 'Watch export' },
|
||||
distance_km: 8.5,
|
||||
ascent_m: null,
|
||||
descent_m: null,
|
||||
}),
|
||||
entry({ place_id: 3, name: 'Long trail', date: '2026-06-01', day_count: 3, distance_km: null }),
|
||||
entry({ place_id: 4, name: 'Rained off', date: '2026-05-01', excluded: true }),
|
||||
entry({ place_id: 5, name: 'Next week', date: '2026-10-27' }),
|
||||
],
|
||||
uncounted: { unassigned: 2, undated: 0 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||
});
|
||||
|
||||
describe('TourActivityStatsDialog', () => {
|
||||
it('FE-STA2-DIALOG-001: shows the sums, at least where a tour lacks the distance, and what they rest on', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
|
||||
expect(screen.getByText('≥ 20.5 km')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('2');
|
||||
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('tour-activity-upcoming')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('tour-activity-unassigned')).toHaveTextContent('2');
|
||||
expect(screen.queryByTestId('tour-activity-undated')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-002: splits by type, the count at the corner of each type', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
expect(within(screen.getByTestId('tour-activity-type-hike')).getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-type-kayak-distance')).toHaveTextContent('8.5 km');
|
||||
expect(screen.queryByTestId('tour-activity-type-kayak-ascent')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-003: lists every done tour, newest first, with its basis; the one ahead is not there', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
const list = screen.getByTestId('tour-activity-list');
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(4);
|
||||
expect(screen.queryByText('Next week')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-row-2-9')).toHaveTextContent('Recorded');
|
||||
expect(screen.getByTestId('tour-activity-track-corner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tour-activity-row-1')).toHaveTextContent('Planned');
|
||||
expect(
|
||||
within(screen.getByTestId('tour-activity-row-3')).getByLabelText('Planned on several days, counted once')
|
||||
).toHaveTextContent('3');
|
||||
expect(screen.queryByTestId('tour-activity-3-distance')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-004: a tour left out stays listed and can be counted again, a counted one left out', async () => {
|
||||
const stats = statsOf(data);
|
||||
const user = userEvent.setup();
|
||||
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('tour-activity-row-4')).toHaveTextContent('Left out');
|
||||
await user.click(screen.getByRole('button', { name: 'Count again' }));
|
||||
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4 }), false);
|
||||
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByRole('button', { name: 'Leave out' }));
|
||||
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 1 }), true);
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-005: a tour on its way to the server cannot be switched twice', () => {
|
||||
render(<TourActivityStatsDialog stats={statsOf(data, { pending: new Set([1]) })} open onClose={vi.fn()} />);
|
||||
expect(screen.getByTestId('tour-activity-toggle-1')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-006: the trip of a tour opens from its badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByText('Alps'));
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/5');
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-007: the filters sit on the head band and change period and type', async () => {
|
||||
const stats = statsOf(data);
|
||||
const user = userEvent.setup();
|
||||
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
|
||||
await user.click(screen.getByRole('button', { name: /Period/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'This year' }));
|
||||
expect(stats.setPeriod).toHaveBeenCalledWith('thisYear');
|
||||
await user.click(screen.getByRole('button', { name: /Type/ }));
|
||||
await user.click(screen.getByRole('button', { name: 'Paddle tour' }));
|
||||
expect(stats.setType).toHaveBeenCalledWith('kayak');
|
||||
});
|
||||
|
||||
it('FE-STA2-DIALOG-008: before the read, while empty, and closed', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<TourActivityStatsDialog stats={statsOf(null)} open onClose={onClose} />);
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
||||
rerender(
|
||||
<TourActivityStatsDialog
|
||||
stats={statsOf({ entries: [], uncounted: { unassigned: 0, undated: 0 } })}
|
||||
open
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No completed tours yet')).toBeInTheDocument();
|
||||
const [closeButton] = screen.getAllByRole('button', { name: 'Close' });
|
||||
await user.click(closeButton as HTMLElement);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
rerender(<TourActivityStatsDialog stats={statsOf(null)} open={false} onClose={onClose} />);
|
||||
expect(screen.queryByText('My tour activities')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
|
||||
import { Footprints } from 'lucide-react';
|
||||
import { createElement, useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
|
||||
import { TourActivityEntryRow } from './TourActivityEntryRow';
|
||||
import { ActivityFilters, ActivityStatusLine, BasisBadges, MeasurePills, UncountedBadges } from './tourActivityParts';
|
||||
import { distanceReading, heightReading, partialHint, typeIcon, useDistanceUnit } from './tourActivityReading';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The sums as labelled fields; a sum nobody knows is left out, a short one says at least and why. */
|
||||
function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translate): TourMetricField[] {
|
||||
const fields: TourMetricField[] = [{ label: t('tours.mode.tours'), value: String(totals.count) }];
|
||||
const sums = [
|
||||
{ label: t('tours.detail.distance'), sum: totals.distanceKm, reading: distanceReading(totals, unit) },
|
||||
{ label: t('tours.detail.ascent'), sum: totals.ascentM, reading: heightReading(totals.ascentM, unit) },
|
||||
{ label: t('tours.detail.descent'), sum: totals.descentM, reading: heightReading(totals.descentM, unit) },
|
||||
];
|
||||
for (const { label, sum, reading } of sums) {
|
||||
if (!reading) continue;
|
||||
const info = reading.partial ? { label, description: partialHint(sum, t) } : undefined;
|
||||
fields.push({ label, value: reading.text, info });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** One type of the period: its icon with how many tours at the corner, its name, its distance and climb. */
|
||||
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useDistanceUnit();
|
||||
const distance = distanceReading(totals, unit);
|
||||
const ascent = heightReading(totals.ascentM, unit);
|
||||
return (
|
||||
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
|
||||
<span className="relative flex-none">
|
||||
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
|
||||
{createElement(typeIcon(type), { size: 14, strokeWidth: 1.9 })}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-edge-faint"
|
||||
style={fs(9)}
|
||||
>
|
||||
{totals.count}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t(`tourTypes.${type}`)}
|
||||
<span className="sr-only">{`: ${t('tours.mode.tours')} ${totals.count}`}</span>
|
||||
</span>
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
<MeasurePills
|
||||
distance={distance?.text ?? null}
|
||||
ascent={ascent?.text ?? null}
|
||||
testId={`tour-activity-type-${type}`}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The sums, what they rest on, the split by type, and every tour of the period with its switch. */
|
||||
function StatsBody({ stats }: { stats: TourActivityStats }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useDistanceUnit();
|
||||
const { summary, data } = stats;
|
||||
if (!summary || !data) return <ActivityStatusLine stats={stats} />;
|
||||
const showTypes = stats.type === 'all' && summary.types.length > 1;
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<TourFactFields fields={totalFields(summary.totals, unit, t)} />
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<BasisBadges byBasis={summary.totals.byBasis} />
|
||||
<UncountedBadges upcoming={summary.upcoming} {...data.uncounted} />
|
||||
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
|
||||
</div>
|
||||
</div>
|
||||
{showTypes && (
|
||||
<DialogSection label={t('tours.stats.byType')}>
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||
{summary.types.map(({ type, ...totals }) => (
|
||||
<TypeLine key={type} type={type} totals={totals} />
|
||||
))}
|
||||
</ul>
|
||||
</DialogSection>
|
||||
)}
|
||||
<DialogSection
|
||||
label={t('tours.mode.tours')}
|
||||
action={
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{summary.done.length}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{summary.done.length === 0 ? (
|
||||
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t(data.entries.length === 0 ? 'tours.stats.empty' : 'tours.stats.none')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="-mx-2.5 my-0 flex list-none flex-col gap-0.5 p-0" data-testid="tour-activity-list">
|
||||
{summary.done.map((entry) => (
|
||||
<TourActivityEntryRow
|
||||
key={entry.track ? `${entry.place_id}-${entry.track.place_id}` : entry.place_id}
|
||||
entry={entry}
|
||||
busy={stats.pending.has(entry.place_id)}
|
||||
onToggleExcluded={(target) => void stats.setExcluded(target, !target.excluded)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: the user's tour activities in full, opened from the Atlas: the
|
||||
* period and type on the head band, the sums with what they rest on, the
|
||||
* split by type, and every tour done in the period, each one able to be left
|
||||
* out (a tour that fell through) and counted again without touching its plan.
|
||||
*/
|
||||
export function TourActivityStatsDialog({
|
||||
stats,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
stats: TourActivityStats;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
return (
|
||||
<DialogShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
align="top"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Footprints size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('tours.stats.title')}
|
||||
sub={t('tours.stats.sub')}
|
||||
subWraps
|
||||
pills={<ActivityFilters stats={stats} />}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<StatsBody stats={stats} />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { TourActivityBasis, TourActivityStatsEntry } from '@trek/shared';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
Footprints,
|
||||
Loader2,
|
||||
Ruler,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import { createElement } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { PillSelect } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
|
||||
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
|
||||
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
|
||||
import { typeIcon, typeLabel, type Translate } from './tourActivityReading';
|
||||
import { periodYear, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
|
||||
import type { TourActivityStats } from './useTourActivityStats';
|
||||
|
||||
/** What a period is called in the filter. */
|
||||
function periodLabel(period: ActivityPeriod, t: Translate): string {
|
||||
if (period === 'all') return t('tours.stats.period.all');
|
||||
if (period === 'thisYear') return t('tours.stats.period.thisYear');
|
||||
if (period === 'last12') return t('tours.stats.period.last12');
|
||||
return String(periodYear(period));
|
||||
}
|
||||
|
||||
/** The period and type filters, as head band pills or quiet ones in a panel. */
|
||||
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const typeOption = (type: ActivityType) => ({
|
||||
value: type,
|
||||
label: typeLabel(type, t),
|
||||
icon: createElement(typeIcon(type), { size: 12, strokeWidth: 2.2, className: 'flex-none text-content-muted' }),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<PillSelect
|
||||
quiet={quiet}
|
||||
label={t('tours.stats.period')}
|
||||
value={stats.period}
|
||||
options={stats.periods.map((period) => ({
|
||||
value: period,
|
||||
label: periodLabel(period, t),
|
||||
icon: <CalendarRange size={12} strokeWidth={2.2} className="flex-none text-content-muted" />,
|
||||
}))}
|
||||
fallback={{ label: periodLabel(stats.period, t) }}
|
||||
onChange={stats.setPeriod}
|
||||
/>
|
||||
<PillSelect
|
||||
quiet={quiet}
|
||||
label={t('tours.filter.type')}
|
||||
value={stats.type}
|
||||
options={stats.types.map(typeOption)}
|
||||
fallback={typeOption(stats.type)}
|
||||
onChange={stats.setType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
|
||||
export function MeasurePills({
|
||||
distance,
|
||||
ascent,
|
||||
testId,
|
||||
}: {
|
||||
distance: string | null;
|
||||
ascent: string | null;
|
||||
/** Prefix of the badges' test ids: `<testId>-distance`, `<testId>-ascent`. */
|
||||
testId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{distance !== null && (
|
||||
<TourDayRowPill
|
||||
icon={Ruler}
|
||||
value={distance}
|
||||
label={`${t('tours.detail.distance')}: ${distance}`}
|
||||
testId={`${testId}-distance`}
|
||||
/>
|
||||
)}
|
||||
{ascent !== null && (
|
||||
<TourDayRowPill
|
||||
icon={ArrowUp}
|
||||
value={ascent}
|
||||
label={`${t('tours.detail.ascent')}: ${ascent}`}
|
||||
testId={`${testId}-ascent`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sums rest on, each as a figure badge: how many tours count with
|
||||
* their planned values and how many with a recorded track. The planned values
|
||||
* are never passed off as measured, so they always say so.
|
||||
*/
|
||||
export function BasisBadges({ byBasis }: { byBasis: Record<TourActivityBasis, number> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{byBasis.plan > 0 && (
|
||||
<FigureBadge
|
||||
testId="tour-activity-basis-plan"
|
||||
label={t('tours.stats.basisPlan')}
|
||||
value={String(byBasis.plan)}
|
||||
icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||
info={t('tours.stats.basisPlanHint')}
|
||||
/>
|
||||
)}
|
||||
{byBasis.track > 0 && (
|
||||
<FigureBadge
|
||||
testId="tour-activity-basis-track"
|
||||
label={t('tours.stats.basisTrack')}
|
||||
value={String(byBasis.track)}
|
||||
icon={<Footprints size={10} strokeWidth={2.2} className="flex-none text-success" />}
|
||||
info={t('tours.stats.basisTrackCountHint')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** What does not count, said as quiet figure badges: tours still ahead, on no day, on days without a date. */
|
||||
export function UncountedBadges({
|
||||
upcoming,
|
||||
unassigned,
|
||||
undated,
|
||||
}: {
|
||||
upcoming: number;
|
||||
unassigned: number;
|
||||
undated: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const badges = [
|
||||
{ key: 'upcoming', value: upcoming, labelKey: 'tours.stats.upcoming', infoKey: 'tours.stats.upcomingHint' },
|
||||
{
|
||||
key: 'unassigned',
|
||||
value: unassigned,
|
||||
labelKey: 'tours.stats.unassigned',
|
||||
infoKey: 'tours.stats.unassignedHint',
|
||||
},
|
||||
{ key: 'undated', value: undated, labelKey: 'tours.stats.undated', infoKey: 'tours.stats.undatedHint' },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{badges
|
||||
.filter((badge) => badge.value > 0)
|
||||
.map((badge) => (
|
||||
<FigureBadge
|
||||
key={badge.key}
|
||||
testId={`tour-activity-${badge.key}`}
|
||||
label={t(badge.labelKey)}
|
||||
value={String(badge.value)}
|
||||
icon={<CalendarClock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||
info={t(badge.infoKey)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour's type as a round tile; a tour done with a recorded track carries
|
||||
* the footprints at its corner, the way a waypoint's number carries its kind.
|
||||
*/
|
||||
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
|
||||
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
|
||||
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
|
||||
{createElement(tourDayRowIcon(entry.tour_type), { size: 15, strokeWidth: 1.9 })}
|
||||
</span>
|
||||
{entry.basis === 'track' && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid="tour-activity-track-corner"
|
||||
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-success-soft text-success ring-2 ring-surface-card"
|
||||
>
|
||||
<Footprints size={9} strokeWidth={2.6} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the read stands while there is nothing to show yet: on its way, refused
|
||||
* offline, or failed with a way to try again.
|
||||
*/
|
||||
export function ActivityStatusLine({ stats }: { stats: Pick<TourActivityStats, 'status' | 'retry'> }) {
|
||||
const { t } = useTranslation();
|
||||
// Not asked yet: the tab has not been shown, so there is nothing to say.
|
||||
if (stats.status === 'idle') return null;
|
||||
if (stats.status === 'offline')
|
||||
return (
|
||||
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||
<WifiOff size={13} strokeWidth={2} className="flex-none" />
|
||||
{t('common.needsConnection')}
|
||||
</p>
|
||||
);
|
||||
if (stats.status === 'error')
|
||||
return (
|
||||
<p role="alert" className="m-0 flex flex-wrap items-center gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} strokeWidth={2} className="flex-none text-warning" />
|
||||
{t('tours.stats.unavailable')}
|
||||
<button
|
||||
type="button"
|
||||
onClick={stats.retry}
|
||||
className="font-semibold text-content underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{t('common.errorRetry')}
|
||||
</button>
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
|
||||
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { TourActivityTotals } from '@trek/shared';
|
||||
import { Layers, type LucideIcon } from 'lucide-react';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
|
||||
import { sumReading, type ActivityType } from './tourActivityStatsModel';
|
||||
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The distance unit the user reads in. */
|
||||
export function useDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
}
|
||||
|
||||
/** What a type is called in the filter. */
|
||||
export function typeLabel(type: ActivityType, t: Translate): string {
|
||||
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
|
||||
}
|
||||
|
||||
/** The icon a type is drawn with; every type at once is a stack. */
|
||||
export function typeIcon(type: ActivityType): LucideIcon {
|
||||
return type === 'all' ? Layers : tourDayRowIcon(type);
|
||||
}
|
||||
|
||||
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
|
||||
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
|
||||
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
|
||||
}
|
||||
|
||||
/** A height sum as said, the same way. */
|
||||
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
|
||||
return sumReading(sum, (metres) => formatElevation(metres, unit));
|
||||
}
|
||||
|
||||
/** How a short sum explains itself: known for some of the tours only. */
|
||||
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
|
||||
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// FE-STA2-MODEL-001 to FE-STA2-MODEL-007
|
||||
// STA-02: the periods and types the Atlas offers for the user's tour
|
||||
// activities, and how a sum reads when some tours do not know the value.
|
||||
import type { TourActivityStatsEntry } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
periodOptions,
|
||||
periodRange,
|
||||
periodYear,
|
||||
splitMeasure,
|
||||
sumReading,
|
||||
typeOptions,
|
||||
} from './tourActivityStatsModel';
|
||||
|
||||
const TODAY = '2026-10-20';
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 10,
|
||||
ascent_m: 500,
|
||||
descent_m: 400,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('tourActivityStatsModel', () => {
|
||||
it('FE-STA2-MODEL-001: each period covers its days, both ends included', () => {
|
||||
expect(periodRange('all', TODAY)).toEqual({ from: null, to: null });
|
||||
expect(periodRange('thisYear', TODAY)).toEqual({ from: '2026-01-01', to: '2026-12-31' });
|
||||
expect(periodRange('last12', TODAY)).toEqual({ from: '2025-10-20', to: TODAY });
|
||||
expect(periodRange('year:2024', TODAY)).toEqual({ from: '2024-01-01', to: '2024-12-31' });
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-002: offers the earlier years a done tour is in, the newest first, and no empty one', () => {
|
||||
const entries = [
|
||||
entry({ date: '2023-05-01' }),
|
||||
entry({ date: '2025-08-01' }),
|
||||
entry({ date: '2025-09-01' }),
|
||||
entry({ date: '2026-03-01' }),
|
||||
];
|
||||
expect(periodOptions(entries, TODAY)).toEqual(['all', 'thisYear', 'last12', 'year:2025', 'year:2023']);
|
||||
expect(periodOptions([], TODAY)).toEqual(['all', 'thisYear', 'last12']);
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-003: offers the types of done tours only, in planning order, older types after them', () => {
|
||||
const entries = [
|
||||
entry({ tour_type: 'kayak' }),
|
||||
entry({ tour_type: 'run' }),
|
||||
entry({ tour_type: 'hike' }),
|
||||
entry({ tour_type: 'bike', date: '2026-12-01' }),
|
||||
];
|
||||
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'run', 'kayak']);
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-004: reads the year of a year period only', () => {
|
||||
expect(periodYear('year:2024')).toBe(2024);
|
||||
expect(periodYear('thisYear')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-005: a sum nobody knows says nothing, a short one says at least', () => {
|
||||
const km = (value: number) => `${value} km`;
|
||||
expect(sumReading({ value: null, known: 0, of: 2 }, km)).toBeNull();
|
||||
expect(sumReading({ value: 12, known: 2, of: 2 }, km)).toEqual({ text: '12 km', partial: false });
|
||||
expect(sumReading({ value: 12, known: 1, of: 2 }, km)).toEqual({ text: '≥ 12 km', partial: true });
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-006: splits a measure into its number and unit', () => {
|
||||
expect(splitMeasure('128.4 km')).toEqual({ value: '128.4', unit: 'km' });
|
||||
expect(splitMeasure('≥ 12 km')).toEqual({ value: '≥ 12', unit: 'km' });
|
||||
expect(splitMeasure('12')).toEqual({ value: '12', unit: '' });
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-007: a tour still ahead offers neither its year nor its type', () => {
|
||||
expect(periodOptions([entry({ date: '2025-01-01' }), entry({ date: '2027-01-01' })], TODAY)).toEqual([
|
||||
'all',
|
||||
'thisYear',
|
||||
'last12',
|
||||
'year:2025',
|
||||
]);
|
||||
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => {
|
||||
const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })];
|
||||
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
isTourActivityDone,
|
||||
plannableTourTypeSchema,
|
||||
type TourActivityStatsEntry,
|
||||
type TourActivitySum,
|
||||
type TourTypeKey,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* STA-02: the periods and types the Atlas narrows the user's tour activities
|
||||
* by, and how a sum reads. Pure, so the hook and its views share one answer.
|
||||
*/
|
||||
|
||||
/** All time, the calendar year, the last twelve months, or one earlier year. */
|
||||
export type ActivityPeriod = 'all' | 'thisYear' | 'last12' | `year:${number}`;
|
||||
|
||||
/** One type, or every type at once. */
|
||||
export type ActivityType = TourTypeKey | 'all';
|
||||
|
||||
interface PeriodRange {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
/** The days a period covers, both included, for `today` (YYYY-MM-DD in the user's own calendar). */
|
||||
export function periodRange(period: ActivityPeriod, today: string): PeriodRange {
|
||||
const year = Number(today.slice(0, 4));
|
||||
if (period === 'thisYear') return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||
// A year back to the day, today included: the same day a year ago is the first one.
|
||||
if (period === 'last12') return { from: `${year - 1}${today.slice(4)}`, to: today };
|
||||
if (period.startsWith('year:')) {
|
||||
const chosen = period.slice(5);
|
||||
return { from: `${chosen}-01-01`, to: `${chosen}-12-31` };
|
||||
}
|
||||
return { from: null, to: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The periods worth offering: all time, this year and the last twelve months,
|
||||
* then each earlier year a done tour is in, the newest first. A year without
|
||||
* a tour would only ever show nothing.
|
||||
*/
|
||||
export function periodOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityPeriod[] {
|
||||
const thisYear = today.slice(0, 4);
|
||||
const years = new Set(
|
||||
entries
|
||||
.filter((entry) => isTourActivityDone(entry, today))
|
||||
.map((entry) => entry.date.slice(0, 4))
|
||||
.filter((year) => year < thisYear)
|
||||
);
|
||||
const earlier = [...years].sort((a, b) => b.localeCompare(a)).map((year): ActivityPeriod => `year:${Number(year)}`);
|
||||
return ['all', 'thisYear', 'last12', ...earlier];
|
||||
}
|
||||
|
||||
/** The types a done tour has, in the order tours are planned in, the older types after them. */
|
||||
export function typeOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityType[] {
|
||||
const present = new Set(entries.filter((entry) => isTourActivityDone(entry, today)).map((entry) => entry.tour_type));
|
||||
const planned: readonly string[] = plannableTourTypeSchema.options;
|
||||
const ordered = [...present].sort((a, b) => rank(planned, a) - rank(planned, b) || a.localeCompare(b));
|
||||
return ['all', ...ordered];
|
||||
}
|
||||
|
||||
function rank(order: readonly string[], value: string): number {
|
||||
const index = order.indexOf(value);
|
||||
return index === -1 ? order.length : index;
|
||||
}
|
||||
|
||||
/** The year of a `year:` period, or null for the others. */
|
||||
export function periodYear(period: ActivityPeriod): number | null {
|
||||
return period.startsWith('year:') ? Number(period.slice(5)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum as it is said: nothing when nobody knows the value, else the value,
|
||||
* marked as at least that much when some of the tours lack it.
|
||||
*/
|
||||
export function sumReading(
|
||||
sum: TourActivitySum,
|
||||
format: (value: number) => string
|
||||
): { text: string; partial: boolean } | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
return { text: partial ? `≥ ${format(sum.value)}` : format(sum.value), partial };
|
||||
}
|
||||
|
||||
/** A formatted measure split into its number and its unit, so the number can stand large ("128.4", "km"). */
|
||||
export function splitMeasure(text: string): { value: string; unit: string } {
|
||||
const at = text.lastIndexOf(' ');
|
||||
return at === -1 ? { value: text, unit: '' } : { value: text.slice(0, at), unit: text.slice(at + 1) };
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// FE-STA2-HOOK-001 to FE-STA2-HOOK-009
|
||||
// STA-02: the user's own tour activities are read when the tab is first shown,
|
||||
// narrowed by period and type, and a tour left out shows at once and comes back
|
||||
// with a message when the server refuses.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourActivityStatsEntry, TourActivityStatsResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useTourActivityStats } from './useTourActivityStats';
|
||||
|
||||
const repo = vi.hoisted(() => ({ get: vi.fn(), setExcluded: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../../../repo/tourActivityStatsRepo', () => ({
|
||||
tourActivityStatsRepo: repo,
|
||||
TourActivityStatsOfflineError: class TourActivityStatsOfflineError extends Error {},
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||
return {
|
||||
place_id: 1,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
trip: { id: 5, title: 'Alps' },
|
||||
derived_from_place_id: null,
|
||||
date: '2026-07-02',
|
||||
basis: 'plan',
|
||||
track: null,
|
||||
day_count: 1,
|
||||
distance_km: 10,
|
||||
ascent_m: 500,
|
||||
descent_m: 400,
|
||||
excluded: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const stats: TourActivityStatsResponse = {
|
||||
entries: [
|
||||
entry(),
|
||||
entry({ place_id: 2, name: 'Lake', tour_type: 'kayak', date: '2025-08-01', distance_km: 8 }),
|
||||
entry({ place_id: 3, name: 'Summit', date: '2026-11-01' }),
|
||||
],
|
||||
uncounted: { unassigned: 0, undated: 0 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date(2026, 9, 20, 12));
|
||||
repo.get.mockResolvedValue(stats);
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useTourActivityStats', () => {
|
||||
it('FE-STA2-HOOK-001: reads nothing until the tab is shown, then sums what is done', async () => {
|
||||
const { result, rerender } = renderHook(({ active }) => useTourActivityStats(active), {
|
||||
initialProps: { active: false },
|
||||
});
|
||||
expect(result.current.status).toBe('idle');
|
||||
expect(repo.get).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ active: true });
|
||||
expect(result.current.status).toBe('loading');
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(result.current.summary?.totals.count).toBe(2);
|
||||
expect(result.current.summary?.totals.distanceKm.value).toBe(18);
|
||||
expect(result.current.summary?.upcoming).toBe(1);
|
||||
expect(result.current.periods).toEqual(['all', 'thisYear', 'last12', 'year:2025']);
|
||||
expect(result.current.types).toEqual(['all', 'hike', 'kayak']);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-002: narrows by period and by type', async () => {
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
act(() => result.current.setPeriod('thisYear'));
|
||||
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([1]);
|
||||
act(() => {
|
||||
result.current.setPeriod('all');
|
||||
result.current.setType('kayak');
|
||||
});
|
||||
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-003: offline and failed reads are told apart, and a retry reads again', async () => {
|
||||
const { TourActivityStatsOfflineError } = await import('../../../repo/tourActivityStatsRepo');
|
||||
repo.get.mockRejectedValueOnce(new TourActivityStatsOfflineError());
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('offline'));
|
||||
|
||||
repo.get.mockRejectedValueOnce(new Error('boom'));
|
||||
act(() => result.current.retry());
|
||||
expect(result.current.status).toBe('loading');
|
||||
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
expect(repo.get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-004: leaving a tour out shows at once and takes the server answer', async () => {
|
||||
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||
repo.setExcluded.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
|
||||
let write: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
write = result.current.setExcluded(entry(), true);
|
||||
});
|
||||
expect(result.current.pending.has(1)).toBe(true);
|
||||
expect(result.current.summary?.totals.count).toBe(1);
|
||||
expect(repo.setExcluded).toHaveBeenCalledWith({ place_id: 1, excluded: true });
|
||||
|
||||
await act(async () => {
|
||||
const entries = stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item));
|
||||
answer({ ...stats, entries });
|
||||
await write;
|
||||
});
|
||||
expect(result.current.pending.size).toBe(0);
|
||||
expect(result.current.summary?.done.find((item) => item.place_id === 1)?.excluded).toBe(true);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-005: a refused exclusion counts the tour again and says so', async () => {
|
||||
repo.setExcluded.mockRejectedValue(new Error('nope'));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
await act(async () => {
|
||||
await result.current.setExcluded(entry(), true);
|
||||
});
|
||||
expect(result.current.summary?.totals.count).toBe(2);
|
||||
expect(result.current.pending.size).toBe(0);
|
||||
expect(toast.error).toHaveBeenCalledWith('tours.stats.excludeFailed');
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-006: a read answered after the tab went away is dropped', async () => {
|
||||
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||
repo.get.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const { result, unmount } = renderHook(() => useTourActivityStats(true));
|
||||
expect(result.current.status).toBe('loading');
|
||||
unmount();
|
||||
await act(async () => answer(stats));
|
||||
expect(result.current.data).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-007: a write before the first read leaves the empty state empty', async () => {
|
||||
repo.get.mockReturnValue(new Promise(() => undefined));
|
||||
repo.setExcluded.mockRejectedValue(new Error('offline'));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await act(async () => {
|
||||
await result.current.setExcluded(entry(), true);
|
||||
});
|
||||
expect(result.current.data).toBeNull();
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-008: an older exclusion answered last never puts a newer one back', async () => {
|
||||
const answers: Array<(value: TourActivityStatsResponse) => void> = [];
|
||||
repo.setExcluded.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
const excludedOf = (placeId: number) =>
|
||||
result.current.data?.entries.find((item) => item.place_id === placeId)?.excluded;
|
||||
const withExcluded = (...ids: number[]) => ({
|
||||
...stats,
|
||||
entries: stats.entries.map((item) => ({ ...item, excluded: ids.includes(item.place_id) })),
|
||||
});
|
||||
|
||||
let first: Promise<void> = Promise.resolve();
|
||||
let second: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
first = result.current.setExcluded(entry(), true);
|
||||
});
|
||||
act(() => {
|
||||
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, true);
|
||||
});
|
||||
// The second answer comes first and carries both; the first, from before the second, only its own.
|
||||
await act(async () => {
|
||||
answers[1]?.(withExcluded(1, 2));
|
||||
await second;
|
||||
});
|
||||
await act(async () => {
|
||||
answers[0]?.(withExcluded(1));
|
||||
await first;
|
||||
});
|
||||
expect(excludedOf(1)).toBe(true);
|
||||
expect(excludedOf(2)).toBe(true);
|
||||
|
||||
// The other way round: the first comes first, without the second still on its way, which stays left out.
|
||||
act(() => {
|
||||
first = result.current.setExcluded(entry(), false);
|
||||
});
|
||||
act(() => {
|
||||
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, false);
|
||||
});
|
||||
await act(async () => {
|
||||
answers[2]?.(withExcluded(2));
|
||||
await first;
|
||||
});
|
||||
expect(excludedOf(1)).toBe(false);
|
||||
expect(excludedOf(2)).toBe(false);
|
||||
await act(async () => {
|
||||
answers[3]?.(withExcluded());
|
||||
await second;
|
||||
});
|
||||
expect(excludedOf(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-STA2-HOOK-009: a read asked before an exclusion and answered after it does not undo it', async () => {
|
||||
const { result } = renderHook(() => useTourActivityStats(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||
let read: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||
repo.get.mockReturnValue(new Promise((resolve) => (read = resolve)));
|
||||
act(() => result.current.retry());
|
||||
repo.setExcluded.mockResolvedValue({
|
||||
...stats,
|
||||
entries: stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item)),
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.setExcluded(entry(), true);
|
||||
});
|
||||
await act(async () => read(stats));
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.data?.entries.find((item) => item.place_id === 1)?.excluded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
summarizeTourActivities,
|
||||
type TourActivityStatsEntry,
|
||||
type TourActivityStatsResponse,
|
||||
type TourActivitySummary,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from '../../../repo/tourActivityStatsRepo';
|
||||
import { localIsoDate } from '../../../utils/localDate';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import {
|
||||
periodOptions,
|
||||
periodRange,
|
||||
typeOptions,
|
||||
type ActivityPeriod,
|
||||
type ActivityType,
|
||||
} from './tourActivityStatsModel';
|
||||
|
||||
/** Where the read stands: not asked yet, on its way, there, refused offline, or failed. */
|
||||
type ActivityStatsStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error';
|
||||
|
||||
export interface TourActivityStats {
|
||||
status: ActivityStatsStatus;
|
||||
data: TourActivityStatsResponse | null;
|
||||
/** The sums of the chosen period and type; null until the first read is there. */
|
||||
summary: TourActivitySummary | null;
|
||||
period: ActivityPeriod;
|
||||
setPeriod: (period: ActivityPeriod) => void;
|
||||
periods: ActivityPeriod[];
|
||||
type: ActivityType;
|
||||
setType: (type: ActivityType) => void;
|
||||
types: ActivityType[];
|
||||
/** The tours whose exclusion is on its way to the server. */
|
||||
pending: ReadonlySet<number>;
|
||||
setExcluded: (entry: TourActivityStatsEntry, excluded: boolean) => Promise<void>;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/** What the latest read came back with, and the attempt it answered. */
|
||||
interface Answered {
|
||||
attempt: number;
|
||||
status: Exclude<ActivityStatsStatus, 'idle' | 'loading'>;
|
||||
}
|
||||
|
||||
/** Every entry of the tour with the flag as asked, the rest as they were. */
|
||||
function flagged(data: TourActivityStatsResponse, placeId: number, excluded: boolean): TourActivityStatsResponse {
|
||||
return {
|
||||
...data,
|
||||
entries: data.entries.map((entry) => (entry.place_id === placeId ? { ...entry, excluded } : entry)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every entry with the flags of the exclusions still on their way laid over it, the way they show already. */
|
||||
function withFlags(data: TourActivityStatsResponse, flags: ReadonlyMap<number, boolean>): TourActivityStatsResponse {
|
||||
if (flags.size === 0) return data;
|
||||
return {
|
||||
...data,
|
||||
entries: data.entries.map((entry) =>
|
||||
flags.has(entry.place_id) ? { ...entry, excluded: flags.get(entry.place_id) === true } : entry
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-02: the user's own tour activities in the Atlas. Read live when the tab
|
||||
* is first shown (and again on retry), never cached on the device; narrowed by
|
||||
* period and type here, summed by the shared summary the assistant uses too.
|
||||
* Leaving a tour out shows at once and is undone with a message when the
|
||||
* server refuses it; the plan of the tour never changes. Answers can come
|
||||
* back in any order: only the answer to the latest read or write is taken
|
||||
* whole, with the exclusions still on their way laid over it; an older one
|
||||
* only confirms its own tour's flag, so it never puts back a state from
|
||||
* before a newer change.
|
||||
*/
|
||||
export function useTourActivityStats(active: boolean): TourActivityStats {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
// The answer to the latest read and which attempt it answered: a retry is loading until its own answer is there.
|
||||
const [answered, setAnswered] = useState<Answered | null>(null);
|
||||
const [data, setData] = useState<TourActivityStatsResponse | null>(null);
|
||||
const [period, setPeriod] = useState<ActivityPeriod>('all');
|
||||
const [type, setType] = useState<ActivityType>('all');
|
||||
const [pending, setPending] = useState<ReadonlySet<number>>(() => new Set());
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const today = localIsoDate();
|
||||
// Raised by every read and every write, so an answer knows whether a newer request went out after it.
|
||||
const latest = useRef(0);
|
||||
// The exclusions on their way to the server, by tour: the flag the user asked for.
|
||||
const inFlight = useRef(new Map<number, boolean>());
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const controller = new AbortController();
|
||||
const asked = ++latest.current;
|
||||
tourActivityStatsRepo.get(controller.signal).then(
|
||||
(answer) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
|
||||
setAnswered({ attempt, status: 'ready' });
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setAnswered({ attempt, status: err instanceof TourActivityStatsOfflineError ? 'offline' : 'error' });
|
||||
}
|
||||
);
|
||||
return () => controller.abort();
|
||||
}, [active, attempt]);
|
||||
|
||||
let status: ActivityStatsStatus = 'loading';
|
||||
if (answered?.attempt === attempt) status = answered.status;
|
||||
else if (!active && !answered) status = 'idle';
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const range = periodRange(period, today);
|
||||
return summarizeTourActivities(data.entries, { today, ...range, type: type === 'all' ? null : type });
|
||||
}, [data, period, type, today]);
|
||||
const periods = useMemo(() => periodOptions(data?.entries ?? [], today), [data, today]);
|
||||
const types = useMemo(() => typeOptions(data?.entries ?? [], today), [data, today]);
|
||||
|
||||
const setExcluded = useCallback(
|
||||
async (entry: TourActivityStatsEntry, excluded: boolean) => {
|
||||
const placeId = entry.place_id;
|
||||
const asked = ++latest.current;
|
||||
inFlight.current.set(placeId, excluded);
|
||||
setPending((current) => new Set(current).add(placeId));
|
||||
setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||
try {
|
||||
const answer = await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded });
|
||||
inFlight.current.delete(placeId);
|
||||
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
|
||||
else setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||
} catch {
|
||||
inFlight.current.delete(placeId);
|
||||
setData((current) => (current ? flagged(current, placeId, !excluded) : current));
|
||||
toast.error(t('tours.stats.excludeFailed'));
|
||||
} finally {
|
||||
setPending((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(placeId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
|
||||
const retry = useCallback(() => setAttempt((count) => count + 1), []);
|
||||
|
||||
return {
|
||||
status,
|
||||
data,
|
||||
summary,
|
||||
period,
|
||||
setPeriod,
|
||||
periods,
|
||||
type,
|
||||
setType,
|
||||
types,
|
||||
pending,
|
||||
setExcluded,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// FE-FERRATA-CARD-001 to FE-FERRATA-CARD-009: the card for one picked via ferrata.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import { type FerrataGroup, groupFerratas } from './ferrataGroups';
|
||||
|
||||
/** About 222 m of latitude per segment. */
|
||||
const segment = (id: number, from: number, overrides: Partial<Ferrata> = {}): Ferrata => ({
|
||||
id: `way/${id}`,
|
||||
name: 'Via ferrata Pisciadù',
|
||||
grade: { raw: '5', level: 5, plus: '+' },
|
||||
routeName: 'Sella group',
|
||||
geometry: [
|
||||
[from, 11.81],
|
||||
[from + 0.002, 11.81],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const LOWER = segment(77, 46.53, { grade: { raw: '2', level: 2, plus: null } });
|
||||
const UPPER = segment(78, 46.532);
|
||||
|
||||
function groupOf(...segments: Ferrata[]): FerrataGroup {
|
||||
const [group] = groupFerratas(segments);
|
||||
if (!group) throw new Error('no group');
|
||||
return group;
|
||||
}
|
||||
|
||||
describe('FerrataDetailCard', () => {
|
||||
it('FE-FERRATA-CARD-001: shows the name, the route, the grade range and length as badges, and the attribution', () => {
|
||||
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Via ferrata Pisciadù' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Sella group')).toBeInTheDocument();
|
||||
// A badge, not a line colour: the grade is neutral, never the tour's green to red.
|
||||
expect(screen.getByText('K2 to K5+')).toHaveClass('text-content-secondary');
|
||||
expect(screen.getByText('445 m')).toBeInTheDocument();
|
||||
expect(screen.getByText('Data © OpenStreetMap contributors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-002: links to the way that was picked on openstreetmap.org, in a new tab', () => {
|
||||
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} />);
|
||||
const link = screen.getByRole('link', { name: /View in OpenStreetMap/ });
|
||||
|
||||
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/78');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-003: an unnamed ferrata takes its route name as title and does not repeat it', () => {
|
||||
const unnamed = segment(77, 46.53, { name: null });
|
||||
render(<FerrataDetailCard group={groupOf(unnamed)} segment={unnamed} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Sella group' })).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Sella group')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-004: one grade reads once; a letter scale keeps its raw grade, none at all reads unknown', () => {
|
||||
const { rerender } = render(<FerrataDetailCard group={groupOf(UPPER)} segment={UPPER} />);
|
||||
expect(screen.getByText('K5+')).toBeInTheDocument();
|
||||
|
||||
const letter = segment(80, 46.53, { grade: { raw: 'C/D', level: null, plus: null } });
|
||||
rerender(<FerrataDetailCard group={groupOf(letter)} segment={letter} />);
|
||||
expect(screen.getByText('C/D')).toHaveClass('text-content-secondary');
|
||||
|
||||
const bare = segment(81, 46.53, { name: null, routeName: null, grade: { raw: null, level: null, plus: null } });
|
||||
rerender(<FerrataDetailCard group={groupOf(bare)} segment={bare} />);
|
||||
expect(screen.getByText('Grade unknown')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Unnamed via ferrata' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-005: offers a close button only when it can be closed', () => {
|
||||
const onClose = vi.fn();
|
||||
const group = groupOf(UPPER);
|
||||
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<FerrataDetailCard group={group} segment={UPPER} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-006: separates nothing with a middle dot', () => {
|
||||
const { container } = render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={LOWER} />);
|
||||
expect(container.textContent).not.toContain('·');
|
||||
});
|
||||
it('FE-FERRATA-CARD-007: a long name wraps onto a second line instead of being cut to one', () => {
|
||||
const long = 'Herrmann von Barth Klettersteig über den Wetterstein';
|
||||
render(<FerrataDetailCard group={groupOf(segment(79, 46.6, { name: long }))} segment={UPPER} />);
|
||||
const heading = screen.getByRole('heading', { name: long });
|
||||
expect(heading).toHaveClass('line-clamp-2');
|
||||
expect(heading).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-008: offers "Add to tour" only where the tour can be edited, and hands over the ferrata', () => {
|
||||
const group = groupOf(LOWER, UPPER);
|
||||
const { rerender } = render(<FerrataDetailCard group={group} segment={UPPER} />);
|
||||
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
|
||||
|
||||
const onAdopt = vi.fn();
|
||||
rerender(<FerrataDetailCard group={group} segment={UPPER} onAdopt={onAdopt} />);
|
||||
fireEvent.click(screen.getByTestId('ferrata-adopt'));
|
||||
expect(onAdopt).toHaveBeenCalledWith(group);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CARD-009: a ferrata the tour already climbs says so instead of offering it again', () => {
|
||||
render(<FerrataDetailCard group={groupOf(LOWER, UPPER)} segment={UPPER} onAdopt={vi.fn()} onTour />);
|
||||
expect(screen.getByTestId('ferrata-on-tour')).toHaveTextContent('Part of the tour');
|
||||
expect(screen.queryByTestId('ferrata-adopt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Check, ExternalLink, MapPinPlus, MoveHorizontal, Route, X } from 'lucide-react';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { MAP_CARD_GLASS } from '../Map/mapControlShadow';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { ferrataOsmUrl } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* One via ferrata, picked on the map: its name, the route it belongs to, its
|
||||
* grade range and total length as badges, and the way back to the source.
|
||||
* `segment` is the OSM way that was clicked, which the source link opens.
|
||||
* OpenStreetMap's data is ODbL, which asks for the attribution next to
|
||||
* anything shown from it, so the card carries it rather than relying on the
|
||||
* map's own corner.
|
||||
*/
|
||||
export default function FerrataDetailCard({
|
||||
group,
|
||||
segment,
|
||||
onClose,
|
||||
onAdopt,
|
||||
onTour = false,
|
||||
width,
|
||||
}: {
|
||||
group: FerrataGroup;
|
||||
segment: Ferrata;
|
||||
onClose?: () => void;
|
||||
/** Puts the ferrata onto the tour being planned; absent where the tour cannot be edited. */
|
||||
onAdopt?: (group: FerrataGroup) => void;
|
||||
/** The tour on the map already climbs this ferrata: the card says so instead of offering it again. */
|
||||
onTour?: boolean;
|
||||
/** Set when the card stands on another control and takes its width. */
|
||||
width?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
const label = ferrataLabel(group, t);
|
||||
const name = label.name;
|
||||
// The route line only adds something when it is not already the title.
|
||||
const route = group.routeName && group.routeName !== name ? group.routeName : null;
|
||||
// Without any grade the badge says so, rather than leaving a gap.
|
||||
const grade = label.grade ?? t('ferratas.unknownGrade');
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="ferrata-detail-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Route size={16} className="mt-0.5 flex-none text-content-muted" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={name}>
|
||||
{name}
|
||||
</h3>
|
||||
{route && <p className="truncate text-caption text-content-muted">{route}</p>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<SoftPill>{grade}</SoftPill>
|
||||
{group.lengthM > 0 && (
|
||||
<SoftPill icon={<MoveHorizontal size={11} aria-hidden="true" />}>
|
||||
{formatDistance(group.lengthM / 1000, unit ?? 'metric')}
|
||||
</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
{onTour ? (
|
||||
<div className="mt-3 flex" data-testid="ferrata-on-tour">
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('ferratas.onTour')}
|
||||
</SoftPill>
|
||||
</div>
|
||||
) : (
|
||||
onAdopt && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAdopt(group)}
|
||||
data-testid="ferrata-adopt"
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-full bg-accent px-3 py-1.5 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPinPlus size={13} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('ferratas.adopt')}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<a
|
||||
href={ferrataOsmUrl(segment)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('ferratas.viewInOsm')}
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
|
||||
{t('ferratas.attribution')}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// FE-FERRATA-OVERLAY-001 to FE-FERRATA-OVERLAY-004: where the picked ferrata's card sits over the map, and how it closes.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import FerrataDetailOverlay from './FerrataDetailOverlay';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
|
||||
const FERRATA: Ferrata = {
|
||||
id: 'way/42',
|
||||
name: 'Via delle Trincee',
|
||||
grade: { raw: '4', level: 4, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[46.52, 11.8],
|
||||
[46.53, 11.81],
|
||||
],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/42',
|
||||
};
|
||||
|
||||
const [GROUP] = groupFerratas([FERRATA]);
|
||||
if (!GROUP) throw new Error('no group');
|
||||
const SELECTION: FerrataSelection = { group: GROUP, segment: FERRATA };
|
||||
|
||||
describe('FerrataDetailOverlay', () => {
|
||||
it('FE-FERRATA-OVERLAY-001: shows nothing while no ferrata is picked', () => {
|
||||
render(<FerrataDetailOverlay selection={null} onClose={vi.fn()} insetEnd={400} bottom={66} />);
|
||||
expect(screen.queryByTestId('ferrata-detail-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-OVERLAY-002: anchors the card at the bottom of the inline end, clear of the panel and the control row', () => {
|
||||
render(<FerrataDetailOverlay selection={SELECTION} onClose={vi.fn()} insetEnd={400} bottom={66} />);
|
||||
const overlay = screen.getByTestId('ferrata-detail-overlay');
|
||||
expect(overlay.style.insetInlineEnd).toBe('400px');
|
||||
// Logical only: no physical side, so a right-to-left page mirrors it.
|
||||
expect(overlay.style.right).toBe('');
|
||||
expect(overlay.style.left).toBe('');
|
||||
expect(overlay.style.bottom).toBe('66px');
|
||||
expect(overlay).toContainElement(screen.getByTestId('ferrata-detail-card'));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-OVERLAY-003: closes on a press outside it, not on one inside, and reports the outside press', () => {
|
||||
const onClose = vi.fn();
|
||||
const onOutsidePress = vi.fn();
|
||||
render(
|
||||
<div>
|
||||
<button type="button">map</button>
|
||||
<FerrataDetailOverlay
|
||||
selection={SELECTION}
|
||||
onClose={onClose}
|
||||
onOutsidePress={onOutsidePress}
|
||||
insetEnd={400}
|
||||
bottom={66}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
fireEvent.pointerDown(screen.getByText('Via delle Trincee'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onOutsidePress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-OVERLAY-004: closes on Escape and on its own close button', () => {
|
||||
const onClose = vi.fn();
|
||||
render(<FerrataDetailOverlay selection={SELECTION} onClose={onClose} insetEnd={400} bottom={66} />);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
|
||||
/**
|
||||
* The picked ferrata's card over the map, anchored at the bottom of the inline
|
||||
* end, above the row of map controls. `insetEnd` is what the panel on that
|
||||
* side covers plus the gap the layer pill keeps to the panel on the other, so
|
||||
* the card mirrors the pill and never slides under the panel; `bottom` clears
|
||||
* the control row (the layer pill on the flat map, the dock and the grade
|
||||
* legend in 3D).
|
||||
*
|
||||
* It closes on a press anywhere outside it and on Escape. A press on another
|
||||
* ferrata closes this card first and the click then opens that one's. The
|
||||
* press outside is handed to `onOutsidePress` as well, so the map under it can
|
||||
* leave the click that follows alone (see `useOutsidePressGuard`).
|
||||
*/
|
||||
export default function FerrataDetailOverlay({
|
||||
selection,
|
||||
onClose,
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
onAdopt,
|
||||
onTourIds,
|
||||
}: {
|
||||
selection: FerrataSelection | null;
|
||||
onClose: () => void;
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
/** A fixed width, to match what the card stands on (the grade legend in 3D). */
|
||||
width?: number;
|
||||
onAdopt?: (group: FerrataGroup) => void;
|
||||
/** The ferratas the tour on the map already climbs. */
|
||||
onTourIds?: ReadonlySet<string>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
|
||||
|
||||
if (!selection) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
|
||||
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
|
||||
data-testid="ferrata-detail-overlay"
|
||||
>
|
||||
<FerrataDetailCard
|
||||
group={selection.group}
|
||||
segment={selection.segment}
|
||||
onClose={onClose}
|
||||
onAdopt={onAdopt}
|
||||
onTour={onTourIds?.has(selection.group.id) ?? false}
|
||||
width={width}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The via ferrata mark as a React element: the ladder on a disc that the map
|
||||
* draws at a ferrata's midpoint, in the same `--map-ferrata` tokens, for the
|
||||
* places beside the map that point at one (a waypoint at its foot or top).
|
||||
*/
|
||||
export function FerrataGlyph({ size = 14, className = '' }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 22 22" aria-hidden="true" className={className}>
|
||||
<circle cx="11" cy="11" r="9.75" fill="var(--map-ferrata-casing)" stroke="var(--map-ferrata)" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6"
|
||||
fill="none"
|
||||
stroke="var(--map-ferrata)"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same ladder as a line icon, drawn the way lucide draws its icons (24 grid,
|
||||
* round caps, the current colour), for controls that sit among lucide icons:
|
||||
* the via ferrata switch in the map's layer pill.
|
||||
*/
|
||||
export function FerrataLineIcon({ size = 15, strokeWidth = 2 }: { size?: number; strokeWidth?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 3v18M16 3v18M8 7h8M8 12h8M8 17h8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import { type FerrataSelectHandler, useFerrataLayerGL } from './useFerrataLayerGL';
|
||||
|
||||
/**
|
||||
* The via ferrata layer as a mountable piece for a GL map view: it renders
|
||||
* nothing itself, the lines live on `map`. See useFerrataLayerGL for the
|
||||
* layering, the highlight and the click handling.
|
||||
*/
|
||||
export default function FerrataLayerGL({
|
||||
map,
|
||||
groups,
|
||||
onSelect,
|
||||
selectedId = null,
|
||||
}: {
|
||||
map: Map | null;
|
||||
groups: readonly FerrataGroup[];
|
||||
onSelect: FerrataSelectHandler;
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
useFerrataLayerGL(map, groups, onSelect, selectedId);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* FE-FERRATA-LEAFLET-001 to FE-FERRATA-LEAFLET-011: the via ferrata layer on
|
||||
* the Leaflet renderer.
|
||||
*
|
||||
* Leaflet itself is not under test: the mock reflects each polyline's and
|
||||
* marker's props back as data attributes, so the assertions read which lines
|
||||
* are drawn, in which order and with what options. Casing and line stay
|
||||
* non-interactive so they cannot swallow clicks meant for places; only the
|
||||
* invisible hit line on top carries the tooltip, the hover and the click.
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import FerrataLayerLeaflet from './FerrataLayerLeaflet';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
|
||||
vi.mock('../../i18n', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}(${Object.values(params).join(',')})` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const zoom = { value: 12 };
|
||||
const zoomEnd: { handler: (() => void) | null } = { handler: null };
|
||||
|
||||
type Handlers = Record<string, ((event: { latlng: { lat: number; lng: number } }) => void) | undefined>;
|
||||
const at = { latlng: { lat: 46.505, lng: 11.705 } };
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
Polyline: ({
|
||||
positions,
|
||||
pathOptions,
|
||||
interactive,
|
||||
bubblingMouseEvents,
|
||||
eventHandlers,
|
||||
children,
|
||||
}: {
|
||||
positions: unknown;
|
||||
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; className?: string };
|
||||
interactive?: boolean;
|
||||
bubblingMouseEvents?: boolean;
|
||||
eventHandlers?: Handlers;
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="polyline"
|
||||
data-positions={JSON.stringify(positions)}
|
||||
data-color={pathOptions.color}
|
||||
data-weight={pathOptions.weight}
|
||||
data-opacity={pathOptions.opacity}
|
||||
data-dash={pathOptions.dashArray ?? ''}
|
||||
data-class={pathOptions.className ?? ''}
|
||||
data-interactive={interactive === false ? 'false' : 'true'}
|
||||
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
|
||||
onClick={() => eventHandlers?.click?.(at)}
|
||||
onMouseOver={() => eventHandlers?.mouseover?.(at)}
|
||||
onMouseOut={() => eventHandlers?.mouseout?.(at)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Marker: ({ position, eventHandlers }: { position: [number, number]; eventHandlers?: Handlers }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ferrata-icon"
|
||||
data-position={JSON.stringify(position)}
|
||||
onClick={() => eventHandlers?.click?.(at)}
|
||||
/>
|
||||
),
|
||||
Tooltip: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<span data-testid="tooltip" data-class={className ?? ''}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
// A pixel per 0.00001 degree: enough to tell icons apart that sit a few metres from each other.
|
||||
useMap: () => ({
|
||||
getZoom: () => zoom.value,
|
||||
project: ([lat, lng]: [number, number]) => ({ x: lng * 100_000, y: -lat * 100_000 }),
|
||||
}),
|
||||
useMapEvents: (handlers: { zoomend?: () => void }) => {
|
||||
zoomEnd.handler = handlers.zoomend ?? null;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const ferrata = (
|
||||
id: string,
|
||||
name: string | null,
|
||||
level: number | null,
|
||||
options: { routeName?: string | null; lat?: number; lng?: number; plus?: '+' | null } = {}
|
||||
): Ferrata => {
|
||||
const lat = options.lat ?? 46.5;
|
||||
const lng = options.lng ?? 11.7;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
grade: { raw: level === null ? null : String(level), level, plus: options.plus ?? null },
|
||||
routeName: options.routeName ?? null,
|
||||
geometry: [
|
||||
[lat, lng],
|
||||
[lat + 0.01, Math.round((lng + 0.01) * 1e6) / 1e6],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
};
|
||||
};
|
||||
|
||||
const lines = () => screen.getAllByTestId('polyline');
|
||||
|
||||
beforeEach(() => {
|
||||
zoom.value = 12;
|
||||
});
|
||||
|
||||
describe('FerrataLayerLeaflet', () => {
|
||||
it('FE-FERRATA-LEAFLET-001: draws nothing for no ferratas', () => {
|
||||
const { container } = render(<FerrataLayerLeaflet groups={[]} onSelect={vi.fn()} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-002: a light casing, the dark dashed line, then the invisible hit line, in [lat, lng]', () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
root.style.setProperty('--map-ferrata-casing', 'white');
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
|
||||
|
||||
expect(lines().map((line) => line.dataset.weight)).toEqual(['6', '2.5', '16']);
|
||||
expect(lines().map((line) => line.dataset.color)).toEqual(['white', 'black', 'white']);
|
||||
expect(lines().map((line) => line.dataset.interactive)).toEqual(['false', 'false', 'true']);
|
||||
expect(lines()[1]?.dataset.dash).toBe('6 5');
|
||||
expect(lines()[2]?.dataset.opacity).toBe('0');
|
||||
expect(JSON.parse(lines()[0]?.dataset.positions ?? '[]')).toEqual([
|
||||
[46.5, 11.7],
|
||||
[46.51, 11.71],
|
||||
]);
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
root.style.removeProperty('--map-ferrata-casing');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-003: the tooltip names the ferrata and its grade range, or falls back to the route and unnamed', () => {
|
||||
const groups = groupFerratas([
|
||||
ferrata('way/1', 'Pisciadù', 2),
|
||||
// Picks up where way/1 ends: one ferrata in two segments.
|
||||
ferrata('way/2', 'Pisciadù', 4, { lat: 46.51, lng: 11.71, plus: '+' }),
|
||||
ferrata('way/3', null, 3, { routeName: 'Sentiero attrezzato', lat: 47 }),
|
||||
ferrata('way/4', null, null, { lat: 48 }),
|
||||
]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
expect(screen.getAllByTestId('tooltip').map((tip) => tip.textContent)).toEqual([
|
||||
'Pisciadùferratas.gradeRange(K2,K4+)',
|
||||
'Pisciadùferratas.gradeRange(K2,K4+)',
|
||||
'Sentiero attrezzatoK3',
|
||||
'ferratas.unnamed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-004: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
|
||||
const onSelect = vi.fn();
|
||||
const target = ferrata('way/9', 'Tridentina', 2);
|
||||
const groups = groupFerratas([target]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
|
||||
|
||||
const hit = lines()[2];
|
||||
if (!hit) throw new Error('no hit line');
|
||||
fireEvent.click(hit);
|
||||
expect(onSelect).toHaveBeenCalledWith(groups[0], target, { lng: 11.705, lat: 46.505 });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-005: the hit line keeps its click from reaching the map, so no control point lands under it', () => {
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
|
||||
expect(lines()[2]?.dataset.bubbles).toBe('false');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-006: the grade sits beside the name as its own label, without a separator', () => {
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 4)])} onSelect={vi.fn()} />);
|
||||
const tip = screen.getByTestId('tooltip');
|
||||
expect(tip.textContent).not.toContain('·');
|
||||
expect(tip.querySelector('span')?.textContent).toBe('K4');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-007: hovering a segment lights up the whole ferrata in the deeper violet, and so does the open card', () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata-lit', 'navy');
|
||||
const groups = groupFerratas([
|
||||
ferrata('way/1', 'Via A', 2),
|
||||
ferrata('way/2', 'Via A', 3, { lat: 46.51, lng: 11.71 }),
|
||||
ferrata('way/3', 'Via B', 3, { lat: 47 }),
|
||||
]);
|
||||
const { rerender } = render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
// Casings 0 to 2, lines 3 to 5, hit lines 6 to 8.
|
||||
const lineColours = () =>
|
||||
lines()
|
||||
.slice(3, 6)
|
||||
.map((line) => line.dataset.color);
|
||||
expect(lineColours()).not.toContain('navy');
|
||||
|
||||
const hit = lines()[7];
|
||||
if (!hit) throw new Error('no hit line');
|
||||
fireEvent.mouseOver(hit);
|
||||
expect(lineColours().map((colour) => colour === 'navy')).toEqual([true, true, false]);
|
||||
fireEvent.mouseOut(hit);
|
||||
expect(lineColours()).not.toContain('navy');
|
||||
|
||||
rerender(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[1]?.id ?? null} />);
|
||||
expect(lineColours().map((colour) => colour === 'navy')).toEqual([false, false, true]);
|
||||
root.style.removeProperty('--map-ferrata-lit');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-008: shows a route icon halfway along each ferrata only once zoomed in, and it opens the card', () => {
|
||||
const onSelect = vi.fn();
|
||||
const groups = groupFerratas([ferrata('way/1', 'Via A', 2), ferrata('way/2', 'Via B', 3, { lat: 47 })]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={onSelect} />);
|
||||
expect(screen.queryAllByTestId('ferrata-icon')).toHaveLength(0);
|
||||
|
||||
zoom.value = 14;
|
||||
act(() => zoomEnd.handler?.());
|
||||
const icons = screen.getAllByTestId('ferrata-icon');
|
||||
expect(icons).toHaveLength(2);
|
||||
const [lat, lng] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
|
||||
expect(lat).toBeCloseTo(46.505, 4);
|
||||
expect(lng).toBeCloseTo(11.705, 4);
|
||||
|
||||
fireEvent.click(icons[1] as HTMLElement);
|
||||
expect(onSelect).toHaveBeenCalledWith(groups[1], groups[1]?.segments[0], { lng: 11.705, lat: 46.505 });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-009: a theme change recolours the lines without a refetch', async () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
render(<FerrataLayerLeaflet groups={groupFerratas([ferrata('way/1', 'Pisciadù', 3)])} onSelect={vi.fn()} />);
|
||||
expect(lines()[1]?.dataset.color).toBe('black');
|
||||
|
||||
await act(async () => {
|
||||
root.style.setProperty('--map-ferrata', 'ivory');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(lines()[1]?.dataset.color).toBe('ivory'));
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
});
|
||||
it('FE-FERRATA-LEAFLET-010: a lit ferrata is drawn solid and wider on a wider casing, the hit line and tooltip carry their classes', () => {
|
||||
const groups = groupFerratas([ferrata('way/1', 'Via A', 2)]);
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} selectedId={groups[0]?.id ?? null} />);
|
||||
expect(lines().map((line) => line.dataset.weight)).toEqual(['9', '4', '16']);
|
||||
expect(lines()[1]?.dataset.dash).toBe('');
|
||||
expect(lines()[2]?.dataset.class).toBe('trek-ferrata-hit');
|
||||
expect(screen.getByTestId('tooltip').dataset.class).toBe('map-tooltip');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-LEAFLET-011: of two icons that would touch only the longer ferrata keeps its own', () => {
|
||||
const short = ferrata('way/1', 'Via A', 2);
|
||||
const long: Ferrata = {
|
||||
...ferrata('way/2', 'Via B', 3, { lat: 46.5001 }),
|
||||
geometry: [
|
||||
[46.4951, 11.695],
|
||||
[46.5151, 11.715],
|
||||
],
|
||||
};
|
||||
const groups = groupFerratas([short, long]);
|
||||
zoom.value = 14;
|
||||
render(<FerrataLayerLeaflet groups={groups} onSelect={vi.fn()} />);
|
||||
const icons = screen.getAllByTestId('ferrata-icon');
|
||||
expect(icons).toHaveLength(1);
|
||||
const [lat] = JSON.parse(icons[0]?.dataset.position ?? '[]') as [number, number];
|
||||
expect(lat).toBeCloseTo(46.5051, 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import L from 'leaflet';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
import type { FerrataSelectHandler } from './useFerrataLayerGL';
|
||||
import type { FerrataMapLayer } from './useFerrataMapLayer';
|
||||
import { useFerrataPaint } from './useFerrataPaint';
|
||||
|
||||
/**
|
||||
* The route icon for Leaflet: the shared SVG in a divIcon, painted straight
|
||||
* from the tokens, so a theme change restyles it without rebuilding anything.
|
||||
* Built once; it carries no user content.
|
||||
*/
|
||||
/** The hit line's class, which index.css keeps free of the focus ring a clicked path draws. */
|
||||
const FERRATA_HIT_CLASS = 'trek-ferrata-hit';
|
||||
|
||||
const ICON = L.divIcon({
|
||||
html: ferrataIconSvg({ line: 'var(--map-ferrata)', casing: 'var(--map-ferrata-casing)' }),
|
||||
className: 'trek-ferrata-icon',
|
||||
iconSize: [FERRATA_ICON_SIZE, FERRATA_ICON_SIZE],
|
||||
iconAnchor: [FERRATA_ICON_SIZE / 2, FERRATA_ICON_SIZE / 2],
|
||||
});
|
||||
|
||||
/**
|
||||
* The ferratas whose icon shows: none below FERRATA_ICON_MIN_ZOOM, and above
|
||||
* it only those whose icon touches no other one at this zoom, the longer
|
||||
* ferrata first. Leaflet has no symbol placement of its own, so this is what
|
||||
* GL's `icon-allow-overlap: false` does there. Positions are projected at the
|
||||
* zoom, which a pan does not change, so only a zoom or new ferratas place again.
|
||||
*/
|
||||
function useIconGroups(groups: readonly FerrataGroup[]): readonly FerrataGroup[] {
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
|
||||
return useMemo(() => {
|
||||
if (zoom < FERRATA_ICON_MIN_ZOOM) return [];
|
||||
const longestFirst = [...groups].sort((a, b) => b.lengthM - a.lengthM);
|
||||
const shown = placeFerrataIcons(
|
||||
longestFirst.map((group) => {
|
||||
const { x, y } = map.project(group.midpoint, zoom);
|
||||
return { id: group.id, x, y };
|
||||
})
|
||||
);
|
||||
return groups.filter((group) => shown.has(group.id));
|
||||
}, [map, groups, zoom]);
|
||||
}
|
||||
|
||||
/** The tooltip's words: the ferrata's name and its grade. */
|
||||
function FerrataTooltipText({ group }: { group: FerrataGroup }) {
|
||||
const { t } = useTranslation();
|
||||
const { name, grade } = ferrataLabel(group, t);
|
||||
return (
|
||||
<>
|
||||
{name}
|
||||
{grade && <span className="ms-1.5 font-semibold">{grade}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata layer on the Leaflet renderer, in the same construction as
|
||||
* the GL layer: per OSM way a light casing and the dark dashed line, the whole
|
||||
* ferrata lit (solid, wider, on a wider casing, in the highlight colour) while
|
||||
* the pointer is on it or its card is open, a sticky tooltip in the map's
|
||||
* tooltip look naming it with its grade, and from FERRATA_ICON_MIN_ZOOM a
|
||||
* small route icon halfway along it where it does not touch another one. A
|
||||
* click hands the ferrata and the way to `onSelect`, like a click on the GL
|
||||
* layer does.
|
||||
*/
|
||||
export default function FerrataLayerLeaflet({
|
||||
groups,
|
||||
onSelect,
|
||||
selectedId = null,
|
||||
}: {
|
||||
groups: readonly FerrataGroup[];
|
||||
onSelect: FerrataSelectHandler;
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
const paint = useFerrataPaint();
|
||||
const iconGroups = useIconGroups(groups);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
|
||||
const lines = useMemo(() => {
|
||||
const casing = { color: paint.casing, weight: 6, opacity: 0.9, lineCap: 'round', lineJoin: 'round' } as const;
|
||||
const litCasing = { ...casing, weight: 9, opacity: 1 } as const;
|
||||
const line = {
|
||||
color: paint.line,
|
||||
weight: 2.5,
|
||||
opacity: 1,
|
||||
dashArray: '6 5',
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
} as const;
|
||||
// Solid: an empty dash list clears the dashes Leaflet would otherwise keep from the line's options.
|
||||
const highlight = {
|
||||
color: paint.highlight,
|
||||
weight: 4,
|
||||
opacity: 1,
|
||||
dashArray: '',
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
} as const;
|
||||
// The class lets the stylesheet drop the focus ring a clicked SVG path keeps; the card has its own focus.
|
||||
const hit = { color: paint.casing, weight: 16, opacity: 0, className: FERRATA_HIT_CLASS } as const;
|
||||
return groups.flatMap((group) =>
|
||||
group.segments.map((segment) => ({
|
||||
group,
|
||||
segment,
|
||||
positions: ferrataPositions(segment),
|
||||
casing,
|
||||
litCasing,
|
||||
line,
|
||||
highlight,
|
||||
hit,
|
||||
}))
|
||||
);
|
||||
}, [groups, paint]);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
|
||||
return (
|
||||
<>
|
||||
{lines.map(({ group, segment, positions, casing, litCasing }) => (
|
||||
<Polyline
|
||||
key={`${segment.id}-casing`}
|
||||
positions={positions}
|
||||
pathOptions={lit(group) ? litCasing : casing}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{lines.map(({ group, segment, positions, line, highlight }) => (
|
||||
<Polyline
|
||||
key={`${segment.id}-line`}
|
||||
positions={positions}
|
||||
pathOptions={lit(group) ? highlight : line}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* A wide, invisible line on top carries the tooltip, the hover and the click, so a
|
||||
thin dashed route is still easy to hit with a finger. The click stops here: it
|
||||
opens the ferrata's card and must not also drop a control point under it. */}
|
||||
{lines.map(({ group, segment, positions, hit }) => (
|
||||
<Polyline
|
||||
key={`${segment.id}-hit`}
|
||||
positions={positions}
|
||||
pathOptions={hit}
|
||||
bubblingMouseEvents={false}
|
||||
eventHandlers={{
|
||||
click: (event) => onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat }),
|
||||
mouseover: () => setHovered(group.id),
|
||||
mouseout: () => setHovered((current) => (current === group.id ? null : current)),
|
||||
}}
|
||||
>
|
||||
<Tooltip sticky direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">
|
||||
<FerrataTooltipText group={group} />
|
||||
</Tooltip>
|
||||
</Polyline>
|
||||
))}
|
||||
{iconGroups.map((group) => (
|
||||
<Marker
|
||||
key={`${group.id}-icon`}
|
||||
position={group.midpoint}
|
||||
icon={ICON}
|
||||
keyboard={false}
|
||||
eventHandlers={{
|
||||
click: (event) => {
|
||||
const segment = group.segments[0];
|
||||
if (segment) onSelect(group, segment, { lng: event.latlng.lng, lat: event.latlng.lat });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The layer as the Leaflet map view mounts it from a `FerrataMapLayer`: drawn only while it is switched on. */
|
||||
export function FerrataMapLayerLeaflet({ layer }: { layer: FerrataMapLayer | undefined }) {
|
||||
if (!layer?.active) return null;
|
||||
return <FerrataLayerLeaflet groups={layer.groups} onSelect={layer.onSelect} selectedId={layer.selectedId} />;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// FE-FERRATA-CROSS-001 to FE-FERRATA-CROSS-009: where a tour's line climbs a via ferrata, and the tiles along it.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { crossingLegs, ferrataEnds, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
|
||||
/** About 11 m of latitude. */
|
||||
const STEP = 0.0001;
|
||||
|
||||
function ferrata(id: number, from: [number, number], steps: number): Ferrata {
|
||||
return {
|
||||
id: `way/${id}`,
|
||||
name: 'Via Test',
|
||||
routeName: null,
|
||||
grade: { raw: '3', level: 3, plus: null },
|
||||
geometry: Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]] as [number, number]),
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** A line north from a point, one point every STEP. */
|
||||
function northFrom(from: [number, number], steps: number): [number, number][] {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => [from[0] + i * STEP, from[1]]);
|
||||
}
|
||||
|
||||
describe('routeFerrataCrossings', () => {
|
||||
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 20)]);
|
||||
|
||||
it('FE-FERRATA-CROSS-001: a route climbing along the ferrata gives one stretch, measured along the route', () => {
|
||||
const route = [...northFrom([47.399, 11.0], 9), ...northFrom([47.4, 11.0], 20)];
|
||||
const [stretch, ...rest] = routeFerrataCrossings(route, groups);
|
||||
expect(rest).toEqual([]);
|
||||
expect(stretch.group.id).toBe(groups[0].id);
|
||||
expect(stretch.fromM).toBeGreaterThan(95);
|
||||
expect(stretch.toM - stretch.fromM).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-002: a trail that only crosses the ferrata is no stretch', () => {
|
||||
const across: [number, number][] = Array.from({ length: 21 }, (_, i) => [47.401, 10.999 + i * STEP]);
|
||||
expect(routeFerrataCrossings(across, groups)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-003: a route nowhere near a ferrata, or no route, gives nothing', () => {
|
||||
expect(routeFerrataCrossings(northFrom([46.0, 10.0], 20), groups)).toEqual([]);
|
||||
expect(routeFerrataCrossings(null, groups)).toEqual([]);
|
||||
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 20), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-CROSS-004: a short gap in the data does not split the stretch', () => {
|
||||
// Two pieces of one ferrata with 44 m missing between them: the route point
|
||||
// in the middle of the hole is out of reach, the ones beside it are not.
|
||||
const pieces = groupFerratas([ferrata(2, [47.4, 11.0], 10), ferrata(3, [47.4 + 14 * STEP, 11.0], 11)]);
|
||||
expect(pieces).toHaveLength(1);
|
||||
expect(routeFerrataCrossings(northFrom([47.4, 11.0], 25), pieces)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeFerrataCrossings at the end of a ferrata', () => {
|
||||
it('FE-FERRATA-CROSS-007: a route that only runs beside the end of a long ferrata is no stretch', () => {
|
||||
const groups = groupFerratas([ferrata(1, [47.4, 11.0], 40)]);
|
||||
// Ten points (about 100 m) along the ferrata's top, then away.
|
||||
const route = [...northFrom([47.4033, 11.0], 9), ...northFrom([47.405, 11.0], 10)];
|
||||
expect(routeFerrataCrossings(route, groups)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeFerrataCrossings beside a second ferrata', () => {
|
||||
it('FE-FERRATA-CROSS-008: where two ferratas run a few metres apart, the route belongs to the nearer one', () => {
|
||||
const near = ferrata(1, [47.4, 11.0], 30);
|
||||
// A second ferrata about 15 m to the east, along the same stretch.
|
||||
const beside = { ...ferrata(2, [47.4, 11.0002], 30), name: 'Via Beside' };
|
||||
const groups = groupFerratas([beside, near]);
|
||||
const [stretch, ...rest] = routeFerrataCrossings(northFrom([47.4, 11.0], 30), groups);
|
||||
expect(rest).toEqual([]);
|
||||
expect(stretch.group.name).toBe('Via Test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('crossingLegs', () => {
|
||||
it('FE-FERRATA-CROSS-009: places waypoints in order along a route that passes the same spot twice, and a stretch by its middle', () => {
|
||||
// Out 300 m north and back: the turn point is waypoint b, and a and c stand at the same spot.
|
||||
const samples = [0, 100, 200, 300, 400, 500, 600].map((distanceMeters, i) => ({
|
||||
distanceMeters,
|
||||
lat: 47.4 + (i <= 3 ? i : 6 - i) * 0.0009,
|
||||
lng: 11,
|
||||
}));
|
||||
const waypoints = [
|
||||
{ id: 'a', lat: 47.4, lng: 11 },
|
||||
{ id: 'b', lat: 47.4027, lng: 11 },
|
||||
{ id: 'c', lat: 47.4, lng: 11 },
|
||||
];
|
||||
expect(
|
||||
crossingLegs(waypoints, samples, [
|
||||
{ fromM: 50, toM: 250 },
|
||||
{ fromM: 280, toM: 520 },
|
||||
])
|
||||
).toEqual(['a', 'b']);
|
||||
expect(crossingLegs([], samples, [{ fromM: 50, toM: 80 }])).toEqual([null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataTilesAlong', () => {
|
||||
it('FE-FERRATA-CROSS-005: names each grid tile the route passes once, and gives up past the cap', () => {
|
||||
const tiles = ferrataTilesAlong(northFrom([47.4, 11.0], 20));
|
||||
expect(tiles).toHaveLength(1);
|
||||
const long: [number, number][] = Array.from({ length: 400 }, (_, i) => [40 + i * 0.06, 11]);
|
||||
expect(ferrataTilesAlong(long)).toBeNull();
|
||||
expect(ferrataTilesAlong(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataEnds', () => {
|
||||
it('FE-FERRATA-CROSS-006: the two ends farthest apart, across the segments of one ferrata', () => {
|
||||
const [group] = groupFerratas([ferrata(1, [47.4, 11.0], 5), ferrata(2, [47.4005, 11.0], 5)]);
|
||||
const ends = ferrataEnds(group);
|
||||
expect(ends).not.toBeNull();
|
||||
const lats = ends ? ends.map(([lat]) => lat).sort() : [];
|
||||
expect(lats[0]).toBeCloseTo(47.4, 6);
|
||||
expect(lats[1]).toBeCloseTo(47.401, 6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { ferrataTileAt, ferrataTileKey, type FerrataTile } from '@trek/shared';
|
||||
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* Where a tour's line runs along a via ferrata. The router walks a ferrata
|
||||
* like any trail once the difficulty cap allows alpine routes (T4 and up), so
|
||||
* a tour can climb one without anyone having picked it; this finds those
|
||||
* stretches on the line the planner shows, for the warning badge and the band
|
||||
* on the elevation profile.
|
||||
*/
|
||||
export interface FerrataCrossing {
|
||||
group: FerrataGroup;
|
||||
/** Where the stretch starts and ends, in metres along the route, as the elevation profile counts them. */
|
||||
fromM: number;
|
||||
toM: number;
|
||||
}
|
||||
|
||||
/** How close a route point has to come to a ferrata to count as on it. */
|
||||
const ON_FERRATA_M = 20;
|
||||
/**
|
||||
* A shorter run is a trail crossing the ferrata or meeting it at an end, not a
|
||||
* climb along it. A ferrata shorter than that counts once most of it is walked.
|
||||
*/
|
||||
const MIN_STRETCH_M = 150;
|
||||
const MIN_SHARE_OF_SHORT_FERRATA = 0.6;
|
||||
/** Two stretches on the same ferrata this close together are one: a gap in the data, not a way off it. */
|
||||
const JOIN_GAP_M = 30;
|
||||
/** The most grid tiles a route asks for, about a thousand kilometres of line. */
|
||||
const MAX_ROUTE_TILES = 200;
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
function distanceM([lat1, lng1]: readonly [number, number], [lat2, lng2]: readonly [number, number]): number {
|
||||
const dLat = (lat2 - lat1) * RAD;
|
||||
const dLng = (lng2 - lng1) * RAD;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
/** The distance from a point to a polyline, in metres, on a flat projection around the point (fine at these scales). */
|
||||
function distanceToLineM(point: readonly [number, number], line: readonly [number, number][]): number {
|
||||
const metresPerLat = EARTH_RADIUS_M * RAD;
|
||||
const metresPerLng = metresPerLat * Math.cos(point[0] * RAD);
|
||||
const project = ([lat, lng]: readonly [number, number]) => [
|
||||
(lng - point[1]) * metresPerLng,
|
||||
(lat - point[0]) * metresPerLat,
|
||||
];
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
for (let i = 1; i < line.length; i += 1) {
|
||||
const [ax, ay] = project(line[i - 1]);
|
||||
const [bx, by] = project(line[i]);
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const span = dx * dx + dy * dy;
|
||||
const t = span === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / span));
|
||||
best = Math.min(best, Math.hypot(ax + t * dx, ay + t * dy));
|
||||
}
|
||||
if (line.length === 1) {
|
||||
const [x, y] = project(line[0]);
|
||||
best = Math.hypot(x, y);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Whether a point lies inside a group's box widened by the reach. */
|
||||
function nearBox(point: readonly [number, number], group: FerrataGroup): boolean {
|
||||
const padLat = ON_FERRATA_M / (EARTH_RADIUS_M * RAD);
|
||||
const padLng = padLat / Math.max(0.1, Math.cos(point[0] * RAD));
|
||||
const { south, north, west, east } = group.bbox;
|
||||
return (
|
||||
point[0] >= south - padLat && point[0] <= north + padLat && point[1] >= west - padLng && point[1] <= east + padLng
|
||||
);
|
||||
}
|
||||
|
||||
/** The ferrata a route point is on, if any. */
|
||||
function ferrataAt(
|
||||
point: readonly [number, number],
|
||||
groups: readonly FerrataGroup[],
|
||||
lines: ReadonlyMap<string, [number, number][][]>
|
||||
): FerrataGroup | null {
|
||||
// The nearest one within reach: two ferratas often start a few metres apart at the same foot.
|
||||
let nearest: FerrataGroup | null = null;
|
||||
let nearestM = ON_FERRATA_M;
|
||||
for (const group of groups) {
|
||||
if (!nearBox(point, group)) continue;
|
||||
for (const line of lines.get(group.id) ?? []) {
|
||||
const gap = distanceToLineM(point, line);
|
||||
if (gap <= nearestM) {
|
||||
nearestM = gap;
|
||||
nearest = group;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stretches of the route that run along a ferrata, in route order. A
|
||||
* stretch is measured between the route points that lie on the ferrata, so a
|
||||
* trail that only crosses one or meets its foot stays out.
|
||||
*/
|
||||
export function routeFerrataCrossings(
|
||||
route: readonly (readonly [number, number])[] | null | undefined,
|
||||
groups: readonly FerrataGroup[]
|
||||
): FerrataCrossing[] {
|
||||
if (!route || route.length < 2 || groups.length === 0) return [];
|
||||
const lines = new Map(groups.map((group) => [group.id, group.segments.map(ferrataPositions)]));
|
||||
const stretches: FerrataCrossing[] = [];
|
||||
let along = 0;
|
||||
let open: FerrataCrossing | null = null;
|
||||
for (let i = 0; i < route.length; i += 1) {
|
||||
if (i > 0) along += distanceM(route[i - 1], route[i]);
|
||||
const group = ferrataAt(route[i], groups, lines);
|
||||
if (open && group === open.group) {
|
||||
open.toM = along;
|
||||
continue;
|
||||
}
|
||||
if (open) stretches.push(open);
|
||||
open = group ? { group, fromM: along, toM: along } : null;
|
||||
}
|
||||
if (open) stretches.push(open);
|
||||
|
||||
const joined: FerrataCrossing[] = [];
|
||||
for (const stretch of stretches) {
|
||||
const last = joined[joined.length - 1];
|
||||
if (last && last.group === stretch.group && stretch.fromM - last.toM <= JOIN_GAP_M) last.toM = stretch.toM;
|
||||
else joined.push({ ...stretch });
|
||||
}
|
||||
return joined.filter(
|
||||
(stretch) =>
|
||||
stretch.toM - stretch.fromM >= Math.min(MIN_STRETCH_M, stretch.group.lengthM * MIN_SHARE_OF_SHORT_FERRATA)
|
||||
);
|
||||
}
|
||||
|
||||
/** The grid tiles the route runs through, so the ferratas along it can be loaded with the layer off; null past the cap. */
|
||||
export function ferrataTilesAlong(
|
||||
route: readonly (readonly [number, number])[] | null | undefined
|
||||
): FerrataTile[] | null {
|
||||
if (!route || route.length < 2) return [];
|
||||
const tiles = new Map<string, FerrataTile>();
|
||||
for (const [lat, lng] of route) {
|
||||
const tile = ferrataTileAt(lat, lng);
|
||||
tiles.set(ferrataTileKey(tile), tile);
|
||||
if (tiles.size > MAX_ROUTE_TILES) return null;
|
||||
}
|
||||
return [...tiles.values()];
|
||||
}
|
||||
|
||||
/** The two ends of a ferrata that lie farthest apart, the start and the top of the climb in either order. */
|
||||
export function ferrataEnds(group: FerrataGroup): [[number, number], [number, number]] | null {
|
||||
const ends = group.segments.flatMap((segment) => {
|
||||
const line = ferrataPositions(segment);
|
||||
const first = line[0];
|
||||
const last = line[line.length - 1];
|
||||
return first && last ? [first, last] : [];
|
||||
});
|
||||
let best: [[number, number], [number, number]] | null = null;
|
||||
let bestM = -1;
|
||||
for (let i = 0; i < ends.length; i += 1) {
|
||||
for (let j = i + 1; j < ends.length; j += 1) {
|
||||
const span = distanceM(ends[i], ends[j]);
|
||||
if (span > bestM) {
|
||||
bestM = span;
|
||||
best = [ends[i], ends[j]];
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The widest gap two pieces of one ferrata may leave between them and still be walked as one line. */
|
||||
const CHAIN_GAP_M = 80;
|
||||
|
||||
/**
|
||||
* The ferrata as one line from `from` (one of its ends) to its other end: its
|
||||
* OSM pieces, which arrive in any order and either direction, put end to end,
|
||||
* each turned the way the walk goes. Null when the pieces do not join up.
|
||||
*/
|
||||
export function ferrataLine(group: FerrataGroup, from: [number, number]): [number, number][] | null {
|
||||
const pieces = group.segments.map((segment) => ferrataPositions(segment)).filter((line) => line.length >= 2);
|
||||
if (pieces.length === 0) return null;
|
||||
const line: [number, number][] = [];
|
||||
let tail = from;
|
||||
while (pieces.length > 0) {
|
||||
let bestIndex = -1;
|
||||
let bestReversed = false;
|
||||
let bestGap = Number.POSITIVE_INFINITY;
|
||||
pieces.forEach((piece, index) => {
|
||||
const startGap = distanceM(tail, piece[0]);
|
||||
const endGap = distanceM(tail, piece[piece.length - 1]);
|
||||
if (startGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, false, startGap];
|
||||
if (endGap < bestGap) [bestIndex, bestReversed, bestGap] = [index, true, endGap];
|
||||
});
|
||||
if (bestGap > CHAIN_GAP_M) return null;
|
||||
const [piece] = pieces.splice(bestIndex, 1);
|
||||
const oriented = bestReversed ? [...piece].reverse() : piece;
|
||||
line.push(...(line.length > 0 ? oriented.slice(1) : oriented));
|
||||
tail = oriented[oriented.length - 1];
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/** How close a waypoint has to stand to a ferrata's end to count as at its foot or top. */
|
||||
const AT_END_M = 40;
|
||||
|
||||
/**
|
||||
* The ferrata each waypoint stands at an end of, by waypoint id, among the
|
||||
* ferratas the route climbs: the ones "Add to tour" set, or any placed there.
|
||||
*/
|
||||
export function waypointsAtFerrataEnds<T>(
|
||||
waypoints: readonly { id: string; lat: number; lng: number }[],
|
||||
crossings: readonly FerrataCrossing[],
|
||||
describe: (group: FerrataGroup) => T
|
||||
): Record<string, T> {
|
||||
const marks: Record<string, T> = {};
|
||||
const groups = [...new Map(crossings.map(({ group }) => [group.id, group])).values()];
|
||||
for (const point of waypoints) {
|
||||
const at = groups.find((group) =>
|
||||
group.segments.some((segment) => {
|
||||
const line = ferrataPositions(segment);
|
||||
const first = line[0];
|
||||
const last = line[line.length - 1];
|
||||
return [first, last].some((end) => !!end && distanceM([point.lat, point.lng], end) <= AT_END_M);
|
||||
})
|
||||
);
|
||||
if (at) marks[point.id] = describe(at);
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each stretch, the waypoint it follows on the route: each waypoint is
|
||||
* placed at the nearest point of the line after the waypoint before it
|
||||
* (`samples`, metres along it as the profile counts them), and a stretch
|
||||
* belongs to the leg its middle lies on. Null
|
||||
* before the first waypoint or without any.
|
||||
*/
|
||||
export function crossingLegs(
|
||||
waypoints: readonly { id: string; lat: number; lng: number }[],
|
||||
samples: readonly { distanceMeters: number; lat?: number; lng?: number }[],
|
||||
crossings: readonly Pick<FerrataCrossing, 'fromM' | 'toM'>[]
|
||||
): Array<string | null> {
|
||||
// In order along the line: each waypoint is looked for from where the one
|
||||
// before it stands, so a route that passes a spot twice keeps its legs apart.
|
||||
let from = 0;
|
||||
const along = waypoints.map((point) => {
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
let at = from;
|
||||
for (const sample of samples) {
|
||||
if (sample.distanceMeters < from || sample.lat == null || sample.lng == null) continue;
|
||||
const gap = distanceM([point.lat, point.lng], [sample.lat, sample.lng]);
|
||||
if (gap < best) {
|
||||
best = gap;
|
||||
at = sample.distanceMeters;
|
||||
}
|
||||
}
|
||||
from = at;
|
||||
return { id: point.id, at };
|
||||
});
|
||||
return crossings.map((crossing) => {
|
||||
// By its middle: the trail to a ferrata's foot often runs beside it for a few metres first.
|
||||
const middle = (crossing.fromM + crossing.toM) / 2;
|
||||
let after: string | null = null;
|
||||
for (const point of along) if (point.at <= middle) after = point.id;
|
||||
return after;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// FE-FERRATA-GROUP-001 to FE-FERRATA-GROUP-009: OSM's short segments read back as the ferratas a climber means.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import {
|
||||
FERRATA_NAME_REACH_M,
|
||||
ferrataBboxesMeet,
|
||||
ferrataGradeRange,
|
||||
ferrataGroupOf,
|
||||
ferrataGroupsInView,
|
||||
groupFerratas,
|
||||
} from './ferrataGroups';
|
||||
|
||||
/** About 111 m of latitude. */
|
||||
const STEP = 0.001;
|
||||
|
||||
function segment(
|
||||
id: number,
|
||||
from: [number, number],
|
||||
options: {
|
||||
name?: string | null;
|
||||
routeName?: string | null;
|
||||
level?: number | null;
|
||||
plus?: '+' | '-' | null;
|
||||
raw?: string;
|
||||
} = {}
|
||||
): Ferrata {
|
||||
const level = options.level === undefined ? 3 : options.level;
|
||||
return {
|
||||
id: `way/${id}`,
|
||||
name: options.name === undefined ? 'Via X' : options.name,
|
||||
routeName: options.routeName ?? null,
|
||||
grade: { raw: options.raw ?? (level === null ? null : String(level)), level, plus: options.plus ?? null },
|
||||
geometry: [from, [from[0] + STEP, from[1]]],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe('groupFerratas', () => {
|
||||
it('FE-FERRATA-GROUP-001: joins touching segments of one name into one ferrata, named after them', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(2, [46.5, 11.7], { level: 4 }),
|
||||
segment(1, [46.5 + STEP, 11.7], { level: 2 }),
|
||||
segment(3, [46.5 + 2 * STEP, 11.7], { level: 3 }),
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
const [group] = groups;
|
||||
expect(group?.id).toBe('group/way/1');
|
||||
expect(group?.name).toBe('Via X');
|
||||
expect(group?.segments.map((s) => s.id)).toEqual(['way/2', 'way/1', 'way/3']);
|
||||
expect(group?.lengthM).toBeGreaterThan(330);
|
||||
expect(group?.lengthM).toBeLessThan(340);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-002: chains segments within the reach, and keeps a homonym far away apart', () => {
|
||||
const gap = (FERRATA_NAME_REACH_M - 50) / 111_195;
|
||||
const groups = groupFerratas([
|
||||
segment(1, [46.5, 11.7]),
|
||||
segment(2, [46.5 + STEP + gap, 11.7]),
|
||||
// The same name ten kilometres north is another ferrata.
|
||||
segment(3, [46.6, 11.7]),
|
||||
]);
|
||||
expect(groups.map((g) => g.segments.map((s) => s.id))).toEqual([['way/1', 'way/2'], ['way/3']]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-003: reads names without case or outer spaces, and keeps every unnamed segment alone', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(1, [46.5, 11.7], { name: 'Via X' }),
|
||||
segment(2, [46.5 + STEP, 11.7], { name: ' via x ' }),
|
||||
segment(3, [46.5, 11.71], { name: null }),
|
||||
segment(4, [46.5 + STEP, 11.71], { name: null }),
|
||||
]);
|
||||
expect(groups.map((g) => g.segments.length)).toEqual([2, 1, 1]);
|
||||
expect(groups[1]?.name).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-004: groups by the route relation first, over a wider reach and whatever the ways are named', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(10, [46.5, 11.7], { routeName: 'Brigata Tridentina', name: 'Pitch 1' }),
|
||||
// 1.5 km on, the same route.
|
||||
segment(11, [46.5135, 11.7], { routeName: 'Brigata Tridentina', name: null }),
|
||||
// A way named like the route but outside the relation is its own ferrata.
|
||||
segment(12, [46.5 + STEP, 11.7], { name: 'Brigata Tridentina' }),
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]?.segments.map((s) => s.id)).toEqual(['way/10', 'way/11']);
|
||||
expect(groups[0]?.routeName).toBe('Brigata Tridentina');
|
||||
expect(groups[0]?.name).toBe('Pitch 1');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-005: spans the grades from the easiest to the hardest segment, minus before plus', () => {
|
||||
const [group] = groupFerratas([
|
||||
segment(1, [46.5, 11.7], { level: 4, plus: '+' }),
|
||||
segment(2, [46.5 + STEP, 11.7], { level: 2, plus: '-' }),
|
||||
segment(3, [46.5 + 2 * STEP, 11.7], { level: 2 }),
|
||||
segment(4, [46.5 + 3 * STEP, 11.7], { level: null }),
|
||||
]);
|
||||
expect(group && ferrataGradeRange(group)).toEqual({ from: 'K2-', to: 'K4+' });
|
||||
expect(group?.rawGrade).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-006: without a numeric grade keeps the tag as written, and has no range', () => {
|
||||
const [group] = groupFerratas([segment(1, [46.5, 11.7], { level: null, raw: 'C/D' })]);
|
||||
expect(group && ferrataGradeRange(group)).toBeNull();
|
||||
expect(group?.rawGrade).toBe('C/D');
|
||||
const [single] = groupFerratas([segment(2, [46.5, 11.7], { level: 3 })]);
|
||||
expect(single && ferrataGradeRange(single)).toEqual({ from: 'K3', to: 'K3' });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-007: puts the label halfway along the longest segment and spans every segment in its box', () => {
|
||||
const long: Ferrata = {
|
||||
...segment(1, [46.5, 11.7]),
|
||||
geometry: [
|
||||
[46.5, 11.7],
|
||||
[46.5 + 4 * STEP, 11.7],
|
||||
],
|
||||
};
|
||||
const [group] = groupFerratas([segment(2, [46.5 + 4 * STEP, 11.7]), long]);
|
||||
expect(group?.midpoint[0]).toBeCloseTo(46.502, 5);
|
||||
expect(group?.midpoint[1]).toBeCloseTo(11.7, 5);
|
||||
expect(group?.bbox.south).toBe(46.5);
|
||||
expect(group?.bbox.north).toBeCloseTo(46.505, 9);
|
||||
expect(group?.bbox.west).toBe(11.7);
|
||||
expect(group?.bbox.east).toBe(11.7);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-008: finds the ferrata a way belongs to, and counts the ferratas reaching into a view', () => {
|
||||
const groups = groupFerratas([
|
||||
segment(1, [46.5, 11.7]),
|
||||
segment(2, [46.5 + STEP, 11.7]),
|
||||
segment(3, [46.6, 11.8], { name: 'Other' }),
|
||||
]);
|
||||
expect(ferrataGroupOf(groups, 'way/2')?.id).toBe('group/way/1');
|
||||
expect(ferrataGroupOf(groups, 'way/99')).toBeNull();
|
||||
expect(ferrataGroupOf(groups, null)).toBeNull();
|
||||
expect(ferrataGroupsInView(groups, { south: 46.49, west: 11.69, north: 46.51, east: 11.71 })).toBe(1);
|
||||
expect(ferrataGroupsInView(groups, { south: 46.4, west: 11.6, north: 46.7, east: 11.9 })).toBe(2);
|
||||
expect(ferrataGroupsInView(groups, null)).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GROUP-009: boxes meet on a shared edge, not across a gap; a line under two points is dropped', () => {
|
||||
const box = { south: 0, west: 0, north: 1, east: 1 };
|
||||
expect(ferrataBboxesMeet(box, { south: 1, west: 1, north: 2, east: 2 })).toBe(true);
|
||||
expect(ferrataBboxesMeet(box, { south: 1.1, west: 0, north: 2, east: 1 })).toBe(false);
|
||||
const point = { ...segment(1, [46.5, 11.7]), geometry: [[46.5, 11.7]] } as Ferrata;
|
||||
expect(groupFerratas([point])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Ferrata, FerrataBbox, FerrataGrade } from '@trek/shared';
|
||||
|
||||
import { ferrataGradeLabel, ferrataPositions } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* OpenStreetMap maps one via ferrata as many short ways: the approach, each
|
||||
* pitch, a bridge, the exit. This turns those segments back into the routes a
|
||||
* climber means, so the layer counts, highlights and describes one ferrata
|
||||
* rather than a dozen pieces of it. Pure and React-free.
|
||||
*
|
||||
* Segments belong together when they share their route relation's name, or,
|
||||
* with no relation, their own name, and lie close to each other: a chain of
|
||||
* segments joins as long as each one comes within the reach of the next.
|
||||
* Unnamed segments stay on their own; nothing says two of them are one route.
|
||||
*/
|
||||
|
||||
/** How close two segments of the same name have to come to read as one ferrata. */
|
||||
export const FERRATA_NAME_REACH_M = 300;
|
||||
/**
|
||||
* The same for a route relation's name. A relation is one route by definition,
|
||||
* so the reach is wider; it only keeps two distant routes that happen to share
|
||||
* a name apart.
|
||||
*/
|
||||
const FERRATA_ROUTE_REACH_M = 2000;
|
||||
|
||||
export interface FerrataGroup {
|
||||
/** Stable for as long as the group keeps its lowest way: `group/<way id>`. */
|
||||
id: string;
|
||||
name: string | null;
|
||||
routeName: string | null;
|
||||
/** The OSM ways it is made of, in the order they arrived. */
|
||||
segments: readonly Ferrata[];
|
||||
/** The easiest and the hardest numeric grade among the segments, or null when none has one. */
|
||||
grades: { min: FerrataGrade; max: FerrataGrade } | null;
|
||||
/** A grade as tagged when no segment has a numeric one (a letter scale such as "C/D"). */
|
||||
rawGrade: string | null;
|
||||
/** The summed length of every segment, in metres. */
|
||||
lengthM: number;
|
||||
bbox: FerrataBbox;
|
||||
/** Where its label goes: halfway along its longest segment, as [lat, lng]. */
|
||||
midpoint: [number, number];
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
function distanceM([lat1, lng1]: [number, number], [lat2, lng2]: [number, number]): number {
|
||||
const dLat = (lat2 - lat1) * RAD;
|
||||
const dLng = (lng2 - lng1) * RAD;
|
||||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * RAD) * Math.cos(lat2 * RAD) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
function lengthOf(points: readonly [number, number][]): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i += 1) total += distanceM(points[i - 1], points[i]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box around the points, the ones that are not finite numbers skipped, or
|
||||
* null when none is left.
|
||||
*/
|
||||
export function ferrataPointsBbox(points: Iterable<readonly [number, number]>): FerrataBbox | null {
|
||||
let south = Infinity;
|
||||
let west = Infinity;
|
||||
let north = -Infinity;
|
||||
let east = -Infinity;
|
||||
for (const [lat, lng] of points) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
west = Math.min(west, lng);
|
||||
east = Math.max(east, lng);
|
||||
}
|
||||
return south <= north && west <= east ? { south, west, north, east } : null;
|
||||
}
|
||||
|
||||
/** A segment's box; the schema guarantees it at least two placed points. */
|
||||
function bboxOf(points: readonly [number, number][]): FerrataBbox {
|
||||
return ferrataPointsBbox(points) ?? { south: 0, west: 0, north: 0, east: 0 };
|
||||
}
|
||||
|
||||
/** Whether two boxes share any point, edges included. */
|
||||
export function ferrataBboxesMeet(a: FerrataBbox, b: FerrataBbox): boolean {
|
||||
return a.south <= b.north && b.south <= a.north && a.west <= b.east && b.west <= a.east;
|
||||
}
|
||||
|
||||
/** The point halfway along a line, by length. */
|
||||
function halfway(points: readonly [number, number][]): [number, number] {
|
||||
const half = lengthOf(points) / 2;
|
||||
let walked = 0;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const step = distanceM(points[i - 1], points[i]);
|
||||
if (walked + step >= half && step > 0) {
|
||||
const t = (half - walked) / step;
|
||||
const [lat1, lng1] = points[i - 1];
|
||||
const [lat2, lng2] = points[i];
|
||||
return [lat1 + (lat2 - lat1) * t, lng1 + (lng2 - lng1) * t];
|
||||
}
|
||||
walked += step;
|
||||
}
|
||||
return points[0] ?? [0, 0];
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
ferrata: Ferrata;
|
||||
points: [number, number][];
|
||||
bbox: FerrataBbox;
|
||||
lengthM: number;
|
||||
}
|
||||
|
||||
/** Whether two boxes come within `reach` metres of each other, a cheap test before any point is compared. */
|
||||
function boxesWithin(a: FerrataBbox, b: FerrataBbox, reach: number): boolean {
|
||||
const latPad = reach / (EARTH_RADIUS_M * RAD);
|
||||
const lngPad = latPad / Math.max(0.01, Math.cos(((a.south + a.north) / 2) * RAD));
|
||||
return (
|
||||
a.south - latPad <= b.north && b.south - latPad <= a.north && a.west - lngPad <= b.east && b.west - lngPad <= a.east
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an end of either segment comes within `reach` of a point of the other: they touch or nearly do. */
|
||||
function segmentsMeet(a: Segment, b: Segment, reach: number): boolean {
|
||||
if (!boxesWithin(a.bbox, b.bbox, reach)) return false;
|
||||
const near = (ends: Segment, line: Segment) => {
|
||||
const tips = [ends.points[0], ends.points[ends.points.length - 1]];
|
||||
return tips.some((tip) => tip !== undefined && line.points.some((point) => distanceM(tip, point) <= reach));
|
||||
};
|
||||
return near(a, b) || near(b, a);
|
||||
}
|
||||
|
||||
/** What segments group by, or null for one that stays alone. Case and outer spaces do not tell two names apart. */
|
||||
function groupKey(ferrata: Ferrata): { key: string; reach: number } | null {
|
||||
const route = ferrata.routeName?.trim().toLocaleLowerCase();
|
||||
if (route) return { key: `route:${route}`, reach: FERRATA_ROUTE_REACH_M };
|
||||
const name = ferrata.name?.trim().toLocaleLowerCase();
|
||||
if (name) return { key: `name:${name}`, reach: FERRATA_NAME_REACH_M };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Segments of one key joined into chains: any two that meet end up in one set. */
|
||||
function chains(segments: Segment[], reach: number): Segment[][] {
|
||||
const parent = segments.map((_, index) => index);
|
||||
const find = (i: number): number => {
|
||||
let root = i;
|
||||
while (parent[root] !== root) root = parent[root];
|
||||
parent[i] = root;
|
||||
return root;
|
||||
};
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
for (let j = i + 1; j < segments.length; j += 1) {
|
||||
if (find(i) !== find(j) && segmentsMeet(segments[i], segments[j], reach)) parent[find(j)] = find(i);
|
||||
}
|
||||
}
|
||||
const sets = new Map<number, Segment[]>();
|
||||
segments.forEach((segment, index) => {
|
||||
const root = find(index);
|
||||
const set = sets.get(root);
|
||||
if (set) set.push(segment);
|
||||
else sets.set(root, [segment]);
|
||||
});
|
||||
return [...sets.values()];
|
||||
}
|
||||
|
||||
/** -, none, + in that order within a level. */
|
||||
const gradeRank = (grade: FerrataGrade) =>
|
||||
(grade.level ?? 0) * 3 + (grade.plus === '-' ? 0 : grade.plus === '+' ? 2 : 1);
|
||||
|
||||
function wayNumber(id: string): number {
|
||||
const n = Number(id.slice(id.indexOf('/') + 1));
|
||||
return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function toGroup(members: Segment[]): FerrataGroup {
|
||||
const segments = members.map((member) => member.ferrata);
|
||||
const graded = segments.map((segment) => segment.grade).filter((grade) => grade.level !== null);
|
||||
const sorted = [...graded].sort((a, b) => gradeRank(a) - gradeRank(b));
|
||||
const longest = members.reduce((best, member) => (member.lengthM > best.lengthM ? member : best), members[0]);
|
||||
// The name of the longest named segment speaks for the group.
|
||||
const named = [...members].sort((a, b) => b.lengthM - a.lengthM).find((member) => member.ferrata.name);
|
||||
const lowest = [...segments].sort((a, b) => wayNumber(a.id) - wayNumber(b.id))[0];
|
||||
return {
|
||||
id: `group/${lowest.id}`,
|
||||
name: named?.ferrata.name ?? null,
|
||||
routeName: segments.find((segment) => segment.routeName)?.routeName ?? null,
|
||||
segments,
|
||||
grades: sorted.length ? { min: sorted[0], max: sorted[sorted.length - 1] } : null,
|
||||
rawGrade: sorted.length ? null : (segments.find((segment) => segment.grade.raw)?.grade.raw ?? null),
|
||||
lengthM: members.reduce((sum, member) => sum + member.lengthM, 0),
|
||||
bbox: members.slice(1).reduce(
|
||||
(box, member) => ({
|
||||
south: Math.min(box.south, member.bbox.south),
|
||||
west: Math.min(box.west, member.bbox.west),
|
||||
north: Math.max(box.north, member.bbox.north),
|
||||
east: Math.max(box.east, member.bbox.east),
|
||||
}),
|
||||
members[0].bbox
|
||||
),
|
||||
midpoint: halfway(longest.points),
|
||||
};
|
||||
}
|
||||
|
||||
/** The segments as ferratas, in the order their first segment arrived. */
|
||||
export function groupFerratas(ferratas: readonly Ferrata[]): FerrataGroup[] {
|
||||
const keyed = new Map<string, { reach: number; segments: Segment[] }>();
|
||||
const ordered: Array<Segment[] | string> = [];
|
||||
for (const ferrata of ferratas) {
|
||||
const points = ferrataPositions(ferrata);
|
||||
if (points.length < 2) continue;
|
||||
const segment: Segment = { ferrata, points, bbox: bboxOf(points), lengthM: lengthOf(points) };
|
||||
const key = groupKey(ferrata);
|
||||
if (!key) {
|
||||
ordered.push([segment]);
|
||||
continue;
|
||||
}
|
||||
const bucket = keyed.get(key.key);
|
||||
if (bucket) bucket.segments.push(segment);
|
||||
else {
|
||||
keyed.set(key.key, { reach: key.reach, segments: [segment] });
|
||||
ordered.push(key.key);
|
||||
}
|
||||
}
|
||||
return ordered.flatMap((entry) => {
|
||||
if (typeof entry !== 'string') return [toGroup(entry)];
|
||||
const bucket = keyed.get(entry);
|
||||
return bucket ? chains(bucket.segments, bucket.reach).map(toGroup) : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** The group a way belongs to, for a click or a selection that names one segment. */
|
||||
export function ferrataGroupOf(groups: readonly FerrataGroup[], segmentId: string | null): FerrataGroup | null {
|
||||
if (!segmentId) return null;
|
||||
return groups.find((group) => group.segments.some((segment) => segment.id === segmentId)) ?? null;
|
||||
}
|
||||
|
||||
/** How many ferratas reach into the area on screen. */
|
||||
export function ferrataGroupsInView(groups: readonly FerrataGroup[], view: FerrataBbox | null): number {
|
||||
if (!view) return 0;
|
||||
return groups.reduce((count, group) => (ferrataBboxesMeet(group.bbox, view) ? count + 1 : count), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The group's grade on the K scale: the easiest and the hardest segment
|
||||
* (`K2`, `K4`), the same label twice when they agree, null when no segment has
|
||||
* a numeric grade.
|
||||
*/
|
||||
export function ferrataGradeRange(group: Pick<FerrataGroup, 'grades'>): { from: string; to: string } | null {
|
||||
if (!group.grades) return null;
|
||||
const from = ferrataGradeLabel(group.grades.min);
|
||||
const to = ferrataGradeLabel(group.grades.max);
|
||||
return from && to ? { from, to } : null;
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* What a ferrata is called on the map and on its card: its own name, else its
|
||||
* route's, else "unnamed"; and its grade, `K3` or `K2 to K4`, or without a
|
||||
* numeric grade the tag as written (a letter scale such as "C/D" still says
|
||||
* something), or null when there is none at all.
|
||||
*/
|
||||
export function ferrataLabel(
|
||||
group: Pick<FerrataGroup, 'name' | 'routeName' | 'grades' | 'rawGrade'>,
|
||||
t: Translate
|
||||
): { name: string; grade: string | null } {
|
||||
const name = group.name ?? group.routeName ?? t('ferratas.unnamed');
|
||||
const range = ferrataGradeRange(group);
|
||||
if (!range) return { name, grade: group.rawGrade };
|
||||
return { name, grade: range.from === range.to ? range.from : t('ferratas.gradeRange', range) };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// FE-FERRATA-ICON-001 to FE-FERRATA-ICON-004: the route icon halfway along a via ferrata.
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
FERRATA_ICON_GAP,
|
||||
FERRATA_ICON_PIXEL_RATIO,
|
||||
FERRATA_ICON_SIZE,
|
||||
ferrataIconSvg,
|
||||
loadFerrataIcon,
|
||||
placeFerrataIcons,
|
||||
} from './ferrataIcon';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('ferrataIcon', () => {
|
||||
it('FE-FERRATA-ICON-001: draws a ladder in the line colour on a disc of the casing colour', () => {
|
||||
const svg = ferrataIconSvg({ line: 'var(--text-primary)', casing: 'rgb(255, 255, 255)' });
|
||||
expect(svg).toContain('fill="rgb(255, 255, 255)"');
|
||||
expect(svg).toContain('stroke="var(--text-primary)"');
|
||||
expect(svg).toContain(`width="${FERRATA_ICON_SIZE}"`);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-ICON-002: lets nothing but a colour into the markup', () => {
|
||||
const svg = ferrataIconSvg({ line: '"/><script>alert(1)</script>', casing: 'white' });
|
||||
expect(svg).not.toContain('<script>');
|
||||
expect(svg).toContain('stroke="currentColor"');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-ICON-003: hands GL a sharp image of the same SVG, and rejects when it cannot be decoded', async () => {
|
||||
const made: Array<{ src: string; width?: number }> = [];
|
||||
class FakeImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
constructor(public width?: number) {
|
||||
made.push(this as unknown as { src: string; width?: number });
|
||||
}
|
||||
set src(value: string) {
|
||||
(this as unknown as { _src: string })._src = value;
|
||||
queueMicrotask(() => (value.includes('fail') ? this.onerror?.() : this.onload?.()));
|
||||
}
|
||||
get src() {
|
||||
return (this as unknown as { _src: string })._src;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
|
||||
const image = await loadFerrataIcon({ line: 'black', casing: 'white' });
|
||||
expect(image).toBe(made[0]);
|
||||
expect(made[0]?.width).toBe(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
|
||||
expect(decodeURIComponent(made[0]?.src ?? '')).toContain('stroke="black"');
|
||||
|
||||
await expect(loadFerrataIcon({ line: 'fail', casing: 'white' })).rejects.toThrow('could not be drawn');
|
||||
});
|
||||
it('FE-FERRATA-ICON-004: leaves out an icon that would touch one already placed, first come first placed', () => {
|
||||
const placed = placeFerrataIcons([
|
||||
{ id: 'a', x: 100, y: 100 },
|
||||
{ id: 'b', x: 100 + FERRATA_ICON_GAP - 1, y: 100 },
|
||||
{ id: 'c', x: 100, y: 100 + FERRATA_ICON_GAP },
|
||||
{ id: 'd', x: 400, y: 400 },
|
||||
]);
|
||||
expect([...placed]).toEqual(['a', 'c', 'd']);
|
||||
expect(FERRATA_ICON_GAP).toBeGreaterThan(FERRATA_ICON_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { FerrataPaint } from './ferrataPaint';
|
||||
|
||||
/**
|
||||
* The small route icon a via ferrata carries at its midpoint once the map is
|
||||
* zoomed in: a ladder in the line colour on a disc of the casing colour, so it
|
||||
* reads as part of the line it sits on.
|
||||
*
|
||||
* One SVG for both renderers: Leaflet puts it in a divIcon, where it can take
|
||||
* `var(--token)` straight from the page; GL needs an image with the colours
|
||||
* resolved, which `loadFerrataIcon` makes from the resolved paint.
|
||||
*/
|
||||
|
||||
/** The zoom from which the icons show; below it they would crowd a valley full of ferratas. */
|
||||
export const FERRATA_ICON_MIN_ZOOM = 13;
|
||||
|
||||
/** The icon's size on screen, in CSS pixels. */
|
||||
export const FERRATA_ICON_SIZE = 22;
|
||||
|
||||
/** The name the GL renderer registers the image under. */
|
||||
export const FERRATA_ICON_IMAGE = 'trek-ferrata-icon';
|
||||
|
||||
/**
|
||||
* A colour as it may go into the markup: a resolved token (a hex, `rgb()` or
|
||||
* named value) or a `var(--token)`. Anything else, which a token never
|
||||
* resolves to, falls back to `currentColor` rather than reaching the SVG.
|
||||
*/
|
||||
function safeColour(value: string): string {
|
||||
return /^[#\w\s(),.%-]+$/.test(value) ? value : 'currentColor';
|
||||
}
|
||||
|
||||
export function ferrataIconSvg(paint: Pick<FerrataPaint, 'line' | 'casing'>, size = FERRATA_ICON_SIZE): string {
|
||||
const line = safeColour(paint.line);
|
||||
const casing = safeColour(paint.casing);
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 22 22" aria-hidden="true">`,
|
||||
`<circle cx="11" cy="11" r="9.75" fill="${casing}" stroke="${line}" stroke-width="1.5"/>`,
|
||||
`<path d="M8 6v10M14 6v10M8 8.5h6M8 11h6M8 13.5h6" fill="none" stroke="${line}" stroke-width="1.6" stroke-linecap="round"/>`,
|
||||
'</svg>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/** How sharp the GL image is drawn; the map scales it back to FERRATA_ICON_SIZE. */
|
||||
export const FERRATA_ICON_PIXEL_RATIO = 2;
|
||||
|
||||
/**
|
||||
* The icon as an image for a GL map, in the colours the tokens resolve to now.
|
||||
* Rejects when the browser cannot decode it, and the lines then simply go
|
||||
* without their icons.
|
||||
*/
|
||||
export function loadFerrataIcon(paint: Pick<FerrataPaint, 'line' | 'casing'>): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image(FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('The via ferrata icon could not be drawn'));
|
||||
const svg = ferrataIconSvg(paint, FERRATA_ICON_SIZE * FERRATA_ICON_PIXEL_RATIO);
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** The room an icon keeps to the next one, in CSS pixels, so two discs never touch. */
|
||||
export const FERRATA_ICON_GAP = FERRATA_ICON_SIZE + 4;
|
||||
|
||||
/**
|
||||
* Which icons fit on screen without touching one another, for a renderer that
|
||||
* does not place symbols itself (Leaflet; GL keeps `icon-allow-overlap` off for
|
||||
* the same effect). `points` are the icons' screen positions in pixels, in the
|
||||
* order they should win: an icon closer than FERRATA_ICON_GAP to one already
|
||||
* placed is left out.
|
||||
*/
|
||||
export function placeFerrataIcons(points: ReadonlyArray<{ id: string; x: number; y: number }>): Set<string> {
|
||||
const placed: Array<{ x: number; y: number }> = [];
|
||||
const ids = new Set<string>();
|
||||
for (const point of points) {
|
||||
if (placed.some((other) => Math.hypot(other.x - point.x, other.y - point.y) < FERRATA_ICON_GAP)) continue;
|
||||
placed.push(point);
|
||||
ids.add(point.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// FE-FERRATA-MODEL-001 to FE-FERRATA-MODEL-007: the pure half of the via ferrata layer.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { FERRATA_BBOX_MAX_SPAN_DEG, type Ferrata } from '@trek/shared';
|
||||
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
import {
|
||||
ferrataBboxFits,
|
||||
ferrataBboxParam,
|
||||
ferrataFeatureCollection,
|
||||
ferrataGradeLabel,
|
||||
ferrataLabelCollection,
|
||||
ferrataOsmUrl,
|
||||
ferrataPositions,
|
||||
} from './ferrataModel';
|
||||
|
||||
const grade = (level: number | null, plus: '+' | '-' | null = null, raw: string | null = null) => ({
|
||||
level,
|
||||
plus,
|
||||
raw,
|
||||
});
|
||||
|
||||
const FERRATA: Ferrata = {
|
||||
id: 'way/123',
|
||||
name: 'Pisciadù',
|
||||
grade: grade(3, '+', '3+'),
|
||||
routeName: 'Brigata Tridentina',
|
||||
geometry: [
|
||||
[46.53, 11.81],
|
||||
[46.54, 11.82],
|
||||
],
|
||||
osmUrl: 'https://www.openstreetmap.org/way/123',
|
||||
};
|
||||
|
||||
describe('ferrataModel', () => {
|
||||
it('FE-FERRATA-MODEL-001: labels a numeric grade on the K scale with its plus or minus', () => {
|
||||
expect(ferrataGradeLabel(grade(3))).toBe('K3');
|
||||
expect(ferrataGradeLabel(grade(4, '+'))).toBe('K4+');
|
||||
expect(ferrataGradeLabel(grade(2, '-'))).toBe('K2-');
|
||||
expect(ferrataGradeLabel(grade(0))).toBe('K0');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-002: has no label for a way without a numeric level, letter scales included', () => {
|
||||
expect(ferrataGradeLabel(grade(null))).toBeNull();
|
||||
expect(ferrataGradeLabel(grade(null, null, 'C/D'))).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-003: takes an area only up to the server limit and only a real box', () => {
|
||||
const max = FERRATA_BBOX_MAX_SPAN_DEG;
|
||||
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max, east: 11 + max })).toBe(true);
|
||||
expect(ferrataBboxFits({ south: 46, west: 11, north: 46 + max + 0.01, east: 11.2 })).toBe(false);
|
||||
expect(ferrataBboxFits({ south: 46, west: 11, north: 46.2, east: 11 + max + 0.01 })).toBe(false);
|
||||
expect(ferrataBboxFits({ south: 46.2, west: 11, north: 46.2, east: 11.2 })).toBe(false);
|
||||
// The server's own rule: a 0.500 span that reads 0.5000000000000071 still goes out.
|
||||
expect(ferrataBboxFits({ south: 63.501, west: 11, north: 64.001, east: 11.2 })).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-004: writes the bbox parameter in south,west,north,east order', () => {
|
||||
expect(ferrataBboxParam({ south: 46.1, west: 11.25, north: 46.3, east: 11.5 })).toBe('46.100,11.250,46.300,11.500');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-005: builds one GeoJSON line per way, in [lng, lat], carrying only the way and its group', () => {
|
||||
const collection = ferrataFeatureCollection(groupFerratas([FERRATA]));
|
||||
expect(collection).toEqual({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { id: 'way/123', group: 'group/way/123' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[11.81, 46.53],
|
||||
[11.82, 46.54],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(ferrataPositions(FERRATA)).toEqual(FERRATA.geometry);
|
||||
expect(ferrataFeatureCollection([]).features).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-006: puts one label point per ferrata halfway along it, in [lng, lat]', () => {
|
||||
const [feature] = ferrataLabelCollection(groupFerratas([FERRATA])).features;
|
||||
expect(feature?.properties).toEqual({ group: 'group/way/123' });
|
||||
expect(feature?.geometry.type).toBe('Point');
|
||||
const [lng, lat] = feature?.geometry.coordinates ?? [0, 0];
|
||||
expect(lng).toBeCloseTo(11.815, 4);
|
||||
expect(lat).toBeCloseTo(46.535, 4);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MODEL-007: links only ever to openstreetmap.org, whatever the answer sent as osmUrl', () => {
|
||||
expect(ferrataOsmUrl(FERRATA)).toBe('https://www.openstreetmap.org/way/123');
|
||||
expect(ferrataOsmUrl({ id: 'javascript:alert(1)' })).toBe('https://www.openstreetmap.org/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { type Ferrata, type FerrataBbox, type FerrataGrade, ferrataBboxSpanOk } from '@trek/shared';
|
||||
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
|
||||
/**
|
||||
* The pure half of the via ferrata layer: grade wording, the area checks a
|
||||
* request makes, and the GeoJSON the GL renderer draws. React-free, so both
|
||||
* renderers and the detail card read the same answers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The grade as the K scale reads it: `K3`, `K4+`, `K2-`. Null when the way has
|
||||
* no numeric level (untagged, or a letter scale such as "C/D" the server keeps
|
||||
* as `raw` and does not convert).
|
||||
*/
|
||||
export function ferrataGradeLabel(grade: FerrataGrade): string | null {
|
||||
if (grade.level === null || grade.level === undefined) return null;
|
||||
return `K${grade.level}${grade.plus ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line as `[lat, lng]` pairs Leaflet takes. Zod's tuple reads as "maybe two
|
||||
* numbers, maybe more" where the client compiles without strict mode, so the
|
||||
* pairs are narrowed here rather than cast; the schema has already guaranteed
|
||||
* both are numbers.
|
||||
*/
|
||||
export function ferrataPositions(ferrata: Pick<Ferrata, 'geometry'>): Array<[number, number]> {
|
||||
return ferrata.geometry.flatMap((pair): Array<[number, number]> =>
|
||||
typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : []
|
||||
);
|
||||
}
|
||||
|
||||
const OSM_ORIGIN = 'https://www.openstreetmap.org/';
|
||||
|
||||
/**
|
||||
* The way's page on openstreetmap.org. Built from the id the contract pins to
|
||||
* `way/<digits>` rather than taken from `osmUrl` as sent, so the link can only
|
||||
* ever point at OpenStreetMap whatever an Overpass mirror put in the answer.
|
||||
*/
|
||||
export function ferrataOsmUrl(ferrata: Pick<Ferrata, 'id'>): string {
|
||||
return /^way\/\d+$/.test(ferrata.id) ? `${OSM_ORIGIN}${ferrata.id}` : OSM_ORIGIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server takes this area: a real box no wider than
|
||||
* FERRATA_BBOX_MAX_SPAN_DEG per side. Past it the layer asks the user to zoom
|
||||
* in instead of loading every tile of the view.
|
||||
*/
|
||||
export function ferrataBboxFits(bbox: FerrataBbox): boolean {
|
||||
// The span check is the server's own, so a box the client sends is never refused as too large.
|
||||
return bbox.north > bbox.south && bbox.east > bbox.west && ferrataBboxSpanOk(bbox);
|
||||
}
|
||||
|
||||
/** The `bbox` query parameter, in the south,west,north,east order the contract reads. */
|
||||
export function ferrataBboxParam(bbox: FerrataBbox): string {
|
||||
return [bbox.south, bbox.west, bbox.north, bbox.east].map((edge) => edge.toFixed(3)).join(',');
|
||||
}
|
||||
|
||||
interface FerrataLineFeature {
|
||||
type: 'Feature';
|
||||
properties: { id: string; group: string };
|
||||
geometry: { type: 'LineString'; coordinates: Array<[number, number]> };
|
||||
}
|
||||
|
||||
interface FerrataLabelFeature {
|
||||
type: 'Feature';
|
||||
properties: { group: string };
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
}
|
||||
|
||||
interface FeatureCollection<F> {
|
||||
type: 'FeatureCollection';
|
||||
features: F[];
|
||||
}
|
||||
|
||||
const lngLat = ([lat, lng]: [number, number]): [number, number] => [lng, lat];
|
||||
|
||||
/**
|
||||
* The segments as a GeoJSON FeatureCollection for MapLibre or Mapbox GL, one
|
||||
* line per OSM way. The contract carries [lat, lng] like the rest of TREK;
|
||||
* GeoJSON wants [lng, lat], and this is the one place the layer flips it. Only
|
||||
* the way and its group ride along: a click looks the ferrata up by id, so
|
||||
* names never pass through the map's own feature state, and a highlight
|
||||
* filters on the group.
|
||||
*/
|
||||
export function ferrataFeatureCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLineFeature> {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: groups.flatMap((group) =>
|
||||
group.segments.map((segment) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { id: segment.id, group: group.id },
|
||||
geometry: { type: 'LineString' as const, coordinates: ferrataPositions(segment).map(lngLat) },
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** One point per ferrata, halfway along it, where the GL renderer puts the route icon. */
|
||||
export function ferrataLabelCollection(groups: readonly FerrataGroup[]): FeatureCollection<FerrataLabelFeature> {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: groups.map((group) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { group: group.id },
|
||||
geometry: { type: 'Point' as const, coordinates: lngLat(group.midpoint) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// FE-FERRATA-PAINT-001 to FE-FERRATA-PAINT-003: the line colours come from the --map-ferrata tokens.
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ferrataPaint, sameFerrataPaint } from './ferrataPaint';
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of [
|
||||
'--map-ferrata',
|
||||
'--map-ferrata-casing',
|
||||
'--map-ferrata-lit',
|
||||
'--success',
|
||||
'--warning',
|
||||
'--danger',
|
||||
])
|
||||
root.style.removeProperty(name);
|
||||
});
|
||||
|
||||
describe('ferrataPaint', () => {
|
||||
it('FE-FERRATA-PAINT-001: draws the line, its halo and the lit line in the via ferrata tokens', () => {
|
||||
root.style.setProperty('--map-ferrata', 'violet');
|
||||
root.style.setProperty('--map-ferrata-casing', 'white');
|
||||
root.style.setProperty('--map-ferrata-lit', 'indigo');
|
||||
expect(ferrataPaint()).toEqual({ line: 'violet', casing: 'white', highlight: 'indigo' });
|
||||
});
|
||||
|
||||
it('FE-FERRATA-PAINT-002: never takes the grade colours of the tour line', () => {
|
||||
root.style.setProperty('--success', 'green');
|
||||
root.style.setProperty('--warning', 'orange');
|
||||
root.style.setProperty('--danger', 'crimson');
|
||||
const paint = Object.values(ferrataPaint());
|
||||
expect(paint).not.toContain('green');
|
||||
expect(paint).not.toContain('orange');
|
||||
expect(paint).not.toContain('crimson');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-PAINT-003: falls back to currentColor where a token is not defined, and compares by colour', () => {
|
||||
const paint = ferrataPaint();
|
||||
expect(paint).toEqual({ line: 'currentColor', casing: 'currentColor', highlight: 'currentColor' });
|
||||
expect(sameFerrataPaint(paint, { ...paint })).toBe(true);
|
||||
expect(sameFerrataPaint(paint, { ...paint, highlight: 'red' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readMapPaint, sameMapPaint } from '../Map/mapPaint';
|
||||
|
||||
/**
|
||||
* The colours the via ferrata lines are drawn in.
|
||||
*
|
||||
* Ferratas have a look of their own: a dashed violet line on a white halo.
|
||||
* Violet is the one colour nothing else on the planner map uses (the route is
|
||||
* blue, its grades run green to red, the waypoints are blue), and the halo
|
||||
* keeps it readable over a satellite tile or dark hillshade. The ferrata under
|
||||
* the pointer or open in the card is drawn solid, wider and in a deeper violet.
|
||||
* The grade is a badge on the card, never a line colour.
|
||||
*
|
||||
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
|
||||
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
|
||||
* `--map-ferrata*` tokens are read off the document when the layer draws
|
||||
* (`mapPaint.ts`; the precedent is the hazard overlay in useHazardLayerGL),
|
||||
* which keeps light and dark mode apart without a colour literal in the layer.
|
||||
*/
|
||||
export interface FerrataPaint {
|
||||
/** The dashed line itself. */
|
||||
line: string;
|
||||
/** The halo under the line, so it stays readable over a satellite or hillshade tile. */
|
||||
casing: string;
|
||||
/** The ferrata under the pointer or open in the card. */
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
export const FERRATA_TOKENS = {
|
||||
line: '--map-ferrata',
|
||||
casing: '--map-ferrata-casing',
|
||||
highlight: '--map-ferrata-lit',
|
||||
} as const;
|
||||
|
||||
export function ferrataPaint(): FerrataPaint {
|
||||
return readMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
|
||||
return sameMapPaint(a, b);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* FE-FERRATA-TILES-001 to FE-FERRATA-TILES-012: the per-trip tile store behind
|
||||
* the via ferrata layer.
|
||||
*
|
||||
* Pinned: nothing goes out before the device cache was read, a tile is asked
|
||||
* for once and never dropped, the view goes before any prefetch and runs
|
||||
* beside one on its way rather than behind it, answers are
|
||||
* kept per tile, the view's failures are reported and retried, and a stop
|
||||
* calls off what is on its way but keeps what is loaded.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata, FerrataBbox, FerratasResponse } from '@trek/shared';
|
||||
|
||||
import type { FerrataTileRow } from '../../repo/ferrataRepo';
|
||||
import { FerrataTileStore, ferrataRowsOf } from './ferrataTileStore';
|
||||
|
||||
const ferrata = (id: number, lat: number, lng: number, lat2 = lat + 0.01, lng2 = lng): Ferrata => ({
|
||||
id: `way/${id}`,
|
||||
name: `Ferrata ${id}`,
|
||||
grade: { raw: '3', level: 3, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, lng],
|
||||
[lat2, lng2],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
});
|
||||
|
||||
const reply = (ferratas: Ferrata[], truncated = false): FerratasResponse => ({
|
||||
ferratas,
|
||||
truncated,
|
||||
source: 'openstreetmap',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
});
|
||||
|
||||
interface Call {
|
||||
bbox: FerrataBbox;
|
||||
signal: AbortSignal;
|
||||
resolve: (value: FerratasResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function harness(options: { persist?: boolean } = {}) {
|
||||
const calls: Call[] = [];
|
||||
const persisted: FerrataTileRow[][] = [];
|
||||
const fetch = vi.fn(
|
||||
(bbox: FerrataBbox, signal: AbortSignal) =>
|
||||
new Promise<FerratasResponse>((resolve, reject) => calls.push({ bbox, signal, resolve, reject }))
|
||||
);
|
||||
const persist = vi.fn((rows: FerrataTileRow[]) => {
|
||||
persisted.push(rows);
|
||||
return Promise.resolve();
|
||||
});
|
||||
const store = new FerrataTileStore({
|
||||
fetch,
|
||||
persist: options.persist === false ? undefined : persist,
|
||||
now: () => 1_000,
|
||||
retryDelaysMs: [100, 200],
|
||||
});
|
||||
return { store, calls, fetch, persist, persisted };
|
||||
}
|
||||
|
||||
/** Lets a settled fetch's handlers run. */
|
||||
const flush = async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
const A = { row: 930, col: 236 }; // 46.50 to 46.55, 11.80 to 11.85
|
||||
const B = { row: 930, col: 237 };
|
||||
const FAR = { row: 940, col: 236 };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('FerrataTileStore', () => {
|
||||
it('FE-FERRATA-TILES-001: asks for nothing before the cache was read, and nothing the cache already had', () => {
|
||||
const { store, fetch } = harness();
|
||||
expect(store.beginHydration()).toBe(true);
|
||||
expect(store.beginHydration()).toBe(false);
|
||||
store.want([A, B], 'visible');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(store.getSnapshot().waiting.size).toBe(2);
|
||||
|
||||
const cached = ferrata(1, 46.52, 11.82);
|
||||
store.hydrate([
|
||||
{ tile: '930:236', fetchedAt: 1, ferratas: [cached] },
|
||||
{ tile: '930:237', fetchedAt: 1, ferratas: [] },
|
||||
]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const snapshot = store.getSnapshot();
|
||||
expect(snapshot.hydrated).toBe(true);
|
||||
expect([...snapshot.loaded].sort()).toEqual(['930:236', '930:237']);
|
||||
expect(snapshot.waiting.size).toBe(0);
|
||||
expect(snapshot.ferratas).toEqual([cached]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-002: asks for the missing tiles in one box, keeps them, and never asks for them again', async () => {
|
||||
const { store, calls, fetch } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A, B], 'visible');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(calls[0]?.bbox).toEqual({ south: 46.5, west: 11.8, north: 46.55, east: 11.9 });
|
||||
|
||||
const found = ferrata(1, 46.52, 11.82);
|
||||
calls[0]?.resolve(reply([found]));
|
||||
await flush();
|
||||
expect(store.getSnapshot().ferratas).toEqual([found]);
|
||||
expect(store.getSnapshot().loaded.has('930:237')).toBe(true);
|
||||
|
||||
store.want([A, B], 'visible');
|
||||
store.want([A], 'background');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-003: keeps every ferrata it ever loaded, a later answer adding to them by id', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'visible');
|
||||
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
|
||||
await flush();
|
||||
store.want([FAR], 'visible');
|
||||
// The same way again, as a tile across its end returns it, and one more.
|
||||
calls[1]?.resolve(reply([ferrata(1, 46.52, 11.82), ferrata(2, 47.01, 11.82)]));
|
||||
await flush();
|
||||
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/1', 'way/2']);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-004: the view goes out beside a prefetch on its way, and prefetches go one at a time', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
store.want([FAR], 'background');
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
// The view does not wait for the prefetch already on its way.
|
||||
store.want([B], 'visible');
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.bbox).toEqual({ south: 46.5, west: 11.85, north: 46.55, east: 11.9 });
|
||||
calls[1]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(2);
|
||||
|
||||
calls[0]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(calls[2]?.bbox.south).toBe(47);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-012: runs two of the view requests at once and queues the rest behind them', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
// Three blocks of the grid apart: three requests.
|
||||
store.want([A, { row: 930, col: 240 }, { row: 930, col: 244 }], 'visible');
|
||||
expect(calls).toHaveLength(2);
|
||||
calls[0]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls[2]?.bbox.west).toBe(12.2);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-005: a tile a queued prefetch holds moves up with it when the view wants it', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
store.want([FAR], 'background');
|
||||
store.want([B], 'background');
|
||||
store.want([B], 'visible');
|
||||
expect(store.getSnapshot().waiting.has('930:237')).toBe(true);
|
||||
// Moved up, it takes the view's lane at once instead of waiting behind the other prefetches.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]?.bbox.west).toBe(11.85);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-006: keeps each answer per tile, an empty tile included', async () => {
|
||||
const { store, calls, persisted } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A, B], 'visible');
|
||||
const inA = ferrata(1, 46.52, 11.82);
|
||||
calls[0]?.resolve(reply([inA]));
|
||||
await flush();
|
||||
expect(persisted).toEqual([
|
||||
[
|
||||
{ tile: '930:236', fetchedAt: 1_000, ferratas: [inA] },
|
||||
{ tile: '930:237', fetchedAt: 1_000, ferratas: [] },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-007: reports the view failing and asks again after each delay; a 400 reads as zoom in', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'visible');
|
||||
calls[0]?.reject(new Error('502'));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBe('unavailable');
|
||||
expect(store.getSnapshot().waiting.size).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(calls).toHaveLength(2);
|
||||
calls[1]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBeNull();
|
||||
|
||||
store.want([FAR], 'visible');
|
||||
calls[2]?.reject({ response: { status: 400 } });
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBe('refused');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-008: a failed prefetch says nothing and is asked for again on the next want', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
calls[0]?.reject(new Error('502'));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBeNull();
|
||||
store.want([A], 'background');
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-009: a stop aborts what is on its way and drops the queue, and keeps what is loaded', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
|
||||
store.want([A], 'visible');
|
||||
store.want([B], 'background');
|
||||
expect(calls).toHaveLength(2);
|
||||
store.stop();
|
||||
expect(calls.map((call) => call.signal.aborted)).toEqual([true, true]);
|
||||
calls[0]?.resolve(reply([ferrata(1, 46.52, 11.82)]));
|
||||
await flush();
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(store.getSnapshot().ferratas.map((f) => f.id)).toEqual(['way/9']);
|
||||
expect(store.getSnapshot().waiting.size).toBe(0);
|
||||
// Wanted again, the tiles go out again.
|
||||
store.want([A], 'visible');
|
||||
expect(calls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-010: tells its listeners only of a change, and keeps the list while only the tiles change', async () => {
|
||||
const { store, calls } = harness({ persist: false });
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.hydrate([{ tile: '940:236', fetchedAt: 1, ferratas: [ferrata(9, 47.01, 11.82)] }]);
|
||||
const first = store.getSnapshot();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.want([FAR], 'visible');
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(store.getSnapshot()).toBe(first);
|
||||
|
||||
store.want([A], 'visible');
|
||||
calls[0]?.resolve(reply([]));
|
||||
await flush();
|
||||
expect(store.getSnapshot()).not.toBe(first);
|
||||
expect(store.getSnapshot().ferratas).toBe(first.ferratas);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-TILES-011: a prefetch on its way that holds a tile of the view reports its failure as the view', async () => {
|
||||
const { store, calls } = harness();
|
||||
store.hydrate([]);
|
||||
store.want([A], 'background');
|
||||
store.want([A], 'visible');
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(store.getSnapshot().waiting.has('930:236')).toBe(true);
|
||||
|
||||
calls[0]?.reject(new Error('502'));
|
||||
await flush();
|
||||
expect(store.getSnapshot().failure).toBe('unavailable');
|
||||
expect(store.getSnapshot().waiting.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ferrataRowsOf', () => {
|
||||
it('gives a ferrata to every tile of the box its own line reaches into', () => {
|
||||
// From 46.52 to 46.57: across the row line at 46.55.
|
||||
const across = ferrata(1, 46.52, 11.82, 46.57, 11.82);
|
||||
const rows = ferrataRowsOf({ minRow: 930, maxRow: 931, minCol: 236, maxCol: 237 }, [across], 5);
|
||||
expect(rows.map((row) => [row.tile, row.ferratas.length])).toEqual([
|
||||
['930:236', 1],
|
||||
['930:237', 0],
|
||||
['931:236', 1],
|
||||
['931:237', 0],
|
||||
]);
|
||||
expect(rows.every((row) => row.fetchedAt === 5)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
type Ferrata,
|
||||
type FerrataBbox,
|
||||
type FerratasResponse,
|
||||
type FerrataTile,
|
||||
type FerrataTileRange,
|
||||
ferrataTileKey,
|
||||
ferrataTileRange,
|
||||
ferrataTileRequests,
|
||||
ferrataTilesOf,
|
||||
} from '@trek/shared';
|
||||
|
||||
import type { FerrataTileRow } from '../../repo/ferrataRepo';
|
||||
import { ferrataPointsBbox } from './ferrataGroups';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
|
||||
/**
|
||||
* The via ferrata layer's tiles for one trip, outside React.
|
||||
*
|
||||
* Every ferrata loaded for the trip stays, in a map by OSM id, whatever the
|
||||
* map does afterwards: a pan or a zoom only asks for the tiles of the shared
|
||||
* 0.05 degree grid that are not loaded yet, never for ones it has, and never
|
||||
* drops any. Tiles wanted for the view on screen go first; the ring around it
|
||||
* and the trip's area load behind them. The two have lanes of their own: the
|
||||
* view's requests run beside a prefetch on its way rather than behind it,
|
||||
* VISIBLE_AT_ONCE of them at a time, while prefetches go one at a time, so
|
||||
* the view is never kept waiting by a prefetch. Answers go to `persist` per tile, and a
|
||||
* trip's cached tiles come back through `hydrate`, which holds every request
|
||||
* until the cache has been read, so a cached tile is never asked for again.
|
||||
*
|
||||
* Built for `useSyncExternalStore`: `subscribe` and `getSnapshot`, and the
|
||||
* snapshot is a new object only when something in it changed.
|
||||
*/
|
||||
|
||||
type FerrataTilePriority = 'visible' | 'background';
|
||||
|
||||
/**
|
||||
* Why the last request for the view failed: `refused` (a 400, the server would
|
||||
* not take the box, read as "zoom in") or `unavailable` (anything else: the
|
||||
* Overpass mirrors, worth another try).
|
||||
*/
|
||||
type FerrataTileFailure = 'refused' | 'unavailable';
|
||||
|
||||
export interface FerrataTileSnapshot {
|
||||
/** Every tile loaded for the trip, cached or fetched. */
|
||||
loaded: ReadonlySet<string>;
|
||||
/** Every ferrata those tiles hold, each once. */
|
||||
ferratas: readonly Ferrata[];
|
||||
/** Tiles wanted for the view that are queued or on their way. */
|
||||
waiting: ReadonlySet<string>;
|
||||
failure: FerrataTileFailure | null;
|
||||
/** True when an answer for the view was cut at the server's cap. */
|
||||
truncated: boolean;
|
||||
/** Whether the cached tiles have been read. */
|
||||
hydrated: boolean;
|
||||
}
|
||||
|
||||
interface FerrataTileStoreDeps {
|
||||
fetch: (bbox: FerrataBbox, signal: AbortSignal) => Promise<FerratasResponse>;
|
||||
persist?: (rows: FerrataTileRow[]) => Promise<void>;
|
||||
now?: () => number;
|
||||
/** How long to wait before asking again for the view after the mirrors failed it. */
|
||||
retryDelaysMs?: readonly number[];
|
||||
}
|
||||
|
||||
interface Job {
|
||||
bbox: FerrataBbox;
|
||||
range: FerrataTileRange;
|
||||
keys: string[];
|
||||
priority: FerrataTilePriority;
|
||||
}
|
||||
|
||||
const FERRATA_RETRY_DELAYS_MS = [5_000, 15_000, 45_000] as const;
|
||||
|
||||
/** Requests for the view on screen that may be on their way at once. */
|
||||
const VISIBLE_AT_ONCE = 2;
|
||||
/** Prefetches on their way at once: one, beside the view's own. */
|
||||
const BACKGROUND_AT_ONCE = 1;
|
||||
|
||||
/** A job on its way, and the lane it took: the lane is what it counts against, even once it is moved up. */
|
||||
interface Flight {
|
||||
job: Job;
|
||||
lane: FerrataTilePriority;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
function failureOf(error: unknown): FerrataTileFailure {
|
||||
const status = (error as { response?: { status?: unknown } } | null)?.response?.status;
|
||||
return status === 400 ? 'refused' : 'unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* The ferratas of an answer per tile of the box it was for: each one goes to
|
||||
* every tile its own box reaches into, and every tile gets a row, an empty one
|
||||
* included, because "no ferrata here" is an answer worth keeping too.
|
||||
*/
|
||||
export function ferrataRowsOf(range: FerrataTileRange, ferratas: readonly Ferrata[], at: number): FerrataTileRow[] {
|
||||
const rows = new Map(ferrataTilesOf(range).map((tile) => [ferrataTileKey(tile), [] as Ferrata[]]));
|
||||
for (const ferrata of ferratas) {
|
||||
const box = ferrataPointsBbox(ferrataPositions(ferrata));
|
||||
if (!box) continue;
|
||||
const own = ferrataTileRange(box);
|
||||
const minRow = Math.max(own.minRow, range.minRow);
|
||||
const maxRow = Math.min(own.maxRow, range.maxRow);
|
||||
const minCol = Math.max(own.minCol, range.minCol);
|
||||
const maxCol = Math.min(own.maxCol, range.maxCol);
|
||||
if (minRow > maxRow || minCol > maxCol) continue;
|
||||
for (const tile of ferrataTilesOf({ minRow, maxRow, minCol, maxCol }))
|
||||
rows.get(ferrataTileKey(tile))?.push(ferrata);
|
||||
}
|
||||
return [...rows].map(([tile, list]) => ({ tile, fetchedAt: at, ferratas: list }));
|
||||
}
|
||||
|
||||
export class FerrataTileStore {
|
||||
private readonly loaded = new Set<string>();
|
||||
private readonly byId = new Map<string, Ferrata>();
|
||||
/** Tiles in a queued or in-flight job, so no tile is asked for twice at once. */
|
||||
private readonly pending = new Set<string>();
|
||||
private readonly waiting = new Set<string>();
|
||||
private queue: Job[] = [];
|
||||
private readonly inFlight = new Set<Flight>();
|
||||
private failure: FerrataTileFailure | null = null;
|
||||
private truncated = false;
|
||||
private hydrated = false;
|
||||
private hydrating = false;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryAttempt = 0;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private snapshot: FerrataTileSnapshot;
|
||||
/** Writes to the device cache that failed; the tiles still draw, they only load again next time. */
|
||||
persistFailures = 0;
|
||||
|
||||
constructor(private readonly deps: FerrataTileStoreDeps) {
|
||||
this.snapshot = this.build();
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
getSnapshot = (): FerrataTileSnapshot => this.snapshot;
|
||||
|
||||
/** Starts reading the cache; requests wait until `hydrate` hands its rows over. */
|
||||
beginHydration(): boolean {
|
||||
if (this.hydrated || this.hydrating) return false;
|
||||
this.hydrating = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The cached tiles. Queued jobs whose tiles all came back from the cache are dropped. */
|
||||
hydrate(rows: readonly FerrataTileRow[]): void {
|
||||
for (const row of rows) {
|
||||
this.loaded.add(row.tile);
|
||||
this.waiting.delete(row.tile);
|
||||
for (const ferrata of row.ferratas) this.byId.set(ferrata.id, ferrata);
|
||||
}
|
||||
this.hydrating = false;
|
||||
this.hydrated = true;
|
||||
this.queue = this.queue.filter((job) => {
|
||||
if (!job.keys.every((key) => this.loaded.has(key))) return true;
|
||||
for (const key of job.keys) this.pending.delete(key);
|
||||
return false;
|
||||
});
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the tiles not loaded or on their way yet. `visible` ones jump the
|
||||
* queue, and a background job already holding one of them is moved up with
|
||||
* it; the error of an earlier view is cleared, its retry called off.
|
||||
*/
|
||||
want(tiles: readonly FerrataTile[], priority: FerrataTilePriority): void {
|
||||
const keys = tiles.map(ferrataTileKey);
|
||||
if (priority === 'visible') {
|
||||
this.clearRetry();
|
||||
// The ones on their way too, so their failure is reported and retried like the view's own.
|
||||
const jobs = [...[...this.inFlight].map((flight) => flight.job), ...this.queue];
|
||||
for (const job of jobs) {
|
||||
if (job.priority === 'background' && job.keys.some((key) => keys.includes(key))) job.priority = 'visible';
|
||||
}
|
||||
for (const key of keys) if (!this.loaded.has(key)) this.waiting.add(key);
|
||||
}
|
||||
const missing = tiles.filter((tile, index) => !this.loaded.has(keys[index]) && !this.pending.has(keys[index]));
|
||||
for (const request of ferrataTileRequests(missing)) {
|
||||
const jobKeys = ferrataTilesOf(request.range).map(ferrataTileKey);
|
||||
for (const key of jobKeys) this.pending.add(key);
|
||||
this.queue.push({ ...request, keys: jobKeys, priority });
|
||||
}
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
/** Calls off everything queued, in flight or due for a retry; what is loaded stays. */
|
||||
stop(): void {
|
||||
this.clearRetry();
|
||||
for (const flight of this.inFlight) flight.controller.abort();
|
||||
this.inFlight.clear();
|
||||
this.queue = [];
|
||||
this.pending.clear();
|
||||
this.waiting.clear();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private clearRetry(): void {
|
||||
if (this.retryTimer !== null) clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
/** How many jobs of a lane are on their way. */
|
||||
private flying(lane: FerrataTilePriority): number {
|
||||
let count = 0;
|
||||
for (const flight of this.inFlight) if (flight.lane === lane) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/** The next queued job of a priority, taken off the queue, or null for none. */
|
||||
private take(priority: FerrataTilePriority): Job | null {
|
||||
const index = this.queue.findIndex((job) => job.priority === priority);
|
||||
if (index < 0) return null;
|
||||
const [job] = this.queue.splice(index, 1);
|
||||
return job ?? null;
|
||||
}
|
||||
|
||||
/** Starts what the lanes have room for: the view's jobs in theirs, the prefetches in theirs. */
|
||||
private pump(): void {
|
||||
if (!this.hydrated) return;
|
||||
for (const [lane, room] of [
|
||||
['visible', VISIBLE_AT_ONCE],
|
||||
['background', BACKGROUND_AT_ONCE],
|
||||
] as const) {
|
||||
while (this.flying(lane) < room) {
|
||||
const job = this.take(lane);
|
||||
if (!job) break;
|
||||
this.launch(job, lane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private launch(job: Job, lane: FerrataTilePriority): void {
|
||||
const controller = new AbortController();
|
||||
const flight: Flight = { job, lane, controller };
|
||||
this.inFlight.add(flight);
|
||||
void this.deps.fetch(job.bbox, controller.signal).then(
|
||||
(reply) => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.inFlight.delete(flight);
|
||||
this.settle(job, reply);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
this.inFlight.delete(flight);
|
||||
this.fail(job, failureOf(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private settle(job: Job, reply: FerratasResponse): void {
|
||||
const at = (this.deps.now ?? Date.now)();
|
||||
for (const ferrata of reply.ferratas) this.byId.set(ferrata.id, ferrata);
|
||||
for (const key of job.keys) {
|
||||
this.loaded.add(key);
|
||||
this.pending.delete(key);
|
||||
this.waiting.delete(key);
|
||||
}
|
||||
if (job.priority === 'visible') {
|
||||
this.failure = null;
|
||||
this.retryAttempt = 0;
|
||||
this.truncated = reply.truncated;
|
||||
}
|
||||
const persist = this.deps.persist;
|
||||
if (persist) {
|
||||
void persist(ferrataRowsOf(job.range, reply.ferratas, at)).catch(() => {
|
||||
this.persistFailures += 1;
|
||||
});
|
||||
}
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
private fail(job: Job, failure: FerrataTileFailure): void {
|
||||
for (const key of job.keys) this.pending.delete(key);
|
||||
if (job.priority === 'visible') {
|
||||
// The view's tiles are no longer on their way: the status reads the failure instead.
|
||||
for (const key of job.keys) this.waiting.delete(key);
|
||||
this.failure = failure;
|
||||
const delay = (this.deps.retryDelaysMs ?? FERRATA_RETRY_DELAYS_MS)[this.retryAttempt];
|
||||
if (failure === 'unavailable' && delay !== undefined) {
|
||||
this.retryAttempt += 1;
|
||||
const tiles = ferrataTilesOf(job.range);
|
||||
this.clearRetry();
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.want(tiles, 'visible');
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
this.pump();
|
||||
}
|
||||
|
||||
private build(): FerrataTileSnapshot {
|
||||
return {
|
||||
loaded: new Set(this.loaded),
|
||||
ferratas: [...this.byId.values()],
|
||||
waiting: new Set(this.waiting),
|
||||
failure: this.failure,
|
||||
truncated: this.truncated,
|
||||
hydrated: this.hydrated,
|
||||
};
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const next = this.build();
|
||||
const prev = this.snapshot;
|
||||
// An unchanged list keeps its reference, so what is drawn from it is not rebuilt.
|
||||
const sameFerratas =
|
||||
prev.ferratas.length === next.ferratas.length &&
|
||||
prev.ferratas.every((ferrata, index) => ferrata === next.ferratas[index]);
|
||||
const same =
|
||||
sameFerratas &&
|
||||
prev.failure === next.failure &&
|
||||
prev.truncated === next.truncated &&
|
||||
prev.hydrated === next.hydrated &&
|
||||
sameSet(prev.loaded, next.loaded) &&
|
||||
sameSet(prev.waiting, next.waiting);
|
||||
if (same) return;
|
||||
this.snapshot = sameFerratas ? { ...next, ferratas: prev.ferratas } : next;
|
||||
for (const listener of [...this.listeners]) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const value of a) if (!b.has(value)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* FE-FERRATA-GL-001 to FE-FERRATA-GL-013: the via ferrata layer on a
|
||||
* MapLibre or Mapbox GL map.
|
||||
*
|
||||
* Pinned: the layers go in once per map (and wait for `idle` when tiles are
|
||||
* still loading, the failure the hazard overlay had), the line is the dark
|
||||
* dashed via ferrata violet and never a grade colour, new data replaces the
|
||||
* sources' contents instead of rebuilding the layers, a click on the hit line
|
||||
* hands the ferrata and the way to `onSelect`, hover and selection highlight
|
||||
* the whole ferrata (solid, on a wider casing) and name it above the pointer,
|
||||
* the 3D terrain draws wider lines, the route icon joins once its image is drawn, a theme
|
||||
* change repaints the installed layers in place, and an unmount leaves nothing
|
||||
* behind.
|
||||
*/
|
||||
import { act, render, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { Map as MapboxMap, MapMouseEvent } from 'mapbox-gl';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import FerrataLayerGL from './FerrataLayerGL';
|
||||
import { groupFerratas } from './ferrataGroups';
|
||||
import { useFerrataLayerGL } from './useFerrataLayerGL';
|
||||
|
||||
vi.mock('./ferrataIcon', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('./ferrataIcon')>()),
|
||||
loadFerrataIcon: vi.fn(() => Promise.resolve({ width: 44, height: 44 })),
|
||||
}));
|
||||
|
||||
const SOURCE = 'trek-ferratas';
|
||||
const LABEL_SOURCE = 'trek-ferratas-labels';
|
||||
const CASING = 'trek-ferratas-casing';
|
||||
const LINE = 'trek-ferratas-line';
|
||||
const HIGHLIGHT_CASING = 'trek-ferratas-highlight-casing';
|
||||
const HIGHLIGHT = 'trek-ferratas-highlight';
|
||||
const HIT = 'trek-ferratas-hit';
|
||||
const LABEL = 'trek-ferratas-label';
|
||||
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT];
|
||||
|
||||
const ferrata = (id: string, lat: number, name: string, lng = 11.7): Ferrata => ({
|
||||
id,
|
||||
name,
|
||||
grade: { raw: '2', level: 2, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, lng],
|
||||
[lat + 0.01, lng + 0.01],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/${id}`,
|
||||
});
|
||||
|
||||
/** Two ferratas: way/1 and way/2 are one (same name, touching), way/3 another. */
|
||||
const GROUPS = groupFerratas([
|
||||
ferrata('way/1', 46.5, 'Via A'),
|
||||
ferrata('way/2', 46.51, 'Via A', 11.71),
|
||||
ferrata('way/3', 47, 'Via B'),
|
||||
]);
|
||||
|
||||
type Handler = (event?: unknown) => void;
|
||||
|
||||
function fakeGlMap(styleLoaded = true) {
|
||||
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
|
||||
const layers = new Set<string>();
|
||||
const images = new Set<string>();
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
const once = new Map<string, Set<Handler>>();
|
||||
const state = { styleLoaded, removed: false, hit: null as null | { properties: { id: string; group: string } } };
|
||||
const canvas = document.createElement('canvas');
|
||||
const container = document.createElement('div');
|
||||
const keyOf = (type: string, layer?: unknown) => (typeof layer === 'string' ? `${type}:${layer}` : type);
|
||||
const map = {
|
||||
isStyleLoaded: vi.fn(() => state.styleLoaded),
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string) => {
|
||||
sources.set(id, { setData: vi.fn() });
|
||||
}),
|
||||
removeSource: vi.fn((id: string) => {
|
||||
sources.delete(id);
|
||||
}),
|
||||
// Like MapLibre: a removed map has no style, and asking it for a layer throws.
|
||||
getStyle: vi.fn(() => (state.removed ? undefined : { version: 8 })),
|
||||
getLayer: vi.fn((id: string) => {
|
||||
if (state.removed) throw new TypeError('can\'t access property "getLayer", this.style is undefined');
|
||||
return layers.has(id) ? {} : undefined;
|
||||
}),
|
||||
addLayer: vi.fn((spec: { id: string }) => {
|
||||
layers.add(spec.id);
|
||||
}),
|
||||
removeLayer: vi.fn((id: string) => {
|
||||
layers.delete(id);
|
||||
}),
|
||||
setPaintProperty: vi.fn(),
|
||||
setFilter: vi.fn(),
|
||||
hasImage: vi.fn((id: string) => images.has(id)),
|
||||
addImage: vi.fn((id: string) => {
|
||||
images.add(id);
|
||||
}),
|
||||
removeImage: vi.fn((id: string) => {
|
||||
images.delete(id);
|
||||
}),
|
||||
queryRenderedFeatures: vi.fn(() => (state.hit ? [state.hit] : [])),
|
||||
getCanvas: vi.fn(() => canvas),
|
||||
getContainer: vi.fn(() => container),
|
||||
on: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
|
||||
const key = keyOf(type, layerOrFn);
|
||||
const set = handlers.get(key) ?? new Set<Handler>();
|
||||
set.add((fn ?? layerOrFn) as Handler);
|
||||
handlers.set(key, set);
|
||||
}),
|
||||
once: vi.fn((type: string, fn: Handler) => {
|
||||
const set = once.get(type) ?? new Set<Handler>();
|
||||
set.add(fn);
|
||||
once.set(type, set);
|
||||
}),
|
||||
off: vi.fn((type: string, layerOrFn: unknown, fn?: Handler) => {
|
||||
const target = (fn ?? layerOrFn) as Handler;
|
||||
handlers.get(keyOf(type, layerOrFn))?.delete(target);
|
||||
once.get(type)?.delete(target);
|
||||
}),
|
||||
};
|
||||
const emit = (key: string, event?: unknown) => [...(handlers.get(key) ?? [])].forEach((fn) => fn(event));
|
||||
return {
|
||||
map: map as unknown as MapboxMap,
|
||||
spies: map,
|
||||
state,
|
||||
sources,
|
||||
layers,
|
||||
images,
|
||||
canvas,
|
||||
container,
|
||||
emit,
|
||||
listeners: () => [...handlers.values()].reduce((sum, set) => sum + set.size, 0),
|
||||
emitIdle: () => {
|
||||
const pending = [...(once.get('idle') ?? [])];
|
||||
once.delete('idle');
|
||||
pending.forEach((fn) => fn());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const click = { point: { x: 4, y: 4 }, lngLat: { lng: 11.705, lat: 46.505 } } as unknown as MapMouseEvent;
|
||||
|
||||
const addedIds = (gl: ReturnType<typeof fakeGlMap>) => gl.spies.addLayer.mock.calls.map(([layer]) => layer.id);
|
||||
const spec = (gl: ReturnType<typeof fakeGlMap>, id: string) =>
|
||||
gl.spies.addLayer.mock.calls.map(([layer]) => layer).find((layer) => layer.id === id) as unknown as {
|
||||
paint: Record<string, unknown>;
|
||||
filter?: unknown;
|
||||
minzoom?: number;
|
||||
layout?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const filterOf = (ids: string[]) => ['in', ['get', 'group'], ['literal', ids]];
|
||||
|
||||
describe('useFerrataLayerGL', () => {
|
||||
it('FE-FERRATA-GL-001: adds the line and label sources and the casing, line, highlight casing, highlight and hit layers, in that order', () => {
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
|
||||
expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' }));
|
||||
expect(gl.spies.addSource).toHaveBeenCalledWith(LABEL_SOURCE, expect.objectContaining({ type: 'geojson' }));
|
||||
expect(addedIds(gl)).toEqual(LAYERS);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-002: draws a dashed violet line on a white casing, never by grade', () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
root.style.setProperty('--map-ferrata-casing', 'white');
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
|
||||
const line = spec(gl, LINE);
|
||||
expect(line.paint['line-color']).toBe('black');
|
||||
expect(line.paint['line-dasharray']).toEqual([2, 1.5]);
|
||||
expect(spec(gl, CASING).paint['line-color']).toBe('white');
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
root.style.removeProperty('--map-ferrata-casing');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-003: waits for idle when the style is still loading tiles', () => {
|
||||
const gl = fakeGlMap(false);
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
expect(gl.spies.addSource).not.toHaveBeenCalled();
|
||||
|
||||
gl.state.styleLoaded = true;
|
||||
gl.emitIdle();
|
||||
expect(addedIds(gl)).toEqual(LAYERS);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-004: new ferratas replace the sources data without rebuilding the layers', () => {
|
||||
const gl = fakeGlMap();
|
||||
const { rerender } = renderHook(({ groups }) => useFerrataLayerGL(gl.map, groups, vi.fn()), {
|
||||
initialProps: { groups: GROUPS.slice(0, 1) },
|
||||
});
|
||||
rerender({ groups: GROUPS });
|
||||
|
||||
expect(addedIds(gl)).toEqual(LAYERS);
|
||||
expect(gl.sources.get(SOURCE)?.setData).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
features: [
|
||||
expect.objectContaining({ properties: { id: 'way/1', group: 'group/way/1' } }),
|
||||
expect.objectContaining({ properties: { id: 'way/2', group: 'group/way/1' } }),
|
||||
expect.objectContaining({ properties: { id: 'way/3', group: 'group/way/3' } }),
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(gl.sources.get(LABEL_SOURCE)?.setData).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ features: [expect.anything(), expect.anything()] })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-005: a click on the hit line hands the ferrata, the way and the point to onSelect', () => {
|
||||
const gl = fakeGlMap();
|
||||
const onSelect = vi.fn();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, onSelect));
|
||||
|
||||
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
|
||||
gl.emit('click', click);
|
||||
expect(onSelect).toHaveBeenCalledWith(GROUPS[0], GROUPS[0]?.segments[1], { lng: 11.705, lat: 46.505 });
|
||||
|
||||
onSelect.mockClear();
|
||||
gl.state.hit = null;
|
||||
gl.emit('click', click);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-006: the pointer turns into a hand over a line, and the whole ferrata under it lights up', () => {
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
expect(spec(gl, HIGHLIGHT).filter).toEqual(filterOf([]));
|
||||
|
||||
gl.emit(`mouseenter:${HIT}`);
|
||||
expect(gl.canvas.style.cursor).toBe('pointer');
|
||||
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
|
||||
act(() => gl.emit(`mousemove:${HIT}`, click));
|
||||
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/1']));
|
||||
|
||||
act(() => gl.emit(`mouseleave:${HIT}`));
|
||||
expect(gl.canvas.style.cursor).toBe('');
|
||||
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf([]));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-007: the ferrata open in the card stays highlighted', () => {
|
||||
const gl = fakeGlMap();
|
||||
const { rerender } = renderHook(({ selected }) => useFerrataLayerGL(gl.map, GROUPS, vi.fn(), selected), {
|
||||
initialProps: { selected: null as string | null },
|
||||
});
|
||||
rerender({ selected: 'group/way/3' });
|
||||
expect(gl.spies.setFilter).toHaveBeenLastCalledWith(HIGHLIGHT, filterOf(['group/way/3']));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-008: adds the route icon layer from the zoom it shows at, once its image is drawn', async () => {
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
|
||||
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||
expect(gl.images.has('trek-ferrata-icon')).toBe(true);
|
||||
const label = spec(gl, LABEL);
|
||||
expect(label.minzoom).toBe(13);
|
||||
expect(label.layout?.['icon-image']).toBe('trek-ferrata-icon');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-009: unmounting removes the layers, the sources, the icon and every listener; no map, no work', async () => {
|
||||
const gl = fakeGlMap();
|
||||
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
|
||||
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||
unmount();
|
||||
|
||||
expect(gl.layers.size).toBe(0);
|
||||
expect(gl.sources.size).toBe(0);
|
||||
expect(gl.images.size).toBe(0);
|
||||
expect(gl.listeners()).toBe(0);
|
||||
expect(() => renderHook(() => useFerrataLayerGL(null, GROUPS, vi.fn()))).not.toThrow();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-013: a map already removed with its view (leaving 3D) is left alone on unmount', async () => {
|
||||
const gl = fakeGlMap();
|
||||
const { unmount } = render(<FerrataLayerGL map={gl.map} groups={GROUPS} onSelect={vi.fn()} />);
|
||||
await waitFor(() => expect(gl.layers.has(LABEL)).toBe(true));
|
||||
gl.state.removed = true;
|
||||
expect(() => unmount()).not.toThrow();
|
||||
expect(gl.listeners()).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-010: a theme change repaints the installed layers without rebuilding them', async () => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const gl = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
expect(gl.spies.setPaintProperty).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.style.setProperty('--map-ferrata', 'ivory');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(gl.spies.setPaintProperty).toHaveBeenCalledWith(LINE, 'line-color', 'ivory'));
|
||||
expect(addedIds(gl).filter((id) => LAYERS.includes(id))).toEqual(LAYERS);
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
});
|
||||
it('FE-FERRATA-GL-011: names the ferrata under the pointer with its grade, above the pointer, and hides it on leave', () => {
|
||||
const gl = fakeGlMap();
|
||||
const { unmount } = renderHook(() => useFerrataLayerGL(gl.map, GROUPS, vi.fn()));
|
||||
gl.state.hit = { properties: { id: 'way/2', group: 'group/way/1' } };
|
||||
act(() => gl.emit(`mousemove:${HIT}`, click));
|
||||
|
||||
const label = gl.container.querySelector<HTMLElement>('[data-testid="ferrata-hover-label"]');
|
||||
expect(label?.style.display).toBe('');
|
||||
expect(label?.textContent).toBe('Via AK2');
|
||||
expect(label?.querySelector('span')?.textContent).toBe('K2');
|
||||
expect(label?.style.transform).toContain('translate(4px, -8px)');
|
||||
expect(label).toHaveClass('bg-surface-card', 'text-content', 'border-edge-faint');
|
||||
|
||||
act(() => gl.emit(`mouseleave:${HIT}`));
|
||||
expect(label?.style.display).toBe('none');
|
||||
unmount();
|
||||
expect(gl.container.querySelector('[data-testid="ferrata-hover-label"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-GL-012: lights a ferrata solid on a wider casing, and draws wider lines on the 3D terrain', () => {
|
||||
const flat = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(flat.map, GROUPS, vi.fn()));
|
||||
expect(spec(flat, CASING).paint['line-width']).toBe(6);
|
||||
expect(spec(flat, LINE).paint['line-width']).toBe(2.5);
|
||||
expect(spec(flat, HIGHLIGHT_CASING).paint['line-width']).toBe(9);
|
||||
expect(spec(flat, HIGHLIGHT).paint['line-width']).toBe(4);
|
||||
expect(spec(flat, HIGHLIGHT).paint['line-dasharray']).toBeUndefined();
|
||||
|
||||
const terrain = fakeGlMap();
|
||||
renderHook(() => useFerrataLayerGL(terrain.map, GROUPS, vi.fn(), null, 'terrain'));
|
||||
expect(spec(terrain, CASING).paint['line-width']).toBe(8);
|
||||
expect(spec(terrain, LINE).paint['line-width']).toBe(3.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { ExpressionSpecification, GeoJSONSource, Map, MapMouseEvent } from 'mapbox-gl';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Ferrata } from '@trek/shared';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { mapHoverLabel } from '../Map/mapHoverLabel';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { FERRATA_ICON_IMAGE, FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_PIXEL_RATIO, loadFerrataIcon } from './ferrataIcon';
|
||||
import { ferrataFeatureCollection, ferrataLabelCollection } from './ferrataModel';
|
||||
import type { FerrataPaint } from './ferrataPaint';
|
||||
import { useFerrataPaint } from './useFerrataPaint';
|
||||
|
||||
const SOURCE = 'trek-ferratas';
|
||||
const LABEL_SOURCE = `${SOURCE}-labels`;
|
||||
const CASING = `${SOURCE}-casing`;
|
||||
const LINE = `${SOURCE}-line`;
|
||||
/** The wider casing under a lit ferrata, so it stands out by its shape and not only its colour. */
|
||||
const HIGHLIGHT_CASING = `${SOURCE}-highlight-casing`;
|
||||
/** The whole ferrata under the pointer or open in the card, drawn solid over its own line. */
|
||||
const HIGHLIGHT = `${SOURCE}-highlight`;
|
||||
/** A wide, invisible line on top, so a thin dashed route is still easy to tap. */
|
||||
const HIT = `${SOURCE}-hit`;
|
||||
const LABEL = `${SOURCE}-label`;
|
||||
const LAYERS = [CASING, LINE, HIGHLIGHT_CASING, HIGHLIGHT, HIT, LABEL];
|
||||
const LIT_LAYERS = [HIGHLIGHT_CASING, HIGHLIGHT];
|
||||
|
||||
/**
|
||||
* The line widths, in pixels. On the 3D terrain the map is seen at a slant
|
||||
* and over a hillshade, so the lines there are drawn wider to stay readable.
|
||||
*/
|
||||
interface FerrataWidths {
|
||||
casing: number;
|
||||
line: number;
|
||||
litCasing: number;
|
||||
lit: number;
|
||||
}
|
||||
|
||||
const FLAT_WIDTHS: FerrataWidths = { casing: 6, line: 2.5, litCasing: 9, lit: 4 };
|
||||
const TERRAIN_WIDTHS: FerrataWidths = { casing: 8, line: 3.5, litCasing: 11, lit: 5 };
|
||||
|
||||
/** Where the layer is drawn: the flat map, or the 3D terrain, which draws its lines wider. */
|
||||
type FerrataSurface = 'flat' | 'terrain';
|
||||
|
||||
/** Where on the map a ferrata was clicked, for a popup or card anchored there. */
|
||||
interface FerrataClickPoint {
|
||||
lng: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
/** A click on a line: the ferrata it belongs to, the OSM way under the pointer and where it landed. */
|
||||
export type FerrataSelectHandler = (group: FerrataGroup, segment: Ferrata, at: FerrataClickPoint) => void;
|
||||
|
||||
/** The groups to highlight, as a GL filter on the features' `group`. */
|
||||
function highlightFilter(ids: readonly string[]): ExpressionSpecification {
|
||||
return ['in', ['get', 'group'], ['literal', [...ids]]];
|
||||
}
|
||||
|
||||
const LINE_LAYOUT = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
||||
|
||||
/**
|
||||
* The two sources and the five line layers, bottom to top: casing, dashed
|
||||
* line, the lit ferrata's wider casing and its solid line, hit line.
|
||||
*/
|
||||
function addFerrataLayers(
|
||||
map: Map,
|
||||
paint: FerrataPaint,
|
||||
widths: FerrataWidths,
|
||||
data: GeoJSON.GeoJSON,
|
||||
labels: GeoJSON.GeoJSON
|
||||
): void {
|
||||
map.addSource(SOURCE, { type: 'geojson', data });
|
||||
map.addSource(LABEL_SOURCE, { type: 'geojson', data: labels });
|
||||
map.addLayer({
|
||||
id: CASING,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
paint: { 'line-color': paint.casing, 'line-width': widths.casing, 'line-opacity': 0.9 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: LINE,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
paint: { 'line-color': paint.line, 'line-width': widths.line, 'line-dasharray': [2, 1.5] },
|
||||
});
|
||||
map.addLayer({
|
||||
id: HIGHLIGHT_CASING,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
filter: highlightFilter([]),
|
||||
paint: { 'line-color': paint.casing, 'line-width': widths.litCasing },
|
||||
});
|
||||
map.addLayer({
|
||||
id: HIGHLIGHT,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
filter: highlightFilter([]),
|
||||
paint: { 'line-color': paint.highlight, 'line-width': widths.lit },
|
||||
});
|
||||
map.addLayer({
|
||||
id: HIT,
|
||||
type: 'line',
|
||||
source: SOURCE,
|
||||
layout: LINE_LAYOUT,
|
||||
paint: { 'line-color': paint.casing, 'line-width': 16, 'line-opacity': 0 },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the route icon in `paint` and, the first time, the symbol layer that
|
||||
* shows it from FERRATA_ICON_MIN_ZOOM. Done once the image is decoded; a
|
||||
* layer torn down meanwhile (`gone`) is left alone.
|
||||
*/
|
||||
function addFerrataIcon(map: Map, paint: FerrataPaint, gone: () => boolean): void {
|
||||
void loadFerrataIcon(paint).then(
|
||||
(image) => {
|
||||
if (gone() || !map.getSource(LABEL_SOURCE)) return;
|
||||
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
|
||||
map.addImage(FERRATA_ICON_IMAGE, image, { pixelRatio: FERRATA_ICON_PIXEL_RATIO });
|
||||
if (map.getLayer(LABEL)) return;
|
||||
map.addLayer({
|
||||
id: LABEL,
|
||||
type: 'symbol',
|
||||
source: LABEL_SOURCE,
|
||||
minzoom: FERRATA_ICON_MIN_ZOOM,
|
||||
layout: { 'icon-image': FERRATA_ICON_IMAGE, 'icon-allow-overlap': false, 'icon-ignore-placement': false },
|
||||
});
|
||||
},
|
||||
// Undecodable: the lines simply go without their icons.
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
function removeFerrataLayers(map: Map): void {
|
||||
// A map already removed with its view (leaving 3D) took its style and the layers with it;
|
||||
// asking it for a layer then throws.
|
||||
if (!map.getStyle?.()) return;
|
||||
for (const layer of [...LAYERS].reverse()) if (map.getLayer(layer)) map.removeLayer(layer);
|
||||
for (const source of [LABEL_SOURCE, SOURCE]) if (map.getSource(source)) map.removeSource(source);
|
||||
if (map.hasImage(FERRATA_ICON_IMAGE)) map.removeImage(FERRATA_ICON_IMAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata layer on a MapLibre or Mapbox GL map: a source of the OSM
|
||||
* ways and one of a point per ferrata, a light casing, the dark dashed line,
|
||||
* the highlight of the ferrata under the pointer or `selectedId` (solid and
|
||||
* wider, on a wider casing), a hit line that turns a click into
|
||||
* `onSelect(group, way, point)` and shows the name and grade above the
|
||||
* pointer, and from FERRATA_ICON_MIN_ZOOM a small route icon halfway along
|
||||
* each ferrata. `surface` 'terrain' draws the lines wider for the 3D map.
|
||||
*
|
||||
* The layers are installed once per map and again after a style swap (a
|
||||
* basemap or theme change drops every custom layer); new ferratas only replace
|
||||
* the sources' data, so tiles loading after a pan do not rebuild the layers.
|
||||
* Like the hazard overlay, a draw that finds the style busy loading tiles
|
||||
* waits for `idle` rather than for a `style.load` that may never come. A theme
|
||||
* change repaints the installed layers in place and redraws the icon.
|
||||
*/
|
||||
export function useFerrataLayerGL(
|
||||
map: Map | null,
|
||||
groups: readonly FerrataGroup[],
|
||||
onSelect: FerrataSelectHandler,
|
||||
selectedId: string | null = null,
|
||||
surface: FerrataSurface = 'flat'
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const data = useMemo(() => ferrataFeatureCollection(groups), [groups]);
|
||||
const labels = useMemo(() => ferrataLabelCollection(groups), [groups]);
|
||||
const paint = useFerrataPaint();
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
// The handlers read the newest props without reinstalling the layers on every render.
|
||||
const latest = useRef({ groups, data, labels, onSelect, paint, t, surface });
|
||||
// The paint the installed layers were last drawn in, so a mount does not repaint what it just drew.
|
||||
const painted = useRef<FerrataPaint | null>(null);
|
||||
// Set by the install effect: redraws the icon in the current paint on a theme change.
|
||||
const iconRepaint = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
latest.current = { groups, data, labels, onSelect, paint, t, surface };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
let waiting = false;
|
||||
let disposed = false;
|
||||
const hoverLabel = mapHoverLabel(map, 'ferrata-hover-label');
|
||||
const draw = () => {
|
||||
if (map.getSource(SOURCE)) return;
|
||||
if (!map.isStyleLoaded()) {
|
||||
if (!waiting) {
|
||||
waiting = true;
|
||||
map.once('idle', retry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { paint, data, labels } = latest.current;
|
||||
painted.current = paint;
|
||||
const widths = latest.current.surface === 'terrain' ? TERRAIN_WIDTHS : FLAT_WIDTHS;
|
||||
addFerrataLayers(map, paint, widths, data, labels);
|
||||
addFerrataIcon(map, paint, () => disposed);
|
||||
};
|
||||
const retry = () => {
|
||||
waiting = false;
|
||||
draw();
|
||||
};
|
||||
const featureAt = (event: MapMouseEvent) => {
|
||||
if (!map.getLayer(HIT)) return null;
|
||||
const hit = map.queryRenderedFeatures(event.point, { layers: [HIT] })[0];
|
||||
return hit?.properties ?? null;
|
||||
};
|
||||
const click = (event: MapMouseEvent) => {
|
||||
const id: unknown = featureAt(event)?.id;
|
||||
for (const group of latest.current.groups) {
|
||||
const segment = group.segments.find((candidate) => candidate.id === id);
|
||||
if (segment) {
|
||||
latest.current.onSelect(group, segment, { lng: event.lngLat.lng, lat: event.lngLat.lat });
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
const move = (event: MapMouseEvent) => {
|
||||
const id: unknown = featureAt(event)?.group;
|
||||
const group = typeof id === 'string' ? latest.current.groups.find((candidate) => candidate.id === id) : undefined;
|
||||
setHovered(group ? group.id : null);
|
||||
if (group) {
|
||||
const label = ferrataLabel(group, latest.current.t);
|
||||
hoverLabel.show({ name: label.name, detail: label.grade }, event.point);
|
||||
} else hoverLabel.hide();
|
||||
};
|
||||
const enter = () => {
|
||||
map.getCanvas().style.cursor = 'pointer';
|
||||
};
|
||||
const leave = () => {
|
||||
map.getCanvas().style.cursor = '';
|
||||
setHovered(null);
|
||||
hoverLabel.hide();
|
||||
};
|
||||
const repaintIcon = () => addFerrataIcon(map, latest.current.paint, () => disposed);
|
||||
|
||||
draw();
|
||||
map.on('style.load', draw);
|
||||
map.on('click', click);
|
||||
map.on('mousemove', HIT, move);
|
||||
map.on('mouseenter', HIT, enter);
|
||||
map.on('mouseleave', HIT, leave);
|
||||
iconRepaint.current = repaintIcon;
|
||||
return () => {
|
||||
disposed = true;
|
||||
iconRepaint.current = null;
|
||||
map.off('style.load', draw);
|
||||
map.off('idle', retry);
|
||||
map.off('click', click);
|
||||
map.off('mousemove', HIT, move);
|
||||
map.off('mouseenter', HIT, enter);
|
||||
map.off('mouseleave', HIT, leave);
|
||||
hoverLabel.remove();
|
||||
removeFerrataLayers(map);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
(map?.getSource(SOURCE) as GeoJSONSource | undefined)?.setData(data);
|
||||
(map?.getSource(LABEL_SOURCE) as GeoJSONSource | undefined)?.setData(labels);
|
||||
}, [map, data, labels]);
|
||||
|
||||
const highlighted = useMemo(
|
||||
() => [...new Set([hovered, selectedId].filter((id): id is string => !!id))],
|
||||
[hovered, selectedId]
|
||||
);
|
||||
useEffect(() => {
|
||||
for (const layer of LIT_LAYERS) if (map?.getLayer(layer)) map.setFilter(layer, highlightFilter(highlighted));
|
||||
}, [map, highlighted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || painted.current === paint || !map.getLayer(LINE)) return;
|
||||
painted.current = paint;
|
||||
for (const layer of [CASING, HIGHLIGHT_CASING])
|
||||
if (map.getLayer(layer)) map.setPaintProperty(layer, 'line-color', paint.casing);
|
||||
map.setPaintProperty(LINE, 'line-color', paint.line);
|
||||
if (map.getLayer(HIGHLIGHT)) map.setPaintProperty(HIGHLIGHT, 'line-color', paint.highlight);
|
||||
iconRepaint.current?.();
|
||||
}, [map, paint]);
|
||||
}
|
||||
|
||||
/** The GL map the layer draws on. MapLibre's map answers the same calls the layer makes. */
|
||||
export type FerrataGlMap = Map;
|
||||
|
||||
/**
|
||||
* Just the two calls a hit test needs, so the Mapbox and the MapLibre map both
|
||||
* fit, each with the point type of its own click events. Optional because a stand-in map (a test double, a map torn down mid
|
||||
* click) may lack them, and a hit test must then answer no rather than throw.
|
||||
*/
|
||||
interface HitTestMap<Point> {
|
||||
getLayer?(id: string): unknown;
|
||||
queryRenderedFeatures?(point: Point, options: { layers: string[] }): unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click at `point` landed on a drawn ferrata. A map's own click
|
||||
* handler asks this first, so picking a ferrata opens its card instead of also
|
||||
* dropping a control point or a place under it.
|
||||
*/
|
||||
export function isFerrataHit<Point>(map: HitTestMap<Point>, point: Point): boolean {
|
||||
if (typeof map.getLayer !== 'function' || typeof map.queryRenderedFeatures !== 'function') return false;
|
||||
return !!map.getLayer(HIT) && map.queryRenderedFeatures(point, { layers: [HIT] }).length > 0;
|
||||
}
|
||||
|
||||
const NO_GROUPS: readonly FerrataGroup[] = [];
|
||||
const IGNORE_SELECT: FerrataSelectHandler = () => {};
|
||||
|
||||
/**
|
||||
* The layer as a map view mounts it from a `FerrataMapLayer`: drawn only while
|
||||
* it is switched on, so a map with the layer off carries no source or layers.
|
||||
*/
|
||||
export function useFerrataMapLayerGL(
|
||||
map: Map | null,
|
||||
layer:
|
||||
| { active: boolean; groups: readonly FerrataGroup[]; onSelect: FerrataSelectHandler; selectedId: string | null }
|
||||
| undefined,
|
||||
surface: FerrataSurface = 'flat'
|
||||
) {
|
||||
useFerrataLayerGL(
|
||||
layer?.active ? map : null,
|
||||
layer?.groups ?? NO_GROUPS,
|
||||
layer?.onSelect ?? IGNORE_SELECT,
|
||||
layer?.selectedId ?? null,
|
||||
surface
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* FE-FERRATA-MAPLAYER-001 to FE-FERRATA-MAPLAYER-008: the via ferrata layer as
|
||||
* one map surface holds it: off by default, the area the map reports, the
|
||||
* trip's area to prefetch, the ferratas grouped from OSM's segments with their
|
||||
* count in view, and the card a click opens.
|
||||
*
|
||||
* useTripFerratas is mocked: what it asks the server is its own suite's
|
||||
* subject. This one checks what the layer hands it (the view, whether it is on
|
||||
* at all, the trip's area) and what it hands the map back.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Ferrata, FerrataBbox } from '@trek/shared';
|
||||
|
||||
import { ferrataAreaOf, useFerrataMapLayer } from './useFerrataMapLayer';
|
||||
import { useTripFerratas } from './useTripFerratas';
|
||||
|
||||
vi.mock('./useTripFerratas', () => ({ useTripFerratas: vi.fn() }));
|
||||
|
||||
const BOX: FerrataBbox = { south: 46.5, west: 11.7, north: 46.6, east: 11.9 };
|
||||
const OTHER: FerrataBbox = { south: 47.4, west: 11.6, north: 47.5, east: 11.8 };
|
||||
|
||||
const segment = (id: number, lat: number): Ferrata => ({
|
||||
id: `way/${id}`,
|
||||
name: 'Via delle Trincee',
|
||||
grade: { raw: String(id), level: id, plus: null },
|
||||
routeName: null,
|
||||
geometry: [
|
||||
[lat, 11.8],
|
||||
[lat + 0.001, 11.8],
|
||||
],
|
||||
osmUrl: `https://www.openstreetmap.org/way/${id}`,
|
||||
});
|
||||
// Two touching segments of one ferrata.
|
||||
const LOWER = segment(2, 46.52);
|
||||
const UPPER = segment(4, 46.521);
|
||||
|
||||
const lastCall = () => vi.mocked(useTripFerratas).mock.lastCall;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER], status: 'ready', truncated: false });
|
||||
});
|
||||
|
||||
describe('useFerrataMapLayer', () => {
|
||||
it('FE-FERRATA-MAPLAYER-001: starts off, so nothing is asked for', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
expect(result.current.layer.active).toBe(false);
|
||||
expect(result.current.layer.count).toBe(0);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(lastCall()).toEqual([7, null, false, null, []]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-002: an area reported while off is remembered without a re-render, and used once it is on', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
const renders = vi.mocked(useTripFerratas).mock.calls.length;
|
||||
|
||||
act(() => result.current.onViewportChange(BOX));
|
||||
expect(vi.mocked(useTripFerratas).mock.calls.length).toBe(renders);
|
||||
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.layer.active).toBe(true);
|
||||
expect(lastCall()?.slice(0, 3)).toEqual([7, BOX, true]);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-003: while on, every new area reaches the request', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
act(() => result.current.onViewportChange(OTHER));
|
||||
expect(lastCall()?.slice(0, 3)).toEqual([7, OTHER, true]);
|
||||
});
|
||||
|
||||
it("FE-FERRATA-MAPLAYER-004: hands the trip's area around its lines to the loader", () => {
|
||||
const lines = [
|
||||
[[46.5, 11.7]],
|
||||
[
|
||||
[46.7, 11.6],
|
||||
[46.6, 11.9],
|
||||
],
|
||||
] as Array<Array<[number, number]>>;
|
||||
renderHook(() => useFerrataMapLayer(7, lines));
|
||||
expect(lastCall()?.[3]).toEqual({ south: 46.5, west: 11.6, north: 46.7, east: 11.9 });
|
||||
expect(ferrataAreaOf([])).toBeNull();
|
||||
expect(ferrataAreaOf([[[Number.NaN, 1]]])).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-005: groups the segments into one ferrata and counts the ferratas in view', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.onViewportChange(BOX));
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.layer.groups).toHaveLength(1);
|
||||
expect(result.current.layer.groups[0]?.segments).toEqual([LOWER, UPPER]);
|
||||
expect(result.current.layer.count).toBe(1);
|
||||
|
||||
act(() => result.current.onViewportChange(OTHER));
|
||||
expect(result.current.layer.count).toBe(0);
|
||||
// Out of view, still drawn.
|
||||
expect(result.current.layer.groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-006: a click opens the whole ferrata in the card, highlighted, and close shuts it', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
const group = result.current.layer.groups[0];
|
||||
if (!group) throw new Error('no group');
|
||||
|
||||
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
|
||||
expect(result.current.selected?.group.id).toBe(group.id);
|
||||
expect(result.current.selected?.segment).toBe(UPPER);
|
||||
expect(result.current.layer.selectedId).toBe(group.id);
|
||||
|
||||
act(() => result.current.close());
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(result.current.layer.selectedId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-007: the card follows its ferrata when more of its segments load', () => {
|
||||
const { result, rerender } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
const group = result.current.layer.groups[0];
|
||||
if (!group) throw new Error('no group');
|
||||
act(() => result.current.layer.onSelect(group, UPPER, { lat: 46.521, lng: 11.8 }));
|
||||
|
||||
// A lower way loads and joins the ferrata; its id is now the group's.
|
||||
const lowest = segment(1, 46.519);
|
||||
vi.mocked(useTripFerratas).mockReturnValue({ ferratas: [LOWER, UPPER, lowest], status: 'ready', truncated: false });
|
||||
rerender();
|
||||
expect(result.current.selected?.group.segments).toHaveLength(3);
|
||||
expect(result.current.selected?.segment).toBe(UPPER);
|
||||
expect(result.current.layer.selectedId).toBe('group/way/1');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-MAPLAYER-008: switching the layer off closes the card and stops asking', () => {
|
||||
const { result } = renderHook(() => useFerrataMapLayer(7));
|
||||
act(() => result.current.layer.onToggle());
|
||||
const group = result.current.layer.groups[0];
|
||||
if (!group) throw new Error('no group');
|
||||
act(() => result.current.layer.onSelect(group, LOWER, { lat: 46.52, lng: 11.8 }));
|
||||
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.layer.active).toBe(false);
|
||||
expect(result.current.selected).toBeNull();
|
||||
expect(lastCall()?.[2]).toBe(false);
|
||||
|
||||
// On again, the card stays closed until the next click.
|
||||
act(() => result.current.layer.onToggle());
|
||||
expect(result.current.selected).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Ferrata, FerrataBbox } from '@trek/shared';
|
||||
|
||||
import { type FerrataCrossing, ferrataTilesAlong, routeFerrataCrossings } from './ferrataCrossings';
|
||||
import {
|
||||
type FerrataGroup,
|
||||
ferrataGroupOf,
|
||||
ferrataGroupsInView,
|
||||
ferrataPointsBbox,
|
||||
groupFerratas,
|
||||
} from './ferrataGroups';
|
||||
import type { FerrataSelectHandler } from './useFerrataLayerGL';
|
||||
import { type FerrataStatus, useTripFerratas } from './useTripFerratas';
|
||||
|
||||
/**
|
||||
* The via ferrata layer as a map hands it around: whether it is on, the switch
|
||||
* the layer pill shows with its count badge, the ferratas to draw (grouped
|
||||
* from OSM's segments), the one open in the card, and what a click on a line
|
||||
* does. Both flat renderers and the 3D terrain map take this one object, so
|
||||
* the three cannot drift apart on what the layer is.
|
||||
*/
|
||||
export interface FerrataMapLayer {
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
status: FerrataStatus;
|
||||
/** How many ferratas reach into the view on screen. */
|
||||
count: number;
|
||||
groups: readonly FerrataGroup[];
|
||||
/** The ferrata open in the card, drawn highlighted. */
|
||||
selectedId: string | null;
|
||||
onSelect: FerrataSelectHandler;
|
||||
}
|
||||
|
||||
/** The ferrata open in the card, and the OSM way that was picked on it. */
|
||||
export interface FerrataSelection {
|
||||
group: FerrataGroup;
|
||||
segment: Ferrata;
|
||||
}
|
||||
|
||||
/** The box around every point of every line the stage hands over, or null for none. */
|
||||
export function ferrataAreaOf(lines: ReadonlyArray<ReadonlyArray<readonly [number, number]>>): FerrataBbox | null {
|
||||
return ferrataPointsBbox(lines.flat());
|
||||
}
|
||||
|
||||
/**
|
||||
* The via ferrata layer of one map surface: off by default, and nothing is
|
||||
* read or asked for while it is off.
|
||||
*
|
||||
* `onViewportChange` takes the visible area from whichever map is on screen
|
||||
* (the flat planner map or the 3D terrain). While the layer is off the area is
|
||||
* only remembered, not put into state, so panning a map with the layer off
|
||||
* re-renders nothing; switching it on starts from the last area the map
|
||||
* reported instead of waiting for the next pan. `areaLines` are the lines the
|
||||
* trip's area is made of (tour routes, waypoints, places), prefetched once
|
||||
* the layer is on. The selection names the OSM way that was clicked, so it
|
||||
* follows its ferrata when more segments load and join it, and closes when the
|
||||
* layer is switched off.
|
||||
*/
|
||||
export function useFerrataMapLayer(
|
||||
tripId: number | null | undefined,
|
||||
areaLines: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [],
|
||||
/** The tour's line as the elevation profile measures it; the ferratas along it load with the layer off too. */
|
||||
routeLine: readonly (readonly [number, number])[] | null = null
|
||||
) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [bbox, setBbox] = useState<FerrataBbox | null>(null);
|
||||
const [selectedSegment, setSelectedSegment] = useState<string | null>(null);
|
||||
const lastArea = useRef<FerrataBbox | null>(null);
|
||||
const activeRef = useRef(false);
|
||||
const area = ferrataAreaOf(areaLines);
|
||||
const routeTiles = useMemo(() => ferrataTilesAlong(routeLine), [routeLine]);
|
||||
const { ferratas, status } = useTripFerratas(tripId, bbox, active, area, routeTiles);
|
||||
const groups = useMemo(() => groupFerratas(ferratas), [ferratas]);
|
||||
const crossings = useMemo<FerrataCrossing[]>(() => routeFerrataCrossings(routeLine, groups), [routeLine, groups]);
|
||||
const count = active ? ferrataGroupsInView(groups, bbox) : 0;
|
||||
|
||||
const onViewportChange = useCallback((next: FerrataBbox) => {
|
||||
lastArea.current = next;
|
||||
if (activeRef.current) setBbox(next);
|
||||
}, []);
|
||||
|
||||
const onToggle = useCallback(() => {
|
||||
const next = !activeRef.current;
|
||||
activeRef.current = next;
|
||||
setActive(next);
|
||||
setSelectedSegment(null);
|
||||
if (next) setBbox(lastArea.current);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => setSelectedSegment(null), []);
|
||||
const onSelect = useCallback<FerrataSelectHandler>((_group, segment) => setSelectedSegment(segment.id), []);
|
||||
|
||||
const selected = useMemo<FerrataSelection | null>(() => {
|
||||
if (!active) return null;
|
||||
const group = ferrataGroupOf(groups, selectedSegment);
|
||||
const segment = group?.segments.find((candidate) => candidate.id === selectedSegment);
|
||||
return group && segment ? { group, segment } : null;
|
||||
}, [active, groups, selectedSegment]);
|
||||
const selectedId = selected?.group.id ?? null;
|
||||
|
||||
const layer = useMemo<FerrataMapLayer>(
|
||||
() => ({ active, onToggle, status, count, groups, selectedId, onSelect }),
|
||||
[active, onToggle, status, count, groups, selectedId, onSelect]
|
||||
);
|
||||
|
||||
return { layer, onViewportChange, selected, close, crossings };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-FERRATA-USEPAINT-001 to FE-FERRATA-USEPAINT-003: the line colours follow the theme on <html>.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useFerrataPaint } from './useFerrataPaint';
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
afterEach(() => {
|
||||
root.style.removeProperty('--map-ferrata');
|
||||
root.classList.remove('dark', 'unrelated');
|
||||
root.removeAttribute('data-scheme');
|
||||
});
|
||||
|
||||
describe('useFerrataPaint', () => {
|
||||
it('FE-FERRATA-USEPAINT-001: starts with the colours the tokens resolve to', () => {
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const { result } = renderHook(() => useFerrataPaint());
|
||||
expect(result.current.line).toBe('black');
|
||||
});
|
||||
|
||||
it('FE-FERRATA-USEPAINT-002: resolves again when the scheme or a token on the root changes', async () => {
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const { result } = renderHook(() => useFerrataPaint());
|
||||
|
||||
await act(async () => {
|
||||
root.setAttribute('data-scheme', 'forest');
|
||||
root.style.setProperty('--map-ferrata', 'ivory');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.line).toBe('ivory'));
|
||||
});
|
||||
|
||||
it('FE-FERRATA-USEPAINT-003: keeps the same reference when a change leaves every colour as it was', async () => {
|
||||
root.style.setProperty('--map-ferrata', 'black');
|
||||
const { result } = renderHook(() => useFerrataPaint());
|
||||
const first = result.current;
|
||||
|
||||
await act(async () => {
|
||||
root.classList.add('unrelated');
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMapPaint } from '../Map/mapPaint';
|
||||
import { FERRATA_TOKENS, type FerrataPaint } from './ferrataPaint';
|
||||
|
||||
/** The line colours, resolved again whenever the theme on `<html>` changes (see `useMapPaint`). */
|
||||
export function useFerrataPaint(): FerrataPaint {
|
||||
return useMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-FERRATA-GUARD-001: the press that closed a ferrata's card is kept from also acting on the map.
|
||||
import { act, fireEvent, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useOutsidePressGuard } from './useOutsidePressGuard';
|
||||
|
||||
describe('useOutsidePressGuard', () => {
|
||||
it('FE-FERRATA-GUARD-001: the guard drops the one map click after the closing press and is off once that click went by', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => useOutsidePressGuard());
|
||||
const guarded = result.current.guard(handler);
|
||||
expect(result.current.guard(undefined)).toBeUndefined();
|
||||
|
||||
act(() => result.current.arm());
|
||||
guarded?.('first');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
guarded?.('second');
|
||||
expect(handler).toHaveBeenCalledWith('second');
|
||||
|
||||
// Armed, but the click lands somewhere that is not the map: the guard comes off right after it.
|
||||
act(() => result.current.arm());
|
||||
fireEvent.click(document.body);
|
||||
act(() => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
guarded?.('third');
|
||||
expect(handler).toHaveBeenCalledWith('third');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user