Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice 2654e651c7 style(i18n): wrap the long special theme hints the way Prettier wants 2026-10-18 13:04:19 +02:00
Maurice ce1fa68c84 feat(appearance): Halloween special theme with 3D decor and a costumed mascot
Adds a Special Themes row to Settings > Appearance > Theme. Halloween is a
scheme on the existing data-scheme axis that is always dark: the applier and
the boot script force .dark while it is on, and the color mode control is
dimmed with a hint.

- Palette for the whole app (surfaces, dashboard, Vacay glass, Journey,
  mobile chrome) in theme/special/halloween.tokens.css, loaded with index.css.
- Decor as a lazy chunk mounted by SpecialThemeLayer while the scheme is on:
  fog, embers, bat flocks, a passing ghost, corner cobwebs, spiders that drop
  on a thread and flee when poked, a pumpkin patch and an ember running along
  the navbar. Figures are pre-rendered 3D sprite sheets.
- Nothing is drawn over a map: the Atlas gets no decor, the trip planner only
  dresses its side panels. Marker clusters turn into lanterns.
- The 3D mascot plays its own Halloween clips in every scene (witch hat, a
  spider on the brim, a lit pumpkin beside it); the SVG fallback wears the
  same costume.
- Reduced motion stills everything, transparency off drops the fog, shared
  pages never show it.
- Navbar, bottom nav, notifications, Atlas and Collections read the dark flag
  off the .dark class instead of settings.dark_mode, so a forced dark theme
  reaches them.
2026-10-18 12:59:36 +02:00
938 changed files with 4919 additions and 40359 deletions
+130 -322
View File
@@ -9,73 +9,23 @@ permissions:
# analysis; pull requests get PR analysis with decoration on the PR itself
# (the scanner picks the context up from the Actions environment).
#
# Job names read "<package>: <what>", so the checks list on a PR sorts by
# package. What each job holds:
#
# Repo: Changed Areas decides what the change needs to run (below)
# Shared: i18n Parity every locale has en's files and keys
# Shared: Coverage shared's tests with coverage
# Server: Types builds shared and the server, typechecks shared,
# the server's src, tests and scripts, smokes the
# production require chain, the strict ratchet,
# the generated entities and Kysely types
# Server: Lint & Checks ESLint (typed), errors and warning counts; then
# Prettier, the plugin facts, the DB call graph,
# the shared ratchet code, the source ratchets
# (size, boundaries, dialect, tx, test SQL, test
# mocks, hand-built services, query API, MCP zod,
# error envelopes, response contracts), open
# request schemas, knip
# Server: Tests n/7 one slice of the server suite, with coverage
# Server: Coverage merges the slices, checks the per-domain
# thresholds, uploads lcov
# Server: S3 Contract (MinIO)
# the storage driver against MinIO
# Server: Postgres Probe the repository SQL against Postgres
# Client: Types & Checks typecheck and the strict ratchet; then the page
# pattern and the source ratchets (RTL, size,
# Prettier, layers, offline path, duplication,
# desktop/phone pairs, skips, i18n keys, theme
# tokens)
# Client: Lint ESLint (typed), errors and warning counts
# Client: Tests n/7 one slice of the client suite, with coverage
# Client: Coverage merges the slices, checks the thresholds,
# uploads lcov
# Client: Bundle the production build, one chunk per map engine
# Client: End-to-End the app in Chromium and WebKit
# Plugin SDK: Types & Coverage
# Repo: Docker Image builds the image and boots it until ready
# Repo: Helm Chart & Compose
# Repo: Sonar Scan SonarCloud analysis, not required
# ci-ok the one required check (below)
#
# Job layout:
# - Everything starts right after changes: the gates and the tests run side
# by side, so a run takes as long as its slowest job and not the gates
# plus the tests. The tests used to wait for the gates, which put ~9
# minutes in front of every test result. The gates were one job per
# package (~8-9 minutes each); each is now two jobs of ~4-5 minutes,
# split where the time is (the server's typechecks, the client's lint),
# so neither outlasts a test shard.
# - The client and server suites run in shards (client-tests,
# server-tests), each running a slice with coverage and uploading a blob
# report; client-coverage and server-coverage merge the blobs, check the
# thresholds against the whole and upload lcov. A red shard is usually a
# failing test, a red merge job usually a threshold. None of the other
# test jobs feeds the scan.
# - Job count: GitHub runs at most 20 jobs of the organisation at once and
# does not say in which order it starts queued ones. A code change starts
# the 14 shards and about a dozen other jobs; seven of those (parity, the
# SDK, the bundle, S3, Postgres, shared coverage, the chart) are done in
# a minute or two, so what queues at the start waits about that long. The
# jobs that run for minutes (shards, gates, e2e, the image) must not
# outnumber the 20 slots, or one of them waits for another to finish and
# the run grows by a whole job: a layout with six gate jobs did exactly
# that. Check the count before adding a long job or a shard.
# - Scan `needs:` the gates and the four coverage jobs. A coverage job
# failing must stop the scan — a missing lcov file would make every line
# of that package report as uncovered, and the dashboard would show a
# collapse that did not happen.
# Job layout — three tiers:
# - Preflight: plugin-facts, i18n-parity and the quality jobs
# (server-quality, client-quality, plugin-sdk-quality) hold the cheap
# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all
# of them, so a gate failure stops the run before the expensive coverage
# fan-out spends ~15 runner-minutes on a result the gates already sank.
# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract
# runs the MinIO contract suite. Coverage jobs run tests only — with the
# gates cleared in preflight, they can only fail because tests failed.
# client-e2e drives the real app in Chromium and WebKit, client-bundle
# builds the client and checks each map engine keeps a chunk of its own,
# and postgres-probe runs the dialect helpers and the repository
# statements it reaches against a Postgres service. None of them feeds the scan.
# - Scan `needs:` exactly the four coverage jobs. A coverage job failing
# must stop the scan — a missing lcov file would make every line of that
# package report as uncovered, and the dashboard would show a collapse
# that did not happen. (The preflight gates stop it transitively.)
# - Fork PRs and runs Dependabot triggers don't receive the repo's Actions
# secrets, so the two jobs that need them skip themselves: s3-contract
# (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). ci-ok counts a skip as
@@ -88,7 +38,7 @@ permissions:
# required check that never reports blocks the PR for good.
# - docker-image builds the Dockerfile and boots the result, and
# deploy-manifests lints and renders the Helm chart and checks the
# compose files. Both run beside the gates rather than after them.
# compose files. Both run beside the preflight rather than after it.
# - ci-ok `needs:` every gate, test, image and chart job, and is the one
# check the branch ruleset requires. It fails when any of them failed or
# was cancelled and passes when they passed or were skipped (by changes,
@@ -119,7 +69,7 @@ concurrency:
jobs:
changes:
name: 'Repo: Changed Areas'
name: Changed Areas
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
@@ -194,8 +144,36 @@ jobs:
node scripts/ci/changed-areas.mjs --all
fi
plugin-facts:
name: Plugin Permission Facts
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity:
name: 'Shared: i18n Parity'
name: i18n Key Parity
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -211,8 +189,8 @@ jobs:
- name: Check i18n key parity
run: node shared/scripts/i18n-parity.mjs --strict
server-types:
name: 'Server: Types'
server-quality:
name: Server Types & Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -279,36 +257,6 @@ jobs:
# migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types
server-lint-checks:
name: 'Server: Lint & Checks'
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build shared
run: npm run build --workspace=shared
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint
# Errors fail, and the warnings per rule may not pass
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
@@ -316,19 +264,8 @@ jobs:
# program, which outgrows Node's default ~4 GB heap on the runner.
env:
NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
# generated from it and checked in, because publish-plugin-sdk builds
# from a standalone checkout with no server present. This fails the run
# if a copy drifts, if a new permission has no admin.plugins.perm.*
# translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
- name: Formatting check
# Prettier over src/ and tests/: a file outside
# scripts/format-baseline.json must be formatted, and the list only
@@ -422,12 +359,12 @@ jobs:
# knip (knip.jsonc) over client, server and shared: unused files,
# exports, types and dependencies per file may not rise above
# scripts/ci/knip-baseline.json (scripts/ci/knip-ratchet.mjs). Here
# rather than in client-types-checks because it needs every workspace's
# dependencies, which the client jobs do not install.
# rather than in client-quality because it needs every workspace's
# dependencies, which only this job installs.
run: npm run lint:knip
client-types-checks:
name: 'Client: Types & Checks'
client-quality:
name: Client Types & Lint
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
@@ -443,7 +380,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -458,6 +395,15 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict
- name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json.
# Typed linting (no-floating-promises, no-misused-promises) loads the
# whole TypeScript program, so it gets the heap the server lint gets.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: cd client && npm run lint:warnings
- name: Page pattern check
run: cd client && npm run lint:pages
@@ -521,12 +467,15 @@ jobs:
# follow the user's scheme, accent and text size (scripts/theme-lint.mjs).
run: cd client && npm run theme:lint
client-lint:
name: 'Client: Lint'
plugin-sdk-quality:
name: Plugin SDK Types
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
defaults:
run:
working-directory: plugin-sdk
steps:
- uses: actions/checkout@v6
@@ -534,40 +483,20 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
# plugin-sdk is standalone (own lockfile, not a root workspace), so it
# installs from its own directory.
- name: Install dependencies
run: npm ci --workspace shared --workspace client
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
# 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
- name: Typecheck
run: npm run typecheck
client-bundle:
name: 'Client: Bundle'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Client Bundle
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -578,7 +507,7 @@ jobs:
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
@@ -594,11 +523,10 @@ jobs:
run: cd client && npm run check:gl-split
client-e2e:
name: 'Client: End-to-End'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Client End-to-End
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -652,17 +580,17 @@ jobs:
retention-days: 7
s3-contract:
name: 'Server: S3 Contract (MinIO)'
name: S3 Driver Contract (MinIO)
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: changes
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
# Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and
# the AIStor container would fail to boot. A run Dependabot triggers gets
# only Dependabot secrets, so its PRs (same repo, not a fork) hit the same
# wall. Pushes and manual dispatches by anyone else do have secrets, so
# only those two are filtered out.
# only those two are filtered out. (The implicit success() on this
# expression also keeps the job skipped when a preflight fails.)
if: >-
needs.changes.outputs.code == 'true' &&
github.actor != 'dependabot[bot]' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
@@ -678,14 +606,14 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
@@ -743,10 +671,10 @@ jobs:
# artifact seeds the file and a change that fixes a statement can lower it
# without a local Postgres.
postgres-probe:
name: 'Server: Postgres Probe'
name: Postgres Probe
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: changes
needs: [changes, plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
if: needs.changes.outputs.code == 'true'
services:
postgres:
@@ -799,20 +727,11 @@ jobs:
if-no-files-found: ignore
retention-days: 14
# The server suite in seven slices. vitest.shard.config.ts is vitest.config.ts
# without the thresholds and the coverage reports: a slice's blob carries
# its coverage to server-coverage, which checks the whole. fail-fast is off,
# so one red run shows every failing slice and not just the first.
server-tests:
name: 'Server: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
server-coverage:
name: Server Coverage
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -825,63 +744,20 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Check @swc/core's native binding loads
# The server's SWC transform (server/vitest.config.ts) needs it. The
# lockfile carries the Linux binary, so npm ci installs it; this only
# names the cause if a lockfile regenerated without it ever lands. (It
# used to be installed here with --legacy-peer-deps, which pruned
# eight peer packages and left the tests on a tree the lockfile does
# not describe.)
run: node -e "require('@swc/core').transformSync('let a = 1')"
- name: Ensure @swc/core's Linux binary for unplugin-swc
# The lockfile was generated on Windows and omits @swc/core's Linux
# optional native binary, so npm ci/install skips it on the runner.
# Install the matching version explicitly so the server's SWC transform
# (server/vitest.config.ts) can load.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build shared
run: npm run build --workspace=shared
- name: Run a slice of the tests with coverage
run: >-
cd server && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: server-blob-${{ matrix.shard }}
path: server/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
server-coverage:
name: 'Server: Coverage'
needs: server-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: server-blob-*
path: server/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd server && npx vitest run --merge-reports --coverage
- name: Run tests with coverage
run: cd server && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -890,18 +766,11 @@ jobs:
path: server/coverage/
retention-days: 1
# The client suite in seven slices, the same way as server-tests. The two
# suites cost about the same per slice at seven each (~4-6 minutes).
client-tests:
name: 'Client: Tests ${{ matrix.shard }}/7'
needs: changes
if: needs.changes.outputs.code == 'true'
client-coverage:
name: Client Coverage
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -916,56 +785,13 @@ jobs:
# @testing-library/react) in the tree without the re-resolution dance
# the folded coverage job used to need.
- name: Install dependencies
run: npm ci --workspace shared --workspace client
run: npm ci --workspace shared && npm ci --workspace client
- name: Build shared
run: npm run build --workspace=shared
- name: Run a slice of the tests with coverage
run: >-
cd client && npx vitest run --config vitest.shard.config.ts --coverage
--shard=${{ matrix.shard }}/7
--reporter=default --reporter=github-actions --reporter=blob
- name: Upload the blob report
uses: actions/upload-artifact@v6
with:
name: client-blob-${{ matrix.shard }}
path: client/.vitest-reports/
include-hidden-files: true
if-no-files-found: error
retention-days: 1
client-coverage:
name: 'Client: Coverage'
needs: client-tests
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace shared --workspace client
- name: Download the blob reports
uses: actions/download-artifact@v7
with:
pattern: client-blob-*
path: client/.vitest-reports/
merge-multiple: true
- name: Merge the slices and check the thresholds
# Every blob holds a whole coverage map, and the merge reads them all
# into one process.
env:
NODE_OPTIONS: --max-old-space-size=6144
run: cd client && npx vitest run --merge-reports --coverage
- name: Run tests with coverage
run: cd client && npm run test:coverage
- name: Upload coverage
uses: actions/upload-artifact@v6
@@ -975,11 +801,10 @@ jobs:
retention-days: 1
shared-coverage:
name: 'Shared: Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
name: Shared Coverage
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
steps:
- uses: actions/checkout@v6
@@ -1002,14 +827,11 @@ jobs:
path: shared/coverage/
retention-days: 1
# Typecheck and tests in one job: each takes seconds, and a runner of its
# own would cost more in setup than it saves.
plugin-sdk:
name: 'Plugin SDK: Types & Coverage'
needs: changes
if: needs.changes.outputs.code == 'true'
plugin-sdk-coverage:
name: Plugin SDK Coverage
runs-on: ubuntu-24.04
timeout-minutes: 45
needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality]
defaults:
run:
working-directory: plugin-sdk
@@ -1028,9 +850,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Run tests with coverage
run: npm run test:coverage
@@ -1042,7 +861,7 @@ jobs:
retention-days: 1
scan:
name: 'Repo: Sonar Scan'
name: Scan
runs-on: ubuntu-24.04
timeout-minutes: 45
# Fork PRs and Dependabot runs don't receive SONAR_TOKEN, so skip the scan
@@ -1052,18 +871,9 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
# Coverage jobs failing must stop the scan: a missing lcov file would make
# every line of that package report as uncovered, and the dashboard would
# show a collapse that did not happen. The gates run beside the tests now,
# so they are listed here too, and a failed gate still stops the scan.
needs:
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-coverage
- client-coverage
- shared-coverage
- plugin-sdk
# show a collapse that did not happen. The preflight gates already stop it
# transitively through these four, so they aren't repeated here.
needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage]
steps:
- uses: actions/checkout@v6
@@ -1124,7 +934,7 @@ jobs:
# reached dev unseen. It is separate from the vulnerability scan on purpose,
# so a newly published CVE cannot hide a broken build or the other way round.
docker-image:
name: 'Repo: Docker Image'
name: Docker Image
needs: changes
if: needs.changes.outputs.image == 'true'
runs-on: ubuntu-24.04
@@ -1201,7 +1011,7 @@ jobs:
# the output checked against the Kubernetes API schemas. Both tools come
# from their release pages, pinned by version and checksum.
deploy-manifests:
name: 'Repo: Helm Chart & Compose'
name: Helm Chart & Compose
needs: changes
if: needs.changes.outputs.deploy == 'true'
runs-on: ubuntu-24.04
@@ -1272,21 +1082,19 @@ jobs:
ci-ok:
needs:
- changes
- plugin-facts
- i18n-parity
- server-types
- server-lint-checks
- client-types-checks
- client-lint
- server-quality
- client-quality
- plugin-sdk-quality
- client-bundle
- client-e2e
- s3-contract
- postgres-probe
- server-tests
- server-coverage
- client-tests
- client-coverage
- shared-coverage
- plugin-sdk
- plugin-sdk-coverage
- docker-image
- deploy-manifests
# always(): without it a failed dependency would skip this job, and the
-2
View File
@@ -55,8 +55,6 @@ coverage
coverage-*/
*.lcov
.nyc_output
# Blob reports of a sharded vitest run (test.yml)
.vitest-reports
# Cache
.npm
+1 -3
View File
@@ -134,10 +134,8 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`, client also the strict-mode ratchet `lint:strict`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips`, `lint:i18n-keys` and `theme:lint`, shared's `contracts:open`, the repo-wide `lint:knip` (in server-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.
`.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.
- **Job names read "package: what"** (`Server: Types`, `Client: Tests 3/7`), and the list at the top of `test.yml` says what each job holds. A new job follows the scheme and joins that list, and the job-count note there says when a long job needs a slot freed first.
- **Tests run beside the gates, in shards.** The client and server suites run in slices on parallel runners (`client-tests`, `server-tests`, through each package's `vitest.shard.config.ts`), and `client-coverage`/`server-coverage` merge their blob reports and check the thresholds against the whole. A red slice is usually a failing test, a red merge job usually a threshold.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix).
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
+1
View File
@@ -47,6 +47,7 @@
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/creepster": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0",
+2
View File
@@ -24,6 +24,8 @@
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
// Special themes (APPEARANCE_SPECIAL_SCHEMES in @trek/shared) are dark-only.
if (!isShared && s.scheme === 'halloween') dark = true;
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
+2 -2
View File
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 874,
"@typescript-eslint/no-misused-promises": 875,
"no-console": 88,
"complexity": 230,
"@typescript-eslint/no-explicit-any": 147,
@@ -12,7 +12,7 @@
"react-hooks/set-state-in-effect": 197,
"@typescript-eslint/no-floating-promises": 167,
"react-hooks/exhaustive-deps": 121,
"@typescript-eslint/no-unused-vars": 129,
"@typescript-eslint/no-unused-vars": 130,
"react-hooks/immutability": 28,
"max-lines-per-function": 133,
"react-hooks/static-components": 78,
+4 -91
View File
@@ -2,7 +2,6 @@
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
*/
import { ESLint } from 'eslint';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import ts from 'typescript';
import {
@@ -138,90 +137,6 @@ 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);
/**
@@ -233,7 +148,6 @@ export async function check({
baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false,
lint = lintClient,
lintText = lint === lintClient ? lintTextClient : null,
log = console.log,
error = console.error,
}) {
@@ -246,13 +160,13 @@ export async function check({
}
for (const line of errors) error(`FAIL ${line}`);
const grown = [];
let grown = 0;
let stale = 0;
for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue;
grown.push({ area, rule });
grown++;
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
@@ -268,8 +182,7 @@ export async function check({
});
stale += fallen.length;
}
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
if (grown.length) {
if (grown) {
error(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.'
@@ -278,5 +191,5 @@ export async function check({
log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
);
return errors.length || grown.length || stale ? 1 : 0;
return errors.length || grown || stale ? 1 : 0;
}
+2 -2
View File
@@ -5,9 +5,9 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1401,
"src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2296,
"src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195,
"src/components/PDF/TripPDF.tsx": 1082,
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
+2 -2
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21,
"components/Layout/Navbar.tsx": 22,
"components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3,
@@ -269,7 +269,7 @@
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
+2
View File
@@ -33,6 +33,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel'
import SpecialThemeLayer from './theme/special/SpecialThemeLayer'
// Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed'
@@ -405,6 +406,7 @@ export default function App() {
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:special-theme" fallback={null}><SpecialThemeLayer /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing
-13
View File
@@ -1,13 +0,0 @@
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 -11
View File
@@ -2,19 +2,17 @@ import {
paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema,
paddleRegionSchema,
paddleRegionSettingsSchema,
successResponseSchema,
type PaddleRegionRequest,
type PaddleRegionSettings,
} from '@trek/shared';
import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 };
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
export const paddleRegionsApi = {
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
catalog: (q: string, signal?: AbortSignal) =>
apiClient
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
@@ -31,11 +29,4 @@ export const paddleRegionsApi = {
apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
decline: (id: string) =>
apiClient
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
saveSettings: (settings: PaddleRegionSettings) =>
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
};
-10
View File
@@ -1,10 +0,0 @@
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)),
};
-16
View File
@@ -1,16 +0,0 @@
import type { AdminFeatureToggleResponse } from '@trek/shared';
import apiClient from './client';
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
function tourBaseMapAdminApi(path: string) {
return {
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
apiClient.put(path, { enabled }).then((r) => r.data),
};
}
/** MAP-01: OpenHikingMap for a hike. */
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
/** BIK-06: CyclOSM for a bike tour. */
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
-33
View File
@@ -1,33 +0,0 @@
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']) };
},
};
-18
View File
@@ -1,18 +0,0 @@
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),
};
-16
View File
@@ -1,16 +0,0 @@
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),
};
-20
View File
@@ -1,20 +0,0 @@
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),
};
-31
View File
@@ -1,31 +0,0 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
-4
View File
@@ -93,10 +93,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events.
'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
+7 -20
View File
@@ -77,7 +77,6 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
} = useAddonManager()
if (loading) {
@@ -156,25 +155,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
}
if (addon.id === 'tours' && addon.enabled) {
return (
<>
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
<AddonSubRow
key={key}
icon={<Icon size={14} />}
title={t(titleKey)}
description={t(subtitleKey)}
enabled={enabled}
onToggle={() => void toggle()}
/>
))}
</>
<AddonSubRow
icon={<WandererMark size={14} />}
title={t('admin.toursWanderer.title')}
description={t('admin.toursWanderer.subtitle')}
enabled={toursWanderer}
onToggle={() => void toggleToursWanderer()}
/>
)
}
// The AI-parsing settings hang off their tile like every other sub-feature,
@@ -2,13 +2,11 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useAddonStore } from '../../../store/addonStore';
import type { Addon } from './addonModel';
import { useAddonManager } from './useAddonManager';
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -37,9 +35,6 @@ beforeEach(() => {
useAddonStore.setState({ loadAddons });
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
});
afterEach(() => vi.restoreAllMocks());
@@ -51,13 +46,6 @@ async function loaded() {
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
/** One of the map switches under Tours, by its key. */
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
const found = manager.mapSwitches.find((entry) => entry.key === key);
if (!found) throw new Error(`no ${key} map switch`);
return found;
}
describe('useAddonManager', () => {
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
const { result, rerender } = await loaded();
@@ -127,33 +115,4 @@ describe('useAddonManager', () => {
await act(() => result.current.documentProviderOptions[0].toggle());
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
});
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'hiking').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
expect(loadAddons).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
});
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
const { result } = await loaded();
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
await act(() => mapSwitch(result.current, 'cycle').toggle());
expect(update).toHaveBeenCalledWith(false);
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
});
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
const { result } = await loaded();
await act(() => result.current.toggleToursWanderer());
expect(result.current.toursWanderer).toBe(false);
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
});
});
@@ -1,8 +1,6 @@
import { Bike, Footprints } from 'lucide-react';
import { useEffect, useEffectEvent, useState } from 'react';
import { adminApi } from '../../../api/client';
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
import { wandererAdminApi } from '../../../api/wanderer';
import { useTranslation } from '../../../i18n';
import { useAddonStore } from '../../../store/addonStore';
@@ -22,8 +20,6 @@ export function useAddonManager() {
const [addons, setAddons] = useState<Addon[]>([]);
const [loading, setLoading] = useState(true);
const [toursWanderer, setToursWanderer] = useState(false);
const [toursHikingMap, setToursHikingMap] = useState(false);
const [toursCycleMap, setToursCycleMap] = useState(false);
const loadAddons = async () => {
try {
@@ -40,14 +36,6 @@ export function useAddonManager() {
.get()
.then((flag) => setToursWanderer(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
hikingMapAdminApi
.get()
.then((flag) => setToursHikingMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
cycleMapAdminApi
.get()
.then((flag) => setToursCycleMap(flag.enabled))
.catch(() => toast.error(t('admin.addons.toast.error')));
});
useEffect(() => {
loadOnMount();
@@ -75,44 +63,20 @@ export function useAddonManager() {
toast.success(t('admin.addons.toast.updated'));
};
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
const flipToursSwitch = async (
current: boolean,
set: (enabled: boolean) => void,
save: (enabled: boolean) => Promise<unknown>
) => {
const next = !current;
set(next);
/** wanderer under Tours: flipped at once, put back when the save fails. */
const toggleToursWanderer = async () => {
const next = !toursWanderer;
setToursWanderer(next);
try {
await save(next);
await wandererAdminApi.update(next);
} catch {
set(!next);
setToursWanderer(!next);
toast.error(t('admin.addons.toast.error'));
return;
}
void refreshGlobalAddons();
toast.success(t('admin.addons.toast.updated'));
};
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
const mapSwitches = [
{
key: 'hiking',
Icon: Footprints,
titleKey: 'admin.toursHikingMap.title',
subtitleKey: 'admin.toursHikingMap.subtitle',
enabled: toursHikingMap,
toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update),
},
{
key: 'cycle',
Icon: Bike,
titleKey: 'admin.toursCycleMap.title',
subtitleKey: 'admin.toursCycleMap.subtitle',
enabled: toursCycleMap,
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
},
] as const;
const asOption = (provider: Addon): ProviderOption => ({
key: provider.id,
@@ -142,6 +106,5 @@ export function useAddonManager() {
documentProviderOptions,
toursWanderer,
toggleToursWanderer,
mapSwitches,
};
}
@@ -1,167 +0,0 @@
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-027: the paddle regions card in the admin area, drawn from a
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
// controlled usePaddleRegionsAdmin (the hook has its own test).
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
@@ -25,13 +25,11 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'ready',
origin: 'admin',
source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234,
error: null,
progress: null,
network: null,
...overrides,
};
}
@@ -58,8 +56,6 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
toursOn: true,
offline: false,
regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false,
error: null,
retry: vi.fn(),
@@ -67,8 +63,6 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
busyId: null,
add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
approve: vi.fn(async () => {}),
decline: vi.fn(async () => {}),
removing: null,
askRemove: vi.fn(),
cancelRemove: vi.fn(),
@@ -301,91 +295,6 @@ 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', () => {
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
expect(isReading({ status: 'queued' })).toBe(true);
@@ -15,7 +15,6 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip';
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
@@ -23,10 +22,8 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
/**
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
* place a paddler plans in by itself, with the regions that wait for an admin.
* Both the desktop and the phone admin show this one card under the addons,
* while the Tours addon is on.
* picked from Geofabrik's list. Both the desktop and the phone admin show
* this one card under the addons, while the Tours addon is on.
*/
export default function PaddleRegionsPanel() {
return (
@@ -70,8 +67,6 @@ function Panel() {
</div>
)}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<AutoLoadSettings admin={admin} />
<RequestList admin={admin} />
<CatalogSearch admin={admin} />
<RegionList admin={admin} />
</fieldset>
@@ -221,41 +216,6 @@ 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 }) {
const { t, language } = useTranslation();
const busy = admin.busyId === region.id;
@@ -279,12 +239,10 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
</span>
<span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} />
{region.origin === 'auto' && <AutoOriginPill />}
{region.imported_at && (
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
)}
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
<RegionNetwork region={region} />
</span>
{region.status === 'failed' && region.error && (
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
@@ -1,50 +0,0 @@
// 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);
});
});
@@ -1,59 +0,0 @@
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,15 +24,11 @@ const repo = vi.hoisted(() => ({
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
paddleRegionRepo: repo,
}));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -44,13 +40,11 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
id,
name: id,
status,
origin: 'admin',
source_timestamp: null,
imported_at: null,
feature_count: 0,
error: null,
progress: null,
network: null,
...over,
};
}
@@ -1,4 +1,4 @@
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n';
@@ -7,7 +7,6 @@ import { useAddonStore } from '../../../store/addonStore';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import { isReading } from './paddleRegionsModel';
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
/** How often the list is asked again while a region waits or is read. */
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
@@ -24,14 +23,12 @@ function errorText(t: Translate, err: unknown): string {
}
/**
* The regions as the server holds them, with those paddlers asked for, asked
* again every few seconds while any of them is read, and on `reload` after a
* change. Every answer is set
* The regions as the server holds them, asked again every few seconds while
* any of them is read, and on `reload` after a change. Every answer is set
* from the request's own callback, so a slow one never overwrites a newer.
*/
function useRegionList(active: boolean, offline: boolean, t: Translate) {
const [regions, setRegions] = useState<PaddleRegion[]>([]);
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [asked, setAsked] = useState(0);
@@ -41,8 +38,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
paddleRegionRepo.list().then(
(list) => {
if (cancelled) return;
setRegions(list.regions);
setRequests(list.requests);
setRegions(list);
setError(null);
setLoaded(true);
},
@@ -63,7 +59,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
return () => window.clearInterval(timer);
}, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, requests, loading: !loaded && !offline, error, reload };
return { regions, loading: !loaded && !offline, error, reload };
}
/** Geofabrik's list searched as the admin types, the last search winning. */
@@ -103,8 +99,7 @@ function useCatalogSearch(offline: boolean, t: Translate) {
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
* in Geofabrik's list to add one, reading a region again and removing it
* after a confirmation. Shown only while the Tours addon is on; the status of
* a read follows without reloading the page. PAD-11C: how TREK loads regions
* for paddlers by itself, and the regions they asked for, to load or decline.
* a read follows without reloading the page.
*/
export function usePaddleRegionsAdmin() {
const { t } = useTranslation();
@@ -112,7 +107,6 @@ export function usePaddleRegionsAdmin() {
const { offline } = useNetworkMode();
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
const list = useRegionList(toursOn, offline, t);
const autoLoad = usePaddleAutoLoad(toursOn, offline);
const search = useCatalogSearch(offline, t);
const [busyId, setBusyId] = useState<string | null>(null);
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
@@ -152,12 +146,6 @@ export function usePaddleRegionsAdmin() {
[run, setQuery, t]
);
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
/** A paddler's request let through: the region is added like any other. */
const approve = useCallback(
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
[run, t]
);
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
const confirmRemove = useCallback(() => {
const region = removing;
setRemoving(null);
@@ -169,8 +157,6 @@ export function usePaddleRegionsAdmin() {
toursOn,
offline,
regions: list.regions,
requests: list.requests,
autoLoad,
loading: list.loading,
error: list.error,
retry: reload,
@@ -179,8 +165,6 @@ export function usePaddleRegionsAdmin() {
busyId,
add,
refresh,
approve,
decline,
removing,
askRemove: setRemoving,
cancelRemove: () => setRemoving(null),
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
];
/**
@@ -1,13 +1,12 @@
import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
import type { DocSyncProvider, useDocSync } from './useDocSync'
/**
* Connecting a store, as a dialog rather than a panel that unfolds in place.
@@ -33,7 +32,7 @@ export default function DocSyncConnectModal({
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const test = async () => setVerdict(await form.probe())
const labelId = useId()
@@ -135,7 +134,7 @@ function Verdict({
busy,
failure,
}: {
verdict: DocSyncVerdict | null
verdict: { connected: boolean; account?: string; error?: string } | null
busy: boolean
/** A call that failed outside the connection test (saving, most of all). */
failure: string | null
@@ -173,16 +172,10 @@ function Verdict({
)
}
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
// The footer shares its row with the buttons, and a refusal cut to "The
// credentials were re..." with no way to read the rest left nothing to go on
// (#2666). Two lines, and the whole of it with the store's reason on hover.
return (
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
</span>
</Tooltip>
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
</span>
)
}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
/**
* The credential form's rules, for both shells.
@@ -39,7 +39,7 @@ export interface ConnectForm {
setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */
complete: boolean
probe: () => Promise<DocSyncVerdict>
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
save: () => Promise<boolean>
}
@@ -12,14 +12,6 @@ import { useServerPing } from '../../../sync/useServerPing'
* reason for the same problem.
*/
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
export interface DocSyncVerdict {
connected: boolean
account?: string
error?: string
detail?: string
}
export interface DocSyncField {
field_key: string
label: string
@@ -272,9 +264,11 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test')
try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) {
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
return { connected: false, error: readError(e) }
} finally {
setBusy(null)
}
@@ -98,19 +98,6 @@ describe('JourneySettingsDialog', () => {
await screen.findByRole('button', { name: /create share link/i })
})
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney({
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
}))
expect(screen.getByText('Through trips')).toBeInTheDocument()
expect(screen.getByText('lena')).toBeInTheDocument()
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
const user = userEvent.setup()
const { onSaved } = mountDialog()
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,8 +277,6 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection>
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
</div>
</div>
@@ -326,32 +324,3 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</>
)
}
/**
* Who a trip's linked journal lets in (#2683), under its own heading below the
* contributors: shown here, but managed in that trip's settings, so read only.
*/
function TripAccessSection({ access, roleLabel }: {
access: JourneyTripAccess[] | undefined
roleLabel: (role: JourneyContributor['role']) => string
}) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<DialogSection label={t('journey.contributors.viaTrips')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
<div className={ROWS}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
</div>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
</div>
))}
</div>
</DialogSection>
)
}
+3 -2
View File
@@ -9,6 +9,7 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
import { useAuthStore } from '../../store/authStore'
import { visibleManagedNavItems } from '../../managed'
import { isNavItemActive, useCreateAction } from './useCreateAction'
import { useIsDark } from '../../hooks/useIsDark'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -22,8 +23,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
export default function BottomNav() {
const { t } = useTranslation()
const navigate = useNavigate()
const darkMode = useSettingsStore(s => s.settings.dark_mode)
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
const addons = useAddonStore(s => s.addons)
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop
@@ -7,13 +7,14 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import { useIsDark } from '../../hooks/useIsDark'
export default function InAppNotificationBell(): React.ReactElement {
const { t } = useTranslation()
const navigate = useNavigate()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
+18 -14
View File
@@ -169,24 +169,23 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin');
});
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
const settings = screen.getByRole('button', { name: 'Trip settings' });
expect(back.parentElement).toBe(settings.closest('div'));
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
render(<Navbar onShare={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
expect(shareBtn).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
const user = userEvent.setup();
const onSettings = vi.fn();
render(<Navbar onSettings={onSettings} />);
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
expect(onSettings).toHaveBeenCalled();
const onShare = vi.fn();
render(<Navbar onShare={onShare} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
});
@@ -454,8 +453,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
});
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back);
@@ -463,6 +462,11 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme);
+18 -22
View File
@@ -6,13 +6,14 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { visibleManagedNavItems } from '../../managed'
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
import { useIsDark } from '../../hooks/useIsDark'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -21,8 +22,7 @@ interface NavbarProps {
tripId?: number | string
onBack?: () => void
showBack?: boolean
/** The trip's Settings: who is on it, its links and its journal. */
onSettings?: () => void
onShare?: () => void
}
interface Addon {
@@ -33,7 +33,7 @@ interface Addon {
enabled: boolean
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
@@ -42,8 +42,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [scrolled, setScrolled] = useState<boolean>(false)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
@@ -122,7 +122,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
}} className="trek-navbar hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). Both columns keep at least their
@@ -174,21 +174,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span>
))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
// The same pill as the trip's own beside it, so the two read as one pair.
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
<span className="text-caption font-semibold">{t('nav.settings')}</span>
</button>
)}
</div>
{/* Centred liquid-glass tab menu (design handoff).
@@ -261,6 +246,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />}
@@ -1,4 +1,4 @@
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
@@ -59,38 +59,6 @@ describe('MapLayerSwitcher', () => {
expect(onChange).toHaveBeenCalledWith('topo')
})
it('FE-COMP-MAPLAYER-018: offers the hiking map only where the tour type and the admin offer it (MAP-01)', () => {
const onChange = vi.fn()
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'satellite'] }} />)
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
rerender(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'hiking', 'satellite'] }} />)
fireEvent.click(screen.getByRole('button', { name: 'Hiking' }))
expect(onChange).toHaveBeenCalledWith('hiking')
})
it('FE-COMP-MAPLAYER-019: a hiking map that stopped answering says so on its segment, and only while it is shown', () => {
const baseHealth = { base: 'hiking' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
const layers = { bases: ['default', 'hiking'] as const, baseHealth }
const { rerender } = render(<TourMapLayerSwitcher active="hiking" onChange={() => {}} layers={layers} />)
const segment = screen.getByRole('button', { name: 'Hiking' })
expect(segment).toHaveAttribute('aria-pressed', 'true')
expect(segment).toHaveTextContent('!')
expect(screen.getByRole('status')).toHaveTextContent('OpenHikingMap is not answering right now')
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={layers} />)
expect(screen.getByRole('button', { name: 'Hiking' })).not.toHaveTextContent('!')
expect(screen.queryByRole('status')).toBeNull()
})
it('FE-COMP-MAPLAYER-020: CyclOSM sits in the switch as Cycling and says so when it stops answering (BIK-06)', () => {
const baseHealth = { base: 'cycle' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
render(<TourMapLayerSwitcher active="cycle" onChange={() => {}} layers={{ bases: ['default', 'cycle'], baseHealth }} />)
expect(screen.getByRole('button', { name: 'Cycling' })).toHaveTextContent('!')
expect(screen.getByRole('status')).toHaveTextContent('CyclOSM is not answering right now')
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
})
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
const button = screen.getByRole('button')
+8 -28
View File
@@ -1,4 +1,4 @@
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
import type { CSSProperties } from 'react'
import { useTranslation } from '../../i18n'
@@ -9,10 +9,9 @@ import { fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
export type BaseLayer = 'default' | 'satellite'
export type TourBaseLayer = BaseLayer | TourRasterBase
export type TourBaseLayer = BaseLayer | 'topo'
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
export interface TourLayerOffer {
@@ -20,8 +19,6 @@ export interface TourLayerOffer {
overlays?: readonly ToggleableRasterOverlay[]
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
paddle?: PaddleMapLayer
/** MAP-01, BIK-06: whether the map on terms shown (OpenHikingMap, CyclOSM) answers; the maps report into it. */
baseHealth?: BaseMapHealth
}
const FROSTED_SHELL = {
@@ -86,8 +83,6 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
const TOUR_LAYERS = [
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
{ id: 'topo', labelKey: null, Icon: Mountain },
{ id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints },
{ id: 'cycle', labelKey: 'map.baseLayer.cycle', Icon: Bike },
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
] as const
@@ -204,15 +199,6 @@ const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; down
* once its source stopped answering, which the tooltip names. The map and the
* planning go on without it.
*/
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
function DownBadge() {
return (
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
!
</span>
)
}
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
const { t } = useTranslation()
const look = OVERLAY_LOOK[overlay.id]
@@ -235,7 +221,11 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
>
<look.Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
{down && <DownBadge />}
{down && (
<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>
</Tooltip>
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
@@ -275,18 +265,14 @@ export function TourMapLayerSwitcher({
const { t } = useTranslation()
const bases = layers?.bases
const overlays = layers?.overlays
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
const health = layers?.baseHealth
return (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
const selected = active === id
const label = labelKey ? t(labelKey) : 'Topo'
const down = !!health?.down && health.base === id && active === id
const downLabel = id === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')
return (
<Tooltip key={id} label={down ? downLabel : label} placement="top" disabled={!compact && !down}>
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
<button
type="button"
onClick={() => onChange(id)}
@@ -297,16 +283,10 @@ export function TourMapLayerSwitcher({
>
<Icon size={15} strokeWidth={2} />
{!compact && <span>{label}</span>}
{down && <DownBadge />}
</button>
</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" />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
+1 -6
View File
@@ -252,7 +252,7 @@ describe('MapView', () => {
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 }
const props = (viewBaseLayer: 'default' | 'topo' | 'hiking' | 'satellite') => ({
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
})
@@ -279,11 +279,6 @@ describe('MapView', () => {
expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true)
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng]))
// MAP-01: OpenHikingMap is drawn in WGS-84 like topo, from its own template.
rerender(<MapView {...props('hiking')} />)
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'EPSG:3857')
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', 'https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png')
rerender(<MapView {...props('satellite')} />)
const satelliteMap = screen.getByTestId('map-container')
expect(satelliteMap).not.toBe(topoMap)
+22 -15
View File
@@ -8,7 +8,6 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers'
import { RouteHitBand } from './RouteHitBand'
import { formatViaDwell } from './routeViaDwell'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -63,8 +62,7 @@ import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './p
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { SelectedTrackLine, TrackLines } from './TrackLines'
import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
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 { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
@@ -840,7 +838,7 @@ export const MapView = memo(function MapView({
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
const isSatellite = baseLayer === 'satellite'
const tourRaster = tourRasterBaseOf(baseLayer) // topo, OpenHikingMap or CyclOSM, the Tours planner's own
const isTopo = baseLayer === 'topo'
// The caller hands over whatever the user configured; what kind of basemap
// that is decides which layer draws it. A saved raster template still wins,
// the default is a vector style.
@@ -852,7 +850,7 @@ export const MapView = memo(function MapView({
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
@@ -1117,16 +1115,17 @@ export const MapView = memo(function MapView({
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
/>
) : tourRaster ? (
) : isTopo ? (
<TileLayer
key={baseLayer}
url={tourRaster.url}
attribution={tourRaster.attribution}
maxNativeZoom={tourRaster.maxZoom}
key="topo"
url={OPENTOPOMAP_TILE_URL}
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
maxZoom={MAP_MAX_ZOOM}
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
keepBuffer={8}
updateWhenZooming={false}
updateWhenIdle={true}
referrerPolicy="strict-origin-when-cross-origin"
eventHandlers={tileHealthHandlers(tourLayers?.baseHealth, baseLayer)}
/>
) : basemap.kind === 'vector' ? (
<VectorBasemap style={basemap.style} />
@@ -1159,7 +1158,8 @@ export const MapView = memo(function MapView({
</MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */}
The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline
@@ -1173,7 +1173,15 @@ export const MapView = memo(function MapView({
key={`${i}-casing`}
positions={seg}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
/>,
<Polyline
key={`${i}-core`}
@@ -1181,7 +1189,6 @@ export const MapView = memo(function MapView({
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false}
/>,
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
])}
{/* The last bit to a place the road does not reach.
+7 -4
View File
@@ -8,7 +8,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
import type mapboxgl from 'mapbox-gl'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n/TranslationContext'
import { applyBaseLayer } from './glRasterLayers'
import { applySatellite } from './glRasterLayers'
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
@@ -612,7 +612,7 @@ export function MapViewGL({
const mapRef = useRef<any | null>(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers)
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2185,11 +2185,14 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
const apply = () => applyBaseLayer(map, baseLayer)
const apply = () => {
applySatellite(map, isSatellite)
applySatellite(map, baseLayer === 'topo', true)
}
apply()
map.on('styledata', apply)
return () => { map.off('styledata', apply) }
}, [baseLayer, mapReady, glProvider])
}, [isSatellite, baseLayer, mapReady, glProvider])
if (!isMapLibre && !mapboxToken) {
return (
@@ -2,25 +2,18 @@ import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */
export type PinCss = (size: number, selected: boolean) => string;
function plannerWaypointIcon(
pinCss: PinCss,
point: PlannerPinPoint,
index: number,
selected: boolean,
draggable: boolean
): L.DivIcon {
const { size, text } = plannerPinShape(point, index, selected);
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
const size = selected ? 30 : 26;
return L.divIcon({
className: 'tour-planner-waypoint-marker',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
});
}
@@ -32,7 +25,7 @@ export default function PlannerWaypointMarker({
onMove,
pinCss,
}: {
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
index: number;
selected: boolean;
onClick?: (id: string) => void;
@@ -46,11 +39,7 @@ export default function PlannerWaypointMarker({
handlersRef.current = { onClick, onMove };
}, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const { label, shaping } = point;
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping]
);
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
const eventHandlers = useMemo(
() => ({
click: (event: { originalEvent: MouseEvent }) => {
@@ -1,35 +0,0 @@
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,55 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers';
/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */
function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) {
const sources = new Map<string, Record<string, unknown>>();
const layers = new Map<string, string>();
const map = {
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string, source: Record<string, unknown>) => sources.set(id, source)),
getLayer: vi.fn((id: string) => (layers.has(id) ? { id } : undefined)),
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, 'visible')),
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, value)),
getStyle: vi.fn(() => ({ layers: styleLayers })),
};
return { map, sources, layers };
}
describe('applyBaseLayer (MAP-01)', () => {
it('lays OpenHikingMap with its credit under the first thing TREK draws', () => {
const { map, sources } = fakeMap();
applyBaseLayer(map, 'hiking');
expect(tourRasterSourceId('hiking')).toBe('trip-hiking');
expect(sources.get('trip-hiking')).toMatchObject({
type: 'raster',
tiles: ['https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png'],
maxzoom: 18,
});
expect(String(sources.get('trip-hiking')?.attribution)).toContain('openmaps.fr/donate');
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-hiking-raster' }), 'trip-route');
// Nothing else is built while it is off.
expect(sources.has('trip-topo')).toBe(false);
expect(sources.has('trip-satellite')).toBe(false);
});
it('switching away hides it, and back shows the same layer again without a second source', () => {
const { map, layers } = fakeMap();
applyBaseLayer(map, 'hiking');
applyBaseLayer(map, 'topo');
expect(layers.get('trip-hiking-raster')).toBe('none');
expect(layers.get('trip-topo-raster')).toBe('visible');
applyBaseLayer(map, 'hiking');
expect(layers.get('trip-hiking-raster')).toBe('visible');
expect(layers.get('trip-topo-raster')).toBe('none');
expect(map.addSource).toHaveBeenCalledTimes(2);
});
it("keeps the user's own map when the style refuses the layer", () => {
const { map } = fakeMap();
map.addLayer.mockImplementation(() => {
throw new Error('style not loaded');
});
expect(() => applyBaseLayer(map, 'hiking')).not.toThrow();
});
});
+17 -21
View File
@@ -1,9 +1,15 @@
import { SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL } from '../../constants/mapDefaults';
import { TOUR_RASTER_BASE_IDS, TOUR_RASTER_BASES, type TourRasterBase } from './tourRasterBases';
import {
OPENTOPOMAP_TILE_ATTRIBUTION,
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
* adds on top: the satellite, topo and hiking base layers, and (in mapRasterOverlay.ts)
* adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts)
* the transparent overlays such as the seamarks.
*/
@@ -44,10 +50,9 @@ 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.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function applySatellite(map: any, on: boolean, raster?: TourRasterBase): void {
const layerId = raster ? `trip-${raster}-raster` : SATELLITE_LAYER_ID;
const sourceId = raster ? tourRasterSourceId(raster) : SATELLITE_SOURCE_ID;
const source = raster ? TOUR_RASTER_BASES[raster] : null;
export function applySatellite(map: any, on: boolean, topo = false): void {
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
try {
if (map.getLayer(layerId)) {
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
@@ -60,10 +65,12 @@ export function applySatellite(map: any, on: boolean, raster?: TourRasterBase):
if (!map.getSource(sourceId)) {
map.addSource(sourceId, {
type: 'raster',
tiles: source ? source.tiles : [SATELLITE_TILE_URL],
tiles: topo
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
: [SATELLITE_TILE_URL],
tileSize: 256,
maxzoom: source ? source.maxZoom : SATELLITE_TILE_MAXZOOM,
attribution: source ? source.attribution : SATELLITE_TILE_ATTRIBUTION,
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
});
}
// Under the first thing TREK draws, over everything the basemap style draws.
@@ -83,14 +90,3 @@ export function applySatellite(map: any, on: boolean, raster?: TourRasterBase):
/* 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);
}
@@ -1,11 +1,6 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
useRasterOverlaysGL,
useSourceTileHealthGL,
type MapRasterOverlay,
type RasterOverlayGlMap,
} from './mapRasterOverlay';
import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay';
type Handler = (event: Record<string, unknown>) => void;
@@ -108,27 +103,3 @@ describe('useRasterOverlaysGL', () => {
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);
});
});
+6 -38
View File
@@ -4,8 +4,7 @@ import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerG
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers';
import type { BaseMapHealth, TileHealth } from './tourRasterBases';
import { OWN_LAYER_PREFIXES } from './glRasterLayers';
/**
* A transparent raster layer laid over the basemap and under the route, such
@@ -105,47 +104,16 @@ export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: re
}, [map, overlays]);
}
/**
* Reports the tiles of one GL source into its health while `health` is given:
* a failure carries the source it came from, an arrival a tile. The base layer
* itself is drawn by applyBaseLayer.
*/
export function useSourceTileHealthGL(
map: RasterOverlayGlMap | null,
sourceId: string,
health: TileHealth | undefined
) {
const { onTileError, onTileLoad } = health ?? {};
useEffect(() => {
if (!map || !onTileError || !onTileLoad) return;
const failed = (event: { error?: Error; sourceId?: string }) => {
if (event.sourceId === sourceId) onTileError();
};
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
if (event.sourceId === sourceId && event.tile) onTileLoad();
};
map.on('error', failed);
map.on('sourcedata', loaded);
return () => {
map.off('error', failed);
map.off('sourcedata', loaded);
};
}, [map, sourceId, onTileError, onTileLoad]);
}
/**
* The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers,
* the paddle obstacles, and whether the map on terms (OpenHikingMap, CyclOSM) answers while it is the base map.
*/
/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
export function useTourMapLayersGL(
map: RasterOverlayGlMap | null,
ferrataLayer: FerrataMapLayer | undefined,
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
overlays: readonly MapRasterOverlay[] | undefined,
paddle?: PaddleMapLayer
) {
useFerrataMapLayerGL(map, ferrataLayer);
useRasterOverlaysGL(map, layers?.overlays);
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
useRasterOverlaysGL(map, overlays);
usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle);
}
/**
+1 -9
View File
@@ -110,15 +110,7 @@ export interface MapViewProps {
onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{
id: string;
lat: number;
lng: number;
name?: string;
detail?: string;
label?: string;
shaping?: boolean;
}>;
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */
@@ -1,12 +0,0 @@
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
import { describe, expect, it } from 'vitest';
import { plannerPinShape } from './plannerPinShape';
describe('plannerPinShape', () => {
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
});
});
@@ -1,16 +0,0 @@
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
export interface PlannerPinPoint {
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
label?: string;
shaping?: boolean;
}
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
export function plannerPinShape(
point: PlannerPinPoint,
index: number,
selected: boolean
): { size: number; text: string } {
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
}
+3 -30
View File
@@ -1,5 +1,4 @@
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, type LucideIcon } from 'lucide-react'
import type { TourPoiCategory } from '@trek/shared'
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
// The road-trip kinds take their colour from the one place that defines it, so the
// search result, the map pin and the road-trip rail cannot drift apart.
@@ -41,35 +40,9 @@ export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
)
const CORE_COLOR = new Map(POI_CATEGORIES.map(c => [c.key, c.color] as const))
const coreColor = (key: string) => CORE_COLOR.get(key) ?? POI_DEFAULT_COLOR
/**
* What a hike, a ride or a paddle tour stops for (PLN-02), searched along a tour in the
* planner and drawn on its map. Each takes the colour of the explore category
* it is closest to, so the map keeps one palette.
*/
export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
viewpoint: { key: 'viewpoint', labelKey: 'tours.poi.cat.viewpoint', Icon: Binoculars, color: coreColor('sights') },
peak: { key: 'peak', labelKey: 'tours.poi.cat.peak', Icon: Mountain, color: coreColor('nature') },
shelter: { key: 'shelter', labelKey: 'tours.poi.cat.shelter', Icon: Warehouse, color: HOTEL_COLOR },
drinking_water: { key: 'drinking_water', labelKey: 'tours.poi.cat.drinking_water', Icon: Droplet, color: coreColor('museum') },
food: { key: 'food', labelKey: 'tours.poi.cat.food', Icon: Utensils, color: coreColor('restaurant') },
toilets: { key: 'toilets', labelKey: 'tours.poi.cat.toilets', Icon: Toilet, color: coreColor('bar') },
picnic: { key: 'picnic', labelKey: 'tours.poi.cat.picnic', Icon: Sandwich, color: coreColor('nature') },
campsite: { key: 'campsite', labelKey: 'tours.poi.cat.campsite', Icon: Tent, color: coreColor('activity') },
sights: { key: 'sights', labelKey: 'tours.poi.cat.sights', Icon: Camera, color: coreColor('sights') },
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
}
// A Map rather than an object, so a category named after an Object.prototype member
// (`constructor`, `toString`) finds nothing rather than a function. A Tours category
// that shares a key with an explore one (`sights`, `campsite`) keeps the explore look.
const CATEGORY_BY_KEY = new Map(
[...Object.values(TOUR_POI_LOOKS), ...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const),
)
// (`constructor`, `toString`) finds nothing rather than a function.
const CATEGORY_BY_KEY = new Map([...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const))
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
export interface Poi {
@@ -1,85 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>();
return { ...actual, calculateRouteWithLegs: vi.fn() };
});
const WAYPOINTS = [
{ lat: 47.5, lng: 19.04 },
{ lat: 47.49, lng: 19.05 },
];
const ROUTED = {
coordinates: [
[47.5, 19.04],
[47.49, 19.05],
],
legs: [],
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(calculateRouteWithLegs).mockReset();
});
afterEach(() => vi.useRealTimers());
describe('calculateRouteWithRetries', () => {
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
});
it('waits as long as the host asks when that is longer', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, {});
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
});
it('gives up after the last delay and throws the refusal', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
const answer = calculateRouteWithRetries(WAYPOINTS, {});
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
await settled;
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
});
it('throws a refusal the host meant at once', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
});
it('stops waiting when the day changes', async () => {
const controller = new AbortController();
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
await vi.advanceTimersByTimeAsync(10);
controller.abort();
await settled;
});
});
-48
View File
@@ -1,48 +0,0 @@
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
export type { RouteProfileKey } from './RouteCalculator';
/** How often a rate-limited route is asked for again, and how long after. */
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
/** A pause that ends early, without throwing, when the signal aborts. */
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal?.aborted || ms <= 0) {
resolve();
return;
}
const timer = setTimeout(done, ms);
function done(): void {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolve();
}
signal?.addEventListener('abort', done, { once: true });
});
/**
* `calculateRouteWithLegs`, asked again while the host says it is busy.
*
* The public OSRM hosts allow about one request a second and answer 429 above that. A
* day with a change of mode, or a quick switch between days, asks more often than that,
* and the day route used to give up on the first refusal and keep its straight lines
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
* as long as the host asks when it says; a refusal the host meant is thrown at once,
* because the same request earns the same answer. An abort ends the wait, and the next
* attempt then throws the AbortError the caller already handles.
*/
export async function calculateRouteWithRetries(
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
): ReturnType<typeof calculateRouteWithLegs> {
for (let attempt = 0; ; attempt++) {
try {
return await calculateRouteWithLegs(waypoints, opts);
} catch (err) {
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
}
}
}
@@ -1,38 +0,0 @@
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();
});
});
@@ -1,98 +0,0 @@
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;
}
+32 -12
View File
@@ -1,5 +1,4 @@
import type { RouteProfileKey } from './RouteCalculator'
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
import { projectDayItinerary } from './dayTourProjection'
@@ -186,6 +185,20 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60
/** How often a rate-limited leg is tried again, and how long after. */
const RETRY_DELAYS_MS = [1500, 4000]
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal?.aborted || ms <= 0) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal?.removeEventListener('abort', done)
resolve()
}
signal?.addEventListener('abort', done, { once: true })
})
/**
* Ask the router for every leg of every day, one at a time.
@@ -220,16 +233,23 @@ export async function routeTripLegs(
runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => {
tasks.push(async () => {
if (signal?.aborted) return
try {
const answer = await calculateRouteWithRetries(
chunk.waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
onAnswer?.(routed)
} catch {
// Refused for good, or aborted: the leg keeps its straight line.
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
if (signal?.aborted) return
try {
const answer = await calculateRouteWithLegs(
chunk.waypoints,
{ signal, profile: chunk.mode, tripId, dayId: day.id },
)
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
onAnswer?.(routed)
return
} catch (err) {
if (signal?.aborted) return
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
// When the host says how long to wait, waiting less is just a second refusal.
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
}
}
})
})
@@ -2,7 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract';
import { plannerPinShape } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
@@ -72,10 +71,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId;
const { size, text } = plannerPinShape(point, index, selected);
const size = selected ? 30 : 26;
const el = document.createElement('button');
el.type = 'button';
el.textContent = text;
el.textContent = String(index + 1);
const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { useSettingsStore } from '../../store/settingsStore'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
@@ -531,55 +531,3 @@ describe('valhallaAlternates', () => {
expect(calls).toBe(2)
})
})
describe('valhallaTraceAttributes (BIK-01)', () => {
const TRACE = 'https://valhalla1.openstreetmap.de/trace_attributes'
it('VALHALLA-TRACE-001: asks for the ways under the line, matched as a mountain bike rides, and hands the answer on', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post(TRACE, async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ shape: SHAPE, edges: [] })
}))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toEqual({ shape: SHAPE, edges: [] })
expect(bodies[0]).toMatchObject({
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
shape_match: 'map_snap',
filters: { action: 'include' },
})
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
})
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
let shape: unknown[] = []
server.use(http.post(TRACE, async ({ request }) => {
shape = ((await request.json()) as { shape: unknown[] }).shape
return HttpResponse.json({})
}))
const line = Array.from({ length: 9001 }, (_, i): [number, number] => [47 + i / 1e5, 11])
await valhallaTraceAttributes(line)
expect(shape.length).toBeLessThanOrEqual(4001)
expect(shape[0]).toEqual({ lat: 47, lon: 11 })
expect(shape[shape.length - 1]).toEqual({ lat: line[9000][0], lon: 11 })
})
it('VALHALLA-TRACE-003: null for a refusal, a network failure, a line of one point or a router that is not Valhalla', async () => {
server.use(http.post(TRACE, () => new HttpResponse(null, { status: 400 })))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
server.use(http.post(TRACE, () => HttpResponse.error()))
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
await expect(valhallaTraceAttributes(SHAPE_POINTS.slice(0, 1))).resolves.toBeNull()
setSettings({ routing_base_url: 'https://osrm.example' })
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
})
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
let asked = false
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
const controller = new AbortController()
controller.abort()
await expect(valhallaTraceAttributes(SHAPE_POINTS, controller.signal)).resolves.toBeNull()
expect(asked).toBe(false)
})
})
+4 -52
View File
@@ -55,9 +55,6 @@ const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
cycling: 'bicycle',
}
/** What a caller tunes in the costing model: a weighting, a switch, or a bicycle type by name. */
export type CostingOptions = Record<string, number | string | boolean>
/**
* What a caller can ask to be left out, against the costing option that asks for it.
*
@@ -194,7 +191,7 @@ export async function valhallaRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
costingOptions: Readonly<Record<string, number>> = {},
): Promise<ValhallaRun | null> {
const base = valhallaBase()
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
@@ -318,14 +315,14 @@ function routeBody(
waypoints: Waypoint[],
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
costingOptions: Readonly<CostingOptions> = {},
costingOptions: Readonly<Record<string, number>> = {},
): Record<string, unknown> {
const costing = COSTING[profile]
// All of them in one request: the options are independent weightings, so asking to
// leave out tolls and ferries together is one question rather than two routes to
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
// away together answered 656.9 km against 339.2 km for the plain route.
const options: CostingOptions = { ...costingOptions }
const options: Record<string, number> = { ...costingOptions }
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
return {
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
@@ -362,51 +359,6 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
}
}
/** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */
const TRACE_MAX_POINTS = 4000
/** How long a description may take before it counts as failed, so a router that hangs does not leave its card waiting. */
const TRACE_TIMEOUT_MS = 30_000
/**
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
* of every edge, with the line as matched. The edges are matched as a mountain
* bike would ride them, the costing that refuses the fewest ways, so a road
* bike's route and a gravel one are described alike. Null for every failure:
* the description is an addition beside a route that already exists.
*/
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
const base = valhallaBase()
if (!base || line.length < 2) return null
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon }))
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS)
try {
await valhallaTurn(base, signal)
if (signal?.aborted) return null
const response = await fetch(`${base}/trace_attributes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
body: JSON.stringify({
shape,
costing: 'bicycle',
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
shape_match: 'map_snap',
filters: {
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
'edge.begin_shape_index', 'edge.end_shape_index'],
action: 'include',
},
}),
signal: bounded,
})
if (!response.ok) return null
return await response.json()
} catch {
return null
}
}
/** One request, at most ten locations. */
async function requestRun(
base: string,
@@ -414,7 +366,7 @@ async function requestRun(
profile: 'driving' | 'walking' | 'cycling',
avoid: readonly AvoidClass[],
signal?: AbortSignal,
costingOptions: Readonly<CostingOptions> = {},
costingOptions: Readonly<Record<string, number>> = {},
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
return data === null ? null : runFrom(data)
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
import type { InAppNotification } from '../../store/inAppNotificationStore'
import { useSettingsStore } from '../../store/settingsStore'
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
import { useIsDark } from '../../hooks/useIsDark'
function relativeTime(dateStr: string, locale: string): string {
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
@@ -17,8 +18,8 @@ interface NotificationItemProps {
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation()
const { settings } = useSettingsStore()
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
// Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = useIsDark()
const { responding, handleRespond, handleNavigate, markRead, deleteNotification } =
useNotificationItemActions(notification, onClose)
@@ -271,9 +271,7 @@ describe('PackingListPanel', () => {
];
const { container } = render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
// Only the category grip stays: the order of the categories is not the A to Z of their items.
const draggable = [...container.querySelectorAll('[draggable="true"]')];
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
expect(container.querySelector('[draggable="true"]')).toBeNull();
});
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
} from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers'
@@ -38,16 +38,6 @@ interface KategorieGruppeProps {
// Off while the list is shown A-Z: a drop there would move an item in an order
// the reader cannot see.
reorderable?: boolean
// Moving the whole category among the others, wired by the list, which sees every
// card. `onDrop` gets the category that was dragged onto this one.
categoryDrag?: {
isDragging: boolean
isOver: boolean
onStart: () => void
onOver: () => void
onEnd: () => void
onDrop: (dragged: string) => void
}
// Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
@@ -56,7 +46,7 @@ interface KategorieGruppeProps {
onLeave?: (id: number, userId: number) => void
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null)
@@ -130,9 +120,8 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
return (
// Shaped after the phone's category card: a tinted head band carrying the
// name as written, the count, and the fold arrow on the far right.
<CategoryDropFrame drag={categoryDrag}>
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName && canEdit ? (
@@ -355,65 +344,6 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
))}
</div>
)}
</CategoryDropFrame>
)
}
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
/** The data type a dragged category carries, its name as the value. */
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
/**
* The card, and while a category is dragged its drop target. Captured, because
* the item rows stop their own drops from bubbling.
*/
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
// Told apart by the drag's own data type, which is there from the first event on:
// a flag set in state on dragstart can still be unrendered when a quick drag
// arrives, and a dragged item must pass through to its rows untouched.
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
return (
<div
onDragOverCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
}) : undefined}
onDropCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
}) : undefined}
style={{
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
transition: 'opacity 0.15s, box-shadow 0.15s',
}}
>
{children}
</div>
)
}
/** The handle a category is dragged by, in the head band before its colour dot. */
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
if (!drag) return null
return (
<span
draggable
role="button"
aria-label={label}
title={label}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
e.dataTransfer.setData('text/plain', kategorie)
drag.onStart()
}}
onDragEnd={drag.onEnd}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
>
<GripVertical size={14} />
</span>
)
}
@@ -1,4 +1,3 @@
import { useState } from 'react'
import type { PackingState } from './usePackingListPanel'
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
import EmptyState from '../shared/EmptyState'
@@ -8,13 +7,8 @@ export function PackingList(S: PackingState) {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
} = S
// Which category is being dragged and which card it is over. Held here, above
// the cards, because the drag starts in one card and ends in another.
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
const [overCategory, setOverCategory] = useState<string | null>(null)
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? (
@@ -46,14 +40,6 @@ export function PackingList(S: PackingState) {
allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
reorderable={sort === 'manual'}
categoryDrag={canEdit ? {
isDragging: draggedCategory === kat,
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
onOver: () => setOverCategory(kat),
onEnd: endCategoryDrag,
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
} : undefined}
currentUserId={currentUserId}
onSetSharing={handleSetSharing}
onClone={handleCloneItem}
@@ -6,7 +6,6 @@ import {
groupPackingItems,
isLastCustomItemInCategory,
isPackingPlaceholder,
itemOrderWithCategoryMoved,
packingCategoryOrder,
packingItemWeight,
packingProgress,
@@ -15,7 +14,7 @@ import {
} from './packingListModel';
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
@@ -77,28 +76,4 @@ describe('packingListModel', () => {
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
});
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
const items = [day1, day2a, own, day3, day2b];
const common = packingViewItems(items, 'common');
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
expect(order).toEqual([14, 11, 12, 15, 13]);
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
});
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
const items = [shared, stove, loose];
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
});
});
@@ -25,34 +25,6 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
return seen;
}
/**
* The whole list's item order with category `from` moved to where `to` stands.
*
* A category has no position of its own: it shows where its first item is. So a
* category is moved by moving its items, as one block, through the item reorder
* the list already has; nothing new is stored, and the order reaches every device
* the way a dragged item does. The view's items come first, grouped in the new
* order with each group in its own order; the other view's items follow in theirs,
* which leaves that view's categories as they were. Null when nothing would move.
*/
export function itemOrderWithCategoryMoved(
items: PackingItem[],
viewItems: PackingItem[],
from: string,
to: string,
defaultCategory: string
): number[] | null {
const order = packingCategoryOrder(viewItems, defaultCategory);
const fromIndex = order.indexOf(from);
const toIndex = order.indexOf(to);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
order.splice(fromIndex, 1);
order.splice(toIndex, 0, from);
const inView = new Set(viewItems.map((i) => i.id));
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
}
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
if (status === 'open') return items.filter((i) => !i.checked);
if (status === 'done') return items.filter((i) => !!i.checked);
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem } from '../../types'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
import { usePackingBags } from './usePackingBags'
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
import { usePackingImport } from './usePackingImport'
@@ -106,13 +106,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
// A category dropped onto another takes its place; the slice reorders
// optimistically and rolls back with a toast of its own.
const moveCategory = (from: string, to: string) => {
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
}
const {
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
@@ -220,7 +213,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
)
return (
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
<div ref={bodyRef}>
{asking ? (
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
+4 -9
View File
@@ -1,4 +1,4 @@
import type { MouseEvent, ReactNode, Ref } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { Clock, MoreHorizontal, type LucideIcon } from 'lucide-react'
import { Tooltip } from '../shared/Tooltip'
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
@@ -15,11 +15,8 @@ import { fs } from '../shared/DialogShell'
export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2'
/** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId, ref }: {
export function BarButton({ label, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom' }: {
label: string
/** A longer explanation for the tooltip; the label still names the button. */
tooltip?: string
testId?: string
onClick: (e: MouseEvent<HTMLButtonElement>) => void
active?: boolean
disabled?: boolean
@@ -29,12 +26,10 @@ export function BarButton({ label, tooltip, onClick, active = false, disabled =
ariaExpanded?: boolean
ariaHaspopup?: 'dialog' | 'menu'
placement?: 'top' | 'bottom' | 'left' | 'right'
/** The button itself, for a list or a menu that opens beside it. */
ref?: Ref<HTMLButtonElement>
}) {
return (
<Tooltip label={tooltip ?? label} placement={placement}>
<button ref={ref} type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
<Tooltip label={label} placement={placement}>
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted enabled:hover:bg-surface-card enabled:hover:text-content'} ${className}`}>
{children}
@@ -308,10 +308,9 @@ export default function AccountTab(): React.ReactElement {
/>
<input
type="text"
inputMode="text"
autoComplete="one-time-code"
inputMode="numeric"
value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`}
@@ -6,7 +6,8 @@ import { Segmented } from '../shared/dialogParts'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS, SPECIAL_THEMES } from '../../theme/schemes'
import SpecialThemeCard from '../../theme/special/SpecialThemeCard'
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
import { useAppearanceEditor } from './useAppearanceEditor'
@@ -35,7 +36,7 @@ const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border
export default function AppearanceSettingsTab(): React.ReactElement {
const { t } = useTranslation()
const tr = (key: string, fallback: string) => t(key) || fallback
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
const { cfg, darkMode, isDark, modeLocked, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
useAppearanceEditor()
// The stored mode, read the way the old boolean values meant it.
@@ -50,7 +51,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
{/* Color mode */}
<SettingRow
label={tr('settings.colorMode', 'Color mode')}
hint={modeLocked ? t('settings.appearance.special.modeLocked') : undefined}
control={
<div className={modeLocked ? 'pointer-events-none opacity-45' : undefined} aria-disabled={modeLocked || undefined}>
<Segmented
label={tr('settings.colorMode', 'Color mode')}
value={mode}
@@ -70,9 +73,20 @@ export default function AppearanceSettingsTab(): React.ReactElement {
},
]}
/>
</div>
}
/>
{/* Special themes: a whole look, not just an accent */}
<SettingRow label={t('settings.appearance.special')} stacked>
<div className="grid gap-2 sm:grid-cols-2">
{SPECIAL_THEMES.map((s) => (
<SpecialThemeCard key={s.id} theme={s} active={cfg.schemeId === s.id}
onSelect={() => update({ schemeId: s.id })} />
))}
</div>
</SettingRow>
{/* Color scheme swatches */}
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -290,6 +304,7 @@ function schemeFallback(id: string): string {
rose: 'Rose',
amber: 'Amber',
violet: 'Violet',
halloween: 'Halloween',
}
return map[id] || id
}
@@ -1,6 +1,7 @@
import { DEFAULT_APPEARANCE, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
import { DEFAULT_APPEARANCE, isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { useIsDark } from '../../hooks/useIsDark';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { applyAppearance } from '../../theme/applyAppearance';
@@ -42,10 +43,10 @@ export function useAppearanceEditor() {
[updateSetting]
);
const isDark =
settings.dark_mode === true ||
settings.dark_mode === 'dark' ||
(settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
// Off the `.dark` class: a special theme forces dark whatever the stored mode says.
const isDark = useIsDark();
// While a special theme is on, the color mode has nothing to switch.
const modeLocked = isSpecialScheme(cfg.schemeId);
// Live preview now (DOM), persist after a short debounce (API).
const update = (patch: Partial<AppearanceConfig>) => {
@@ -83,6 +84,7 @@ export function useAppearanceEditor() {
cfg,
darkMode: settings.dark_mode,
isDark,
modeLocked,
update,
setMode,
setWidget,
@@ -222,7 +222,7 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
if (times.walkingMinutes != null)
add(
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
formatPlannedTourDuration(times.walkingMinutes),
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
'trip-plan-tour-walking-time'
@@ -47,7 +47,6 @@ import { Tooltip } from '../shared/Tooltip';
import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
import { PaddleWindSection } from './paddle/PaddleWindCard';
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
@@ -589,10 +588,6 @@ export default function TourDetailDialog({
</span>
</Tooltip>
)}
{/* A tour written here offline has no server copy to export until it syncs. */}
{analysis && place.id > 0 && (
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
)}
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
<FooterSpacer />
{canEdit && onDelete && (
@@ -1,78 +0,0 @@
import { Download, Loader2, Share } from 'lucide-react';
import { useTranslation } from '../../i18n';
import { BarButton } from '../Planner/planParts';
import { DialogButton } from '../shared/DialogShell';
import { Tooltip } from '../shared/Tooltip';
import { useTourGpxExport, type TourGpxExportTarget } from './useTourGpxExport';
/**
* PR1-06A, IO-01 in a footer: the saved tour as a GPX file, its own action
* beside the navigation, which only leads to the start. "GPX" says the format,
* the tooltip what the file holds. Where the platform takes GPX files, a share
* button beside it hands the same file to the system's share sheet.
*/
export function TourGpxExportButton({ target }: { target: TourGpxExportTarget }) {
const { t } = useTranslation();
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
return (
<>
<Tooltip label={t('tours.gpxExport.hint')}>
<span className="inline-flex">
<DialogButton
onClick={() => void exportGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.action')}
data-testid="tour-gpx-export"
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">GPX</span>
</DialogButton>
</span>
</Tooltip>
{canShare && (
<Tooltip label={t('tours.gpxExport.share')}>
<span className="inline-flex">
<DialogButton
onClick={() => void shareGpx(target)}
disabled={exporting}
aria-label={t('tours.gpxExport.share')}
data-testid="tour-gpx-share"
icon={<Share size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{t('tours.gpxExport.shareShort')}</span>
</DialogButton>
</span>
</Tooltip>
)}
</>
);
}
/**
* The same export in the planner's head. While the tour has unsaved changes
* the file would not hold them, so the buttons wait for the save and say so.
*/
export function TourGpxExportBarButton({ target, unsaved }: { target: TourGpxExportTarget; unsaved: boolean }) {
const { t } = useTranslation();
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
return (
<>
<BarButton
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
onClick={() => void exportGpx(target)}
disabled={unsaved || exporting}
>
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
</BarButton>
{canShare && (
<BarButton
label={t(unsaved ? 'tours.gpxExport.shareSaveFirst' : 'tours.gpxExport.share')}
onClick={() => void shareGpx(target)}
disabled={unsaved || exporting}
>
<Share size={15} strokeWidth={2} />
</BarButton>
)}
</>
);
}
@@ -1,12 +1,5 @@
import {
BIKE_PROFILES,
type BikeProfile,
type PaddleBoatType,
type PaddleWaterContext,
type PlannableTourType,
type TourGpxImportFields,
} from '@trek/shared';
import { AlertTriangle, FileDown, Info } from 'lucide-react';
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
import { useId, useMemo, useState } from 'react';
import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
@@ -27,7 +20,6 @@ import {
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { TourTypeSelect } from './planner/TourTypeSelect';
import { TourNotice } from './tourParts';
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
@@ -84,12 +76,10 @@ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpx
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
const { t } = useTranslation();
const labelId = useId();
const typeLabelId = useId();
const tileUrl = useTileUrl(OFM_POSITRON);
const [type, setType] = useState<PlannableTourType>('hike');
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
const [water, setWater] = useState<PaddleWaterContext | null>(null);
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
@@ -106,9 +96,7 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
const fields: TourGpxImportFields =
type === 'kayak' && boat && water
? { tour_type: 'kayak', boat_type: boat, water_context: water }
: type === 'bike'
? { tour_type: 'bike', bike_profile: bikeProfile }
: { tour_type: 'hike' };
: { tour_type: 'hike' };
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
return (
@@ -180,21 +168,18 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
)}
</DialogSection>
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
<DialogSection label={t('tours.import.previewAs')}>
<div className="flex flex-col gap-3">
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
{type === 'bike' && (
<div data-testid="gpx-preview-bike" className="min-w-0">
<span className={LABEL}>{t('tours.bike.profile')}</span>
<Segmented<BikeProfile>
value={bikeProfile}
onChange={setBikeProfile}
label={t('tours.bike.profile')}
fill
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
/>
</div>
)}
<Segmented<PlannableTourType>
value={type}
onChange={setType}
label={t('tours.import.previewAs')}
fill
options={[
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
]}
/>
{type === 'kayak' && (
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
<div className="min-w-0">
@@ -177,26 +177,3 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
</div>
);
}
/** What a list says when its filters leave nothing: so, and a way to clear them. */
export function TourFilterEmpty({ filters }: { filters: TourListFilters }) {
const { t } = useTranslation();
return (
<div
role="status"
className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint"
>
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>
{t('tours.filter.empty')}
</p>
<button
type="button"
onClick={filters.reset}
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}
>
{t('tours.filter.reset')}
</button>
</div>
);
}
@@ -1,202 +0,0 @@
import type { TourListItem, TourTypeKey } from '@trek/shared';
import {
ArrowDown,
ArrowUp,
CalendarClock,
ChartColumnBig,
ChevronDown,
Clock,
FileDown,
Mountain,
Route,
Ruler,
Waves,
type LucideIcon,
} from 'lucide-react';
import { useMemo, type ComponentType } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import { formatDistance, formatElevation } from '../../utils/units';
import { fs } from '../shared/DialogShell';
import { useRailFold } from './railFold';
import { TourDayRowPill } from './TourDayRowFacts';
import { InfoDot, RailCard } from './tourParts';
import { formatPlannedTourDuration } from './tourPresentation';
import { tourScopeSummary, type ScopeSum, type TourTypeScope } from './tourScope';
type Translate = (key: string, params?: Record<string, unknown>) => string;
const TYPE_ICON: Partial<Record<TourTypeKey, ComponentType<{ size?: number; strokeWidth?: number }>>> = {
hike: Mountain,
kayak: Waves,
};
/** A count as a white badge of the card: the number, then what it counts. */
function CountBadge({ value, label, tone = 'neutral' }: { value: number; label: string; tone?: 'neutral' | 'faint' }) {
return (
<span
className={`inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 shadow-sm ring-1 ring-inset ring-edge-faint ${tone === 'faint' ? 'text-content-muted' : 'text-content-secondary'}`}
style={fs(11, 'body')}
>
<span className="font-geist font-semibold tabular-nums text-content">{value}</span>
{label}
</span>
);
}
/** One measure of a type as a badge: its icon and sum, its name (and how complete it is) in the tooltip. */
interface ScopeBadge {
key: string;
icon: LucideIcon;
value: string;
label: string;
}
/**
* A sum as a badge: nothing when no tour has the value, else the sum, marked
* as at least that much when some tours lack it, the tooltip saying for how many.
*/
function sumBadge(
{ key, icon, name }: Pick<ScopeBadge, 'key' | 'icon'> & { name: string },
sum: ScopeSum,
format: (value: number) => string,
t: Translate
): ScopeBadge | null {
if (sum.value === null) return null;
const partial = sum.known < sum.of;
const value = partial ? `≥ ${format(sum.value)}` : format(sum.value);
const label = partial
? `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`
: `${name}: ${value}`;
return { key, icon, value, label };
}
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
const distance = (km: number) => formatDistance(km, unit);
const height = (m: number) => formatElevation(m, unit);
return [
sumBadge({ key: 'distance', icon: Ruler, name: t('tours.detail.distance') }, scope.distanceKm, distance, t),
sumBadge({ key: 'ascent', icon: ArrowUp, name: t('tours.detail.ascent') }, scope.ascentM, height, t),
sumBadge({ key: 'descent', icon: ArrowDown, name: t('tours.detail.descent') }, scope.descentM, height, t),
sumBadge(
{ key: 'routed', icon: Clock, name: t('tours.scope.routed') },
scope.routedMinutes,
formatPlannedTourDuration,
t
),
sumBadge(
{ key: 'imported', icon: FileDown, name: t('tours.scope.imported') },
scope.importedMinutes,
formatPlannedTourDuration,
t
),
sumBadge(
{ key: 'planned', icon: CalendarClock, name: t('tours.scope.planned') },
scope.plannedMinutes,
formatPlannedTourDuration,
t
),
].filter((badge): badge is ScopeBadge => badge !== null);
}
/** One tour type: its name and how many tours, then its sums as a line of badges. */
function TypeScope({ scope }: { scope: TourTypeScope }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const Icon = TYPE_ICON[scope.type] ?? Route;
const badges = typeBadges(scope, unit, t);
return (
<section
aria-label={t(`tourTypes.${scope.type}`)}
className="flex flex-col gap-1.5"
data-testid={`tour-scope-${scope.type}`}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">
<Icon size={13} strokeWidth={2} />
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12, 'body')}>
{t(`tourTypes.${scope.type}`)}
</span>
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
{scope.count}
</span>
</div>
{badges.length > 0 ? (
<div className="flex flex-wrap gap-1.5 ps-[22px]">
{badges.map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`tour-scope-${scope.type}-${badge.key}`}
/>
))}
</div>
) : (
<p className="m-0 ps-[22px] text-content-faint" style={fs(11.5, 'body')}>
{t('tours.scope.noMeasures')}
</p>
)}
</section>
);
}
/**
* STA-01: what the trip's tours add up to, above their list, by type: how many,
* how many are on a day, distance, ascent and descent, and the three kinds of
* time apart (the router's, an import's, the planned one with breaks), never
* added up across types. It reads the list as filtered, so a type chosen in the
* filter narrows both. A value no tour has is left out rather than shown as
* zero; a sum some tours lack is marked as at least that much. It starts folded
* to its counts, and opening it is remembered in this browser.
*/
export function TourScopeSummary({ tours }: { tours: readonly TourListItem[] }) {
const { t } = useTranslation();
// Folded until opened: the list is what the rail is for, the totals a look on demand.
const { folded, toggle } = useRailFold('trip-totals', true);
// On a day as the day lists say, not the list's flag, so a tour put on a day counts at once (as its row's badge does).
const assignments = useTripStore((state) => state.assignments);
const scope = useMemo(() => {
const onDays = new Set(Object.values(assignments ?? {}).flatMap((list) => list.map((item) => item.place_id)));
return tourScopeSummary(tours, (tour) => onDays.has(tour.place_id));
}, [assignments, tours]);
if (scope.count === 0) return null;
const toggleLabel = folded ? t('common.expand') : t('common.collapse');
return (
<RailCard
icon={<ChartColumnBig size={14} strokeWidth={2} />}
title={t('tours.scope.title')}
aside={
<span className="flex flex-none items-center gap-0.5">
<InfoDot label={t('tours.scope.title')} description={t('tours.scope.info')} />
<button
type="button"
onClick={toggle}
aria-expanded={!folded}
aria-label={toggleLabel}
data-testid="tour-scope-toggle"
className="grid h-6 w-6 place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<ChevronDown size={14} strokeWidth={2.2} className={`transition-transform ${folded ? '' : 'rotate-180'}`} />
</button>
</span>
}
>
<div className="flex flex-wrap items-center gap-1.5" data-testid="tour-scope-counts">
<CountBadge value={scope.count} label={t('tours.mode.tours')} />
<CountBadge value={scope.onDays} label={t('places.planned')} tone="faint" />
<CountBadge value={scope.count - scope.onDays} label={t('places.unplanned')} tone="faint" />
</div>
{!folded && (
<div className="mt-3 flex flex-col gap-3 border-t border-edge-faint pt-3">
{scope.types.map((typeScope) => (
<TypeScope key={typeScope.type} scope={typeScope} />
))}
</div>
)}
</RailCard>
);
}
@@ -17,8 +17,6 @@ export interface TourWorkspaceRailParts {
trackPaddlePlan?: TrackPaddlePlan;
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
paddleChecklist?: PaddleChecklist;
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
tourPlaceIds?: ReadonlySet<number>;
}
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
@@ -38,7 +36,6 @@ export default function TourWorkspaceRail({
recordedTrack,
trackPaddlePlan,
paddleChecklist,
tourPlaceIds,
} = workspace;
return wanderer.open ? (
<WandererBrowserRail browser={wanderer} />
@@ -51,7 +48,6 @@ export default function TourWorkspaceRail({
recordedTrack={recordedTrack}
trackPaddlePlan={trackPaddlePlan}
paddleChecklist={paddleChecklist}
tourPlaceIds={tourPlaceIds}
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
/>
);
@@ -349,8 +349,7 @@ describe('ToursSidebar', () => {
// A file without heights says so instead of showing zero.
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
fireEvent.click(screen.getByTestId('tour-type-select'))
fireEvent.click(screen.getByTestId('tour-type-option-kayak'))
fireEvent.click(screen.getByRole('button', { name: 'Paddle tour' }))
const confirm = screen.getByTestId('gpx-preview-import')
expect(confirm).toBeDisabled()
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
+9 -2
View File
@@ -9,7 +9,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
import { Tooltip } from '../shared/Tooltip'
import EmptyState from '../shared/EmptyState'
import { TourDayMenu } from './tourParts'
import TourListFilterMenu, { TourFilterEmpty } from './TourListFilterMenu'
import TourListFilterMenu from './TourListFilterMenu'
import { useTourListFilters } from './useTourListFilters'
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
import { useTourGpxImport } from './useTourGpxImport'
@@ -97,7 +97,14 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
{!loading && filters.emptyByFilter ? (
<TourFilterEmpty filters={filters} />
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
<button type="button" onClick={filters.reset}
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
style={fs(11.5, 'body')}>
{t('tours.filter.reset')}
</button>
</div>
) : !loading && filters.filtered.length === 0 ? (
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
@@ -1,152 +0,0 @@
import type { TourTrailGrade } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { BikeWaysCard } from './BikeWaysCard';
import type { RouteWays } from './routeWays';
import type { RouteWaysState } from './useRouteWays';
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
vi.mock('./useRouteWays', () => ({
useRouteWays: (input: unknown) => {
hook.input = input;
return { ...(hook.state as object), retry: hook.retry };
},
}));
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
];
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
],
};
function planner(overrides: Partial<TourPlannerController> = {}) {
return {
tripId: 4,
mode: { type: 'new-draft' },
isBike: true,
bikeProfile: 'touring',
route: LINE,
routeCurrent: true,
...overrides,
} as unknown as TourPlannerController;
}
const grade = (id: number, mtb: string | null, uphill: string | null = null): TourTrailGrade => ({
id,
mtb_scale: mtb,
mtb_scale_uphill: uphill,
mtb_scale_imba: null,
});
function show(state: Partial<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
return render(<BikeWaysCard planner={planner(overrides)} />);
}
beforeEach(() => {
localStorage.clear();
useTourRouteHighlightStore.getState().clear();
});
afterEach(() => vi.clearAllMocks());
describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
show({});
const card = screen.getByTestId('bike-ways-card');
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
expect(card).not.toHaveTextContent('Trail grade');
});
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
show({});
const row = screen.getByTestId('bike-ways-row-surface-gravel');
fireEvent.click(row);
expect(row).toHaveAttribute('aria-pressed', 'true');
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
id: 'surface-gravel',
lines: [LINE.slice(1, 3)],
paint: 'gravel',
});
fireEvent.click(row);
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
it('FE-TOUR-BIKECARD-003: a road bike tour names what is unpaved and shows it on the map', () => {
show({}, { bikeProfile: 'road' } as Partial<TourPlannerController>);
fireEvent.click(screen.getByRole('button', { name: 'Show on the map' }));
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('unpaved');
expect(useTourRouteHighlightStore.getState().highlight?.lines).toEqual([LINE.slice(1)]);
});
it('FE-TOUR-BIKECARD-004: a mountain bike tour shows its trail grades, uphill apart, the unknown said', () => {
show({
grades: {
status: 'ready',
byWay: new Map([
[1, grade(1, '0')],
[2, grade(2, '2', '3')],
]),
unasked: new Set([3]),
},
});
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
});
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
expect(hook.retry).toHaveBeenCalled();
});
it('FE-TOUR-BIKECARD-006: says when it is still asking, offline, or failed with a way to ask again', () => {
const { unmount } = show({ status: 'loading', ways: null });
expect(screen.getByTestId('bike-ways-card')).toHaveTextContent(/./);
unmount();
const offline = show({ status: 'offline', ways: null });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
offline.unmount();
show({ status: 'failed', ways: null });
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(hook.retry).toHaveBeenCalledTimes(1);
});
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
unmount();
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
});
it('FE-TOUR-BIKECARD-008: only for a bike tour being edited, and only the route as planned now', () => {
const { unmount } = show({ status: 'idle', ways: null });
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
unmount();
const hike = show({}, { isBike: false });
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
hike.unmount();
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
});
});
@@ -1,341 +0,0 @@
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
import { useMemo, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { formatDistance } from '../../../utils/units';
import { SoftPill } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { CardAction, RailCard, TourNotice } from '../tourParts';
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
import {
sharesBy,
stretchesOf,
trailClassOf,
UNPAVED,
type ClassShare,
type RouteWayEdge,
type RouteWays,
type SurfaceClass,
type TrailClass,
type WayClass,
} from './routeWays';
import { useRouteWays, type TrailGrades } from './useRouteWays';
const percent = (share: number) => `${Math.round(share * 100)} %`;
interface ShareRowProps<C extends string> {
id: string;
row: ClassShare<C>;
label: string;
paint: BikePaintKey;
/** A dot in the class's colour; a kind of way has none. */
dot: boolean;
ways: RouteWays;
inClass: (edge: RouteWayEdge) => boolean;
}
/**
* One class of the route: its share and length, and a switch that points at
* its stretches on the map. Pressed again, or another row pressed, it lets go.
*/
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
const toggle = () =>
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
return (
<li>
<button
type="button"
aria-pressed={shown}
data-testid={`bike-ways-row-${id}`}
onClick={toggle}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
>
<span
aria-hidden="true"
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
style={dot ? { background: bikePaintCss(paint) } : undefined}
/>
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
{label}
</span>
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
{formatDistance(row.lengthM / 1000, unit)}
</span>
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
{percent(row.share)}
</span>
</button>
</li>
);
}
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
return (
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
{rows.map((row) => (
<span
key={row.key}
className="h-full"
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
/>
))}
</span>
);
}
function Section({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
{label}
</span>
{children}
</div>
);
}
function SurfaceSection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
return (
<Section label={t('tours.bike.ways.surface')}>
<ShareBar rows={rows} paintOf={surfacePaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`surface-${row.key}`}
row={row}
label={t(`tours.bike.ways.surface.${row.key}`)}
paint={surfacePaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => edge.surface === row.key}
/>
))}
</ul>
</Section>
);
}
function WaySection({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
return (
<Section label={t('tours.bike.ways.way')}>
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`way-${row.key}`}
row={row}
label={t(`tours.bike.ways.way.${row.key}`)}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => edge.way === row.key}
/>
))}
</ul>
</Section>
);
}
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
const { t } = useTranslation();
const classOf = useMemo(
() =>
(edge: RouteWayEdge): TrailClass =>
edge.wayId !== null && grades.unasked.has(edge.wayId)
? 'unasked'
: trailClassOf(edge.wayId === null ? undefined : grades.byWay.get(edge.wayId)),
[grades]
);
const uphillOf = useMemo(
() =>
(edge: RouteWayEdge): string => {
const step = edge.wayId === null ? null : grades.byWay.get(edge.wayId)?.mtb_scale_uphill;
return step ? `u${step[0]}` : 'none';
},
[grades]
);
const rows = useMemo(() => sharesBy<TrailClass>(ways, classOf), [ways, classOf]);
const uphill = useMemo(() => sharesBy<string>(ways, uphillOf).filter((row) => row.key !== 'none'), [ways, uphillOf]);
// Nothing could be asked about: said once with a way to ask again, not as a row covering the whole tour.
const noneAnswered =
grades.status === 'ready' && grades.byWay.size === 0 && rows.every((row) => row.key === 'unasked');
const unread = noneAnswered ? 'failed' : grades.status;
if (unread !== 'ready')
return (
<Section label={t('tours.bike.trail.title')}>
<GradesStatus status={unread} onRetry={onRetry} />
</Section>
);
return (
<>
<Section label={t('tours.bike.trail.title')}>
<ShareBar rows={rows} paintOf={trailPaintKey} />
<ul className="m-0 flex list-none flex-col p-0">
{rows.map((row) => (
<ShareRow
key={row.key}
id={`trail-${row.key}`}
row={row}
label={t(`tours.bike.trail.${row.key}`)}
paint={trailPaintKey(row.key)}
dot
ways={ways}
inClass={(edge) => classOf(edge) === row.key}
/>
))}
</ul>
</Section>
{uphill.length > 0 && (
<Section label={t('tours.bike.trail.uphillTitle')}>
<ul className="m-0 flex list-none flex-col p-0">
{uphill.map((row) => (
<ShareRow
key={row.key}
id={`uphill-${row.key}`}
row={row}
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
paint="way"
dot={false}
ways={ways}
inClass={(edge) => uphillOf(edge) === row.key}
/>
))}
</ul>
</Section>
)}
<span className="text-content-faint" style={fs(11, 'body')}>
{t('tours.bike.trail.note')}
</span>
</>
);
}
function GradesStatus({ status, onRetry }: { status: Exclude<TrailGrades['status'], 'ready'>; onRetry: () => void }) {
const { t } = useTranslation();
if (status === 'loading')
return (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.trail.loading')}
</span>
);
return (
<TourNotice
tone="warning"
icon={status === 'offline' ? <WifiOff size={13} strokeWidth={2} /> : <AlertTriangle size={13} strokeWidth={2} />}
>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t(status === 'offline' ? 'tours.bike.ways.offline' : 'tours.bike.trail.failed')}</span>
{status === 'failed' && (
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
{t('common.errorRetry')}
</CardAction>
)}
</span>
</TourNotice>
);
}
/** BIK-02: a road bike tour names what of it is known to be unpaved, and points at it on the map. */
function UnpavedNotice({ ways }: { ways: RouteWays }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const unpaved = ways.edges.filter((edge) => UNPAVED.has(edge.surface)).reduce((sum, edge) => sum + edge.lengthM, 0);
if (unpaved === 0) return null;
const show = () =>
useTourRouteHighlightStore
.getState()
.show({ id: 'unpaved', lines: stretchesOf(ways, (edge) => UNPAVED.has(edge.surface)), paint: 'gravel' });
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.unpavedNotice', { length: formatDistance(unpaved / 1000, unit) })}</span>
<CardAction icon={<Layers size={12} strokeWidth={2} />} onClick={show}>
{t('tours.bike.ways.showOnMap')}
</CardAction>
</span>
</TourNotice>
);
}
/**
* BIK-01 to BIK-05: what the bike tour being planned runs on, its surfaces and
* its kinds of way, and on a mountain bike tour the trail grades. Read off the
* router's description of the route as OpenStreetMap maps it; where the data
* says nothing the card says unknown. Nothing here changes the tour.
*/
export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
const { t } = useTranslation();
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
const route = planner.routeCurrent ? planner.route : null;
const state = useRouteWays({
tripId: planner.tripId,
active: planner.isBike && editing,
route,
profile: planner.bikeProfile,
});
const { ways } = state;
const unpavedShare = useMemo(
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
[ways]
);
if (!planner.isBike || !editing || state.status === 'idle') return null;
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
return (
<RailCard
icon={<Layers size={14} strokeWidth={2} />}
title={t('tours.bike.ways.title')}
foldId="bike-ways"
aside={
unpaved ? (
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
) : undefined
}
>
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
{state.status === 'loading' && (
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
{t('tours.bike.ways.loading')}
</span>
)}
{state.status === 'offline' && (
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
)}
{state.status === 'failed' && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{t('tours.bike.ways.failed')}</span>
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
{t('common.errorRetry')}
</CardAction>
</span>
</TourNotice>
)}
{ways && (
<>
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
<SurfaceSection ways={ways} />
<WaySection ways={ways} />
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
{t('tours.bike.ways.source')}
</span>
</>
)}
</div>
</RailCard>
);
}
@@ -1,46 +0,0 @@
import { useMapPaint } from '../../Map/mapPaint';
import type { SurfaceClass, TrailClass } from './routeWays';
/**
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
* by name. The cards take the CSS variable, which follows the theme on its
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
* colour of its own: it is pointed at in the accent.
*/
const BIKE_PAINT_TOKENS = {
paved: '--bike-surface-paved',
rough: '--bike-surface-rough',
compacted: '--bike-surface-compacted',
gravel: '--bike-surface-gravel',
ground: '--bike-surface-ground',
unknown: '--bike-surface-unknown',
s0: '--bike-trail-s0',
s1: '--bike-trail-s1',
s2: '--bike-trail-s2',
s3: '--bike-trail-s3',
s4: '--bike-trail-s4',
way: '--accent',
casing: '--bike-highlight-casing',
} as const;
export type BikePaintKey = keyof typeof BIKE_PAINT_TOKENS;
/** The paint of a trail class; a way without a grade, or not asked about, in the grey of the unknown. */
export function trailPaintKey(key: TrailClass): BikePaintKey {
return key === 'none' || key === 'unasked' ? 'unknown' : key;
}
/** The paint of a surface class. */
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
return key;
}
/** A colour as a CSS variable, for markup on the page. */
export function bikePaintCss(key: BikePaintKey): string {
return `var(${BIKE_PAINT_TOKENS[key]})`;
}
/** The colours resolved for map paint, again whenever the theme changes. */
export function useBikePaint(): Record<BikePaintKey, string> {
return useMapPaint(BIKE_PAINT_TOKENS);
}
@@ -1,27 +0,0 @@
import type { RouteHighlight } from '../../../store/tourRouteHighlightStore';
import type { BikePaintKey } from './bikePaint';
type Line = [number, number][];
type LineColor = { line: string; casing: string } | undefined;
/**
* The planner map's lines with a highlight laid over them: the route's own
* lines first as they were, the highlighted stretches after, solid, in their
* colour over a rim of the map's own white, so they read over the route.
* Without a highlight the lines stay exactly as they came.
*/
export function withRouteHighlight(
highlight: RouteHighlight | null,
route: Line[] | null,
walking: boolean[] | null,
colors: LineColor[] | null,
paint: Record<BikePaintKey, string>
): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } {
if (!highlight?.lines.length || !route?.length) return { route, walking, colors };
const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing };
return {
route: [...route, ...highlight.lines],
walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null,
colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)],
};
}
@@ -1,155 +0,0 @@
import { describe, expect, it } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
const LINE: [number, number][] = [
[47.0, 11.0],
[47.001, 11.0],
[47.002, 11.0],
[47.003, 11.0],
[47.004, 11.0],
];
function answer(edges: Record<string, unknown>[], line = LINE) {
return { shape: encodePolyline(line, 6), edges };
}
const edge = (from: number, to: number, extra: Record<string, unknown> = {}) => ({
begin_shape_index: from,
end_shape_index: to,
length: 0.1,
surface: 'paved_smooth',
use: 'road',
road_class: 'residential',
way_id: 100 + from,
...extra,
});
describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
it('FE-TOUR-WAYS-001: reads each edge into its place on the line, its surface, its kind of way and its way', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { surface: 'paved', use: 'cycleway' }),
edge(1, 2, { surface: 'gravel', use: 'track', length: 0.25 }),
edge(2, 3, { surface: 'dirt', use: 'path' }),
edge(3, 4, { surface: 'compacted', use: 'road', road_class: 'primary', cycle_lane: 'dedicated', way_id: 0 }),
])
);
expect(ways?.line).toHaveLength(5);
expect(ways?.edges.map(({ surface, way, wayId, lengthM }) => [surface, way, wayId, lengthM])).toEqual([
['paved', 'cyclePath', 100, 100],
['gravel', 'track', 101, 250],
['ground', 'path', 102, 100],
['compacted', 'cycleLane', null, 100],
]);
});
it('FE-TOUR-WAYS-002: names quiet roads, roads, ferries and what it does not know', () => {
const ways = routeWaysFrom(
answer([
edge(0, 1, { use: 'living_street' }),
edge(1, 2, { use: 'road', road_class: 'secondary', surface: 'paved_rough' }),
edge(2, 3, { use: 'ferry', surface: undefined }),
edge(3, 4, { use: 'something_new', surface: 'wood' }),
])
);
expect(ways?.edges.map(({ way, surface }) => [way, surface])).toEqual([
['quiet', 'paved'],
['road', 'rough'],
['ferry', 'unknown'],
['other', 'unknown'],
]);
});
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
const ways = routeWaysFrom(
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
);
expect(ways?.edges).toHaveLength(1);
expect(routeWaysFrom(null)).toBeNull();
expect(routeWaysFrom({ shape: 1, edges: [] })).toBeNull();
expect(routeWaysFrom(answer([]))).toBeNull();
expect(routeWaysFrom(answer([edge(0, 0)], [[47, 11]]))).toBeNull();
});
});
const WAYS: RouteWays = {
line: LINE,
edges: [
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
],
};
describe('what the line runs on (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-004: shares by class, the largest first, a class with no length left out', () => {
expect(sharesBy(WAYS, (e) => e.surface)).toEqual([
{ key: 'paved', lengthM: 300, share: 0.6 },
{ key: 'gravel', lengthM: 200, share: 0.4 },
]);
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
});
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
expect(stretchesOf(WAYS, () => false)).toEqual([]);
});
it('FE-TOUR-WAYS-006: the ways to grade, each once, in riding order, at most as many as asked', () => {
expect(wayIdsOf(WAYS, 10)).toEqual([1, 2]);
expect(wayIdsOf(WAYS, 1)).toEqual([1]);
});
it('FE-TOUR-WAYS-007: a trail grade by its step, S4 and up together, none where there is none', () => {
expect(trailClassOf({ mtb_scale: '0' })).toBe('s0');
expect(trailClassOf({ mtb_scale: '2+' })).toBe('s2');
expect(trailClassOf({ mtb_scale: '5' })).toBe('s4');
expect(trailClassOf({ mtb_scale: null })).toBe('none');
expect(trailClassOf({ mtb_scale: 'x' })).toBe('none');
expect(trailClassOf(undefined)).toBe('none');
});
});
describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-008: a class names its token; an ungraded way is grey', () => {
expect(surfacePaintKey('gravel')).toBe('gravel');
expect(trailPaintKey('s3')).toBe('s3');
expect(trailPaintKey('none')).toBe('unknown');
expect(trailPaintKey('unasked')).toBe('unknown');
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
});
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
const route = [LINE.slice(0, 3), LINE.slice(2)];
const drawn = withRouteHighlight(
{ id: 'surface:gravel', lines: [LINE.slice(1, 3)], paint: 'gravel' },
route,
[false, true],
null,
paint
);
expect(drawn.route).toEqual([...route, LINE.slice(1, 3)]);
expect(drawn.walking).toEqual([false, true, false]);
expect(drawn.colors).toEqual([undefined, undefined, { line: '#a0522d', casing: '#fff' }]);
const way = withRouteHighlight({ id: 'way:track', lines: [LINE], paint: 'track' }, route, null, null, paint);
expect(way.colors?.[2]).toEqual({ line: '#111', casing: '#fff' });
expect(way.walking).toBeNull();
});
it('FE-TOUR-WAYS-010: without a highlight, or a route to lay it on, the lines stay as they came', () => {
const route = [LINE];
const colors = [{ line: '#00f', casing: '#fff' }];
expect(withRouteHighlight(null, route, [false], colors, paint)).toEqual({ route, walking: [false], colors });
expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route);
expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull();
});
});
@@ -1,190 +0,0 @@
import type { TourTrailGrade } from '@trek/shared';
import { decodePolyline } from '../../Map/transitGeometry';
/**
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
* description of the ways along its line (Valhalla's trace attributes): the
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
* Pure, so the classes and their shares are tested apart from the request.
*/
/** Surfaces from smooth to rough; `unknown` where the data names none. */
export type SurfaceClass = 'paved' | 'rough' | 'compacted' | 'gravel' | 'ground' | 'unknown';
/** Kinds of way, the ones a rider plans by. */
export type WayClass = 'cyclePath' | 'cycleLane' | 'quiet' | 'road' | 'track' | 'path' | 'ferry' | 'other';
/**
* mtb:scale steps as a rider reads them: S0 to S3 apart, S4 and up together
* (rare, and all of them expert ground); `none` where OpenStreetMap gives the
* way no grade, `unasked` where it could not be asked this time.
*/
export type TrailClass = 's0' | 's1' | 's2' | 's3' | 's4' | 'none' | 'unasked';
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
export interface RouteWayEdge {
/** Where on the line the stretch starts and ends, as indices into `RouteWays.line`. */
from: number;
to: number;
lengthM: number;
surface: SurfaceClass;
way: WayClass;
/** The OpenStreetMap way, where the router names it. */
wayId: number | null;
}
export interface RouteWays {
/** The line as the router matched it, which the stretches index into. */
line: [number, number][];
edges: RouteWayEdge[];
}
const SURFACE_OF: Record<string, SurfaceClass> = {
paved_smooth: 'paved',
paved: 'paved',
paved_rough: 'rough',
compacted: 'compacted',
gravel: 'gravel',
dirt: 'ground',
path: 'ground',
};
const PATH_USES = new Set([
'path',
'footway',
'pedestrian',
'bridleway',
'mountain_bike',
'steps',
'sidewalk',
'pedestrian_crossing',
'elevator',
]);
const QUIET_ROAD_CLASSES = new Set(['residential', 'service_other', 'unclassified']);
function wayOf(use: string | undefined, roadClass: string | undefined, cycleLane: string | undefined): WayClass {
if (use === 'cycleway' || use === 'cycle_street') return 'cyclePath';
if (use === 'ferry' || use === 'rail_ferry') return 'ferry';
if (use === 'track') return 'track';
if (use && PATH_USES.has(use)) return 'path';
if (
use === 'living_street' ||
use === 'service_road' ||
use === 'driveway' ||
use === 'alley' ||
use === 'parking_aisle'
)
return 'quiet';
if (use === 'road') {
if (cycleLane === 'dedicated' || cycleLane === 'separated') return 'cycleLane';
return roadClass && QUIET_ROAD_CLASSES.has(roadClass) ? 'quiet' : 'road';
}
return 'other';
}
interface TraceEdge {
surface?: unknown;
use?: unknown;
road_class?: unknown;
cycle_lane?: unknown;
length?: unknown;
way_id?: unknown;
begin_shape_index?: unknown;
end_shape_index?: unknown;
}
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
/**
* The router's answer read into stretches, or null when it is no answer of
* that kind. Edge lengths come in kilometres; an edge without its place on the
* line is dropped rather than placed by guess.
*/
export function routeWaysFrom(answer: unknown): RouteWays | null {
const data = answer as { shape?: unknown; edges?: unknown } | null;
if (!data || typeof data.shape !== 'string' || !Array.isArray(data.edges)) return null;
const line = decodePolyline(data.shape, 6);
if (line.length < 2) return null;
const edges: RouteWayEdge[] = [];
for (const raw of data.edges as TraceEdge[]) {
const from = count(raw.begin_shape_index);
const to = count(raw.end_shape_index);
const km = count(raw.length);
if (from === null || to === null || km === null || to < from || to >= line.length) continue;
const surface = SURFACE_OF[text(raw.surface) ?? ''] ?? 'unknown';
const wayId = count(raw.way_id);
edges.push({
from,
to,
lengthM: km * 1000,
surface,
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
wayId: wayId && wayId > 0 ? wayId : null,
});
}
return edges.length ? { line, edges } : null;
}
/** A trail grade's class: the step of mtb:scale, S4 and up together. */
export function trailClassOf(grade: Pick<TourTrailGrade, 'mtb_scale'> | undefined): TrailClass {
const step = grade?.mtb_scale ? Number(grade.mtb_scale[0]) : null;
if (step === null || Number.isNaN(step)) return 'none';
return step >= 4 ? 's4' : (`s${step}` as TrailClass);
}
export interface ClassShare<C extends string> {
key: C;
lengthM: number;
/** Of the whole line, 0 to 1. */
share: number;
}
/** How much of the line falls in each class, the largest first; classes it has none of are left out. */
export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: RouteWayEdge) => C): ClassShare<C>[] {
const total = ways.edges.reduce((sum, edge) => sum + edge.lengthM, 0);
const sums = new Map<C, number>();
for (const edge of ways.edges) sums.set(classOf(edge), (sums.get(classOf(edge)) ?? 0) + edge.lengthM);
return [...sums]
.filter(([, lengthM]) => lengthM > 0)
.map(([key, lengthM]) => ({ key, lengthM, share: total > 0 ? lengthM / total : 0 }))
.sort((a, b) => b.lengthM - a.lengthM);
}
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
const stretches: [number, number][][] = [];
let open: { from: number; to: number } | null = null;
const close = () => {
if (open && open.to > open.from) stretches.push(ways.line.slice(open.from, open.to + 1));
open = null;
};
for (const edge of ways.edges) {
if (!inClass(edge)) {
close();
continue;
}
if (open && edge.from <= open.to) open.to = Math.max(open.to, edge.to);
else {
close();
open = { from: edge.from, to: edge.to };
}
}
close();
return stretches;
}
/** The ways along the line OpenStreetMap could grade, each once, in the order they are ridden. */
export function wayIdsOf(ways: RouteWays, limit: number): number[] {
const ids: number[] = [];
const seen = new Set<number>();
for (const edge of ways.edges) {
if (edge.wayId === null || seen.has(edge.wayId)) continue;
seen.add(edge.wayId);
ids.push(edge.wayId);
if (ids.length === limit) break;
}
return ids;
}
@@ -1,22 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useHighlightedRoute } from './useHighlightedRoute';
const LINE: [number, number][] = [
[47, 11],
[47.01, 11],
];
describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => {
act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' }));
const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
expect(shown.result.current.route).toHaveLength(2);
expect(shown.result.current.colors?.[1]).toMatchObject({ line: expect.any(String), casing: expect.any(String) });
const hidden = renderHook(() => useHighlightedRoute(false, [LINE], null, null));
expect(hidden.result.current.route).toEqual([LINE]);
act(() => useTourRouteHighlightStore.getState().clear());
expect(shown.result.current.route).toEqual([LINE]);
});
});
@@ -1,18 +0,0 @@
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useBikePaint } from './bikePaint';
import { withRouteHighlight } from './routeHighlight';
type Lines = Parameters<typeof withRouteHighlight>[1];
type Walking = Parameters<typeof withRouteHighlight>[2];
type Colors = Parameters<typeof withRouteHighlight>[3];
/**
* BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's
* card points at laid over it, while `shown` (the route being edited is on
* the map); otherwise the lines as they came.
*/
export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) {
const highlight = useTourRouteHighlightStore((state) => state.highlight);
const paint = useBikePaint();
return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint);
}
@@ -1,141 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { encodePolyline } from '../../../../tests/helpers/polyline';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { useRouteWays } from './useRouteWays';
const trace = vi.hoisted(() => ({ valhallaTraceAttributes: vi.fn() }));
const gradesRepo = vi.hoisted(() => ({ grades: vi.fn() }));
const network = vi.hoisted(() => ({ offline: false }));
vi.mock('../../Map/valhallaRoute', () => trace);
vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo }));
vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) }));
// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-008
const ROUTE: [number, number][] = [
[47.0, 11.0],
[47.002, 11.0],
[47.004, 11.0],
];
const ANSWER = {
shape: encodePolyline(ROUTE, 6),
edges: [
{ begin_shape_index: 0, end_shape_index: 1, length: 0.2, surface: 'gravel', use: 'track', way_id: 11 },
{ begin_shape_index: 1, end_shape_index: 2, length: 0.2, surface: 'dirt', use: 'path', way_id: 12 },
],
};
type Input = Parameters<typeof useRouteWays>[0];
const input = (overrides: Partial<Input> = {}): Input => ({
tripId: 3,
active: true,
route: ROUTE,
profile: 'touring',
...overrides,
});
async function settle() {
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
}
beforeEach(() => {
vi.useFakeTimers();
network.offline = false;
trace.valhallaTraceAttributes.mockReset().mockResolvedValue(ANSWER);
gradesRepo.grades.mockReset();
});
afterEach(() => vi.useRealTimers());
describe('useRouteWays (BIK-01 to BIK-05)', () => {
it('FE-TOUR-WAYS-HOOK-001: asks nothing while inactive or without a route of two points', () => {
for (const overrides of [{ active: false }, { route: null }, { route: ROUTE.slice(0, 1) }]) {
const { result } = renderHook(() => useRouteWays(input(overrides)));
expect(result.current.status).toBe('idle');
}
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-002: waits for the route to settle, then reads its ways, without grades for a touring bike', async () => {
const { result } = renderHook(() => useRouteWays(input()));
expect(result.current.status).toBe('loading');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal));
expect(result.current.status).toBe('ready');
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
expect(result.current.grades).toBeNull();
expect(gradesRepo.grades).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => {
gradesRepo.grades.mockResolvedValue({
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
unanswered: [12],
});
const { result } = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(gradesRepo.grades).toHaveBeenCalledWith(3, { way_ids: [11, 12] }, expect.any(AbortSignal));
expect(result.current.grades?.status).toBe('ready');
expect(result.current.grades?.byWay.get(11)?.mtb_scale).toBe('2');
expect([...(result.current.grades?.unasked ?? [])]).toEqual([12]);
});
it('FE-TOUR-WAYS-HOOK-004: grades that cannot be had say why: offline, or failed', async () => {
gradesRepo.grades.mockRejectedValueOnce(new OnlineOnlyError());
const offline = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(offline.result.current.status).toBe('ready');
expect(offline.result.current.grades?.status).toBe('offline');
offline.unmount();
gradesRepo.grades.mockRejectedValueOnce(new Error('overpass'));
const failed = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
await settle();
expect(failed.result.current.grades?.status).toBe('failed');
});
it('FE-TOUR-WAYS-HOOK-005: a route the router cannot describe fails, and retry asks again', async () => {
trace.valhallaTraceAttributes.mockResolvedValueOnce(null);
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('failed');
act(() => result.current.retry());
expect(result.current.status).toBe('loading');
await settle();
expect(trace.valhallaTraceAttributes).toHaveBeenCalledTimes(2);
expect(result.current.status).toBe('ready');
});
it('FE-TOUR-WAYS-HOOK-006: an error on the way fails rather than leaving the card waiting', async () => {
trace.valhallaTraceAttributes.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('failed');
});
it('FE-TOUR-WAYS-HOOK-007: offline it says so and asks nothing', async () => {
network.offline = true;
const { result } = renderHook(() => useRouteWays(input()));
await settle();
expect(result.current.status).toBe('offline');
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
});
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
const { result, rerender, unmount } = renderHook((props: Input) => useRouteWays(props), { initialProps: input() });
await settle();
expect(result.current.status).toBe('ready');
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
rerender(input({ route: [...ROUTE, [47.006, 11.0]] }));
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
expect(result.current.status).toBe('loading');
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
unmount();
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
});
});
@@ -1,111 +0,0 @@
import { TOUR_TRAIL_GRADE_MAX_WAYS, type BikeProfile, type TourTrailGrade } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
import { valhallaTraceAttributes } from '../../Map/valhallaRoute';
import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays';
/** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */
const SETTLE_MS = 600;
type RouteWaysStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'offline';
export interface TrailGrades {
status: 'loading' | 'ready' | 'failed' | 'offline';
byWay: ReadonlyMap<number, TourTrailGrade>;
/** Ways the data could not be asked about: their grade is unknown, not absent. */
unasked: ReadonlySet<number>;
}
export interface RouteWaysState {
status: RouteWaysStatus;
ways: RouteWays | null;
/** BIK-05, a mountain bike tour's only: the trail grades of its ways. */
grades: TrailGrades | null;
}
interface Answer extends RouteWaysState {
attempt: number;
/** The route and the question it answers; another one makes it stale. */
route: [number, number][];
withGrades: boolean;
}
const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null };
const NO_GRADES = { byWay: new Map<number, TourTrailGrade>(), unasked: new Set<number>() };
/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
async function askRouteWays(
tripId: number | string,
route: [number, number][],
withGrades: boolean,
signal: AbortSignal,
onWays: (ways: RouteWays) => void
): Promise<RouteWaysState> {
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal));
if (!ways) return { status: 'failed', ways: null, grades: null };
if (!withGrades) return { status: 'ready', ways, grades: null };
onWays(ways);
const ids = wayIdsOf(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
if (ids.length === 0) return { status: 'ready', ways, grades: { status: 'ready', ...NO_GRADES } };
try {
const answer = await tourTrailGradeRepo.grades(tripId, { way_ids: ids }, signal);
const byWay = new Map(answer.ways.map((grade) => [grade.id, grade]));
return { status: 'ready', ways, grades: { status: 'ready', byWay, unasked: new Set(answer.unanswered) } };
} catch (failure) {
const status = failure instanceof OnlineOnlyError ? 'offline' : 'failed';
return { status: 'ready', ways, grades: { status, ...NO_GRADES } };
}
}
/**
* BIK-01 to BIK-05: what the bike tour being planned runs on, asked of the
* router once its route has settled, and for a mountain bike tour the trail
* grades of its ways. A new route makes the answer on its way stale and takes
* the map's highlight down; nothing of it changes the tour.
*/
export function useRouteWays(input: {
tripId: number | string;
active: boolean;
route: [number, number][] | null;
profile: BikeProfile;
}): RouteWaysState & { retry: () => void } {
const { tripId, active, route, profile } = input;
const { offline } = useNetworkMode();
const [answer, setAnswer] = useState<Answer | null>(null);
const [attempt, setAttempt] = useState(0);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const withGrades = profile === 'mtb';
const asking = active && !offline && !!route && route.length >= 2;
useEffect(() => {
useTourRouteHighlightStore.getState().clear();
if (!asking || !route) return;
const controller = new AbortController();
const settle = (state: RouteWaysState) => {
if (!controller.signal.aborted) setAnswer({ ...state, route, withGrades, attempt });
};
const timer = window.setTimeout(() => {
askRouteWays(tripId, route, withGrades, controller.signal, (ways) =>
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
)
.then(settle)
// Whatever went wrong, the card says it failed and offers to ask again rather than wait for ever.
.catch(() => settle({ status: 'failed', ways: null, grades: null }));
}, SETTLE_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [asking, attempt, route, tripId, withGrades]);
// The highlight belongs to the route it was drawn on.
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
if (!active || !route || route.length < 2) return { ...IDLE, retry };
if (offline) return { status: 'offline', ways: null, grades: null, retry };
const current = answer?.route === route && answer.withGrades === withGrades && answer.attempt === attempt;
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
}
@@ -1,8 +1,7 @@
import type { TourMaxHikingDifficulty } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { freeLegBetween } from '../planner/tourRouteActions';
import { isTourRoute } from '../planner/tourRouteOutcome';
import { routeBikeTour, routeWalkingTour } from '../planner/tourRouting';
import { routeWalkingTour } from '../planner/tourRouting';
import { paddleFieldsOf } from '../planner/usePaddleFields';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
@@ -42,8 +41,6 @@ export function useGpxConversion(planner: TourPlannerController) {
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
// A paddle import is planned as a paddle tour is: every leg drawn by hand, no router asked.
const paddle = tour?.tour_type === 'kayak';
// BIK-01: a bike import is planned for its profile.
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
useEffect(() => () => abort.current?.abort(), []);
@@ -66,11 +63,9 @@ export function useGpxConversion(planner: TourPlannerController) {
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
}))
: points;
const routed = bikeProfile
? await routeBikeTour(legs, bikeProfile, controller.signal)
: await routeWalkingTour(legs, controller.signal, difficulty);
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
if (controller.signal.aborted) return;
if (!isTourRoute(routed)) {
if (!routed) {
setState({ ...base, status: 'failed', error: 'routing' });
return;
}
@@ -81,7 +76,7 @@ export function useGpxConversion(planner: TourPlannerController) {
line: routed.coordinates,
comparison: compareRoutes(trackPoints, suggestion),
});
}, [bikeProfile, difficulty, paddle, tour, track]);
}, [difficulty, paddle, tour, track]);
const cancel = useCallback(() => {
abort.current?.abort();
@@ -104,14 +99,12 @@ export function useGpxConversion(planner: TourPlannerController) {
points: current.points,
sourcePlaceId: tour.place_id,
inPlace: how === 'replace',
revision: tour.revision,
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
});
if (started) setState(IDLE);
return started;
},
[bikeProfile, current.points, current.status, difficulty, paddle, startDerivedTour, tour]
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
);
return useMemo(
@@ -1,344 +0,0 @@
import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
import { useId, useMemo } from 'react';
import { OFM_POSITRON } from '../../../constants/mapDefaults';
import { useTileUrl } from '../../../hooks/useTileUrl';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance, formatElevation } from '../../../utils/units';
import { MapViewAuto } from '../../Map/MapViewAuto';
import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
import {
DialogButton,
DialogFooter,
DialogHeader,
DialogShell,
DialogTile,
FooterSpacer,
fs,
NEUTRAL_TINT,
} from '../../shared/DialogShell';
import { TourDayRowPill } from '../TourDayRowFacts';
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation';
import { type TourLibrary } from './useTourLibrary';
type Translate = (key: string, params?: Record<string, unknown>) => string;
type Unit = Parameters<typeof formatDistance>[1];
const ALL = 'all';
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
return [
minutes != null && {
key: 'time',
icon: Clock,
value: formatPlannedTourDuration(minutes),
label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`,
},
tour.distance != null && {
key: 'distance',
icon: Ruler,
value: formatDistance(tour.distance, unit),
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
},
tour.elevation_gain != null &&
tour.tour_type !== 'kayak' && {
key: 'ascent',
icon: ArrowUp,
value: formatElevation(tour.elevation_gain, unit),
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
},
].filter((badge) => !!badge);
}
/** One tour of the list: its name, the trip it is in, its measures. */
function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
return (
<li>
<button
type="button"
onClick={onChoose}
aria-pressed={selected}
data-testid={`tour-library-row-${tour.place_id}`}
className={`flex w-full min-w-0 flex-col gap-1 rounded-[10px] px-2.5 py-2 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
>
<span className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
{tour.name}
</span>
<span className="flex-none text-content-faint" style={fs(11, 'body')}>
{t(`tourTypes.${tour.tour_type}`)}
</span>
</span>
<span className="min-w-0 truncate text-content-muted" style={fs(11, 'body')}>
{tour.trip.title}
</span>
<span className="flex flex-wrap gap-1.5">
{measureBadges(tour, unit, t).map((badge) => (
<TourDayRowPill
key={badge.key}
icon={badge.icon}
value={badge.value}
label={badge.label}
testId={`tour-library-${badge.key}`}
/>
))}
</span>
</button>
</li>
);
}
/** A line of the list's state: loading, offline, failed with a retry, empty, or cut short. */
function ListState({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const { listStatus, list } = library;
if (listStatus === 'offline')
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.library.offline')}</TourNotice>;
if (listStatus === 'error')
return (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
<span className="flex flex-wrap items-center gap-2">
{t('tours.library.loadFailed')}
<button type="button" onClick={library.retry} className="font-semibold underline underline-offset-2">
{t('tours.planner.retry')}
</button>
</span>
</TourNotice>
);
if (listStatus === 'loading' && !list)
return (
<p role="status" className="m-0 flex items-center gap-2 py-4 text-content-faint" style={fs(12, 'body')}>
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
{t('common.loading')}
</p>
);
if (list && list.tours.length === 0)
return (
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
{list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')}
</p>
);
if (list?.truncated) return <TourNotice>{t('tours.library.truncated')}</TourNotice>;
return null;
}
/** The search box and the two filters over the list. */
function ListFilters({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const searchId = useId();
const trips = library.list?.trips ?? [];
const tripOptions = [
{ value: ALL, label: t('tours.library.allTrips') },
...trips.map((trip) => ({ value: String(trip.id), label: trip.title, hint: String(trip.tour_count) })),
];
const typeOptions = [
{ value: ALL, label: t('tours.library.allTypes') },
...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
];
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-2">
<Search size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
<label htmlFor={searchId} className="sr-only">
{t('tours.library.search')}
</label>
<input
id={searchId}
autoFocus
value={library.filters.query}
onChange={(event) => library.filter({ query: event.target.value })}
placeholder={t('tours.library.search')}
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
style={fs(12.5, 'body')}
/>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<PillSelect
quiet
label={t('tours.library.trip')}
value={library.filters.tripFilter === null ? ALL : String(library.filters.tripFilter)}
options={tripOptions}
onChange={(value) => library.filter({ tripFilter: value === ALL ? null : Number(value) })}
/>
<PillSelect
quiet
label={t('tours.filter.type')}
value={library.filters.typeFilter ?? ALL}
options={typeOptions}
onChange={(value) => library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })}
/>
</div>
</div>
);
}
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
const minutes = tourPlannedTimes(tour).plannedTotalMinutes;
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
return [
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)),
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
formatElevation(tour.elevation_gain ?? 0, unit)
),
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
formatElevation(tour.elevation_loss ?? 0, unit)
),
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
].filter((field): field is TourMetricField => field !== null);
}
/** The chosen tour: on a map, its facts, and the name the copy gets. */
function Preview({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
const tileUrl = useTileUrl(OFM_POSITRON);
const nameId = useId();
const line = library.preview?.line;
const route = useMemo(
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
() =>
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
[line]
);
const { selected } = library;
if (!selected)
return (
<div className="grid h-full min-h-[240px] place-items-center rounded-[14px] border border-dashed border-edge px-6 text-center">
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12.5, 'body')}>
{t('tours.library.chooseHint')}
</p>
</div>
);
return (
<div className="flex min-w-0 flex-col gap-3" data-testid="tour-library-preview">
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
{route.length > 0 && (
<MapViewAuto
places={[]}
route={route}
tileUrl={tileUrl}
focusPoints={route[0]}
focusKey={String(selected.place_id)}
followSelection={false}
/>
)}
{library.previewStatus === 'loading' && (
<span className="absolute inset-0 grid place-items-center text-content-faint">
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
</span>
)}
{(library.previewStatus === 'error' || library.previewStatus === 'offline') && (
<span
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
style={fs(12, 'body')}
>
{t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')}
</span>
)}
</div>
<RailFacts facts={previewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t)} />
<EditorField label={t('tours.library.nameHere')} htmlFor={nameId} hint={t('tours.library.independent')}>
<input
id={nameId}
className={INPUT}
value={library.name}
maxLength={255}
onChange={(event) => library.setName(event.target.value)}
/>
</EditorField>
</div>
);
}
/**
* LIB-02: the dialog to take a tour of another trip over into this one. The
* list on the left, the chosen tour on the right; the copy is made only by the
* button in its foot.
*/
/** LIB-02: the dialog that takes a tour of another trip into this one, opened from "Add tour". */
export function TourLibraryDialog({ library }: { library: TourLibrary }) {
const { t } = useTranslation();
const labelId = useId();
if (!library.open) return null;
return (
<DialogShell
onClose={library.close}
labelledBy={labelId}
width="wide"
align="top"
blocked={library.taking}
header={
<DialogHeader
tile={
<DialogTile>
<Library size={20} strokeWidth={2} className="text-content" />
</DialogTile>
}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={library.close}
eyebrow={t('tours.library.eyebrow')}
title={t('tours.library.title')}
sub={t('tours.library.sub')}
subWraps
/>
}
footer={
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={library.close} disabled={library.taking}>
{t('common.cancel')}
</DialogButton>
<DialogButton
variant="primary"
data-testid="tour-library-confirm"
icon={
library.taking ? (
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
) : (
<CopyPlus size={14} strokeWidth={2} />
)
}
disabled={!library.canConfirm}
onClick={() => void library.confirm()}
>
{t('tours.library.confirm')}
</DialogButton>
</DialogFooter>
}
>
<div className="grid min-h-0 grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<section aria-label={t('tours.library.title')} className="flex min-w-0 flex-col gap-3">
<ListFilters library={library} />
<ListState library={library} />
{library.list && library.list.tours.length > 0 && (
<ul
className={`m-0 flex max-h-[420px] list-none flex-col gap-0.5 overflow-y-auto py-0 pe-2 ps-0 transition-opacity [scrollbar-gutter:stable] ${library.listStatus === 'loading' ? 'opacity-60' : ''}`}
data-testid="tour-library-list"
>
{library.list.tours.map((tour) => (
<LibraryRow
key={tour.place_id}
tour={tour}
selected={library.selected?.place_id === tour.place_id}
onChoose={() => library.choose(tour)}
/>
))}
</ul>
)}
</section>
<Preview library={library} />
</div>
</DialogShell>
);
}
@@ -1,198 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourCreateResponse, TourLibraryItem, TourLibraryResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { TourLibraryDialog } from './TourLibraryDialog';
import { useTourLibrary } from './useTourLibrary';
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-008
vi.mock('../../Map/MapViewAuto', () => ({
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
<div data-testid="library-map" data-points={route[0]?.length ?? 0} />
),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
function item(over: Partial<TourLibraryItem> & Pick<TourLibraryItem, 'place_id' | 'name'>): TourLibraryItem {
return {
description: null,
tour_type: 'hike',
distance: 8.2,
elevation_gain: 600,
elevation_loss: 590,
duration: 180,
planned_duration_minutes: null,
break_additional_minutes: null,
max_hiking_difficulty: 2,
has_waypoints: true,
origin: 'trek',
trip: { id: 2, title: 'Alps' },
...over,
} as TourLibraryItem;
}
const RIDGE = item({ place_id: 11, name: 'Ridge' });
const LAKE = item({
place_id: 12,
name: 'Lake loop',
tour_type: 'kayak',
distance: null,
elevation_gain: 40,
trip: { id: 3, title: 'Lakes' },
});
const LIST: TourLibraryResponse = {
tours: [RIDGE, LAKE],
trips: [
{ id: 3, title: 'Lakes', tour_count: 1 },
{ id: 2, title: 'Alps', tour_count: 1 },
],
truncated: false,
};
let list: ReturnType<typeof vi.spyOn>;
let detail: ReturnType<typeof vi.spyOn>;
let takeOver: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
list = vi.spyOn(tourLibraryRepo, 'list').mockResolvedValue(LIST);
detail = vi.spyOn(tourLibraryRepo, 'detail').mockResolvedValue({
tour: RIDGE,
line: [
[47, 11],
[47.1, 11.1],
],
waypoint_count: 4,
});
takeOver = vi.spyOn(tourLibraryRepo, 'takeOver').mockResolvedValue({
tour: { place_id: 99, name: 'My ridge' },
waypoints: [],
} as unknown as TourCreateResponse);
});
afterEach(() => vi.restoreAllMocks());
describe('useTourLibrary', () => {
it('FE-TOUR-LIBRARY-001: asks nothing until opened, then lists the other trips’ tours, the asking trip left out', async () => {
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
expect(list).not.toHaveBeenCalled();
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
expect(list).toHaveBeenCalledWith({ exclude_trip_id: 5 }, expect.any(AbortSignal));
expect(result.current.list?.tours).toHaveLength(2);
});
it('FE-TOUR-LIBRARY-002: narrows by name after a pause, and by trip and type at once', async () => {
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
act(() => result.current.filter({ query: ' rid ', tripFilter: 2, typeFilter: 'hike' }));
expect(result.current.listStatus).toBe('loading');
await waitFor(() =>
expect(list).toHaveBeenLastCalledWith(
{ q: 'rid', trip_id: 2, type: 'hike', exclude_trip_id: 5 },
expect.any(AbortSignal)
)
);
});
it('FE-TOUR-LIBRARY-003: says when it is offline or failed, and asks again on retry', async () => {
list.mockRejectedValueOnce(new OnlineOnlyError()).mockRejectedValueOnce(new Error('500'));
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
act(() => result.current.show());
await waitFor(() => expect(result.current.listStatus).toBe('offline'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.listStatus).toBe('error'));
act(() => result.current.retry());
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
});
it('FE-TOUR-LIBRARY-004: previews the tour chosen and takes it over under the name given, then hands it in', async () => {
const onTakenOver = vi.fn();
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
act(() => result.current.show());
act(() => result.current.choose(RIDGE));
expect(result.current.name).toBe('Ridge');
await waitFor(() => expect(result.current.previewStatus).toBe('ready'));
expect(detail).toHaveBeenCalledWith(11, expect.any(AbortSignal));
act(() => result.current.setName(' My ridge '));
await act(() => result.current.confirm());
expect(takeOver).toHaveBeenCalledWith(5, { source_trip_id: 2, source_place_id: 11, name: 'My ridge' });
expect(onTakenOver).toHaveBeenCalledWith(expect.objectContaining({ place_id: 99 }));
expect(result.current.open).toBe(false);
expect(toast.success).toHaveBeenCalled();
});
it('FE-TOUR-LIBRARY-005: nothing is taken over without a name, and a failed take-over keeps the dialog', async () => {
takeOver.mockRejectedValueOnce(new Error('500'));
const onTakenOver = vi.fn();
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
act(() => result.current.show());
act(() => result.current.choose(RIDGE));
act(() => result.current.setName(' '));
expect(result.current.canConfirm).toBe(false);
await act(() => result.current.confirm());
expect(takeOver).not.toHaveBeenCalled();
act(() => result.current.setName('Ridge'));
await act(() => result.current.confirm());
expect(onTakenOver).not.toHaveBeenCalled();
expect(result.current.open).toBe(true);
expect(toast.error).toHaveBeenCalled();
});
});
describe('TourLibraryDialog', () => {
function Harness({ onTakenOver = vi.fn() }: { onTakenOver?: () => void }) {
const library = useTourLibrary({ tripId: 5, onTakenOver });
// The way in is the rail's Add tour menu (TourAddMenu); here a plain button stands in for it.
return (
<>
<button type="button" data-testid="tour-rail-library" onClick={library.show}>
open
</button>
<TourLibraryDialog library={library} />
</>
);
}
it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => {
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
const ridge = await screen.findByTestId('tour-library-row-11');
expect(ridge).toHaveTextContent('Ridge');
expect(ridge).toHaveTextContent('Alps');
expect(within(ridge).getByTestId('tour-library-distance')).toHaveTextContent('8.2 km');
const lake = screen.getByTestId('tour-library-row-12');
// No distance known, and a paddle tour's climb says nothing: neither is shown.
expect(within(lake).queryByTestId('tour-library-distance')).toBeNull();
expect(within(lake).queryByTestId('tour-library-ascent')).toBeNull();
expect(screen.getByTestId('tour-library-confirm')).toBeDisabled();
});
it('FE-TOUR-LIBRARY-007: choosing a tour shows it on a map with its facts and the name field; the button takes it over', async () => {
const onTakenOver = vi.fn();
render(<Harness onTakenOver={onTakenOver} />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
fireEvent.click(await screen.findByTestId('tour-library-row-11'));
const preview = screen.getByTestId('tour-library-preview');
expect(await within(preview).findByTestId('library-map')).toHaveAttribute('data-points', '2');
expect(preview).toHaveTextContent('Alps');
expect(preview).toHaveTextContent('4');
expect(within(preview).getByDisplayValue('Ridge')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('tour-library-confirm'));
await waitFor(() => expect(onTakenOver).toHaveBeenCalled());
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
});
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
render(<Harness />);
fireEvent.click(screen.getByTestId('tour-rail-library'));
expect(await screen.findByText('Your other trips have no tours yet.')).toBeInTheDocument();
});
});
@@ -1,181 +0,0 @@
import type {
TourLibraryDetailResponse,
TourLibraryItem,
TourLibraryResponse,
TourListItem,
TourTypeKey,
} from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { OnlineOnlyError } from '../../../repo/onlineOnly';
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
import { useToast } from '../../shared/Toast';
/** How long typing rests before the list is asked again. */
const SEARCH_PAUSE_MS = 250;
type LoadStatus = 'loading' | 'ready' | 'error' | 'offline';
/** An answer with the request it belongs to, so a newer request reads as loading, never as an older answer. */
interface Answer<T> {
key: string;
status: Exclude<LoadStatus, 'loading'>;
data?: T;
}
const failure = (err: unknown): 'error' | 'offline' => (err instanceof OnlineOnlyError ? 'offline' : 'error');
interface ListFilters {
query: string;
tripFilter: number | null;
typeFilter: TourTypeKey | null;
}
/** The list while the dialog is open: asked at once on opening, then after a pause in typing; the asking trip left out. */
function useLibraryList(
open: boolean,
tripId: number,
{ query, tripFilter, typeFilter }: ListFilters,
attempt: number
) {
const [answer, setAnswer] = useState<Answer<TourLibraryResponse> | null>(null);
const askedRef = useRef(false);
const q = query.trim();
const key = JSON.stringify([q, tripFilter, typeFilter, attempt]);
useEffect(() => {
if (!open) {
askedRef.current = false;
return undefined;
}
const controller = new AbortController();
const ask = () =>
tourLibraryRepo
.list(
{
q: q || undefined,
trip_id: tripFilter ?? undefined,
type: typeFilter ?? undefined,
exclude_trip_id: tripId,
},
controller.signal
)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
const timer = window.setTimeout(() => void ask(), askedRef.current ? SEARCH_PAUSE_MS : 0);
askedRef.current = true;
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [open, key, q, tripFilter, typeFilter, tripId]);
return {
status: (answer?.key === key ? answer.status : 'loading') as LoadStatus,
/** The last list that came in, kept while a newer one loads so the rows do not flicker away. */
data: answer?.data ?? null,
};
}
/** The preview of the tour chosen, read anew for each choice. */
function useLibraryPreview(open: boolean, selected: TourLibraryItem | null, attempt: number) {
const [answer, setAnswer] = useState<Answer<TourLibraryDetailResponse> | null>(null);
const placeId = selected?.place_id ?? null;
const key = `${placeId ?? ''}:${attempt}`;
useEffect(() => {
if (!open || placeId === null) return undefined;
const controller = new AbortController();
tourLibraryRepo
.detail(placeId, controller.signal)
.then((data) => setAnswer({ key, status: 'ready', data }))
.catch((err: unknown) => {
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
});
return () => controller.abort();
}, [open, placeId, key]);
const status: LoadStatus | null = placeId === null ? null : answer?.key === key ? answer.status : 'loading';
return { status, data: status === 'ready' ? (answer?.data ?? null) : null };
}
/**
* LIB-02: taking a tour of another trip over into this one. The dialog lists
* the tours of the user's other trips, searched by name and filtered by trip
* and type, previews the one chosen, and takes it over only on an explicit
* confirm, under the name in the field. Nothing is written before that, and
* closing writes nothing. The new tour then comes into the trip through
* `onTakenOver`, which also opens it.
*/
export function useTourLibrary({
tripId,
onTakenOver,
}: {
tripId: number;
onTakenOver: (tour: TourListItem) => void | Promise<void>;
}) {
const { t } = useTranslation();
const toast = useToast();
const [open, setOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>({ query: '', tripFilter: null, typeFilter: null });
const [selected, setSelected] = useState<TourLibraryItem | null>(null);
const [name, setName] = useState('');
const [taking, setTaking] = useState(false);
const [attempt, setAttempt] = useState(0);
const list = useLibraryList(open, tripId, filters, attempt);
const preview = useLibraryPreview(open, selected, attempt);
const reset = () => {
setOpen(false);
setSelected(null);
setFilters({ query: '', tripFilter: null, typeFilter: null });
};
const close = () => {
if (!taking) reset();
};
const choose = (tour: TourLibraryItem) => {
setSelected(tour);
setName(tour.name);
};
const filter = (change: Partial<ListFilters>) => setFilters((current) => ({ ...current, ...change }));
const confirm = async () => {
const chosenName = name.trim();
if (!selected || !chosenName || taking) return;
setTaking(true);
try {
const result = await tourLibraryRepo.takeOver(tripId, {
source_trip_id: selected.trip.id,
source_place_id: selected.place_id,
name: chosenName,
});
setTaking(false);
reset();
await onTakenOver(result.tour);
toast.success(t('tours.library.taken', { name: result.tour.name }));
} catch (err: unknown) {
setTaking(false);
toast.error(t(failure(err) === 'offline' ? 'tours.library.offline' : 'tours.library.failed'));
}
};
return {
open,
show: useCallback(() => setOpen(true), []),
close,
filters,
filter,
listStatus: list.status,
list: list.data,
selected,
choose,
previewStatus: preview.status,
preview: preview.data,
name,
setName,
taking,
canConfirm: !!selected && name.trim().length > 0 && !taking,
confirm,
retry: () => setAttempt((value) => value + 1),
};
}
export type TourLibrary = ReturnType<typeof useTourLibrary>;
@@ -367,25 +367,21 @@ export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
const notice = noticeOf(checklist);
const { summary, check } = checklist;
return (
<>
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
foldId="paddle-check"
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
</RailCard>
{/* Outside the card's body: an obstacle picked on the map opens its entry with the card folded too. */}
<RailCard
icon={<ListChecks size={14} strokeWidth={2} />}
title={t('tours.paddle.check.title')}
aside={summary ? <PaddleCheckPill check={summary} /> : null}
>
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
{notice === 'new' && <PaddleObstacleNotice />}
{summary && <PaddleCheckDataLine check={summary} />}
{check && <CheckList checklist={checklist} check={check} />}
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.check.notClearance')}
</p>
</div>
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
</>
</RailCard>
);
}
@@ -1,36 +0,0 @@
import type { PaddleLakeStyle } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
/**
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
* shore is for wind, when a capsize near the bank is a short swim. The wind
* card suggests it on a windy day; here it is chosen.
*/
export function PaddleLakeStyleField({
value,
onChange,
}: {
value: PaddleLakeStyle;
onChange: (style: PaddleLakeStyle) => void;
}) {
const { t } = useTranslation();
return (
<div data-testid="tour-planner-lake-style" className="min-w-0">
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
<Segmented<PaddleLakeStyle>
value={value}
onChange={onChange}
label={t('tours.paddle.lakeStyle')}
fill
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
/>
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.paddle.lakeStyle.${value}Hint`)}
</span>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More