mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+335
-143
@@ -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
|
||||
@@ -257,6 +279,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 +316,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 +422,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 +443,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 +458,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 +521,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 +538,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 +594,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 +652,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 +678,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 +743,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 +799,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 +825,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 +890,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 +916,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 +975,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 +1002,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 +1028,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 +1042,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 +1052,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 +1124,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 +1201,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 +1272,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.
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 875,
|
||||
"@typescript-eslint/no-misused-promises": 874,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 560,
|
||||
"react-hooks/refs": 559,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
"@typescript-eslint/no-unused-vars": 130,
|
||||
"@typescript-eslint/no-unused-vars": 129,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"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": 1409,
|
||||
"src/components/Map/MapView.tsx": 1401,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2371,
|
||||
"src/components/Map/MapViewGL.tsx": 2296,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1147,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 22,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
@@ -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,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,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,19 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX 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 GPX import: a file 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,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,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,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,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)),
|
||||
};
|
||||
@@ -93,6 +93,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',
|
||||
|
||||
@@ -77,6 +77,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
|
||||
if (loading) {
|
||||
@@ -155,13 +156,25 @@ 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()}
|
||||
/>
|
||||
<>
|
||||
<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,
|
||||
|
||||
@@ -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,8 @@
|
||||
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';
|
||||
@@ -20,6 +22,8 @@ export function useAddonManager() {
|
||||
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 {
|
||||
@@ -36,6 +40,14 @@ export function useAddonManager() {
|
||||
.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();
|
||||
@@ -63,20 +75,44 @@ export function useAddonManager() {
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
/** wanderer under Tours: flipped at once, put back when the save fails. */
|
||||
const toggleToursWanderer = async () => {
|
||||
const next = !toursWanderer;
|
||||
setToursWanderer(next);
|
||||
/** 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 wandererAdminApi.update(next);
|
||||
await save(next);
|
||||
} catch {
|
||||
setToursWanderer(!next);
|
||||
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,
|
||||
@@ -106,5 +142,6 @@ export function useAddonManager() {
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,20 +5,12 @@ import type { Ferrata } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
|
||||
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';
|
||||
|
||||
/** The map controls' frosted glass, so the card reads as one of them. */
|
||||
const MAP_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -57,7 +49,7 @@ export default function FerrataDetailCard({
|
||||
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_GLASS, width }}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="ferrata-detail-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
@@ -39,26 +40,7 @@ export default function FerrataDetailOverlay({
|
||||
onTourIds?: ReadonlySet<string>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const open = !!selection;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: PointerEvent) => {
|
||||
const card = ref.current;
|
||||
if (card && event.target instanceof Node && card.contains(event.target)) return;
|
||||
onOutsidePress?.();
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, onOutsidePress]);
|
||||
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
|
||||
|
||||
if (!selection) return null;
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMapPaint, sameMapPaint } from '../Map/mapPaint';
|
||||
|
||||
/**
|
||||
* The colours the via ferrata lines are drawn in.
|
||||
*
|
||||
@@ -10,9 +12,9 @@
|
||||
*
|
||||
* 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 (the
|
||||
* precedent is the hazard overlay in useHazardLayerGL), which keeps light and
|
||||
* dark mode apart without a colour literal in the layer.
|
||||
* `--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. */
|
||||
@@ -23,27 +25,16 @@ export interface FerrataPaint {
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
const TOKENS = {
|
||||
export const FERRATA_TOKENS = {
|
||||
line: '--map-ferrata',
|
||||
casing: '--map-ferrata-casing',
|
||||
highlight: '--map-ferrata-lit',
|
||||
} as const;
|
||||
|
||||
/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */
|
||||
function token(name: string, style: CSSStyleDeclaration | null): string {
|
||||
const value = style?.getPropertyValue(name).trim();
|
||||
return value || 'currentColor';
|
||||
}
|
||||
|
||||
export function ferrataPaint(): FerrataPaint {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
|
||||
return {
|
||||
line: token(TOKENS.line, style),
|
||||
casing: token(TOKENS.casing, style),
|
||||
highlight: token(TOKENS.highlight, style),
|
||||
};
|
||||
return readMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
|
||||
return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight;
|
||||
return sameMapPaint(a, b);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMapPaint } from '../Map/mapPaint';
|
||||
import { FERRATA_TOKENS, type FerrataPaint } from './ferrataPaint';
|
||||
|
||||
import { type FerrataPaint, ferrataPaint, sameFerrataPaint } from './ferrataPaint';
|
||||
|
||||
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
|
||||
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
|
||||
|
||||
/**
|
||||
* The line colours, resolved again whenever the light/dark class, the colour
|
||||
* scheme or a custom accent on `<html>` changes. Map paint holds resolved
|
||||
* strings rather than `var(--token)`, so without this a theme toggle would leave
|
||||
* the lines in the old scheme's colours. Same observer approach as `useIsDark`,
|
||||
* which only watches the class. The reference only changes when a colour does,
|
||||
* so a memo keyed on it does not churn.
|
||||
*/
|
||||
/** The line colours, resolved again whenever the theme on `<html>` changes (see `useMapPaint`). */
|
||||
export function useFerrataPaint(): FerrataPaint {
|
||||
const [paint, setPaint] = useState(ferrataPaint);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = ferrataPaint();
|
||||
setPaint((current) => (sameFerrataPaint(current, next) ? current : next));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return paint;
|
||||
return useMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import type { DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
||||
@@ -32,7 +33,7 @@ export default function DocSyncConnectModal({
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
||||
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
@@ -134,7 +135,7 @@ function Verdict({
|
||||
busy,
|
||||
failure,
|
||||
}: {
|
||||
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||
verdict: DocSyncVerdict | null
|
||||
busy: boolean
|
||||
/** A call that failed outside the connection test (saving, most of all). */
|
||||
failure: string | null
|
||||
@@ -172,10 +173,16 @@ function Verdict({
|
||||
)
|
||||
}
|
||||
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
||||
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
||||
// The footer shares its row with the buttons, and a refusal cut to "The
|
||||
// credentials were re..." with no way to read the rest left nothing to go on
|
||||
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
|
||||
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
|
||||
</span>
|
||||
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
||||
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* The credential form's rules, for both shells.
|
||||
@@ -39,7 +39,7 @@ export interface ConnectForm {
|
||||
setInsecureTls: (on: boolean) => void
|
||||
/** Every required field is either filled in or already stored. */
|
||||
complete: boolean
|
||||
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
|
||||
probe: () => Promise<DocSyncVerdict>
|
||||
save: () => Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ import { useServerPing } from '../../../sync/useServerPing'
|
||||
* reason for the same problem.
|
||||
*/
|
||||
|
||||
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
|
||||
export interface DocSyncVerdict {
|
||||
connected: boolean
|
||||
account?: string
|
||||
error?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface DocSyncField {
|
||||
field_key: string
|
||||
label: string
|
||||
@@ -264,11 +272,9 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
||||
setBusy('test')
|
||||
try {
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
|
||||
connected: boolean; account?: string; error?: string; detail?: string
|
||||
}
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
|
||||
} catch (e: unknown) {
|
||||
return { connected: false, error: readError(e) }
|
||||
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
|
||||
await screen.findByRole('button', { name: /create share link/i })
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
|
||||
const { unmount } = mountDialog()
|
||||
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mountDialog(buildJourney({
|
||||
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
|
||||
}))
|
||||
expect(screen.getByText('Through trips')).toBeInTheDocument()
|
||||
expect(screen.getByText('lena')).toBeInTheDocument()
|
||||
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSaved } = mountDialog()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import JourneyShareSection from './JourneyShareSection'
|
||||
import { useJourneySettings } from './useJourneySettings'
|
||||
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
|
||||
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</div>
|
||||
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
||||
</DialogSection>
|
||||
|
||||
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
||||
* contributors: shown here, but managed in that trip's settings, so read only.
|
||||
*/
|
||||
function TripAccessSection({ access, roleLabel }: {
|
||||
access: JourneyTripAccess[] | undefined
|
||||
roleLabel: (role: JourneyContributor['role']) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<DialogSection label={t('journey.contributors.viaTrips')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
||||
<div className={ROWS}>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
||||
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
||||
</div>
|
||||
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,23 +169,24 @@ describe('Navbar', () => {
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||
render(<Navbar onShare={vi.fn()} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
expect(shareBtn).toBeInTheDocument();
|
||||
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
||||
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
||||
expect(back.parentElement).toBe(settings.closest('div'));
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onShare = vi.fn();
|
||||
render(<Navbar onShare={onShare} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareBtn);
|
||||
expect(onShare).toHaveBeenCalled();
|
||||
const onSettings = vi.fn();
|
||||
render(<Navbar onSettings={onSettings} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
||||
expect(onSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
|
||||
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
||||
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.mouseEnter(back);
|
||||
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
|
||||
fireEvent.mouseLeave(back);
|
||||
expect(back.style.background).toBe('transparent');
|
||||
|
||||
const share = screen.getByRole('button', { name: /share/i });
|
||||
fireEvent.mouseEnter(share);
|
||||
expect(share.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(share);
|
||||
expect(share.style.background).toBe('var(--bg-card)');
|
||||
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
fireEvent.mouseEnter(theme);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
@@ -21,7 +21,8 @@ interface NavbarProps {
|
||||
tripId?: number | string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
/** The trip's Settings: who is on it, its links and its journal. */
|
||||
onSettings?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -32,7 +33,7 @@ interface Addon {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* The trip's Settings sit with the trip, beside its pill: they are about this
|
||||
trip, not about the app the right side of the bar belongs to. */}
|
||||
{onSettings && (
|
||||
// The same pill as the trip's own beside it, so the two read as one pair.
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
aria-label={t('trip.settings.title')}
|
||||
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="text-caption font-semibold">{t('nav.settings')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
itself on top of them. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
<button type="button" onClick={onShare}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('nav.share')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -59,6 +59,38 @@ describe('MapLayerSwitcher', () => {
|
||||
expect(onChange).toHaveBeenCalledWith('topo')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-018: offers the hiking map only where the tour type and the admin offer it (MAP-01)', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'satellite'] }} />)
|
||||
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'hiking', 'satellite'] }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hiking' }))
|
||||
expect(onChange).toHaveBeenCalledWith('hiking')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-019: a hiking map that stopped answering says so on its segment, and only while it is shown', () => {
|
||||
const baseHealth = { base: 'hiking' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
|
||||
const layers = { bases: ['default', 'hiking'] as const, baseHealth }
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="hiking" onChange={() => {}} layers={layers} />)
|
||||
const segment = screen.getByRole('button', { name: 'Hiking' })
|
||||
expect(segment).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(segment).toHaveTextContent('!')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('OpenHikingMap is not answering right now')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={layers} />)
|
||||
expect(screen.getByRole('button', { name: 'Hiking' })).not.toHaveTextContent('!')
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-020: CyclOSM sits in the switch as Cycling and says so when it stops answering (BIK-06)', () => {
|
||||
const baseHealth = { base: 'cycle' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
|
||||
render(<TourMapLayerSwitcher active="cycle" onChange={() => {}} layers={{ bases: ['default', 'cycle'], baseHealth }} />)
|
||||
expect(screen.getByRole('button', { name: 'Cycling' })).toHaveTextContent('!')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('CyclOSM is not answering right now')
|
||||
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
|
||||
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
@@ -7,9 +8,21 @@ import type { FerrataStatus } from '../Ferratas/useTripFerratas'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | 'topo'
|
||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||
|
||||
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
|
||||
export interface TourLayerOffer {
|
||||
bases?: readonly TourBaseLayer[]
|
||||
overlays?: readonly ToggleableRasterOverlay[]
|
||||
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
|
||||
paddle?: PaddleMapLayer
|
||||
/** MAP-01, BIK-06: whether the map on terms shown (OpenHikingMap, CyclOSM) answers; the maps report into it. */
|
||||
baseHealth?: BaseMapHealth
|
||||
}
|
||||
|
||||
const FROSTED_SHELL = {
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
|
||||
@@ -73,6 +86,8 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
const TOUR_LAYERS = [
|
||||
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
|
||||
{ id: 'topo', labelKey: null, Icon: Mountain },
|
||||
{ id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints },
|
||||
{ id: 'cycle', labelKey: 'map.baseLayer.cycle', Icon: Bike },
|
||||
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
|
||||
] as const
|
||||
|
||||
@@ -178,6 +193,56 @@ function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How each overlay the planner can offer is named and drawn on its switch. */
|
||||
const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; downKey: string }> = {
|
||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for a transparent overlay (the seamarks), the way the via
|
||||
* ferrata switch looks: on in the accent, and in the warning tone with a badge
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it.
|
||||
*/
|
||||
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
|
||||
function DownBadge() {
|
||||
return (
|
||||
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
|
||||
!
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const look = OVERLAY_LOOK[overlay.id]
|
||||
if (!look) return null
|
||||
const label = t(look.labelKey)
|
||||
const down = overlay.status === 'down'
|
||||
const on = overlay.status !== 'off'
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={down ? t(look.downKey) : label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={overlay.onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
data-testid={`map-overlay-${overlay.id}`}
|
||||
data-status={overlay.status}
|
||||
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(on)}
|
||||
>
|
||||
<look.Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
@@ -191,10 +256,13 @@ export function TourMapLayerSwitcher({
|
||||
terrainActive = false,
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
/** The base layers and overlays this tour's type offers; all three bases and no overlay when left out. */
|
||||
layers?: TourLayerOffer
|
||||
/** Offers the 3D terrain switch beside the base layers. */
|
||||
onToggleTerrain?: () => void
|
||||
terrainActive?: boolean
|
||||
@@ -205,14 +273,20 @@ export function TourMapLayerSwitcher({
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const health = layers?.baseHealth
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
const down = !!health?.down && health.base === id && active === id
|
||||
const downLabel = id === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')
|
||||
return (
|
||||
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
|
||||
<Tooltip key={id} label={down ? downLabel : label} placement="top" disabled={!compact && !down}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
@@ -223,13 +297,20 @@ export function TourMapLayerSwitcher({
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{health?.down && active === health.base && (
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||
</span>
|
||||
)}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||
{onToggleTerrain && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('MapView', () => {
|
||||
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
|
||||
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
|
||||
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 }
|
||||
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
|
||||
const props = (viewBaseLayer: 'default' | 'topo' | 'hiking' | 'satellite') => ({
|
||||
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
|
||||
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
|
||||
})
|
||||
@@ -279,6 +279,11 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true)
|
||||
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng]))
|
||||
|
||||
// MAP-01: OpenHikingMap is drawn in WGS-84 like topo, from its own template.
|
||||
rerender(<MapView {...props('hiking')} />)
|
||||
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'EPSG:3857')
|
||||
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', 'https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png')
|
||||
|
||||
rerender(<MapView {...props('satellite')} />)
|
||||
const satelliteMap = screen.getByTestId('map-container')
|
||||
expect(satelliteMap).not.toBe(topoMap)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'
|
||||
import { RouteHitBand } from './RouteHitBand'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -63,11 +63,13 @@ import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './p
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { SelectedTrackLine, TrackLines } from './TrackLines'
|
||||
import DawarichTrailLayer from './DawarichTrailLayer'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
|
||||
import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -831,14 +833,14 @@ export const MapView = memo(function MapView({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
}: MapViewProps) {
|
||||
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
|
||||
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
|
||||
const isSatellite = baseLayer === 'satellite'
|
||||
const isTopo = baseLayer === 'topo'
|
||||
const tourRaster = tourRasterBaseOf(baseLayer) // topo, OpenHikingMap or CyclOSM, the Tours planner's own
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
// the default is a vector style.
|
||||
@@ -850,7 +852,7 @@ export const MapView = memo(function MapView({
|
||||
* decided before the map is constructed, because Leaflet cannot change a map's
|
||||
* CRS afterwards — hence the whole map, not just the tile layer.
|
||||
*/
|
||||
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
|
||||
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
|
||||
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
@@ -1115,17 +1117,16 @@ export const MapView = memo(function MapView({
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : isTopo ? (
|
||||
) : tourRaster ? (
|
||||
<TileLayer
|
||||
key="topo"
|
||||
url={OPENTOPOMAP_TILE_URL}
|
||||
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
|
||||
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
|
||||
key={baseLayer}
|
||||
url={tourRaster.url}
|
||||
attribution={tourRaster.attribution}
|
||||
maxNativeZoom={tourRaster.maxZoom}
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
eventHandlers={tileHealthHandlers(tourLayers?.baseHealth, baseLayer)}
|
||||
/>
|
||||
) : basemap.kind === 'vector' ? (
|
||||
<VectorBasemap style={basemap.style} />
|
||||
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||
<Polyline
|
||||
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
|
||||
])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
@@ -1291,7 +1284,7 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} paddle={tourLayers?.paddle} />
|
||||
|
||||
{/* The route as it was actually recorded (#2279). Drawn in the casing
|
||||
pane's sibling order so it sits under the planned route rather than
|
||||
@@ -1339,7 +1332,7 @@ export const MapView = memo(function MapView({
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,9 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { applyBaseLayer } from './glRasterLayers'
|
||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
@@ -32,7 +33,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import { groupCoincidentPlaces } from './coincidentPlaces'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { type FerrataGlMap, isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'
|
||||
import type { FerrataGlMap } from '../Ferratas/useFerrataLayerGL'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { useDawarichTrailGL } from './useDawarichTrailGL'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
@@ -44,7 +45,7 @@ import { poiPinParts } from './poiMarker'
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
|
||||
@@ -65,25 +66,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite'
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
|
||||
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
|
||||
// route source so they sit under the current route, which also puts them before the first
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -201,59 +183,6 @@ function addPlaceClusterLayers(map: any): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Visibility comes first: a layer that is already
|
||||
* there only needs flipping, so the usual pass is one call and a tap answers at once.
|
||||
*
|
||||
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
|
||||
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
|
||||
* the usual state, and the only retry here is `styledata`, which fires on style edits
|
||||
* and not when those finish. Behind that gate a stored choice met a busy map on load,
|
||||
* and a tap on the road trip stage met the sources the same render had just set again,
|
||||
* so the imagery was never drawn. The gate was also the wrong question: this only runs
|
||||
* after `load`, and addSource and addLayer refuse only a style document that is not in
|
||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID
|
||||
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
|
||||
return
|
||||
}
|
||||
if (!on) return // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'raster',
|
||||
tiles: topo
|
||||
? ['a', 'b', 'c'].map(subdomain => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
|
||||
: [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
})
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? []
|
||||
const firstOwn = layers.find((l: { id: string }) =>
|
||||
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
}, firstOwn?.id)
|
||||
} catch { /* a style that refuses the layer keeps the plain basemap */ }
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
@@ -624,7 +553,7 @@ export function MapViewGL({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
@@ -683,7 +612,7 @@ export function MapViewGL({
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
|
||||
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers)
|
||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -1500,7 +1429,7 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead
|
||||
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -2256,14 +2185,11 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const apply = () => {
|
||||
applySatellite(map, isSatellite)
|
||||
applySatellite(map, baseLayer === 'topo', true)
|
||||
}
|
||||
const apply = () => applyBaseLayer(map, baseLayer)
|
||||
apply()
|
||||
map.on('styledata', apply)
|
||||
return () => { map.off('styledata', apply) }
|
||||
}, [isSatellite, baseLayer, mapReady, glProvider])
|
||||
}, [baseLayer, mapReady, glProvider])
|
||||
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
@@ -2311,7 +2237,7 @@ export function MapViewGL({
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
|
||||
@@ -2,18 +2,25 @@ import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
export type PinCss = (size: number, selected: boolean) => string;
|
||||
|
||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26;
|
||||
function plannerWaypointIcon(
|
||||
pinCss: PinCss,
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean,
|
||||
draggable: boolean
|
||||
): L.DivIcon {
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
|
||||
onMove,
|
||||
pinCss,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||
const { label, shaping } = point;
|
||||
const icon = useMemo(
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
|
||||
[index, label, onMove, pinCss, selected, shaping]
|
||||
);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { TileLayer } from 'react-leaflet';
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import { PaddleLayerLeaflet } from '../Tours/paddle/PaddleLayerLeaflet';
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||
import type { MapRasterOverlay } from './mapRasterOverlay';
|
||||
|
||||
/** Over the basemap's tiles (z-index 1) and every basemap raster, under the route's own panes. */
|
||||
const OVERLAY_Z_INDEX = 300;
|
||||
|
||||
/** The overlays on the Leaflet map: a tile layer each while it is on, nothing while it is off. */
|
||||
function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOverlay[] }) {
|
||||
return (
|
||||
<>
|
||||
{overlays
|
||||
?.filter((overlay) => overlay.active)
|
||||
.map((overlay) => (
|
||||
<TileLayer
|
||||
key={overlay.id}
|
||||
url={overlay.url}
|
||||
attribution={overlay.attribution}
|
||||
minZoom={overlay.minZoom}
|
||||
maxNativeZoom={overlay.maxZoom}
|
||||
maxZoom={22}
|
||||
zIndex={OVERLAY_Z_INDEX}
|
||||
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tour planner's layers on the Leaflet map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
|
||||
export function TourMapLayersLeaflet({
|
||||
ferrataLayer,
|
||||
overlays,
|
||||
paddle,
|
||||
}: {
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
overlays?: readonly MapRasterOverlay[];
|
||||
paddle?: PaddleMapLayer;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
<RasterOverlaysLeaflet overlays={overlays} />
|
||||
<PaddleLayerLeaflet layer={paddle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Polyline } from 'react-leaflet';
|
||||
|
||||
/**
|
||||
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
||||
* than the drawn line, that takes the click which reshapes the route, as the
|
||||
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
|
||||
*/
|
||||
export function RouteHitBand({
|
||||
positions,
|
||||
onRouteClick,
|
||||
}: {
|
||||
positions: [number, number][];
|
||||
onRouteClick: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: 'transparent',
|
||||
weight: 20,
|
||||
opacity: 0,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
className: 'cursor-copy',
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
||||
e.originalEvent.stopPropagation();
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers';
|
||||
|
||||
/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */
|
||||
function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) {
|
||||
const sources = new Map<string, Record<string, unknown>>();
|
||||
const layers = new Map<string, string>();
|
||||
const map = {
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string, source: Record<string, unknown>) => sources.set(id, source)),
|
||||
getLayer: vi.fn((id: string) => (layers.has(id) ? { id } : undefined)),
|
||||
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, 'visible')),
|
||||
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, value)),
|
||||
getStyle: vi.fn(() => ({ layers: styleLayers })),
|
||||
};
|
||||
return { map, sources, layers };
|
||||
}
|
||||
|
||||
describe('applyBaseLayer (MAP-01)', () => {
|
||||
it('lays OpenHikingMap with its credit under the first thing TREK draws', () => {
|
||||
const { map, sources } = fakeMap();
|
||||
applyBaseLayer(map, 'hiking');
|
||||
expect(tourRasterSourceId('hiking')).toBe('trip-hiking');
|
||||
expect(sources.get('trip-hiking')).toMatchObject({
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png'],
|
||||
maxzoom: 18,
|
||||
});
|
||||
expect(String(sources.get('trip-hiking')?.attribution)).toContain('openmaps.fr/donate');
|
||||
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-hiking-raster' }), 'trip-route');
|
||||
// Nothing else is built while it is off.
|
||||
expect(sources.has('trip-topo')).toBe(false);
|
||||
expect(sources.has('trip-satellite')).toBe(false);
|
||||
});
|
||||
|
||||
it('switching away hides it, and back shows the same layer again without a second source', () => {
|
||||
const { map, layers } = fakeMap();
|
||||
applyBaseLayer(map, 'hiking');
|
||||
applyBaseLayer(map, 'topo');
|
||||
expect(layers.get('trip-hiking-raster')).toBe('none');
|
||||
expect(layers.get('trip-topo-raster')).toBe('visible');
|
||||
applyBaseLayer(map, 'hiking');
|
||||
expect(layers.get('trip-hiking-raster')).toBe('visible');
|
||||
expect(layers.get('trip-topo-raster')).toBe('none');
|
||||
expect(map.addSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the user's own map when the style refuses the layer", () => {
|
||||
const { map } = fakeMap();
|
||||
map.addLayer.mockImplementation(() => {
|
||||
throw new Error('style not loaded');
|
||||
});
|
||||
expect(() => applyBaseLayer(map, 'hiking')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL } from '../../constants/mapDefaults';
|
||||
import { TOUR_RASTER_BASE_IDS, TOUR_RASTER_BASES, type TourRasterBase } from './tourRasterBases';
|
||||
|
||||
/*
|
||||
* The raster layers a GL map draws between its basemap style and what TREK
|
||||
* adds on top: the satellite, topo and hiking base layers, and (in mapRasterOverlay.ts)
|
||||
* the transparent overlays such as the seamarks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite';
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster';
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
|
||||
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
|
||||
// route source so they sit under the current route, which also puts them before the first
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Visibility comes first: a layer that is already
|
||||
* there only needs flipping, so the usual pass is one call and a tap answers at once.
|
||||
*
|
||||
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
|
||||
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
|
||||
* the usual state, and the only retry here is `styledata`, which fires on style edits
|
||||
* and not when those finish. Behind that gate a stored choice met a busy map on load,
|
||||
* and a tap on the road trip stage met the sources the same render had just set again,
|
||||
* so the imagery was never drawn. The gate was also the wrong question: this only runs
|
||||
* after `load`, and addSource and addLayer refuse only a style document that is not in
|
||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function applySatellite(map: any, on: boolean, raster?: TourRasterBase): void {
|
||||
const layerId = raster ? `trip-${raster}-raster` : SATELLITE_LAYER_ID;
|
||||
const sourceId = raster ? tourRasterSourceId(raster) : SATELLITE_SOURCE_ID;
|
||||
const source = raster ? TOUR_RASTER_BASES[raster] : null;
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!on) return; // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'raster',
|
||||
tiles: source ? source.tiles : [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: source ? source.maxZoom : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: source ? source.attribution : SATELLITE_TILE_ATTRIBUTION,
|
||||
});
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? [];
|
||||
const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some((prefix) => l.id.startsWith(prefix)));
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
},
|
||||
firstOwn?.id
|
||||
);
|
||||
} catch {
|
||||
/* a style that refuses the layer keeps the plain basemap */
|
||||
}
|
||||
}
|
||||
|
||||
/** The GL source a Tours raster base map is drawn from, which its tile events name. */
|
||||
export function tourRasterSourceId(raster: TourRasterBase): string {
|
||||
return `trip-${raster}`;
|
||||
}
|
||||
|
||||
/** The base layer on a GL map: the imagery, a Tours raster map, or neither over the user's own style. */
|
||||
export function applyBaseLayer(map: Parameters<typeof applySatellite>[0], baseLayer: string): void {
|
||||
applySatellite(map, baseLayer === 'satellite');
|
||||
for (const raster of TOUR_RASTER_BASE_IDS) applySatellite(map, baseLayer === raster, raster);
|
||||
}
|
||||
@@ -8,3 +8,11 @@
|
||||
* exactly that reason — the others were the ones that needed to match it.
|
||||
*/
|
||||
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
|
||||
|
||||
/** The frosted glass of the map's floating cards (a ferrata's, a paddle obstacle's), so they read as its controls. */
|
||||
export const MAP_CARD_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Colours for map paint, read off the theme. Map paint cannot take a
|
||||
* `var(--token)`: Leaflet writes the colour into an SVG attribute and GL
|
||||
* paint wants a resolved colour string. So the map layers name the tokens
|
||||
* they draw with, and the colours are read off the document when they draw,
|
||||
* which keeps light and dark mode apart without a colour literal in a layer.
|
||||
*/
|
||||
|
||||
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
|
||||
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
|
||||
|
||||
/** What each token resolves to on the root, `currentColor` where nothing defines it (a test DOM). */
|
||||
export function readMapPaint<K extends string>(tokens: Readonly<Record<K, string>>): Record<K, string> {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
|
||||
const keys = Object.keys(tokens) as K[];
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, style?.getPropertyValue(tokens[key]).trim() || 'currentColor'])
|
||||
) as Record<K, string>;
|
||||
}
|
||||
|
||||
export function sameMapPaint<K extends string>(
|
||||
a: Readonly<Record<K, string>>,
|
||||
b: Readonly<Record<K, string>>
|
||||
): boolean {
|
||||
return (Object.keys(a) as K[]).every((key) => a[key] === b[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours, read again whenever the light or dark class, the colour scheme
|
||||
* or a custom accent on `<html>` changes; without this a theme toggle would
|
||||
* leave a layer in the old scheme's colours. The answer only changes identity
|
||||
* when a colour does, so a memo keyed on it does not churn. `tokens` is a
|
||||
* constant of the caller's module.
|
||||
*/
|
||||
export function useMapPaint<K extends string>(tokens: Readonly<Record<K, string>>): Record<K, string> {
|
||||
const [paint, setPaint] = useState(() => readMapPaint(tokens));
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = readMapPaint(tokens);
|
||||
setPaint((current) => (sameMapPaint(current, next) ? current : next));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
||||
return () => observer.disconnect();
|
||||
}, [tokens]);
|
||||
return paint;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
useRasterOverlaysGL,
|
||||
useSourceTileHealthGL,
|
||||
type MapRasterOverlay,
|
||||
type RasterOverlayGlMap,
|
||||
} from './mapRasterOverlay';
|
||||
|
||||
type Handler = (event: Record<string, unknown>) => void;
|
||||
|
||||
/** Enough of a GL map to watch what the overlay hook does with it. */
|
||||
function fakeMap(styleLayers: Array<{ id: string }> = []) {
|
||||
const layers = new Map<string, { visibility: string }>();
|
||||
const sources = new Set<string>();
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
const map = {
|
||||
getLayer: vi.fn((id: string) => layers.get(id)),
|
||||
getSource: vi.fn((id: string) => (sources.has(id) ? {} : undefined)),
|
||||
addSource: vi.fn((id: string) => sources.add(id)),
|
||||
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, { visibility: 'visible' })),
|
||||
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, { visibility: value })),
|
||||
getStyle: vi.fn(() => ({ layers: styleLayers })),
|
||||
on: vi.fn((type: string, handler: Handler) => {
|
||||
if (!handlers.has(type)) handlers.set(type, new Set());
|
||||
handlers.get(type)?.add(handler);
|
||||
}),
|
||||
off: vi.fn((type: string, handler: Handler) => handlers.get(type)?.delete(handler)),
|
||||
};
|
||||
const fire = (type: string, event: Record<string, unknown>) =>
|
||||
handlers.get(type)?.forEach((handler) => handler(event));
|
||||
return { map, layers, fire, gl: map as unknown as RasterOverlayGlMap };
|
||||
}
|
||||
|
||||
function overlay(active: boolean): MapRasterOverlay {
|
||||
return {
|
||||
id: 'seamarks',
|
||||
url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
attribution: 'OpenSeaMap',
|
||||
minZoom: 6,
|
||||
maxZoom: 18,
|
||||
active,
|
||||
onTileError: vi.fn(),
|
||||
onTileLoad: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRasterOverlaysGL', () => {
|
||||
it('adds a switched-on overlay under the first own layer, so the route stays on top', () => {
|
||||
const { map, gl } = fakeMap([{ id: 'background' }, { id: 'trek-route-line' }]);
|
||||
renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
|
||||
expect(map.addSource).toHaveBeenCalledWith(
|
||||
'trek-overlay-seamarks',
|
||||
expect.objectContaining({ type: 'raster', minzoom: 6 })
|
||||
);
|
||||
expect(map.addLayer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'trek-overlay-seamarks' }),
|
||||
'trek-route-line'
|
||||
);
|
||||
});
|
||||
|
||||
it('adds nothing for an overlay that is off, and hides one already there when it is switched off', () => {
|
||||
const { map, gl, layers } = fakeMap();
|
||||
const { rerender } = renderHook(({ list }) => useRasterOverlaysGL(gl, list), {
|
||||
initialProps: { list: [overlay(false)] },
|
||||
});
|
||||
expect(map.addLayer).not.toHaveBeenCalled();
|
||||
rerender({ list: [overlay(true)] });
|
||||
expect(layers.get('trek-overlay-seamarks')?.visibility).toBe('visible');
|
||||
rerender({ list: [overlay(false)] });
|
||||
expect(layers.get('trek-overlay-seamarks')?.visibility).toBe('none');
|
||||
});
|
||||
|
||||
it('puts the overlay back after a basemap change rebuilt the style', () => {
|
||||
const { map, gl, layers, fire } = fakeMap();
|
||||
renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
|
||||
layers.clear();
|
||||
fire('styledata', {});
|
||||
expect(map.addLayer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports its own tiles' failures and arrivals, and leaves other sources' events alone", () => {
|
||||
const { gl, fire } = fakeMap();
|
||||
const seamarks = overlay(true);
|
||||
renderHook(() => useRasterOverlaysGL(gl, [seamarks]));
|
||||
fire('error', { sourceId: 'trek-overlay-seamarks', error: new Error('503') });
|
||||
fire('error', { sourceId: 'basemap', error: new Error('503') });
|
||||
fire('sourcedata', { sourceId: 'trek-overlay-seamarks', tile: {} });
|
||||
fire('sourcedata', { sourceId: 'trek-overlay-seamarks' });
|
||||
expect(seamarks.onTileError).toHaveBeenCalledTimes(1);
|
||||
expect(seamarks.onTileLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the plain map when the style refuses the layer, and lets go of its listeners on unmount', () => {
|
||||
const { map, gl } = fakeMap();
|
||||
map.addLayer.mockImplementation(() => {
|
||||
throw new Error('style not loaded');
|
||||
});
|
||||
const { unmount } = renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
|
||||
unmount();
|
||||
expect(map.off).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does nothing without a map or without overlays', () => {
|
||||
const { map, gl } = fakeMap();
|
||||
renderHook(() => useRasterOverlaysGL(null, [overlay(true)]));
|
||||
renderHook(() => useRasterOverlaysGL(gl, []));
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSourceTileHealthGL (MAP-01)', () => {
|
||||
it("reports one source's failed and arrived tiles into its health, nothing else's", () => {
|
||||
const { gl, fire } = fakeMap();
|
||||
const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
|
||||
renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health));
|
||||
fire('error', { sourceId: 'trip-hiking', error: new Error('503') });
|
||||
fire('error', { sourceId: 'trip-topo', error: new Error('503') });
|
||||
fire('sourcedata', { sourceId: 'trip-hiking', tile: {} });
|
||||
fire('sourcedata', { sourceId: 'trip-hiking' });
|
||||
expect(health.onTileError).toHaveBeenCalledTimes(1);
|
||||
expect(health.onTileLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('listens to nothing while the map is not shown, and lets go when it goes', () => {
|
||||
const { map, gl } = fakeMap();
|
||||
const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
|
||||
renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', undefined));
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
const { unmount } = renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health));
|
||||
unmount();
|
||||
expect(map.off).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
import { useEffect } from 'react';
|
||||
import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
|
||||
import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers';
|
||||
import type { BaseMapHealth, TileHealth } from './tourRasterBases';
|
||||
|
||||
/**
|
||||
* A transparent raster layer laid over the basemap and under the route, such
|
||||
* as OpenSeaMap's seamarks over a paddle tour. Only the tiles in view are
|
||||
* fetched while it is on: nothing is prefetched or stored for offline use, and
|
||||
* the browser's own cache follows the answer's headers.
|
||||
*/
|
||||
export interface MapRasterOverlay {
|
||||
id: string;
|
||||
url: string;
|
||||
attribution: string;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
active: boolean;
|
||||
/** A tile failed to load; the layer's owner decides when that means the source is down. */
|
||||
onTileError: () => void;
|
||||
/** A tile arrived; the source answers again. */
|
||||
onTileLoad: () => void;
|
||||
}
|
||||
|
||||
type RasterOverlayStatus = 'off' | 'on' | 'down';
|
||||
|
||||
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
|
||||
export interface ToggleableRasterOverlay extends MapRasterOverlay {
|
||||
status: RasterOverlayStatus;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** One map type for both engines, as the ferrata layer does: MapLibre answers every call made here. */
|
||||
export type RasterOverlayGlMap = Map;
|
||||
|
||||
/**
|
||||
* The layer id. It carries TREK's own prefix, so a basemap raster added later
|
||||
* (satellite, topo) is put under it, and it goes in under the first other own
|
||||
* layer, so the route and its markers stay on top.
|
||||
*/
|
||||
function overlayLayerId(id: string): string {
|
||||
return `trek-overlay-${id}`;
|
||||
}
|
||||
|
||||
function applyOverlay(map: RasterOverlayGlMap, overlay: MapRasterOverlay): void {
|
||||
const layerId = overlayLayerId(overlay.id);
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', overlay.active ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!overlay.active) return;
|
||||
if (!map.getSource(layerId)) {
|
||||
map.addSource(layerId, {
|
||||
type: 'raster',
|
||||
tiles: [overlay.url],
|
||||
tileSize: 256,
|
||||
minzoom: overlay.minZoom,
|
||||
maxzoom: overlay.maxZoom,
|
||||
attribution: overlay.attribution,
|
||||
});
|
||||
}
|
||||
const firstOwn = (map.getStyle()?.layers ?? []).find(
|
||||
(layer) =>
|
||||
OWN_LAYER_PREFIXES.some((prefix) => layer.id.startsWith(prefix)) && !layer.id.startsWith('trek-overlay-')
|
||||
);
|
||||
map.addLayer({ id: layerId, type: 'raster', source: layerId, paint: { 'raster-opacity': 1 } }, firstOwn?.id);
|
||||
} catch {
|
||||
// A style that refuses the layer keeps the plain map; the switch reports nothing loaded.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlays on a GL map, kept through a basemap change (which rebuilds the
|
||||
* style and drops them) and reporting their tiles' arrivals and failures.
|
||||
*/
|
||||
export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: readonly MapRasterOverlay[] | undefined) {
|
||||
useEffect(() => {
|
||||
if (!map || !overlays?.length) return;
|
||||
const apply = () => overlays.forEach((overlay) => applyOverlay(map, overlay));
|
||||
const sourceOf = (event: { sourceId?: string }) =>
|
||||
overlays.find((overlay) => overlayLayerId(overlay.id) === event.sourceId);
|
||||
// A tile's failure carries the source it came from; other errors carry none and are not ours.
|
||||
const failed = (event: { error?: Error; sourceId?: string }) => {
|
||||
const overlay = sourceOf(event);
|
||||
if (overlay?.active) overlay.onTileError();
|
||||
};
|
||||
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
|
||||
const overlay = sourceOf(event);
|
||||
if (overlay?.active && event.tile) overlay.onTileLoad();
|
||||
};
|
||||
apply();
|
||||
map.on('styledata', apply);
|
||||
map.on('error', failed);
|
||||
map.on('sourcedata', loaded);
|
||||
return () => {
|
||||
map.off('styledata', apply);
|
||||
map.off('error', failed);
|
||||
map.off('sourcedata', loaded);
|
||||
};
|
||||
}, [map, overlays]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the tiles of one GL source into its health while `health` is given:
|
||||
* a failure carries the source it came from, an arrival a tile. The base layer
|
||||
* itself is drawn by applyBaseLayer.
|
||||
*/
|
||||
export function useSourceTileHealthGL(
|
||||
map: RasterOverlayGlMap | null,
|
||||
sourceId: string,
|
||||
health: TileHealth | undefined
|
||||
) {
|
||||
const { onTileError, onTileLoad } = health ?? {};
|
||||
useEffect(() => {
|
||||
if (!map || !onTileError || !onTileLoad) return;
|
||||
const failed = (event: { error?: Error; sourceId?: string }) => {
|
||||
if (event.sourceId === sourceId) onTileError();
|
||||
};
|
||||
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
|
||||
if (event.sourceId === sourceId && event.tile) onTileLoad();
|
||||
};
|
||||
map.on('error', failed);
|
||||
map.on('sourcedata', loaded);
|
||||
return () => {
|
||||
map.off('error', failed);
|
||||
map.off('sourcedata', loaded);
|
||||
};
|
||||
}, [map, sourceId, onTileError, onTileLoad]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers,
|
||||
* the paddle obstacles, and whether the map on terms (OpenHikingMap, CyclOSM) answers while it is the base map.
|
||||
*/
|
||||
export function useTourMapLayersGL(
|
||||
map: RasterOverlayGlMap | null,
|
||||
ferrataLayer: FerrataMapLayer | undefined,
|
||||
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
|
||||
) {
|
||||
useFerrataMapLayerGL(map, ferrataLayer);
|
||||
useRasterOverlaysGL(map, layers?.overlays);
|
||||
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
|
||||
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click at `point` landed on an object of the tour layers, a via
|
||||
* ferrata or a paddle obstacle. The maps ask this before their own click
|
||||
* handler, so picking one opens its card instead of also setting a waypoint.
|
||||
*/
|
||||
export function isTourLayerHit<Point>(map: Parameters<typeof isFerrataHit<Point>>[0], point: Point): boolean {
|
||||
return isFerrataHit(map, point) || isPaddleHit(map, point);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag';
|
||||
import type { CompassMap } from './MapCompassPill';
|
||||
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
|
||||
import type { MapPlaceTool, TourBaseLayer, TourLayerOffer } from './MapLayerSwitcher';
|
||||
import type { Poi } from './poiCategories';
|
||||
|
||||
/*
|
||||
@@ -110,7 +110,15 @@ export interface MapViewProps {
|
||||
onHighlightAlternative?: (index: number | null) => void;
|
||||
/** Generic numbered control points for list-first route editors. */
|
||||
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
plannerWaypoints?: Array<{
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
/** Drops a dragged control point; returns false when the move was refused. */
|
||||
@@ -129,6 +137,11 @@ export interface MapViewProps {
|
||||
* Ferratas modules.
|
||||
*/
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/**
|
||||
* What the tour's type offers in the tour layer pill (P2B-06): its base layers,
|
||||
* and transparent overlays drawn over the basemap and under the route.
|
||||
*/
|
||||
tourLayers?: TourLayerOffer;
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
|
||||
describe('plannerPinShape', () => {
|
||||
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
||||
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
|
||||
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
|
||||
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
||||
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
||||
export interface PlannerPinPoint {
|
||||
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}
|
||||
|
||||
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
||||
export function plannerPinShape(
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): { size: number; text: string } {
|
||||
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
||||
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
|
||||
import type { TourPoiCategory } from '@trek/shared'
|
||||
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
@@ -40,9 +41,35 @@ export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
||||
)
|
||||
|
||||
const CORE_COLOR = new Map(POI_CATEGORIES.map(c => [c.key, c.color] as const))
|
||||
const coreColor = (key: string) => CORE_COLOR.get(key) ?? POI_DEFAULT_COLOR
|
||||
|
||||
/**
|
||||
* What a hike, a ride or a paddle tour stops for (PLN-02), searched along a tour in the
|
||||
* planner and drawn on its map. Each takes the colour of the explore category
|
||||
* it is closest to, so the map keeps one palette.
|
||||
*/
|
||||
export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
|
||||
viewpoint: { key: 'viewpoint', labelKey: 'tours.poi.cat.viewpoint', Icon: Binoculars, color: coreColor('sights') },
|
||||
peak: { key: 'peak', labelKey: 'tours.poi.cat.peak', Icon: Mountain, color: coreColor('nature') },
|
||||
shelter: { key: 'shelter', labelKey: 'tours.poi.cat.shelter', Icon: Warehouse, color: HOTEL_COLOR },
|
||||
drinking_water: { key: 'drinking_water', labelKey: 'tours.poi.cat.drinking_water', Icon: Droplet, color: coreColor('museum') },
|
||||
food: { key: 'food', labelKey: 'tours.poi.cat.food', Icon: Utensils, color: coreColor('restaurant') },
|
||||
toilets: { key: 'toilets', labelKey: 'tours.poi.cat.toilets', Icon: Toilet, color: coreColor('bar') },
|
||||
picnic: { key: 'picnic', labelKey: 'tours.poi.cat.picnic', Icon: Sandwich, color: coreColor('nature') },
|
||||
campsite: { key: 'campsite', labelKey: 'tours.poi.cat.campsite', Icon: Tent, color: coreColor('activity') },
|
||||
sights: { key: 'sights', labelKey: 'tours.poi.cat.sights', Icon: Camera, color: coreColor('sights') },
|
||||
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
|
||||
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
|
||||
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
|
||||
}
|
||||
|
||||
// A Map rather than an object, so a category named after an Object.prototype member
|
||||
// (`constructor`, `toString`) finds nothing rather than a function.
|
||||
const CATEGORY_BY_KEY = new Map([...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const))
|
||||
// (`constructor`, `toString`) finds nothing rather than a function. A Tours category
|
||||
// that shares a key with an explore one (`sights`, `campsite`) keeps the explore look.
|
||||
const CATEGORY_BY_KEY = new Map(
|
||||
[...Object.values(TOUR_POI_LOOKS), ...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
export interface Poi {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 19.04 },
|
||||
{ lat: 47.49, lng: 19.05 },
|
||||
];
|
||||
const ROUTED = {
|
||||
coordinates: [
|
||||
[47.5, 19.04],
|
||||
[47.49, 19.05],
|
||||
],
|
||||
legs: [],
|
||||
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('calculateRouteWithRetries', () => {
|
||||
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits as long as the host asks when that is longer', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
});
|
||||
|
||||
it('gives up after the last delay and throws the refusal', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
await settled;
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
|
||||
});
|
||||
|
||||
it('throws a refusal the host meant at once', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
|
||||
|
||||
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting when the day changes', async () => {
|
||||
const controller = new AbortController();
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
|
||||
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
controller.abort();
|
||||
|
||||
await settled;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
|
||||
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
|
||||
export type { RouteProfileKey } from './RouteCalculator';
|
||||
|
||||
/** How often a rate-limited route is asked for again, and how long after. */
|
||||
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
|
||||
|
||||
/** A pause that ends early, without throwing, when the signal aborts. */
|
||||
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal?.aborted || ms <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(done, ms);
|
||||
function done(): void {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `calculateRouteWithLegs`, asked again while the host says it is busy.
|
||||
*
|
||||
* The public OSRM hosts allow about one request a second and answer 429 above that. A
|
||||
* day with a change of mode, or a quick switch between days, asks more often than that,
|
||||
* and the day route used to give up on the first refusal and keep its straight lines
|
||||
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
|
||||
* as long as the host asks when it says; a refusal the host meant is thrown at once,
|
||||
* because the same request earns the same answer. An abort ends the wait, and the next
|
||||
* attempt then throws the AbortError the caller already handles.
|
||||
*/
|
||||
export async function calculateRouteWithRetries(
|
||||
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
|
||||
): ReturnType<typeof calculateRouteWithLegs> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await calculateRouteWithLegs(waypoints, opts);
|
||||
} catch (err) {
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
|
||||
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
|
||||
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isOnTermsBase, tileHealthHandlers, TOUR_RASTER_BASES, tourRasterBaseOf } from './tourRasterBases';
|
||||
|
||||
// FE-MAP-TOURBASES-001 to FE-MAP-TOURBASES-003
|
||||
|
||||
describe('tourRasterBases (MAP-01, BIK-06)', () => {
|
||||
it('FE-MAP-TOURBASES-001: knows topo, OpenHikingMap and CyclOSM, CyclOSM on its three shards', () => {
|
||||
expect(tourRasterBaseOf('topo')).toBe(TOUR_RASTER_BASES.topo);
|
||||
expect(tourRasterBaseOf('cycle')?.tiles).toEqual([
|
||||
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||
'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||
'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||
]);
|
||||
expect(tourRasterBaseOf('cycle')?.maxZoom).toBe(20);
|
||||
expect(tourRasterBaseOf('satellite')).toBeNull();
|
||||
// Inherited names are no base map.
|
||||
expect(tourRasterBaseOf('toString')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-TOURBASES-002: the maps on terms carry their credit; topo is no map on terms', () => {
|
||||
expect(isOnTermsBase('hiking')).toBe(true);
|
||||
expect(isOnTermsBase('cycle')).toBe(true);
|
||||
expect(isOnTermsBase('topo')).toBe(false);
|
||||
expect(isOnTermsBase('default')).toBe(false);
|
||||
expect(TOUR_RASTER_BASES.cycle.onTerms?.credit.map((part) => part.href)).toEqual([
|
||||
'https://www.cyclosm.org',
|
||||
'https://www.openstreetmap.org/copyright',
|
||||
]);
|
||||
expect(TOUR_RASTER_BASES.hiking.onTerms?.credit).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-MAP-TOURBASES-003: a Leaflet layer reports into the health of the map watched, and only of that one', () => {
|
||||
const health = { base: 'cycle' as const, down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
|
||||
expect(tileHealthHandlers(health, 'cycle')).toEqual({ tileerror: health.onTileError, tileload: health.onTileLoad });
|
||||
expect(tileHealthHandlers(health, 'hiking')).toBeUndefined();
|
||||
expect(tileHealthHandlers(undefined, 'cycle')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
CYCLOSM_TILE_ATTRIBUTION,
|
||||
CYCLOSM_TILE_MAXZOOM,
|
||||
CYCLOSM_TILE_URL,
|
||||
OPENHIKINGMAP_TILE_ATTRIBUTION,
|
||||
OPENHIKINGMAP_TILE_MAXZOOM,
|
||||
OPENHIKINGMAP_TILE_URL,
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
} from '../../constants/mapDefaults';
|
||||
|
||||
/** The raster base maps only the Tours planner offers, drawn the same way by Leaflet, GL and the 3D map. */
|
||||
export type TourRasterBase = 'topo' | 'hiking' | 'cycle';
|
||||
|
||||
export interface TourRasterBaseSource {
|
||||
/** The Leaflet template; `{s}` is a shard Leaflet fills in. */
|
||||
url: string;
|
||||
/** The same tiles spelled out for a GL source, which knows no shards. */
|
||||
tiles: string[];
|
||||
attribution: string;
|
||||
maxZoom: number;
|
||||
/**
|
||||
* A map its operator offers on terms (MAP-01, BIK-06): loaded only as viewed, never
|
||||
* prefetched or kept offline, its credit readable without a click, and watched for whether it
|
||||
* answers. `credit` is what the 3D map shows above its controls.
|
||||
*/
|
||||
onTerms?: { credit: ReadonlyArray<{ label: string; href: string }> };
|
||||
}
|
||||
|
||||
export const TOUR_RASTER_BASES: Record<TourRasterBase, TourRasterBaseSource> = {
|
||||
topo: {
|
||||
url: OPENTOPOMAP_TILE_URL,
|
||||
tiles: ['a', 'b', 'c'].map((shard) => OPENTOPOMAP_TILE_URL.replace('{s}', shard)),
|
||||
attribution: OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
maxZoom: OPENTOPOMAP_TILE_MAXZOOM,
|
||||
},
|
||||
hiking: {
|
||||
url: OPENHIKINGMAP_TILE_URL,
|
||||
tiles: [OPENHIKINGMAP_TILE_URL],
|
||||
attribution: OPENHIKINGMAP_TILE_ATTRIBUTION,
|
||||
maxZoom: OPENHIKINGMAP_TILE_MAXZOOM,
|
||||
onTerms: {
|
||||
credit: [
|
||||
{ label: '\u00a9 OpenHikingMap', href: 'https://wiki.openstreetmap.org/wiki/OpenHikingMap' },
|
||||
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
|
||||
{ label: '\u2764\ufe0f Donation', href: 'https://openmaps.fr/donate' },
|
||||
],
|
||||
},
|
||||
},
|
||||
cycle: {
|
||||
url: CYCLOSM_TILE_URL,
|
||||
tiles: ['a', 'b', 'c'].map((shard) => CYCLOSM_TILE_URL.replace('{s}', shard)),
|
||||
attribution: CYCLOSM_TILE_ATTRIBUTION,
|
||||
maxZoom: CYCLOSM_TILE_MAXZOOM,
|
||||
onTerms: {
|
||||
credit: [
|
||||
{ label: '\u00a9 CyclOSM', href: 'https://www.cyclosm.org' },
|
||||
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOUR_RASTER_BASE_IDS = Object.keys(TOUR_RASTER_BASES) as TourRasterBase[];
|
||||
|
||||
/** The raster source of a base layer, or null for the user's own map and the imagery. */
|
||||
export function tourRasterBaseOf(layer: string): TourRasterBaseSource | null {
|
||||
return Object.prototype.hasOwnProperty.call(TOUR_RASTER_BASES, layer)
|
||||
? TOUR_RASTER_BASES[layer as TourRasterBase]
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether the layer is a map its operator offers on terms (OpenHikingMap, CyclOSM). */
|
||||
export function isOnTermsBase(layer: string): layer is TourRasterBase {
|
||||
return !!tourRasterBaseOf(layer)?.onTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a best-effort tile source still answers: a run of failed tiles marks
|
||||
* it down, one tile arriving clears that. The maps report into it, the switch
|
||||
* shows it.
|
||||
*/
|
||||
export interface TileHealth {
|
||||
down: boolean;
|
||||
onTileError: () => void;
|
||||
onTileLoad: () => void;
|
||||
}
|
||||
|
||||
/** The health of the map on terms that is the base map now, with which one it is. */
|
||||
export interface BaseMapHealth extends TileHealth {
|
||||
base: TourRasterBase;
|
||||
}
|
||||
|
||||
/** A Leaflet tile layer's events that report into the health of `layer`, while it is the map watched. */
|
||||
export function tileHealthHandlers(health: BaseMapHealth | undefined, layer: string) {
|
||||
return health?.base === layer ? { tileerror: health.onTileError, tileload: health.onTileLoad } : undefined;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import type { RouteProfileKey } from './RouteCalculator'
|
||||
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
@@ -185,20 +186,6 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a rate-limited leg is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
@@ -233,23 +220,16 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||
}
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithRetries(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
} catch {
|
||||
// Refused for good, or aborted: the leg keeps its straight line.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Closes a card floating over the map on a press anywhere outside it and on
|
||||
* Escape, while it is open. The press outside is handed to `onOutsidePress`
|
||||
* as well, so the map under it can leave the click that follows alone (see
|
||||
* `useOutsidePressGuard`). A press on another object closes this card first,
|
||||
* and the click then opens that object's.
|
||||
*/
|
||||
export function useMapCardDismiss(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onOutsidePress?: () => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: PointerEvent) => {
|
||||
const card = ref.current;
|
||||
if (card && event.target instanceof Node && card.contains(event.target)) return;
|
||||
onOutsidePress?.();
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, onOutsidePress, ref]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId;
|
||||
const size = selected ? 30 : 26;
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.textContent = text;
|
||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||
el.setAttribute('aria-label', name);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
|
||||
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
@@ -531,3 +531,55 @@ describe('valhallaAlternates', () => {
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaTraceAttributes (BIK-01)', () => {
|
||||
const TRACE = 'https://valhalla1.openstreetmap.de/trace_attributes'
|
||||
|
||||
it('VALHALLA-TRACE-001: asks for the ways under the line, matched as a mountain bike rides, and hands the answer on', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ shape: SHAPE, edges: [] })
|
||||
}))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toEqual({ shape: SHAPE, edges: [] })
|
||||
expect(bodies[0]).toMatchObject({
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
shape_match: 'map_snap',
|
||||
filters: { action: 'include' },
|
||||
})
|
||||
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
|
||||
let shape: unknown[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
shape = ((await request.json()) as { shape: unknown[] }).shape
|
||||
return HttpResponse.json({})
|
||||
}))
|
||||
const line = Array.from({ length: 9001 }, (_, i): [number, number] => [47 + i / 1e5, 11])
|
||||
await valhallaTraceAttributes(line)
|
||||
expect(shape.length).toBeLessThanOrEqual(4001)
|
||||
expect(shape[0]).toEqual({ lat: 47, lon: 11 })
|
||||
expect(shape[shape.length - 1]).toEqual({ lat: line[9000][0], lon: 11 })
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-003: null for a refusal, a network failure, a line of one point or a router that is not Valhalla', async () => {
|
||||
server.use(http.post(TRACE, () => new HttpResponse(null, { status: 400 })))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
server.use(http.post(TRACE, () => HttpResponse.error()))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS.slice(0, 1))).resolves.toBeNull()
|
||||
setSettings({ routing_base_url: 'https://osrm.example' })
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
|
||||
let asked = false
|
||||
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS, controller.signal)).resolves.toBeNull()
|
||||
expect(asked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,9 @@ const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/** What a caller tunes in the costing model: a weighting, a switch, or a bicycle type by name. */
|
||||
export type CostingOptions = Record<string, number | string | boolean>
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
@@ -191,7 +194,7 @@ export async function valhallaRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
@@ -315,14 +318,14 @@ function routeBody(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Record<string, unknown> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = { ...costingOptions }
|
||||
const options: CostingOptions = { ...costingOptions }
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
return {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
@@ -359,6 +362,51 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
|
||||
}
|
||||
}
|
||||
|
||||
/** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */
|
||||
const TRACE_MAX_POINTS = 4000
|
||||
/** How long a description may take before it counts as failed, so a router that hangs does not leave its card waiting. */
|
||||
const TRACE_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
|
||||
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
|
||||
* of every edge, with the line as matched. The edges are matched as a mountain
|
||||
* bike would ride them, the costing that refuses the fewest ways, so a road
|
||||
* bike's route and a gravel one are described alike. Null for every failure:
|
||||
* the description is an addition beside a route that already exists.
|
||||
*/
|
||||
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
|
||||
const base = valhallaBase()
|
||||
if (!base || line.length < 2) return null
|
||||
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
|
||||
const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon }))
|
||||
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS)
|
||||
try {
|
||||
await valhallaTurn(base, signal)
|
||||
if (signal?.aborted) return null
|
||||
const response = await fetch(`${base}/trace_attributes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify({
|
||||
shape,
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
shape_match: 'map_snap',
|
||||
filters: {
|
||||
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
|
||||
'edge.begin_shape_index', 'edge.end_shape_index'],
|
||||
action: 'include',
|
||||
},
|
||||
}),
|
||||
signal: bounded,
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
@@ -366,7 +414,7 @@ async function requestRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
|
||||
return data === null ? null : runFrom(data)
|
||||
|
||||
@@ -271,7 +271,9 @@ describe('PackingListPanel', () => {
|
||||
];
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(container.querySelector('[draggable="true"]')).toBeNull();
|
||||
// Only the category grip stays: the order of the categories is not the A to Z of their items.
|
||||
const draggable = [...container.querySelectorAll('[draggable="true"]')];
|
||||
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
@@ -38,6 +38,16 @@ interface KategorieGruppeProps {
|
||||
// Off while the list is shown A-Z: a drop there would move an item in an order
|
||||
// the reader cannot see.
|
||||
reorderable?: boolean
|
||||
// Moving the whole category among the others, wired by the list, which sees every
|
||||
// card. `onDrop` gets the category that was dragged onto this one.
|
||||
categoryDrag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: () => void
|
||||
onOver: () => void
|
||||
onEnd: () => void
|
||||
onDrop: (dragged: string) => void
|
||||
}
|
||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
@@ -46,7 +56,7 @@ interface KategorieGruppeProps {
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
@@ -120,8 +130,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
return (
|
||||
// Shaped after the phone's category card: a tinted head band carrying the
|
||||
// name as written, the count, and the fold arrow on the far right.
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<CategoryDropFrame drag={categoryDrag}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName && canEdit ? (
|
||||
@@ -344,6 +355,65 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CategoryDropFrame>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
|
||||
|
||||
/** The data type a dragged category carries, its name as the value. */
|
||||
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
|
||||
|
||||
/**
|
||||
* The card, and while a category is dragged its drop target. Captured, because
|
||||
* the item rows stop their own drops from bubbling.
|
||||
*/
|
||||
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
|
||||
// Told apart by the drag's own data type, which is there from the first event on:
|
||||
// a flag set in state on dragstart can still be unrendered when a quick drag
|
||||
// arrives, and a dragged item must pass through to its rows untouched.
|
||||
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
|
||||
return (
|
||||
<div
|
||||
onDragOverCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
|
||||
}) : undefined}
|
||||
onDropCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
|
||||
}) : undefined}
|
||||
style={{
|
||||
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
|
||||
transition: 'opacity 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The handle a category is dragged by, in the head band before its colour dot. */
|
||||
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
|
||||
if (!drag) return null
|
||||
return (
|
||||
<span
|
||||
draggable
|
||||
role="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
|
||||
e.dataTransfer.setData('text/plain', kategorie)
|
||||
drag.onStart()
|
||||
}}
|
||||
onDragEnd={drag.onEnd}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -7,8 +8,13 @@ export function PackingList(S: PackingState) {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
|
||||
} = S
|
||||
// Which category is being dragged and which card it is over. Held here, above
|
||||
// the cards, because the drag starts in one card and ends in another.
|
||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
||||
const [overCategory, setOverCategory] = useState<string | null>(null)
|
||||
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
@@ -40,6 +46,14 @@ export function PackingList(S: PackingState) {
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
reorderable={sort === 'manual'}
|
||||
categoryDrag={canEdit ? {
|
||||
isDragging: draggedCategory === kat,
|
||||
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
|
||||
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
|
||||
onOver: () => setOverCategory(kat),
|
||||
onEnd: endCategoryDrag,
|
||||
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
|
||||
} : undefined}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={handleSetSharing}
|
||||
onClone={handleCloneItem}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
itemOrderWithCategoryMoved,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
|
||||
|
||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||
@@ -76,4 +77,28 @@ describe('packingListModel', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||
});
|
||||
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
|
||||
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
|
||||
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
|
||||
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
|
||||
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
|
||||
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
|
||||
const items = [day1, day2a, own, day3, day2b];
|
||||
const common = packingViewItems(items, 'common');
|
||||
|
||||
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
|
||||
expect(order).toEqual([14, 11, 12, 15, 13]);
|
||||
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
|
||||
|
||||
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
|
||||
const items = [shared, stove, loose];
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,34 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole list's item order with category `from` moved to where `to` stands.
|
||||
*
|
||||
* A category has no position of its own: it shows where its first item is. So a
|
||||
* category is moved by moving its items, as one block, through the item reorder
|
||||
* the list already has; nothing new is stored, and the order reaches every device
|
||||
* the way a dragged item does. The view's items come first, grouped in the new
|
||||
* order with each group in its own order; the other view's items follow in theirs,
|
||||
* which leaves that view's categories as they were. Null when nothing would move.
|
||||
*/
|
||||
export function itemOrderWithCategoryMoved(
|
||||
items: PackingItem[],
|
||||
viewItems: PackingItem[],
|
||||
from: string,
|
||||
to: string,
|
||||
defaultCategory: string
|
||||
): number[] | null {
|
||||
const order = packingCategoryOrder(viewItems, defaultCategory);
|
||||
const fromIndex = order.indexOf(from);
|
||||
const toIndex = order.indexOf(to);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, from);
|
||||
const inView = new Set(viewItems.map((i) => i.id));
|
||||
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
|
||||
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter((i) => !i.checked);
|
||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { usePackingBags } from './usePackingBags'
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||
import { usePackingImport } from './usePackingImport'
|
||||
@@ -106,6 +106,13 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
// A category dropped onto another takes its place; the slice reorders
|
||||
// optimistically and rolls back with a toast of its own.
|
||||
const moveCategory = (from: string, to: string) => {
|
||||
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
|
||||
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
|
||||
}
|
||||
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||
@@ -213,7 +220,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
@@ -35,9 +36,10 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-walking-time"
|
||||
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
aria-label={`${movingTimeLabel(tourTimes.tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
{movingTimeLabel(tourTimes.tour, t('tours.planner.walkingShort'), t)}:{' '}
|
||||
{formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
|
||||
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
|
||||
<div ref={bodyRef}>
|
||||
{asking ? (
|
||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import type { MouseEvent, ReactNode, Ref } from 'react'
|
||||
import { Clock, MoreHorizontal, type LucideIcon } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
@@ -15,8 +15,11 @@ import { fs } from '../shared/DialogShell'
|
||||
export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2'
|
||||
|
||||
/** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */
|
||||
export function BarButton({ label, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom' }: {
|
||||
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId, ref }: {
|
||||
label: string
|
||||
/** A longer explanation for the tooltip; the label still names the button. */
|
||||
tooltip?: string
|
||||
testId?: string
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
@@ -26,10 +29,12 @@ export function BarButton({ label, onClick, active = false, disabled = false, ch
|
||||
ariaExpanded?: boolean
|
||||
ariaHaspopup?: 'dialog' | 'menu'
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
/** The button itself, for a list or a menu that opens beside it. */
|
||||
ref?: Ref<HTMLButtonElement>
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label} placement={placement}>
|
||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
|
||||
<Tooltip label={tooltip ?? label} placement={placement}>
|
||||
<button ref={ref} type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
|
||||
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted enabled:hover:bg-surface-card enabled:hover:text-content'} ${className}`}>
|
||||
{children}
|
||||
|
||||
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
aria-label={t('settings.mfa.codePlaceholder')}
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts';
|
||||
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
|
||||
const tour: TourListItem = {
|
||||
@@ -74,3 +75,72 @@ describe('RC-01 Tour day-row facts', () => {
|
||||
expect(openRow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAD-09 the check bench on a tour row', () => {
|
||||
const kayak: TourListItem = { ...tour, tour_type: 'kayak', paddle_check: paddleBench() };
|
||||
|
||||
it('PCD-001: a kayak tour with a bench shows one checklist pill, its tooltip saying it is no clearance', () => {
|
||||
render(<TourDayRowPills tour={kayak} />);
|
||||
const pill = screen.getByTestId('plan-tour-paddle-check');
|
||||
expect(pill).toHaveTextContent('1 open');
|
||||
expect(pill.getAttribute('aria-label')).toContain('A checklist is no safety or legal clearance.');
|
||||
});
|
||||
|
||||
it('PCD-002: an area without obstacle data shows no pill at all, never a count', () => {
|
||||
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'none', open: 3 }) }} />);
|
||||
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PCD-002b: data that could not be read still shows, without a count', () => {
|
||||
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'unavailable', open: 3 }) }} />);
|
||||
expect(screen.getByTestId('plan-tour-paddle-check')).not.toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it.each([false, true])('PCD-003: the day plan facts name the bench as one more fact (mobile=%s)', (mobile) => {
|
||||
render(<TourDayRowFacts tour={kayak} mobile={mobile} />);
|
||||
expect(screen.getByTestId('plan-tour-paddle-check')).toHaveAccessibleName('Checklist: 1 open');
|
||||
});
|
||||
|
||||
const without: Array<[string, TourListItem]> = [
|
||||
['a hike with a bench', { ...tour, paddle_check: paddleBench() }],
|
||||
['a kayak tour without a bench', { ...tour, tour_type: 'kayak' }],
|
||||
['a kayak tour whose bench is null', { ...tour, tour_type: 'kayak', paddle_check: null }],
|
||||
];
|
||||
it.each(without)('PCD-004: %s shows no checklist pill and no checklist fact', (_case, item) => {
|
||||
render(
|
||||
<>
|
||||
<TourDayRowPills tour={item} />
|
||||
<TourDayRowFacts tour={item} />
|
||||
</>
|
||||
);
|
||||
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the compact facts of a tour list row', () => {
|
||||
const testIds = () =>
|
||||
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
|
||||
el.getAttribute('data-testid')
|
||||
);
|
||||
const timed = { ...tour, distance: 4.2, elevation_gain: 478, planned_duration_minutes: 50 };
|
||||
|
||||
it('PCD-005: a hike keeps time, distance and climbing, in that order', () => {
|
||||
render(<TourDayRowPills tour={timed} compact />);
|
||||
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-distance', 'plan-tour-ascent']);
|
||||
});
|
||||
|
||||
it('PCD-006: a paddle tour puts its check bench second and leaves out the climbing', () => {
|
||||
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} compact />);
|
||||
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-paddle-check', 'plan-tour-distance']);
|
||||
});
|
||||
|
||||
it('PCD-007: the day plan still shows every fact', () => {
|
||||
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} />);
|
||||
expect(testIds()).toEqual([
|
||||
'trip-plan-tour-time',
|
||||
'plan-tour-distance',
|
||||
'plan-tour-ascent',
|
||||
'plan-tour-paddle-check',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -7,7 +7,9 @@ import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { WhiteBadge } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { paddleCheckFact } from './paddle/paddleCheckModel';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
@@ -117,7 +119,7 @@ export function TourDayRowAvatar({
|
||||
}
|
||||
|
||||
/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */
|
||||
function TourDayRowPill({
|
||||
export function TourDayRowPill({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
@@ -139,19 +141,32 @@ function TourDayRowPill({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A paddle tour's check bench as one fact (PAD-09), or null for a hike, a tour
|
||||
* listed without one, or an area without obstacle data: a row has no room for
|
||||
* a badge that says nothing, and the checklist itself still names the gap.
|
||||
*/
|
||||
function paddleCheckOf(tour: TourListItem, t: Parameters<typeof paddleCheckFact>[1]) {
|
||||
const check = tour.tour_type === 'kayak' ? tour.paddle_check : null;
|
||||
return check && check.data !== 'none' ? paddleCheckFact(check, t) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour's facts on its day-plan row, in the badges every other stop uses: the
|
||||
* time it takes (the planned total where there is one, otherwise the walking
|
||||
* time, the parts in the tooltip), how far and how much climbing.
|
||||
* time, the parts in the tooltip), how far, how much climbing and, for a paddle
|
||||
* tour, its check bench. `compact` (the tour list) keeps the three that matter
|
||||
* most, in that order: the time, then a paddle tour's check bench and its
|
||||
* distance, or a hike's distance and climbing.
|
||||
*/
|
||||
export function TourDayRowPills({ tour }: { tour: TourListItem }) {
|
||||
export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const times = tourPlannedTimes(tour);
|
||||
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
|
||||
const timeParts = [
|
||||
times.walkingMinutes != null
|
||||
? `${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
@@ -160,34 +175,38 @@ export function TourDayRowPills({ tour }: { tour: TourListItem }) {
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return (
|
||||
<>
|
||||
{shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
)}
|
||||
{tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
icon={Ruler}
|
||||
value={formatDistance(tour.distance, unit)}
|
||||
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
|
||||
testId="plan-tour-distance"
|
||||
/>
|
||||
)}
|
||||
{tour.elevation_gain != null && (
|
||||
<TourDayRowPill
|
||||
icon={ArrowUp}
|
||||
value={formatElevation(tour.elevation_gain, unit)}
|
||||
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
|
||||
testId="plan-tour-ascent"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
const check = paddleCheckOf(tour, t);
|
||||
const time = shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
key="time"
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
);
|
||||
const distance = tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
key="distance"
|
||||
icon={Ruler}
|
||||
value={formatDistance(tour.distance, unit)}
|
||||
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
|
||||
testId="plan-tour-distance"
|
||||
/>
|
||||
);
|
||||
const ascent = tour.elevation_gain != null && (
|
||||
<TourDayRowPill
|
||||
key="ascent"
|
||||
icon={ArrowUp}
|
||||
value={formatElevation(tour.elevation_gain, unit)}
|
||||
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
|
||||
testId="plan-tour-ascent"
|
||||
/>
|
||||
);
|
||||
const bench = check && <TourDayRowPill key="check" {...check} testId="plan-tour-paddle-check" />;
|
||||
if (!compact) return <>{[time, distance, ascent, bench]}</>;
|
||||
// A paddle tour's climbing is the river's fall, nothing to plan by; its check bench is.
|
||||
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
|
||||
}
|
||||
|
||||
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
|
||||
@@ -203,9 +222,9 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
|
||||
if (times.walkingMinutes != null)
|
||||
add(
|
||||
Footprints,
|
||||
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
|
||||
formatPlannedTourDuration(times.walkingMinutes),
|
||||
t('tours.planner.inspector.duration'),
|
||||
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
|
||||
'trip-plan-tour-walking-time'
|
||||
);
|
||||
if (times.breakMinutes != null && times.breakMinutes > 0)
|
||||
@@ -222,6 +241,8 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
|
||||
'trip-plan-tour-planned-duration'
|
||||
);
|
||||
const check = paddleCheckOf(tour, t);
|
||||
if (check) add(check.icon, check.value, t('tours.paddle.check.title'), 'plan-tour-paddle-check');
|
||||
return (
|
||||
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{facts.map((fact) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/rende
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TourDetailDialog from './TourDetailDialog'
|
||||
@@ -141,6 +142,16 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
|
||||
|
||||
const row = screen.getByText('Distance').closest('dl')
|
||||
expect(row).toHaveClass('grid-cols-3')
|
||||
expect(row).toContainElement(screen.getByText('Paddling'))
|
||||
expect(row).toContainElement(screen.getByText('Planned'))
|
||||
expect(screen.queryByText('Ascent')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
|
||||
render(
|
||||
<TourDetailDialog
|
||||
@@ -214,4 +225,19 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getAllByText('T1').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/easy/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PCT-001: a paddle tour shows its checklist and the safety row before the wind, a hike neither', () => {
|
||||
const { unmount } = render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', paddle_check: paddleBench() }} />)
|
||||
|
||||
expect(screen.getByText('Checklist')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('paddle-check-summary')).toBeInTheDocument()
|
||||
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
|
||||
expect(safety.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
unmount()
|
||||
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, paddle_check: paddleBench() }} />)
|
||||
expect(screen.queryByText('Checklist')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Paddling has risks/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Minus,
|
||||
Mountain,
|
||||
Navigation,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -23,14 +22,14 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { translateApiError, useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types';
|
||||
import type { Assignment, AssignmentsMap, Day, DistanceUnit, Place, TripFile } from '../../types';
|
||||
import { openFile } from '../../utils/fileDownload';
|
||||
import { filesForPlace } from '../../utils/placeFiles';
|
||||
import { analyzeRouteGeometry } from '../../utils/routeGeometry';
|
||||
@@ -48,8 +47,13 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import TrackColorPicker from '../shared/TrackColorPicker';
|
||||
import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { TourGpxExportButton } from './TourGpxExportButton';
|
||||
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
|
||||
import { PaddleWindSection } from './paddle/PaddleWindCard';
|
||||
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { tourDaysOf } from './tourDays';
|
||||
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
|
||||
import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts';
|
||||
import {
|
||||
formatPlannedTourDuration,
|
||||
@@ -113,13 +117,67 @@ function assignmentForSelectedDay(
|
||||
);
|
||||
}
|
||||
|
||||
/** The tour's measures, its times and its profile. Without heights the distance joins the times in one row. */
|
||||
function TourDetailMeasures({
|
||||
analysis,
|
||||
unit,
|
||||
timeFields,
|
||||
trackColor,
|
||||
placeId,
|
||||
}: {
|
||||
analysis: NonNullable<ReturnType<typeof analyzeRouteGeometry>>;
|
||||
unit: DistanceUnit;
|
||||
timeFields: TourMetricField[];
|
||||
trackColor: string;
|
||||
placeId: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-none flex-col gap-2">
|
||||
{analysis.minEle == null && analysis.gain == null ? (
|
||||
<TourMetricFields
|
||||
analysis={analysis}
|
||||
unit={unit}
|
||||
extra={timeFields}
|
||||
columns={timeFields.length === 1 ? 2 : timeFields.length === 2 ? 3 : undefined}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TourMetricFields analysis={analysis} unit={unit} columns={5} />
|
||||
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
|
||||
</>
|
||||
)}
|
||||
{analysis.distanceIndexedProfileSamples.length >= 2 && (
|
||||
<div className={`${BOX} p-2`}>
|
||||
<ElevationProfile
|
||||
samples={analysis.distanceIndexedProfileSamples}
|
||||
color={trackColor}
|
||||
gradientId={`tour-elevation-${placeId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a tour starts, the place its wind is asked for (PAD-08). */
|
||||
function placeStart(place: Place): { lat: number; lng: number } | null {
|
||||
return place.lat != null && place.lng != null ? { lat: place.lat, lng: place.lng } : null;
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
|
||||
function tourTimeFields(times: ReturnType<typeof tourPlannedTimes>, t: Translate): TourMetricField[] {
|
||||
function tourTimeFields(
|
||||
times: ReturnType<typeof tourPlannedTimes>,
|
||||
t: Translate,
|
||||
tour: Pick<TourListItem, 'tour_type'>
|
||||
): TourMetricField[] {
|
||||
return [
|
||||
times.walkingMinutes != null
|
||||
? { label: t('tours.planner.walkingShort'), value: formatPlannedTourDuration(times.walkingMinutes) }
|
||||
? {
|
||||
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
|
||||
value: formatPlannedTourDuration(times.walkingMinutes),
|
||||
}
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
|
||||
@@ -166,9 +224,25 @@ function TourHeadPills({
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />
|
||||
{createElement(tourDayRowIcon(tour.tour_type), {
|
||||
size: 11,
|
||||
strokeWidth: 2,
|
||||
className: 'flex-none',
|
||||
style: { color: trackColor },
|
||||
})}
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
{/* A paddle tour names its boat and its water, each a badge of its own. */}
|
||||
{tour.boat_type && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
|
||||
{t(`tours.paddle.boat.${tour.boat_type}`)}
|
||||
</span>
|
||||
)}
|
||||
{tour.water_context && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-water">
|
||||
{t(`tours.paddle.water.${tour.water_context}`)}
|
||||
</span>
|
||||
)}
|
||||
{source !== 'trek' && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<FileDown size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
@@ -337,7 +411,11 @@ export default function TourDetailDialog({
|
||||
);
|
||||
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
|
||||
|
||||
const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry]);
|
||||
const heightsHidden = heightsUnknown(tour);
|
||||
const analysis = useMemo(() => {
|
||||
const measured = analyzeRouteGeometry(place.route_geometry);
|
||||
return measured && heightsHidden ? withoutHeights(measured) : measured;
|
||||
}, [heightsHidden, place.route_geometry]);
|
||||
const trackColor = resolveTrackColor(place);
|
||||
const inheritedColor = inheritedTrackColor(place);
|
||||
const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id);
|
||||
@@ -350,7 +428,7 @@ export default function TourDetailDialog({
|
||||
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website);
|
||||
const times = tourPlannedTimes(tour);
|
||||
|
||||
const timeFields = tourTimeFields(times, t);
|
||||
const timeFields = tourTimeFields(times, t, tour);
|
||||
|
||||
const header = (
|
||||
<>
|
||||
@@ -511,6 +589,10 @@ export default function TourDetailDialog({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* A tour written here offline has no server copy to export until it syncs. */}
|
||||
{analysis && place.id > 0 && (
|
||||
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
|
||||
)}
|
||||
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
|
||||
<FooterSpacer />
|
||||
{canEdit && onDelete && (
|
||||
@@ -562,20 +644,16 @@ export default function TourDetailDialog({
|
||||
</DialogSection>
|
||||
|
||||
{analysis && (
|
||||
<div className="flex flex-none flex-col gap-2">
|
||||
<TourMetricFields analysis={analysis} unit={distanceUnit} columns={5} />
|
||||
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
|
||||
{analysis.distanceIndexedProfileSamples.length >= 2 && (
|
||||
<div className={`${BOX} p-2`}>
|
||||
<ElevationProfile
|
||||
samples={analysis.distanceIndexedProfileSamples}
|
||||
color={trackColor}
|
||||
gradientId={`tour-elevation-${place.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TourDetailMeasures
|
||||
analysis={analysis}
|
||||
unit={distanceUnit}
|
||||
timeFields={timeFields}
|
||||
trackColor={trackColor}
|
||||
placeId={place.id}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
|
||||
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
|
||||
|
||||
{description && (
|
||||
<DialogSection className="flex-none" label={t('places.formDescription')}>
|
||||
|
||||
@@ -6,7 +6,15 @@ import TourGpxDropZone from './TourGpxDropZone';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
function gpxImport(overrides: Partial<TourGpxImport> = {}): TourGpxImport {
|
||||
return { importing: false, importFiles: vi.fn().mockResolvedValue(undefined), pick: vi.fn(), ...overrides };
|
||||
return {
|
||||
importing: false,
|
||||
importFiles: vi.fn().mockResolvedValue(undefined),
|
||||
pick: vi.fn(),
|
||||
preview: null,
|
||||
confirm: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ban, FileDown, Loader2 } from 'lucide-react';
|
||||
import { useRef, useState, type DragEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */
|
||||
@@ -64,6 +65,7 @@ export default function TourGpxDropZone({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
{(over || gpxImport.importing) && (
|
||||
<div
|
||||
data-testid="tour-gpx-drop"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Download, Loader2, Share } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { BarButton } from '../Planner/planParts';
|
||||
import { DialogButton } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useTourGpxExport, type TourGpxExportTarget } from './useTourGpxExport';
|
||||
|
||||
/**
|
||||
* PR1-06A, IO-01 in a footer: the saved tour as a GPX file, its own action
|
||||
* beside the navigation, which only leads to the start. "GPX" says the format,
|
||||
* the tooltip what the file holds. Where the platform takes GPX files, a share
|
||||
* button beside it hands the same file to the system's share sheet.
|
||||
*/
|
||||
export function TourGpxExportButton({ target }: { target: TourGpxExportTarget }) {
|
||||
const { t } = useTranslation();
|
||||
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={t('tours.gpxExport.hint')}>
|
||||
<span className="inline-flex">
|
||||
<DialogButton
|
||||
onClick={() => void exportGpx(target)}
|
||||
disabled={exporting}
|
||||
aria-label={t('tours.gpxExport.action')}
|
||||
data-testid="tour-gpx-export"
|
||||
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
|
||||
>
|
||||
<span className="max-sm:hidden">GPX</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{canShare && (
|
||||
<Tooltip label={t('tours.gpxExport.share')}>
|
||||
<span className="inline-flex">
|
||||
<DialogButton
|
||||
onClick={() => void shareGpx(target)}
|
||||
disabled={exporting}
|
||||
aria-label={t('tours.gpxExport.share')}
|
||||
data-testid="tour-gpx-share"
|
||||
icon={<Share size={14} strokeWidth={2} />}
|
||||
>
|
||||
<span className="max-sm:hidden">{t('tours.gpxExport.shareShort')}</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same export in the planner's head. While the tour has unsaved changes
|
||||
* the file would not hold them, so the buttons wait for the save and say so.
|
||||
*/
|
||||
export function TourGpxExportBarButton({ target, unsaved }: { target: TourGpxExportTarget; unsaved: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
|
||||
return (
|
||||
<>
|
||||
<BarButton
|
||||
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
|
||||
onClick={() => void exportGpx(target)}
|
||||
disabled={unsaved || exporting}
|
||||
>
|
||||
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
|
||||
</BarButton>
|
||||
{canShare && (
|
||||
<BarButton
|
||||
label={t(unsaved ? 'tours.gpxExport.shareSaveFirst' : 'tours.gpxExport.share')}
|
||||
onClick={() => void shareGpx(target)}
|
||||
disabled={unsaved || exporting}
|
||||
>
|
||||
<Share size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
BIKE_PROFILES,
|
||||
type BikeProfile,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
type PlannableTourType,
|
||||
type TourGpxImportFields,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, FileDown, Info } from 'lucide-react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { MapViewAuto } from '../Map/MapViewAuto';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { LABEL, Segmented } from '../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TourTypeSelect } from './planner/TourTypeSelect';
|
||||
import { TourNotice } from './tourParts';
|
||||
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
|
||||
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
|
||||
const BADGE =
|
||||
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
|
||||
|
||||
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
|
||||
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
|
||||
return (
|
||||
<li
|
||||
data-testid="gpx-preview-track"
|
||||
data-duplicate={track.duplicate || undefined}
|
||||
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{track.name || name}
|
||||
</span>
|
||||
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
|
||||
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
||||
{heights ? (
|
||||
<>
|
||||
<span className={BADGE}>↑ {formatElevation(track.elevation_gain_m ?? 0, unit)}</span>
|
||||
<span className={BADGE}>↓ {formatElevation(track.elevation_loss_m ?? 0, unit)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
|
||||
)}
|
||||
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: a GPX file shown before anything is stored. The whole course on a
|
||||
* map, every track with its measures, the tracks the trip already holds named
|
||||
* as skipped, and the type to import them as; a paddle import names its boat
|
||||
* and water, as a planned paddle tour does. Closing it stores nothing.
|
||||
*/
|
||||
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
|
||||
const { preview } = gpxImport;
|
||||
if (!preview) return null;
|
||||
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
|
||||
}
|
||||
|
||||
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const typeLabelId = useId();
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const [type, setType] = useState<PlannableTourType>('hike');
|
||||
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
|
||||
const [water, setWater] = useState<PaddleWaterContext | null>(null);
|
||||
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
const lines = useMemo(
|
||||
() =>
|
||||
files.flatMap((file) =>
|
||||
file.tracks.map((track) => track.geometry.map(([lat, lng]): [number, number] => [lat, lng]))
|
||||
),
|
||||
[files]
|
||||
);
|
||||
const focusPoints = useMemo(() => lines.flat(), [lines]);
|
||||
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
|
||||
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
|
||||
const fields: TourGpxImportFields =
|
||||
type === 'kayak' && boat && water
|
||||
? { tour_type: 'kayak', boat_type: boat, water_context: water }
|
||||
: type === 'bike'
|
||||
? { tour_type: 'bike', bike_profile: bikeProfile }
|
||||
: { tour_type: 'hike' };
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={gpxImport.cancel}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
blocked={gpxImport.importing}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<FileDown size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={gpxImport.cancel}
|
||||
eyebrow={t('tours.import.previewTitle')}
|
||||
title={title}
|
||||
sub={t('tours.import.previewHint')}
|
||||
subWraps
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={gpxImport.cancel} disabled={gpxImport.importing}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
data-testid="gpx-preview-import"
|
||||
icon={<FileDown size={14} strokeWidth={2} />}
|
||||
disabled={!canImport}
|
||||
onClick={() => void gpxImport.confirm(fields)}
|
||||
>
|
||||
{t('tours.import.previewConfirm', { count: fresh })}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-testid="gpx-preview-map"
|
||||
className="relative h-[280px] flex-none overflow-hidden rounded-[14px] border border-edge-faint"
|
||||
>
|
||||
<MapViewAuto
|
||||
places={[]}
|
||||
route={lines}
|
||||
tileUrl={tileUrl}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={files.map(({ file }) => file.name).join('|')}
|
||||
followSelection={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
|
||||
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{tracks.map(({ file, track }, index) => (
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
{fresh === 0 && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.import.previewAllDuplicates')}
|
||||
</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
|
||||
{type === 'bike' && (
|
||||
<div data-testid="gpx-preview-bike" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.bike.profile')}</span>
|
||||
<Segmented<BikeProfile>
|
||||
value={bikeProfile}
|
||||
onChange={setBikeProfile}
|
||||
label={t('tours.bike.profile')}
|
||||
fill
|
||||
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'kayak' && (
|
||||
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.boat')}</span>
|
||||
<Segmented<PaddleBoatType>
|
||||
value={(boat ?? '') as PaddleBoatType}
|
||||
onChange={setBoat}
|
||||
label={t('tours.paddle.boat')}
|
||||
fill
|
||||
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.water')}</span>
|
||||
<Segmented<PaddleWaterContext>
|
||||
value={(water ?? '') as PaddleWaterContext}
|
||||
onChange={setWater}
|
||||
label={t('tours.paddle.water')}
|
||||
fill
|
||||
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{paddleIncomplete && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.chooseBoatImport')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -177,3 +177,26 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a list says when its filters leave nothing: so, and a way to clear them. */
|
||||
export function TourFilterEmpty({ filters }: { filters: TourListFilters }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('tours.filter.empty')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
|
||||
)
|
||||
}
|
||||
|
||||
/** The tile, the name and one line of badges with the description trailing after them. */
|
||||
/** The tile, the name, the description on a line of its own and one line of badges. */
|
||||
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
|
||||
tour: TourListItem
|
||||
nameId: string
|
||||
@@ -60,24 +60,24 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: {
|
||||
<div className="min-w-0 flex-1">
|
||||
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
|
||||
style={fs(13, 'body')}>{tour.name}</span>
|
||||
{/* One line like a plan row's: the badges keep their place, the description gives way,
|
||||
and what does not fit fades out instead of ending at a hard edge. */}
|
||||
{tour.description && (
|
||||
<span
|
||||
data-testid="tour-description-preview"
|
||||
className="mt-0.5 block min-w-0 truncate text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{tour.description}
|
||||
</span>
|
||||
)}
|
||||
{/* One line of whole badges, most important first: one that does not fit wraps onto a
|
||||
second line the fixed height hides, so a narrow sidebar shows two and never half of one. */}
|
||||
<div
|
||||
data-testid="tour-metrics"
|
||||
className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
|
||||
className="-mx-0.5 mt-0.5 flex h-6 min-w-0 flex-wrap content-start gap-x-1.5 gap-y-2 overflow-hidden px-0.5 pt-0.5"
|
||||
>
|
||||
<TourCautionBadge tour={tour} />
|
||||
<TourDayRowPills tour={tour} />
|
||||
<TourDayRowPills tour={tour} compact />
|
||||
<TourDaysBadge tourDays={tourDays} />
|
||||
{tour.description && (
|
||||
<span
|
||||
data-testid="tour-description-preview"
|
||||
className="min-w-0 flex-1 truncate text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{tour.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,9 +108,9 @@ function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps,
|
||||
|
||||
/**
|
||||
* A tour in a list, laid out like a stop in the day plan: its tile with the
|
||||
* grade, the name, and one line of the plan's white badges (time, distance,
|
||||
* ascent, the days it is on, a caution) with the description trailing after
|
||||
* them. The day and delete actions sit on the right.
|
||||
* grade, the name, the description below it, and one line of the plan's
|
||||
* white badges (time, distance, ascent, the days it is on, a caution). The day
|
||||
* and delete actions sit on the right.
|
||||
*/
|
||||
export default function TourListRow({ tour,
|
||||
days = NO_DAYS,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { TourListItem, TourTypeKey } from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CalendarClock,
|
||||
ChartColumnBig,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
FileDown,
|
||||
Mountain,
|
||||
Route,
|
||||
Ruler,
|
||||
Waves,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, type ComponentType } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useRailFold } from './railFold';
|
||||
import { TourDayRowPill } from './TourDayRowFacts';
|
||||
import { InfoDot, RailCard } from './tourParts';
|
||||
import { formatPlannedTourDuration } from './tourPresentation';
|
||||
import { tourScopeSummary, type ScopeSum, type TourTypeScope } from './tourScope';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
const TYPE_ICON: Partial<Record<TourTypeKey, ComponentType<{ size?: number; strokeWidth?: number }>>> = {
|
||||
hike: Mountain,
|
||||
kayak: Waves,
|
||||
};
|
||||
|
||||
/** A count as a white badge of the card: the number, then what it counts. */
|
||||
function CountBadge({ value, label, tone = 'neutral' }: { value: number; label: string; tone?: 'neutral' | 'faint' }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 shadow-sm ring-1 ring-inset ring-edge-faint ${tone === 'faint' ? 'text-content-muted' : 'text-content-secondary'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
<span className="font-geist font-semibold tabular-nums text-content">{value}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One measure of a type as a badge: its icon and sum, its name (and how complete it is) in the tooltip. */
|
||||
interface ScopeBadge {
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum as a badge: nothing when no tour has the value, else the sum, marked
|
||||
* as at least that much when some tours lack it, the tooltip saying for how many.
|
||||
*/
|
||||
function sumBadge(
|
||||
{ key, icon, name }: Pick<ScopeBadge, 'key' | 'icon'> & { name: string },
|
||||
sum: ScopeSum,
|
||||
format: (value: number) => string,
|
||||
t: Translate
|
||||
): ScopeBadge | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
|
||||
const label = partial
|
||||
? `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`
|
||||
: `${name}: ${value}`;
|
||||
return { key, icon, value, label };
|
||||
}
|
||||
|
||||
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
|
||||
const distance = (km: number) => formatDistance(km, unit);
|
||||
const height = (m: number) => formatElevation(m, unit);
|
||||
return [
|
||||
sumBadge({ key: 'distance', icon: Ruler, name: t('tours.detail.distance') }, scope.distanceKm, distance, t),
|
||||
sumBadge({ key: 'ascent', icon: ArrowUp, name: t('tours.detail.ascent') }, scope.ascentM, height, t),
|
||||
sumBadge({ key: 'descent', icon: ArrowDown, name: t('tours.detail.descent') }, scope.descentM, height, t),
|
||||
sumBadge(
|
||||
{ key: 'routed', icon: Clock, name: t('tours.scope.routed') },
|
||||
scope.routedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
),
|
||||
sumBadge(
|
||||
{ key: 'imported', icon: FileDown, name: t('tours.scope.imported') },
|
||||
scope.importedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
),
|
||||
sumBadge(
|
||||
{ key: 'planned', icon: CalendarClock, name: t('tours.scope.planned') },
|
||||
scope.plannedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
),
|
||||
].filter((badge): badge is ScopeBadge => badge !== null);
|
||||
}
|
||||
|
||||
/** One tour type: its name and how many tours, then its sums as a line of badges. */
|
||||
function TypeScope({ scope }: { scope: TourTypeScope }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const Icon = TYPE_ICON[scope.type] ?? Route;
|
||||
const badges = typeBadges(scope, unit, t);
|
||||
return (
|
||||
<section
|
||||
aria-label={t(`tourTypes.${scope.type}`)}
|
||||
className="flex flex-col gap-1.5"
|
||||
data-testid={`tour-scope-${scope.type}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12, 'body')}>
|
||||
{t(`tourTypes.${scope.type}`)}
|
||||
</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{scope.count}
|
||||
</span>
|
||||
</div>
|
||||
{badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 ps-[22px]">
|
||||
{badges.map((badge) => (
|
||||
<TourDayRowPill
|
||||
key={badge.key}
|
||||
icon={badge.icon}
|
||||
value={badge.value}
|
||||
label={badge.label}
|
||||
testId={`tour-scope-${scope.type}-${badge.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 ps-[22px] text-content-faint" style={fs(11.5, 'body')}>
|
||||
{t('tours.scope.noMeasures')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-01: what the trip's tours add up to, above their list, by type: how many,
|
||||
* how many are on a day, distance, ascent and descent, and the three kinds of
|
||||
* time apart (the router's, an import's, the planned one with breaks), never
|
||||
* added up across types. It reads the list as filtered, so a type chosen in the
|
||||
* filter narrows both. A value no tour has is left out rather than shown as
|
||||
* zero; a sum some tours lack is marked as at least that much. It starts folded
|
||||
* to its counts, and opening it is remembered in this browser.
|
||||
*/
|
||||
export function TourScopeSummary({ tours }: { tours: readonly TourListItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
// Folded until opened: the list is what the rail is for, the totals a look on demand.
|
||||
const { folded, toggle } = useRailFold('trip-totals', true);
|
||||
// On a day as the day lists say, not the list's flag, so a tour put on a day counts at once (as its row's badge does).
|
||||
const assignments = useTripStore((state) => state.assignments);
|
||||
const scope = useMemo(() => {
|
||||
const onDays = new Set(Object.values(assignments ?? {}).flatMap((list) => list.map((item) => item.place_id)));
|
||||
return tourScopeSummary(tours, (tour) => onDays.has(tour.place_id));
|
||||
}, [assignments, tours]);
|
||||
if (scope.count === 0) return null;
|
||||
const toggleLabel = folded ? t('common.expand') : t('common.collapse');
|
||||
return (
|
||||
<RailCard
|
||||
icon={<ChartColumnBig size={14} strokeWidth={2} />}
|
||||
title={t('tours.scope.title')}
|
||||
aside={
|
||||
<span className="flex flex-none items-center gap-0.5">
|
||||
<InfoDot label={t('tours.scope.title')} description={t('tours.scope.info')} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={!folded}
|
||||
aria-label={toggleLabel}
|
||||
data-testid="tour-scope-toggle"
|
||||
className="grid h-6 w-6 place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2.2} className={`transition-transform ${folded ? '' : 'rotate-180'}`} />
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="tour-scope-counts">
|
||||
<CountBadge value={scope.count} label={t('tours.mode.tours')} />
|
||||
<CountBadge value={scope.onDays} label={t('places.planned')} tone="faint" />
|
||||
<CountBadge value={scope.count - scope.onDays} label={t('places.unplanned')} tone="faint" />
|
||||
</div>
|
||||
{!folded && (
|
||||
<div className="mt-3 flex flex-col gap-3 border-t border-edge-faint pt-3">
|
||||
{scope.types.map((typeScope) => (
|
||||
<TypeScope key={typeScope.type} scope={typeScope} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import TourWorkspaceRail from './TourWorkspaceRail';
|
||||
import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail';
|
||||
import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
@@ -23,18 +25,13 @@ function wanderer(overrides: Partial<WandererBrowser>): WandererBrowser {
|
||||
return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser;
|
||||
}
|
||||
|
||||
function workspace(browser: WandererBrowser): TourWorkspaceRailParts {
|
||||
return { tourPlanner: planner, wandererBrowser: browser, tourConversion: conversion, recordedTrack };
|
||||
}
|
||||
|
||||
describe('TourWorkspaceRail', () => {
|
||||
it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => {
|
||||
render(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={wanderer({ open: true })}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
render(<TourWorkspaceRail workspace={workspace(wanderer({ open: true }))} canEdit canAssign />);
|
||||
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -42,16 +39,7 @@ describe('TourWorkspaceRail', () => {
|
||||
it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => {
|
||||
const browser = wanderer({});
|
||||
seen.rail.length = 0;
|
||||
const view = render(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={browser}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign={false}
|
||||
/>
|
||||
);
|
||||
const view = render(<TourWorkspaceRail workspace={workspace(browser)} canEdit canAssign={false} />);
|
||||
expect(screen.getByText('tour editor')).toBeInTheDocument();
|
||||
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
|
||||
planner,
|
||||
@@ -62,16 +50,17 @@ describe('TourWorkspaceRail', () => {
|
||||
onOpenWanderer: browser.openBrowser,
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={wanderer({ enabled: false })}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
view.rerender(<TourWorkspaceRail workspace={workspace(wanderer({ enabled: false }))} canEdit canAssign />);
|
||||
expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PCW-001: hands the paddle plan and the checklist of the tour in view on to the editor rail', () => {
|
||||
const trackPaddlePlan = { pick: null } as unknown as TrackPaddlePlan;
|
||||
const paddleChecklist = { status: 'ready' } as unknown as PaddleChecklist;
|
||||
const parts = { ...workspace(wanderer({})), trackPaddlePlan, paddleChecklist };
|
||||
seen.rail.length = 0;
|
||||
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
|
||||
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
|
||||
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import { TourPlannerRail } from './planner/TourPlannerPanels';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import WandererBrowserRail from './wanderer/WandererBrowserRail';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
|
||||
/** What the tours workspace hands its left rail: the parts of the planner page's state, by their names there. */
|
||||
export interface TourWorkspaceRailParts {
|
||||
tourPlanner: TourPlannerController;
|
||||
wandererBrowser: WandererBrowser;
|
||||
tourConversion: GpxConversion;
|
||||
recordedTrack: RecordedTrack;
|
||||
/** The portages and locks of a recorded paddle track in view. */
|
||||
trackPaddlePlan?: TrackPaddlePlan;
|
||||
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
|
||||
paddleChecklist?: PaddleChecklist;
|
||||
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
|
||||
tourPlaceIds?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
|
||||
export default function TourWorkspaceRail({
|
||||
planner,
|
||||
wanderer,
|
||||
conversion,
|
||||
recordedTrack,
|
||||
workspace,
|
||||
canEdit,
|
||||
canAssign,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
wanderer: WandererBrowser;
|
||||
conversion: GpxConversion;
|
||||
recordedTrack: RecordedTrack;
|
||||
workspace: TourWorkspaceRailParts;
|
||||
canEdit: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const {
|
||||
tourPlanner,
|
||||
wandererBrowser: wanderer,
|
||||
tourConversion,
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
paddleChecklist,
|
||||
tourPlaceIds,
|
||||
} = workspace;
|
||||
return wanderer.open ? (
|
||||
<WandererBrowserRail browser={wanderer} />
|
||||
) : (
|
||||
<TourPlannerRail
|
||||
planner={planner}
|
||||
planner={tourPlanner}
|
||||
canEdit={canEdit}
|
||||
canAssign={canAssign}
|
||||
conversion={conversion}
|
||||
conversion={tourConversion}
|
||||
recordedTrack={recordedTrack}
|
||||
trackPaddlePlan={trackPaddlePlan}
|
||||
paddleChecklist={paddleChecklist}
|
||||
tourPlaceIds={tourPlaceIds}
|
||||
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { toursApi } from '../../api/client'
|
||||
import { tourGpxApi } from '../../api/tourGpx'
|
||||
import ToursSidebar from './ToursSidebar'
|
||||
import { pickFiles } from '../../../tests/helpers/filePicker'
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
|
||||
|
||||
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }))
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
@@ -41,6 +42,19 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
||||
})
|
||||
|
||||
function previewTrack(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'Ridge',
|
||||
geometry: [[47, 11, 600], [47.01, 11.01, 650], [47.02, 11.02, 640]] as [number, number, number][],
|
||||
point_count: 3,
|
||||
distance_km: 2.4,
|
||||
elevation_gain_m: 50,
|
||||
elevation_loss_m: 10,
|
||||
duplicate: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToursSidebar', () => {
|
||||
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
@@ -61,15 +75,18 @@ describe('ToursSidebar', () => {
|
||||
expect(row).toHaveClass('bg-surface-selected')
|
||||
})
|
||||
|
||||
it('RS-01: trails the description after the badges on one line, as a plan row does', () => {
|
||||
it('RS-01: puts the description on its own line between the name and the badges', () => {
|
||||
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
||||
const line = within(row).getByTestId('tour-metrics')
|
||||
expect(line).toHaveClass('flex-nowrap', 'overflow-hidden')
|
||||
expect(line).toHaveClass('flex-wrap', 'h-6', 'overflow-hidden')
|
||||
expect(line).toHaveTextContent('1 h 35 min')
|
||||
expect(within(line).getByTestId('tour-description-preview')).toHaveTextContent('A quiet path above the lake')
|
||||
expect(within(line).getByTestId('tour-description-preview')).toHaveClass('truncate')
|
||||
const description = within(row).getByTestId('tour-description-preview')
|
||||
expect(line).not.toContainElement(description)
|
||||
expect(description).toHaveTextContent('A quiet path above the lake')
|
||||
expect(description).toHaveClass('truncate')
|
||||
expect(description.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
// The information link belongs to the detail, not the list row.
|
||||
expect(row).not.toHaveTextContent('Komoot')
|
||||
})
|
||||
@@ -205,7 +222,8 @@ describe('ToursSidebar', () => {
|
||||
})
|
||||
|
||||
it('quarantines returned Tour ids before loading the imported Places', async () => {
|
||||
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({
|
||||
tours: [tour], caution: false, skipped: 0,
|
||||
})
|
||||
const onToursChanged = vi.fn()
|
||||
@@ -224,6 +242,7 @@ describe('ToursSidebar', () => {
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
|
||||
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
// Only the places are read again, not the whole trip: that would rebuild the workspace.
|
||||
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
|
||||
@@ -231,11 +250,13 @@ describe('ToursSidebar', () => {
|
||||
expect(onToursChanged).toHaveBeenCalledWith([42])
|
||||
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0])
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
|
||||
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
||||
const onToursChanged = vi.fn()
|
||||
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
|
||||
render(<ToursSidebar
|
||||
@@ -250,6 +271,7 @@ describe('ToursSidebar', () => {
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
|
||||
expect(onToursChanged).not.toHaveBeenCalled()
|
||||
@@ -257,9 +279,10 @@ describe('ToursSidebar', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('RC-04: names each file that fails and still imports the rest', async () => {
|
||||
vi.spyOn(toursApi, 'importGpx')
|
||||
vi.spyOn(tourGpxApi, 'preview')
|
||||
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
|
||||
.mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
|
||||
.mockResolvedValueOnce({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
@@ -269,15 +292,79 @@ describe('ToursSidebar', () => {
|
||||
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
|
||||
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
// The file without a track is named at once and left out of the preview.
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
|
||||
expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx')
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: shows the file before storing anything, and closing the preview stores nothing', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack(), previewTrack({ name: 'Back', duplicate: true })] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('GPX preview')
|
||||
expect(dialog).toHaveTextContent('ridge.gpx')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[0]).toHaveTextContent('3 points')
|
||||
// Only the track the trip does not hold yet is imported.
|
||||
expect(screen.getByRole('button', { name: 'Import 1 tour' })).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(importCall).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: a file the trip holds completely cannot be imported again, and says why', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ duplicate: true })] })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: a paddle import waits for its boat and water, then sends them along', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ elevation_gain_m: null, elevation_loss_m: null })] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
// A file without heights says so instead of showing zero.
|
||||
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
|
||||
|
||||
fireEvent.click(screen.getByTestId('tour-type-select'))
|
||||
fireEvent.click(screen.getByTestId('tour-type-option-kayak'))
|
||||
const confirm = screen.getByTestId('gpx-preview-import')
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'SUP' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Lake' }))
|
||||
expect(confirm).toBeEnabled()
|
||||
fireEvent.click(confirm)
|
||||
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
|
||||
const importCall = vi.spyOn(toursApi, 'importGpx')
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
|
||||
@@ -9,8 +9,9 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import TourListFilterMenu from './TourListFilterMenu'
|
||||
import TourListFilterMenu, { TourFilterEmpty } from './TourListFilterMenu'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
|
||||
import { useTourGpxImport } from './useTourGpxImport'
|
||||
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
|
||||
import { useTourMapVisibility } from './useTourMapVisibility'
|
||||
@@ -96,14 +97,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
|
||||
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
|
||||
{!loading && filters.emptyByFilter ? (
|
||||
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
<TourFilterEmpty filters={filters} />
|
||||
) : !loading && filters.filtered.length === 0 ? (
|
||||
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
|
||||
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
|
||||
@@ -130,6 +124,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { TourTrailGrade } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { BikeWaysCard } from './BikeWaysCard';
|
||||
import type { RouteWays } from './routeWays';
|
||||
import type { RouteWaysState } from './useRouteWays';
|
||||
|
||||
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
|
||||
vi.mock('./useRouteWays', () => ({
|
||||
useRouteWays: (input: unknown) => {
|
||||
hook.input = input;
|
||||
return { ...(hook.state as object), retry: hook.retry };
|
||||
},
|
||||
}));
|
||||
|
||||
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
[47.001, 11.0],
|
||||
[47.002, 11.0],
|
||||
[47.003, 11.0],
|
||||
];
|
||||
const WAYS: RouteWays = {
|
||||
line: LINE,
|
||||
edges: [
|
||||
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}) {
|
||||
return {
|
||||
tripId: 4,
|
||||
mode: { type: 'new-draft' },
|
||||
isBike: true,
|
||||
bikeProfile: 'touring',
|
||||
route: LINE,
|
||||
routeCurrent: true,
|
||||
...overrides,
|
||||
} as unknown as TourPlannerController;
|
||||
}
|
||||
|
||||
const grade = (id: number, mtb: string | null, uphill: string | null = null): TourTrailGrade => ({
|
||||
id,
|
||||
mtb_scale: mtb,
|
||||
mtb_scale_uphill: uphill,
|
||||
mtb_scale_imba: null,
|
||||
});
|
||||
|
||||
function show(state: Partial<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
|
||||
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
|
||||
return render(<BikeWaysCard planner={planner(overrides)} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useTourRouteHighlightStore.getState().clear();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
|
||||
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
|
||||
show({});
|
||||
const card = screen.getByTestId('bike-ways-card');
|
||||
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
|
||||
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
|
||||
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
|
||||
expect(card).not.toHaveTextContent('Trail grade');
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
|
||||
show({});
|
||||
const row = screen.getByTestId('bike-ways-row-surface-gravel');
|
||||
fireEvent.click(row);
|
||||
expect(row).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
|
||||
id: 'surface-gravel',
|
||||
lines: [LINE.slice(1, 3)],
|
||||
paint: 'gravel',
|
||||
});
|
||||
fireEvent.click(row);
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-003: a road bike tour names what is unpaved and shows it on the map', () => {
|
||||
show({}, { bikeProfile: 'road' } as Partial<TourPlannerController>);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on the map' }));
|
||||
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('unpaved');
|
||||
expect(useTourRouteHighlightStore.getState().highlight?.lines).toEqual([LINE.slice(1)]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-004: a mountain bike tour shows its trail grades, uphill apart, the unknown said', () => {
|
||||
show({
|
||||
grades: {
|
||||
status: 'ready',
|
||||
byWay: new Map([
|
||||
[1, grade(1, '0')],
|
||||
[2, grade(2, '2', '3')],
|
||||
]),
|
||||
unasked: new Set([3]),
|
||||
},
|
||||
});
|
||||
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
|
||||
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
|
||||
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
|
||||
expect(hook.retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-006: says when it is still asking, offline, or failed with a way to ask again', () => {
|
||||
const { unmount } = show({ status: 'loading', ways: null });
|
||||
expect(screen.getByTestId('bike-ways-card')).toHaveTextContent(/./);
|
||||
unmount();
|
||||
const offline = show({ status: 'offline', ways: null });
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
|
||||
offline.unmount();
|
||||
show({ status: 'failed', ways: null });
|
||||
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
expect(hook.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
|
||||
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
|
||||
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
|
||||
unmount();
|
||||
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-008: only for a bike tour being edited, and only the route as planned now', () => {
|
||||
const { unmount } = show({ status: 'idle', ways: null });
|
||||
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
|
||||
unmount();
|
||||
const hike = show({}, { isBike: false });
|
||||
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
|
||||
hike.unmount();
|
||||
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
|
||||
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
|
||||
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { CardAction, RailCard, TourNotice } from '../tourParts';
|
||||
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
|
||||
import {
|
||||
sharesBy,
|
||||
stretchesOf,
|
||||
trailClassOf,
|
||||
UNPAVED,
|
||||
type ClassShare,
|
||||
type RouteWayEdge,
|
||||
type RouteWays,
|
||||
type SurfaceClass,
|
||||
type TrailClass,
|
||||
type WayClass,
|
||||
} from './routeWays';
|
||||
import { useRouteWays, type TrailGrades } from './useRouteWays';
|
||||
|
||||
const percent = (share: number) => `${Math.round(share * 100)} %`;
|
||||
|
||||
interface ShareRowProps<C extends string> {
|
||||
id: string;
|
||||
row: ClassShare<C>;
|
||||
label: string;
|
||||
paint: BikePaintKey;
|
||||
/** A dot in the class's colour; a kind of way has none. */
|
||||
dot: boolean;
|
||||
ways: RouteWays;
|
||||
inClass: (edge: RouteWayEdge) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One class of the route: its share and length, and a switch that points at
|
||||
* its stretches on the map. Pressed again, or another row pressed, it lets go.
|
||||
*/
|
||||
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
|
||||
const toggle = () =>
|
||||
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={shown}
|
||||
data-testid={`bike-ways-row-${id}`}
|
||||
onClick={toggle}
|
||||
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
|
||||
style={dot ? { background: bikePaintCss(paint) } : undefined}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{formatDistance(row.lengthM / 1000, unit)}
|
||||
</span>
|
||||
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
|
||||
{percent(row.share)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
|
||||
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
|
||||
return (
|
||||
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
{rows.map((row) => (
|
||||
<span
|
||||
key={row.key}
|
||||
className="h-full"
|
||||
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SurfaceSection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
|
||||
return (
|
||||
<Section label={t('tours.bike.ways.surface')}>
|
||||
<ShareBar rows={rows} paintOf={surfacePaintKey} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`surface-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.ways.surface.${row.key}`)}
|
||||
paint={surfacePaintKey(row.key)}
|
||||
dot
|
||||
ways={ways}
|
||||
inClass={(edge) => edge.surface === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function WaySection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
|
||||
return (
|
||||
<Section label={t('tours.bike.ways.way')}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`way-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.ways.way.${row.key}`)}
|
||||
paint="way"
|
||||
dot={false}
|
||||
ways={ways}
|
||||
inClass={(edge) => edge.way === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
|
||||
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const classOf = useMemo(
|
||||
() =>
|
||||
(edge: RouteWayEdge): TrailClass =>
|
||||
edge.wayId !== null && grades.unasked.has(edge.wayId)
|
||||
? 'unasked'
|
||||
: trailClassOf(edge.wayId === null ? undefined : grades.byWay.get(edge.wayId)),
|
||||
[grades]
|
||||
);
|
||||
const uphillOf = useMemo(
|
||||
() =>
|
||||
(edge: RouteWayEdge): string => {
|
||||
const step = edge.wayId === null ? null : grades.byWay.get(edge.wayId)?.mtb_scale_uphill;
|
||||
return step ? `u${step[0]}` : 'none';
|
||||
},
|
||||
[grades]
|
||||
);
|
||||
const rows = useMemo(() => sharesBy<TrailClass>(ways, classOf), [ways, classOf]);
|
||||
const uphill = useMemo(() => sharesBy<string>(ways, uphillOf).filter((row) => row.key !== 'none'), [ways, uphillOf]);
|
||||
// Nothing could be asked about: said once with a way to ask again, not as a row covering the whole tour.
|
||||
const noneAnswered =
|
||||
grades.status === 'ready' && grades.byWay.size === 0 && rows.every((row) => row.key === 'unasked');
|
||||
const unread = noneAnswered ? 'failed' : grades.status;
|
||||
if (unread !== 'ready')
|
||||
return (
|
||||
<Section label={t('tours.bike.trail.title')}>
|
||||
<GradesStatus status={unread} onRetry={onRetry} />
|
||||
</Section>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Section label={t('tours.bike.trail.title')}>
|
||||
<ShareBar rows={rows} paintOf={trailPaintKey} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`trail-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.trail.${row.key}`)}
|
||||
paint={trailPaintKey(row.key)}
|
||||
dot
|
||||
ways={ways}
|
||||
inClass={(edge) => classOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
{uphill.length > 0 && (
|
||||
<Section label={t('tours.bike.trail.uphillTitle')}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{uphill.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`uphill-${row.key}`}
|
||||
row={row}
|
||||
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
|
||||
paint="way"
|
||||
dot={false}
|
||||
ways={ways}
|
||||
inClass={(edge) => uphillOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.bike.trail.note')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GradesStatus({ status, onRetry }: { status: Exclude<TrailGrades['status'], 'ready'>; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'loading')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
{t('tours.bike.trail.loading')}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<TourNotice
|
||||
tone="warning"
|
||||
icon={status === 'offline' ? <WifiOff size={13} strokeWidth={2} /> : <AlertTriangle size={13} strokeWidth={2} />}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{t(status === 'offline' ? 'tours.bike.ways.offline' : 'tours.bike.trail.failed')}</span>
|
||||
{status === 'failed' && (
|
||||
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
|
||||
{t('common.errorRetry')}
|
||||
</CardAction>
|
||||
)}
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/** BIK-02: a road bike tour names what of it is known to be unpaved, and points at it on the map. */
|
||||
function UnpavedNotice({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const unpaved = ways.edges.filter((edge) => UNPAVED.has(edge.surface)).reduce((sum, edge) => sum + edge.lengthM, 0);
|
||||
if (unpaved === 0) return null;
|
||||
const show = () =>
|
||||
useTourRouteHighlightStore
|
||||
.getState()
|
||||
.show({ id: 'unpaved', lines: stretchesOf(ways, (edge) => UNPAVED.has(edge.surface)), paint: 'gravel' });
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{t('tours.bike.ways.unpavedNotice', { length: formatDistance(unpaved / 1000, unit) })}</span>
|
||||
<CardAction icon={<Layers size={12} strokeWidth={2} />} onClick={show}>
|
||||
{t('tours.bike.ways.showOnMap')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: what the bike tour being planned runs on, its surfaces and
|
||||
* its kinds of way, and on a mountain bike tour the trail grades. Read off the
|
||||
* router's description of the route as OpenStreetMap maps it; where the data
|
||||
* says nothing the card says unknown. Nothing here changes the tour.
|
||||
*/
|
||||
export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
|
||||
const { t } = useTranslation();
|
||||
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
|
||||
const route = planner.routeCurrent ? planner.route : null;
|
||||
const state = useRouteWays({
|
||||
tripId: planner.tripId,
|
||||
active: planner.isBike && editing,
|
||||
route,
|
||||
profile: planner.bikeProfile,
|
||||
});
|
||||
const { ways } = state;
|
||||
const unpavedShare = useMemo(
|
||||
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
|
||||
[ways]
|
||||
);
|
||||
if (!planner.isBike || !editing || state.status === 'idle') return null;
|
||||
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Layers size={14} strokeWidth={2} />}
|
||||
title={t('tours.bike.ways.title')}
|
||||
foldId="bike-ways"
|
||||
aside={
|
||||
unpaved ? (
|
||||
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
|
||||
{state.status === 'loading' && (
|
||||
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
{t('tours.bike.ways.loading')}
|
||||
</span>
|
||||
)}
|
||||
{state.status === 'offline' && (
|
||||
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{t('tours.bike.ways.failed')}</span>
|
||||
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
|
||||
{t('common.errorRetry')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</TourNotice>
|
||||
)}
|
||||
{ways && (
|
||||
<>
|
||||
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
|
||||
<SurfaceSection ways={ways} />
|
||||
<WaySection ways={ways} />
|
||||
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
|
||||
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
|
||||
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
|
||||
{t('tours.bike.ways.source')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user