mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 988890866a | |||
| 0dda5bc3e9 | |||
| 7fa8b3fe35 | |||
| 112017a9e8 | |||
| cb5c5ab4a1 | |||
| bed9f53615 | |||
| a8e2486cd3 | |||
| 5d5015066f | |||
| 3e8d192305 | |||
| bafb74f872 | |||
| b71e32cf12 | |||
| 786df612fd | |||
| e6002444a1 | |||
| e453816ded | |||
| b9ca822d51 | |||
| 20537193b6 | |||
| 60611aee65 | |||
| 6c66cff365 | |||
| d3f574f225 | |||
| e5c5ffbc00 | |||
| 562176bd83 | |||
| ae699187f7 | |||
| 1d72fbd25f | |||
| 2d0c0597b3 | |||
| a85a50e186 | |||
| c8eb510bd4 | |||
| beea4c762b | |||
| d924dcf99d | |||
| 371083ed47 | |||
| eaeaf68352 | |||
| 5156fa2c0a | |||
| 6c6e285c3d | |||
| 93e1156aa4 | |||
| dc19807126 | |||
| 5580ff71a9 | |||
| e42622d71d | |||
| 94674e7572 | |||
| 7f14dde362 | |||
| 9765cf3fee | |||
| c38eb7ebf0 | |||
| e653733390 | |||
| 2655e510fc | |||
| 904eb31e62 | |||
| 76ec302526 | |||
| 810feba413 | |||
| 4009588078 | |||
| ef583ede18 | |||
| 9bc543a387 | |||
| 7291555e95 | |||
| 57f0e2f263 | |||
| de043cfb58 | |||
| 69df85bfa2 | |||
| be6702f3ab | |||
| 164a3633ac | |||
| 204ee2ef86 | |||
| baba2de344 | |||
| 49dd2db040 | |||
| 49e306ea48 | |||
| 1d7c8a63e2 | |||
| fb208a88e2 | |||
| 5ae8ac8e70 | |||
| 20bd384feb | |||
| 58302dc9e4 | |||
| b460cfd9a7 | |||
| 2b8d2e790f | |||
| 2fc9b1991a | |||
| 4d81180fc1 | |||
| 0f600d04f5 | |||
| b53257deaf | |||
| b284095ee0 | |||
| e40e576e39 | |||
| 4d9b84f681 | |||
| 223b056de2 | |||
| 981c372640 | |||
| 839d736905 | |||
| b9391a9921 | |||
| 5e0f216b0c | |||
| a249c861e6 | |||
| 2a40b884a1 | |||
| f5be556c27 | |||
| 6b1488994f | |||
| 2ee4ea1e65 | |||
| a1d683d7bc | |||
| ae726ed370 | |||
| f4750352e6 | |||
| 65ee472d66 | |||
| 73fa7aeb19 | |||
| fbba4fd73b | |||
| 2e1dca9fe9 | |||
| aacc7f2c8d | |||
| f833a05461 | |||
| 65fe8cd6e5 | |||
| 3fe34061fd | |||
| 3659297af1 | |||
| f57a0dbd31 | |||
| 3748bdb815 | |||
| 8fa75b3c4e | |||
| 6ec06c90f4 | |||
| 2449616ee9 | |||
| acc294dd8e | |||
| bde2ad2ca8 | |||
| ae79ab8dad | |||
| 6a0c54cb2f | |||
| a8be1c5b45 | |||
| d0cc6fc2f4 | |||
| f016972a4e | |||
| c80152f41d | |||
| f745ca13c9 | |||
| 4f68029b2c | |||
| 64445a5204 | |||
| bff9bcde92 | |||
| 3572f4b8a3 | |||
| ea9e997338 | |||
| ad2d653e9a | |||
| b177daea3c | |||
| 63a7a4d397 | |||
| f4533fc69b | |||
| 1993ed4d28 | |||
| 1969ca57d9 | |||
| b826ecd425 | |||
| 19946039ad | |||
| 6577595058 | |||
| 78366a9024 | |||
| 0331d9950f | |||
| dfe11e9c4c | |||
| fcdb7e48cd | |||
| 008aaeb408 | |||
| 5ff7e54e09 | |||
| 0169be3d79 | |||
| 68907ec811 | |||
| 51bfb5abd1 | |||
| 1d9b9bf838 | |||
| c83eb42730 | |||
| cd407cf80c | |||
| 71fe69c1f1 | |||
| 54baf85a2a | |||
| 3882feec5c | |||
| 6477bf5e39 | |||
| 971133a1eb | |||
| 0344db6d0c | |||
| 5d265c98de | |||
| 554d6f1b9f | |||
| f79b2020d6 | |||
| 9f0caf8539 | |||
| 7470a2c6d4 | |||
| 4ca5728072 | |||
| 2203ff3a2e | |||
| 0d7e3ceb3d | |||
| 93a508a0e6 | |||
| 0b92dcc86b | |||
| c836ee3988 | |||
| 188953158a | |||
| 20f8266439 | |||
| e5f01e570a | |||
| 2a8dbf91a8 | |||
| ec9e1a7533 | |||
| 3c823e07f4 | |||
| 9ed944c1e6 | |||
| 2955c61c86 | |||
| c97105ce61 | |||
| 290961cced | |||
| 4c594876ef | |||
| d5d2d6b25e | |||
| e3268735f2 | |||
| 9a59703a34 | |||
| 200ce58e98 | |||
| a965b2c75e | |||
| 91a834010b | |||
| a8d06193bd | |||
| 97b748555f | |||
| 187c4e2fc8 | |||
| dfd02940b5 | |||
| da0f79e285 | |||
| e877d35e0b | |||
| f3a39d9452 | |||
| b374fdc989 | |||
| b8eb2d909e | |||
| e429b49ef6 | |||
| 73237ffedb | |||
| adcaca3c5f | |||
| c3229afcba | |||
| 12e7bdf6e8 | |||
| 9382fa6dc6 | |||
| 3332495b7a | |||
| 75d8351fec | |||
| ce07c4dc72 | |||
| c5c88691bc | |||
| 827e682684 | |||
| 11a8d1b4d1 | |||
| 692373c8b8 | |||
| f6fd2722d0 | |||
| 9cd227fd81 | |||
| e4b49e1148 | |||
| a87ce65899 | |||
| 06e6e25b25 | |||
| d322363e53 | |||
| a20b682087 | |||
| ad86713fdc | |||
| 66525c66b7 | |||
| 7a484a09d4 | |||
| d72352911e | |||
| 134ab64bce | |||
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 |
@@ -26,5 +26,8 @@
|
|||||||
*.eot binary
|
*.eot binary
|
||||||
*.pdf binary
|
*.pdf binary
|
||||||
*.zip binary
|
*.zip binary
|
||||||
|
# Ski area graphs (SGR1) as the Places host writes them, byte for byte
|
||||||
|
*.sgr binary
|
||||||
|
*.gz binary
|
||||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||||
|
|||||||
+339
-144
@@ -9,23 +9,73 @@ permissions:
|
|||||||
# analysis; pull requests get PR analysis with decoration on the PR itself
|
# analysis; pull requests get PR analysis with decoration on the PR itself
|
||||||
# (the scanner picks the context up from the Actions environment).
|
# (the scanner picks the context up from the Actions environment).
|
||||||
#
|
#
|
||||||
# Job layout — three tiers:
|
# Job names read "<package>: <what>", so the checks list on a PR sorts by
|
||||||
# - Preflight: plugin-facts, i18n-parity and the quality jobs
|
# package. What each job holds:
|
||||||
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
|
#
|
||||||
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
|
# Repo: Changed Areas decides what the change needs to run (below)
|
||||||
# of them, so a gate failure stops the run before the expensive coverage
|
# Shared: i18n Parity every locale has en's files and keys
|
||||||
# fan-out spends ~15 runner-minutes on a result the gates already sank.
|
# Shared: Coverage shared's tests with coverage
|
||||||
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
|
# Server: Types builds shared and the server, typechecks shared,
|
||||||
# runs the MinIO contract suite. Coverage jobs run tests only — with the
|
# the server's src, tests and scripts, smokes the
|
||||||
# gates cleared in preflight, they can only fail because tests failed.
|
# production require chain, the strict ratchet,
|
||||||
# client-e2e drives the real app in Chromium and WebKit, client-bundle
|
# the generated entities and Kysely types
|
||||||
# builds the client and checks each map engine keeps a chunk of its own,
|
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
|
||||||
# and postgres-probe runs the dialect helpers and the repository
|
# Prettier, the plugin facts, the DB call graph,
|
||||||
# statements it reaches against a Postgres service. None of them feeds the scan.
|
# the shared ratchet code, the source ratchets
|
||||||
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
|
# (size, boundaries, dialect, tx, test SQL, test
|
||||||
# must stop the scan — a missing lcov file would make every line of that
|
# mocks, hand-built services, query API, MCP zod,
|
||||||
# package report as uncovered, and the dashboard would show a collapse
|
# error envelopes, response contracts), open
|
||||||
# that did not happen. (The preflight gates stop it transitively.)
|
# 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
|
# - 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
|
# 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
|
# (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.
|
# required check that never reports blocks the PR for good.
|
||||||
# - docker-image builds the Dockerfile and boots the result, and
|
# - docker-image builds the Dockerfile and boots the result, and
|
||||||
# deploy-manifests lints and renders the Helm chart and checks the
|
# 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
|
# - 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
|
# 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,
|
# was cancelled and passes when they passed or were skipped (by changes,
|
||||||
@@ -69,7 +119,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
name: Changed Areas
|
name: 'Repo: Changed Areas'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
@@ -144,36 +194,8 @@ jobs:
|
|||||||
node scripts/ci/changed-areas.mjs --all
|
node scripts/ci/changed-areas.mjs --all
|
||||||
fi
|
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:
|
i18n-parity:
|
||||||
name: i18n Key Parity
|
name: 'Shared: i18n Parity'
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -189,8 +211,8 @@ jobs:
|
|||||||
- name: Check i18n key parity
|
- name: Check i18n key parity
|
||||||
run: node shared/scripts/i18n-parity.mjs --strict
|
run: node shared/scripts/i18n-parity.mjs --strict
|
||||||
|
|
||||||
server-quality:
|
server-types:
|
||||||
name: Server Types & Lint
|
name: 'Server: Types'
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -239,8 +261,11 @@ jobs:
|
|||||||
# constructor parameter does not break its hand-wired call sites at
|
# constructor parameter does not break its hand-wired call sites at
|
||||||
# compile time: the argument arrives as undefined and throws at
|
# compile time: the argument arrives as undefined and throws at
|
||||||
# runtime, if anything reads it at all. This is the step that
|
# runtime, if anything reads it at all. This is the step that
|
||||||
# catches it.
|
# catches it. src and tests together are past 570,000 lines and need
|
||||||
|
# about 4.3 GB, over Node's default heap.
|
||||||
run: cd server && npm run typecheck:tests
|
run: cd server && npm run typecheck:tests
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
|
||||||
- name: Typecheck entity generator
|
- name: Typecheck entity generator
|
||||||
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
|
# tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts;
|
||||||
@@ -257,6 +282,36 @@ jobs:
|
|||||||
# migration that changes a column regenerates it in the same change.
|
# migration that changes a column regenerates it in the same change.
|
||||||
run: cd server && npm run check:db-types
|
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
|
- name: Lint
|
||||||
# Errors fail, and the warnings per rule may not pass
|
# Errors fail, and the warnings per rule may not pass
|
||||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||||
@@ -264,8 +319,19 @@ jobs:
|
|||||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
TREK_LINT_BASE: refs/remotes/lint-base
|
||||||
run: cd server && npm run lint:warnings
|
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
|
- name: Formatting check
|
||||||
# Prettier over src/ and tests/: a file outside
|
# Prettier over src/ and tests/: a file outside
|
||||||
# scripts/format-baseline.json must be formatted, and the list only
|
# scripts/format-baseline.json must be formatted, and the list only
|
||||||
@@ -359,12 +425,12 @@ jobs:
|
|||||||
# knip (knip.jsonc) over client, server and shared: unused files,
|
# knip (knip.jsonc) over client, server and shared: unused files,
|
||||||
# exports, types and dependencies per file may not rise above
|
# exports, types and dependencies per file may not rise above
|
||||||
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
|
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
|
||||||
# rather than in client-quality because it needs every workspace's
|
# rather than in client-types-checks because it needs every workspace's
|
||||||
# dependencies, which only this job installs.
|
# dependencies, which the client jobs do not install.
|
||||||
run: npm run lint:knip
|
run: npm run lint:knip
|
||||||
|
|
||||||
client-quality:
|
client-types-checks:
|
||||||
name: Client Types & Lint
|
name: 'Client: Types & Checks'
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -380,7 +446,7 @@ jobs:
|
|||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspace shared && npm ci --workspace client
|
run: npm ci --workspace shared --workspace client
|
||||||
|
|
||||||
- name: Build shared
|
- name: Build shared
|
||||||
run: npm run build --workspace=shared
|
run: npm run build --workspace=shared
|
||||||
@@ -395,15 +461,6 @@ jobs:
|
|||||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||||
run: cd client && npm run lint:strict
|
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
|
- name: Page pattern check
|
||||||
run: cd client && npm run lint:pages
|
run: cd client && npm run lint:pages
|
||||||
|
|
||||||
@@ -467,36 +524,13 @@ jobs:
|
|||||||
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
|
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
|
||||||
run: cd client && npm run theme:lint
|
run: cd client && npm run theme:lint
|
||||||
|
|
||||||
plugin-sdk-quality:
|
client-lint:
|
||||||
name: Plugin SDK Types
|
name: 'Client: Lint'
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -507,7 +541,47 @@ jobs:
|
|||||||
cache-dependency-path: package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- 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
|
- name: Build shared
|
||||||
run: npm run build --workspace=shared
|
run: npm run build --workspace=shared
|
||||||
@@ -523,10 +597,11 @@ jobs:
|
|||||||
run: cd client && npm run check:gl-split
|
run: cd client && npm run check:gl-split
|
||||||
|
|
||||||
client-e2e:
|
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
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -580,17 +655,17 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
s3-contract:
|
s3-contract:
|
||||||
name: S3 Driver Contract (MinIO)
|
name: 'Server: S3 Contract (MinIO)'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
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
|
# 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
|
# 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
|
# 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
|
# wall. Pushes and manual dispatches by anyone else do have secrets, so
|
||||||
# only those two are filtered out. (The implicit success() on this
|
# only those two are filtered out.
|
||||||
# expression also keeps the job skipped when a preflight fails.)
|
|
||||||
if: >-
|
if: >-
|
||||||
|
needs.changes.outputs.code == 'true' &&
|
||||||
github.actor != 'dependabot[bot]' &&
|
github.actor != 'dependabot[bot]' &&
|
||||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||||
|
|
||||||
@@ -606,14 +681,14 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
- name: Check @swc/core's native binding loads
|
||||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
# The server's SWC transform (server/vitest.config.ts) needs it. The
|
||||||
# optional native binary, so npm ci/install skips it on the runner.
|
# lockfile carries the Linux binary, so npm ci installs it; this only
|
||||||
# Install the matching version explicitly so the server's SWC transform
|
# names the cause if a lockfile regenerated without it ever lands. (It
|
||||||
# (server/vitest.config.ts) can load.
|
# used to be installed here with --legacy-peer-deps, which pruned
|
||||||
run: |
|
# eight peer packages and left the tests on a tree the lockfile does
|
||||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
# not describe.)
|
||||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
run: node -e "require('@swc/core').transformSync('let a = 1')"
|
||||||
|
|
||||||
- name: Build shared
|
- name: Build shared
|
||||||
run: npm run build --workspace=shared
|
run: npm run build --workspace=shared
|
||||||
@@ -671,10 +746,10 @@ jobs:
|
|||||||
# artifact seeds the file and a change that fixes a statement can lower it
|
# artifact seeds the file and a change that fixes a statement can lower it
|
||||||
# without a local Postgres.
|
# without a local Postgres.
|
||||||
postgres-probe:
|
postgres-probe:
|
||||||
name: Postgres Probe
|
name: 'Server: Postgres Probe'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
needs: changes
|
||||||
if: needs.changes.outputs.code == 'true'
|
if: needs.changes.outputs.code == 'true'
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -727,11 +802,20 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
server-coverage:
|
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
|
||||||
name: Server Coverage
|
# 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
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 30
|
||||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -744,20 +828,63 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
- name: Check @swc/core's native binding loads
|
||||||
# The lockfile was generated on Windows and omits @swc/core's Linux
|
# The server's SWC transform (server/vitest.config.ts) needs it. The
|
||||||
# optional native binary, so npm ci/install skips it on the runner.
|
# lockfile carries the Linux binary, so npm ci installs it; this only
|
||||||
# Install the matching version explicitly so the server's SWC transform
|
# names the cause if a lockfile regenerated without it ever lands. (It
|
||||||
# (server/vitest.config.ts) can load.
|
# used to be installed here with --legacy-peer-deps, which pruned
|
||||||
run: |
|
# eight peer packages and left the tests on a tree the lockfile does
|
||||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
# not describe.)
|
||||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
run: node -e "require('@swc/core').transformSync('let a = 1')"
|
||||||
|
|
||||||
- name: Build shared
|
- name: Build shared
|
||||||
run: npm run build --workspace=shared
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run a slice of the tests with coverage
|
||||||
run: cd server && npm run test: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
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
@@ -766,11 +893,18 @@ jobs:
|
|||||||
path: server/coverage/
|
path: server/coverage/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
client-coverage:
|
# The client suite in seven slices, the same way as server-tests. The two
|
||||||
name: Client Coverage
|
# 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
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 30
|
||||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [1, 2, 3, 4, 5, 6, 7]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -785,13 +919,56 @@ jobs:
|
|||||||
# @testing-library/react) in the tree without the re-resolution dance
|
# @testing-library/react) in the tree without the re-resolution dance
|
||||||
# the folded coverage job used to need.
|
# the folded coverage job used to need.
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspace shared && npm ci --workspace client
|
run: npm ci --workspace shared --workspace client
|
||||||
|
|
||||||
- name: Build shared
|
- name: Build shared
|
||||||
run: npm run build --workspace=shared
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run a slice of the tests with coverage
|
||||||
run: cd client && npm run test: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
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
@@ -801,10 +978,11 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
shared-coverage:
|
shared-coverage:
|
||||||
name: Shared Coverage
|
name: 'Shared: Coverage'
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.code == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
@@ -827,11 +1005,14 @@ jobs:
|
|||||||
path: shared/coverage/
|
path: shared/coverage/
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
plugin-sdk-coverage:
|
# Typecheck and tests in one job: each takes seconds, and a runner of its
|
||||||
name: Plugin SDK Coverage
|
# 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
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: plugin-sdk
|
working-directory: plugin-sdk
|
||||||
@@ -850,6 +1031,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: npm run test:coverage
|
run: npm run test:coverage
|
||||||
|
|
||||||
@@ -861,7 +1045,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
scan:
|
scan:
|
||||||
name: Scan
|
name: 'Repo: Sonar Scan'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
|
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
|
||||||
@@ -871,9 +1055,18 @@ jobs:
|
|||||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
(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
|
# 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
|
# 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
|
# show a collapse that did not happen. The gates run beside the tests now,
|
||||||
# transitively through these four, so they aren't repeated here.
|
# so they are listed here too, and a failed gate still stops the scan.
|
||||||
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
|
needs:
|
||||||
|
- i18n-parity
|
||||||
|
- server-types
|
||||||
|
- server-lint-checks
|
||||||
|
- client-types-checks
|
||||||
|
- client-lint
|
||||||
|
- server-coverage
|
||||||
|
- client-coverage
|
||||||
|
- shared-coverage
|
||||||
|
- plugin-sdk
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
@@ -934,7 +1127,7 @@ jobs:
|
|||||||
# reached dev unseen. It is separate from the vulnerability scan on purpose,
|
# 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.
|
# so a newly published CVE cannot hide a broken build or the other way round.
|
||||||
docker-image:
|
docker-image:
|
||||||
name: Docker Image
|
name: 'Repo: Docker Image'
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.image == 'true'
|
if: needs.changes.outputs.image == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -1011,7 +1204,7 @@ jobs:
|
|||||||
# the output checked against the Kubernetes API schemas. Both tools come
|
# the output checked against the Kubernetes API schemas. Both tools come
|
||||||
# from their release pages, pinned by version and checksum.
|
# from their release pages, pinned by version and checksum.
|
||||||
deploy-manifests:
|
deploy-manifests:
|
||||||
name: Helm Chart & Compose
|
name: 'Repo: Helm Chart & Compose'
|
||||||
needs: changes
|
needs: changes
|
||||||
if: needs.changes.outputs.deploy == 'true'
|
if: needs.changes.outputs.deploy == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -1082,19 +1275,21 @@ jobs:
|
|||||||
ci-ok:
|
ci-ok:
|
||||||
needs:
|
needs:
|
||||||
- changes
|
- changes
|
||||||
- plugin-facts
|
|
||||||
- i18n-parity
|
- i18n-parity
|
||||||
- server-quality
|
- server-types
|
||||||
- client-quality
|
- server-lint-checks
|
||||||
- plugin-sdk-quality
|
- client-types-checks
|
||||||
|
- client-lint
|
||||||
- client-bundle
|
- client-bundle
|
||||||
- client-e2e
|
- client-e2e
|
||||||
- s3-contract
|
- s3-contract
|
||||||
- postgres-probe
|
- postgres-probe
|
||||||
|
- server-tests
|
||||||
- server-coverage
|
- server-coverage
|
||||||
|
- client-tests
|
||||||
- client-coverage
|
- client-coverage
|
||||||
- shared-coverage
|
- shared-coverage
|
||||||
- plugin-sdk-coverage
|
- plugin-sdk
|
||||||
- docker-image
|
- docker-image
|
||||||
- deploy-manifests
|
- deploy-manifests
|
||||||
# always(): without it a failed dependency would skip this job, and the
|
# always(): without it a failed dependency would skip this job, and the
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ coverage
|
|||||||
coverage-*/
|
coverage-*/
|
||||||
*.lcov
|
*.lcov
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
# Blob reports of a sharded vitest run (test.yml)
|
||||||
|
.vitest-reports
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
.npm
|
.npm
|
||||||
|
|||||||
@@ -134,8 +134,10 @@ These principles come out of a full-repo audit and shape all new code:
|
|||||||
|
|
||||||
## Quality gates in CI
|
## 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:`.
|
- **`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).
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ env:
|
|||||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||||
# Defaults to 25000.
|
# Defaults to 25000.
|
||||||
# TREK_PLACES_ENABLED: "false"
|
# TREK_PLACES_ENABLED: "false"
|
||||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads,
|
||||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
# and the OpenStreetMap ways and horse routes Tours reads) asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||||
# here. Defaults to on.
|
# here. Defaults to on.
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 404,
|
"@typescript-eslint/no-non-null-assertion": 404,
|
||||||
"@typescript-eslint/no-misused-promises": 875,
|
"@typescript-eslint/no-misused-promises": 874,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 230,
|
"complexity": 230,
|
||||||
"@typescript-eslint/no-explicit-any": 147,
|
"@typescript-eslint/no-explicit-any": 147,
|
||||||
"trek/no-swallowed-catch": 133,
|
"trek/no-swallowed-catch": 133,
|
||||||
"max-params": 35,
|
"max-params": 35,
|
||||||
"trek/store-without-selector": 69,
|
"trek/store-without-selector": 69,
|
||||||
"react-hooks/refs": 559,
|
"react-hooks/refs": 558,
|
||||||
"react-hooks/set-state-in-effect": 197,
|
"react-hooks/set-state-in-effect": 197,
|
||||||
"@typescript-eslint/no-floating-promises": 167,
|
"@typescript-eslint/no-floating-promises": 167,
|
||||||
"react-hooks/exhaustive-deps": 121,
|
"react-hooks/exhaustive-deps": 121,
|
||||||
"@typescript-eslint/no-unused-vars": 130,
|
"@typescript-eslint/no-unused-vars": 129,
|
||||||
"react-hooks/immutability": 28,
|
"react-hooks/immutability": 28,
|
||||||
"max-lines-per-function": 133,
|
"max-lines-per-function": 133,
|
||||||
"react-hooks/static-components": 78,
|
"react-hooks/static-components": 78,
|
||||||
"react-hooks/preserve-manual-memoization": 15,
|
"react-hooks/preserve-manual-memoization": 12,
|
||||||
"no-useless-assignment": 7,
|
"no-useless-assignment": 7,
|
||||||
"no-empty": 19,
|
"no-empty": 19,
|
||||||
"trek/no-window-globals": 41,
|
"trek/no-window-globals": 41,
|
||||||
|
|||||||
@@ -223,6 +223,15 @@
|
|||||||
"desktop": ["src/pages/CollectionsPage.tsx"],
|
"desktop": ["src/pages/CollectionsPage.tsx"],
|
||||||
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver",
|
||||||
|
"hooks": [
|
||||||
|
"src/components/Tours/collection/useCollectionTours.ts",
|
||||||
|
"src/components/Tours/collection/useCollectionTourTakeOver.ts"
|
||||||
|
],
|
||||||
|
"desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/collections/MCollTours.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Copy collection places to a trip",
|
"feature": "Copy collection places to a trip",
|
||||||
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
|
||||||
@@ -571,6 +580,24 @@
|
|||||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
|
||||||
|
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
|
||||||
|
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "A tour's start and end in the day plan, its end worked out from its time, and an end that runs into the next timed item",
|
||||||
|
"hooks": ["src/components/Tours/tourDayTime.ts"],
|
||||||
|
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"feature": "A multi-day tour put on a day: the whole tour there, or a stage a day from that day on",
|
||||||
|
"hooks": ["src/components/Tours/stages/stageSpread.ts"],
|
||||||
|
"desktop": ["src/components/Tours/stages/TourDayChoiceDialog.tsx", "src/components/Tours/stages/TourDayAddButton.tsx"],
|
||||||
|
"mobile": ["src/mobile/screens/trip/sheets/MTourDayChoiceSheet.tsx"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"feature": "Reservation form incl. hotel accommodation creation",
|
"feature": "Reservation form incl. hotel accommodation creation",
|
||||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||||
*/
|
*/
|
||||||
import { ESLint } from 'eslint';
|
import { ESLint } from 'eslint';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import ts from 'typescript';
|
import ts from 'typescript';
|
||||||
import {
|
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);
|
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'),
|
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||||
update = false,
|
update = false,
|
||||||
lint = lintClient,
|
lint = lintClient,
|
||||||
|
lintText = lint === lintClient ? lintTextClient : null,
|
||||||
log = console.log,
|
log = console.log,
|
||||||
error = console.error,
|
error = console.error,
|
||||||
}) {
|
}) {
|
||||||
@@ -160,13 +246,13 @@ export async function check({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const line of errors) error(`FAIL ${line}`);
|
for (const line of errors) error(`FAIL ${line}`);
|
||||||
let grown = 0;
|
const grown = [];
|
||||||
let stale = 0;
|
let stale = 0;
|
||||||
for (const area of AREAS) {
|
for (const area of AREAS) {
|
||||||
for (const [rule, n] of Object.entries(counts[area])) {
|
for (const [rule, n] of Object.entries(counts[area])) {
|
||||||
const allowed = baseline[area][rule] ?? 0;
|
const allowed = baseline[area][rule] ?? 0;
|
||||||
if (n <= allowed) continue;
|
if (n <= allowed) continue;
|
||||||
grown++;
|
grown.push({ area, rule });
|
||||||
const top = Object.entries(files[area][rule])
|
const top = Object.entries(files[area][rule])
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
@@ -182,7 +268,8 @@ export async function check({
|
|||||||
});
|
});
|
||||||
stale += fallen.length;
|
stale += fallen.length;
|
||||||
}
|
}
|
||||||
if (grown) {
|
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||||
|
if (grown.length) {
|
||||||
error(
|
error(
|
||||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||||
'Run npx eslint <file> to see each one.'
|
'Run npx eslint <file> to see each one.'
|
||||||
@@ -191,5 +278,5 @@ export async function check({
|
|||||||
log(
|
log(
|
||||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
`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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
"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.test.tsx": 2920,
|
||||||
"src/components/Map/MapViewGL.tsx": 2299,
|
"src/components/Map/MapViewGL.tsx": 2293,
|
||||||
"src/components/Map/RouteCalculator.ts": 1195,
|
"src/components/Map/RouteCalculator.ts": 1195,
|
||||||
"src/components/PDF/TripPDF.tsx": 1082,
|
"src/components/PDF/TripPDF.tsx": 1082,
|
||||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
"src/components/Planner/DayPlanSidebar.tsx": 2963,
|
||||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
"src/components/Planner/PlaceFormModal.tsx": 1193,
|
||||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||||
"src/components/Studio/TravelElements.tsx": 1738,
|
"src/components/Studio/TravelElements.tsx": 1738,
|
||||||
"src/components/Studio/autoLayout.ts": 1691,
|
"src/components/Studio/autoLayout.ts": 1691,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||||
"src/pages/LoginPage.tsx": 1348,
|
"src/pages/LoginPage.tsx": 1348,
|
||||||
"src/pages/TripPlannerPage.tsx": 1142,
|
"src/pages/TripPlannerPage.tsx": 1140,
|
||||||
"src/pages/atlas/useAtlas.ts": 1287,
|
"src/pages/atlas/useAtlas.ts": 1287,
|
||||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// FE-API-COLLECTION-TOURS-001 to FE-API-COLLECTION-TOURS-002
|
||||||
|
//
|
||||||
|
// LIB-03: every method pins its verb, its path and its body, and hands back what
|
||||||
|
// the server answered.
|
||||||
|
import { http, HttpResponse, type JsonBodyType } from 'msw';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { collectionToursApi } from './collectionTours';
|
||||||
|
|
||||||
|
const BASE = '/api/addons/collections/3/tours';
|
||||||
|
|
||||||
|
let calls: Array<{ method: string; path: string; body: unknown }> = [];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
function record(data: JsonBodyType | null) {
|
||||||
|
return async ({ request }: { request: Request }) => {
|
||||||
|
const text = await request.text();
|
||||||
|
calls.push({ method: request.method, path: new URL(request.url).pathname, body: text ? JSON.parse(text) : null });
|
||||||
|
return data === null ? new HttpResponse(null, { status: 200 }) : HttpResponse.json(data);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collectionToursApi', () => {
|
||||||
|
it('FE-API-COLLECTION-TOURS-001: lists, reads, keeps and removes the tours of a collection', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(BASE, record({ tours: [] })),
|
||||||
|
http.get(`${BASE}/4`, record({ tour: { id: 4 }, line: [], points: [], trips: [] })),
|
||||||
|
http.post(BASE, record({ tour: { id: 5 } })),
|
||||||
|
http.delete(`${BASE}/4`, record({ success: true }))
|
||||||
|
);
|
||||||
|
expect(await collectionToursApi.list(3)).toEqual({ tours: [] });
|
||||||
|
expect((await collectionToursApi.detail(3, 4)).tour).toEqual({ id: 4 });
|
||||||
|
expect(await collectionToursApi.save(3, { source_trip_id: 2, source_place_id: 7, name: 'Kept' })).toEqual({
|
||||||
|
tour: { id: 5 },
|
||||||
|
});
|
||||||
|
expect(await collectionToursApi.remove(3, 4)).toBeUndefined();
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ method: 'GET', path: BASE, body: null },
|
||||||
|
{ method: 'GET', path: `${BASE}/4`, body: null },
|
||||||
|
{ method: 'POST', path: BASE, body: { source_trip_id: 2, source_place_id: 7, name: 'Kept' } },
|
||||||
|
{ method: 'DELETE', path: `${BASE}/4`, body: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-API-COLLECTION-TOURS-002: takes a kept tour into a trip with the choice about its points', async () => {
|
||||||
|
server.use(http.post('/api/trips/6/tours/from-collection', record({ tour: { place_id: 9 }, waypoints: [] })));
|
||||||
|
const body = { collection_id: 3, collection_tour_id: 4, include_waypoints: false };
|
||||||
|
expect((await collectionToursApi.takeOver(6, body)).tour).toEqual({ place_id: 9 });
|
||||||
|
expect(calls).toEqual([{ method: 'POST', path: '/api/trips/6/tours/from-collection', body }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type {
|
||||||
|
CollectionTourDetailResponse,
|
||||||
|
CollectionTourListResponse,
|
||||||
|
CollectionTourSaveRequest,
|
||||||
|
CollectionTourSaveResponse,
|
||||||
|
CollectionTourTakeOverRequest,
|
||||||
|
TourCreateResponse,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
const base = (collectionId: number) => `/addons/collections/${collectionId}/tours`;
|
||||||
|
|
||||||
|
/** LIB-03: the whole tours a collection keeps, keeping one, removing one and taking one into a trip. */
|
||||||
|
export const collectionToursApi = {
|
||||||
|
list: (collectionId: number, signal?: AbortSignal): Promise<CollectionTourListResponse> =>
|
||||||
|
apiClient.get(base(collectionId), { signal }).then((r) => r.data),
|
||||||
|
detail: (collectionId: number, tourId: number, signal?: AbortSignal): Promise<CollectionTourDetailResponse> =>
|
||||||
|
apiClient.get(`${base(collectionId)}/${tourId}`, { signal }).then((r) => r.data),
|
||||||
|
save: (collectionId: number, body: CollectionTourSaveRequest): Promise<CollectionTourSaveResponse> =>
|
||||||
|
apiClient.post(base(collectionId), body).then((r) => r.data),
|
||||||
|
remove: (collectionId: number, tourId: number): Promise<void> =>
|
||||||
|
apiClient.delete(`${base(collectionId)}/${tourId}`).then(() => undefined),
|
||||||
|
takeOver: (tripId: number, body: CollectionTourTakeOverRequest): Promise<TourCreateResponse> =>
|
||||||
|
apiClient.post(`/trips/${tripId}/tours/from-collection`, body).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// FE-API-HORSE-001 to FE-API-HORSE-002
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { horseRoutesApi } from './horseRoutes';
|
||||||
|
|
||||||
|
const reply = { routes: [], truncated: false, source: 'openstreetmap', attribution: '© OpenStreetMap contributors' };
|
||||||
|
const BBOX = '47.10000,11.20000,47.30000,11.50000';
|
||||||
|
|
||||||
|
describe('horseRoutesApi.list', () => {
|
||||||
|
it('FE-API-HORSE-001: asks GET /api/trips/:tripId/tours/horse-routes with the view and returns the body', async () => {
|
||||||
|
let url = '';
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/7/tours/horse-routes', ({ request }) => {
|
||||||
|
url = request.url;
|
||||||
|
return HttpResponse.json(reply);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(horseRoutesApi.list(7, BBOX)).resolves.toEqual(reply);
|
||||||
|
expect(new URL(url).searchParams.get('bbox')).toBe(BBOX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-API-HORSE-002: no Overpass mirror answering (502) rejects', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/7/tours/horse-routes', () =>
|
||||||
|
HttpResponse.json({ error: 'Horse route data is unavailable right now' }, { status: 502 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(horseRoutesApi.list(7, BBOX)).rejects.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the browser waits for GET /api/trips/:tripId/tours/horse-routes.
|
||||||
|
* The server races the Overpass mirrors under its own deadline (25 s by
|
||||||
|
* default) and answers 502 when none made it, so this sits just above it, as
|
||||||
|
* the via ferrata layer's wait does.
|
||||||
|
*/
|
||||||
|
const HORSE_ROUTES_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HOR-01 GET /api/trips/:tripId/tours/horse-routes: the signposted horse
|
||||||
|
* routes OpenStreetMap knows inside one map view. The answer comes back
|
||||||
|
* unparsed; repo/horseRouteRepo.ts checks it against the shared contract.
|
||||||
|
*/
|
||||||
|
export const horseRoutesApi = {
|
||||||
|
list: (tripId: number | string, bbox: string, signal?: AbortSignal): Promise<unknown> =>
|
||||||
|
apiClient
|
||||||
|
.get(`/trips/${tripId}/tours/horse-routes`, { params: { bbox }, signal, timeout: HORSE_ROUTES_TIMEOUT_MS })
|
||||||
|
.then((response) => response.data as unknown),
|
||||||
|
};
|
||||||
@@ -0,0 +1,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),
|
||||||
|
};
|
||||||
@@ -2,17 +2,19 @@ import {
|
|||||||
paddleRegionCatalogResponseSchema,
|
paddleRegionCatalogResponseSchema,
|
||||||
paddleRegionListResponseSchema,
|
paddleRegionListResponseSchema,
|
||||||
paddleRegionSchema,
|
paddleRegionSchema,
|
||||||
|
paddleRegionSettingsSchema,
|
||||||
successResponseSchema,
|
successResponseSchema,
|
||||||
type PaddleRegionRequest,
|
type PaddleRegionRequest,
|
||||||
|
type PaddleRegionSettings,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import apiClient from './client';
|
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). */
|
/** 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 };
|
const GEOFABRIK = { timeout: 30_000 };
|
||||||
|
|
||||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
|
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||||
export const paddleRegionsApi = {
|
export const paddleRegionsApi = {
|
||||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||||
catalog: (q: string, signal?: AbortSignal) =>
|
catalog: (q: string, signal?: AbortSignal) =>
|
||||||
apiClient
|
apiClient
|
||||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||||
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
|
|||||||
apiClient
|
apiClient
|
||||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||||
.then((r) => successResponseSchema.parse(r.data)),
|
.then((r) => successResponseSchema.parse(r.data)),
|
||||||
|
decline: (id: string) =>
|
||||||
|
apiClient
|
||||||
|
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
|
||||||
|
.then((r) => successResponseSchema.parse(r.data)),
|
||||||
|
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||||
|
saveSettings: (settings: PaddleRegionSettings) =>
|
||||||
|
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
||||||
|
export const paddleRouteApi = {
|
||||||
|
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
||||||
|
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { skiRouteResponseSchema, type SkiRouteInput, type SkiRouteResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** The piste router: a ski tour's legs planned on the piste data the server keeps, held to the contract. */
|
||||||
|
export const skiRouteApi = {
|
||||||
|
plan: (tripId: number | string, request: SkiRouteInput, signal?: AbortSignal): Promise<SkiRouteResponse> =>
|
||||||
|
apiClient.post(`/trips/${tripId}/ski/route`, request, { signal }).then((r) => skiRouteResponseSchema.parse(r.data)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { TourActivityAssignRequest, TourDetailResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** ACT-01: the recorded tracks of a tour, assigned and taken off; both answer the tour's detail. */
|
||||||
|
export const tourActivitiesApi = {
|
||||||
|
assign: (
|
||||||
|
tripId: number | string,
|
||||||
|
placeId: number | string,
|
||||||
|
body: TourActivityAssignRequest
|
||||||
|
): Promise<TourDetailResponse> =>
|
||||||
|
apiClient.put(`/trips/${tripId}/tours/${placeId}/activities`, body).then((r) => r.data),
|
||||||
|
unassign: (tripId: number | string, placeId: number | string, activityId: number): Promise<TourDetailResponse> =>
|
||||||
|
apiClient.delete(`/trips/${tripId}/tours/${placeId}/activities/${activityId}`).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { TourActivityExclusionRequest, TourActivityStatsResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** STA-02: the user's own tour activities across their trips, and leaving one out of them; both answer the sums. */
|
||||||
|
export const tourActivityStatsApi = {
|
||||||
|
get: (signal?: AbortSignal): Promise<TourActivityStatsResponse> =>
|
||||||
|
apiClient.get('/tours/activity-stats', { signal }).then((r) => r.data),
|
||||||
|
setExcluded: (body: TourActivityExclusionRequest): Promise<TourActivityStatsResponse> =>
|
||||||
|
apiClient.put('/tours/activity-stats/exclusions', body).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** An admin's switch for a map or a service 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');
|
||||||
|
/** SKI-02: the piste router, which plans ski tours on the Places host's piste data. */
|
||||||
|
export const skiRoutingAdminApi = tourBaseMapAdminApi('/admin/tours-ski-routing');
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||||
import { postMultipart } from './client';
|
import { postMultipart } from './client';
|
||||||
|
|
||||||
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
|
/** A GPX, KML or KMZ file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||||
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
@@ -9,7 +9,10 @@ function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
|||||||
return fd;
|
return fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
|
/**
|
||||||
|
* The Tours file import: a GPX, KML or KMZ file (IO-02) read for its preview
|
||||||
|
* first (PAD-02), then imported as the type chosen.
|
||||||
|
*/
|
||||||
export const tourGpxApi = {
|
export const tourGpxApi = {
|
||||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
||||||
|
|||||||
@@ -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,20 @@
|
|||||||
|
import type { TourDetailResponse, TourPackingLinkRequest, TourPackingLinkResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a tour's packing link lives; a write queued offline replays to the
|
||||||
|
* same path, `{id}` standing for a tour that has not synced yet.
|
||||||
|
*/
|
||||||
|
export function tourPackingLinkPath(tripId: number | string, placeId: number | string): string {
|
||||||
|
return `/trips/${tripId}/tours/${placeId}/packing-link`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P2D-XD-01: the packing list, or the category of it, a tour points at. */
|
||||||
|
export const tourPackingLinkApi = {
|
||||||
|
read: (tripId: number | string, placeId: number, signal?: AbortSignal): Promise<TourPackingLinkResponse> =>
|
||||||
|
apiClient.get(tourPackingLinkPath(tripId, placeId), { signal }).then((r) => r.data),
|
||||||
|
link: (tripId: number | string, placeId: number, body: TourPackingLinkRequest): Promise<TourDetailResponse> =>
|
||||||
|
apiClient.put(tourPackingLinkPath(tripId, placeId), body).then((r) => r.data),
|
||||||
|
unlink: (tripId: number | string, placeId: number): Promise<TourDetailResponse> =>
|
||||||
|
apiClient.delete(tourPackingLinkPath(tripId, placeId)).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { TourPoiSearchRequest, TourPoiSearchResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLN-02: places along a tour. A search runs several stretches against the map
|
||||||
|
* search on the server, each allowed its own timeout there, so it may take
|
||||||
|
* longer than the shared default.
|
||||||
|
*/
|
||||||
|
const POI_SEARCH_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
export const tourPoiSearchApi = {
|
||||||
|
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(`/trips/${tripId}/tours/poi-search`, body, { signal, timeout: POI_SEARCH_TIMEOUT_MS })
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { TourDetailResponse, TourSkiClassificationRequest } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** SKI-02: a ski track classified again, its discipline and cross-country technique. */
|
||||||
|
export const tourSkiApi = {
|
||||||
|
classify: (
|
||||||
|
tripId: number | string,
|
||||||
|
placeId: number | string,
|
||||||
|
body: TourSkiClassificationRequest
|
||||||
|
): Promise<TourDetailResponse> => apiClient.put(`/trips/${tripId}/tours/${placeId}/ski`, body).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BIK-05: the trail grades of the ways a mountain bike tour runs on. The
|
||||||
|
* server asks Overpass in parts, one after another, so a long tour may take
|
||||||
|
* longer than the shared default.
|
||||||
|
*/
|
||||||
|
const TRAIL_GRADE_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
export const tourTrailGradeApi = {
|
||||||
|
grades: (
|
||||||
|
tripId: number | string,
|
||||||
|
body: TourTrailGradeRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<TourTrailGradeResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS })
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
|
||||||
|
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
|
||||||
|
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
|
||||||
|
export const tourVisitsApi = {
|
||||||
|
list: (tripId: number | string): Promise<TourVisitListResponse> =>
|
||||||
|
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
|
||||||
|
update: (
|
||||||
|
tripId: number | string,
|
||||||
|
assignmentId: number,
|
||||||
|
stationKey: string,
|
||||||
|
body: TourVisitUpdateRequest
|
||||||
|
): Promise<TourVisitListResponse> =>
|
||||||
|
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW-07: a Google Maps walking route read into its stops. The server may
|
||||||
|
* follow a short link a few hops within its own deadline, so the call waits
|
||||||
|
* a little longer than that before it gives up.
|
||||||
|
*/
|
||||||
|
const WALK_IMPORT_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
export const tourWalkImportApi = {
|
||||||
|
preview: (
|
||||||
|
tripId: number | string,
|
||||||
|
body: WalkImportPreviewRequest,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<WalkImportPreviewResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminFeatureToggleResponse,
|
AdminFeatureToggleResponse,
|
||||||
TourSourceCheckResponse,
|
TourSourceCheckResponse,
|
||||||
|
TourSourceHintResponse,
|
||||||
WandererConnection,
|
WandererConnection,
|
||||||
WandererImportPreview,
|
WandererImportPreview,
|
||||||
WandererImportRequest,
|
WandererImportRequest,
|
||||||
@@ -11,6 +12,7 @@ import type {
|
|||||||
WandererSettingsRequest,
|
WandererSettingsRequest,
|
||||||
WandererTestRequest,
|
WandererTestRequest,
|
||||||
WandererTestResponse,
|
WandererTestResponse,
|
||||||
|
WandererWatch,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
@@ -31,6 +33,10 @@ export const wandererApi = {
|
|||||||
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
||||||
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
||||||
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
|
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
|
||||||
|
/** WAN-02: the optional background watch of the connection, with the open hints. */
|
||||||
|
getWatch: (): Promise<WandererWatch> => apiClient.get('/integrations/wanderer/watch').then((r) => r.data),
|
||||||
|
setWatch: (enabled: boolean): Promise<WandererWatch> =>
|
||||||
|
apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data),
|
||||||
|
|
||||||
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
|
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
|
||||||
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
|
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
|
||||||
@@ -43,6 +49,9 @@ export const wandererApi = {
|
|||||||
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
|
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
|
||||||
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
||||||
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
|
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
|
||||||
|
/** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */
|
||||||
|
sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise<TourSourceHintResponse> =>
|
||||||
|
apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The admin's switch for wanderer under the Tours addon. */
|
/** The admin's switch for wanderer under the Tours addon. */
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
|||||||
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||||
'tours:changed',
|
'tours:changed',
|
||||||
|
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
|
||||||
|
'tours:visits',
|
||||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||||
'collab:note:created',
|
'collab:note:created',
|
||||||
'collab:note:updated',
|
'collab:note:updated',
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ beforeEach(() => {
|
|||||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
||||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false })),
|
||||||
|
// The Tours switches load beside the list; unanswered, each would toast the same error as a failed list.
|
||||||
|
http.get('/api/admin/tours-hiking-map', () => HttpResponse.json({ enabled: false })),
|
||||||
|
http.get('/api/admin/tours-cycle-map', () => HttpResponse.json({ enabled: false })),
|
||||||
|
http.get('/api/admin/tours-ski-routing', () => HttpResponse.json({ enabled: false }))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||||
toursWanderer,
|
toursWanderer,
|
||||||
toggleToursWanderer,
|
toggleToursWanderer,
|
||||||
|
mapSwitches,
|
||||||
} = useAddonManager()
|
} = useAddonManager()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -155,13 +156,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
|||||||
}
|
}
|
||||||
if (addon.id === 'tours' && addon.enabled) {
|
if (addon.id === 'tours' && addon.enabled) {
|
||||||
return (
|
return (
|
||||||
<AddonSubRow
|
<>
|
||||||
icon={<WandererMark size={14} />}
|
<AddonSubRow
|
||||||
title={t('admin.toursWanderer.title')}
|
icon={<WandererMark size={14} />}
|
||||||
description={t('admin.toursWanderer.subtitle')}
|
title={t('admin.toursWanderer.title')}
|
||||||
enabled={toursWanderer}
|
description={t('admin.toursWanderer.subtitle')}
|
||||||
onToggle={() => void toggleToursWanderer()}
|
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,
|
// 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { adminApi } from '../../../api/client';
|
import { adminApi } from '../../../api/client';
|
||||||
|
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
|
||||||
|
import { wandererAdminApi } from '../../../api/wanderer';
|
||||||
import { useAddonStore } from '../../../store/addonStore';
|
import { useAddonStore } from '../../../store/addonStore';
|
||||||
import type { Addon } from './addonModel';
|
import type { Addon } from './addonModel';
|
||||||
import { useAddonManager } from './useAddonManager';
|
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() }));
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
@@ -35,6 +37,10 @@ beforeEach(() => {
|
|||||||
useAddonStore.setState({ loadAddons });
|
useAddonStore.setState({ loadAddons });
|
||||||
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
|
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
|
||||||
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
|
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 });
|
||||||
|
vi.spyOn(skiRoutingAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||||
});
|
});
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
@@ -46,6 +52,13 @@ async function loaded() {
|
|||||||
|
|
||||||
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
|
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' | 'ski') {
|
||||||
|
const found = manager.mapSwitches.find((entry) => entry.key === key);
|
||||||
|
if (!found) throw new Error(`no ${key} map switch`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
describe('useAddonManager', () => {
|
describe('useAddonManager', () => {
|
||||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
||||||
const { result, rerender } = await loaded();
|
const { result, rerender } = await loaded();
|
||||||
@@ -115,4 +128,43 @@ describe('useAddonManager', () => {
|
|||||||
await act(() => result.current.documentProviderOptions[0].toggle());
|
await act(() => result.current.documentProviderOptions[0].toggle());
|
||||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
|
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-010: the piste router switch under Tours loads on, flips and saves on its own (SKI-02)', async () => {
|
||||||
|
const update = vi.spyOn(skiRoutingAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||||
|
const { result } = await loaded();
|
||||||
|
await waitFor(() => expect(mapSwitch(result.current, 'ski').enabled).toBe(true));
|
||||||
|
await act(() => mapSwitch(result.current, 'ski').toggle());
|
||||||
|
expect(update).toHaveBeenCalledWith(false);
|
||||||
|
expect(mapSwitch(result.current, 'ski').enabled).toBe(false);
|
||||||
|
expect(mapSwitch(result.current, 'cycle').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,12 +1,27 @@
|
|||||||
|
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||||
|
import { Bike, Footprints, MountainSnow } from 'lucide-react';
|
||||||
import { useEffect, useEffectEvent, useState } from 'react';
|
import { useEffect, useEffectEvent, useState } from 'react';
|
||||||
|
|
||||||
import { adminApi } from '../../../api/client';
|
import { adminApi } from '../../../api/client';
|
||||||
|
import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../../api/tourBaseMaps';
|
||||||
import { wandererAdminApi } from '../../../api/wanderer';
|
import { wandererAdminApi } from '../../../api/wanderer';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { useAddonStore } from '../../../store/addonStore';
|
import { useAddonStore } from '../../../store/addonStore';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||||
|
|
||||||
|
/** A switch under Tours as the server keeps it, read into `set`; `onError` tells the admin it could not be read. */
|
||||||
|
function loadToursFlag(
|
||||||
|
api: { get: () => Promise<AdminFeatureToggleResponse> },
|
||||||
|
set: (enabled: boolean) => void,
|
||||||
|
onError: () => void
|
||||||
|
) {
|
||||||
|
api
|
||||||
|
.get()
|
||||||
|
.then((flag) => set(flag.enabled))
|
||||||
|
.catch(onError);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The addon switches behind both admin shells: the addon list loaded on mount, an
|
* The addon switches behind both admin shells: the addon list loaded on mount, an
|
||||||
* optimistic toggle with a per row rollback, and the lists each shell groups its tiles
|
* optimistic toggle with a per row rollback, and the lists each shell groups its tiles
|
||||||
@@ -20,6 +35,9 @@ export function useAddonManager() {
|
|||||||
const [addons, setAddons] = useState<Addon[]>([]);
|
const [addons, setAddons] = useState<Addon[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [toursWanderer, setToursWanderer] = useState(false);
|
const [toursWanderer, setToursWanderer] = useState(false);
|
||||||
|
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||||
|
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||||
|
const [toursSkiRouting, setToursSkiRouting] = useState(false);
|
||||||
|
|
||||||
const loadAddons = async () => {
|
const loadAddons = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -32,10 +50,11 @@ export function useAddonManager() {
|
|||||||
|
|
||||||
const loadOnMount = useEffectEvent(() => {
|
const loadOnMount = useEffectEvent(() => {
|
||||||
void loadAddons().finally(() => setLoading(false));
|
void loadAddons().finally(() => setLoading(false));
|
||||||
wandererAdminApi
|
const unreadable = () => toast.error(t('admin.addons.toast.error'));
|
||||||
.get()
|
loadToursFlag(wandererAdminApi, setToursWanderer, unreadable);
|
||||||
.then((flag) => setToursWanderer(flag.enabled))
|
loadToursFlag(hikingMapAdminApi, setToursHikingMap, unreadable);
|
||||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
loadToursFlag(cycleMapAdminApi, setToursCycleMap, unreadable);
|
||||||
|
loadToursFlag(skiRoutingAdminApi, setToursSkiRouting, unreadable);
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadOnMount();
|
loadOnMount();
|
||||||
@@ -63,20 +82,52 @@ export function useAddonManager() {
|
|||||||
toast.success(t('admin.addons.toast.updated'));
|
toast.success(t('admin.addons.toast.updated'));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** wanderer under Tours: flipped at once, put back when the save fails. */
|
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
|
||||||
const toggleToursWanderer = async () => {
|
const flipToursSwitch = async (
|
||||||
const next = !toursWanderer;
|
current: boolean,
|
||||||
setToursWanderer(next);
|
set: (enabled: boolean) => void,
|
||||||
|
save: (enabled: boolean) => Promise<unknown>
|
||||||
|
) => {
|
||||||
|
const next = !current;
|
||||||
|
set(next);
|
||||||
try {
|
try {
|
||||||
await wandererAdminApi.update(next);
|
await save(next);
|
||||||
} catch {
|
} catch {
|
||||||
setToursWanderer(!next);
|
set(!next);
|
||||||
toast.error(t('admin.addons.toast.error'));
|
toast.error(t('admin.addons.toast.error'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshGlobalAddons();
|
void refreshGlobalAddons();
|
||||||
toast.success(t('admin.addons.toast.updated'));
|
toast.success(t('admin.addons.toast.updated'));
|
||||||
};
|
};
|
||||||
|
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
|
||||||
|
/** The maps and the piste router Tours offers on their operators' terms, one switch each (MAP-01, BIK-06, SKI-02). */
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ski',
|
||||||
|
Icon: MountainSnow,
|
||||||
|
titleKey: 'admin.toursSkiRouting.title',
|
||||||
|
subtitleKey: 'admin.toursSkiRouting.subtitle',
|
||||||
|
enabled: toursSkiRouting,
|
||||||
|
toggle: () => flipToursSwitch(toursSkiRouting, setToursSkiRouting, skiRoutingAdminApi.update),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
const asOption = (provider: Addon): ProviderOption => ({
|
const asOption = (provider: Addon): ProviderOption => ({
|
||||||
key: provider.id,
|
key: provider.id,
|
||||||
@@ -106,5 +157,6 @@ export function useAddonManager() {
|
|||||||
documentProviderOptions,
|
documentProviderOptions,
|
||||||
toursWanderer,
|
toursWanderer,
|
||||||
toggleToursWanderer,
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
// 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).
|
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||||
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
|||||||
id: 'oberpfalz',
|
id: 'oberpfalz',
|
||||||
name: 'Oberpfalz',
|
name: 'Oberpfalz',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
origin: 'admin',
|
||||||
source_timestamp: '2026-10-03T12:00:00Z',
|
source_timestamp: '2026-10-03T12:00:00Z',
|
||||||
imported_at: '2026-10-04T08:00:00Z',
|
imported_at: '2026-10-04T08:00:00Z',
|
||||||
feature_count: 1234,
|
feature_count: 1234,
|
||||||
error: null,
|
error: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
|
network: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
|||||||
toursOn: true,
|
toursOn: true,
|
||||||
offline: false,
|
offline: false,
|
||||||
regions: [],
|
regions: [],
|
||||||
|
requests: [],
|
||||||
|
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
retry: vi.fn(),
|
retry: vi.fn(),
|
||||||
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
|||||||
busyId: null,
|
busyId: null,
|
||||||
add: vi.fn(async () => {}),
|
add: vi.fn(async () => {}),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
|
approve: vi.fn(async () => {}),
|
||||||
|
decline: vi.fn(async () => {}),
|
||||||
removing: null,
|
removing: null,
|
||||||
askRemove: vi.fn(),
|
askRemove: vi.fn(),
|
||||||
cancelRemove: vi.fn(),
|
cancelRemove: vi.fn(),
|
||||||
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('paddleRegionsModel', () => {
|
||||||
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
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: 'queued' })).toBe(true);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
|
|||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
|
||||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||||
|
|
||||||
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||||
* picked from Geofabrik's list. Both the desktop and the phone admin show
|
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||||
* this one card under the addons, while the Tours addon is on.
|
* 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() {
|
export default function PaddleRegionsPanel() {
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +70,8 @@ function Panel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
<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} />
|
<CatalogSearch admin={admin} />
|
||||||
<RegionList admin={admin} />
|
<RegionList admin={admin} />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }) {
|
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||||
const { t, language } = useTranslation();
|
const { t, language } = useTranslation();
|
||||||
const busy = admin.busyId === region.id;
|
const busy = admin.busyId === region.id;
|
||||||
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex flex-wrap items-center gap-1.5">
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
<RegionStatus region={region} />
|
<RegionStatus region={region} />
|
||||||
|
{region.origin === 'auto' && <AutoOriginPill />}
|
||||||
{region.imported_at && (
|
{region.imported_at && (
|
||||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||||
)}
|
)}
|
||||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||||
|
<RegionNetwork region={region} />
|
||||||
</span>
|
</span>
|
||||||
{region.status === 'failed' && region.error && (
|
{region.status === 'failed' && region.error && (
|
||||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
|
|||||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
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() }));
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||||
paddleRegionRepo: repo,
|
// 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 }));
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
|
|||||||
id,
|
id,
|
||||||
name: id,
|
name: id,
|
||||||
status,
|
status,
|
||||||
|
origin: 'admin',
|
||||||
source_timestamp: null,
|
source_timestamp: null,
|
||||||
imported_at: null,
|
imported_at: null,
|
||||||
feature_count: 0,
|
feature_count: 0,
|
||||||
error: null,
|
error: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
|
network: null,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
|
|||||||
import { getApiErrorMessage } from '../../../types';
|
import { getApiErrorMessage } from '../../../types';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { isReading } from './paddleRegionsModel';
|
import { isReading } from './paddleRegionsModel';
|
||||||
|
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
|
||||||
|
|
||||||
/** How often the list is asked again while a region waits or is read. */
|
/** 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;
|
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
||||||
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regions as the server holds them, asked again every few seconds while
|
* The regions as the server holds them, with those paddlers asked for, asked
|
||||||
* any of them is read, and on `reload` after a change. Every answer is set
|
* 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.
|
* from the request's own callback, so a slow one never overwrites a newer.
|
||||||
*/
|
*/
|
||||||
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||||
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
||||||
|
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [asked, setAsked] = useState(0);
|
const [asked, setAsked] = useState(0);
|
||||||
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
|||||||
paddleRegionRepo.list().then(
|
paddleRegionRepo.list().then(
|
||||||
(list) => {
|
(list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRegions(list);
|
setRegions(list.regions);
|
||||||
|
setRequests(list.requests);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
},
|
},
|
||||||
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [active, offline, reading]);
|
}, [active, offline, reading]);
|
||||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||||
return { regions, loading: !loaded && !offline, error, reload };
|
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||||
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
|
|||||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
* 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
|
* 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
|
* after a confirmation. Shown only while the Tours addon is on; the status of
|
||||||
* a read follows without reloading the page.
|
* 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() {
|
export function usePaddleRegionsAdmin() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
|
|||||||
const { offline } = useNetworkMode();
|
const { offline } = useNetworkMode();
|
||||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||||
const list = useRegionList(toursOn, offline, t);
|
const list = useRegionList(toursOn, offline, t);
|
||||||
|
const autoLoad = usePaddleAutoLoad(toursOn, offline);
|
||||||
const search = useCatalogSearch(offline, t);
|
const search = useCatalogSearch(offline, t);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||||
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
|
|||||||
[run, setQuery, t]
|
[run, setQuery, t]
|
||||||
);
|
);
|
||||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
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 confirmRemove = useCallback(() => {
|
||||||
const region = removing;
|
const region = removing;
|
||||||
setRemoving(null);
|
setRemoving(null);
|
||||||
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
|
|||||||
toursOn,
|
toursOn,
|
||||||
offline,
|
offline,
|
||||||
regions: list.regions,
|
regions: list.regions,
|
||||||
|
requests: list.requests,
|
||||||
|
autoLoad,
|
||||||
loading: list.loading,
|
loading: list.loading,
|
||||||
error: list.error,
|
error: list.error,
|
||||||
retry: reload,
|
retry: reload,
|
||||||
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
|
|||||||
busyId,
|
busyId,
|
||||||
add,
|
add,
|
||||||
refresh,
|
refresh,
|
||||||
|
approve,
|
||||||
|
decline,
|
||||||
removing,
|
removing,
|
||||||
askRemove: setRemoving,
|
askRemove: setRemoving,
|
||||||
cancelRemove: () => setRemoving(null),
|
cancelRemove: () => setRemoving(null),
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// FE-STA2-PANE-001 to FE-STA2-PANE-006
|
||||||
|
// STA-02: the Atlas tab of the user's own tour activities: the distance large
|
||||||
|
// with how many tours at its corner, the climb, the types, what the numbers
|
||||||
|
// rest on, and the full list in a dialog.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||||
|
import { render, screen } from '../../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||||
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
|
import { AtlasTourActivityPane } from './AtlasTourActivityPane';
|
||||||
|
import type { TourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
const hook = vi.hoisted(() => ({ current: null as unknown, active: [] as boolean[] }));
|
||||||
|
vi.mock('./useTourActivityStats', () => ({
|
||||||
|
useTourActivityStats: (active: boolean) => {
|
||||||
|
hook.active.push(active);
|
||||||
|
return hook.current;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TODAY = '2026-10-20';
|
||||||
|
|
||||||
|
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||||
|
return {
|
||||||
|
place_id: 1,
|
||||||
|
name: 'Ridge',
|
||||||
|
tour_type: 'hike',
|
||||||
|
trip: { id: 5, title: 'Alps' },
|
||||||
|
derived_from_place_id: null,
|
||||||
|
date: '2026-07-02',
|
||||||
|
basis: 'plan',
|
||||||
|
track: null,
|
||||||
|
day_count: 1,
|
||||||
|
distance_km: 12,
|
||||||
|
ascent_m: 800,
|
||||||
|
descent_m: 790,
|
||||||
|
excluded: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsOf(data: TourActivityStatsResponse | null, overrides: Partial<TourActivityStats> = {}) {
|
||||||
|
const stats: TourActivityStats = {
|
||||||
|
status: data ? 'ready' : 'loading',
|
||||||
|
data,
|
||||||
|
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
|
||||||
|
period: 'all',
|
||||||
|
setPeriod: vi.fn(),
|
||||||
|
periods: ['all', 'thisYear', 'last12'],
|
||||||
|
type: 'all',
|
||||||
|
setType: vi.fn(),
|
||||||
|
types: ['all', 'hike', 'kayak'],
|
||||||
|
pending: new Set(),
|
||||||
|
setExcluded: vi.fn().mockResolvedValue(undefined),
|
||||||
|
retry: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
hook.current = stats;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TourActivityStatsResponse = {
|
||||||
|
entries: [
|
||||||
|
entry(),
|
||||||
|
entry({ place_id: 2, name: 'Summit', date: '2026-08-01', distance_km: 20.5, ascent_m: 1200, descent_m: 1200 }),
|
||||||
|
entry({
|
||||||
|
place_id: 3,
|
||||||
|
name: 'Lake',
|
||||||
|
tour_type: 'kayak',
|
||||||
|
basis: 'track',
|
||||||
|
track: { place_id: 9, name: 'Watch' },
|
||||||
|
distance_km: 8,
|
||||||
|
ascent_m: null,
|
||||||
|
descent_m: null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
uncounted: { unassigned: 0, undated: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
hook.active = [];
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AtlasTourActivityPane', () => {
|
||||||
|
it('FE-STA2-PANE-001: follows whether the tab is shown, silent before, loading until the read is there', () => {
|
||||||
|
statsOf(null, { status: 'idle' });
|
||||||
|
const { rerender } = render(<AtlasTourActivityPane active={false} />);
|
||||||
|
expect(hook.active).toEqual([false]);
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||||
|
statsOf(null);
|
||||||
|
rerender(<AtlasTourActivityPane active />);
|
||||||
|
expect(hook.active).toEqual([false, true]);
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-PANE-002: shows the distance large with the tour count at its corner, and the climb', () => {
|
||||||
|
statsOf(data);
|
||||||
|
render(<AtlasTourActivityPane active />);
|
||||||
|
expect(screen.getByTestId('atlas-tour-activity-distance')).toHaveTextContent('40.5km');
|
||||||
|
expect(screen.getByTestId('atlas-tour-activity-count')).toHaveTextContent('3');
|
||||||
|
expect(screen.getByTestId('atlas-tour-activity-ascent')).toHaveTextContent('2000m');
|
||||||
|
expect(screen.getByTestId('atlas-tour-activity-type-hike')).toHaveTextContent('2');
|
||||||
|
expect(screen.getByTestId('atlas-tour-activity-type-kayak')).toHaveTextContent('8 km');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-PANE-003: says what the numbers rest on and what does not count', () => {
|
||||||
|
statsOf(data);
|
||||||
|
render(<AtlasTourActivityPane active />);
|
||||||
|
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('Planned2');
|
||||||
|
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('Recorded1');
|
||||||
|
expect(screen.getByTestId('tour-activity-undated')).toHaveTextContent('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-PANE-004: one type chosen leaves the type columns out; nothing done says so', () => {
|
||||||
|
statsOf(data, { type: 'hike', summary: summarizeTourActivities(data.entries, { today: TODAY, type: 'hike' }) });
|
||||||
|
const { rerender } = render(<AtlasTourActivityPane active />);
|
||||||
|
expect(screen.queryByTestId('atlas-tour-activity-type-hike')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
statsOf(data, { summary: summarizeTourActivities(data.entries, { today: TODAY, from: '2027-01-01' }) });
|
||||||
|
rerender(<AtlasTourActivityPane active />);
|
||||||
|
expect(screen.getByText('No completed tours in this period')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Show tours' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-PANE-005: the full list opens in the dialog', async () => {
|
||||||
|
statsOf(data);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AtlasTourActivityPane active />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Show tours' }));
|
||||||
|
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tour-activity-list')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-PANE-006: offline and failed reads say so, a failed one with a retry', async () => {
|
||||||
|
statsOf(null, { status: 'offline' });
|
||||||
|
const { rerender } = render(<AtlasTourActivityPane active />);
|
||||||
|
expect(screen.getByText(/needs a connection/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const failed = statsOf(null, { status: 'error' });
|
||||||
|
rerender(<AtlasTourActivityPane active />);
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Tour activities could not be loaded');
|
||||||
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }));
|
||||||
|
expect(failed.retry).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import type { TourActivitySummary } from '@trek/shared';
|
||||||
|
import { Footprints, ListOrdered } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import type { DistanceUnit } from '../../../types';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { CardAction, InfoDot } from '../../Tours/tourParts';
|
||||||
|
import { ActivityFilters, ActivityStatusLine, BasisBadges, UncountedBadges } from './tourActivityParts';
|
||||||
|
import { distanceReading, heightReading, typeIcon, typeLabel, useDistanceUnit } from './tourActivityReading';
|
||||||
|
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||||
|
import { splitMeasure } from './tourActivityStatsModel';
|
||||||
|
import { useTourActivityStats, type TourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
/** The wash the hero tile sits on, as the Atlas numbers do: the text colour, faintly, over the glass. */
|
||||||
|
const HERO_TINT = 'color-mix(in srgb, var(--text-primary) 7%, transparent)';
|
||||||
|
/** How many types the strip names before the dialog takes over. */
|
||||||
|
const STRIP_TYPES = 3;
|
||||||
|
const EYEBROW = 'mt-1.5 whitespace-nowrap font-semibold uppercase tracking-wide text-content-faint';
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <span aria-hidden="true" className="mx-3 my-3 w-0.5 flex-none rounded-full bg-edge-faint" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The distance of the period, large, with how many tours at its corner; the
|
||||||
|
* tour count stands in when no tour knows its distance.
|
||||||
|
*/
|
||||||
|
function Hero({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const reading = distanceReading(summary.totals, unit);
|
||||||
|
const shown = reading ? splitMeasure(reading.text) : { value: String(summary.totals.count), unit: '' };
|
||||||
|
const countLabel = `${t('tours.mode.tours')}: ${summary.totals.count}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative mx-2 my-2 flex flex-col justify-center rounded-xl px-5 py-4"
|
||||||
|
style={{ background: HERO_TINT }}
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-1.5" data-testid="atlas-tour-activity-distance">
|
||||||
|
<span className="font-black tabular-nums leading-none text-content" style={fs(40, 'title')}>
|
||||||
|
{shown.value}
|
||||||
|
</span>
|
||||||
|
{shown.unit && (
|
||||||
|
<span className="font-medium text-content-muted" style={fs(14, 'body')}>
|
||||||
|
{shown.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={EYEBROW} style={fs(9)}>
|
||||||
|
{reading ? t('tours.detail.distance') : t('tours.mode.tours')}
|
||||||
|
</span>
|
||||||
|
{reading && (
|
||||||
|
<Tooltip label={countLabel}>
|
||||||
|
<span
|
||||||
|
aria-label={countLabel}
|
||||||
|
data-testid="atlas-tour-activity-count"
|
||||||
|
className="absolute -end-2 -top-2 inline-flex h-[22px] items-center gap-1 rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||||
|
style={fs(11)}
|
||||||
|
>
|
||||||
|
<Footprints size={11} strokeWidth={2.2} className="flex-none text-content-muted" />
|
||||||
|
{summary.totals.count}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A height sum as the Atlas numbers stand: the value, its unit, and what it is below. */
|
||||||
|
function Figure({ label, text, testId }: { label: string; text: string; testId: string }) {
|
||||||
|
const { value, unit } = splitMeasure(text);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-none flex-col items-center justify-center px-3 py-4" data-testid={testId}>
|
||||||
|
<span className="flex items-baseline gap-1">
|
||||||
|
<span className="font-black tabular-nums leading-none text-content" style={fs(24, 'subtitle')}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-content-muted" style={fs(11, 'body')}>
|
||||||
|
{unit}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={EYEBROW} style={fs(9)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The types of the period, the most tours first: each an icon with its count at the corner, its distance below. */
|
||||||
|
function TypeColumns({ summary, unit }: { summary: TourActivitySummary; unit: DistanceUnit }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-none items-center gap-4 px-2 py-3">
|
||||||
|
{summary.types.slice(0, STRIP_TYPES).map((scope) => {
|
||||||
|
const Icon = typeIcon(scope.type);
|
||||||
|
const distance = distanceReading(scope, unit);
|
||||||
|
const label = `${typeLabel(scope.type, t)}: ${scope.count}${distance ? `, ${distance.text}` : ''}`;
|
||||||
|
return (
|
||||||
|
<Tooltip key={scope.type} label={label}>
|
||||||
|
<span
|
||||||
|
aria-label={label}
|
||||||
|
className="flex flex-col items-center gap-1.5"
|
||||||
|
data-testid={`atlas-tour-activity-type-${scope.type}`}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||||
|
<Icon size={15} strokeWidth={1.9} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
|
||||||
|
style={fs(9)}
|
||||||
|
>
|
||||||
|
{scope.count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{distance && (
|
||||||
|
<span
|
||||||
|
className="whitespace-nowrap font-semibold tabular-nums text-content-secondary"
|
||||||
|
style={fs(11, 'body')}
|
||||||
|
>
|
||||||
|
{distance.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the sums rest on, what does not count, and the way into the full list. */
|
||||||
|
interface StripProps {
|
||||||
|
stats: TourActivityStats;
|
||||||
|
summary: TourActivitySummary;
|
||||||
|
onOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideColumn({ stats, summary, onOpen }: StripProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const uncounted = stats.data?.uncounted ?? { unassigned: 0, undated: 0 };
|
||||||
|
return (
|
||||||
|
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<BasisBadges byBasis={summary.totals.byBasis} />
|
||||||
|
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<UncountedBadges upcoming={summary.upcoming} {...uncounted} />
|
||||||
|
</div>
|
||||||
|
{summary.done.length > 0 && (
|
||||||
|
<CardAction icon={<ListOrdered size={13} strokeWidth={2.2} />} onClick={onOpen}>
|
||||||
|
{t('tours.stats.showTours')}
|
||||||
|
</CardAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The numbers of the period, or why there are none, with the filters before them. */
|
||||||
|
function Strip({ stats, summary, onOpen }: StripProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unit = useDistanceUnit();
|
||||||
|
const ascent = heightReading(summary.totals.ascentM, unit);
|
||||||
|
const descent = heightReading(summary.totals.descentM, unit);
|
||||||
|
const counted = summary.totals.count > 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-none flex-col items-start justify-center gap-1.5 px-3 py-3">
|
||||||
|
<ActivityFilters stats={stats} quiet />
|
||||||
|
</div>
|
||||||
|
{counted ? (
|
||||||
|
<>
|
||||||
|
<Hero summary={summary} unit={unit} />
|
||||||
|
{ascent && <Figure label={t('tours.detail.ascent')} text={ascent.text} testId="atlas-tour-activity-ascent" />}
|
||||||
|
{descent && (
|
||||||
|
<Figure label={t('tours.detail.descent')} text={descent.text} testId="atlas-tour-activity-descent" />
|
||||||
|
)}
|
||||||
|
{stats.type === 'all' && summary.types.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<TypeColumns summary={summary} unit={unit} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="m-0 flex max-w-[260px] items-center px-4 py-5 text-content-muted" style={fs(12, 'body')}>
|
||||||
|
{t(stats.data?.entries.length ? 'tours.stats.none' : 'tours.stats.empty')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
|
<SideColumn stats={stats} summary={summary} onOpen={onOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STA-02: the Atlas tab of the user's own tour activities: the period and type,
|
||||||
|
* the distance done with how many tours at its corner, the climb, the types,
|
||||||
|
* and what the numbers rest on (the trip planning, or recorded tracks where
|
||||||
|
* linked). The full list, where a tour can be left out, opens in a dialog. The
|
||||||
|
* read waits until the tab is first shown.
|
||||||
|
*/
|
||||||
|
export function AtlasTourActivityPane({ active, maxWidth }: { active: boolean; maxWidth?: number }) {
|
||||||
|
const stats = useTourActivityStats(active);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex w-full items-stretch justify-center overflow-x-auto px-2"
|
||||||
|
style={{ maxWidth }}
|
||||||
|
data-testid="atlas-tour-activity"
|
||||||
|
>
|
||||||
|
{stats.summary ? (
|
||||||
|
<Strip stats={stats} summary={stats.summary} onOpen={() => setOpen(true)} />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center px-6 py-5">
|
||||||
|
<ActivityStatusLine stats={stats} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TourActivityStatsDialog stats={stats} open={open} onClose={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import type { TourActivityStatsEntry } from '@trek/shared';
|
||||||
|
import { Ban, Briefcase, CalendarDays, Footprints, Loader2, Repeat, RotateCcw } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { formatDate } from '../../../utils/formatters';
|
||||||
|
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||||
|
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { EntryTile, MeasurePills } from './tourActivityParts';
|
||||||
|
import { useDistanceUnit } from './tourActivityReading';
|
||||||
|
|
||||||
|
const ROW_BADGE_ICON = 'flex-none text-content-faint';
|
||||||
|
|
||||||
|
/** What the entry's values rest on, as a pill: the plan, or the track that replaced it, named in the tooltip. */
|
||||||
|
function BasisPill({ entry }: { entry: TourActivityStatsEntry }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const track = entry.basis === 'track';
|
||||||
|
const hint = track
|
||||||
|
? t('tours.stats.basisTrackHint', { name: entry.track?.name ?? '' })
|
||||||
|
: t('tours.stats.basisPlanHint');
|
||||||
|
return (
|
||||||
|
<Tooltip label={hint}>
|
||||||
|
<span
|
||||||
|
className="inline-flex"
|
||||||
|
aria-label={hint}
|
||||||
|
data-testid={`tour-activity-basis-${entry.basis}-${entry.place_id}`}
|
||||||
|
>
|
||||||
|
<SoftPill
|
||||||
|
tone={track ? 'success' : 'neutral'}
|
||||||
|
icon={
|
||||||
|
track ? (
|
||||||
|
<Footprints size={10} strokeWidth={2.2} className="flex-none" />
|
||||||
|
) : (
|
||||||
|
<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(track ? 'tours.stats.basisTrack' : 'tours.stats.basisPlan')}
|
||||||
|
</SoftPill>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day it counts on, the trip it is in (a way into that trip), and how many days the plan has it on. */
|
||||||
|
function EntryFacts({ entry }: { entry: TourActivityStatsEntry }) {
|
||||||
|
const { t, locale } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
|
||||||
|
<WhiteBadge icon={<CalendarDays size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
|
||||||
|
{formatDate(entry.date, locale) ?? entry.date}
|
||||||
|
</WhiteBadge>
|
||||||
|
<Tooltip label={t('tours.stats.openTrip')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void navigate(`/trips/${entry.trip.id}`)}
|
||||||
|
className="inline-flex min-w-0 max-w-[220px] rounded-full transition-opacity hover:opacity-75 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<WhiteBadge
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
|
icon={<Briefcase size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}
|
||||||
|
>
|
||||||
|
<span className="truncate">{entry.trip.title}</span>
|
||||||
|
</WhiteBadge>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
{entry.day_count > 1 && (
|
||||||
|
<Tooltip label={t('tours.stats.multiDayHint')}>
|
||||||
|
<span className="inline-flex" aria-label={t('tours.stats.multiDayHint')}>
|
||||||
|
<WhiteBadge icon={<Repeat size={10} strokeWidth={2.2} className={ROW_BADGE_ICON} />}>
|
||||||
|
{entry.day_count}
|
||||||
|
</WhiteBadge>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<BasisPill entry={entry} />
|
||||||
|
{entry.excluded && <SoftPill caps>{t('tours.stats.excluded')}</SoftPill>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The round button that leaves the tour out, or counts it again; named by its tooltip. */
|
||||||
|
function ExcludeToggle({
|
||||||
|
entry,
|
||||||
|
busy,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
entry: TourActivityStatsEntry;
|
||||||
|
busy: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = entry.excluded ? t('tours.stats.include') : t('tours.stats.exclude');
|
||||||
|
const hint = entry.excluded ? label : `${label}: ${t('tours.stats.excludeHint')}`;
|
||||||
|
let icon = <Ban size={14} strokeWidth={2} />;
|
||||||
|
if (busy) icon = <Loader2 size={14} strokeWidth={2} className="animate-spin" />;
|
||||||
|
else if (entry.excluded) icon = <RotateCcw size={14} strokeWidth={2} />;
|
||||||
|
return (
|
||||||
|
<Tooltip label={hint}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={entry.excluded}
|
||||||
|
data-testid={`tour-activity-toggle-${entry.place_id}`}
|
||||||
|
className="grid h-8 w-8 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STA-02: one tour of the user's activities: its type, name and where it
|
||||||
|
* counts, its distance and climb, and the switch to leave it out. A tour left
|
||||||
|
* out stays in the list, dimmed, so it can be counted again in place.
|
||||||
|
*/
|
||||||
|
export function TourActivityEntryRow({
|
||||||
|
entry,
|
||||||
|
busy,
|
||||||
|
onToggleExcluded,
|
||||||
|
}: {
|
||||||
|
entry: TourActivityStatsEntry;
|
||||||
|
busy: boolean;
|
||||||
|
onToggleExcluded: (entry: TourActivityStatsEntry) => void;
|
||||||
|
}) {
|
||||||
|
const unit = useDistanceUnit();
|
||||||
|
const key = entry.track ? `${entry.place_id}-${entry.track.place_id}` : String(entry.place_id);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-testid={`tour-activity-row-${key}`}
|
||||||
|
className="flex min-w-0 items-center gap-3 rounded-[12px] px-2.5 py-2 transition-colors hover:bg-surface-hover"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex min-w-0 flex-1 items-center gap-3 transition-opacity ${entry.excluded ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<EntryTile entry={entry} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||||
|
{entry.name}
|
||||||
|
</span>
|
||||||
|
<EntryFacts entry={entry} />
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-none flex-col items-end gap-1">
|
||||||
|
<MeasurePills
|
||||||
|
distance={entry.distance_km === null ? null : formatDistance(entry.distance_km, unit)}
|
||||||
|
ascent={entry.ascent_m === null ? null : formatElevation(entry.ascent_m, unit)}
|
||||||
|
testId={`tour-activity-${key}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<ExcludeToggle entry={entry} busy={busy} onToggle={() => onToggleExcluded(entry)} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// FE-STA2-DIALOG-001 to FE-STA2-DIALOG-008
|
||||||
|
// STA-02: the full list of the user's tour activities: the sums with what they
|
||||||
|
// rest on, the split by type, each tour with its basis and a switch to leave it
|
||||||
|
// out (dimmed, never hidden), and the way into its trip.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { summarizeTourActivities, type TourActivityStatsEntry, type TourActivityStatsResponse } from '@trek/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||||
|
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||||
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
|
import { TourActivityStatsDialog } from './TourActivityStatsDialog';
|
||||||
|
import type { TourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
const navigate = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('react-router', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('react-router')>()),
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TODAY = '2026-10-20';
|
||||||
|
|
||||||
|
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||||
|
return {
|
||||||
|
place_id: 1,
|
||||||
|
name: 'Ridge',
|
||||||
|
tour_type: 'hike',
|
||||||
|
trip: { id: 5, title: 'Alps' },
|
||||||
|
derived_from_place_id: null,
|
||||||
|
date: '2026-07-02',
|
||||||
|
basis: 'plan',
|
||||||
|
track: null,
|
||||||
|
day_count: 1,
|
||||||
|
distance_km: 12,
|
||||||
|
ascent_m: 800,
|
||||||
|
descent_m: 790,
|
||||||
|
excluded: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsOf(
|
||||||
|
data: TourActivityStatsResponse | null,
|
||||||
|
overrides: Partial<TourActivityStats> = {}
|
||||||
|
): TourActivityStats {
|
||||||
|
return {
|
||||||
|
status: data ? 'ready' : 'loading',
|
||||||
|
data,
|
||||||
|
summary: data ? summarizeTourActivities(data.entries, { today: TODAY }) : null,
|
||||||
|
period: 'all',
|
||||||
|
setPeriod: vi.fn(),
|
||||||
|
periods: ['all', 'thisYear', 'last12'],
|
||||||
|
type: 'all',
|
||||||
|
setType: vi.fn(),
|
||||||
|
types: ['all', 'hike', 'kayak'],
|
||||||
|
pending: new Set(),
|
||||||
|
setExcluded: vi.fn().mockResolvedValue(undefined),
|
||||||
|
retry: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: TourActivityStatsResponse = {
|
||||||
|
entries: [
|
||||||
|
entry(),
|
||||||
|
entry({
|
||||||
|
place_id: 2,
|
||||||
|
name: 'Lake',
|
||||||
|
tour_type: 'kayak',
|
||||||
|
date: '2026-08-01',
|
||||||
|
basis: 'track',
|
||||||
|
track: { place_id: 9, name: 'Watch export' },
|
||||||
|
distance_km: 8.5,
|
||||||
|
ascent_m: null,
|
||||||
|
descent_m: null,
|
||||||
|
}),
|
||||||
|
entry({ place_id: 3, name: 'Long trail', date: '2026-06-01', day_count: 3, distance_km: null }),
|
||||||
|
entry({ place_id: 4, name: 'Rained off', date: '2026-05-01', excluded: true }),
|
||||||
|
entry({ place_id: 5, name: 'Next week', date: '2026-10-27' }),
|
||||||
|
],
|
||||||
|
uncounted: { unassigned: 2, undated: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'metric' }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TourActivityStatsDialog', () => {
|
||||||
|
it('FE-STA2-DIALOG-001: shows the sums, at least where a tour lacks the distance, and what they rest on', () => {
|
||||||
|
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||||
|
expect(screen.getByRole('heading', { name: 'My tour activities' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('≥ 20.5 km')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tour-activity-basis-plan')).toHaveTextContent('2');
|
||||||
|
expect(screen.getByTestId('tour-activity-basis-track')).toHaveTextContent('1');
|
||||||
|
expect(screen.getByTestId('tour-activity-upcoming')).toHaveTextContent('1');
|
||||||
|
expect(screen.getByTestId('tour-activity-unassigned')).toHaveTextContent('2');
|
||||||
|
expect(screen.queryByTestId('tour-activity-undated')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-002: splits by type, the count at the corner of each type', () => {
|
||||||
|
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||||
|
expect(within(screen.getByTestId('tour-activity-type-hike')).getByText('2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tour-activity-type-kayak-distance')).toHaveTextContent('8.5 km');
|
||||||
|
expect(screen.queryByTestId('tour-activity-type-kayak-ascent')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-003: lists every done tour, newest first, with its basis; the one ahead is not there', () => {
|
||||||
|
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||||
|
const list = screen.getByTestId('tour-activity-list');
|
||||||
|
expect(within(list).getAllByRole('listitem')).toHaveLength(4);
|
||||||
|
expect(screen.queryByText('Next week')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tour-activity-row-2-9')).toHaveTextContent('Recorded');
|
||||||
|
expect(screen.getByTestId('tour-activity-track-corner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('tour-activity-row-1')).toHaveTextContent('Planned');
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId('tour-activity-row-3')).getByLabelText('Planned on several days, counted once')
|
||||||
|
).toHaveTextContent('3');
|
||||||
|
expect(screen.queryByTestId('tour-activity-3-distance')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-004: a tour left out stays listed and can be counted again, a counted one left out', async () => {
|
||||||
|
const stats = statsOf(data);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('tour-activity-row-4')).toHaveTextContent('Left out');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Count again' }));
|
||||||
|
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4 }), false);
|
||||||
|
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByRole('button', { name: 'Leave out' }));
|
||||||
|
expect(stats.setExcluded).toHaveBeenCalledWith(expect.objectContaining({ place_id: 1 }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-005: a tour on its way to the server cannot be switched twice', () => {
|
||||||
|
render(<TourActivityStatsDialog stats={statsOf(data, { pending: new Set([1]) })} open onClose={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId('tour-activity-toggle-1')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-006: the trip of a tour opens from its badge', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TourActivityStatsDialog stats={statsOf(data)} open onClose={vi.fn()} />);
|
||||||
|
await user.click(within(screen.getByTestId('tour-activity-row-1')).getByText('Alps'));
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/trips/5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-007: the filters sit on the head band and change period and type', async () => {
|
||||||
|
const stats = statsOf(data);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TourActivityStatsDialog stats={stats} open onClose={vi.fn()} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: /Period/ }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'This year' }));
|
||||||
|
expect(stats.setPeriod).toHaveBeenCalledWith('thisYear');
|
||||||
|
await user.click(screen.getByRole('button', { name: /Type/ }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Paddle tour' }));
|
||||||
|
expect(stats.setType).toHaveBeenCalledWith('kayak');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-DIALOG-008: before the read, while empty, and closed', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { rerender } = render(<TourActivityStatsDialog stats={statsOf(null)} open onClose={onClose} />);
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
||||||
|
rerender(
|
||||||
|
<TourActivityStatsDialog
|
||||||
|
stats={statsOf({ entries: [], uncounted: { unassigned: 0, undated: 0 } })}
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText('No completed tours yet')).toBeInTheDocument();
|
||||||
|
const [closeButton] = screen.getAllByRole('button', { name: 'Close' });
|
||||||
|
await user.click(closeButton as HTMLElement);
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
rerender(<TourActivityStatsDialog stats={statsOf(null)} open={false} onClose={onClose} />);
|
||||||
|
expect(screen.queryByText('My tour activities')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { TourActivityTotals, TourTypeKey } from '@trek/shared';
|
||||||
|
import { Footprints } from 'lucide-react';
|
||||||
|
import { createElement, useId } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import type { DistanceUnit } from '../../../types';
|
||||||
|
import {
|
||||||
|
DialogButton,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogSection,
|
||||||
|
DialogShell,
|
||||||
|
DialogTile,
|
||||||
|
FooterSpacer,
|
||||||
|
fs,
|
||||||
|
NEUTRAL_TINT,
|
||||||
|
} from '../../shared/DialogShell';
|
||||||
|
import { InfoDot, TourFactFields, type TourMetricField } from '../../Tours/tourParts';
|
||||||
|
import { TourActivityEntryRow } from './TourActivityEntryRow';
|
||||||
|
import { ActivityFilters, ActivityStatusLine, BasisBadges, MeasurePills, UncountedBadges } from './tourActivityParts';
|
||||||
|
import { distanceReading, heightReading, partialHint, typeIcon, useDistanceUnit } from './tourActivityReading';
|
||||||
|
import type { TourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
/** The sums as labelled fields; a sum nobody knows is left out, a short one says at least and why. */
|
||||||
|
function totalFields(totals: TourActivityTotals, unit: DistanceUnit, t: Translate): TourMetricField[] {
|
||||||
|
const fields: TourMetricField[] = [{ label: t('tours.mode.tours'), value: String(totals.count) }];
|
||||||
|
const sums = [
|
||||||
|
{ label: t('tours.detail.distance'), sum: totals.distanceKm, reading: distanceReading(totals, unit) },
|
||||||
|
{ label: t('tours.detail.ascent'), sum: totals.ascentM, reading: heightReading(totals.ascentM, unit) },
|
||||||
|
{ label: t('tours.detail.descent'), sum: totals.descentM, reading: heightReading(totals.descentM, unit) },
|
||||||
|
];
|
||||||
|
for (const { label, sum, reading } of sums) {
|
||||||
|
if (!reading) continue;
|
||||||
|
const info = reading.partial ? { label, description: partialHint(sum, t) } : undefined;
|
||||||
|
fields.push({ label, value: reading.text, info });
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One type of the period: its icon with how many tours at the corner, its name, its distance and climb. */
|
||||||
|
function TypeLine({ type, totals }: { type: TourTypeKey; totals: TourActivityTotals }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unit = useDistanceUnit();
|
||||||
|
const distance = distanceReading(totals, unit);
|
||||||
|
const ascent = heightReading(totals.ascentM, unit);
|
||||||
|
return (
|
||||||
|
<li className="flex min-w-0 items-center gap-3 py-1" data-testid={`tour-activity-type-${type}`}>
|
||||||
|
<span className="relative flex-none">
|
||||||
|
<span className="grid h-[30px] w-[30px] place-items-center rounded-full bg-edge text-content-secondary">
|
||||||
|
{createElement(typeIcon(type), { size: 14, strokeWidth: 1.9 })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -end-1.5 -top-1.5 grid h-4 min-w-4 place-items-center rounded-full bg-surface-card px-1 font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-edge-faint"
|
||||||
|
style={fs(9)}
|
||||||
|
>
|
||||||
|
{totals.count}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>
|
||||||
|
{t(`tourTypes.${type}`)}
|
||||||
|
<span className="sr-only">{`: ${t('tours.mode.tours')} ${totals.count}`}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-none items-center gap-1.5">
|
||||||
|
<MeasurePills
|
||||||
|
distance={distance?.text ?? null}
|
||||||
|
ascent={ascent?.text ?? null}
|
||||||
|
testId={`tour-activity-type-${type}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sums, what they rest on, the split by type, and every tour of the period with its switch. */
|
||||||
|
function StatsBody({ stats }: { stats: TourActivityStats }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unit = useDistanceUnit();
|
||||||
|
const { summary, data } = stats;
|
||||||
|
if (!summary || !data) return <ActivityStatusLine stats={stats} />;
|
||||||
|
const showTypes = stats.type === 'all' && summary.types.length > 1;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<TourFactFields fields={totalFields(summary.totals, unit, t)} />
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<BasisBadges byBasis={summary.totals.byBasis} />
|
||||||
|
<UncountedBadges upcoming={summary.upcoming} {...data.uncounted} />
|
||||||
|
<InfoDot label={t('tours.stats.title')} description={t('tours.stats.basisInfo')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showTypes && (
|
||||||
|
<DialogSection label={t('tours.stats.byType')}>
|
||||||
|
<ul className="m-0 flex list-none flex-col gap-1 p-0">
|
||||||
|
{summary.types.map(({ type, ...totals }) => (
|
||||||
|
<TypeLine key={type} type={type} totals={totals} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogSection>
|
||||||
|
)}
|
||||||
|
<DialogSection
|
||||||
|
label={t('tours.mode.tours')}
|
||||||
|
action={
|
||||||
|
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||||
|
{summary.done.length}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{summary.done.length === 0 ? (
|
||||||
|
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
|
||||||
|
{t(data.entries.length === 0 ? 'tours.stats.empty' : 'tours.stats.none')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="-mx-2.5 my-0 flex list-none flex-col gap-0.5 p-0" data-testid="tour-activity-list">
|
||||||
|
{summary.done.map((entry) => (
|
||||||
|
<TourActivityEntryRow
|
||||||
|
key={entry.track ? `${entry.place_id}-${entry.track.place_id}` : entry.place_id}
|
||||||
|
entry={entry}
|
||||||
|
busy={stats.pending.has(entry.place_id)}
|
||||||
|
onToggleExcluded={(target) => void stats.setExcluded(target, !target.excluded)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</DialogSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STA-02: the user's tour activities in full, opened from the Atlas: the
|
||||||
|
* period and type on the head band, the sums with what they rest on, the
|
||||||
|
* split by type, and every tour done in the period, each one able to be left
|
||||||
|
* out (a tour that fell through) and counted again without touching its plan.
|
||||||
|
*/
|
||||||
|
export function TourActivityStatsDialog({
|
||||||
|
stats,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
stats: TourActivityStats;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const labelId = useId();
|
||||||
|
return (
|
||||||
|
<DialogShell
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy={labelId}
|
||||||
|
width="editor"
|
||||||
|
header={
|
||||||
|
<DialogHeader
|
||||||
|
tile={
|
||||||
|
<DialogTile>
|
||||||
|
<Footprints size={20} strokeWidth={2} className="text-content" />
|
||||||
|
</DialogTile>
|
||||||
|
}
|
||||||
|
tint={NEUTRAL_TINT}
|
||||||
|
labelId={labelId}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t('tours.stats.title')}
|
||||||
|
sub={t('tours.stats.sub')}
|
||||||
|
subWraps
|
||||||
|
pills={<ActivityFilters stats={stats} />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<DialogFooter>
|
||||||
|
<FooterSpacer />
|
||||||
|
<DialogButton onClick={onClose}>{t('common.close')}</DialogButton>
|
||||||
|
</DialogFooter>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StatsBody stats={stats} />
|
||||||
|
</DialogShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import type { TourActivityBasis, TourActivityStatsEntry } from '@trek/shared';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowUp,
|
||||||
|
CalendarClock,
|
||||||
|
CalendarDays,
|
||||||
|
CalendarRange,
|
||||||
|
Footprints,
|
||||||
|
Loader2,
|
||||||
|
Ruler,
|
||||||
|
WifiOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { PillSelect } from '../../shared/dialogParts';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { FigureBadge } from '../../Tours/paddle/FigureBadge';
|
||||||
|
import { TourDayRowPill } from '../../Tours/TourDayRowFacts';
|
||||||
|
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
|
||||||
|
import { typeIcon, typeLabel, type Translate } from './tourActivityReading';
|
||||||
|
import { periodYear, type ActivityPeriod, type ActivityType } from './tourActivityStatsModel';
|
||||||
|
import type { TourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
/** What a period is called in the filter. */
|
||||||
|
function periodLabel(period: ActivityPeriod, t: Translate): string {
|
||||||
|
if (period === 'all') return t('tours.stats.period.all');
|
||||||
|
if (period === 'thisYear') return t('tours.stats.period.thisYear');
|
||||||
|
if (period === 'last12') return t('tours.stats.period.last12');
|
||||||
|
return String(periodYear(period));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The period and type filters, as head band pills or quiet ones in a panel. */
|
||||||
|
export function ActivityFilters({ stats, quiet = false }: { stats: TourActivityStats; quiet?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const typeOption = (type: ActivityType) => ({
|
||||||
|
value: type,
|
||||||
|
label: typeLabel(type, t),
|
||||||
|
icon: createElement(typeIcon(type), { size: 12, strokeWidth: 2.2, className: 'flex-none text-content-muted' }),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PillSelect
|
||||||
|
quiet={quiet}
|
||||||
|
label={t('tours.stats.period')}
|
||||||
|
value={stats.period}
|
||||||
|
options={stats.periods.map((period) => ({
|
||||||
|
value: period,
|
||||||
|
label: periodLabel(period, t),
|
||||||
|
icon: <CalendarRange size={12} strokeWidth={2.2} className="flex-none text-content-muted" />,
|
||||||
|
}))}
|
||||||
|
fallback={{ label: periodLabel(stats.period, t) }}
|
||||||
|
onChange={stats.setPeriod}
|
||||||
|
/>
|
||||||
|
<PillSelect
|
||||||
|
quiet={quiet}
|
||||||
|
label={t('tours.filter.type')}
|
||||||
|
value={stats.type}
|
||||||
|
options={stats.types.map(typeOption)}
|
||||||
|
fallback={typeOption(stats.type)}
|
||||||
|
onChange={stats.setType}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A distance and a climb as white badges, each named in full by its tooltip; one not known is left out. */
|
||||||
|
export function MeasurePills({
|
||||||
|
distance,
|
||||||
|
ascent,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
distance: string | null;
|
||||||
|
ascent: string | null;
|
||||||
|
/** Prefix of the badges' test ids: `<testId>-distance`, `<testId>-ascent`. */
|
||||||
|
testId: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{distance !== null && (
|
||||||
|
<TourDayRowPill
|
||||||
|
icon={Ruler}
|
||||||
|
value={distance}
|
||||||
|
label={`${t('tours.detail.distance')}: ${distance}`}
|
||||||
|
testId={`${testId}-distance`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ascent !== null && (
|
||||||
|
<TourDayRowPill
|
||||||
|
icon={ArrowUp}
|
||||||
|
value={ascent}
|
||||||
|
label={`${t('tours.detail.ascent')}: ${ascent}`}
|
||||||
|
testId={`${testId}-ascent`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the sums rest on, each as a figure badge: how many tours count with
|
||||||
|
* their planned values and how many with a recorded track. The planned values
|
||||||
|
* are never passed off as measured, so they always say so.
|
||||||
|
*/
|
||||||
|
export function BasisBadges({ byBasis }: { byBasis: Record<TourActivityBasis, number> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{byBasis.plan > 0 && (
|
||||||
|
<FigureBadge
|
||||||
|
testId="tour-activity-basis-plan"
|
||||||
|
label={t('tours.stats.basisPlan')}
|
||||||
|
value={String(byBasis.plan)}
|
||||||
|
icon={<CalendarDays size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||||
|
info={t('tours.stats.basisPlanHint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{byBasis.track > 0 && (
|
||||||
|
<FigureBadge
|
||||||
|
testId="tour-activity-basis-track"
|
||||||
|
label={t('tours.stats.basisTrack')}
|
||||||
|
value={String(byBasis.track)}
|
||||||
|
icon={<Footprints size={10} strokeWidth={2.2} className="flex-none text-success" />}
|
||||||
|
info={t('tours.stats.basisTrackCountHint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What does not count, said as quiet figure badges: tours still ahead, on no day, on days without a date. */
|
||||||
|
export function UncountedBadges({
|
||||||
|
upcoming,
|
||||||
|
unassigned,
|
||||||
|
undated,
|
||||||
|
}: {
|
||||||
|
upcoming: number;
|
||||||
|
unassigned: number;
|
||||||
|
undated: number;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const badges = [
|
||||||
|
{ key: 'upcoming', value: upcoming, labelKey: 'tours.stats.upcoming', infoKey: 'tours.stats.upcomingHint' },
|
||||||
|
{
|
||||||
|
key: 'unassigned',
|
||||||
|
value: unassigned,
|
||||||
|
labelKey: 'tours.stats.unassigned',
|
||||||
|
infoKey: 'tours.stats.unassignedHint',
|
||||||
|
},
|
||||||
|
{ key: 'undated', value: undated, labelKey: 'tours.stats.undated', infoKey: 'tours.stats.undatedHint' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{badges
|
||||||
|
.filter((badge) => badge.value > 0)
|
||||||
|
.map((badge) => (
|
||||||
|
<FigureBadge
|
||||||
|
key={badge.key}
|
||||||
|
testId={`tour-activity-${badge.key}`}
|
||||||
|
label={t(badge.labelKey)}
|
||||||
|
value={String(badge.value)}
|
||||||
|
icon={<CalendarClock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}
|
||||||
|
info={t(badge.infoKey)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tour's type as a round tile; a tour done with a recorded track carries
|
||||||
|
* the footprints at its corner, the way a waypoint's number carries its kind.
|
||||||
|
*/
|
||||||
|
export function EntryTile({ entry }: { entry: Pick<TourActivityStatsEntry, 'tour_type' | 'basis'> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Tooltip label={t(`tourTypes.${entry.tour_type}`)}>
|
||||||
|
<span className="relative flex-none" aria-label={t(`tourTypes.${entry.tour_type}`)}>
|
||||||
|
<span className="grid h-[34px] w-[34px] place-items-center rounded-full bg-edge text-content-secondary">
|
||||||
|
{createElement(tourDayRowIcon(entry.tour_type), { size: 15, strokeWidth: 1.9 })}
|
||||||
|
</span>
|
||||||
|
{entry.basis === 'track' && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="tour-activity-track-corner"
|
||||||
|
className="absolute -bottom-1 -end-1 grid h-4 w-4 place-items-center rounded-full bg-success-soft text-success ring-2 ring-surface-card"
|
||||||
|
>
|
||||||
|
<Footprints size={9} strokeWidth={2.6} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the read stands while there is nothing to show yet: on its way, refused
|
||||||
|
* offline, or failed with a way to try again.
|
||||||
|
*/
|
||||||
|
export function ActivityStatusLine({ stats }: { stats: Pick<TourActivityStats, 'status' | 'retry'> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Not asked yet: the tab has not been shown, so there is nothing to say.
|
||||||
|
if (stats.status === 'idle') return null;
|
||||||
|
if (stats.status === 'offline')
|
||||||
|
return (
|
||||||
|
<p className="m-0 flex items-center gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||||
|
<WifiOff size={13} strokeWidth={2} className="flex-none" />
|
||||||
|
{t('common.needsConnection')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
if (stats.status === 'error')
|
||||||
|
return (
|
||||||
|
<p role="alert" className="m-0 flex flex-wrap items-center gap-2 text-content-muted" style={fs(12, 'body')}>
|
||||||
|
<AlertTriangle size={13} strokeWidth={2} className="flex-none text-warning" />
|
||||||
|
{t('tours.stats.unavailable')}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stats.retry}
|
||||||
|
className="font-semibold text-content underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
{t('common.errorRetry')}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<p role="status" className="m-0 flex items-center gap-2 text-content-faint" style={fs(12, 'body')}>
|
||||||
|
<Loader2 size={13} strokeWidth={2.2} className="flex-none animate-spin" />
|
||||||
|
{t('common.loading')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { TourActivityTotals } from '@trek/shared';
|
||||||
|
import { Layers, type LucideIcon } from 'lucide-react';
|
||||||
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
|
import type { DistanceUnit } from '../../../types';
|
||||||
|
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||||
|
import { tourDayRowIcon } from '../../Tours/tourDayRowIcon';
|
||||||
|
import { sumReading, type ActivityType } from './tourActivityStatsModel';
|
||||||
|
|
||||||
|
export type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
/** The distance unit the user reads in. */
|
||||||
|
export function useDistanceUnit(): DistanceUnit {
|
||||||
|
return useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a type is called in the filter. */
|
||||||
|
export function typeLabel(type: ActivityType, t: Translate): string {
|
||||||
|
return type === 'all' ? t('tours.stats.allTypes') : t(`tourTypes.${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The icon a type is drawn with; every type at once is a stack. */
|
||||||
|
export function typeIcon(type: ActivityType): LucideIcon {
|
||||||
|
return type === 'all' ? Layers : tourDayRowIcon(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A distance as said, at least that much when some tours do not know theirs; null when none does. */
|
||||||
|
export function distanceReading(totals: Pick<TourActivityTotals, 'distanceKm'>, unit: DistanceUnit) {
|
||||||
|
return sumReading(totals.distanceKm, (km) => formatDistance(km, unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A height sum as said, the same way. */
|
||||||
|
export function heightReading(sum: TourActivityTotals['ascentM'], unit: DistanceUnit) {
|
||||||
|
return sumReading(sum, (metres) => formatElevation(metres, unit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a short sum explains itself: known for some of the tours only. */
|
||||||
|
export function partialHint(sum: TourActivityTotals['distanceKm'], t: Translate): string {
|
||||||
|
return t('tours.scope.partialBody', { known: sum.known, of: sum.of });
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// FE-STA2-MODEL-001 to FE-STA2-MODEL-007
|
||||||
|
// STA-02: the periods and types the Atlas offers for the user's tour
|
||||||
|
// activities, and how a sum reads when some tours do not know the value.
|
||||||
|
import type { TourActivityStatsEntry } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
periodOptions,
|
||||||
|
periodRange,
|
||||||
|
periodYear,
|
||||||
|
splitMeasure,
|
||||||
|
sumReading,
|
||||||
|
typeOptions,
|
||||||
|
} from './tourActivityStatsModel';
|
||||||
|
|
||||||
|
const TODAY = '2026-10-20';
|
||||||
|
|
||||||
|
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||||
|
return {
|
||||||
|
place_id: 1,
|
||||||
|
name: 'Ridge',
|
||||||
|
tour_type: 'hike',
|
||||||
|
trip: { id: 5, title: 'Alps' },
|
||||||
|
derived_from_place_id: null,
|
||||||
|
date: '2026-07-02',
|
||||||
|
basis: 'plan',
|
||||||
|
track: null,
|
||||||
|
day_count: 1,
|
||||||
|
distance_km: 10,
|
||||||
|
ascent_m: 500,
|
||||||
|
descent_m: 400,
|
||||||
|
excluded: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tourActivityStatsModel', () => {
|
||||||
|
it('FE-STA2-MODEL-001: each period covers its days, both ends included', () => {
|
||||||
|
expect(periodRange('all', TODAY)).toEqual({ from: null, to: null });
|
||||||
|
expect(periodRange('thisYear', TODAY)).toEqual({ from: '2026-01-01', to: '2026-12-31' });
|
||||||
|
expect(periodRange('last12', TODAY)).toEqual({ from: '2025-10-20', to: TODAY });
|
||||||
|
expect(periodRange('year:2024', TODAY)).toEqual({ from: '2024-01-01', to: '2024-12-31' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-002: offers the earlier years a done tour is in, the newest first, and no empty one', () => {
|
||||||
|
const entries = [
|
||||||
|
entry({ date: '2023-05-01' }),
|
||||||
|
entry({ date: '2025-08-01' }),
|
||||||
|
entry({ date: '2025-09-01' }),
|
||||||
|
entry({ date: '2026-03-01' }),
|
||||||
|
];
|
||||||
|
expect(periodOptions(entries, TODAY)).toEqual(['all', 'thisYear', 'last12', 'year:2025', 'year:2023']);
|
||||||
|
expect(periodOptions([], TODAY)).toEqual(['all', 'thisYear', 'last12']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-003: offers the types of done tours only, in planning order, older types after them', () => {
|
||||||
|
const entries = [
|
||||||
|
entry({ tour_type: 'kayak' }),
|
||||||
|
entry({ tour_type: 'run' }),
|
||||||
|
entry({ tour_type: 'hike' }),
|
||||||
|
entry({ tour_type: 'bike', date: '2026-12-01' }),
|
||||||
|
];
|
||||||
|
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'run', 'kayak']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-004: reads the year of a year period only', () => {
|
||||||
|
expect(periodYear('year:2024')).toBe(2024);
|
||||||
|
expect(periodYear('thisYear')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-005: a sum nobody knows says nothing, a short one says at least', () => {
|
||||||
|
const km = (value: number) => `${value} km`;
|
||||||
|
expect(sumReading({ value: null, known: 0, of: 2 }, km)).toBeNull();
|
||||||
|
expect(sumReading({ value: 12, known: 2, of: 2 }, km)).toEqual({ text: '12 km', partial: false });
|
||||||
|
expect(sumReading({ value: 12, known: 1, of: 2 }, km)).toEqual({ text: '≥ 12 km', partial: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-006: splits a measure into its number and unit', () => {
|
||||||
|
expect(splitMeasure('128.4 km')).toEqual({ value: '128.4', unit: 'km' });
|
||||||
|
expect(splitMeasure('≥ 12 km')).toEqual({ value: '≥ 12', unit: 'km' });
|
||||||
|
expect(splitMeasure('12')).toEqual({ value: '12', unit: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-007: a tour still ahead offers neither its year nor its type', () => {
|
||||||
|
expect(periodOptions([entry({ date: '2025-01-01' }), entry({ date: '2027-01-01' })], TODAY)).toEqual([
|
||||||
|
'all',
|
||||||
|
'thisYear',
|
||||||
|
'last12',
|
||||||
|
'year:2025',
|
||||||
|
]);
|
||||||
|
expect(typeOptions([entry({ tour_type: 'walk', date: TODAY })], TODAY)).toEqual(['all']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-MODEL-008: a planned ski tour is a type of its own, after the types planned before it', () => {
|
||||||
|
const entries = [entry({ tour_type: 'ski' }), entry({ tour_type: 'horse' }), entry({ tour_type: 'hike' })];
|
||||||
|
expect(typeOptions(entries, TODAY)).toEqual(['all', 'hike', 'horse', 'ski']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
isTourActivityDone,
|
||||||
|
plannableTourTypeSchema,
|
||||||
|
type TourActivityStatsEntry,
|
||||||
|
type TourActivitySum,
|
||||||
|
type TourTypeKey,
|
||||||
|
} from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STA-02: the periods and types the Atlas narrows the user's tour activities
|
||||||
|
* by, and how a sum reads. Pure, so the hook and its views share one answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** All time, the calendar year, the last twelve months, or one earlier year. */
|
||||||
|
export type ActivityPeriod = 'all' | 'thisYear' | 'last12' | `year:${number}`;
|
||||||
|
|
||||||
|
/** One type, or every type at once. */
|
||||||
|
export type ActivityType = TourTypeKey | 'all';
|
||||||
|
|
||||||
|
interface PeriodRange {
|
||||||
|
from: string | null;
|
||||||
|
to: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days a period covers, both included, for `today` (YYYY-MM-DD in the user's own calendar). */
|
||||||
|
export function periodRange(period: ActivityPeriod, today: string): PeriodRange {
|
||||||
|
const year = Number(today.slice(0, 4));
|
||||||
|
if (period === 'thisYear') return { from: `${year}-01-01`, to: `${year}-12-31` };
|
||||||
|
// A year back to the day, today included: the same day a year ago is the first one.
|
||||||
|
if (period === 'last12') return { from: `${year - 1}${today.slice(4)}`, to: today };
|
||||||
|
if (period.startsWith('year:')) {
|
||||||
|
const chosen = period.slice(5);
|
||||||
|
return { from: `${chosen}-01-01`, to: `${chosen}-12-31` };
|
||||||
|
}
|
||||||
|
return { from: null, to: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The periods worth offering: all time, this year and the last twelve months,
|
||||||
|
* then each earlier year a done tour is in, the newest first. A year without
|
||||||
|
* a tour would only ever show nothing.
|
||||||
|
*/
|
||||||
|
export function periodOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityPeriod[] {
|
||||||
|
const thisYear = today.slice(0, 4);
|
||||||
|
const years = new Set(
|
||||||
|
entries
|
||||||
|
.filter((entry) => isTourActivityDone(entry, today))
|
||||||
|
.map((entry) => entry.date.slice(0, 4))
|
||||||
|
.filter((year) => year < thisYear)
|
||||||
|
);
|
||||||
|
const earlier = [...years].sort((a, b) => b.localeCompare(a)).map((year): ActivityPeriod => `year:${Number(year)}`);
|
||||||
|
return ['all', 'thisYear', 'last12', ...earlier];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The types a done tour has, in the order tours are planned in, the older types after them. */
|
||||||
|
export function typeOptions(entries: readonly TourActivityStatsEntry[], today: string): ActivityType[] {
|
||||||
|
const present = new Set(entries.filter((entry) => isTourActivityDone(entry, today)).map((entry) => entry.tour_type));
|
||||||
|
const planned: readonly string[] = plannableTourTypeSchema.options;
|
||||||
|
const ordered = [...present].sort((a, b) => rank(planned, a) - rank(planned, b) || a.localeCompare(b));
|
||||||
|
return ['all', ...ordered];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rank(order: readonly string[], value: string): number {
|
||||||
|
const index = order.indexOf(value);
|
||||||
|
return index === -1 ? order.length : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The year of a `year:` period, or null for the others. */
|
||||||
|
export function periodYear(period: ActivityPeriod): number | null {
|
||||||
|
return period.startsWith('year:') ? Number(period.slice(5)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sum as it is said: nothing when nobody knows the value, else the value,
|
||||||
|
* marked as at least that much when some of the tours lack it.
|
||||||
|
*/
|
||||||
|
export function sumReading(
|
||||||
|
sum: TourActivitySum,
|
||||||
|
format: (value: number) => string
|
||||||
|
): { text: string; partial: boolean } | null {
|
||||||
|
if (sum.value === null) return null;
|
||||||
|
const partial = sum.known < sum.of;
|
||||||
|
return { text: partial ? `≥ ${format(sum.value)}` : format(sum.value), partial };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A formatted measure split into its number and its unit, so the number can stand large ("128.4", "km"). */
|
||||||
|
export function splitMeasure(text: string): { value: string; unit: string } {
|
||||||
|
const at = text.lastIndexOf(' ');
|
||||||
|
return at === -1 ? { value: text, unit: '' } : { value: text.slice(0, at), unit: text.slice(at + 1) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// FE-STA2-HOOK-001 to FE-STA2-HOOK-009
|
||||||
|
// STA-02: the user's own tour activities are read when the tab is first shown,
|
||||||
|
// narrowed by period and type, and a tour left out shows at once and comes back
|
||||||
|
// with a message when the server refuses.
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import type { TourActivityStatsEntry, TourActivityStatsResponse } from '@trek/shared';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useTourActivityStats } from './useTourActivityStats';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ get: vi.fn(), setExcluded: vi.fn() }));
|
||||||
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||||
|
vi.mock('../../../repo/tourActivityStatsRepo', () => ({
|
||||||
|
tourActivityStatsRepo: repo,
|
||||||
|
TourActivityStatsOfflineError: class TourActivityStatsOfflineError extends Error {},
|
||||||
|
}));
|
||||||
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
function entry(overrides: Partial<TourActivityStatsEntry> = {}): TourActivityStatsEntry {
|
||||||
|
return {
|
||||||
|
place_id: 1,
|
||||||
|
name: 'Ridge',
|
||||||
|
tour_type: 'hike',
|
||||||
|
trip: { id: 5, title: 'Alps' },
|
||||||
|
derived_from_place_id: null,
|
||||||
|
date: '2026-07-02',
|
||||||
|
basis: 'plan',
|
||||||
|
track: null,
|
||||||
|
day_count: 1,
|
||||||
|
distance_km: 10,
|
||||||
|
ascent_m: 500,
|
||||||
|
descent_m: 400,
|
||||||
|
excluded: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: TourActivityStatsResponse = {
|
||||||
|
entries: [
|
||||||
|
entry(),
|
||||||
|
entry({ place_id: 2, name: 'Lake', tour_type: 'kayak', date: '2025-08-01', distance_km: 8 }),
|
||||||
|
entry({ place_id: 3, name: 'Summit', date: '2026-11-01' }),
|
||||||
|
],
|
||||||
|
uncounted: { unassigned: 0, undated: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(2026, 9, 20, 12));
|
||||||
|
repo.get.mockResolvedValue(stats);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe('useTourActivityStats', () => {
|
||||||
|
it('FE-STA2-HOOK-001: reads nothing until the tab is shown, then sums what is done', async () => {
|
||||||
|
const { result, rerender } = renderHook(({ active }) => useTourActivityStats(active), {
|
||||||
|
initialProps: { active: false },
|
||||||
|
});
|
||||||
|
expect(result.current.status).toBe('idle');
|
||||||
|
expect(repo.get).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rerender({ active: true });
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
expect(result.current.summary?.totals.count).toBe(2);
|
||||||
|
expect(result.current.summary?.totals.distanceKm.value).toBe(18);
|
||||||
|
expect(result.current.summary?.upcoming).toBe(1);
|
||||||
|
expect(result.current.periods).toEqual(['all', 'thisYear', 'last12', 'year:2025']);
|
||||||
|
expect(result.current.types).toEqual(['all', 'hike', 'kayak']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-002: narrows by period and by type', async () => {
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
act(() => result.current.setPeriod('thisYear'));
|
||||||
|
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([1]);
|
||||||
|
act(() => {
|
||||||
|
result.current.setPeriod('all');
|
||||||
|
result.current.setType('kayak');
|
||||||
|
});
|
||||||
|
expect(result.current.summary?.done.map((item) => item.place_id)).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-003: offline and failed reads are told apart, and a retry reads again', async () => {
|
||||||
|
const { TourActivityStatsOfflineError } = await import('../../../repo/tourActivityStatsRepo');
|
||||||
|
repo.get.mockRejectedValueOnce(new TourActivityStatsOfflineError());
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('offline'));
|
||||||
|
|
||||||
|
repo.get.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||||
|
|
||||||
|
act(() => result.current.retry());
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
expect(repo.get).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-004: leaving a tour out shows at once and takes the server answer', async () => {
|
||||||
|
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||||
|
repo.setExcluded.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
|
||||||
|
let write: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
write = result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
expect(result.current.pending.has(1)).toBe(true);
|
||||||
|
expect(result.current.summary?.totals.count).toBe(1);
|
||||||
|
expect(repo.setExcluded).toHaveBeenCalledWith({ place_id: 1, excluded: true });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const entries = stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item));
|
||||||
|
answer({ ...stats, entries });
|
||||||
|
await write;
|
||||||
|
});
|
||||||
|
expect(result.current.pending.size).toBe(0);
|
||||||
|
expect(result.current.summary?.done.find((item) => item.place_id === 1)?.excluded).toBe(true);
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-005: a refused exclusion counts the tour again and says so', async () => {
|
||||||
|
repo.setExcluded.mockRejectedValue(new Error('nope'));
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
expect(result.current.summary?.totals.count).toBe(2);
|
||||||
|
expect(result.current.pending.size).toBe(0);
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('tours.stats.excludeFailed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-006: a read answered after the tab went away is dropped', async () => {
|
||||||
|
let answer: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||||
|
repo.get.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||||
|
const { result, unmount } = renderHook(() => useTourActivityStats(true));
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
unmount();
|
||||||
|
await act(async () => answer(stats));
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-007: a write before the first read leaves the empty state empty', async () => {
|
||||||
|
repo.get.mockReturnValue(new Promise(() => undefined));
|
||||||
|
repo.setExcluded.mockRejectedValue(new Error('offline'));
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
expect(result.current.data).toBeNull();
|
||||||
|
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-008: an older exclusion answered last never puts a newer one back', async () => {
|
||||||
|
const answers: Array<(value: TourActivityStatsResponse) => void> = [];
|
||||||
|
repo.setExcluded.mockImplementation(() => new Promise((resolve) => answers.push(resolve)));
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
const excludedOf = (placeId: number) =>
|
||||||
|
result.current.data?.entries.find((item) => item.place_id === placeId)?.excluded;
|
||||||
|
const withExcluded = (...ids: number[]) => ({
|
||||||
|
...stats,
|
||||||
|
entries: stats.entries.map((item) => ({ ...item, excluded: ids.includes(item.place_id) })),
|
||||||
|
});
|
||||||
|
|
||||||
|
let first: Promise<void> = Promise.resolve();
|
||||||
|
let second: Promise<void> = Promise.resolve();
|
||||||
|
act(() => {
|
||||||
|
first = result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, true);
|
||||||
|
});
|
||||||
|
// The second answer comes first and carries both; the first, from before the second, only its own.
|
||||||
|
await act(async () => {
|
||||||
|
answers[1]?.(withExcluded(1, 2));
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
answers[0]?.(withExcluded(1));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
expect(excludedOf(1)).toBe(true);
|
||||||
|
expect(excludedOf(2)).toBe(true);
|
||||||
|
|
||||||
|
// The other way round: the first comes first, without the second still on its way, which stays left out.
|
||||||
|
act(() => {
|
||||||
|
first = result.current.setExcluded(entry(), false);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
second = result.current.setExcluded(stats.entries[1] as TourActivityStatsEntry, false);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
answers[2]?.(withExcluded(2));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
expect(excludedOf(1)).toBe(false);
|
||||||
|
expect(excludedOf(2)).toBe(false);
|
||||||
|
await act(async () => {
|
||||||
|
answers[3]?.(withExcluded());
|
||||||
|
await second;
|
||||||
|
});
|
||||||
|
expect(excludedOf(2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STA2-HOOK-009: a read asked before an exclusion and answered after it does not undo it', async () => {
|
||||||
|
const { result } = renderHook(() => useTourActivityStats(true));
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('ready'));
|
||||||
|
let read: (value: TourActivityStatsResponse) => void = () => undefined;
|
||||||
|
repo.get.mockReturnValue(new Promise((resolve) => (read = resolve)));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
repo.setExcluded.mockResolvedValue({
|
||||||
|
...stats,
|
||||||
|
entries: stats.entries.map((item) => (item.place_id === 1 ? { ...item, excluded: true } : item)),
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.setExcluded(entry(), true);
|
||||||
|
});
|
||||||
|
await act(async () => read(stats));
|
||||||
|
expect(result.current.status).toBe('ready');
|
||||||
|
expect(result.current.data?.entries.find((item) => item.place_id === 1)?.excluded).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
summarizeTourActivities,
|
||||||
|
type TourActivityStatsEntry,
|
||||||
|
type TourActivityStatsResponse,
|
||||||
|
type TourActivitySummary,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { TourActivityStatsOfflineError, tourActivityStatsRepo } from '../../../repo/tourActivityStatsRepo';
|
||||||
|
import { localIsoDate } from '../../../utils/localDate';
|
||||||
|
import { useToast } from '../../shared/Toast';
|
||||||
|
import {
|
||||||
|
periodOptions,
|
||||||
|
periodRange,
|
||||||
|
typeOptions,
|
||||||
|
type ActivityPeriod,
|
||||||
|
type ActivityType,
|
||||||
|
} from './tourActivityStatsModel';
|
||||||
|
|
||||||
|
/** Where the read stands: not asked yet, on its way, there, refused offline, or failed. */
|
||||||
|
type ActivityStatsStatus = 'idle' | 'loading' | 'ready' | 'offline' | 'error';
|
||||||
|
|
||||||
|
export interface TourActivityStats {
|
||||||
|
status: ActivityStatsStatus;
|
||||||
|
data: TourActivityStatsResponse | null;
|
||||||
|
/** The sums of the chosen period and type; null until the first read is there. */
|
||||||
|
summary: TourActivitySummary | null;
|
||||||
|
period: ActivityPeriod;
|
||||||
|
setPeriod: (period: ActivityPeriod) => void;
|
||||||
|
periods: ActivityPeriod[];
|
||||||
|
type: ActivityType;
|
||||||
|
setType: (type: ActivityType) => void;
|
||||||
|
types: ActivityType[];
|
||||||
|
/** The tours whose exclusion is on its way to the server. */
|
||||||
|
pending: ReadonlySet<number>;
|
||||||
|
setExcluded: (entry: TourActivityStatsEntry, excluded: boolean) => Promise<void>;
|
||||||
|
retry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the latest read came back with, and the attempt it answered. */
|
||||||
|
interface Answered {
|
||||||
|
attempt: number;
|
||||||
|
status: Exclude<ActivityStatsStatus, 'idle' | 'loading'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every entry of the tour with the flag as asked, the rest as they were. */
|
||||||
|
function flagged(data: TourActivityStatsResponse, placeId: number, excluded: boolean): TourActivityStatsResponse {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
entries: data.entries.map((entry) => (entry.place_id === placeId ? { ...entry, excluded } : entry)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every entry with the flags of the exclusions still on their way laid over it, the way they show already. */
|
||||||
|
function withFlags(data: TourActivityStatsResponse, flags: ReadonlyMap<number, boolean>): TourActivityStatsResponse {
|
||||||
|
if (flags.size === 0) return data;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
entries: data.entries.map((entry) =>
|
||||||
|
flags.has(entry.place_id) ? { ...entry, excluded: flags.get(entry.place_id) === true } : entry
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STA-02: the user's own tour activities in the Atlas. Read live when the tab
|
||||||
|
* is first shown (and again on retry), never cached on the device; narrowed by
|
||||||
|
* period and type here, summed by the shared summary the assistant uses too.
|
||||||
|
* Leaving a tour out shows at once and is undone with a message when the
|
||||||
|
* server refuses it; the plan of the tour never changes. Answers can come
|
||||||
|
* back in any order: only the answer to the latest read or write is taken
|
||||||
|
* whole, with the exclusions still on their way laid over it; an older one
|
||||||
|
* only confirms its own tour's flag, so it never puts back a state from
|
||||||
|
* before a newer change.
|
||||||
|
*/
|
||||||
|
export function useTourActivityStats(active: boolean): TourActivityStats {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
// The answer to the latest read and which attempt it answered: a retry is loading until its own answer is there.
|
||||||
|
const [answered, setAnswered] = useState<Answered | null>(null);
|
||||||
|
const [data, setData] = useState<TourActivityStatsResponse | null>(null);
|
||||||
|
const [period, setPeriod] = useState<ActivityPeriod>('all');
|
||||||
|
const [type, setType] = useState<ActivityType>('all');
|
||||||
|
const [pending, setPending] = useState<ReadonlySet<number>>(() => new Set());
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const today = localIsoDate();
|
||||||
|
// Raised by every read and every write, so an answer knows whether a newer request went out after it.
|
||||||
|
const latest = useRef(0);
|
||||||
|
// The exclusions on their way to the server, by tour: the flag the user asked for.
|
||||||
|
const inFlight = useRef(new Map<number, boolean>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const asked = ++latest.current;
|
||||||
|
tourActivityStatsRepo.get(controller.signal).then(
|
||||||
|
(answer) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
|
||||||
|
setAnswered({ attempt, status: 'ready' });
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setAnswered({ attempt, status: err instanceof TourActivityStatsOfflineError ? 'offline' : 'error' });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [active, attempt]);
|
||||||
|
|
||||||
|
let status: ActivityStatsStatus = 'loading';
|
||||||
|
if (answered?.attempt === attempt) status = answered.status;
|
||||||
|
else if (!active && !answered) status = 'idle';
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (!data) return null;
|
||||||
|
const range = periodRange(period, today);
|
||||||
|
return summarizeTourActivities(data.entries, { today, ...range, type: type === 'all' ? null : type });
|
||||||
|
}, [data, period, type, today]);
|
||||||
|
const periods = useMemo(() => periodOptions(data?.entries ?? [], today), [data, today]);
|
||||||
|
const types = useMemo(() => typeOptions(data?.entries ?? [], today), [data, today]);
|
||||||
|
|
||||||
|
const setExcluded = useCallback(
|
||||||
|
async (entry: TourActivityStatsEntry, excluded: boolean) => {
|
||||||
|
const placeId = entry.place_id;
|
||||||
|
const asked = ++latest.current;
|
||||||
|
inFlight.current.set(placeId, excluded);
|
||||||
|
setPending((current) => new Set(current).add(placeId));
|
||||||
|
setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||||
|
try {
|
||||||
|
const answer = await tourActivityStatsRepo.setExcluded({ place_id: placeId, excluded });
|
||||||
|
inFlight.current.delete(placeId);
|
||||||
|
if (asked === latest.current) setData(withFlags(answer, inFlight.current));
|
||||||
|
else setData((current) => (current ? flagged(current, placeId, excluded) : current));
|
||||||
|
} catch {
|
||||||
|
inFlight.current.delete(placeId);
|
||||||
|
setData((current) => (current ? flagged(current, placeId, !excluded) : current));
|
||||||
|
toast.error(t('tours.stats.excludeFailed'));
|
||||||
|
} finally {
|
||||||
|
setPending((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(placeId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const retry = useCallback(() => setAttempt((count) => count + 1), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
data,
|
||||||
|
summary,
|
||||||
|
period,
|
||||||
|
setPeriod,
|
||||||
|
periods,
|
||||||
|
type,
|
||||||
|
setType,
|
||||||
|
types,
|
||||||
|
pending,
|
||||||
|
setExcluded,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-COMP-BELL-001 to FE-COMP-BELL-020
|
// FE-COMP-BELL-001 to FE-COMP-BELL-021
|
||||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
@@ -238,4 +238,13 @@ describe('InAppNotificationBell', () => {
|
|||||||
// fetchNotifications should be called once per open (2 total)
|
// fetchNotifications should be called once per open (2 total)
|
||||||
expect(fetchNotifications).toHaveBeenCalledTimes(2);
|
expect(fetchNotifications).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BELL-021: unread badge is large enough that the count is not squeezed', () => {
|
||||||
|
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 150, isLoading: false });
|
||||||
|
render(<InAppNotificationBell />);
|
||||||
|
const badge = screen.getByTestId('notification-badge');
|
||||||
|
expect(badge).toHaveTextContent('99+');
|
||||||
|
expect(badge.style.height).toBe('20px');
|
||||||
|
expect(badge.style.minWidth).toBe('20px');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,13 +52,14 @@ export default function InAppNotificationBell(): React.ReactElement {
|
|||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="absolute -top-0.5 -end-0.5 flex items-center justify-center rounded-full text-white font-bold"
|
data-testid="notification-badge"
|
||||||
|
className="absolute -top-1.5 -end-1.5 flex items-center justify-center rounded-full text-white font-bold tabular-nums ring-2 ring-surface-card"
|
||||||
style={{
|
style={{
|
||||||
background: '#ef4444',
|
background: '#ef4444',
|
||||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))',
|
||||||
minWidth: 14,
|
minWidth: 20,
|
||||||
height: 14,
|
height: 20,
|
||||||
padding: '0 3px',
|
padding: '0 5px',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
|
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-024
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
|
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_ROUND_CONTROL_SIZE } from './MapLayerSwitcher'
|
||||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||||
|
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||||
|
|
||||||
describe('MapLayerSwitcher', () => {
|
describe('MapLayerSwitcher', () => {
|
||||||
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
|
it('FE-COMP-MAPLAYER-001: on the default layer, offers the switch to satellite', () => {
|
||||||
@@ -59,6 +60,38 @@ describe('MapLayerSwitcher', () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith('topo')
|
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', () => {
|
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
|
||||||
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
|
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
|
||||||
const button = screen.getByRole('button')
|
const button = screen.getByRole('button')
|
||||||
@@ -203,3 +236,79 @@ describe('TourMapLayerSwitcher via ferrata switch', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
|
expect(screen.getByRole('button', { name: 'Set points' })).toHaveClass('text-content-muted')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pisteOverlay = (overrides: Partial<ToggleableRasterOverlay> = {}): ToggleableRasterOverlay => ({
|
||||||
|
id: 'pistes',
|
||||||
|
url: 'https://tiles.opensnowmap.org/pistes/{z}/{x}/{y}.png',
|
||||||
|
attribution: 'OpenSnowMap',
|
||||||
|
minZoom: 9,
|
||||||
|
maxZoom: 18,
|
||||||
|
active: false,
|
||||||
|
status: 'off',
|
||||||
|
belowMinZoom: false,
|
||||||
|
onToggle: vi.fn(),
|
||||||
|
onTileError: vi.fn(),
|
||||||
|
onTileLoad: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TourMapLayerSwitcher piste switch (SKI-01)', () => {
|
||||||
|
it('FE-COMP-MAPLAYER-021: the pistes switch sits on its icon alone, named by its tooltip, and offers no key while off', async () => {
|
||||||
|
const overlay = pisteOverlay()
|
||||||
|
render(<TourMapLayerSwitcher active="default" onChange={() => {}} ferrataLayer={ferrataLayer()} layers={{ overlays: [overlay] }} />)
|
||||||
|
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||||
|
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||||
|
expect(toggle.textContent).toBe('')
|
||||||
|
expect(screen.queryByTestId('piste-key-button')).toBeNull()
|
||||||
|
fireEvent.click(toggle)
|
||||||
|
expect(overlay.onToggle).toHaveBeenCalledTimes(1)
|
||||||
|
fireEvent.mouseEnter(toggle)
|
||||||
|
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes, cross-country trails and lifts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPLAYER-022: on, the switch is pressed in the accent and the key rides beside it in the same pill', () => {
|
||||||
|
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
|
||||||
|
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||||
|
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(toggle).toHaveClass('bg-accent', 'text-accent-text')
|
||||||
|
const key = screen.getByTestId('piste-key-button')
|
||||||
|
expect(screen.getByRole('group')).toContainElement(key)
|
||||||
|
expect(key).toHaveAccessibleName('Piste key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPLAYER-023: a piste source that stopped answering shows the warning badge, says so, and hides the key', async () => {
|
||||||
|
render(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down' })] }} />)
|
||||||
|
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||||
|
expect(toggle).toHaveAttribute('data-status', 'down')
|
||||||
|
expect(toggle).toHaveTextContent('!')
|
||||||
|
expect(screen.queryByTestId('piste-key-button')).toBeNull()
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading. The map and your route still work.')
|
||||||
|
fireEvent.mouseEnter(toggle)
|
||||||
|
expect(await screen.findByRole('tooltip')).toHaveTextContent('Pistes are not loading.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-COMP-MAPLAYER-024: on further out than the pistes reach, the switch stays pressed in the soft accent and says to zoom in', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on', belowMinZoom: true })] }} />
|
||||||
|
)
|
||||||
|
const toggle = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||||
|
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||||
|
expect(toggle).toHaveAttribute('data-below-min-zoom', 'true')
|
||||||
|
expect(toggle).toHaveClass('bg-accent-soft', 'text-accent')
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Zoom in to see the pistes')
|
||||||
|
fireEvent.mouseEnter(toggle)
|
||||||
|
expect(await screen.findByRole('tooltip')).toHaveTextContent('Zoom in to see the pistes')
|
||||||
|
// The key stays at hand and opens with the same line on top.
|
||||||
|
fireEvent.click(screen.getByTestId('piste-key-button'))
|
||||||
|
expect(await screen.findByTestId('piste-key-zoom-in')).toHaveTextContent('Zoom in to see the pistes')
|
||||||
|
|
||||||
|
// A source that stopped answering says so instead, and close enough the switch is plainly on.
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'down', belowMinZoom: true })] }} />)
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Pistes are not loading.')
|
||||||
|
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={{ overlays: [pisteOverlay({ active: true, status: 'on' })] }} />)
|
||||||
|
const plain = screen.getByRole('button', { name: 'Pistes, cross-country trails and lifts' })
|
||||||
|
expect(plain).not.toHaveAttribute('data-below-min-zoom')
|
||||||
|
expect(plain).toHaveClass('bg-accent', 'text-accent-text')
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react'
|
||||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||||
import type { CSSProperties } from 'react'
|
import { PisteKeyButton } from '../Tours/winter/PisteKey'
|
||||||
|
import type { ComponentType, CSSProperties } from 'react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer'
|
||||||
@@ -9,9 +10,10 @@ import { fs } from '../shared/DialogShell'
|
|||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||||
|
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||||
|
|
||||||
export type BaseLayer = 'default' | 'satellite'
|
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. */
|
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
|
||||||
export interface TourLayerOffer {
|
export interface TourLayerOffer {
|
||||||
@@ -19,6 +21,8 @@ export interface TourLayerOffer {
|
|||||||
overlays?: readonly ToggleableRasterOverlay[]
|
overlays?: readonly ToggleableRasterOverlay[]
|
||||||
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
|
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
|
||||||
paddle?: PaddleMapLayer
|
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 = {
|
const FROSTED_SHELL = {
|
||||||
@@ -83,6 +87,8 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
|||||||
const TOUR_LAYERS = [
|
const TOUR_LAYERS = [
|
||||||
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
|
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
|
||||||
{ id: 'topo', labelKey: null, Icon: Mountain },
|
{ 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 },
|
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -188,16 +194,49 @@ function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How each overlay the planner can offer is named and drawn on its switch. */
|
/** How an overlay the planner can offer is named and drawn on its switch. */
|
||||||
const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; downKey: string }> = {
|
interface OverlayLook {
|
||||||
|
Icon: typeof Anchor
|
||||||
|
labelKey: string
|
||||||
|
downKey: string
|
||||||
|
/** On its icon alone, named by its tooltip, so the row stays short beside a type's other switches. */
|
||||||
|
iconOnly?: boolean
|
||||||
|
/** What the switch says while the map is further out than the overlay's first tiles. */
|
||||||
|
zoomInKey?: string
|
||||||
|
/** The overlay's key, beside its switch while the layer is on; told when nothing of it is drawn yet. */
|
||||||
|
Key?: ComponentType<{ belowMinZoom: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERLAY_LOOK: Record<string, OverlayLook> = {
|
||||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||||
|
// SKI-01: the row already holds the maps and 3D, so the pistes go by their icon.
|
||||||
|
pistes: {
|
||||||
|
Icon: MountainSnow,
|
||||||
|
labelKey: 'tours.map.pistes',
|
||||||
|
downKey: 'tours.map.pistesDown',
|
||||||
|
zoomInKey: 'tours.map.pistesZoomIn',
|
||||||
|
iconOnly: true,
|
||||||
|
Key: PisteKeyButton,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The switch for a transparent overlay (the seamarks), the way the via
|
* The switch for a transparent overlay (the seamarks, the pistes), the way the via
|
||||||
* ferrata switch looks: on in the accent, and in the warning tone with a badge
|
* 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
|
* once its source stopped answering, which the tooltip names. The map and the
|
||||||
* planning go on without it.
|
* planning go on without it. Switched on further out than its first tiles, it
|
||||||
|
* stays pressed in the soft accent and its tooltip says to zoom in, so an empty
|
||||||
|
* map does not read as broken. An overlay with a key shows the key's button
|
||||||
|
* beside the switch while it is on and answering.
|
||||||
*/
|
*/
|
||||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -206,9 +245,12 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
|||||||
const label = t(look.labelKey)
|
const label = t(look.labelKey)
|
||||||
const down = overlay.status === 'down'
|
const down = overlay.status === 'down'
|
||||||
const on = overlay.status !== 'off'
|
const on = overlay.status !== 'off'
|
||||||
|
const zoomIn = !down && overlay.belowMinZoom && look.zoomInKey ? t(look.zoomInKey) : null
|
||||||
|
const notice = down ? t(look.downKey) : zoomIn
|
||||||
|
const tone = zoomIn ? 'bg-accent-soft text-accent' : 'bg-accent text-accent-text'
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip label={down ? t(look.downKey) : label} placement="top">
|
<Tooltip label={notice ?? label} placement="top">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={overlay.onToggle}
|
onClick={overlay.onToggle}
|
||||||
@@ -216,19 +258,17 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
|||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
data-testid={`map-overlay-${overlay.id}`}
|
data-testid={`map-overlay-${overlay.id}`}
|
||||||
data-status={overlay.status}
|
data-status={overlay.status}
|
||||||
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
data-below-min-zoom={zoomIn ? true : undefined}
|
||||||
|
className={on ? tone : 'text-content-muted'}
|
||||||
style={segmentStyle(on)}
|
style={segmentStyle(on)}
|
||||||
>
|
>
|
||||||
<look.Icon size={15} strokeWidth={2} />
|
<look.Icon size={15} strokeWidth={2} />
|
||||||
{!compact && <span>{label}</span>}
|
{!compact && !look.iconOnly && <span>{label}</span>}
|
||||||
{down && (
|
{down && <DownBadge />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
{look.Key && overlay.status === 'on' && <look.Key belowMinZoom={!!zoomIn} />}
|
||||||
|
<span role="status" aria-live="polite" className="sr-only">{notice ?? ''}</span>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -265,14 +305,18 @@ export function TourMapLayerSwitcher({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bases = layers?.bases
|
const bases = layers?.bases
|
||||||
const overlays = layers?.overlays
|
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 (
|
return (
|
||||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||||
const selected = active === id
|
const selected = active === id
|
||||||
const label = labelKey ? t(labelKey) : 'Topo'
|
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 (
|
return (
|
||||||
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
|
<Tooltip key={id} label={down ? downLabel : label} placement="top" disabled={!compact && !down}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(id)}
|
onClick={() => onChange(id)}
|
||||||
@@ -283,10 +327,16 @@ export function TourMapLayerSwitcher({
|
|||||||
>
|
>
|
||||||
<Icon size={15} strokeWidth={2} />
|
<Icon size={15} strokeWidth={2} />
|
||||||
{!compact && <span>{label}</span>}
|
{!compact && <span>{label}</span>}
|
||||||
|
{down && <DownBadge />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{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" />}
|
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ describe('MapView', () => {
|
|||||||
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
|
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 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 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,
|
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
|
||||||
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
|
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.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]))
|
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')} />)
|
rerender(<MapView {...props('satellite')} />)
|
||||||
const satelliteMap = screen.getByTestId('map-container')
|
const satelliteMap = screen.getByTestId('map-container')
|
||||||
expect(satelliteMap).not.toBe(topoMap)
|
expect(satelliteMap).not.toBe(topoMap)
|
||||||
@@ -1136,10 +1141,11 @@ describe('MapView plugin route vias', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('MapView map event wiring', () => {
|
describe('MapView map event wiring', () => {
|
||||||
it('FE-COMP-MAPVIEW-043: reports the viewport bbox once the map is ready and again on every move', () => {
|
it('FE-COMP-MAPVIEW-043: reports the viewport bbox and its zoom once the map is ready and again on every move', () => {
|
||||||
const onViewportChange = vi.fn()
|
const onViewportChange = vi.fn()
|
||||||
|
mapMock.getZoom.mockReturnValue(10)
|
||||||
render(<MapView onViewportChange={onViewportChange} />)
|
render(<MapView onViewportChange={onViewportChange} />)
|
||||||
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 })
|
expect(onViewportChange).toHaveBeenCalledWith({ south: 47, west: 1, north: 49, east: 3 }, 10)
|
||||||
|
|
||||||
const moveend = handlerFor('moveend')
|
const moveend = handlerFor('moveend')
|
||||||
const zoomend = handlerFor('zoomend')
|
const zoomend = handlerFor('zoomend')
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
|||||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||||
import HazardLayers from './HazardLayers'
|
import HazardLayers from './HazardLayers'
|
||||||
|
import { RouteHitBand } from './RouteHitBand'
|
||||||
import { formatViaDwell } from './routeViaDwell'
|
import { formatViaDwell } from './routeViaDwell'
|
||||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||||
@@ -62,7 +63,8 @@ import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './p
|
|||||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||||
import { SelectedTrackLine, TrackLines } from './TrackLines'
|
import { SelectedTrackLine, TrackLines } from './TrackLines'
|
||||||
import DawarichTrailLayer from './DawarichTrailLayer'
|
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 { crsForBasemap } from './gcj02Crs'
|
||||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||||
import VectorBasemap from './VectorBasemap'
|
import VectorBasemap from './VectorBasemap'
|
||||||
@@ -338,14 +340,14 @@ function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||||
// OSM places for the visible area.
|
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
|
||||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onViewportChange) return
|
if (!onViewportChange) return
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
const b = map.getBounds()
|
const b = map.getBounds()
|
||||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
|
||||||
}
|
}
|
||||||
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
||||||
map.on('moveend', emit)
|
map.on('moveend', emit)
|
||||||
@@ -838,7 +840,7 @@ export const MapView = memo(function MapView({
|
|||||||
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
|
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
|
||||||
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
|
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
|
||||||
const isSatellite = baseLayer === 'satellite'
|
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
|
// 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,
|
// that is decides which layer draws it. A saved raster template still wins,
|
||||||
// the default is a vector style.
|
// 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
|
* decided before the map is constructed, because Leaflet cannot change a map's
|
||||||
* CRS afterwards — hence the whole map, not just the tile layer.
|
* 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 gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
|
||||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||||
<Marker
|
<Marker
|
||||||
@@ -1115,17 +1117,16 @@ export const MapView = memo(function MapView({
|
|||||||
updateWhenIdle={true}
|
updateWhenIdle={true}
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
/>
|
/>
|
||||||
) : isTopo ? (
|
) : tourRaster ? (
|
||||||
<TileLayer
|
<TileLayer
|
||||||
key="topo"
|
key={baseLayer}
|
||||||
url={OPENTOPOMAP_TILE_URL}
|
url={tourRaster.url}
|
||||||
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
|
attribution={tourRaster.attribution}
|
||||||
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
|
maxNativeZoom={tourRaster.maxZoom}
|
||||||
maxZoom={MAP_MAX_ZOOM}
|
maxZoom={MAP_MAX_ZOOM}
|
||||||
keepBuffer={8}
|
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
|
||||||
updateWhenZooming={false}
|
|
||||||
updateWhenIdle={true}
|
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
|
eventHandlers={tileHealthHandlers(tourLayers?.baseHealth, baseLayer)}
|
||||||
/>
|
/>
|
||||||
) : basemap.kind === 'vector' ? (
|
) : basemap.kind === 'vector' ? (
|
||||||
<VectorBasemap style={basemap.style} />
|
<VectorBasemap style={basemap.style} />
|
||||||
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
|
|||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
{/* 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
|
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||||
the two, so it is the one a pointer actually lands on. */}
|
|
||||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
{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).
|
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||||
<Polyline
|
<Polyline
|
||||||
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
|
|||||||
key={`${i}-casing`}
|
key={`${i}-casing`}
|
||||||
positions={seg}
|
positions={seg}
|
||||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
interactive={!!onRouteClick}
|
interactive={false}
|
||||||
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}
|
|
||||||
/>,
|
/>,
|
||||||
<Polyline
|
<Polyline
|
||||||
key={`${i}-core`}
|
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' }}
|
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
/>,
|
/>,
|
||||||
|
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
|
||||||
])}
|
])}
|
||||||
|
|
||||||
{/* The last bit to a place the road does not reach.
|
{/* The last bit to a place the road does not reach.
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ beforeEach(() => {
|
|||||||
glMap.querySourceFeatures.mockReturnValue([])
|
glMap.querySourceFeatures.mockReturnValue([])
|
||||||
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'))
|
glMap.getCanvas.mockImplementation(() => document.createElement('canvas'))
|
||||||
glMap.getBearing.mockReturnValue(0)
|
glMap.getBearing.mockReturnValue(0)
|
||||||
|
glMap.getZoom.mockReturnValue(10)
|
||||||
glMap.queryTerrainElevation.mockReturnValue(null)
|
glMap.queryTerrainElevation.mockReturnValue(null)
|
||||||
// A busy style, a refused source and a custom layer list are each set by one test
|
// A busy style, a refused source and a custom layer list are each set by one test
|
||||||
// for the case it covers. Put back here, or every test after it would silently run
|
// for the case it covers. Put back here, or every test after it would silently run
|
||||||
@@ -1575,7 +1576,6 @@ describe('MapViewGL', () => {
|
|||||||
const list = calls[calls.length - 1][0]
|
const list = calls[calls.length - 1][0]
|
||||||
act(() => { list.querySelectorAll('button')[1].click() })
|
act(() => { list.querySelectorAll('button')[1].click() })
|
||||||
expect(onPoiClick).toHaveBeenCalledWith(other)
|
expect(onPoiClick).toHaveBeenCalledWith(other)
|
||||||
vi.mocked(glMap.getZoom).mockReturnValue(10)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const pinGlyph = (Icon: LucideIcon) => renderIconMarkup(React.createElement(Icon, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
const pinGlyph = (Icon: LucideIcon) => renderIconMarkup(React.createElement(Icon, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||||
@@ -2215,13 +2215,13 @@ describe('MapViewGL', () => {
|
|||||||
expect((window as any).__trek_map).toBeUndefined()
|
expect((window as any).__trek_map).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox for the POI explore pill', async () => {
|
it('FE-COMP-MAPVIEWGL-059: pan and zoom report the visible bbox and tile zoom for the POI explore pill', async () => {
|
||||||
const onViewportChange = vi.fn()
|
const onViewportChange = vi.fn()
|
||||||
render(<MapViewGL places={[]} fitKey={1} onViewportChange={onViewportChange} />)
|
render(<MapViewGL places={[]} fitKey={1} onViewportChange={onViewportChange} />)
|
||||||
await act(async () => {})
|
await act(async () => {})
|
||||||
|
|
||||||
act(() => { mapHandler('moveend')() })
|
act(() => { mapHandler('moveend')() })
|
||||||
expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 })
|
expect(onViewportChange).toHaveBeenCalledWith({ south: 0, west: 0, north: 1, east: 1 }, 11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEWGL-060: a new POI or via set replaces the previous overlay markers', async () => {
|
it('FE-COMP-MAPVIEWGL-060: a new POI or via set replaces the previous overlay markers', async () => {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
|||||||
import type mapboxgl from 'mapbox-gl'
|
import type mapboxgl from 'mapbox-gl'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n/TranslationContext'
|
import { useTranslation } from '../../i18n/TranslationContext'
|
||||||
import { applySatellite } from './glRasterLayers'
|
import { applyBaseLayer } from './glRasterLayers'
|
||||||
|
import { reportGlViewport } from './glViewport'
|
||||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||||
import { MapLockPill } from './MapLockPill'
|
import { MapLockPill } from './MapLockPill'
|
||||||
@@ -612,7 +613,7 @@ export function MapViewGL({
|
|||||||
const mapRef = useRef<any | null>(null)
|
const mapRef = useRef<any | null>(null)
|
||||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||||
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
|
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers)
|
||||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -1432,12 +1433,8 @@ export function MapViewGL({
|
|||||||
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle 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 } })
|
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
|
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
|
||||||
// pill can fetch OSM places for the visible area.
|
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
|
||||||
const emitViewport = () => {
|
|
||||||
const b = map.getBounds()
|
|
||||||
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
|
||||||
}
|
|
||||||
map.on('moveend', emitViewport)
|
map.on('moveend', emitViewport)
|
||||||
map.once('idle', emitViewport)
|
map.once('idle', emitViewport)
|
||||||
// Clear the hover card (and the anchored POI popup) as soon as the camera
|
// Clear the hover card (and the anchored POI popup) as soon as the camera
|
||||||
@@ -2185,14 +2182,11 @@ export function MapViewGL({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map || !mapReady) return
|
if (!map || !mapReady) return
|
||||||
const apply = () => {
|
const apply = () => applyBaseLayer(map, baseLayer)
|
||||||
applySatellite(map, isSatellite)
|
|
||||||
applySatellite(map, baseLayer === 'topo', true)
|
|
||||||
}
|
|
||||||
apply()
|
apply()
|
||||||
map.on('styledata', apply)
|
map.on('styledata', apply)
|
||||||
return () => { map.off('styledata', apply) }
|
return () => { map.off('styledata', apply) }
|
||||||
}, [isSatellite, baseLayer, mapReady, glProvider])
|
}, [baseLayer, mapReady, glProvider])
|
||||||
|
|
||||||
if (!isMapLibre && !mapboxToken) {
|
if (!isMapLibre && !mapboxToken) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,18 +2,25 @@ import L from 'leaflet';
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||||
|
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||||
|
|
||||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||||
export type PinCss = (size: number, selected: boolean) => string;
|
export type PinCss = (size: number, selected: boolean) => string;
|
||||||
|
|
||||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
function plannerWaypointIcon(
|
||||||
const size = selected ? 30 : 26;
|
pinCss: PinCss,
|
||||||
|
point: PlannerPinPoint,
|
||||||
|
index: number,
|
||||||
|
selected: boolean,
|
||||||
|
draggable: boolean
|
||||||
|
): L.DivIcon {
|
||||||
|
const { size, text } = plannerPinShape(point, index, selected);
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'tour-planner-waypoint-marker',
|
className: 'tour-planner-waypoint-marker',
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
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>${plannerPinBadge(point)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
|
|||||||
onMove,
|
onMove,
|
||||||
pinCss,
|
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;
|
index: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick?: (id: string) => void;
|
onClick?: (id: string) => void;
|
||||||
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
|
|||||||
handlersRef.current = { onClick, onMove };
|
handlersRef.current = { onClick, onMove };
|
||||||
}, [onClick, onMove]);
|
}, [onClick, onMove]);
|
||||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
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, stageEnd } = point;
|
||||||
|
const icon = useMemo(
|
||||||
|
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
|
||||||
|
[index, label, onMove, pinCss, selected, shaping, stageEnd]
|
||||||
|
);
|
||||||
const eventHandlers = useMemo(
|
const eventHandlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
click: (event: { originalEvent: MouseEvent }) => {
|
click: (event: { originalEvent: MouseEvent }) => {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOver
|
|||||||
maxNativeZoom={overlay.maxZoom}
|
maxNativeZoom={overlay.maxZoom}
|
||||||
maxZoom={22}
|
maxZoom={22}
|
||||||
zIndex={OVERLAY_Z_INDEX}
|
zIndex={OVERLAY_Z_INDEX}
|
||||||
|
// The page's origin as the referer, which the overlays' operators ask for (OpenSnowMap names it).
|
||||||
|
referrerPolicy="strict-origin-when-cross-origin"
|
||||||
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
|
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
import { buildDayRouteRuns, hotelBookendOf } from './dayRoutePlan'
|
||||||
import { projectDayItinerary } from './dayTourProjection'
|
import { projectDayItinerary, tourStageStopsByPlace } from './dayTourProjection'
|
||||||
import { resolveLegMode } from '../Planner/legMode'
|
import { resolveLegMode } from '../Planner/legMode'
|
||||||
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
|
import { planTripRoute, assembleTripRoute, emptyAnswers, summariseTripRoute } from './tripRouteGeometry'
|
||||||
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
import { buildAssignment, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories'
|
||||||
@@ -437,6 +437,54 @@ describe('Tour day itinerary segments', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// MDT-02: a day holding one stage of a multi-day tour routes to that stage and draws its stretch.
|
||||||
|
describe('A stage of a multi-day tour on a day', () => {
|
||||||
|
const line: [number, number][] = [[48, 11], [48.01, 11], [48.02, 11], [48.03, 11], [48.04, 11]]
|
||||||
|
const stops = [
|
||||||
|
{ key: 'stop-start', lat: 48, lng: 11 },
|
||||||
|
{ key: 'stop-hut', lat: 48.02, lng: 11 },
|
||||||
|
{ key: 'stop-end', lat: 48.04, lng: 11 },
|
||||||
|
]
|
||||||
|
const hutToHut = (order: number, tour_stage_key: string | null) => {
|
||||||
|
const assignment = at(48, 11, order)
|
||||||
|
return { ...assignment, tour_place_id: assignment.place.id, tour_route_geometry: JSON.stringify(line), tour_stage_key }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-MAP-DRP-017: draws and routes to the stretch of the stage, the stops telling where it runs', () => {
|
||||||
|
const second = hutToHut(1, 'stop-hut')
|
||||||
|
const stageStops = new Map([[second.place.id, stops]])
|
||||||
|
expect(projectDayItinerary([second], true, [], stageStops)[0]).toMatchObject({
|
||||||
|
kind: 'tour', valid: true, start: { lat: 48.02, lng: 11 }, end: { lat: 48.04, lng: 11 }, geometry: line.slice(2),
|
||||||
|
})
|
||||||
|
const first = hutToHut(1, 'stop-start')
|
||||||
|
expect(projectDayItinerary([first], true, [], new Map([[first.place.id, stops]]))[0]).toMatchObject({
|
||||||
|
start: { lat: 48, lng: 11 }, end: { lat: 48.02, lng: 11 },
|
||||||
|
})
|
||||||
|
const runs = buildDayRouteRuns(1, {
|
||||||
|
...inputs({ assignments: { '1': [at(47.99, 11, 0), second, at(48.05, 11, 2)] } }),
|
||||||
|
toursEnabled: true, tourStageStops: stageStops,
|
||||||
|
})
|
||||||
|
expect(runs.map(run => run.map(point => [point.lat, point.lng]))).toEqual([[[47.99, 11], [48.02, 11]], [[48.04, 11], [48.05, 11]]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MAP-DRP-018: keeps the whole tour where the stops do not tell the stage', () => {
|
||||||
|
const whole = hutToHut(1, null)
|
||||||
|
const unknown = hutToHut(1, 'stop-gone')
|
||||||
|
const last = hutToHut(1, 'stop-end')
|
||||||
|
for (const assignment of [whole, unknown, last]) {
|
||||||
|
expect(projectDayItinerary([assignment], true, [], new Map([[assignment.place.id, stops]]))[0]).toMatchObject({ geometry: line })
|
||||||
|
}
|
||||||
|
const noStops = hutToHut(1, 'stop-hut')
|
||||||
|
expect(projectDayItinerary([noStops], true)[0]).toMatchObject({ geometry: line })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-MAP-DRP-019: reads the stage stops of the multi-day tours off the tour list, none for a day tour', () => {
|
||||||
|
const stages = tourStageStopsByPlace([{ place_id: 4, stage_stops: stops }, { place_id: 5, stage_stops: null }, { place_id: 6 }])
|
||||||
|
expect([...stages.keys()]).toEqual([4])
|
||||||
|
expect(stages.get(4)).toBe(stops)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// #2600: an imported GPX/KML place sits at its track's first point, so routing it as a
|
// #2600: an imported GPX/KML place sits at its track's first point, so routing it as a
|
||||||
// plain stop drew a second road from the track start to the next stop, alongside the
|
// plain stop drew a second road from the track start to the next stop, alongside the
|
||||||
// track itself. Any assigned place with a track now leaves that stretch to the track.
|
// track itself. Any assigned place with a track now leaves that stretch to the track.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarri
|
|||||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||||
import { projectDayItinerary } from './dayTourProjection'
|
import { projectedById, type TourStageStops } from './dayTourProjection'
|
||||||
|
|
||||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
|
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other']
|
||||||
|
|
||||||
@@ -41,6 +41,8 @@ export interface DayRouteInputs {
|
|||||||
optimizeFromAccommodation: boolean | undefined
|
optimizeFromAccommodation: boolean | undefined
|
||||||
toursEnabled?: boolean
|
toursEnabled?: boolean
|
||||||
places?: Place[]
|
places?: Place[]
|
||||||
|
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to it. */
|
||||||
|
tourStageStops?: TourStageStops
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +85,7 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
|||||||
days: allDays,
|
days: allDays,
|
||||||
})
|
})
|
||||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||||
const projected = new Map(projectDayItinerary(da, input.toursEnabled ?? false, input.places).map(item => [item.assignment.id, item]))
|
const projected = projectedById(da, input.toursEnabled ?? false, input.places, input.tourStageStops)
|
||||||
|
|
||||||
type Entry =
|
type Entry =
|
||||||
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
| { kind: 'place'; id: number; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
|
import type { TourListItem, TourStageStop } from '@trek/shared'
|
||||||
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
import { analyzeRouteGeometry } from '../../utils/routeGeometry'
|
||||||
import type { Assignment, Place } from '../../types'
|
import type { Assignment, Place } from '../../types'
|
||||||
|
import { vertexIndicesOf } from '../Tours/stages/tourStages'
|
||||||
|
|
||||||
|
/** MDT-02: the stops each multi-day tour's stages run between, by the tour's place id. */
|
||||||
|
export type TourStageStops = ReadonlyMap<number, readonly TourStageStop[]>
|
||||||
|
|
||||||
|
/** The stage stops of the trip's multi-day tours, from the tour list; a day tour has none. */
|
||||||
|
export function tourStageStopsByPlace(tours: readonly Pick<TourListItem, 'place_id' | 'stage_stops'>[]): TourStageStops {
|
||||||
|
return new Map(tours.flatMap(tour => (tour.stage_stops ? [[tour.place_id, tour.stage_stops] as const] : [])))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MDT-02: the stretch of a tour's line one stage runs, from the stop it starts
|
||||||
|
* at to the stop the next one starts at; null where the stops do not tell it
|
||||||
|
* (a stage the tour no longer has, a line that has not reached them yet).
|
||||||
|
*/
|
||||||
|
function stageStretch(
|
||||||
|
line: [number, number][],
|
||||||
|
stops: readonly TourStageStop[] | undefined,
|
||||||
|
stageKey: string | null | undefined,
|
||||||
|
): [number, number][] | null {
|
||||||
|
if (!stageKey || !stops || line.length < 2) return null
|
||||||
|
const index = stops.findIndex(stop => stop.key === stageKey)
|
||||||
|
if (index < 0 || index >= stops.length - 1) return null
|
||||||
|
const at = vertexIndicesOf(stops, line)
|
||||||
|
return at[index + 1] > at[index] ? line.slice(at[index], at[index + 1] + 1) : null
|
||||||
|
}
|
||||||
|
|
||||||
export type DayItineraryItem =
|
export type DayItineraryItem =
|
||||||
| { kind: 'stop'; assignment: Assignment }
|
| { kind: 'stop'; assignment: Assignment }
|
||||||
@@ -14,10 +41,17 @@ export type DayItineraryItem =
|
|||||||
valid: boolean
|
valid: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A day's stops with each tour projected onto its line: the day's route runs
|
||||||
|
* to where the tour starts and on from where it ends. A day holding one stage
|
||||||
|
* of a multi-day tour (MDT-02) runs to that stage's start and on from its end,
|
||||||
|
* and draws that stage's stretch, where `stageStops` tells them.
|
||||||
|
*/
|
||||||
export function projectDayItinerary(
|
export function projectDayItinerary(
|
||||||
assignments: Assignment[],
|
assignments: Assignment[],
|
||||||
toursEnabled: boolean,
|
toursEnabled: boolean,
|
||||||
places: readonly Place[] = [],
|
places: readonly Place[] = [],
|
||||||
|
stageStops?: TourStageStops,
|
||||||
): DayItineraryItem[] {
|
): DayItineraryItem[] {
|
||||||
const placesById = new Map(places.map(place => [place.id, place]))
|
const placesById = new Map(places.map(place => [place.id, place]))
|
||||||
return assignments.map(assignment => {
|
return assignments.map(assignment => {
|
||||||
@@ -37,7 +71,8 @@ export function projectDayItinerary(
|
|||||||
loop: start.lat === end.lat && start.lng === end.lng, valid: true } as const
|
loop: start.lat === end.lat && start.lng === end.lng, valid: true } as const
|
||||||
}
|
}
|
||||||
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
|
const analysis = analyzeRouteGeometry(currentPlace ? currentPlace.route_geometry : assignment.tour_route_geometry)
|
||||||
const geometry = analysis?.routeCoordinates ?? []
|
const whole = analysis?.routeCoordinates ?? []
|
||||||
|
const geometry = stageStretch(whole, stageStops?.get(placeId), assignment.tour_stage_key) ?? whole
|
||||||
const start = geometry.length >= 2
|
const start = geometry.length >= 2
|
||||||
? { lat: geometry[0][0], lng: geometry[0][1] }
|
? { lat: geometry[0][0], lng: geometry[0][1] }
|
||||||
: assignment.place?.lat != null && assignment.place?.lng != null
|
: assignment.place?.lat != null && assignment.place?.lng != null
|
||||||
@@ -50,4 +85,14 @@ export function projectDayItinerary(
|
|||||||
loop: !!start && !!end && start.lat === end.lat && start.lng === end.lng,
|
loop: !!start && !!end && start.lat === end.lat && start.lng === end.lng,
|
||||||
valid: !!end } as const
|
valid: !!end } as const
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The day's stops projected as `projectDayItinerary` does, by assignment id, for the route that runs through them. */
|
||||||
|
export function projectedById(
|
||||||
|
assignments: Assignment[],
|
||||||
|
toursEnabled: boolean,
|
||||||
|
places?: readonly Place[],
|
||||||
|
stageStops?: TourStageStops,
|
||||||
|
): Map<number, DayItineraryItem> {
|
||||||
|
return new Map(projectDayItinerary(assignments, toursEnabled, places, stageStops).map(item => [item.assignment.id, item]))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { applyBaseLayer, rasterTileZoom, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rasterTileZoom', () => {
|
||||||
|
it('puts a GL zoom on the 256 px tile scale one step further in, where a raster minimum is read', () => {
|
||||||
|
expect(rasterTileZoom(8)).toBe(9);
|
||||||
|
expect(rasterTileZoom(7.5)).toBe(8.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
import {
|
import { SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL } from '../../constants/mapDefaults';
|
||||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
import { TOUR_RASTER_BASE_IDS, TOUR_RASTER_BASES, type TourRasterBase } from './tourRasterBases';
|
||||||
OPENTOPOMAP_TILE_MAXZOOM,
|
|
||||||
OPENTOPOMAP_TILE_URL,
|
|
||||||
SATELLITE_TILE_ATTRIBUTION,
|
|
||||||
SATELLITE_TILE_MAXZOOM,
|
|
||||||
SATELLITE_TILE_URL,
|
|
||||||
} from '../../constants/mapDefaults';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The raster layers a GL map draws between its basemap style and what TREK
|
* The raster layers a GL map draws between its basemap style and what TREK
|
||||||
* adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts)
|
* adds on top: the satellite, topo and hiking base layers, and (in mapRasterOverlay.ts)
|
||||||
* the transparent overlays such as the seamarks.
|
* the transparent overlays such as the seamarks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -33,6 +27,16 @@ const SATELLITE_LAYER_ID = 'trip-satellite-raster';
|
|||||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||||
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GL map's zoom on the 256 px tile scale that raster tile URLs, their
|
||||||
|
* min and max zoom, and Leaflet count in. The GL engines count on 512 px
|
||||||
|
* tiles, so the same view is one step further in on that scale, and a 256 px
|
||||||
|
* raster source is drawn from its tiles one level up.
|
||||||
|
*/
|
||||||
|
export function rasterTileZoom(glZoom: number): number {
|
||||||
|
return glZoom + 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Puts the imagery on the map, or takes it off again.
|
* Puts the imagery on the map, or takes it off again.
|
||||||
*
|
*
|
||||||
@@ -50,9 +54,10 @@ export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
|||||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function applySatellite(map: any, on: boolean, topo = false): void {
|
export function applySatellite(map: any, on: boolean, raster?: TourRasterBase): void {
|
||||||
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
|
const layerId = raster ? `trip-${raster}-raster` : SATELLITE_LAYER_ID;
|
||||||
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
|
const sourceId = raster ? tourRasterSourceId(raster) : SATELLITE_SOURCE_ID;
|
||||||
|
const source = raster ? TOUR_RASTER_BASES[raster] : null;
|
||||||
try {
|
try {
|
||||||
if (map.getLayer(layerId)) {
|
if (map.getLayer(layerId)) {
|
||||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
||||||
@@ -65,12 +70,10 @@ export function applySatellite(map: any, on: boolean, topo = false): void {
|
|||||||
if (!map.getSource(sourceId)) {
|
if (!map.getSource(sourceId)) {
|
||||||
map.addSource(sourceId, {
|
map.addSource(sourceId, {
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
tiles: topo
|
tiles: source ? source.tiles : [SATELLITE_TILE_URL],
|
||||||
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
|
|
||||||
: [SATELLITE_TILE_URL],
|
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
maxzoom: source ? source.maxZoom : SATELLITE_TILE_MAXZOOM,
|
||||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
attribution: source ? source.attribution : SATELLITE_TILE_ATTRIBUTION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||||
@@ -90,3 +93,14 @@ export function applySatellite(map: any, on: boolean, topo = false): void {
|
|||||||
/* a style that refuses the layer keeps the plain basemap */
|
/* 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { reportGlViewport } from './glViewport';
|
||||||
|
|
||||||
|
/** A GL map showing the given box at the given zoom, or no box yet. */
|
||||||
|
function fakeMap(zoom: number, box: { south: number; west: number; north: number; east: number } | null) {
|
||||||
|
const bounds = box && {
|
||||||
|
getSouth: () => box.south,
|
||||||
|
getWest: () => box.west,
|
||||||
|
getNorth: () => box.north,
|
||||||
|
getEast: () => box.east,
|
||||||
|
};
|
||||||
|
return { getBounds: () => bounds, getZoom: () => zoom };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('reportGlViewport', () => {
|
||||||
|
it('reports the visible box with the zoom on the raster tile scale', () => {
|
||||||
|
const report = vi.fn();
|
||||||
|
reportGlViewport(fakeMap(10, { south: 47, west: 11, north: 48, east: 12 }), report);
|
||||||
|
// The GL engine counts on 512 px tiles; a raster tile's zoom is one step further in.
|
||||||
|
expect(report).toHaveBeenCalledWith({ south: 47, west: 11, north: 48, east: 12 }, 11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing while the map has no bounds yet', () => {
|
||||||
|
const report = vi.fn();
|
||||||
|
reportGlViewport(fakeMap(10, null), report);
|
||||||
|
expect(report).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks nothing of the map when no one listens', () => {
|
||||||
|
const map = fakeMap(10, { south: 47, west: 11, north: 48, east: 12 });
|
||||||
|
const getZoom = vi.spyOn(map, 'getZoom');
|
||||||
|
reportGlViewport(map, undefined);
|
||||||
|
expect(getZoom).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { rasterTileZoom } from './glRasterLayers';
|
||||||
|
import type { MapViewProps } from './mapViewContract';
|
||||||
|
|
||||||
|
/** Enough of a GL map to say what it shows: its bounds and its zoom. */
|
||||||
|
interface ViewportSource {
|
||||||
|
getBounds(): { getSouth(): number; getWest(): number; getNorth(): number; getEast(): number } | null;
|
||||||
|
getZoom(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the page what a GL map shows, so the POI explore pill can fetch the
|
||||||
|
* OSM places of the visible area: its bounds, and its zoom on the raster tile
|
||||||
|
* scale Leaflet reports too, so both maps ask for the same places at the
|
||||||
|
* same view. A map without bounds yet says nothing.
|
||||||
|
*/
|
||||||
|
export function reportGlViewport(map: ViewportSource, report: MapViewProps['onViewportChange']): void {
|
||||||
|
const bounds = map.getBounds();
|
||||||
|
if (!report || !bounds) return;
|
||||||
|
const bbox = { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() };
|
||||||
|
report(bbox, rasterTileZoom(map.getZoom()));
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay';
|
import {
|
||||||
|
useRasterOverlaysGL,
|
||||||
|
useSourceTileHealthGL,
|
||||||
|
type MapRasterOverlay,
|
||||||
|
type RasterOverlayGlMap,
|
||||||
|
} from './mapRasterOverlay';
|
||||||
|
|
||||||
type Handler = (event: Record<string, unknown>) => void;
|
type Handler = (event: Record<string, unknown>) => void;
|
||||||
|
|
||||||
@@ -103,3 +108,27 @@ describe('useRasterOverlaysGL', () => {
|
|||||||
expect(map.on).not.toHaveBeenCalled();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerG
|
|||||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||||
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
|
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
|
||||||
import { OWN_LAYER_PREFIXES } from './glRasterLayers';
|
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
|
* A transparent raster layer laid over the basemap and under the route, such
|
||||||
@@ -30,6 +31,8 @@ type RasterOverlayStatus = 'off' | 'on' | 'down';
|
|||||||
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
|
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
|
||||||
export interface ToggleableRasterOverlay extends MapRasterOverlay {
|
export interface ToggleableRasterOverlay extends MapRasterOverlay {
|
||||||
status: RasterOverlayStatus;
|
status: RasterOverlayStatus;
|
||||||
|
/** On, but the map is further out than its first tiles, so nothing of it is drawn yet. */
|
||||||
|
belowMinZoom: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,16 +107,47 @@ export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: re
|
|||||||
}, [map, overlays]);
|
}, [map, overlays]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
|
/**
|
||||||
|
* 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(
|
export function useTourMapLayersGL(
|
||||||
map: RasterOverlayGlMap | null,
|
map: RasterOverlayGlMap | null,
|
||||||
ferrataLayer: FerrataMapLayer | undefined,
|
ferrataLayer: FerrataMapLayer | undefined,
|
||||||
overlays: readonly MapRasterOverlay[] | undefined,
|
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
|
||||||
paddle?: PaddleMapLayer
|
|
||||||
) {
|
) {
|
||||||
useFerrataMapLayerGL(map, ferrataLayer);
|
useFerrataMapLayerGL(map, ferrataLayer);
|
||||||
useRasterOverlaysGL(map, overlays);
|
useRasterOverlaysGL(map, layers?.overlays);
|
||||||
usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle);
|
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
|
||||||
|
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -110,7 +110,17 @@ export interface MapViewProps {
|
|||||||
onHighlightAlternative?: (index: number | null) => void;
|
onHighlightAlternative?: (index: number | null) => void;
|
||||||
/** Generic numbered control points for list-first route editors. */
|
/** 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. */
|
/** `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;
|
||||||
|
/** MDT-01: a stage of a multi-day tour ends here; the pin carries a moon at its corner. */
|
||||||
|
stageEnd?: boolean;
|
||||||
|
}>;
|
||||||
selectedPlannerWaypointId?: string | null;
|
selectedPlannerWaypointId?: string | null;
|
||||||
onPlannerWaypointClick?: (id: string) => void;
|
onPlannerWaypointClick?: (id: string) => void;
|
||||||
/** Drops a dragged control point; returns false when the move was refused. */
|
/** Drops a dragged control point; returns false when the move was refused. */
|
||||||
@@ -172,7 +182,11 @@ export interface MapViewProps {
|
|||||||
roadtripVias?: Record<number, RoadtripVia[]>;
|
roadtripVias?: Record<number, RoadtripVia[]>;
|
||||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void;
|
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void;
|
||||||
onRemoveVia?: (dayId: number, id: number) => void;
|
onRemoveVia?: (dayId: number, id: number) => void;
|
||||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void;
|
/**
|
||||||
|
* The visible area after every move, and the zoom on the 256 px tile scale
|
||||||
|
* raster tile URLs count in (a GL map's own zoom is one step lower there).
|
||||||
|
*/
|
||||||
|
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }, zoom: number) => void;
|
||||||
/**
|
/**
|
||||||
* The raster tile URL template. Only the Leaflet renderer draws raster tiles;
|
* The raster tile URL template. Only the Leaflet renderer draws raster tiles;
|
||||||
* the GL renderer draws the style of the chosen provider and ignores it.
|
* the GL renderer draws the style of the chosen provider and ignores it.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||||
|
// FE-PLANNER-PIN-002: the stop where a stage of a multi-day tour ends carries the moon at its corner (MDT-01).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { plannerPinBadge, 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: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PIN-002: marks a stage end with the moon at the corner, and nothing else', () => {
|
||||||
|
const badge = plannerPinBadge({ stageEnd: true, label: '3' });
|
||||||
|
expect(badge).toContain('data-stage-end="true"');
|
||||||
|
expect(badge).toContain('<svg');
|
||||||
|
expect(badge).toContain('inset-inline-end');
|
||||||
|
expect(plannerPinBadge({ label: '3' })).toBe('');
|
||||||
|
expect(plannerPinBadge({ stageEnd: true, shaping: true })).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Moon } from 'lucide-react';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderIconMarkup } from '../../utils/iconMarkup';
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
/** MDT-01: a stage of a multi-day tour ends at this stop. */
|
||||||
|
stageEnd?: 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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOON = renderIconMarkup(createElement(Moon, { size: 9, strokeWidth: 2.6, 'aria-hidden': true }));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MDT-01: the moon at the corner of a pin where a stage ends, the night
|
||||||
|
* between two days of the tour, as the planner's list marks the stop's number.
|
||||||
|
* Laid over the pin, so it needs no room of its own and moves with it.
|
||||||
|
*/
|
||||||
|
export function plannerPinBadge(point: PlannerPinPoint): string {
|
||||||
|
if (!point.stageEnd || point.shaping) return '';
|
||||||
|
return `<span data-stage-end="true" aria-hidden="true" style="position:absolute;bottom:-4px;inset-inline-end:-4px;display:grid;place-items:center;width:15px;height:15px;border-radius:9999px;background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow-sm);pointer-events:none">${MOON}</span>`;
|
||||||
|
}
|
||||||
@@ -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, Store, type LucideIcon } from 'lucide-react'
|
||||||
|
import type { TourPoiCategory } from '@trek/shared'
|
||||||
import { parsePluginPoiCategoryKey, type PluginPoiDetail } 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
|
// 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.
|
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||||
@@ -40,9 +41,41 @@ export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
|||||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
({ 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') },
|
||||||
|
// CW-05: a city walk's own, each in the colour of the explore category it comes from.
|
||||||
|
museum: { key: 'museum', labelKey: 'tours.poi.cat.museum', Icon: Landmark, color: coreColor('museum') },
|
||||||
|
park: { key: 'park', labelKey: 'tours.poi.cat.park', Icon: Trees, color: coreColor('nature') },
|
||||||
|
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
|
||||||
|
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
|
||||||
|
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
|
||||||
|
}
|
||||||
|
|
||||||
// A Map rather than an object, so a category named after an Object.prototype member
|
// A Map rather than an object, so a category named after an Object.prototype member
|
||||||
// (`constructor`, `toString`) finds nothing rather than a function.
|
// (`constructor`, `toString`) finds nothing rather than a function. A Tours category
|
||||||
const CATEGORY_BY_KEY = new Map([...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const))
|
// 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).
|
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||||
export interface Poi {
|
export interface Poi {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -107,6 +107,7 @@ export function planTripRoute(input: TripRouteInputs, profile: RouteProfileKey):
|
|||||||
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
|
(input.assignments[String(day.id)] ?? []).filter(assignment => assignment.accommodation_id == null),
|
||||||
input.toursEnabled ?? false,
|
input.toursEnabled ?? false,
|
||||||
input.places,
|
input.places,
|
||||||
|
input.tourStageStops,
|
||||||
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
|
).flatMap(item => item.kind === 'tour' && item.geometry.length >= 2 ? [item.geometry] : []),
|
||||||
}))
|
}))
|
||||||
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
.filter(entry => entry.runs.length > 0 || entry.tourLines.length > 0)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// FE-PLANNER-PIN-GL-001: the GL map's planner pins carry the moon where a stage of a multi-day tour ends (MDT-01).
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL';
|
||||||
|
|
||||||
|
describe('usePlannerWaypointMarkersGL', () => {
|
||||||
|
it('FE-PLANNER-PIN-GL-001: puts the moon on the pin of a stage end only, numbered as the list numbers it', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
const map = { getContainer: () => container, unproject: () => ({ lat: 0, lng: 0 }) };
|
||||||
|
const attached: HTMLElement[] = [];
|
||||||
|
renderHook(() =>
|
||||||
|
usePlannerWaypointMarkersGL({
|
||||||
|
mapRef: { current: map },
|
||||||
|
mapReady: true,
|
||||||
|
pinsRef: { current: [] },
|
||||||
|
attachPin: (el) => {
|
||||||
|
attached.push(el);
|
||||||
|
return { remove: vi.fn(), setLngLat: vi.fn() };
|
||||||
|
},
|
||||||
|
pinCss: () => 'display:flex',
|
||||||
|
plannerWaypoints: [
|
||||||
|
{ id: 'a', lat: 47, lng: 11, label: '1' },
|
||||||
|
{ id: 'b', lat: 47.1, lng: 11, label: '2', stageEnd: true },
|
||||||
|
],
|
||||||
|
selectedPlannerWaypointId: null,
|
||||||
|
onPlannerWaypointClick: vi.fn(),
|
||||||
|
onPlannerWaypointMove: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(attached).toHaveLength(2);
|
||||||
|
expect(attached[0].querySelector('[data-stage-end]')).toBeNull();
|
||||||
|
expect(attached[1].querySelector('[data-stage-end]')).not.toBeNull();
|
||||||
|
expect(attached[1].textContent).toBe('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||||
import type { MapViewProps } from './mapViewContract';
|
import type { MapViewProps } from './mapViewContract';
|
||||||
|
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
|
||||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||||
|
|
||||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||||
@@ -71,13 +72,16 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
|||||||
|
|
||||||
for (const [index, point] of plannerWaypoints.entries()) {
|
for (const [index, point] of plannerWaypoints.entries()) {
|
||||||
const selected = point.id === selectedPlannerWaypointId;
|
const selected = point.id === selectedPlannerWaypointId;
|
||||||
const size = selected ? 30 : 26;
|
const { size, text } = plannerPinShape(point, index, selected);
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
el.textContent = String(index + 1);
|
el.textContent = text;
|
||||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||||
el.setAttribute('aria-label', name);
|
el.setAttribute('aria-label', name);
|
||||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||||
|
// MDT-01: the moon where a stage ends; the pin is positioned, so the badge sits at its corner.
|
||||||
|
const badge = plannerPinBadge(point);
|
||||||
|
if (badge) el.insertAdjacentHTML('beforeend', badge);
|
||||||
const swallow = (event: Event) => event.stopPropagation();
|
const swallow = (event: Event) => event.stopPropagation();
|
||||||
const select = (event: Event) => {
|
const select = (event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import type { RoadtripVia } from '@trek/shared'
|
import type { RoadtripVia } from '@trek/shared'
|
||||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
import type { Accommodation, AssignmentsMap, Day, Place, Reservation } from '../../types'
|
||||||
|
import type { TourStageStops } from './dayTourProjection'
|
||||||
|
|
||||||
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
|
export type { TripOverviewDay, TripRouteSummary } from './tripRouteGeometry'
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ export function useTripRouteOverview(
|
|||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
places: Place[] = EMPTY_PLACES,
|
places: Place[] = EMPTY_PLACES,
|
||||||
vias?: TripRouteVias,
|
vias?: TripRouteVias,
|
||||||
|
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one draws that stage. */
|
||||||
|
tourStageStops?: TourStageStops,
|
||||||
): TripRouteOverview {
|
): TripRouteOverview {
|
||||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||||
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
const toursEnabled = useAddonStore(s => s.isEnabled('tours'))
|
||||||
@@ -66,9 +69,9 @@ export function useTripRouteOverview(
|
|||||||
const viasByDay = vias?.viasByDay
|
const viasByDay = vias?.viasByDay
|
||||||
const plan = useMemo(
|
const plan = useMemo(
|
||||||
() => (enabled
|
() => (enabled
|
||||||
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay }, profile)
|
? planTripRoute({ days, assignments, reservations, accommodations, optimizeFromAccommodation, toursEnabled, places, storedAssignments, viasByDay, tourStageStops }, profile)
|
||||||
: []),
|
: []),
|
||||||
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay],
|
[enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation, profile, toursEnabled, places, storedAssignments, viasByDay, tourStageStops],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
|
|||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { server } from '../../../tests/helpers/msw/server'
|
import { server } from '../../../tests/helpers/msw/server'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
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'
|
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||||
|
|
||||||
@@ -519,6 +519,23 @@ describe('valhallaAlternates', () => {
|
|||||||
expect(bodies[1]).not.toHaveProperty('alternates')
|
expect(bodies[1]).not.toHaveProperty('alternates')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('VALHALLA-ALT-005: a section of a hike is offered ways on foot under its own limit and preferences (RTE-02)', async () => {
|
||||||
|
const bodies: Record<string, unknown>[] = []
|
||||||
|
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||||
|
bodies.push(await request.json() as Record<string, unknown>)
|
||||||
|
return HttpResponse.json(withAlternates([answer({ time: 12_000 })]))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ways = await valhallaAlternates(HAMBURG, BERLIN, 'walking', [], 3, undefined, { max_hiking_difficulty: 4, use_hills: 0.2 })
|
||||||
|
|
||||||
|
expect(ways).toHaveLength(2)
|
||||||
|
expect(bodies[0]).toMatchObject({
|
||||||
|
alternates: 3,
|
||||||
|
costing: 'pedestrian',
|
||||||
|
costing_options: { pedestrian: { max_hiking_difficulty: 4, use_hills: 0.2 } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
|
it('VALHALLA-ALT-004: a refusal is asked once more, then is no answer; an instance with its own OSRM asks nothing', async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
|
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return new HttpResponse(null, { status: 429 }) }))
|
||||||
@@ -531,3 +548,85 @@ describe('valhallaAlternates', () => {
|
|||||||
expect(calls).toBe(2)
|
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-005: a run is matched on foot without a hiking limit, and every line is asked its hiking grades (RUN-02)', 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 valhallaTraceAttributes(SHAPE_POINTS, undefined, 'run')
|
||||||
|
expect(bodies[0]).toMatchObject({
|
||||||
|
costing: 'pedestrian',
|
||||||
|
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
|
||||||
|
shape_match: 'map_snap',
|
||||||
|
})
|
||||||
|
expect((bodies[0].filters as { attributes: string[] }).attributes).toContain('edge.sac_scale')
|
||||||
|
})
|
||||||
|
|
||||||
|
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-006: a hike is matched on foot with every grade of path open, never by its routing limit (MAP-03)', 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 valhallaTraceAttributes(SHAPE_POINTS, undefined, 'foot')
|
||||||
|
expect(bodies[0]).toMatchObject({
|
||||||
|
costing: 'pedestrian',
|
||||||
|
costing_options: { pedestrian: { max_hiking_difficulty: 6 } },
|
||||||
|
shape_match: 'map_snap',
|
||||||
|
})
|
||||||
|
expect(JSON.stringify(bodies[0])).not.toContain('sac_scale')
|
||||||
|
})
|
||||||
|
|
||||||
|
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',
|
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.
|
* 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',
|
profile: 'driving' | 'walking' | 'cycling',
|
||||||
avoid: readonly AvoidClass[],
|
avoid: readonly AvoidClass[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
costingOptions: Readonly<Record<string, number>> = {},
|
costingOptions: Readonly<CostingOptions> = {},
|
||||||
): Promise<ValhallaRun | null> {
|
): Promise<ValhallaRun | null> {
|
||||||
const base = valhallaBase()
|
const base = valhallaBase()
|
||||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||||
@@ -315,14 +318,14 @@ function routeBody(
|
|||||||
waypoints: Waypoint[],
|
waypoints: Waypoint[],
|
||||||
profile: 'driving' | 'walking' | 'cycling',
|
profile: 'driving' | 'walking' | 'cycling',
|
||||||
avoid: readonly AvoidClass[],
|
avoid: readonly AvoidClass[],
|
||||||
costingOptions: Readonly<Record<string, number>> = {},
|
costingOptions: Readonly<CostingOptions> = {},
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const costing = COSTING[profile]
|
const costing = COSTING[profile]
|
||||||
// All of them in one request: the options are independent weightings, so asking to
|
// 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
|
// leave out tolls and ferries together is one question rather than two routes to
|
||||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
// 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
|
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||||
return {
|
return {
|
||||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||||
@@ -359,6 +362,83 @@ 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a line is matched to the ways under it: as a mountain bike rides, the
|
||||||
|
* bicycle costing that refuses the fewest ways, so a road bike's route and a
|
||||||
|
* gravel one are described alike; or on foot with every grade of path open,
|
||||||
|
* since a hike's line (MAP-03) may run on a T6 ridge the matcher must not
|
||||||
|
* refuse, and steps and mountain paths a run takes (RUN-02) stay on the line
|
||||||
|
* (matched as a bike, a trail run over steps was described as the street
|
||||||
|
* beside them). Matching is no routing: the limit a hike was planned with
|
||||||
|
* plays no part. A hike and a run are matched alike; they differ in whether
|
||||||
|
* the router's own hiking grade is asked for.
|
||||||
|
*/
|
||||||
|
const TRACE_COSTING = {
|
||||||
|
bike: { costing: 'bicycle', costing_options: { bicycle: { bicycle_type: 'Mountain' } } },
|
||||||
|
foot: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
|
||||||
|
run: { costing: 'pedestrian', costing_options: { pedestrian: { max_hiking_difficulty: 6 } } },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TraceMatching = keyof typeof TRACE_COSTING
|
||||||
|
|
||||||
|
const TRACE_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']
|
||||||
|
|
||||||
|
/** The router's hiking grade is asked along a bike tour's and a run's line, never a hike's (MAP-03 reads OpenStreetMap). */
|
||||||
|
function traceAttributesFor(matching: TraceMatching): string[] {
|
||||||
|
return matching === 'foot' ? TRACE_ATTRIBUTES : [...TRACE_ATTRIBUTES, 'edge.sac_scale']
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BIK-01, RUN-02 and MAP-03: how the router describes the ways under a line
|
||||||
|
* (`trace_attributes`): the surface, the use and road class, the cycle lane,
|
||||||
|
* the OpenStreetMap way of every edge and, but for a hike, the router's
|
||||||
|
* hiking grade (`sac_scale`, 0 where the way has none), with the line as
|
||||||
|
* matched. A run's grade shares (RUN-02) read the router's grade; the grade a
|
||||||
|
* hike shows (MAP-03) comes from OpenStreetMap itself, since the router's
|
||||||
|
* reading may be normalised or derived, so it is not asked for there. 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,
|
||||||
|
matching: TraceMatching = 'bike'
|
||||||
|
): 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,
|
||||||
|
...TRACE_COSTING[matching],
|
||||||
|
shape_match: 'map_snap',
|
||||||
|
filters: {
|
||||||
|
attributes: traceAttributesFor(matching),
|
||||||
|
action: 'include',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: bounded,
|
||||||
|
})
|
||||||
|
if (!response.ok) return null
|
||||||
|
return await response.json()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** One request, at most ten locations. */
|
/** One request, at most ten locations. */
|
||||||
async function requestRun(
|
async function requestRun(
|
||||||
base: string,
|
base: string,
|
||||||
@@ -366,7 +446,7 @@ async function requestRun(
|
|||||||
profile: 'driving' | 'walking' | 'cycling',
|
profile: 'driving' | 'walking' | 'cycling',
|
||||||
avoid: readonly AvoidClass[],
|
avoid: readonly AvoidClass[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
costingOptions: Readonly<Record<string, number>> = {},
|
costingOptions: Readonly<CostingOptions> = {},
|
||||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
|
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
|
||||||
return data === null ? null : runFrom(data)
|
return data === null ? null : runFrom(data)
|
||||||
@@ -380,6 +460,8 @@ async function requestRun(
|
|||||||
* quickest road beside a day Valhalla had routed was offered as the faster way, and
|
* quickest road beside a day Valhalla had routed was offered as the faster way, and
|
||||||
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
|
* taking it changed nothing. Valhalla answers `alternates` between exactly two locations,
|
||||||
* which is all one leg is, and weighs every one of them the way it weighed the day.
|
* which is all one leg is, and weighs every one of them the way it weighed the day.
|
||||||
|
* `costingOptions` carries the rest of how that day was weighed, such as a hike's limit
|
||||||
|
* and preferences (RTE-02), so a section of a tour is offered ways under its own rules.
|
||||||
*/
|
*/
|
||||||
export async function valhallaAlternates(
|
export async function valhallaAlternates(
|
||||||
from: Waypoint,
|
from: Waypoint,
|
||||||
@@ -388,10 +470,11 @@ export async function valhallaAlternates(
|
|||||||
avoid: readonly AvoidClass[],
|
avoid: readonly AvoidClass[],
|
||||||
count: number,
|
count: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
costingOptions: Readonly<CostingOptions> = {},
|
||||||
): Promise<ValhallaLeg[] | null> {
|
): Promise<ValhallaLeg[] | null> {
|
||||||
const base = valhallaBase()
|
const base = valhallaBase()
|
||||||
if (!base || notValhalla.has(base)) return null
|
if (!base || notValhalla.has(base)) return null
|
||||||
const body = { ...routeBody([from, to], profile, avoid), ...(count > 0 ? { alternates: count } : {}) }
|
const body = { ...routeBody([from, to], profile, avoid, costingOptions), ...(count > 0 ? { alternates: count } : {}) }
|
||||||
return askWithRetry(base, async () => {
|
return askWithRetry(base, async () => {
|
||||||
const data = await postRoute(base, body, signal)
|
const data = await postRoute(base, body, signal)
|
||||||
return data === null ? null : alternatesFrom(data)
|
return data === null ? null : alternatesFrom(data)
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export const KAT_COLORS = [
|
|||||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
||||||
|
|
||||||
// A category's first item is seeded with this sentinel because the server
|
// A category's first item is seeded with this sentinel because the server
|
||||||
// rejects empty names. Treat it as a placeholder in the UI.
|
// rejects empty names. Treat it as a placeholder in the UI. Shared, because a
|
||||||
export const PACKING_PLACEHOLDER_NAME = '...'
|
// tour's packing link (P2D-XD-01) leaves it out the same way.
|
||||||
|
export { PACKING_PLACEHOLDER_NAME } from '@trek/shared'
|
||||||
|
|
||||||
/** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */
|
/** What the import's file picker offers: CSV rows, plain text, or a Markdown list (#875). */
|
||||||
export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown'
|
export const PACKING_IMPORT_ACCEPT = '.csv,.txt,.md,.markdown,text/markdown'
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import '../../styles/fadeEnd.css';
|
|||||||
import type { Category, Place } from '../../types';
|
import type { Category, Place } from '../../types';
|
||||||
import { formatTime } from '../../utils/formatters';
|
import { formatTime } from '../../utils/formatters';
|
||||||
import { fs } from '../shared/DialogShell';
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import { stageNumberOf } from '../Tours/stages/stageNames';
|
||||||
|
import { TourStageDayPills } from '../Tours/stages/TourStageDayPills';
|
||||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||||
|
import { TourDayTimeText } from '../Tours/TourDayTimeParts';
|
||||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||||
|
import { WalkProgressPill } from '../Tours/walk/WalkStations';
|
||||||
import { TimePill } from './planParts';
|
import { TimePill } from './planParts';
|
||||||
|
|
||||||
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
||||||
@@ -19,6 +23,10 @@ interface DayPlanPlaceSublineProps {
|
|||||||
tourTimes: DayRowTourTimes | undefined;
|
tourTimes: DayRowTourTimes | undefined;
|
||||||
isTourPlace: boolean;
|
isTourPlace: boolean;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
|
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
|
||||||
|
assignmentId?: number;
|
||||||
|
/** MDT-02: the stage of a multi-day tour the day holds, by its starting stop's key; absent for the whole tour. */
|
||||||
|
tourStageKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
||||||
@@ -57,9 +65,9 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
|||||||
<TimePill>
|
<TimePill>
|
||||||
<span
|
<span
|
||||||
data-testid="trip-plan-tour-planned-duration"
|
data-testid="trip-plan-tour-planned-duration"
|
||||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${plannedTotalText(tourTimes, t)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||||
>
|
>
|
||||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
|
{t('tours.planner.plannedShort')}: {plannedTotalText(tourTimes, t, true)}
|
||||||
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</TimePill>
|
</TimePill>
|
||||||
@@ -68,6 +76,12 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tour's own pills, or a stage's where the day holds one stage of a multi-day tour (MDT-02).
|
||||||
|
function DayRowTourPills({ tour, stageKey }: { tour: TourListItem; stageKey?: string | null }) {
|
||||||
|
if (stageKey && stageNumberOf(tour, stageKey) !== null) return <TourStageDayPills tour={tour} stageKey={stageKey} />;
|
||||||
|
return <TourDayRowPills tour={tour} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Description line, one line like every stop's.
|
// Description line, one line like every stop's.
|
||||||
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
|
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +104,15 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
|
|||||||
|
|
||||||
// Second line of a place row in the day plan: time, tour facts or planned
|
// Second line of a place row in the day plan: time, tour facts or planned
|
||||||
// tour durations, then the clamped description.
|
// tour durations, then the clamped description.
|
||||||
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
|
export function DayPlanPlaceSubline({
|
||||||
|
place,
|
||||||
|
cat,
|
||||||
|
tourTimes,
|
||||||
|
isTourPlace,
|
||||||
|
timeFormat,
|
||||||
|
assignmentId,
|
||||||
|
tourStageKey,
|
||||||
|
}: DayPlanPlaceSublineProps) {
|
||||||
const { locale } = useTranslation();
|
const { locale } = useTranslation();
|
||||||
const dayRowTour = tourTimes?.tour;
|
const dayRowTour = tourTimes?.tour;
|
||||||
const description = place.description || place.address || cat?.name;
|
const description = place.description || place.address || cat?.name;
|
||||||
@@ -99,13 +121,27 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
|
|||||||
// One line like the row's name: when the hover controls come out, the description
|
// One line like the row's name: when the hover controls come out, the description
|
||||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||||
{place.place_time && (
|
{place.place_time && dayRowTour && (
|
||||||
|
// TIME-02: a tour's end, typed or worked out from its start and its time.
|
||||||
|
<TimePill quiet>
|
||||||
|
<TourDayTimeText
|
||||||
|
entry={{ start: place.place_time, end: place.end_time, stageKey: tourStageKey }}
|
||||||
|
tour={dayRowTour}
|
||||||
|
/>
|
||||||
|
</TimePill>
|
||||||
|
)}
|
||||||
|
{place.place_time && !dayRowTour && (
|
||||||
<TimePill quiet>
|
<TimePill quiet>
|
||||||
{formatTime(place.place_time, locale, timeFormat)}
|
{formatTime(place.place_time, locale, timeFormat)}
|
||||||
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||||
</TimePill>
|
</TimePill>
|
||||||
)}
|
)}
|
||||||
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
{dayRowTour ? (
|
||||||
|
<DayRowTourPills tour={dayRowTour} stageKey={tourStageKey} />
|
||||||
|
) : (
|
||||||
|
tourTimes && <PlannedTourPills tourTimes={tourTimes} />
|
||||||
|
)}
|
||||||
|
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
|
||||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||||
|
// CW-08: public transit between two city walks from the day plan's connector.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { TourListItem } from '@trek/shared';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
import {
|
||||||
|
buildAssignment,
|
||||||
|
buildDay,
|
||||||
|
buildPlace,
|
||||||
|
buildReservation,
|
||||||
|
buildTrip,
|
||||||
|
buildUser,
|
||||||
|
} from '../../../tests/helpers/factories';
|
||||||
import { server } from '../../../tests/helpers/msw/server';
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
import { render, screen } from '../../../tests/helpers/render';
|
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||||
import { useAddonStore } from '../../store/addonStore';
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
|
import type { Reservation } from '../../types';
|
||||||
|
import { tourPlannedTimes } from '../Tours/tourPresentation';
|
||||||
import DayPlanSidebar from './DayPlanSidebar';
|
import DayPlanSidebar from './DayPlanSidebar';
|
||||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||||
const mockDayNotesState = vi.hoisted(() => ({
|
const mockDayNotesState = vi.hoisted(() => ({
|
||||||
@@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({
|
|||||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
// One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText.
|
||||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
distanceText: '2 km',
|
distanceText: '2 km',
|
||||||
durationText: '10 min',
|
durationText: '10 min',
|
||||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
legs: waypoints.slice(1).map((to, index) => ({
|
||||||
|
from: [waypoints[index]?.lat, waypoints[index]?.lng],
|
||||||
|
to: [to.lat, to.lng],
|
||||||
distanceText: '2 km',
|
distanceText: '2 km',
|
||||||
durationText: '10 min',
|
durationText: '10 min',
|
||||||
drivingText: '10 min',
|
drivingText: '10 min',
|
||||||
@@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// CW-08: a walk's stops are read for the names of a transit leg's two ends.
|
||||||
|
const mockTourDetail = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||||
|
return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } };
|
||||||
|
});
|
||||||
|
|
||||||
// PlaceAvatar needs IntersectionObserver
|
// PlaceAvatar needs IntersectionObserver
|
||||||
class MockIO {
|
class MockIO {
|
||||||
observe = vi.fn();
|
observe = vi.fn();
|
||||||
@@ -160,7 +181,15 @@ describe('DayPlanSidebar', () => {
|
|||||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
|
const times = {
|
||||||
|
walkingMinutes: 60,
|
||||||
|
breakMinutes: 35,
|
||||||
|
plannedTotalMinutes: 95,
|
||||||
|
manuallyOverridden: false,
|
||||||
|
walk: null,
|
||||||
|
atLeast: false,
|
||||||
|
estimate: null,
|
||||||
|
};
|
||||||
const props = makeDefaultProps({
|
const props = makeDefaultProps({
|
||||||
days: [day],
|
days: [day],
|
||||||
places: [place],
|
places: [place],
|
||||||
@@ -189,6 +218,22 @@ describe('DayPlanSidebar', () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
||||||
|
|
||||||
|
// CW-02: a city walk whose stay is not entered yet plans at least its known total.
|
||||||
|
view.rerender(
|
||||||
|
<DayPlanSidebar
|
||||||
|
{...{
|
||||||
|
...props,
|
||||||
|
plannedTourDurations: new Map([
|
||||||
|
[place.id, { ...times, breakMinutes: null, plannedTotalMinutes: 135, atLeast: true }],
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const walkTotal = screen.getByTestId('trip-plan-tour-planned-duration');
|
||||||
|
expect(walkTotal).toHaveTextContent('Planned: ≥ 2 h 15 min');
|
||||||
|
expect(walkTotal).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
|
||||||
|
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||||
|
|
||||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||||
view.rerender(<DayPlanSidebar {...props} />);
|
view.rerender(<DayPlanSidebar {...props} />);
|
||||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
||||||
@@ -278,7 +323,10 @@ describe('DayPlanSidebar', () => {
|
|||||||
render(<DayPlanSidebar {...props} />);
|
render(<DayPlanSidebar {...props} />);
|
||||||
|
|
||||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||||
expect(screen.queryByTestId('trip-plan-tour-time')).not.toBeInTheDocument();
|
// TIME-01: without a time of its own the import shows its estimate, worked out from its measures.
|
||||||
|
const time = screen.getByTestId('trip-plan-tour-time');
|
||||||
|
expect(time).toHaveTextContent('≈ 2 h 21 min');
|
||||||
|
expect(time).toHaveAccessibleName(expect.stringContaining('Estimated time: 2 h 21 min'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RC-01: a tour row offers no booking, a tour is not booked', () => {
|
it('RC-01: a tour row offers no booking, a tour is not booked', () => {
|
||||||
@@ -305,3 +353,235 @@ describe('DayPlanSidebar', () => {
|
|||||||
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market.
|
||||||
|
const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }];
|
||||||
|
const oldTown = buildPlace({
|
||||||
|
id: 51,
|
||||||
|
name: 'Old town',
|
||||||
|
tour_place_id: 51,
|
||||||
|
lat: 52.52,
|
||||||
|
lng: 13.4,
|
||||||
|
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||||
|
});
|
||||||
|
const harbour = buildPlace({
|
||||||
|
id: 52,
|
||||||
|
name: 'Harbour',
|
||||||
|
tour_place_id: 52,
|
||||||
|
lat: 52.5,
|
||||||
|
lng: 13.45,
|
||||||
|
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||||
|
});
|
||||||
|
const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||||
|
|
||||||
|
function walkTour(place: { id: number; name: string }, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||||
|
return {
|
||||||
|
place_id: place.id,
|
||||||
|
name: place.name,
|
||||||
|
tour_type: 'walk',
|
||||||
|
distance: 3,
|
||||||
|
elevation_gain: null,
|
||||||
|
elevation_loss: null,
|
||||||
|
duration: 90,
|
||||||
|
break_additional_minutes: null,
|
||||||
|
planned_duration_minutes: null,
|
||||||
|
difficulty: null,
|
||||||
|
wanderer_ref: null,
|
||||||
|
match_confidence: 1,
|
||||||
|
max_hiking_difficulty: null,
|
||||||
|
planned: true,
|
||||||
|
caution: false,
|
||||||
|
station_count: 2,
|
||||||
|
station_stay_minutes: 45,
|
||||||
|
station_stays_unknown: 0,
|
||||||
|
...overrides,
|
||||||
|
} as TourListItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkDayProps(extra: {
|
||||||
|
reservations?: Reservation[];
|
||||||
|
withCafe?: boolean;
|
||||||
|
onPlanTransitLeg?: () => void;
|
||||||
|
firstWalk?: Partial<TourListItem>;
|
||||||
|
}) {
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||||
|
const assignments = {
|
||||||
|
'10': [
|
||||||
|
buildAssignment({
|
||||||
|
id: 201,
|
||||||
|
day_id: 10,
|
||||||
|
order_index: 0,
|
||||||
|
tour_place_id: 51,
|
||||||
|
place: { ...oldTown, place_time: '10:00' },
|
||||||
|
}),
|
||||||
|
buildAssignment({
|
||||||
|
id: 202,
|
||||||
|
day_id: 10,
|
||||||
|
order_index: 1,
|
||||||
|
tour_place_id: 52,
|
||||||
|
place: { ...harbour, place_time: '13:00' },
|
||||||
|
}),
|
||||||
|
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const tours = [walkTour(oldTown, extra.firstWalk), walkTour(harbour)];
|
||||||
|
return makeDefaultProps({
|
||||||
|
days: [day],
|
||||||
|
places: [oldTown, harbour, cafe],
|
||||||
|
assignments,
|
||||||
|
selectedDayId: 10,
|
||||||
|
routeShown: true,
|
||||||
|
tourPlaceIds: new Set([51, 52]),
|
||||||
|
plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])),
|
||||||
|
reservations: extra.reservations ?? [],
|
||||||
|
onPlanTransitLeg: extra.onPlanTransitLeg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */
|
||||||
|
function savedJourney(arrives: string): Reservation {
|
||||||
|
return {
|
||||||
|
...buildReservation({
|
||||||
|
id: 300,
|
||||||
|
type: 'transit',
|
||||||
|
title: 'Old bridge → Fish market',
|
||||||
|
reservation_time: '2025-06-01T12:20',
|
||||||
|
reservation_end_time: `2025-06-01T${arrives}`,
|
||||||
|
day_id: 10,
|
||||||
|
}),
|
||||||
|
endpoints: [
|
||||||
|
{
|
||||||
|
role: 'from',
|
||||||
|
sequence: 0,
|
||||||
|
name: 'Old bridge',
|
||||||
|
lat: 52.51,
|
||||||
|
lng: 13.42,
|
||||||
|
local_date: '2025-06-01',
|
||||||
|
local_time: '12:20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'to',
|
||||||
|
sequence: 1,
|
||||||
|
name: 'Fish market',
|
||||||
|
lat: 52.5,
|
||||||
|
lng: 13.45,
|
||||||
|
local_date: '2025-06-01',
|
||||||
|
local_time: arrives,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
transit: {
|
||||||
|
provider: 'transitous',
|
||||||
|
duration: 1800,
|
||||||
|
transfers: 0,
|
||||||
|
legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextMenu() {
|
||||||
|
return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAddonStore.setState({ addons: TOURS_ON });
|
||||||
|
mockTourDetail.mockImplementation((_tripId: number, placeId: number) =>
|
||||||
|
Promise.resolve({
|
||||||
|
tour: walkTour(placeId === 51 ? oldTown : harbour),
|
||||||
|
waypoints:
|
||||||
|
placeId === 51
|
||||||
|
? [
|
||||||
|
{ lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 },
|
||||||
|
{ lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' },
|
||||||
|
{ lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onPlanTransitLeg = vi.fn();
|
||||||
|
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg })} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByLabelText('Change travel mode'));
|
||||||
|
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||||
|
|
||||||
|
// The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||||
|
dayId: 10,
|
||||||
|
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||||
|
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||||
|
time: '12:15',
|
||||||
|
walk: {
|
||||||
|
dayId: 10,
|
||||||
|
fromWalk: 'Old town',
|
||||||
|
toWalk: 'Harbour',
|
||||||
|
ends: '12:15',
|
||||||
|
endsNextDay: false,
|
||||||
|
endsAtLeast: false,
|
||||||
|
nextStarts: '13:00',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(mockTourDetail).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onPlanTransitLeg = vi.fn();
|
||||||
|
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, withCafe: true })} />);
|
||||||
|
|
||||||
|
const connectors = await screen.findAllByLabelText('Change travel mode');
|
||||||
|
expect(connectors).toHaveLength(2);
|
||||||
|
await user.click(connectors[1]);
|
||||||
|
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||||
|
|
||||||
|
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||||
|
dayId: 10,
|
||||||
|
from: { name: '', lat: 52.49, lng: 13.46 },
|
||||||
|
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||||
|
time: null,
|
||||||
|
});
|
||||||
|
expect(mockTourDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => {
|
||||||
|
render(<DayPlanSidebar {...walkDayProps({ withCafe: true, reservations: [savedJourney('12:50')] })} />);
|
||||||
|
|
||||||
|
// The only leg left is the one from the harbour walk to the cafe; the journey covers the rest.
|
||||||
|
await screen.findByLabelText('Change travel mode');
|
||||||
|
expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1);
|
||||||
|
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => {
|
||||||
|
const onPlanTransitLeg = vi.fn();
|
||||||
|
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, reservations: [savedJourney('13:10')] })} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||||
|
expect(onPlanTransitLeg).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TIME-02-017: a tour with a start shows its end worked out from its time, and nothing runs over', () => {
|
||||||
|
render(<DayPlanSidebar {...walkDayProps({})} />);
|
||||||
|
|
||||||
|
const ends = screen.getAllByTestId('tour-day-end-worked-out');
|
||||||
|
expect(ends[0]).toHaveTextContent('12:15');
|
||||||
|
expect(ends[0]).toHaveAccessibleName('Ends at 12:15, worked out from the start and the tour time of 2 h 15 min');
|
||||||
|
expect(screen.queryByTestId('tour-day-overrun')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TIME-02-018: an end that runs past the start of the next timed item is said under the tour, nothing moved', () => {
|
||||||
|
render(<DayPlanSidebar {...walkDayProps({ firstWalk: { planned_duration_minutes: 240 } })} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId('tour-day-end-worked-out')[0]).toHaveTextContent('14:00');
|
||||||
|
expect(screen.getByTestId('tour-day-overrun')).toHaveTextContent('Ends at 14:00, after Harbour starts at 13:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba
|
|||||||
import { avatarSrc } from '../../utils/avatarSrc'
|
import { avatarSrc } from '../../utils/avatarSrc'
|
||||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
||||||
import { type PickedPlace } from './TransitSearchPanel'
|
import type { PlannedTransitLeg } from './transitLeg'
|
||||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||||
|
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||||
|
import { TourOverrunLine } from '../Tours/TourDayTimeParts'
|
||||||
|
import { dayItemOverrun } from '../Tours/tourDayTime'
|
||||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||||
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
||||||
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
||||||
@@ -36,10 +39,6 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
|||||||
import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts'
|
import { BarButton, MoreButton, SoftPill, TimePill, tintOf } from './planParts'
|
||||||
import './dayPlanRows.css'
|
import './dayPlanRows.css'
|
||||||
|
|
||||||
/** The round white button a row carries on its right (a route on the map, a journey's legs). */
|
|
||||||
const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors'
|
|
||||||
/** The white face every control of a day's route bar shares, so the row reads as one set. */
|
|
||||||
const ROUTE_TOOL = 'bg-surface-card shadow-sm ring-1 ring-edge-faint'
|
|
||||||
import { TypeTile } from './bookings/bookingParts'
|
import { TypeTile } from './bookings/bookingParts'
|
||||||
import { typeInfo } from './bookings/bookingsModel'
|
import { typeInfo } from './bookings/bookingsModel'
|
||||||
import { dayHeadStays } from './dayHeadStays'
|
import { dayHeadStays } from './dayHeadStays'
|
||||||
@@ -68,7 +67,8 @@ import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnec
|
|||||||
import { resolveLegMode } from './legMode'
|
import { resolveLegMode } from './legMode'
|
||||||
import { useLegModeActions } from './useLegModeActions'
|
import { useLegModeActions } from './useLegModeActions'
|
||||||
import { dayExportStops, fillAroundLocked } from './dayRoute'
|
import { dayExportStops, fillAroundLocked } from './dayRoute'
|
||||||
import { projectDayItinerary } from '../Map/dayTourProjection'
|
import { projectDayItinerary, projectedById, type TourStageStops } from '../Map/dayTourProjection'
|
||||||
|
import type { DayRoutePoint } from '../Map/dayRoutePlan'
|
||||||
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule'
|
||||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||||
@@ -85,6 +85,7 @@ import type { TourPlannedTimes } from '../Tours/tourPresentation'
|
|||||||
import type { TourListItem } from '@trek/shared'
|
import type { TourListItem } from '@trek/shared'
|
||||||
import { TourDayRowAvatar } from '../Tours/TourDayRowFacts'
|
import { TourDayRowAvatar } from '../Tours/TourDayRowFacts'
|
||||||
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
|
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
|
||||||
|
import { DAY_HEAD_DROP, DAY_HEAD_HOVER, DAY_HEAD_SELECTED, LOCK_OVERLAY, LOCK_OVERLAY_HOVER, LOCKED_ROW, ROUTE_TOOL, ROW_ROUND } from './dayPlanSurfaces'
|
||||||
|
|
||||||
export interface DayPlanSidebarProps {
|
export interface DayPlanSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -93,6 +94,8 @@ export interface DayPlanSidebarProps {
|
|||||||
places: Place[]
|
places: Place[]
|
||||||
tourPlaceIds?: ReadonlySet<number>
|
tourPlaceIds?: ReadonlySet<number>
|
||||||
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
|
plannedTourDurations?: ReadonlyMap<number, TourPlannedTimes & { tour?: TourListItem }>
|
||||||
|
/** MDT-02: where the stages of the trip's multi-day tours run, so a day holding one routes to that stage. */
|
||||||
|
tourStageStops?: TourStageStops
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
assignments: AssignmentsMap
|
assignments: AssignmentsMap
|
||||||
selectedDayId: number | null
|
selectedDayId: number | null
|
||||||
@@ -161,8 +164,9 @@ export interface DayPlanSidebarProps {
|
|||||||
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
||||||
* the leg's origin and destination endpoints plus the origin's departure time,
|
* the leg's origin and destination endpoints plus the origin's departure time,
|
||||||
* so "public transport" becomes an option on any connector, not just the day header.
|
* so "public transport" becomes an option on any connector, not just the day header.
|
||||||
|
* Between two city walks it carries what the search says of them (CW-08).
|
||||||
*/
|
*/
|
||||||
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
|
onPlanTransitLeg?: (leg: PlannedTransitLeg) => void
|
||||||
/** Opens the journey view for a saved transit entry (#1065). */
|
/** Opens the journey view for a saved transit entry (#1065). */
|
||||||
onOpenTransit?: (reservation: Reservation) => void
|
onOpenTransit?: (reservation: Reservation) => void
|
||||||
onEditTransport?: (reservation: Reservation) => void
|
onEditTransport?: (reservation: Reservation) => void
|
||||||
@@ -187,16 +191,6 @@ export interface DayPlanSidebarProps {
|
|||||||
* days (places, transports, notes). Returns everything the timeline view renders
|
* days (places, transports, notes). Returns everything the timeline view renders
|
||||||
* from, keeping DayPlanSidebar a thin shell over one large day list.
|
* from, keeping DayPlanSidebar a thin shell over one large day list.
|
||||||
*/
|
*/
|
||||||
// The day head's tones: hovered, open, and a drop landing on it. Mixed from the
|
|
||||||
// accent so they follow the user's scheme in both themes.
|
|
||||||
const DAY_HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)'
|
|
||||||
const DAY_HEAD_SELECTED = 'color-mix(in srgb, var(--accent) 12%, transparent)'
|
|
||||||
const DAY_HEAD_DROP = 'color-mix(in srgb, var(--accent) 9%, transparent)'
|
|
||||||
// A stop pinned in place: a whisper of the danger tone on the row, a stronger one over its avatar.
|
|
||||||
const LOCKED_ROW = 'color-mix(in srgb, var(--danger) 7%, transparent)'
|
|
||||||
const LOCK_OVERLAY = 'color-mix(in srgb, var(--danger) 62%, transparent)'
|
|
||||||
const LOCK_OVERLAY_HOVER = 'color-mix(in srgb, var(--danger) 42%, transparent)'
|
|
||||||
|
|
||||||
function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||||
const {
|
const {
|
||||||
tripId,
|
tripId,
|
||||||
@@ -619,12 +613,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
|
// legs to draw. Side-effect free, so the async loop below only does OSRM I/O.
|
||||||
const planDay = (dayId: number) => {
|
const planDay = (dayId: number) => {
|
||||||
const merged = mergedItemsMap[dayId] || []
|
const merged = mergedItemsMap[dayId] || []
|
||||||
const projected = new Map(projectDayItinerary(assignments[String(dayId)] || [], toursEnabled, places).map(item => [item.assignment.id, item]))
|
const projected = projectedById(assignments[String(dayId)] || [], toursEnabled, places, props.tourStageStops)
|
||||||
// Each run point carries the ORIGIN assignment's per-segment travel mode
|
// Each run point carries the ORIGIN assignment's per-segment travel mode
|
||||||
// (#1281): the leg from this point to the next is routed with `mode` (null =
|
// (#1281): the leg from this point to the next is routed with `mode` (null =
|
||||||
// inherit the day default). Transport endpoints carry no mode → day default.
|
// inherit the day default). Transport endpoints carry no mode → day default.
|
||||||
const runs: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; assignmentId?: number }[][] = []
|
const runs: (DayRoutePoint & { id: number })[][] = []
|
||||||
let cur: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; assignmentId?: number }[] = []
|
let cur: (DayRoutePoint & { id: number })[] = []
|
||||||
// A run is only a real drive when it holds an actual place. Two back-to-back
|
// A run is only a real drive when it holds an actual place. Two back-to-back
|
||||||
// transports (e.g. two flights on one day) would otherwise pair the first's
|
// transports (e.g. two flights on one day) would otherwise pair the first's
|
||||||
// arrival with the second's departure into a phantom airport→airport leg — the
|
// arrival with the second's departure into a phantom airport→airport leg — the
|
||||||
@@ -789,6 +783,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
for (let i = 0; i < run.length - 1; i++) {
|
for (let i = 0; i < run.length - 1; i++) {
|
||||||
const from = run[i]
|
const from = run[i]
|
||||||
const to = run[i + 1]
|
const to = run[i + 1]
|
||||||
|
// A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08).
|
||||||
|
if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue
|
||||||
const mode = resolveLegMode(from, to, dfMode)
|
const mode = resolveLegMode(from, to, dfMode)
|
||||||
tasks.push(async () => {
|
tasks.push(async () => {
|
||||||
const seg = await legBetween(from, to, dayId, mode)
|
const seg = await legBetween(from, to, dayId, mode)
|
||||||
@@ -836,7 +832,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
|
// routeDayIds is memoized from the same inputs as routeDayKey below, so keying the
|
||||||
// effect on the string is equivalent while staying stable across unrelated renders.
|
// effect on the string is equivalent while staying stable across unrelated renders.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay])
|
}, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit, toursEnabled, places, roadtripViasByDay, props.tourStageStops])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -1405,26 +1401,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coordinates back to names for the transit search, over every located place,
|
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
|
||||||
// hotel and booking endpoint on the trip (see transitLeg.ts).
|
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
|
||||||
const transitNameIndex = useMemo(
|
const tourOfPlace = (placeId: number) => props.plannedTourDurations?.get(placeId)?.tour
|
||||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
|
||||||
[assignments, accommodations, reservations],
|
onPlan: onPlanTransitLeg, tourOf: tourOfPlace })
|
||||||
)
|
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
|
||||||
|
const leg = transitLegs.legFor(dayId, seg)
|
||||||
// The extra connector-menu entry (#1281 follow-up): search public transit for this
|
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
|
||||||
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
|
|
||||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
|
|
||||||
if (!onPlanTransitLeg) return undefined
|
|
||||||
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
|
|
||||||
if (!leg) return undefined
|
|
||||||
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open the mode menu at the clicked connector: every route profile, the optional
|
// Open the mode menu at the clicked connector: every route profile, the optional
|
||||||
// "public transport" entry, plus a "use day default" entry that clears the override.
|
// "public transport" entry, plus a "use day default" entry that clears the override.
|
||||||
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
||||||
ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
|
ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
|
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
|
||||||
@@ -2033,7 +2023,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
|
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} tourStageKey={assignment.tour_stage_key} />
|
||||||
|
{/* TIME-02: the tour's end runs past the start of the day's next timed item. */}
|
||||||
|
{dayRowTour && <TourOverrunLine overrun={dayItemOverrun(merged, idx, day.id, tourOfPlace)} />}
|
||||||
{assignment.notes && (
|
{assignment.notes && (
|
||||||
// Day-specific note on this stop (#2163) — one muted
|
// Day-specific note on this stop (#2163) — one muted
|
||||||
// caption line so the timeline shows the note exists
|
// caption line so the timeline shows the note exists
|
||||||
@@ -2359,6 +2351,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
<WalkLateLine late={transitLegs.lateBefore(day, res, merged.slice(idx + 1))} />
|
||||||
</div>
|
</div>
|
||||||
{transitMeta && (() => {
|
{transitMeta && (() => {
|
||||||
const expanded = expandedTransitIds.has(res.id)
|
const expanded = expandedTransitIds.has(res.id)
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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}>
|
<div ref={bodyRef}>
|
||||||
{asking ? (
|
{asking ? (
|
||||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||||
|
|||||||
@@ -11,47 +11,19 @@ import { usePermissionsStore } from '../../store/permissionsStore';
|
|||||||
import { usePluginStore } from '../../store/pluginStore';
|
import { usePluginStore } from '../../store/pluginStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories';
|
||||||
|
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
|
||||||
import PlaceFormModal from './PlaceFormModal';
|
import PlaceFormModal from './PlaceFormModal';
|
||||||
|
|
||||||
// Mock CustomTimePicker so we get a simple text input instead of the portal-heavy UI.
|
// CustomTimePicker as a plain text input, see TimePickerStub.
|
||||||
// The aria props land on the input, as the real picker spreads them onto its own.
|
vi.mock('../shared/CustomTimePicker', async () => ({
|
||||||
vi.mock('../shared/CustomTimePicker', () => ({
|
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
|
||||||
default: ({ value, onChange, placeholder, ...aria }: {
|
|
||||||
value: string
|
|
||||||
onChange: (v: string) => void
|
|
||||||
placeholder?: string
|
|
||||||
'aria-label'?: string
|
|
||||||
'aria-describedby'?: string
|
|
||||||
'aria-invalid'?: boolean
|
|
||||||
}) => (
|
|
||||||
<input
|
|
||||||
{...aria}
|
|
||||||
data-testid="time-picker"
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder ?? '00:00'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = placeFormProps;
|
||||||
isOpen: true,
|
|
||||||
onClose: vi.fn(),
|
|
||||||
onSave: vi.fn(),
|
|
||||||
place: null,
|
|
||||||
prefillCoords: null,
|
|
||||||
tripId: 1,
|
|
||||||
categories: [],
|
|
||||||
onCategoryCreated: vi.fn(),
|
|
||||||
assignmentId: null,
|
|
||||||
dayAssignments: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false });
|
seedPlaceFormStores();
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PlaceFormModal', () => {
|
describe('PlaceFormModal', () => {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// FE-PLANNER-PLACEFORM-125: the place form of a tour (TIME-02)
|
||||||
|
import type { TourListItem } from '@trek/shared';
|
||||||
|
|
||||||
|
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories';
|
||||||
|
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
|
||||||
|
import { render, screen } from '../../../tests/helpers/render';
|
||||||
|
import { resetAllStores } from '../../../tests/helpers/store';
|
||||||
|
import PlaceFormModal from './PlaceFormModal';
|
||||||
|
|
||||||
|
// CustomTimePicker as a plain text input, see TimePickerStub.
|
||||||
|
vi.mock('../shared/CustomTimePicker', async () => ({
|
||||||
|
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedPlaceFormStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PlaceFormModal for a tour', () => {
|
||||||
|
it('FE-PLANNER-PLACEFORM-125: a tour with a start and no end says below its times the end it works out to (TIME-02)', () => {
|
||||||
|
const place = buildPlace({ id: 7, name: 'Ridge', place_time: '09:00', end_time: null });
|
||||||
|
const assignment = buildAssignment({ id: 42, day_id: 3, place });
|
||||||
|
const tour: TourListItem = {
|
||||||
|
place_id: 7,
|
||||||
|
name: 'Ridge',
|
||||||
|
tour_type: 'hike',
|
||||||
|
distance: 8,
|
||||||
|
elevation_gain: 300,
|
||||||
|
elevation_loss: 300,
|
||||||
|
duration: 150,
|
||||||
|
planned_duration_minutes: null,
|
||||||
|
break_additional_minutes: 30,
|
||||||
|
difficulty: null,
|
||||||
|
wanderer_ref: null,
|
||||||
|
match_confidence: 1,
|
||||||
|
max_hiking_difficulty: 2,
|
||||||
|
planned: true,
|
||||||
|
caution: false,
|
||||||
|
};
|
||||||
|
const { unmount } = render(
|
||||||
|
<PlaceFormModal {...placeFormProps} place={place} assignmentId={42} dayAssignments={[assignment]} tour={tour} />
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent(
|
||||||
|
'Left empty, the end is worked out: 12:00, the start plus the tour time of 3 h.'
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
render(<PlaceFormModal {...placeFormProps} place={place} assignmentId={42} dayAssignments={[assignment]} />);
|
||||||
|
expect(screen.queryByTestId('tour-day-end-hint')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react'
|
import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react'
|
||||||
import type { RoadtripStopType } from '@trek/shared'
|
import type { RoadtripStopType, TourListItem } from '@trek/shared'
|
||||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
@@ -10,15 +10,14 @@ import CollectionPicker from '../Collections/CollectionPicker'
|
|||||||
import PlaceDetailsColumn from './PlaceDetailsColumn'
|
import PlaceDetailsColumn from './PlaceDetailsColumn'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
|
import { Search, Paperclip, X, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed, PencilLine } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
|
||||||
import { PlaceContactFields } from './PlaceContactFields'
|
import { PlaceContactFields } from './PlaceContactFields'
|
||||||
import { weekFromPeriods } from './placeHours'
|
import { weekFromPeriods } from './placeHours'
|
||||||
import type { PlaceOpeningHours } from '@trek/shared'
|
import type { PlaceOpeningHours } from '@trek/shared'
|
||||||
import {
|
import {
|
||||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||||
prefillForm, timeCollisions, type PlaceFormData,
|
prefillForm, type PlaceFormData,
|
||||||
} from './PlaceFormModal.helpers'
|
} from './PlaceFormModal.helpers'
|
||||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||||
import { usePlaceSearch } from './usePlaceSearch'
|
import { usePlaceSearch } from './usePlaceSearch'
|
||||||
@@ -43,6 +42,7 @@ import {
|
|||||||
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
|
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
|
||||||
import { SoftPill, tintOf } from './planParts'
|
import { SoftPill, tintOf } from './planParts'
|
||||||
import { WHITE_BUTTON } from './placeDialogParts'
|
import { WHITE_BUTTON } from './placeDialogParts'
|
||||||
|
import { TimeSection } from './PlaceFormTimeSection'
|
||||||
|
|
||||||
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
||||||
// category_id is normalised, plus any files chosen before the place existed.
|
// category_id is normalised, plus any files chosen before the place existed.
|
||||||
@@ -85,6 +85,8 @@ interface PlaceFormModalProps {
|
|||||||
serviceStop?: ServiceStopMode | null
|
serviceStop?: ServiceStopMode | null
|
||||||
/** Road trip mode is on, where a visit's End is when the drive leaves it. */
|
/** Road trip mode is on, where a visit's End is when the drive leaves it. */
|
||||||
roadtripActive?: boolean
|
roadtripActive?: boolean
|
||||||
|
/** TIME-02: the tour this place is, whose end the time fields work out from its start; absent for a plain place. */
|
||||||
|
tour?: TourListItem | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -469,7 +471,6 @@ const STACK = 'flex flex-col gap-5'
|
|||||||
const FORM_COLUMN = 'flex min-w-0 flex-1 flex-col gap-4'
|
const FORM_COLUMN = 'flex min-w-0 flex-1 flex-col gap-4'
|
||||||
/** A row of the typed-ahead list or of the search results. */
|
/** A row of the typed-ahead list or of the search results. */
|
||||||
const PICK_ROW = 'flex w-full items-center gap-2 rounded-[8px] px-2.5 py-2 text-start'
|
const PICK_ROW = 'flex w-full items-center gap-2 rounded-[8px] px-2.5 py-2 text-start'
|
||||||
const WARNING_BANNER = 'flex items-start gap-1.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5 text-warning'
|
|
||||||
/** The small accent square beside a field that runs its action, sized by the row it sits in. */
|
/** The small accent square beside a field that runs its action, sized by the row it sits in. */
|
||||||
const SIDE_BUTTON = 'grid w-[38px] flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-60'
|
const SIDE_BUTTON = 'grid w-[38px] flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-60'
|
||||||
/**
|
/**
|
||||||
@@ -843,6 +844,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
dayAssignments={S.dayAssignments}
|
dayAssignments={S.dayAssignments}
|
||||||
hasTimeError={hasTimeError}
|
hasTimeError={hasTimeError}
|
||||||
endIsLeave={!!props.roadtripActive}
|
endIsLeave={!!props.roadtripActive}
|
||||||
|
tour={props.tour}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
<EditorField label={t('places.assignmentNotes')} htmlFor={`${fieldId}-day-notes`}>
|
<EditorField label={t('places.assignmentNotes')} htmlFor={`${fieldId}-day-notes`}>
|
||||||
@@ -1156,61 +1158,3 @@ function PendingFiles({ files, fileRef, onAdd, onRemove, t }: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeSectionProps {
|
|
||||||
form: PlaceFormData
|
|
||||||
handleChange: (field: string, value: string) => void
|
|
||||||
assignmentId: number | null
|
|
||||||
dayAssignments: Assignment[]
|
|
||||||
hasTimeError: boolean
|
|
||||||
/** On a road trip the End is when the drive leaves, which the field says. In Days it
|
|
||||||
* stays the plain label it has always been: nothing is scheduled off it there. */
|
|
||||||
endIsLeave: boolean
|
|
||||||
t: Translate
|
|
||||||
}
|
|
||||||
|
|
||||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, t }: TimeSectionProps) {
|
|
||||||
const collisions = useMemo(
|
|
||||||
() => timeCollisions(assignmentId, dayAssignments, form.place_time, form.end_time),
|
|
||||||
[assignmentId, dayAssignments, form.place_time, form.end_time],
|
|
||||||
)
|
|
||||||
const errorId = useId()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className={GRID_2}>
|
|
||||||
<EditorField label={t('places.startTime')}>
|
|
||||||
<CustomTimePicker
|
|
||||||
value={form.place_time}
|
|
||||||
onChange={v => handleChange('place_time', v)}
|
|
||||||
aria-label={t('places.startTime')}
|
|
||||||
/>
|
|
||||||
</EditorField>
|
|
||||||
<EditorField label={t('places.endTime')} hint={endIsLeave ? t('roadtrip.stop.endIsLeave') : undefined}>
|
|
||||||
<CustomTimePicker
|
|
||||||
value={form.end_time}
|
|
||||||
onChange={v => handleChange('end_time', v)}
|
|
||||||
aria-label={t('places.endTime')}
|
|
||||||
aria-invalid={hasTimeError}
|
|
||||||
aria-describedby={hasTimeError ? errorId : undefined}
|
|
||||||
/>
|
|
||||||
</EditorField>
|
|
||||||
</div>
|
|
||||||
{hasTimeError && (
|
|
||||||
<div id={errorId} className={WARNING_BANNER} style={fs(12, 'body')}>
|
|
||||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
||||||
{t('places.endTimeBeforeStart')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{collisions.length > 0 && (
|
|
||||||
<div className={WARNING_BANNER} style={fs(12, 'body')}>
|
|
||||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
{t('places.timeCollision')}{' '}
|
|
||||||
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { TourListItem } from '@trek/shared';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { useId, useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { Assignment } from '../../types';
|
||||||
|
import CustomTimePicker from '../shared/CustomTimePicker';
|
||||||
|
import { EditorField, GRID_2 } from '../shared/dialogParts';
|
||||||
|
import { fs } from '../shared/DialogShell';
|
||||||
|
import { TourEndHint } from '../Tours/TourDayTimeParts';
|
||||||
|
import { timeCollisions, type PlaceFormData } from './PlaceFormModal.helpers';
|
||||||
|
|
||||||
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
|
const WARNING_BANNER = 'flex items-start gap-1.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5 text-warning';
|
||||||
|
|
||||||
|
interface TimeSectionProps {
|
||||||
|
form: PlaceFormData;
|
||||||
|
handleChange: (field: string, value: string) => void;
|
||||||
|
assignmentId: number | null;
|
||||||
|
dayAssignments: Assignment[];
|
||||||
|
hasTimeError: boolean;
|
||||||
|
/** On a road trip the End is when the drive leaves, which the field says. In Days it
|
||||||
|
* stays the plain label it has always been: nothing is scheduled off it there. */
|
||||||
|
endIsLeave: boolean;
|
||||||
|
/** TIME-02: the tour this place is, whose end the time fields work out from its start. */
|
||||||
|
tour?: TourListItem | null;
|
||||||
|
t: Translate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The place form's start and end time, with the warnings about them and a tour's worked-out end. */
|
||||||
|
export function TimeSection({
|
||||||
|
form,
|
||||||
|
handleChange,
|
||||||
|
assignmentId,
|
||||||
|
dayAssignments,
|
||||||
|
hasTimeError,
|
||||||
|
endIsLeave,
|
||||||
|
tour,
|
||||||
|
t,
|
||||||
|
}: TimeSectionProps) {
|
||||||
|
const collisions = useMemo(
|
||||||
|
() => timeCollisions(assignmentId, dayAssignments, form.place_time, form.end_time),
|
||||||
|
[assignmentId, dayAssignments, form.place_time, form.end_time]
|
||||||
|
);
|
||||||
|
const errorId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className={GRID_2}>
|
||||||
|
<EditorField label={t('places.startTime')}>
|
||||||
|
<CustomTimePicker
|
||||||
|
value={form.place_time}
|
||||||
|
onChange={(v) => handleChange('place_time', v)}
|
||||||
|
aria-label={t('places.startTime')}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
<EditorField label={t('places.endTime')} hint={endIsLeave ? t('roadtrip.stop.endIsLeave') : undefined}>
|
||||||
|
<CustomTimePicker
|
||||||
|
value={form.end_time}
|
||||||
|
onChange={(v) => handleChange('end_time', v)}
|
||||||
|
aria-label={t('places.endTime')}
|
||||||
|
aria-invalid={hasTimeError}
|
||||||
|
aria-describedby={hasTimeError ? errorId : undefined}
|
||||||
|
/>
|
||||||
|
</EditorField>
|
||||||
|
</div>
|
||||||
|
<TourEndHint
|
||||||
|
start={form.place_time}
|
||||||
|
end={form.end_time}
|
||||||
|
assignment={dayAssignments.find((a) => a.id === assignmentId)}
|
||||||
|
tour={tour}
|
||||||
|
/>
|
||||||
|
{hasTimeError && (
|
||||||
|
<div id={errorId} className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||||
|
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||||
|
{t('places.endTimeBeforeStart')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collisions.length > 0 && (
|
||||||
|
<div className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||||
|
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
{t('places.timeCollision')}{' '}
|
||||||
|
{collisions
|
||||||
|
.map((a) => a.place?.name)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { isCarrierTransport } from '../../utils/dayMerge'
|
import { isCarrierTransport } from '../../utils/dayMerge'
|
||||||
import type { TransitProvider } from '@trek/shared'
|
import type { TransitProvider } from '@trek/shared'
|
||||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||||
|
import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote'
|
||||||
|
import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||||
@@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
|
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: {
|
||||||
it: TransitItinerary
|
it: TransitItinerary
|
||||||
tzFrom: string
|
tzFrom: string
|
||||||
tzTo: string
|
tzTo: string
|
||||||
@@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
|||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
adding: boolean
|
adding: boolean
|
||||||
|
/** CW-08: the next city walk this connection arrives too late for. */
|
||||||
|
late: WalkLate | null
|
||||||
t: (k: string, p?: Record<string, string | number>) => string
|
t: (k: string, p?: Record<string, string | number>) => string
|
||||||
}) {
|
}) {
|
||||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||||
@@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<WalkLateLine late={late} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -460,9 +465,11 @@ interface TransitSearchPanelProps {
|
|||||||
initialTo?: PickedPlace | null
|
initialTo?: PickedPlace | null
|
||||||
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
||||||
initialTime?: string | null
|
initialTime?: string | null
|
||||||
|
/** CW-08: the two city walks the leg runs between; said only while the search is for their day. */
|
||||||
|
walk?: WalkTransitContext | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
@@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
|
|
||||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||||
const [time, setTime] = useState(initialTime || '09:00')
|
const walkHere = walk?.dayId === day.id ? walk : null
|
||||||
|
// Between two walks the time is when the first one ends, never one made up: unknown, it is chosen.
|
||||||
|
const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00'))
|
||||||
const [arriveBy, setArriveBy] = useState(false)
|
const [arriveBy, setArriveBy] = useState(false)
|
||||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||||
@@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
|
|
||||||
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
||||||
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
||||||
|
const canSearch = !!from && !!to && !!time && !loading
|
||||||
|
|
||||||
const segBtn = (active: boolean): React.CSSProperties => ({
|
const segBtn = (active: boolean): React.CSSProperties => ({
|
||||||
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
||||||
@@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
||||||
|
<WalkTransitNote walk={walkHere} />
|
||||||
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
||||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
||||||
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||||
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
onClick={search}
|
onClick={search}
|
||||||
disabled={!from || !to || loading}
|
disabled={!canSearch}
|
||||||
className="bg-accent text-accent-text"
|
className="bg-accent text-accent-text"
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: canSearch ? 'pointer' : 'default', fontFamily: 'inherit', opacity: canSearch ? 1 : 0.55, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||||
>
|
>
|
||||||
<Search size={14} strokeWidth={2.2} />
|
<Search size={14} strokeWidth={2.2} />
|
||||||
{loading ? t('transit.searching') : t('transit.search')}
|
{loading ? t('transit.searching') : t('transit.search')}
|
||||||
@@ -743,6 +754,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||||
onAdd={() => addItinerary(it, idx)}
|
onAdd={() => addItinerary(it, idx)}
|
||||||
adding={addingIdx === idx}
|
adding={addingIdx === idx}
|
||||||
|
late={lateForWalk(walkHere, day.date ?? null, { date: dateYMDInTz(it.endTime, tzTo), time: timeHHmmInTz(it.endTime, tzTo) })}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// CW-08: the transit search between two city walks, opened from the day plan's
|
||||||
|
// connector with the first walk's end and the next one's start. The mocks are the
|
||||||
|
// ones TransitSearchPanel.test.tsx renders the panel with.
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { buildDay, buildPlace, buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
|
import type { WalkTransitContext } from '../Tours/walk/walkTransit';
|
||||||
|
import TransitSearchPanel from './TransitSearchPanel';
|
||||||
|
|
||||||
|
const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() } }));
|
||||||
|
|
||||||
|
vi.mock('../../api/client', async (importOriginal) => {
|
||||||
|
const actual = (await importOriginal()) as object;
|
||||||
|
return { ...actual, transitApi: transitApiMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }));
|
||||||
|
|
||||||
|
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' });
|
||||||
|
const OLD_BRIDGE = { name: 'Old bridge', lat: 52.51, lng: 13.42 };
|
||||||
|
const FISH_MARKET = { name: 'Fish market', lat: 52.5, lng: 13.45 };
|
||||||
|
const WALK: WalkTransitContext = {
|
||||||
|
dayId: 10,
|
||||||
|
fromWalk: 'Old town',
|
||||||
|
toWalk: 'Harbour',
|
||||||
|
ends: '12:15',
|
||||||
|
endsNextDay: false,
|
||||||
|
endsAtLeast: false,
|
||||||
|
nextStarts: '13:00',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Berlin, summer time (UTC+2): a tram that departs 12:20 and arrives at `arrives` (UTC).
|
||||||
|
function itinerary(arrives: string) {
|
||||||
|
const at = (time: string) => ({ lat: 52.505, lng: 13.43, time, scheduledTime: null, track: null });
|
||||||
|
return {
|
||||||
|
startTime: '2025-06-01T10:20:00Z',
|
||||||
|
endTime: `2025-06-01T${arrives}:00Z`,
|
||||||
|
duration: 1800,
|
||||||
|
transfers: 0,
|
||||||
|
walkSeconds: 240,
|
||||||
|
legs: [
|
||||||
|
{
|
||||||
|
mode: 'TRAM',
|
||||||
|
from: { ...at('2025-06-01T10:20:00Z'), name: 'START' },
|
||||||
|
to: { ...at(`2025-06-01T${arrives}:00Z`), name: 'END' },
|
||||||
|
duration: 1800,
|
||||||
|
distance: null,
|
||||||
|
headsign: 'Harbour',
|
||||||
|
line: 'M1',
|
||||||
|
lineColor: null,
|
||||||
|
lineTextColor: null,
|
||||||
|
agency: 'BVG',
|
||||||
|
intermediateStops: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(walk: WalkTransitContext | null, initialTime: string | null = walk?.ends ?? null) {
|
||||||
|
const onAdd = vi.fn().mockResolvedValue({});
|
||||||
|
render(
|
||||||
|
<TransitSearchPanel
|
||||||
|
day={day}
|
||||||
|
days={[day]}
|
||||||
|
places={[buildPlace({ id: 1, name: 'Old town', lat: 52.52, lng: 13.4 })]}
|
||||||
|
onAdd={onAdd}
|
||||||
|
initialFrom={OLD_BRIDGE}
|
||||||
|
initialTo={FISH_MARKET}
|
||||||
|
initialTime={initialTime}
|
||||||
|
walk={walk}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return { onAdd };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeField = () => screen.getByPlaceholderText('00:00') as HTMLInputElement;
|
||||||
|
const searchButton = () => screen.getByRole('button', { name: /^Search$/ });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
|
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TransitSearchPanel between two city walks (CW-08)', () => {
|
||||||
|
it('says when the first walk ends and the next starts, and departs from the last stop then', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||||
|
renderPanel(WALK);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||||
|
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||||
|
const [from, to] = screen.getAllByPlaceholderText('Search stop or station…') as HTMLInputElement[];
|
||||||
|
expect(from?.value).toBe('Old bridge');
|
||||||
|
expect(to?.value).toBe('Fish market');
|
||||||
|
expect(timeField().value).toBe('12:15');
|
||||||
|
|
||||||
|
await user.click(searchButton());
|
||||||
|
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||||
|
// 12:15 in Berlin (UTC+2), from the walk's end and to the next one's start.
|
||||||
|
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
from: '52.51,13.42',
|
||||||
|
to: '52.5,13.45',
|
||||||
|
time: '2025-06-01T10:15:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a connection that arrives after the next walk starts, and only that one', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('11:10'), itinerary('10:50')] });
|
||||||
|
renderPanel(WALK);
|
||||||
|
|
||||||
|
await user.click(searchButton());
|
||||||
|
await screen.findByText(/12:20\D+13:10/);
|
||||||
|
const late = screen.getAllByTestId('transit-walk-late');
|
||||||
|
expect(late).toHaveLength(1);
|
||||||
|
expect(late[0]).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||||
|
expect(screen.getByText(/12:20\D+13:10/).closest('button')).toContainElement(late[0] ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says an end that leaves a time out is the earliest', () => {
|
||||||
|
renderPanel({ ...WALK, endsAtLeast: true });
|
||||||
|
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||||
|
'Old town ends at 12:15 at the earliest; not all of its times are known yet.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says a walk that ends after midnight does so, and makes up no time on this day', () => {
|
||||||
|
renderPanel({ ...WALK, ends: null, endsNextDay: true, endsAtLeast: true });
|
||||||
|
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||||
|
'Old town only ends after midnight, so choose a departure time.'
|
||||||
|
);
|
||||||
|
expect(timeField().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes up no time without an end, and waits for one to be chosen', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||||
|
renderPanel({ ...WALK, ends: null, nextStarts: null });
|
||||||
|
|
||||||
|
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||||
|
'When Old town ends is not known yet, so choose a departure time.'
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId('transit-walk-next')).not.toBeInTheDocument();
|
||||||
|
expect(timeField().value).toBe('');
|
||||||
|
expect(searchButton()).toBeDisabled();
|
||||||
|
|
||||||
|
await user.type(timeField(), '1230');
|
||||||
|
await user.tab();
|
||||||
|
expect(searchButton()).toBeEnabled();
|
||||||
|
await user.click(searchButton());
|
||||||
|
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||||
|
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({ time: '2025-06-01T10:30:00.000Z' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on another day than the walks, speaks of them no more and departs at the usual time', () => {
|
||||||
|
renderPanel({ ...WALK, dayId: 11 }, null);
|
||||||
|
expect(screen.queryByTestId('transit-walk-end')).not.toBeInTheDocument();
|
||||||
|
expect(timeField().value).toBe('09:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a connection only once it is opened and taken, between the two stops', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('10:50')] });
|
||||||
|
const { onAdd } = renderPanel(WALK);
|
||||||
|
|
||||||
|
await user.click(searchButton());
|
||||||
|
await user.click(await screen.findByText(/12:20\D+12:50/));
|
||||||
|
expect(onAdd).not.toHaveBeenCalled();
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Add to day' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1));
|
||||||
|
const payload = onAdd.mock.calls[0]?.[0];
|
||||||
|
expect(payload).toMatchObject({ type: 'transit', title: 'Old bridge → Fish market', day_id: 10 });
|
||||||
|
expect(payload.endpoints.map((point: { name: string }) => point.name)).toEqual(['Old bridge', 'Fish market']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -519,6 +519,15 @@ describe('TransportModal', () => {
|
|||||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
|
||||||
|
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
|
||||||
|
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
|
||||||
|
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
|
||||||
|
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
|
||||||
|
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||||
|
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
||||||
const days = [
|
const days = [
|
||||||
buildDay({ id: 10, date: '2025-06-01' }),
|
buildDay({ id: 10, date: '2025-06-01' }),
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { TravelerPicker } from './TravelerPicker'
|
|||||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
import TransitSearchPanel from './TransitSearchPanel'
|
||||||
|
import type { TransitPrefill } from './transitLeg'
|
||||||
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
||||||
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
||||||
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
|
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
|
||||||
@@ -70,7 +71,7 @@ interface TransportModalProps {
|
|||||||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||||||
tripHasDates?: boolean
|
tripHasDates?: boolean
|
||||||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
transitPrefill?: TransitPrefill | null
|
||||||
/** Trip members + guests, for the traveler picker (#1517). */
|
/** Trip members + guests, for the traveler picker (#1517). */
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
}
|
}
|
||||||
@@ -304,6 +305,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
initialFrom={transitPrefill?.from ?? null}
|
initialFrom={transitPrefill?.from ?? null}
|
||||||
initialTo={transitPrefill?.to ?? null}
|
initialTo={transitPrefill?.to ?? null}
|
||||||
initialTime={transitPrefill?.time ?? null}
|
initialTime={transitPrefill?.time ?? null}
|
||||||
|
walk={transitPrefill?.walk ?? null}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* The surfaces the day plan draws its rows and day heads on: the class strings
|
||||||
|
* of the white controls a row and a route bar carry, and the colour mixes a day
|
||||||
|
* head and a pinned stop take. Mixed from the scheme's tokens, so they follow
|
||||||
|
* the user's accent and both themes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The round white button a row carries on its right (a route on the map, a journey's legs). */
|
||||||
|
export const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors';
|
||||||
|
/** The white face every control of a day's route bar shares, so the row reads as one set. */
|
||||||
|
export const ROUTE_TOOL = 'bg-surface-card shadow-sm ring-1 ring-edge-faint';
|
||||||
|
|
||||||
|
// The day head's tones: hovered, open, and a drop landing on it. Mixed from the
|
||||||
|
// accent so they follow the user's scheme in both themes.
|
||||||
|
export const DAY_HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)';
|
||||||
|
export const DAY_HEAD_SELECTED = 'color-mix(in srgb, var(--accent) 12%, transparent)';
|
||||||
|
export const DAY_HEAD_DROP = 'color-mix(in srgb, var(--accent) 9%, transparent)';
|
||||||
|
// A stop pinned in place: a whisper of the danger tone on the row, a stronger one over its avatar.
|
||||||
|
export const LOCKED_ROW = 'color-mix(in srgb, var(--danger) 7%, transparent)';
|
||||||
|
export const LOCK_OVERLAY = 'color-mix(in srgb, var(--danger) 62%, transparent)';
|
||||||
|
export const LOCK_OVERLAY_HOVER = 'color-mix(in srgb, var(--danger) 42%, transparent)';
|
||||||
@@ -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 { Clock, MoreHorizontal, type LucideIcon } from 'lucide-react'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
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'
|
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`. */
|
/** 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
|
label: string
|
||||||
|
/** A longer explanation for the tooltip; the label still names the button. */
|
||||||
|
tooltip?: string
|
||||||
|
testId?: string
|
||||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void
|
onClick: (e: MouseEvent<HTMLButtonElement>) => void
|
||||||
active?: boolean
|
active?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -26,10 +29,12 @@ export function BarButton({ label, onClick, active = false, disabled = false, ch
|
|||||||
ariaExpanded?: boolean
|
ariaExpanded?: boolean
|
||||||
ariaHaspopup?: 'dialog' | 'menu'
|
ariaHaspopup?: 'dialog' | 'menu'
|
||||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
/** The button itself, for a list or a menu that opens beside it. */
|
||||||
|
ref?: Ref<HTMLButtonElement>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label} placement={placement}>
|
<Tooltip label={tooltip ?? label} placement={placement}>
|
||||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
|
<button ref={ref} type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
|
||||||
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
|
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}`}>
|
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}
|
{children}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user