mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 04:04:07 +00:00
Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 067ab89430 | |||
| 019c668744 | |||
| b953ece4b3 | |||
| 94a9cbf070 | |||
| 8eebcea9f5 | |||
| 5e00282309 | |||
| 3b675eed60 | |||
| 51bbd337c9 | |||
| 4203dcd91f | |||
| 1e5314d847 | |||
| b98e530ffc | |||
| deb125bedc | |||
| c508bb9758 | |||
| a55d4e786f | |||
| 5a270abc05 | |||
| f50a1dabe5 | |||
| 1058849388 | |||
| 299beb65b1 | |||
| 23b7f6d71c | |||
| f85b9dc3ff | |||
| 3c38a87271 | |||
| 12e22c258a | |||
| cd5ce4b1d9 | |||
| 6d245ab4da | |||
| 3ca73d432b | |||
| e6c8fee3e5 | |||
| 2dbaf6b029 | |||
| c14b572038 | |||
| c0e951fc8e | |||
| c71ecc8f76 | |||
| a1c7ea209b | |||
| fe24dd03c5 | |||
| 66bff5c342 | |||
| 55b11f792a | |||
| e8fbba236d | |||
| 97455da6ca | |||
| 2a51a1a842 | |||
| 724857fc73 | |||
| 28a577df5e | |||
| 64f370fd0e | |||
| 00ac889139 | |||
| 9ab2fc30ff | |||
| 3238fbc517 | |||
| c03c40d287 | |||
| b06e3e6266 | |||
| 7d669cb5c3 | |||
| 81b3657528 | |||
| 6f4d7d9066 | |||
| 00684f4202 | |||
| c4a39171f2 | |||
| 10e0e59c3f | |||
| e3a8c6fb8a | |||
| 7dc313c567 | |||
| e3f542c8b7 | |||
| ba6428d7f1 | |||
| 29dc967d29 | |||
| 8fd025e003 | |||
| ec8df01f85 | |||
| 79260ad737 | |||
| 0426ce7f01 | |||
| f76b8d5d9c | |||
| 8c11833fb1 | |||
| 45727b3d3b | |||
| 686592437a | |||
| 1da7d0627a | |||
| bc62eb0d6f | |||
| 091ebed8e4 | |||
| 80183bc986 | |||
| 250a7d4eb7 | |||
| 256f8c08e2 | |||
| 4ab3244f8f | |||
| cb546e4a72 | |||
| eeae904867 | |||
| 414a7ad98f | |||
| 58e006cfd9 | |||
| 476dbef7dc | |||
| b53751344b | |||
| 01c0f8b64c | |||
| 44add57109 | |||
| afacf00c9f | |||
| baf69da27f | |||
| 01ae91c939 | |||
| f19f0a33b2 | |||
| e5fc735e13 | |||
| d2ec9eabde | |||
| 9128039c38 | |||
| dd6f41f768 | |||
| 1a73b22810 | |||
| c1d413f31a | |||
| 8316119293 | |||
| 74abaa9d7c | |||
| 091505d17c | |||
| aee275e2b0 | |||
| ef86bf2660 | |||
| 0139c46fb4 | |||
| a0a7fcdbb9 | |||
| 186dd94b61 | |||
| ad721a1ac6 | |||
| 87441f269d | |||
| a0943f8226 | |||
| 6741e8ca0d | |||
| c14f179e39 | |||
| 3bfef73a46 | |||
| 7e790c3fb7 | |||
| 228db8541c | |||
| a729a6935f | |||
| 6c0e39e287 | |||
| 22574a2270 |
@@ -5,6 +5,13 @@ name: Build & Push Docker Image (Prerelease)
|
||||
# :latest-pre, and tags the commit. Nothing moves :latest. Like the stable
|
||||
# release it refuses a commit whose ci-ok is not green. docker.yml with
|
||||
# bump=auto later finalizes the line once main carries it.
|
||||
#
|
||||
# A prerelease asks the TREK services issuer for a registration dialect of its
|
||||
# own -pre.N version (registration-dialect.yml, as the stable release does)
|
||||
# and builds it into the image; nothing of it is committed, dev keeps the
|
||||
# module of the last stable release, which it gets when main is merged back
|
||||
# into dev. Without one the image carries dev's module, with a warning when
|
||||
# that is the original one or old.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -89,10 +96,23 @@ jobs:
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION"
|
||||
|
||||
# The registration dialect of this prerelease, fetched and checked in a job
|
||||
# without write access. It never fails the build.
|
||||
dialect:
|
||||
needs: version-bump
|
||||
uses: ./.github/workflows/registration-dialect.yml
|
||||
with:
|
||||
version: ${{ needs.version-bump.outputs.version }}
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
secrets:
|
||||
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
||||
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
needs: [version-bump, dialect]
|
||||
# Whatever became of the dialect job: without its artifact the image carries dev's module.
|
||||
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -111,6 +131,48 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.version-bump.outputs.sha }}
|
||||
|
||||
- name: Take over the prerelease's dialect
|
||||
id: take
|
||||
uses: actions/download-artifact@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: registration-dialect
|
||||
path: ${{ runner.temp }}/dialect
|
||||
|
||||
# Into the build context only; the commit stays as it is. Without a
|
||||
# dialect of its own the image speaks dev's module, which the issuer
|
||||
# takes for new installations for 180 days after it was made (longer
|
||||
# while it is the newest stable release's). That is warned about, never
|
||||
# stopped on, and a date that cannot be read only warns as well.
|
||||
- name: Place the dialect
|
||||
env:
|
||||
VERSION: ${{ needs.version-bump.outputs.version }}
|
||||
TAKEN: ${{ steps.take.outcome }}
|
||||
REPORT: ${{ matrix.platform == 'linux/amd64' }}
|
||||
run: |
|
||||
set -u
|
||||
target=server/src/nest/service-identity/registration-dialect.ts
|
||||
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
|
||||
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
|
||||
cp "$fetched" "$target"
|
||||
echo "$VERSION registers in dialect $(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target") of its own."
|
||||
exit 0
|
||||
fi
|
||||
[ "$REPORT" = true ] || exit 0
|
||||
id="$(sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$target")"
|
||||
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
|
||||
if [ "$id" = 0 ]; then
|
||||
message="$VERSION got no dialect of its own and registers in the original one, which the issuer accepts only for a transition period."
|
||||
elif made="$(date -u -d "$created" +%s 2>/dev/null)"; then
|
||||
age=$(( ($(date -u +%s) - made) / 86400 ))
|
||||
message="$VERSION got no dialect of its own and registers in dev's dialect $id from $created, $age days old."
|
||||
[ "$age" -gt 150 ] || { echo "$message"; exit 0; }
|
||||
else
|
||||
message="$VERSION got no dialect of its own, and the date of dev's dialect cannot be read from $target."
|
||||
fi
|
||||
echo "::warning::$message"
|
||||
printf '### Registration dialect\n%s\n' "$message" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -156,6 +218,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: [version-bump, build]
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
+145
-21
@@ -2,17 +2,29 @@ name: Build & Push Docker Image
|
||||
|
||||
# The stable release. Dispatched by hand on main, it:
|
||||
# 1. refuses to run unless ci-ok passed on the main commit it releases,
|
||||
# 2. works out the version from the tags and the bump input, commits the
|
||||
# bump to package.json, the lockfile and Chart.yaml, tags it and pushes,
|
||||
# 3. builds both platforms from exactly that bump commit (never from
|
||||
# 2. works out the version from the tags and the bump input,
|
||||
# 3. asks the TREK services issuer for the registration dialect of that
|
||||
# version and checks it, in a job without write access
|
||||
# (registration-dialect.yml); without one the release ships the dialect
|
||||
# already on main, but only while that one still has 90 days to run,
|
||||
# 4. commits the bump to package.json, the lockfile, Chart.yaml and the
|
||||
# dialect, tags it and pushes,
|
||||
# 5. builds both platforms from exactly that bump commit (never from
|
||||
# whatever main points to by then) and publishes :VERSION,
|
||||
# :MAJOR.MINOR, :MAJOR and :latest,
|
||||
# 4. publishes the Helm chart from the same commit.
|
||||
# 6. publishes the Helm chart from the same commit.
|
||||
#
|
||||
# Big or risky changes go out as a prerelease first (docker-dev.yml, tagged
|
||||
# :latest-pre and :<next>-pre.N); bump=auto here then finalizes that line.
|
||||
# Without a prerelease in flight there is no default: the run stops until
|
||||
# someone picks patch, minor or major.
|
||||
#
|
||||
# Each stable release registers new installations in a dialect of its own,
|
||||
# which the issuer accepts for 180 days, and for as long as it is the newest
|
||||
# release's; renewing a key needs none. dev gets the module when main is merged
|
||||
# back into dev after the release; a prerelease asks the issuer for one of its
|
||||
# own (docker-dev.yml). The issuer's side, and what to set up before the first
|
||||
# release with a dialect, is in the operations guide of the TREK services.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -26,6 +38,10 @@ on:
|
||||
description: "Type MAJOR (all caps) to confirm a major release"
|
||||
type: string
|
||||
default: ''
|
||||
allow_stale_dialect:
|
||||
description: 'Ship without a dialect of its own even if the one on main is the original one or has under 90 days left'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -36,13 +52,16 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
version:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
sha: ${{ steps.bump.outputs.SHA }}
|
||||
base: ${{ steps.base.outputs.SHA }}
|
||||
steps:
|
||||
- name: Release from main only
|
||||
if: github.ref != 'refs/heads/main'
|
||||
@@ -50,17 +69,17 @@ jobs:
|
||||
echo "::error::Dispatch this workflow on main, not on $GITHUB_REF."
|
||||
exit 1
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
|
||||
# The bump job checks out exactly this commit, so what ci-ok was checked
|
||||
# on is what gets released, however long the dialect takes.
|
||||
- name: Note the commit being released
|
||||
id: base
|
||||
run: echo "SHA=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The dev push, the main push and any manual run each leave a ci-ok on
|
||||
# this commit. scripts/ci/ci-ok-verdict.mjs counts the newest attempt of
|
||||
@@ -77,14 +96,13 @@ jobs:
|
||||
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?check_name=ci-ok&filter=all&per_page=100" > "$runs"
|
||||
node scripts/ci/ci-ok-verdict.mjs "$runs" "$sha"
|
||||
|
||||
- name: Determine bump type and update version
|
||||
- name: Determine bump type and version
|
||||
id: bump
|
||||
env:
|
||||
BUMP_INPUT: ${{ inputs.bump || 'auto' }}
|
||||
CONFIRM_MAJOR: ${{ inputs.confirm_major }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --tags
|
||||
|
||||
# Derive version from git tags, no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
@@ -143,6 +161,107 @@ jobs:
|
||||
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "$STABLE to $NEW_VERSION ($BUMP)"
|
||||
|
||||
# The registration dialect of this version, fetched and checked in a job
|
||||
# without write access. It never fails; without an artifact from it the
|
||||
# bump decides below whether the dialect on main may ship.
|
||||
dialect:
|
||||
needs: version
|
||||
uses: ./.github/workflows/registration-dialect.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
ref: ${{ needs.version.outputs.base }}
|
||||
secrets:
|
||||
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
||||
|
||||
# Runs whatever became of the dialect job, a timeout or a lost runner
|
||||
# included: no artifact means the dialect on main, if it may still ship.
|
||||
version-bump:
|
||||
needs: [version, dialect]
|
||||
if: ${{ !cancelled() && needs.version.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
sha: ${{ steps.commit.outputs.SHA }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.version.outputs.base }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Take over the checked dialect
|
||||
id: take
|
||||
uses: actions/download-artifact@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: registration-dialect
|
||||
path: ${{ runner.temp }}/dialect
|
||||
|
||||
# Without a dialect of its own the release ships the one on main, but
|
||||
# only while the issuer takes new registrations in it for 90 more days:
|
||||
# dialect 0 closes on the issuer's transition date, and a dialect with
|
||||
# little left would shut out new installations of this release soon after
|
||||
# it is out. Then this stops before anything is committed, unless
|
||||
# allow_stale_dialect says to ship anyway. The issuer keeps the newest
|
||||
# release's dialect open past its date, so the date here is the least it
|
||||
# has, not the most.
|
||||
- name: Place the dialect
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
TAKEN: ${{ steps.take.outcome }}
|
||||
ALLOW_STALE: ${{ inputs.allow_stale_dialect }}
|
||||
run: |
|
||||
set -u
|
||||
target=server/src/nest/service-identity/registration-dialect.ts
|
||||
fetched="$RUNNER_TEMP/dialect/registration-dialect.ts"
|
||||
dialect_id() { sed -n "s/^const DIALECT = '\(.*\)';$/\1/p" "$1"; }
|
||||
report() {
|
||||
echo "$2$1"
|
||||
printf '### Registration dialect\n%s\n' "$1" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
if [ "$TAKEN" = success ] && [ -f "$fetched" ]; then
|
||||
cp "$fetched" "$target"
|
||||
report "$VERSION registers in dialect $(dialect_id "$target"), written by the issuer for it." ""
|
||||
exit 0
|
||||
fi
|
||||
id="$(dialect_id "$target")"
|
||||
created="$(sed -n "s/^ createdAt: '\(.*\)',$/\1/p" "$target")"
|
||||
left=""
|
||||
if [ -n "$id" ] && [ "$id" != 0 ] && made="$(date -u -d "$created" +%s 2>/dev/null)"; then
|
||||
left=$(( (made + 180 * 86400 - $(date -u +%s)) / 86400 ))
|
||||
fi
|
||||
if [ -n "$left" ] && [ "$left" -ge 90 ]; then
|
||||
report "$VERSION ships without a dialect of its own and registers in dialect $id from $created, which the issuer takes for new installations for $left more days at least. Its operator can write one by hand for the next patch." "::warning::"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$id" = 0 ]; then
|
||||
what="the original registration, which the issuer accepts only until the end of its transition period"
|
||||
elif [ -n "$left" ]; then
|
||||
what="dialect $id from $created, which has $left days left"
|
||||
else
|
||||
what="a dialect whose id or date cannot be read from $target"
|
||||
fi
|
||||
if [ "$ALLOW_STALE" = true ]; then
|
||||
report "$VERSION ships without a dialect of its own and registers in $what, as allow_stale_dialect asked." "::warning::"
|
||||
exit 0
|
||||
fi
|
||||
report "$VERSION got no dialect of its own and would register in $what. Nothing was committed or pushed. Make sure the issuer runs with its dialect seed and release token and that TREK_DIALECT_TOKEN is set, then run the release again, or tick allow_stale_dialect to ship anyway." "::error::"
|
||||
exit 1
|
||||
|
||||
- name: Update the version, commit, tag and push
|
||||
id: commit
|
||||
env:
|
||||
NEW_VERSION: ${{ needs.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
@@ -153,7 +272,8 @@ jobs:
|
||||
# landed on main in the meantime.
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json \
|
||||
charts/trek/Chart.yaml server/src/nest/service-identity/registration-dialect.ts
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin HEAD:main --follow-tags
|
||||
@@ -164,7 +284,9 @@ jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
needs: [version, version-bump]
|
||||
# version-bump may run after a failed dialect job; that alone must not skip what follows it.
|
||||
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -205,7 +327,7 @@ jobs:
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
APP_VERSION=${{ needs.version.outputs.version }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ steps.meta.outputs.revision }}
|
||||
org.opencontainers.image.created=${{ steps.meta.outputs.created }}
|
||||
@@ -227,7 +349,8 @@ jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: [version-bump, build]
|
||||
needs: [version, version-bump, build]
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -252,7 +375,7 @@ jobs:
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
VERSION="${{ needs.version.outputs.version }}"
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
# :MAJOR.MINOR takes the patches of one minor line and nothing else,
|
||||
# the conservative pin for an install that wants to wait out a
|
||||
@@ -292,6 +415,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
needs: version-bump
|
||||
if: ${{ !cancelled() && needs.version-bump.result == 'success' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Registration dialect
|
||||
|
||||
# Asks the TREK services issuer for the registration dialect of one version and
|
||||
# checks it, for docker.yml (a stable release commits it with the version bump)
|
||||
# and docker-dev.yml (a prerelease only builds it into its image).
|
||||
#
|
||||
# The issuer's answer runs here, in a job that holds no write token and keeps
|
||||
# no git credentials; the token for the issuer is in the environment of the
|
||||
# one step that fetches. The answer is held to what a dialect is
|
||||
# (scripts/ci/check-registration-dialect.mjs), then type-checked and linted
|
||||
# the way TREK's CI would see it once it is on main and dev. Only a module that
|
||||
# passes all of it goes on, as the artifact `registration-dialect`; without
|
||||
# one the caller decides what ships. This workflow never fails its caller.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version the dialect is for, X.Y.Z or X.Y.Z-pre.N'
|
||||
type: string
|
||||
required: true
|
||||
ref:
|
||||
description: 'The commit whose checker, types and lint config the dialect is checked with'
|
||||
type: string
|
||||
required: true
|
||||
secrets:
|
||||
TREK_DIALECT_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dialect:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
# A failed or timed-out run leaves no artifact, which the caller already
|
||||
# handles; it must not fail the release around it.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# The only step that sees the token. The URL is fixed; the answer is
|
||||
# bounded in time and size and goes to a file nothing runs yet.
|
||||
- name: Fetch the dialect from the issuer
|
||||
id: fetch
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
TREK_DIALECT_TOKEN: ${{ secrets.TREK_DIALECT_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
out="$RUNNER_TEMP/registration-dialect.ts"
|
||||
if [ -z "${TREK_DIALECT_TOKEN:-}" ]; then
|
||||
echo "::warning::TREK_DIALECT_TOKEN is not set; the issuer was not asked for a dialect for $VERSION."
|
||||
exit 0
|
||||
fi
|
||||
status=$(curl --silent --show-error --proto '=https' --tlsv1.2 --max-time 30 --retry 2 --retry-delay 5 \
|
||||
--max-filesize 262144 -H "Authorization: Bearer $TREK_DIALECT_TOKEN" -H 'Content-Type: application/json' \
|
||||
--data "{\"version\":\"$VERSION\"}" -o "$out" -w '%{http_code}' https://places.liketrek.com/v1/dialects) \
|
||||
|| status=000
|
||||
if [ "$status" != 200 ] && [ "$status" != 201 ]; then
|
||||
echo "::warning::The issuer handed out no registration dialect for $VERSION (HTTP $status)."
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$(wc -c < "$out")" -gt 262144 ]; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION is larger than a dialect."
|
||||
rm -f "$out"
|
||||
exit 0
|
||||
fi
|
||||
echo "file=$out" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A module that computes for long is refused by the clock as well.
|
||||
- name: Check the dialect
|
||||
id: check
|
||||
if: steps.fetch.outputs.file != ''
|
||||
timeout-minutes: 2
|
||||
env:
|
||||
FILE: ${{ steps.fetch.outputs.file }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if ! node scripts/ci/check-registration-dialect.mjs "$FILE" "$VERSION"; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION failed its check."
|
||||
exit 0
|
||||
fi
|
||||
echo "What changes against the dialect in the tree:"
|
||||
git diff --no-index -- server/src/nest/service-identity/registration-dialect.ts "$FILE" || true
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install the toolchain
|
||||
if: steps.check.outputs.ok == 'true'
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
npm run build --workspace=shared
|
||||
|
||||
# The bump commit skips CI, so this is where the module meets the strict
|
||||
# config (lint:strict lets a new file have no strict errors) and ESLint
|
||||
# with --max-warnings 0 (lint:warnings starts a new file at none) before
|
||||
# it lands on main.
|
||||
- name: Type-check and lint the dialect
|
||||
id: types
|
||||
if: steps.check.outputs.ok == 'true'
|
||||
timeout-minutes: 5
|
||||
working-directory: server
|
||||
env:
|
||||
FILE: ${{ steps.fetch.outputs.file }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
cp "$FILE" src/nest/service-identity/registration-dialect.ts
|
||||
printf '%s\n' '{"extends": "./tsconfig.strict.json", "include": [],' \
|
||||
'"files": ["src/nest/service-identity/registration-dialect.ts"]}' > tsconfig.dialect-check.json
|
||||
if ! npx tsc -p tsconfig.dialect-check.json --noEmit --pretty false; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION does not type-check under the strict config."
|
||||
exit 0
|
||||
fi
|
||||
if ! npx eslint --max-warnings 0 src/nest/service-identity/registration-dialect.ts; then
|
||||
echo "::warning::The issuer's registration dialect for $VERSION does not pass ESLint."
|
||||
exit 0
|
||||
fi
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Hand the dialect on
|
||||
if: steps.types.outputs.ok == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: registration-dialect
|
||||
path: ${{ steps.fetch.outputs.file }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -1202,7 +1202,7 @@ jobs:
|
||||
docker stop -t 30 trek-ci >/dev/null
|
||||
code="$(docker inspect -f '{{.State.ExitCode}}' trek-ci)"
|
||||
[ "$code" = "0" ] || { echo "::error::docker stop ended with exit code $code, not a graceful shutdown"; exit 1; }
|
||||
docker logs trek-ci 2>&1 | grep -q 'SIGTERM received' \
|
||||
docker logs trek-ci 2>&1 | grep 'SIGTERM received' >/dev/null \
|
||||
|| { echo "::error::the server never saw SIGTERM"; exit 1; }
|
||||
|
||||
- name: Container log
|
||||
|
||||
@@ -6,14 +6,9 @@
|
||||
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<br />
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
|
||||
</picture>
|
||||
### Every part of your trip. One app you own.
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
TREK has grown from a travel planner into a whole ecosystem for trips: plan together in real time on a live map, build hikes, tours and road trips, keep bookings and shared costs in order, pack, write the journal, lay out the photo book and hand the busy work to the built-in AI. Self-hosted, on the web, on your phone and offline.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -32,11 +27,16 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-AGPL_v3-6B7280?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=for-the-badge&label=Release&color=6B7280" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=for-the-badge&label=Docker%20pulls&color=6B7280" /></a>
|
||||
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
|
||||
|
||||
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -214,7 +214,39 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## Powered by dataforest cloud
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
|
||||
<img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="60" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
**Every TREK installation in the world searches through servers that dataforest cloud sponsors.**
|
||||
|
||||
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on dataforest cloud in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
|
||||
|
||||
Thank you, dataforest, for backing open source this generously. 💚
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
|
||||
|
||||
Looking for a server for your own TREK? dataforest cloud is where TREK's own services live.
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
@@ -244,6 +276,9 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
||||
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
> [!TIP]
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
@@ -398,16 +433,6 @@ Caddy handles TLS and WebSockets automatically.
|
||||
Every variable, its default and what it does: see
|
||||
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Data sources
|
||||
|
||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 5.0.0
|
||||
version: 5.0.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "5.0.0"
|
||||
appVersion: "5.0.1"
|
||||
|
||||
@@ -112,6 +112,18 @@ data:
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_SERVICES }}
|
||||
TREK_SERVICES: {{ .Values.env.TREK_SERVICES | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_SERVICES_HOSTS }}
|
||||
TREK_SERVICES_HOSTS: {{ .Values.env.TREK_SERVICES_HOSTS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PHOTON_URL }}
|
||||
TREK_PHOTON_URL: {{ .Values.env.TREK_PHOTON_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_VALHALLA_URL }}
|
||||
TREK_VALHALLA_URL: {{ .Values.env.TREK_VALHALLA_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.GOOGLE_LIST_DATA_BASE }}
|
||||
GOOGLE_LIST_DATA_BASE: {{ .Values.env.GOOGLE_LIST_DATA_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -85,6 +85,12 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: PLACES_API_KEY
|
||||
optional: true
|
||||
- name: TREK_SERVICES_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: TREK_SERVICES_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -23,6 +23,9 @@ data:
|
||||
{{- if .Values.secretEnv.PLACES_API_KEY }}
|
||||
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
|
||||
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
@@ -77,6 +80,9 @@ stringData:
|
||||
{{- if .Values.secretEnv.PLACES_API_KEY }}
|
||||
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.TREK_SERVICES_KEY }}
|
||||
TREK_SERVICES_KEY: {{ .Values.secretEnv.TREK_SERVICES_KEY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | quote }}
|
||||
{{- end }}
|
||||
|
||||
+20
-2
@@ -92,7 +92,8 @@ env:
|
||||
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
|
||||
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
|
||||
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
|
||||
# way. Defaults to https://nominatim.openstreetmap.org.
|
||||
# way, and search and reverse lookups no longer go to the TREK services' Photon. Defaults to
|
||||
# https://nominatim.openstreetmap.org.
|
||||
# OVERPASS_URL: ""
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
@@ -112,6 +113,20 @@ env:
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# TREK_SERVICES: "off"
|
||||
# Places search identifies this installation with a key it registers by itself on first use and keeps in the
|
||||
# database. "off" sends no key; searches then use the shared pool. A key issued to you goes under
|
||||
# secretEnv.TREK_SERVICES_KEY. Defaults to auto.
|
||||
# TREK_SERVICES_HOSTS: ""
|
||||
# Comma-separated hosts that receive the installation key over https. Defaults to
|
||||
# places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com.
|
||||
# TREK_PHOTON_URL: ""
|
||||
# The address search (Photon) of the TREK services. With a key, search and reverse lookups ask it before
|
||||
# Nominatim and fall back to Nominatim when it does not answer. Never asked when NOMINATIM_URL is set.
|
||||
# Defaults to https://photon.liketrek.com.
|
||||
# TREK_VALHALLA_URL: ""
|
||||
# The Valhalla of the TREK services, which this server relays routing to unless the admin picks another
|
||||
# routing service. The browser never talks to it. Defaults to https://valhalla.liketrek.com.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
@@ -164,6 +179,9 @@ secretEnv:
|
||||
# with signing enabled, because such a key rejects every unsigned request. Leave empty
|
||||
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
|
||||
AMAP_API_SECRET: ""
|
||||
# Optional key issued for this installation by the TREK services. Wins over the key TREK
|
||||
# registers by itself and turns that registration off. Leave empty to let TREK register.
|
||||
TREK_SERVICES_KEY: ""
|
||||
# Optional Web Push key pair, private half (base64url, 32 bytes). Only used together with
|
||||
# env.VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns
|
||||
# push off with an error in the log until it is fixed, and every device stays registered.
|
||||
@@ -179,7 +197,7 @@ generateEncryptionKey: false
|
||||
# If set, the chart renders no Secret of its own and instead reads every secret env
|
||||
# var from this existing Kubernetes secret. Expected keys: ENCRYPTION_KEY (rename via
|
||||
# existingSecretKey), ADMIN_EMAIL, ADMIN_PASSWORD, OIDC_CLIENT_SECRET,
|
||||
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET,
|
||||
# UNSPLASH_ACCESS_KEY, PLACES_API_KEY, AMAP_API_KEY, AMAP_API_SECRET, TREK_SERVICES_KEY,
|
||||
# VAPID_PRIVATE_KEY, SMTP_PASS, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_ADMIN_PASS.
|
||||
# Missing keys are simply left unset (they are optional), so only provide the ones your
|
||||
# setup needs.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "5.0.0",
|
||||
"version": "5.0.1",
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
},
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 382,
|
||||
"@typescript-eslint/no-misused-promises": 848,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 220,
|
||||
"@typescript-eslint/no-explicit-any": 146,
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
"trek/no-swallowed-catch": 129,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
@@ -30,16 +30,16 @@
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
"suppressed": {
|
||||
"@typescript-eslint/no-explicit-any": 40,
|
||||
"@typescript-eslint/no-explicit-any": 38,
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||
"@typescript-eslint/no-non-null-assertion": 1915,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
"trek/no-window-globals": 331,
|
||||
"trek/no-window-globals": 325,
|
||||
"trek/no-swallowed-catch": 14,
|
||||
"react-hooks/globals": 5,
|
||||
"react-hooks/refs": 22,
|
||||
|
||||
@@ -130,6 +130,15 @@
|
||||
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Booking save follow-up: travellers and files before the editor closes",
|
||||
"hooks": ["src/components/Planner/useBookingFollowUp.ts"],
|
||||
"desktop": ["src/components/Planner/ReservationModal.tsx", "src/components/Planner/TransportModal.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
|
||||
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Bookings and transports lists",
|
||||
"hooks": ["src/components/Planner/bookings/bookingsModel.ts"],
|
||||
|
||||
@@ -7,9 +7,8 @@
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1063,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/PDF/TripPDF.tsx": 1039,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/** /api/admin/service-identity: how this installation stands with the TREK services. Admin only. */
|
||||
export const serviceIdentityApi = {
|
||||
status: (signal?: AbortSignal): Promise<unknown> =>
|
||||
apiClient.get('/admin/service-identity', { signal }).then((r) => r.data),
|
||||
regenerate: (): Promise<unknown> => apiClient.post('/admin/service-identity/regenerate').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ValhallaRelayAction } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* POST /api/routing/valhalla/:action: a question for the TREK services'
|
||||
* Valhalla, which this server asks with the installation key. The answer is
|
||||
* Valhalla's own, its status included, so it is handed back as a Response and
|
||||
* read like the answer of any other Valhalla. Plain fetch on purpose: the
|
||||
* shared client would turn a 401 into a sign-in redirect and a 429 into a
|
||||
* toast, where the planner only wants to ask FOSSGIS instead.
|
||||
*/
|
||||
export function postValhallaRelay(action: ValhallaRelayAction, body: unknown, signal?: AbortSignal): Promise<Response> {
|
||||
return fetch(`/api/routing/valhalla/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, useState } from 'react'
|
||||
import type { ValhallaProvider } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
@@ -6,6 +7,7 @@ import { INPUT, LABEL } from '../shared/dialogParts'
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
routing_valhalla_provider?: ValhallaProvider
|
||||
}
|
||||
|
||||
interface RoutingFieldsProps {
|
||||
@@ -25,6 +27,14 @@ const ROUTERS = [
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
// '' is no choice of the admin: the instance then decides as before the choice existed.
|
||||
const PROVIDERS = [
|
||||
{ value: '', labelKey: 'settings.valhallaProviderOption.auto' },
|
||||
{ value: 'trek', labelKey: 'settings.valhallaProviderOption.trek' },
|
||||
{ value: 'fossgis', labelKey: 'settings.valhallaProviderOption.fossgis' },
|
||||
{ value: 'custom', labelKey: 'settings.valhallaProviderOption.custom' },
|
||||
] as const
|
||||
|
||||
// The phone screen's look, unchanged: MAdminDefaultUserSettings passes its own hint class.
|
||||
const PHONE = {
|
||||
wrap: 'min-w-0 space-y-1.5',
|
||||
@@ -43,6 +53,12 @@ const DESKTOP = {
|
||||
input: INPUT,
|
||||
}
|
||||
|
||||
function FieldHint({ id, text, hintClassName }: { id: string; text: string; hintClassName?: string }) {
|
||||
return hintClassName !== undefined
|
||||
? <p id={id} className={hintClassName}>{text}</p>
|
||||
: <p id={id} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{text}</p>
|
||||
}
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
@@ -81,14 +97,45 @@ function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName }: R
|
||||
aria-describedby={`${id}-hint`}
|
||||
className={look.input}
|
||||
/>
|
||||
{phone
|
||||
? <p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
: <p id={`${id}-hint`} className="m-0 mt-1 leading-normal text-content-faint" style={fs(11)}>{t(router.hint)}</p>}
|
||||
<FieldHint id={`${id}-hint`} text={t(router.hint)} hintClassName={hintClassName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Which Valhalla answers what OSRM cannot; picking "automatic" clears the choice. */
|
||||
function RoutingProviderField({ defaults, onSave, onReset, hintClassName }: RoutingFieldsProps) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const phone = hintClassName !== undefined
|
||||
const look = phone ? PHONE : DESKTOP
|
||||
const pick = async (value: string) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (value === '') await onReset('routing_valhalla_provider')
|
||||
else await onSave({ routing_valhalla_provider: value as ValhallaProvider })
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className={`${look.wrap} ${!phone && saving ? 'opacity-60' : ''}`} aria-busy={saving}>
|
||||
<div className={look.head}>
|
||||
<label htmlFor={id} className={look.label}>{t('settings.valhallaProvider')}</label>
|
||||
</div>
|
||||
<select id={id} value={defaults.routing_valhalla_provider ?? ''} disabled={saving}
|
||||
onChange={e => void pick(e.target.value)} aria-describedby={`${id}-hint`} className={look.input}>
|
||||
{PROVIDERS.map(option => <option key={option.value} value={option.value}>{t(option.labelKey)}</option>)}
|
||||
</select>
|
||||
<FieldHint id={`${id}-hint`} text={t('settings.valhallaProviderHint')} hintClassName={hintClassName} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
const phone = props.hintClassName !== undefined
|
||||
return <div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
return (
|
||||
<div className={phone ? 'flex flex-col gap-3' : 'flex flex-col gap-4'}>
|
||||
<RoutingProviderField {...props} />
|
||||
{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// FE-ADMIN-SVCID-001 to FE-ADMIN-SVCID-010
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ServiceIdentityStatus } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import ServiceIdentityPanel from './ServiceIdentityPanel';
|
||||
|
||||
// The keys themselves, so a test fails on a missing or renamed key rather than on wording.
|
||||
vi.mock('../../i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../i18n')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, locale: 'en-US' }),
|
||||
}));
|
||||
|
||||
const QUOTA = { limit: 10_000, used: 2_500, remaining: 7_500, resetAt: '2026-10-30T00:00:00.000Z' };
|
||||
const CONNECTED: ServiceIdentityStatus = {
|
||||
state: 'connected',
|
||||
tier: 'std',
|
||||
keySource: 'registered',
|
||||
services: [
|
||||
{ service: 'places', inUse: true, quota: QUOTA },
|
||||
{ service: 'photon', inUse: false, quota: null },
|
||||
{ service: 'valhalla', inUse: false, quota: null },
|
||||
],
|
||||
lastSeenAt: '2026-10-29T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function serve(status: ServiceIdentityStatus) {
|
||||
server.use(http.get('/api/admin/service-identity', () => HttpResponse.json(status)));
|
||||
}
|
||||
|
||||
describe('ServiceIdentityPanel', () => {
|
||||
it('FE-ADMIN-SVCID-001: shows the state, the tier and today against the limit', async () => {
|
||||
serve(CONNECTED);
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.state.connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.tier.std')).toBeInTheDocument();
|
||||
const bar = screen.getByRole('progressbar', { name: 'admin.trekApi.quota.service.places' });
|
||||
expect(bar).toHaveAttribute('aria-valuenow', '2500');
|
||||
expect(bar).toHaveAttribute('aria-valuemax', '10000');
|
||||
// What is left is the figure of the tile; used and the limit sit beside it as badges.
|
||||
expect(screen.getByText('7,500')).toBeInTheDocument();
|
||||
expect(screen.getByText('2,500')).toBeInTheDocument();
|
||||
expect(screen.getByText('10,000')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.limit')).toBeInTheDocument();
|
||||
// The day starts again at the same time for every service, so it is said once, in the header.
|
||||
expect(screen.getAllByText('admin.trekApi.quota.resets')).toHaveLength(1);
|
||||
expect(screen.queryByText(/admin\.trekApi\.quota\.hint\./)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-002: an installation without a key says so and offers a new identity', async () => {
|
||||
serve({ state: 'legacy', tier: 'legacy', keySource: null, services: [], lastSeenAt: null });
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.legacy')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-003: switched off, it shows why and offers nothing', async () => {
|
||||
serve({ state: 'off', tier: null, keySource: null, services: [], lastSeenAt: null });
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.off')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-004: a key from the environment is named, and only changed there', async () => {
|
||||
serve({ ...CONNECTED, keySource: 'env', tier: 'own' });
|
||||
render(<ServiceIdentityPanel />);
|
||||
// A badge naming the variable; the sentence explaining it is its tooltip.
|
||||
expect(await screen.findByText('TREK_SERVICES_KEY')).toBeInTheDocument();
|
||||
expect(screen.getByText('admin.trekApi.quota.key')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-005: a service in use without an allowance to show says it cannot be read', async () => {
|
||||
serve({ ...CONNECTED, services: [{ service: 'places', inUse: true, quota: null }] });
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('progressbar')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-009: lists place search, address search and routing, each with its own allowance', async () => {
|
||||
serve({
|
||||
...CONNECTED,
|
||||
services: [
|
||||
{ service: 'places', inUse: true, quota: QUOTA },
|
||||
{ service: 'photon', inUse: true, quota: { ...QUOTA, used: 40, remaining: 9_960 } },
|
||||
{ service: 'valhalla', inUse: true, quota: { ...QUOTA, used: 9_500, remaining: 500 } },
|
||||
],
|
||||
});
|
||||
render(<ServiceIdentityPanel />);
|
||||
const list = await screen.findByRole('list', { name: 'admin.trekApi.quota.title' });
|
||||
expect(list.querySelectorAll('li')).toHaveLength(3);
|
||||
const value = (name: string) => screen.getByRole('progressbar', { name }).getAttribute('aria-valuenow');
|
||||
expect(value('admin.trekApi.quota.service.places')).toBe('2500');
|
||||
expect(value('admin.trekApi.quota.service.photon')).toBe('40');
|
||||
expect(value('admin.trekApi.quota.service.valhalla')).toBe('9500');
|
||||
expect(screen.queryByText('admin.trekApi.quota.notInUse')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-010: a service this instance does not ask is named as not in use, its allowance still shown', async () => {
|
||||
serve({
|
||||
...CONNECTED,
|
||||
services: [
|
||||
{ service: 'places', inUse: true, quota: QUOTA },
|
||||
{ service: 'photon', inUse: false, quota: null },
|
||||
{ service: 'valhalla', inUse: false, quota: QUOTA },
|
||||
],
|
||||
});
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findAllByText('admin.trekApi.quota.notInUse')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('progressbar')).toHaveLength(2);
|
||||
expect(screen.queryByText('admin.trekApi.quota.unavailable')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-006: a failed read says so instead of showing nothing', async () => {
|
||||
server.use(http.get('/api/admin/service-identity', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-007: a revoked key asks before it starts over, then shows the new state', async () => {
|
||||
let regenerated = 0;
|
||||
serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null });
|
||||
server.use(
|
||||
http.post('/api/admin/service-identity/regenerate', () => {
|
||||
regenerated += 1;
|
||||
return HttpResponse.json({
|
||||
state: 'registering',
|
||||
tier: 'legacy',
|
||||
keySource: null,
|
||||
services: [],
|
||||
lastSeenAt: null,
|
||||
});
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<ServiceIdentityPanel />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'admin.trekApi.quota.regenerate' }));
|
||||
expect(screen.getByText('admin.trekApi.quota.regenerateBody')).toBeInTheDocument();
|
||||
expect(regenerated).toBe(0);
|
||||
|
||||
const regenerateButtons = screen.getAllByRole('button', { name: 'admin.trekApi.quota.regenerate' });
|
||||
const confirm = regenerateButtons[regenerateButtons.length - 1];
|
||||
if (!confirm) throw new Error('no confirm button');
|
||||
await user.click(confirm);
|
||||
await waitFor(() => expect(regenerated).toBe(1));
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.registering')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-008: a managed install leaves the identity to its operator', async () => {
|
||||
serve({ state: 'revoked', tier: 'std', keySource: 'registered', services: [], lastSeenAt: null });
|
||||
render(<ServiceIdentityPanel managed />);
|
||||
expect(await screen.findByText('admin.trekApi.quota.hint.revoked')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'admin.trekApi.quota.regenerate' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
|
||||
import { MapPin, RefreshCw, Route, Search, type LucideIcon } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import { getApiErrorMessage } from '../../utils/apiError';
|
||||
import { formatClockTime } from '../../utils/formatters';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { FigureBadge } from '../Tours/paddle/FigureBadge';
|
||||
import { useServiceIdentity } from './useServiceIdentity';
|
||||
|
||||
const STATE_DOT: Record<ServiceIdentityState, string> = {
|
||||
connected: 'bg-success',
|
||||
registering: 'bg-warning motion-safe:animate-pulse',
|
||||
legacy: 'bg-[var(--text-faint)]',
|
||||
off: 'bg-[var(--text-faint)]',
|
||||
revoked: 'bg-danger',
|
||||
};
|
||||
|
||||
const SERVICE_ICON: Record<string, LucideIcon> = { places: MapPin, photon: Search, valhalla: Route };
|
||||
|
||||
/** Below a tenth of the day left the bar turns amber, at nothing left red. */
|
||||
function barTone(quota: ServiceQuotaView): string {
|
||||
if (quota.remaining === 0) return 'bg-danger';
|
||||
return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent';
|
||||
}
|
||||
|
||||
function figureTone(quota: ServiceQuotaView): string {
|
||||
if (quota.remaining === 0) return 'text-danger';
|
||||
return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content';
|
||||
}
|
||||
|
||||
/** The clock time the counts start again, in the user's format; one for all services, which share the day. */
|
||||
function useResetTime(status: ServiceIdentityStatus | null): string | null {
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
|
||||
const at = status?.services.find((s) => s.quota)?.quota?.resetAt;
|
||||
if (!at) return null;
|
||||
const reset = new Date(at);
|
||||
return formatClockTime(
|
||||
`${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`,
|
||||
is12h
|
||||
);
|
||||
}
|
||||
|
||||
function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
|
||||
if (!allowance.inUse) return t('admin.trekApi.quota.notInUse');
|
||||
return allowance.quota ? null : t('admin.trekApi.quota.unavailable');
|
||||
}
|
||||
|
||||
/**
|
||||
* One service under the key, as a tile: what is left today as the figure that
|
||||
* matters, the day's use as a bar, used and limit as badges. A service not in
|
||||
* use is dimmed rather than hidden, so the row reads the same on every install.
|
||||
*/
|
||||
function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const number = new Intl.NumberFormat(locale);
|
||||
const name = t(`admin.trekApi.quota.service.${allowance.service}`);
|
||||
const note = tileNote(t, allowance);
|
||||
const quota = allowance.quota;
|
||||
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
|
||||
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
|
||||
<Icon size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
|
||||
</div>
|
||||
|
||||
{quota && (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
|
||||
{number.format(quota.remaining)}
|
||||
</p>
|
||||
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={name}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={quota.limit}
|
||||
aria-valuenow={quota.used}
|
||||
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
|
||||
style={{ width: `${share}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
|
||||
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, failed: boolean): string | null {
|
||||
if (!status) return failed ? t('admin.trekApi.quota.unavailable') : null;
|
||||
return status.state === 'connected' ? null : t(`admin.trekApi.quota.hint.${status.state}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* How this installation stands with the TREK services: whether it has its key,
|
||||
* and for place search, address search and routing today's allowance against
|
||||
* what is used, and when the count starts again.
|
||||
* Read when the card is in view and refreshed every 30 seconds while it stays.
|
||||
* A managed install leaves the identity to its operator, so it offers no new one.
|
||||
*/
|
||||
export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const resetAt = useResetTime(status);
|
||||
|
||||
const confirmRegenerate = async () => {
|
||||
setConfirming(false);
|
||||
try {
|
||||
await regenerate();
|
||||
toast.success(t('admin.trekApi.quota.regenerated'));
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
};
|
||||
|
||||
const hint = stateHint(t, status, failed);
|
||||
const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off';
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={panel}
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="m-0 inline-flex h-[26px] items-center gap-2 rounded-full bg-surface-card px-2.5 text-caption font-semibold text-content shadow-sm">
|
||||
<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`} />
|
||||
{status ? t(`admin.trekApi.quota.state.${status.state}`) : t('admin.trekApi.quota.title')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
|
||||
{status?.keySource === 'env' && (
|
||||
<FigureBadge
|
||||
label={t('admin.trekApi.quota.key')}
|
||||
value="TREK_SERVICES_KEY"
|
||||
info={t('admin.trekApi.quota.envKey')}
|
||||
/>
|
||||
)}
|
||||
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status && status.services.length > 0 && (
|
||||
<ul
|
||||
aria-label={t('admin.trekApi.quota.title')}
|
||||
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
|
||||
>
|
||||
{status.services.map((allowance) => (
|
||||
<ServiceTile key={allowance.service} allowance={allowance} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
|
||||
|
||||
{canRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={regenerating}
|
||||
className="inline-flex items-center gap-1.5 self-start rounded-lg text-caption font-medium text-content-secondary hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
size={13}
|
||||
strokeWidth={2}
|
||||
className={regenerating ? 'motion-safe:animate-spin' : ''}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('admin.trekApi.quota.regenerate')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirming}
|
||||
onClose={() => setConfirming(false)}
|
||||
onConfirm={() => void confirmRegenerate()}
|
||||
title={t('admin.trekApi.quota.regenerateTitle')}
|
||||
message={t('admin.trekApi.quota.regenerateBody')}
|
||||
confirmLabel={t('admin.trekApi.quota.regenerate')}
|
||||
danger={false}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TREK_API_SOURCES, trekApiFacts, trekApiFields } from './trekApiModel';
|
||||
import {
|
||||
TREK_API_SOURCES,
|
||||
trekApiAddressFeatures,
|
||||
trekApiFacts,
|
||||
trekApiFields,
|
||||
trekApiRoutingFeatures,
|
||||
} from './trekApiModel';
|
||||
|
||||
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-003
|
||||
// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-004
|
||||
|
||||
const t = (key: string) => `<${key}>`;
|
||||
|
||||
@@ -22,10 +28,12 @@ describe('trekApiModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => {
|
||||
it('FE-TREKAPIMODEL-002: leads with the three services, then the terms, each with a glyph', () => {
|
||||
const facts = trekApiFacts(t);
|
||||
expect(facts.map((f) => f.text)).toEqual([
|
||||
'<admin.trekApi.factPlaces>',
|
||||
'<admin.trekApi.factAddresses>',
|
||||
'<admin.trekApi.factRouting>',
|
||||
'<admin.trekApi.factNoKey>',
|
||||
'<admin.trekApi.factOffline>',
|
||||
'<admin.trekApi.factPrivacy>',
|
||||
@@ -34,6 +42,28 @@ describe('trekApiModel', () => {
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-003: names the data sources untranslated', () => {
|
||||
expect(TREK_API_SOURCES).toEqual(['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']);
|
||||
expect(TREK_API_SOURCES).toEqual([
|
||||
'Overture Maps Foundation',
|
||||
'OpenStreetMap',
|
||||
'Wikivoyage',
|
||||
'Wikimedia',
|
||||
'Mapzen Terrain Tiles',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TREKAPIMODEL-004: lists what the address search and the routing answer', () => {
|
||||
expect(trekApiAddressFeatures(t)).toEqual([
|
||||
'<admin.trekApi.addrStreets>',
|
||||
'<admin.trekApi.addrTowns>',
|
||||
'<admin.trekApi.addrReverse>',
|
||||
'<admin.trekApi.addrLanguages>',
|
||||
]);
|
||||
expect(trekApiRoutingFeatures(t)).toEqual([
|
||||
'<admin.trekApi.routeCar>',
|
||||
'<admin.trekApi.routeBike>',
|
||||
'<admin.trekApi.routeFoot>',
|
||||
'<admin.trekApi.routeAvoid>',
|
||||
'<admin.trekApi.routeElevation>',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { Globe2, KeyRound, ShieldOff, WifiOff } from 'lucide-react';
|
||||
import { Globe2, KeyRound, Route, Search, ShieldOff, WifiOff } from 'lucide-react';
|
||||
|
||||
import type { TranslationFn } from '../../types';
|
||||
|
||||
/**
|
||||
* Proper nouns, so they are not translated. Naming them is also a licence obligation,
|
||||
* not decoration: ODbL and CC BY-SA both require attribution wherever their content is
|
||||
* shown.
|
||||
* shown, and the terrain tiles the routes take their heights from ask to be named too.
|
||||
*/
|
||||
export const TREK_API_SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'];
|
||||
export const TREK_API_SOURCES = [
|
||||
'Overture Maps Foundation',
|
||||
'OpenStreetMap',
|
||||
'Wikivoyage',
|
||||
'Wikimedia',
|
||||
'Mapzen Terrain Tiles',
|
||||
];
|
||||
|
||||
/**
|
||||
* The fields a TREK Places API result carries, for the desktop card and the phone block
|
||||
@@ -29,10 +35,33 @@ export function trekApiFields(t: TranslationFn): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** The four facts the TREK Places API block leads with, each with its glyph. */
|
||||
/** What the address search answers: an address typed, a town, the address of a point. */
|
||||
export function trekApiAddressFeatures(t: TranslationFn): string[] {
|
||||
return [
|
||||
t('admin.trekApi.addrStreets'),
|
||||
t('admin.trekApi.addrTowns'),
|
||||
t('admin.trekApi.addrReverse'),
|
||||
t('admin.trekApi.addrLanguages'),
|
||||
];
|
||||
}
|
||||
|
||||
/** What the routing does: the three ways of getting there, what a drive can avoid, the heights. */
|
||||
export function trekApiRoutingFeatures(t: TranslationFn): string[] {
|
||||
return [
|
||||
t('admin.trekApi.routeCar'),
|
||||
t('admin.trekApi.routeBike'),
|
||||
t('admin.trekApi.routeFoot'),
|
||||
t('admin.trekApi.routeAvoid'),
|
||||
t('admin.trekApi.routeElevation'),
|
||||
];
|
||||
}
|
||||
|
||||
/** The facts the TREK API block leads with, each with its glyph: the three services first, then the terms. */
|
||||
export function trekApiFacts(t: TranslationFn) {
|
||||
return [
|
||||
{ Icon: Globe2, text: t('admin.trekApi.factPlaces') },
|
||||
{ Icon: Search, text: t('admin.trekApi.factAddresses') },
|
||||
{ Icon: Route, text: t('admin.trekApi.factRouting') },
|
||||
{ Icon: KeyRound, text: t('admin.trekApi.factNoKey') },
|
||||
{ Icon: WifiOff, text: t('admin.trekApi.factOffline') },
|
||||
{ Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-ADMIN-SVCID-HOOK-001 to FE-ADMIN-SVCID-HOOK-004
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { ServiceIdentityStatus } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useServiceIdentity } from './useServiceIdentity';
|
||||
|
||||
const { status, regenerate, visible } = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
visible: { current: true },
|
||||
}));
|
||||
|
||||
vi.mock('../../repo/serviceIdentityRepo', () => ({ serviceIdentityRepo: { status, regenerate } }));
|
||||
vi.mock('../../hooks/useElementVisible', () => ({ useElementVisible: () => visible.current }));
|
||||
|
||||
const LEGACY: ServiceIdentityStatus = {
|
||||
state: 'legacy',
|
||||
tier: 'legacy',
|
||||
keySource: null,
|
||||
services: [],
|
||||
lastSeenAt: null,
|
||||
};
|
||||
const card = { current: null };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
visible.current = true;
|
||||
status.mockReset().mockResolvedValue(LEGACY);
|
||||
regenerate.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useServiceIdentity', () => {
|
||||
it('FE-ADMIN-SVCID-HOOK-001: reads once in view, then every 30 seconds', async () => {
|
||||
const { result } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {});
|
||||
expect(result.current.status).toEqual(LEGACY);
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(status).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-HOOK-002: reads nothing while the card is out of view, and stops when it leaves', async () => {
|
||||
visible.current = false;
|
||||
const { rerender } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
});
|
||||
expect(status).not.toHaveBeenCalled();
|
||||
|
||||
visible.current = true;
|
||||
rerender();
|
||||
await act(async () => {});
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
|
||||
visible.current = false;
|
||||
rerender();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
});
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-HOOK-003: marks a failed read, and clears the mark on the next good one', async () => {
|
||||
status.mockRejectedValueOnce(new Error('offline'));
|
||||
const { result } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {});
|
||||
expect(result.current.failed).toBe(true);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(result.current.failed).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-SVCID-HOOK-004: a new identity replaces the status, and a refusal reaches the caller', async () => {
|
||||
const registering = { ...LEGACY, state: 'registering' as const };
|
||||
regenerate.mockResolvedValueOnce(registering).mockRejectedValueOnce(new Error('409'));
|
||||
const { result } = renderHook(() => useServiceIdentity(card));
|
||||
await act(async () => {
|
||||
await result.current.regenerate();
|
||||
});
|
||||
expect(result.current.status).toEqual(registering);
|
||||
await act(async () => {
|
||||
await expect(result.current.regenerate()).rejects.toThrow('409');
|
||||
});
|
||||
expect(result.current.regenerating).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ServiceIdentityStatus } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState, type RefObject } from 'react';
|
||||
import { useElementVisible } from '../../hooks/useElementVisible';
|
||||
import { serviceIdentityRepo } from '../../repo/serviceIdentityRepo';
|
||||
|
||||
const REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The installation's standing with the TREK services for the admin card:
|
||||
* read when the card comes into view, then every 30 seconds while it stays
|
||||
* there. Admin configuration, online only like the rest of the settings tab.
|
||||
*/
|
||||
export function useServiceIdentity(card: RefObject<Element | null>) {
|
||||
const visible = useElementVisible(card);
|
||||
const [status, setStatus] = useState<ServiceIdentityStatus | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [regenerating, setRegenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const controller = new AbortController();
|
||||
const refresh = () => {
|
||||
serviceIdentityRepo.status(controller.signal).then(
|
||||
(next) => {
|
||||
setStatus(next);
|
||||
setFailed(false);
|
||||
},
|
||||
() => {
|
||||
if (!controller.signal.aborted) setFailed(true);
|
||||
}
|
||||
);
|
||||
};
|
||||
refresh();
|
||||
const timer = setInterval(refresh, REFRESH_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
const regenerate = useCallback(async (): Promise<void> => {
|
||||
setRegenerating(true);
|
||||
try {
|
||||
setStatus(await serviceIdentityRepo.regenerate());
|
||||
} finally {
|
||||
setRegenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, failed, regenerating, regenerate };
|
||||
}
|
||||
@@ -102,16 +102,20 @@ export default function HelpPanel(): React.ReactElement | null {
|
||||
<HelpNav context={context} currentId={contextId} activeGuideId={!searching && guide ? guide.id : null} />
|
||||
|
||||
<div className="relative flex flex-col min-h-0 bg-surface" role="main">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="absolute top-4 end-4 z-10 w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{/* The close button sits in a row of its own above the scrolling content rather
|
||||
than floating over it, so no card ever runs up against it. */}
|
||||
<div className="flex shrink-0 justify-end px-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeHelp}
|
||||
aria-label={t('help.center.close')}
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-content-muted bg-surface-card border border-edge shadow-card hover:text-content hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={scrollRef} className="trek-help-scroll flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 py-8">
|
||||
<div className="max-w-[1080px] mx-auto px-6 sm:px-10 pt-4 pb-8">
|
||||
{searching ? (
|
||||
<HelpSearchResults key="search" />
|
||||
) : guide ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-007
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-008
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -119,3 +119,28 @@ describe('DatePicker week start (#2029)', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DatePicker inside a clipping dialog (#2858)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-008: the calendar floats over the page, stays inside the viewport and closes on a click away', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<DatePicker value="2026-03-15" onChange={vi.fn()} />
|
||||
</div>,
|
||||
)
|
||||
const trigger = screen.getByText(/Mar 15, 2026/).closest('button') as HTMLButtonElement
|
||||
// A trigger at the right edge, where the end date of a range sits.
|
||||
trigger.getBoundingClientRect = () => ({ top: 100, bottom: 136, left: window.innerWidth - 120, right: window.innerWidth - 8, width: 112, height: 36, x: 0, y: 0, toJSON: () => ({}) })
|
||||
await user.click(trigger)
|
||||
|
||||
const day31 = screen.getByRole('button', { name: '31' })
|
||||
// Rendered outside the dialog that would clip it.
|
||||
expect(container.contains(day31)).toBe(false)
|
||||
const panel = day31.closest('.fixed') as HTMLElement
|
||||
expect(panel.style.top).toBe('140px')
|
||||
expect(parseFloat(panel.style.left)).toBe(window.innerWidth - 280 - 8)
|
||||
|
||||
await user.click(document.querySelector('[role="presentation"]') as HTMLElement)
|
||||
expect(screen.queryByRole('button', { name: '31' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useWeekStartDay } from '../../hooks/useWeekStartDay'
|
||||
import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||
import { useAnchoredPosition } from '../../hooks/useAnchoredPosition'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
@@ -14,6 +16,9 @@ import { leadingBlanks, weekdayLabels } from '../../utils/calendarWeek'
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
/** The calendar's width; `w-[280px]` below. */
|
||||
const PANEL_WIDTH = 280
|
||||
const VIEWPORT_PAD = 8
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
@@ -23,6 +28,13 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
const { t, locale } = useTranslation()
|
||||
const weekStart = useWeekStartDay()
|
||||
const [open, setOpen] = useState(false)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
// The calendar used to hang absolutely under its trigger, inside dialogs that clip
|
||||
// their content: the photo picker cut off the last week and, for the end date in
|
||||
// the right-hand column, the last weekday (#2858). It now floats over the page,
|
||||
// pinned to the trigger, flipped above it when below is too short and kept inside
|
||||
// the viewport sideways.
|
||||
const anchored = useAnchoredPosition(triggerRef, open, { estimatedHeight: 340, matchWidth: false })
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
@@ -107,6 +119,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-start flex items-center justify-between"
|
||||
@@ -122,12 +135,20 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open && anchored && createPortal(
|
||||
<>
|
||||
{/* Click-away catcher — no semantics of its own; the trigger button
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full start-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
<div role="presentation" className="fixed inset-0 z-[var(--z-toast)]" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed z-[var(--z-toast)] overflow-y-auto bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]"
|
||||
style={{
|
||||
top: anchored.top,
|
||||
bottom: anchored.bottom,
|
||||
left: Math.max(VIEWPORT_PAD, Math.min(anchored.left, window.innerWidth - PANEL_WIDTH - VIEWPORT_PAD)),
|
||||
maxHeight: anchored.maxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
@@ -200,7 +221,7 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
<span className="absolute bottom-1 inset-x-0 mx-auto w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@@ -209,7 +230,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -375,11 +375,12 @@ describe('ProviderPicker', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
expect(ranges).toHaveLength(0)
|
||||
|
||||
// The calendar opens in a portal, so its days are looked up on the page, not under the trigger.
|
||||
const [fromPicker, toPicker] = Array.from(baseElement.querySelectorAll('.flex-1 > .relative')) as HTMLElement[]
|
||||
await user.click(within(fromPicker).getByRole('button'))
|
||||
await user.click(within(fromPicker).getByRole('button', { name: '3' }))
|
||||
await user.click(screen.getByRole('button', { name: '3' }))
|
||||
await user.click(within(toPicker).getByRole('button'))
|
||||
await user.click(within(toPicker).getByRole('button', { name: '9' }))
|
||||
await user.click(screen.getByRole('button', { name: '9' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Search' }))
|
||||
|
||||
await waitFor(() => expect(ranges).toHaveLength(1))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-054
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-062
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -12,6 +12,35 @@ vi.mock('../../api/websocket', () => ({
|
||||
|
||||
// Leaflet does not work in jsdom — mock the entire library
|
||||
vi.mock('leaflet', () => {
|
||||
const domEvent = {
|
||||
on: vi.fn((el: HTMLElement, types: string, fn: (e: Event) => void) => {
|
||||
types.split(' ').forEach(type => el.addEventListener(type, fn));
|
||||
}),
|
||||
stopPropagation: vi.fn((e: Event) => e.stopPropagation()),
|
||||
disableClickPropagation: vi.fn((el: HTMLElement) => {
|
||||
for (const type of ['mousedown', 'touchstart', 'dblclick', 'contextmenu']) el.addEventListener(type, e => e.stopPropagation());
|
||||
}),
|
||||
};
|
||||
// The openable card (#2885): one per map, put on and taken off by the map itself.
|
||||
// Its element is built on the first add, inside a stand-in for the map container.
|
||||
const cardState = { onMap: false, element: null as HTMLElement | null, onAdd: [] as (() => void)[] };
|
||||
const mockCard = {
|
||||
state: cardState,
|
||||
once: vi.fn((_type: string, fn: () => void) => { cardState.onAdd.push(fn); }),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
setContent: vi.fn().mockReturnThis(),
|
||||
getElement: vi.fn(() => cardState.element),
|
||||
addTo: vi.fn(() => {
|
||||
if (!cardState.element) {
|
||||
cardState.element = document.createElement('div');
|
||||
cardState.element.innerHTML = '<div class="trek-journey-popup-title">card</div>';
|
||||
document.createElement('div').appendChild(cardState.element);
|
||||
}
|
||||
cardState.onMap = true;
|
||||
cardState.onAdd.splice(0).forEach(fn => fn());
|
||||
}),
|
||||
remove: vi.fn(() => { cardState.onMap = false; }),
|
||||
};
|
||||
const mockMarker = {
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
@@ -38,31 +67,38 @@ vi.mock('leaflet', () => {
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
|
||||
// The photo and mark layers subscribe to zoom/pan and cluster in world
|
||||
// pixels at the current zoom (#1614, #2889).
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
project: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
hasLayer: vi.fn((layer: unknown) => (layer === mockCard ? cardState.onMap : true)),
|
||||
};
|
||||
return {
|
||||
default: {
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })),
|
||||
marker: vi.fn(() => mockMarker),
|
||||
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
|
||||
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line), remove: vi.fn() }; return line }),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
|
||||
// The map builds its own attribution control so it can collapse to the credit
|
||||
// alone (see the GL twin: the phone lays a carousel over the bottom edge).
|
||||
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
|
||||
tooltip: vi.fn(() => mockCard),
|
||||
DomEvent: domEvent,
|
||||
},
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })),
|
||||
marker: vi.fn(() => mockMarker),
|
||||
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
|
||||
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line), remove: vi.fn() }; return line }),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
|
||||
tooltip: vi.fn(() => mockCard),
|
||||
DomEvent: domEvent,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,6 +112,9 @@ import type { Mock } from 'vitest';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
|
||||
import { mercatorWorldPx } from './journeyMapMarks';
|
||||
import type { JourneyMapStay } from '@trek/shared';
|
||||
|
||||
// maplibre-gl-leaflet hangs the vector basemap into Leaflet's tile pane. The mock
|
||||
// only has to be callable and hand back a layer with the three methods the callers
|
||||
@@ -178,6 +217,115 @@ describe('JourneyMap', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('the marker card as a way into the entry (#2885)', () => {
|
||||
interface MockCard {
|
||||
state: { onMap: boolean; element: HTMLElement | null; onAdd: (() => void)[] };
|
||||
addTo: Mock;
|
||||
remove: Mock;
|
||||
setContent: Mock;
|
||||
}
|
||||
// The leaflet mock hands every map the same card, so each case starts it unbuilt and off the map.
|
||||
function mockedCard(): MockCard {
|
||||
const card = vi.mocked(L.tooltip)() as unknown as MockCard;
|
||||
vi.mocked(L.tooltip).mockClear();
|
||||
return card;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(mockedCard().state, { onMap: false, element: null, onAdd: [] });
|
||||
});
|
||||
|
||||
function renderOpenable(onMarkerCardClick = vi.fn(), visibility?: JourneyMapLayerVisibility) {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
const view = render(
|
||||
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} onMarkerCardClick={onMarkerCardClick} layerVisibility={visibility} />,
|
||||
);
|
||||
return { ...view, ref, onMarkerCardClick };
|
||||
}
|
||||
|
||||
function cardElement(): HTMLElement {
|
||||
const { element } = mockedCard().state;
|
||||
if (!element) throw new Error('the card was never built');
|
||||
return element;
|
||||
}
|
||||
|
||||
function cardTitle(): HTMLElement {
|
||||
return cardElement().firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-059: a tap on the card over the active pin opens that entry and never reaches the map', () => {
|
||||
const { ref, onMarkerCardClick } = renderOpenable();
|
||||
act(() => ref.current?.highlightMarker('e2'));
|
||||
|
||||
expect(L.tooltip).toHaveBeenCalledWith(expect.objectContaining({ className: 'map-tooltip trek-journey-tooltip is-openable' }));
|
||||
const container = cardElement().parentElement as HTMLElement;
|
||||
const mapClick = vi.fn();
|
||||
const mapPress = vi.fn();
|
||||
container.addEventListener('click', mapClick);
|
||||
container.addEventListener('mousedown', mapPress);
|
||||
|
||||
cardTitle().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
cardTitle().click();
|
||||
act(() => ref.current?.highlightMarker('e1'));
|
||||
cardTitle().click();
|
||||
|
||||
expect(onMarkerCardClick.mock.calls).toEqual([['e2'], ['e1']]);
|
||||
expect(mapClick).not.toHaveBeenCalled();
|
||||
expect(mapPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-060: the pins do not open or close the card, so leaving the pin for the card keeps it', () => {
|
||||
const { ref } = renderOpenable();
|
||||
act(() => ref.current?.highlightMarker('e1'));
|
||||
|
||||
// A bound tooltip is what Leaflet closes on the pin's mouseout and blur, which
|
||||
// a tap on the card sends first. The pins bind none and listen for the tap alone.
|
||||
expect(mockedMarker().bindTooltip).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(mockedMarker().on).mock.calls.map((call: unknown[]) => call[0])).toEqual(['click', 'click']);
|
||||
expect(mockedCard().state.onMap).toBe(true);
|
||||
expect(mockedCard().remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-061: the card follows the active pin and leaves with it or with the pins', () => {
|
||||
mockedMarkerWithRemove();
|
||||
const card = mockedCard();
|
||||
const shown = { plan: true, recorded: true, entries: true };
|
||||
const onMarkerCardClick = vi.fn();
|
||||
const { ref, rerender } = renderOpenable(onMarkerCardClick, shown);
|
||||
const map = mockedMap();
|
||||
|
||||
act(() => ref.current?.highlightMarker('e1'));
|
||||
expect(card.setContent).toHaveBeenLastCalledWith(expect.stringContaining('Paris'));
|
||||
act(() => ref.current?.highlightMarker('e2'));
|
||||
expect(card.setContent).toHaveBeenLastCalledWith(expect.stringContaining('Berlin'));
|
||||
expect(card.addTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => ref.current?.highlightMarker(null));
|
||||
expect(card.state.onMap).toBe(false);
|
||||
|
||||
act(() => ref.current?.highlightMarker('e1'));
|
||||
map.hasLayer.mockImplementation((layer: unknown) => layer === card && card.state.onMap);
|
||||
try {
|
||||
rerender(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} onMarkerCardClick={onMarkerCardClick} layerVisibility={{ ...shown, entries: false }} />);
|
||||
expect(card.state.onMap).toBe(false);
|
||||
} finally {
|
||||
map.hasLayer.mockImplementation((layer: unknown) => (layer === card ? card.state.onMap : true));
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-062: without a handler the card stays a hover label on each pin that the drags on the map pass through', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
|
||||
act(() => ref.current?.highlightMarker('e1'));
|
||||
|
||||
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ className: 'map-tooltip trek-journey-tooltip' }),
|
||||
);
|
||||
expect(L.tooltip).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(
|
||||
@@ -201,6 +349,15 @@ describe('JourneyMap', () => {
|
||||
expect(firstCall.html).toContain('>1<');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-058: a stop without a number (a draft) gets an unnumbered pin', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={[{ ...entriesWithCoords[0], dayLabel: null }]} />
|
||||
);
|
||||
const html = String(vi.mocked(L.divIcon).mock.calls[0][0]?.html);
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).toMatch(/font-weight="700"><\/text>/);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => {
|
||||
const entriesWithMood = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
|
||||
@@ -782,9 +939,8 @@ describe('JourneyMap', () => {
|
||||
});
|
||||
|
||||
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
|
||||
// effect can draw while the map still has no center or zoom. Clustering
|
||||
// projects to container pixels, which throws in that window and took the whole
|
||||
// public journey page down with it.
|
||||
// effect can draw while the map still has no center or zoom. Projecting in that
|
||||
// window throws and took the whole public journey page down with it.
|
||||
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
|
||||
const noView = () => { throw new Error('Set map center and zoom first.'); };
|
||||
let hasView = false;
|
||||
@@ -794,12 +950,12 @@ describe('JourneyMap', () => {
|
||||
// The mock hands out one shared map instance, so it can be reached — and
|
||||
// stripped of its view — before the component ever builds one. Typed down to
|
||||
// the three spies this needs: L.map's signature promises a real Leaflet map.
|
||||
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
|
||||
type MapProbe = { getCenter: Mock; project: Mock; on: Mock };
|
||||
L.map(document.createElement('div'));
|
||||
const map = mockedMap() as MapProbe;
|
||||
vi.mocked(L.map).mockClear();
|
||||
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
|
||||
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
|
||||
map.project.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
|
||||
|
||||
try {
|
||||
// Rendering must survive the viewless window, and draw nothing in it.
|
||||
@@ -820,7 +976,7 @@ describe('JourneyMap', () => {
|
||||
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
|
||||
} finally {
|
||||
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
|
||||
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
map.project.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -868,6 +1024,108 @@ describe('JourneyMap', () => {
|
||||
expect(divIconHtml(0)).not.toContain('#16a34a');
|
||||
expect(divIconHtml(1)).toContain('#16a34a');
|
||||
});
|
||||
|
||||
// #2889: a pan used to shift the screen grid the layers cluster on, so photos
|
||||
// near a cell edge changed groups, counts and thumbnails without any zoom.
|
||||
it('FE-COMP-JOURNEYMAP-055: panning keeps the photo and mark groups, only a zoom regroups them', () => {
|
||||
const photos = [
|
||||
{ id: 'p1', lat: 45, lng: 5, thumbUrl: '/uploads/a.jpg' },
|
||||
{ id: 'p2', lat: 45, lng: 5.05, thumbUrl: '/uploads/b.jpg' },
|
||||
];
|
||||
const marks = [
|
||||
{ kind: 'stay' as const, key: 's1', lat: 45, lng: 5, color: '#14b8a6', stay: {} as JourneyMapStay },
|
||||
{ kind: 'stay' as const, key: 's2', lat: 45, lng: 5.05, color: '#14b8a6', stay: {} as JourneyMapStay },
|
||||
];
|
||||
L.map(document.createElement('div'));
|
||||
const map = mockedMap() as { project: Mock; latLngToContainerPoint: Mock; getZoom: Mock; on: Mock; off: Mock };
|
||||
vi.mocked(L.map).mockClear();
|
||||
let zoom = 3;
|
||||
let pan = 0;
|
||||
map.getZoom.mockImplementation(() => zoom);
|
||||
map.project.mockImplementation(([lat, lng]: [number, number], z: number) => mercatorWorldPx(lat, lng, z, 256));
|
||||
map.latLngToContainerPoint.mockImplementation(([lat, lng]: [number, number]) => {
|
||||
const world = mercatorWorldPx(lat, lng, zoom, 256);
|
||||
return { x: world.x - pan, y: world.y };
|
||||
});
|
||||
const drawnSince = (from: number) =>
|
||||
vi.mocked(L.divIcon).mock.calls.slice(from).map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/') || h.includes('#14b8a6'));
|
||||
const moveEnd = () => act(() => {
|
||||
const gone = new Set(map.off.mock.calls.map(c => c[1]));
|
||||
for (const [event, handler] of map.on.mock.calls) if (event === 'moveend' && !gone.has(handler)) (handler as () => void)();
|
||||
});
|
||||
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={[]} photos={photos} marks={marks} />);
|
||||
let from = vi.mocked(L.divIcon).mock.calls.length;
|
||||
moveEnd();
|
||||
const first = drawnSince(from);
|
||||
expect(first.filter(h => h.includes('/uploads/'))).toEqual([expect.stringContaining('>2<')]);
|
||||
expect(first.filter(h => h.includes('#14b8a6'))).toHaveLength(1);
|
||||
expect(map.project).toHaveBeenCalledWith([45, 5.05], 3);
|
||||
expect(map.latLngToContainerPoint).not.toHaveBeenCalled();
|
||||
|
||||
// A pan that puts a 64 px cell edge between the two photos on screen.
|
||||
const mid = (mercatorWorldPx(45, 5, 3, 256).x + mercatorWorldPx(45, 5.05, 3, 256).x) / 2;
|
||||
pan = mid - (Math.floor(mid / 64) + 0.5) * 64;
|
||||
from = vi.mocked(L.divIcon).mock.calls.length;
|
||||
moveEnd();
|
||||
expect(drawnSince(from)).toEqual(first);
|
||||
|
||||
zoom = 12;
|
||||
from = vi.mocked(L.divIcon).mock.calls.length;
|
||||
moveEnd();
|
||||
const zoomed = drawnSince(from);
|
||||
expect(zoomed.filter(h => h.includes('/uploads/'))).toHaveLength(2);
|
||||
expect(zoomed.filter(h => h.includes('#14b8a6'))).toHaveLength(2);
|
||||
} finally {
|
||||
map.getZoom.mockImplementation(() => 10);
|
||||
map.project.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
}
|
||||
});
|
||||
|
||||
describe('the line between the entries on recorded days (#2888)', () => {
|
||||
const days = [
|
||||
{ id: 'a', lat: 48.1, lng: 11.5, title: 'A', mood: null, entry_date: '2026-10-29' },
|
||||
{ id: 'b', lat: 48.2, lng: 11.6, title: 'B', mood: null, entry_date: '2026-10-29' },
|
||||
{ id: 'c', lat: 48.3, lng: 11.7, title: 'C', mood: null, entry_date: '2026-10-30' },
|
||||
];
|
||||
const tracks = [
|
||||
{ place_id: -1, trip_id: 0, name: '2026-10-29', color: '#7c3aed', points: [[48.1, 11.5], [48.2, 11.6]] as [number, number][], kind: 'dawarich_live' as const, date: '2026-10-29' },
|
||||
];
|
||||
const connectionLines = () => {
|
||||
const polyline = vi.mocked(L.polyline).mock;
|
||||
return polyline.calls
|
||||
.map((call, i) => ({ coords: call[0], dashed: (call[1] as { dashArray?: string } | undefined)?.dashArray === '4 6', line: polyline.results[i].value as { addTo: Mock; remove: Mock } }))
|
||||
.filter(call => call.dashed);
|
||||
};
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-056: leaves the recorded day to its track and keeps the jump to the next day', () => {
|
||||
render(<JourneyMap checkins={[]} entries={days} tracks={tracks} />);
|
||||
|
||||
const lines = connectionLines();
|
||||
expect(lines.map(l => l.coords)).toEqual([
|
||||
[[48.2, 11.6], [48.3, 11.7]],
|
||||
[[48.1, 11.5], [48.2, 11.6]],
|
||||
]);
|
||||
expect(lines[0].line.addTo).toHaveBeenCalled();
|
||||
expect(lines[1].line.addTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-057: hiding what was recorded puts the covered stretch back, hiding the entries takes it off again', () => {
|
||||
mockedMarkerWithRemove();
|
||||
const shown = { plan: true, recorded: true, entries: true };
|
||||
const { rerender } = render(<JourneyMap checkins={[]} entries={days} tracks={tracks} layerVisibility={shown} />);
|
||||
const covered = connectionLines()[1].line;
|
||||
|
||||
rerender(<JourneyMap checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false }} />);
|
||||
expect(covered.addTo).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<JourneyMap checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false, entries: false }} />);
|
||||
expect(covered.remove).toHaveBeenCalledTimes(1);
|
||||
expect(L.map).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** The shared marker spy with the `remove` a hidden layer is taken off by. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref } from 'react'
|
||||
import { useEffect, useRef, useImperativeHandle, useCallback, type CSSProperties, type Ref, type RefObject } from 'react'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -11,7 +11,8 @@ import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { PLANNED_BADGE_SVG, type JourneyMapMark } from './journeyMapMarks'
|
||||
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||
import { NOTHING_DRAWN, drawLeafletMarks, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
|
||||
import { NOTHING_DRAWN, drawLeafletMarks, isLeafletLayerShown, leafletWorldPx, switchLeafletLayers, type DrawnLayers, type LeafletLayerKind } from './journeyLeafletLayers'
|
||||
import { entryConnections } from './journeyMapConnections'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
id: string
|
||||
@@ -22,7 +23,7 @@ export interface MapMarkerItem {
|
||||
mood?: string | null
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
dayLabel: number | null
|
||||
photoUrls: string[]
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ interface MapEntry {
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
dayLabel?: number | null
|
||||
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
|
||||
photoUrls?: string[]
|
||||
}
|
||||
@@ -65,15 +66,15 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
/**
|
||||
* Leave the marker labels off.
|
||||
* Open the entry a marker card names when the card itself is tapped.
|
||||
*
|
||||
* On the phone the map sits above a carousel whose active card already carries
|
||||
* the entry's name, and a tooltip on touch is a tap-to-open box rather than a
|
||||
* hover hint — so it says the same thing twice and covers the map to do it
|
||||
* (discussion #2299). On desktop the label is the only name a marker has, so
|
||||
* this stays off there.
|
||||
* On a phone the card is what a tap on a pin brings up, so it has to lead
|
||||
* somewhere (#2885). Wiring it is what makes the card take the pointer, and the
|
||||
* card then stands over the active pin, as the GL popup does; without
|
||||
* it the card stays a label the map's drags pass through, as on desktop, where
|
||||
* it is a hover card the mouse cannot reach.
|
||||
*/
|
||||
hideMarkerTooltip?: boolean
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
paddingBottom?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
@@ -116,7 +117,7 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
dayLabel: e.dayLabel === undefined ? 1 : e.dayLabel,
|
||||
photoUrls: e.photoUrls ?? [],
|
||||
})
|
||||
}
|
||||
@@ -128,12 +129,12 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): string {
|
||||
function markerSvg(dayColor: string, dayLabel: number | null, highlighted: boolean, planned = false): string {
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
const shadow = highlighted
|
||||
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const label = String(dayLabel)
|
||||
const label = dayLabel === null ? '' : String(dayLabel)
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
|
||||
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
|
||||
@@ -184,6 +185,91 @@ function panToMarker(map: L.Map, marker: L.Marker): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The same card the GL renderer shows, from the same builder: which map engine a
|
||||
* reader happens to have selected should not change what a marker tells them
|
||||
* (discussion #2299). The builder escapes everything that came from a person,
|
||||
* which matters most here, since this map is what the public journey page renders.
|
||||
*/
|
||||
function entryCardHtml(item: MapMarkerItem): string {
|
||||
return journeyPopupHtml({
|
||||
title: item.label || item.locationName || 'Entry',
|
||||
place: item.label ? item.locationName : '',
|
||||
date: formatMarkerDate(item.time),
|
||||
photoUrls: item.photoUrls,
|
||||
})
|
||||
}
|
||||
|
||||
const ENTRY_CARD_OPTIONS: L.TooltipOptions = { direction: 'top', offset: [0, -MARKER_H] }
|
||||
|
||||
/**
|
||||
* The card that opens its entry when tapped (#2885), one per map like the GL
|
||||
* popup, shown above the active pin.
|
||||
*
|
||||
* It is deliberately not bound to the pins. A bound tooltip is opened and closed
|
||||
* by Leaflet on the pin's hover and focus, and a tap on the card is, to a phone,
|
||||
* the pointer leaving the pin: the pin's mouseout closed the card between the
|
||||
* tap and its click. Driven by the active pin alone, nothing but a change of
|
||||
* entry takes it away.
|
||||
*/
|
||||
function createEntryCard(open: () => void): L.Tooltip {
|
||||
const card = L.tooltip({ ...ENTRY_CARD_OPTIONS, className: 'map-tooltip trek-journey-tooltip is-openable' })
|
||||
// The element is built on the first open and kept for every later one.
|
||||
card.once('add', () => {
|
||||
const el = card.getElement()
|
||||
if (!el) return
|
||||
// A press on the card is not the start of a drag or a tap on the map.
|
||||
L.DomEvent.disableClickPropagation(el)
|
||||
L.DomEvent.on(el, 'click', (ev) => {
|
||||
L.DomEvent.stopPropagation(ev)
|
||||
open()
|
||||
})
|
||||
})
|
||||
return card
|
||||
}
|
||||
|
||||
interface PinRefs {
|
||||
onCardClickRef: RefObject<((entryId: string) => void) | undefined>
|
||||
mapRef: RefObject<L.Map | null>
|
||||
markersRef: RefObject<Map<string, L.Marker>>
|
||||
itemsRef: RefObject<MapMarkerItem[]>
|
||||
highlightedRef: RefObject<string | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* The openable card of a host that wires onMarkerCardClick. The returned sync puts
|
||||
* it over the active pin while that pin is on the map and takes it off otherwise.
|
||||
*/
|
||||
function useEntryCardSync({ onCardClickRef, mapRef, markersRef, itemsRef, highlightedRef }: PinRefs): () => void {
|
||||
const cardRef = useRef<L.Tooltip | null>(null)
|
||||
const cardEntryIdRef = useRef<string | null>(null)
|
||||
|
||||
const sync = useCallback(() => {
|
||||
const map = mapRef.current
|
||||
const id = highlightedRef.current
|
||||
const marker = id ? markersRef.current.get(id) : undefined
|
||||
const item = id ? itemsRef.current.find(i => i.id === id) : undefined
|
||||
if (!onCardClickRef.current || !map || !marker || !item || !map.hasLayer(marker)) {
|
||||
cardRef.current?.remove()
|
||||
cardEntryIdRef.current = null
|
||||
return
|
||||
}
|
||||
ensureJourneyPopupStyle()
|
||||
let card = cardRef.current
|
||||
if (!card) {
|
||||
card = createEntryCard(() => {
|
||||
if (cardEntryIdRef.current) onCardClickRef.current?.(cardEntryIdRef.current)
|
||||
})
|
||||
cardRef.current = card
|
||||
}
|
||||
cardEntryIdRef.current = item.id
|
||||
card.setLatLng(marker.getLatLng()).setContent(entryCardHtml(item))
|
||||
if (!map.hasLayer(card)) card.addTo(map)
|
||||
}, [onCardClickRef, mapRef, markersRef, itemsRef, highlightedRef])
|
||||
|
||||
return sync
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
/** Fallback when a track carries no colour of its own, matching the planner's default. */
|
||||
@@ -200,12 +286,12 @@ function fitOptions(paddingBottom: number | undefined): L.FitBoundsOptions {
|
||||
}
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, onMarkerCardClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
) {
|
||||
// Read through a ref: the flag is fixed per surface, and putting it in the
|
||||
// Read through a ref: the handler is fixed per surface, and putting it in the
|
||||
// marker effect's deps would rebuild every marker for nothing.
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
const onMarkerCardClickRef = useRef(onMarkerCardClick)
|
||||
onMarkerCardClickRef.current = onMarkerCardClick
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
@@ -256,6 +342,8 @@ function JourneyMap(
|
||||
plannedRef.current = plannedEntryIds
|
||||
})
|
||||
|
||||
const syncEntryCard = useEntryCardSync({ onCardClickRef: onMarkerCardClickRef, mapRef, markersRef, itemsRef, highlightedRef })
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
const prev = highlightedRef.current
|
||||
highlightedRef.current = id
|
||||
@@ -264,7 +352,8 @@ function JourneyMap(
|
||||
)
|
||||
if (prev && prev !== id) paint(prev, false)
|
||||
if (id) paint(id, true)
|
||||
}, [])
|
||||
syncEntryCard()
|
||||
}, [syncEntryCard])
|
||||
|
||||
/**
|
||||
* Bring an entry's marker under the reader without changing how far out they are.
|
||||
@@ -347,7 +436,7 @@ function JourneyMap(
|
||||
iconPlannedRef.current = plannedRef.current
|
||||
const place = <T extends L.Layer>(kind: LeafletLayerKind, layer: T): T => {
|
||||
drawn[kind].push(layer)
|
||||
if (visibilityRef.current[kind]) layer.addTo(map)
|
||||
if (isLeafletLayerShown(kind, visibilityRef.current)) layer.addTo(map)
|
||||
return layer
|
||||
}
|
||||
|
||||
@@ -403,17 +492,18 @@ function JourneyMap(
|
||||
coords.forEach(c => trackCoords.push(c))
|
||||
}
|
||||
|
||||
// route trail: dashed line connecting entries in time order, same as JourneyMapGL
|
||||
if (items.length > 1) {
|
||||
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
|
||||
place('entries', L.polyline(routeCoords, {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
weight: 1.5,
|
||||
opacity: 0.5,
|
||||
dashArray: '4 6',
|
||||
lineCap: 'round', lineJoin: 'round',
|
||||
}))
|
||||
// The faint line between the entries in their order, same as JourneyMapGL, left
|
||||
// off the stretches a recorded day already draws as travelled (#2888).
|
||||
const connections = entryConnections(items.map(i => ({ lat: i.lat, lng: i.lng, date: i.time })), stableTracks)
|
||||
const connectionStyle: L.PolylineOptions = {
|
||||
color: dark ? '#71717A' : '#A1A1AA',
|
||||
weight: 1.5,
|
||||
opacity: 0.5,
|
||||
dashArray: '4 6',
|
||||
lineCap: 'round', lineJoin: 'round',
|
||||
}
|
||||
for (const line of connections.open) place('entries', L.polyline(line, connectionStyle))
|
||||
for (const line of connections.covered) place('coveredConnections', L.polyline(line, connectionStyle))
|
||||
|
||||
// place markers
|
||||
items.forEach((item, i) => {
|
||||
@@ -421,26 +511,11 @@ function JourneyMap(
|
||||
allCoords.push(pos)
|
||||
|
||||
const marker = place('entries', L.marker(pos, { icon: entryIcon(item, false, plannedRef.current.has(item.id)) }))
|
||||
// The same card the GL renderer shows, from the same builder: which map
|
||||
// engine a reader happens to have selected should not change what a marker
|
||||
// tells them (discussion #2299). The builder escapes everything that came
|
||||
// from a person, which matters most here — this map is what the public
|
||||
// journey page renders.
|
||||
if (!hideMarkerTooltipRef.current) {
|
||||
// A host that opens the entry from its card gets the one card that follows
|
||||
// the active pin instead (see syncEntryCard).
|
||||
if (!onMarkerCardClickRef.current) {
|
||||
ensureJourneyPopupStyle()
|
||||
marker.bindTooltip(
|
||||
journeyPopupHtml({
|
||||
title: item.label || item.locationName || 'Entry',
|
||||
place: item.label ? item.locationName : '',
|
||||
date: formatMarkerDate(item.time),
|
||||
photoUrls: item.photoUrls,
|
||||
}),
|
||||
{
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip trek-journey-tooltip',
|
||||
},
|
||||
)
|
||||
marker.bindTooltip(entryCardHtml(item), { ...ENTRY_CARD_OPTIONS, className: 'map-tooltip trek-journey-tooltip' })
|
||||
}
|
||||
|
||||
marker.on('click', () => {
|
||||
@@ -487,7 +562,8 @@ function JourneyMap(
|
||||
// A switch puts its layer back on the map or takes it off, exactly as drawn.
|
||||
useEffect(() => {
|
||||
shownRef.current = switchLeafletLayers(mapRef.current, drawnRef.current, shownRef.current, layerVisibility)
|
||||
}, [layerVisibility])
|
||||
syncEntryCard()
|
||||
}, [layerVisibility, syncEntryCard])
|
||||
|
||||
// The plan's check on an entry pin follows the plan layer without redrawing the map.
|
||||
useEffect(() => {
|
||||
@@ -509,8 +585,8 @@ function JourneyMap(
|
||||
}, [tileUrl])
|
||||
|
||||
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
|
||||
// down and rebuild the map the way the entry effect does. Redrawn on zoom and
|
||||
// pan because the clustering is done in screen space.
|
||||
// down and rebuild the map the way the entry effect does. Redrawn after every
|
||||
// move, but the clusters sit on a world grid, so only a zoom regroups them.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
@@ -522,7 +598,7 @@ function JourneyMap(
|
||||
|
||||
// The initial view (setView/fitBounds) is set on a deferred rAF in the
|
||||
// map-build effect above, so this can run before the map has a center/zoom
|
||||
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
|
||||
// and nothing can be projected in that window. Skip; the moveend/zoomend
|
||||
// listener below redraws once the deferred view lands.
|
||||
try {
|
||||
map.getCenter()
|
||||
@@ -531,7 +607,7 @@ function JourneyMap(
|
||||
}
|
||||
|
||||
const group = L.layerGroup()
|
||||
for (const cluster of clusterPhotos(photos, (lat, lng) => map.latLngToContainerPoint([lat, lng]))) {
|
||||
for (const cluster of clusterPhotos(photos, leafletWorldPx(map))) {
|
||||
const marker = L.marker([cluster.lat, cluster.lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
@@ -563,7 +639,7 @@ function JourneyMap(
|
||||
|
||||
// The plan's places and the stays of day reports (JRN-04), in their own layer
|
||||
// like the photos: below the entry pins and above the thumbnails, regrouped
|
||||
// on every move because marks are only joined where they would overlap.
|
||||
// on zoom because marks are only joined where they would overlap.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
|
||||
@@ -23,7 +23,8 @@ interface MapEntry {
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
/** Null leaves the pin unnumbered: a draft the share link does not count. */
|
||||
dayLabel?: number | null
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -38,7 +39,8 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
hideMarkerTooltip?: boolean
|
||||
/** Open the entry a marker card names when the card is tapped, both renderers. */
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
|
||||
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
|
||||
/** Geotagged photos as thumbnails on the map, both renderers (#2453). */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-040, FE-COMP-JMAPGL-2453
|
||||
// FE-COMP-JMAPGL-001 to FE-COMP-JMAPGL-048, FE-COMP-JMAPGL-2453, FE-COMP-JMAPGL-2910
|
||||
|
||||
type Spy = ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -35,8 +35,9 @@ const gl = vi.hoisted(() => {
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
loaded: vi.fn(() => true),
|
||||
// Container pixels from lng/lat, ten per degree: enough to tell near from far.
|
||||
project: vi.fn(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 })),
|
||||
// Container pixels, ten per degree. The layers must not group by them: they
|
||||
// shift with every pan (#2889).
|
||||
project: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
@@ -182,6 +183,7 @@ beforeEach(() => {
|
||||
return gl.map
|
||||
})
|
||||
gl.map.getZoom.mockReturnValue(10)
|
||||
gl.map.project.mockImplementation(([lng, lat]: [number, number]) => ({ x: lng * 10, y: lat * 10 }))
|
||||
gl.map.getSource.mockReturnValue(null)
|
||||
vi.mocked(isStandardFamily).mockReturnValue(false)
|
||||
vi.mocked(supportsCustom3d).mockReturnValue(false)
|
||||
@@ -236,6 +238,15 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.markers[1].element.innerHTML).toContain('>1<')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-043: a stop without a number (a draft) gets an unnumbered pin', () => {
|
||||
withToken()
|
||||
render(<JourneyMapGL checkins={[]} entries={[{ ...entries[0], dayLabel: null }]} />)
|
||||
|
||||
expect(gl.markers).toHaveLength(1)
|
||||
expect(gl.markers[0].element.innerHTML).toContain('#ff0055')
|
||||
expect(gl.markers[0].element.innerHTML).toMatch(/font-weight="700"><\/text>/)
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-006: entries without coordinates never reach the map', () => {
|
||||
withToken()
|
||||
render(
|
||||
@@ -253,25 +264,14 @@ describe('JourneyMapGL', () => {
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} />)
|
||||
|
||||
expect(gl.map.addSource).toHaveBeenCalledWith('journey-route', expect.objectContaining({ type: 'geojson' }))
|
||||
const source = vi.mocked(gl.map.addSource).mock.calls[0][1] as { data: { geometry: { coordinates: number[][] } } }
|
||||
expect(source.data.geometry.coordinates).toEqual([[2.3522, 48.8566], [13.405, 52.52]])
|
||||
const source = vi.mocked(gl.map.addSource).mock.calls[0][1] as { data: { geometry: { coordinates: number[][][] } } }
|
||||
expect(source.data.geometry.coordinates).toEqual([[[2.3522, 48.8566], [13.405, 52.52]]])
|
||||
expect(gl.map.addLayer).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'journey-route-line',
|
||||
source: 'journey-route',
|
||||
}))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-008: an existing route source is updated instead of re-added', () => {
|
||||
withToken()
|
||||
const setData = vi.fn()
|
||||
gl.map.getSource.mockReturnValue({ setData })
|
||||
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} />)
|
||||
|
||||
expect(gl.map.addSource).not.toHaveBeenCalled()
|
||||
expect(setData).toHaveBeenCalledWith(expect.objectContaining({ type: 'Feature' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-038: a planned GPX track goes on the dashed layer, a recorded route on the solid one (JRN-01)', () => {
|
||||
withToken()
|
||||
render(
|
||||
@@ -507,6 +507,86 @@ describe('JourneyMapGL', () => {
|
||||
expect(document.querySelectorAll('#trek-journey-popup-style')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('the marker card as a way into the entry (#2885)', () => {
|
||||
const withPhotos = [
|
||||
{ ...entries[0], photoUrls: ['/a.jpg', '/b.jpg'] },
|
||||
entries[1],
|
||||
]
|
||||
|
||||
// The stub's setHTML records the markup; the card's content is put in by hand.
|
||||
function openCard(props: Record<string, unknown>, id = 'e1') {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
render(<JourneyMapGL ref={ref} checkins={[]} entries={withPhotos} {...props} />)
|
||||
act(() => { ref.current?.highlightMarker(id) })
|
||||
const popup = gl.popups[0]
|
||||
popup.element.innerHTML = String(popup.setHTML.mock.lastCall?.[0])
|
||||
const mapClick = vi.fn()
|
||||
const host = document.createElement('div')
|
||||
host.appendChild(popup.element)
|
||||
host.addEventListener('click', mapClick)
|
||||
return { ref, popup, mapClick }
|
||||
}
|
||||
|
||||
function tap(popup: PopupStub, selector: string) {
|
||||
const target = popup.element.querySelector(selector) as HTMLElement
|
||||
target.click()
|
||||
}
|
||||
|
||||
it('FE-COMP-JMAPGL-044: a tap anywhere on the card opens its entry and stays off the map', () => {
|
||||
const onMarkerCardClick = vi.fn()
|
||||
const { popup, mapClick } = openCard({ onMarkerCardClick })
|
||||
|
||||
expect(popup.options.className).toContain('is-openable')
|
||||
tap(popup, '.trek-journey-popup-title')
|
||||
|
||||
expect(onMarkerCardClick).toHaveBeenCalledWith('e1')
|
||||
expect(mapClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-045: with a photo handler a thumbnail opens the photo, not the entry', () => {
|
||||
const onMarkerCardClick = vi.fn()
|
||||
const onMarkerPhotoClick = vi.fn()
|
||||
const { popup } = openCard({ onMarkerCardClick, onMarkerPhotoClick })
|
||||
|
||||
tap(popup, '[data-shot="1"]')
|
||||
|
||||
expect(onMarkerPhotoClick).toHaveBeenCalledWith('e1', 1)
|
||||
expect(onMarkerCardClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-046: without a photo handler a thumbnail opens the entry like the rest of the card', () => {
|
||||
const onMarkerCardClick = vi.fn()
|
||||
const { popup } = openCard({ onMarkerCardClick })
|
||||
|
||||
tap(popup, '[data-shot]')
|
||||
|
||||
expect(onMarkerCardClick).toHaveBeenCalledWith('e1')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-047: without a handler the card stays a label the tap passes through', () => {
|
||||
const { popup, mapClick } = openCard({})
|
||||
|
||||
expect(popup.options.className).toBe('trek-journey-popup')
|
||||
tap(popup, '.trek-journey-popup-title')
|
||||
|
||||
expect(mapClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-048: the reused card opens the entry it shows now', () => {
|
||||
const onMarkerCardClick = vi.fn()
|
||||
const { ref, popup } = openCard({ onMarkerCardClick })
|
||||
|
||||
act(() => { ref.current?.highlightMarker('e2') })
|
||||
popup.element.innerHTML = String(popup.setHTML.mock.lastCall?.[0])
|
||||
tap(popup, '.trek-journey-popup-title')
|
||||
|
||||
expect(popup.element.classList.contains('is-openable')).toBe(true)
|
||||
expect(popup.element.classList.contains('is-interactive')).toBe(false)
|
||||
expect(onMarkerCardClick).toHaveBeenCalledWith('e2')
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-023: focusMarker eases over with the 3D pitch and no zoom of its own', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
@@ -553,9 +633,10 @@ describe('JourneyMapGL', () => {
|
||||
{ id: 'b', lat: 45.1, lng: 5.1, thumbUrl: '/api/photos/2/thumbnail' },
|
||||
{ id: 'c', lat: 48, lng: 11, thumbUrl: '/api/photos/3/thumbnail' },
|
||||
]
|
||||
gl.map.getZoom.mockReturnValue(4)
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} onPhotoClick={onPhotoClick} />)
|
||||
const thumbs = gl.markers.filter(m => m.element.innerHTML.includes('/api/photos/'))
|
||||
// a and b fall in one 64px bucket at ten pixels a degree, c in its own.
|
||||
// At zoom 4 a and b lie three world pixels apart in one 64px cell, c far off.
|
||||
expect(thumbs).toHaveLength(2)
|
||||
expect(thumbs[0].element.innerHTML).toContain('>2<')
|
||||
thumbs[0].element.click()
|
||||
@@ -563,6 +644,54 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.on).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-2910: no marker element sets its own position, so the engine keeps it absolute', () => {
|
||||
withToken()
|
||||
const photos = [{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' }]
|
||||
const marks = [{ kind: 'stay', key: 's1', lat: 46, lng: 6, color: '#14b8a6', stay: {} }]
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} marks={marks} />)
|
||||
expect(gl.markers.some(m => m.element.innerHTML.includes('/api/photos/'))).toBe(true)
|
||||
for (const marker of gl.markers) expect(marker.element.style.position).toBe('')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-2889: panning keeps the photo and mark groups, only a zoom regroups them', () => {
|
||||
withToken()
|
||||
const photos = [
|
||||
{ id: 'a', lat: 45, lng: 5, thumbUrl: '/api/photos/1/thumbnail' },
|
||||
{ id: 'b', lat: 45, lng: 5.1, thumbUrl: '/api/photos/2/thumbnail' },
|
||||
]
|
||||
const marks = [
|
||||
{ kind: 'stay', key: 's1', lat: 45, lng: 5, color: '#14b8a6', stay: {} },
|
||||
{ kind: 'stay', key: 's2', lat: 45, lng: 5.1, color: '#14b8a6', stay: {} },
|
||||
]
|
||||
gl.map.getZoom.mockReturnValue(4)
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} photos={photos} marks={marks} />)
|
||||
const drawn = () => {
|
||||
const shown = gl.markers.filter(m => !m.remove.mock.calls.length)
|
||||
return {
|
||||
photos: shown.filter(m => m.element.innerHTML.includes('/api/photos/')).map(m => [m.lngLat, m.element.innerHTML]),
|
||||
marks: shown.filter(m => m.element.innerHTML.includes('#14b8a6')).map(m => [m.lngLat, m.element.innerHTML]),
|
||||
}
|
||||
}
|
||||
const moveEnd = () => act(() => {
|
||||
const gone = new Set(gl.map.off.mock.calls.map(c => c[1]))
|
||||
for (const [event, handler] of gl.map.on.mock.calls) if (event === 'moveend' && !gone.has(handler)) (handler as () => void)()
|
||||
})
|
||||
const before = drawn()
|
||||
expect(before.photos).toHaveLength(1)
|
||||
expect(before.photos[0][1]).toContain('>2<')
|
||||
expect(before.marks).toHaveLength(1)
|
||||
|
||||
// A pan moves every container point; at this offset b crosses a cell edge on screen.
|
||||
gl.map.project.mockImplementation(([lng, lat]: [number, number]) => ({ x: lng * 10 - 19, y: lat * 10 }))
|
||||
moveEnd()
|
||||
expect(drawn()).toEqual(before)
|
||||
|
||||
gl.map.getZoom.mockReturnValue(12)
|
||||
moveEnd()
|
||||
expect(drawn().photos).toHaveLength(2)
|
||||
expect(drawn().marks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-026: focusMarker on an unknown id is a no-op', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
@@ -722,4 +851,45 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.markers[0].element.innerHTML).toContain('#16a34a')
|
||||
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-041: a recorded day takes the line between its entries off the map, the rest stays (#2888)', () => {
|
||||
withToken()
|
||||
const days = [
|
||||
{ id: 'a', lat: 48.1, lng: 11.5, entry_date: '2026-10-29' },
|
||||
{ id: 'b', lat: 48.2, lng: 11.6, entry_date: '2026-10-29' },
|
||||
{ id: 'c', lat: 48.3, lng: 11.7, entry_date: '2026-10-30' },
|
||||
{ id: 'd', lat: 48.4, lng: 11.8, entry_date: '2026-10-30' },
|
||||
]
|
||||
render(
|
||||
<JourneyMapGL
|
||||
checkins={[]}
|
||||
entries={days}
|
||||
tracks={[{ place_id: -1, trip_id: 0, name: '2026-10-29', color: '#7c3aed', points: [[48.1, 11.5], [48.2, 11.6]], kind: 'dawarich_live', date: '2026-10-29' }]}
|
||||
/>,
|
||||
)
|
||||
|
||||
const data = (id: string) => (vi.mocked(gl.map.addSource).mock.calls.find(c => c[0] === id)?.[1] as { data: { geometry: { coordinates: number[][][] } } }).data.geometry.coordinates
|
||||
expect(data('journey-route')).toEqual([[[11.6, 48.2], [11.7, 48.3], [11.8, 48.4]]])
|
||||
expect(data('journey-route-covered')).toEqual([[[11.5, 48.1], [11.6, 48.2]]])
|
||||
const covered = vi.mocked(gl.map.addLayer).mock.calls.map(c => c[0] as { id: string; layout: Record<string, unknown> }).find(l => l.id === 'journey-route-covered-line')
|
||||
expect(covered?.layout.visibility).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-042: hiding what was recorded brings the covered stretches back while the entries show', () => {
|
||||
withToken()
|
||||
const shown = { plan: true, recorded: true, entries: true }
|
||||
const days = [
|
||||
{ id: 'a', lat: 48.1, lng: 11.5, entry_date: '2026-10-29' },
|
||||
{ id: 'b', lat: 48.2, lng: 11.6, entry_date: '2026-10-29' },
|
||||
]
|
||||
const tracks = [{ place_id: -1, trip_id: 0, name: 'Day', color: null, points: [[48.1, 11.5], [48.2, 11.6]] as [number, number][], kind: 'day_recording' as const, date: '2026-10-29' }]
|
||||
const { rerender } = render(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={shown} />)
|
||||
expect(gl.map.addSource).not.toHaveBeenCalledWith('journey-route', expect.anything())
|
||||
|
||||
rerender(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false }} />)
|
||||
expect(gl.map.setLayoutProperty).toHaveBeenCalledWith('journey-route-covered-line', 'visibility', 'visible')
|
||||
|
||||
rerender(<JourneyMapGL checkins={[]} entries={days} tracks={tracks} layerVisibility={{ ...shown, recorded: false, entries: false }} />)
|
||||
expect(gl.map.setLayoutProperty).toHaveBeenLastCalledWith('journey-route-covered-line', 'visibility', 'none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,8 +7,9 @@ import { buildGlMap } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
|
||||
import { MARK_CLUSTER_PX, PLANNED_BADGE_SVG, clusterPoints, glWorldPx, markHitSvg, type JourneyMapMark } from './journeyMapMarks'
|
||||
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||
import { coveredConnectionsShown, entryConnections } from './journeyMapConnections'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -25,7 +26,7 @@ interface MapEntry {
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
dayLabel?: number | null
|
||||
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
|
||||
photoUrls?: string[]
|
||||
}
|
||||
@@ -42,8 +43,8 @@ interface Props {
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
/** See the Leaflet twin: no marker labels where a carousel already names the entry. */
|
||||
hideMarkerTooltip?: boolean
|
||||
/** See the Leaflet twin: a tap on the card opens the entry it names (#2885). */
|
||||
onMarkerCardClick?: (entryId: string) => void
|
||||
/**
|
||||
* Open the entry's photos from the card's thumbnail strip.
|
||||
*
|
||||
@@ -78,7 +79,7 @@ interface Item {
|
||||
locationName: string
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
dayLabel: number | null
|
||||
photoUrls: string[]
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ function buildItems(entries: MapEntry[]): Item[] {
|
||||
locationName: e.location_name || '',
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
dayLabel: e.dayLabel === undefined ? 1 : e.dayLabel,
|
||||
photoUrls: e.photoUrls ?? [],
|
||||
})
|
||||
}
|
||||
@@ -114,8 +115,35 @@ const LAYER_IDS: Record<keyof JourneyMapLayerVisibility, string[]> = {
|
||||
}
|
||||
const EVERY_LAYER: JourneyMapLayerVisibility = { plan: true, recorded: true, entries: true }
|
||||
const NO_PLANNED: ReadonlySet<string> = new Set()
|
||||
/** The stretches between entries a recorded day already draws; shown only while that recording is hidden. */
|
||||
const COVERED_CONNECTION_LAYER = 'journey-route-covered-line'
|
||||
|
||||
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, planned = false): HTMLDivElement {
|
||||
/** One of the faint lines between the entries, as a dashed layer over its own source. */
|
||||
function addConnectionLayer(map: mapboxgl.Map, id: string, lines: [number, number][][], dark: boolean, visible: boolean) {
|
||||
if (lines.length === 0) return
|
||||
map.addSource(id, {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'MultiLineString', coordinates: lines.map(line => line.map(([lat, lng]) => [lng, lat])) },
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: `${id}-line`,
|
||||
type: 'line',
|
||||
source: id,
|
||||
paint: {
|
||||
'line-color': dark ? '#71717A' : '#A1A1AA',
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.5,
|
||||
'line-dasharray': [2, 3],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round', visibility: visible ? 'visible' : 'none' },
|
||||
})
|
||||
}
|
||||
|
||||
function markerHtml(dayColor: string, dayLabel: number | null, highlighted: boolean, planned = false): HTMLDivElement {
|
||||
const fill = dayColor
|
||||
const textColor = '#fff'
|
||||
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||
@@ -123,7 +151,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean, pl
|
||||
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
const label = String(dayLabel)
|
||||
const label = dayLabel === null ? '' : String(dayLabel)
|
||||
|
||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||
@@ -166,13 +194,13 @@ function fitOptions(paddingBottom: number | undefined, tilted: boolean | undefin
|
||||
}
|
||||
|
||||
function JourneyMapGL(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, onMarkerCardClick, onMarkerPhotoClick, photos, onPhotoClick, layerVisibility = EVERY_LAYER, marks, onMarksPick, plannedEntryIds = NO_PLANNED, ref }: Props,
|
||||
) {
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
const onMarkerCardClickRef = useRef(onMarkerCardClick)
|
||||
onMarkerCardClickRef.current = onMarkerCardClick
|
||||
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
|
||||
onMarkerPhotoClickRef.current = onMarkerPhotoClick
|
||||
/** Which entry the card is currently describing, for the delegated photo click. */
|
||||
/** Which entry the card is currently describing, for the delegated card and photo clicks. */
|
||||
const popupItemIdRef = useRef<string | null>(null)
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
@@ -224,9 +252,6 @@ function JourneyMapGL(
|
||||
mapLangRef.current = mapLang
|
||||
|
||||
const showPopup = useCallback((id: string) => {
|
||||
// See `hideMarkerTooltip` on the Leaflet twin: below the carousel the card
|
||||
// already says this, and the popup only covers the map to repeat it.
|
||||
if (hideMarkerTooltipRef.current) return
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (!item || !mapRef.current) return
|
||||
ensureJourneyPopupStyle()
|
||||
@@ -244,6 +269,7 @@ function JourneyMapGL(
|
||||
const offset: [number, number] = [0, -46]
|
||||
popupItemIdRef.current = item.id
|
||||
const interactive = !!onMarkerPhotoClickRef.current && item.photoUrls.length > 0
|
||||
const openable = !!onMarkerCardClickRef.current
|
||||
if (popupRef.current) {
|
||||
popupRef.current.setLngLat([item.lng, item.lat])
|
||||
popupRef.current.setHTML(html)
|
||||
@@ -252,6 +278,7 @@ function JourneyMapGL(
|
||||
if (el) {
|
||||
el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
el.classList.toggle('is-interactive', interactive)
|
||||
el.classList.toggle('is-openable', openable)
|
||||
}
|
||||
} else {
|
||||
popupRef.current = new gl.Popup({
|
||||
@@ -260,7 +287,7 @@ function JourneyMapGL(
|
||||
closeOnMove: false,
|
||||
anchor: 'bottom',
|
||||
offset,
|
||||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}`,
|
||||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}${openable ? ' is-openable' : ''}`,
|
||||
maxWidth: '280px',
|
||||
})
|
||||
.setLngLat([item.lng, item.lat])
|
||||
@@ -270,11 +297,18 @@ function JourneyMapGL(
|
||||
// strip on every entry, so a listener on the thumbnails themselves would have
|
||||
// to be rebound each time and would leak the ones it forgot.
|
||||
popupRef.current.getElement()?.addEventListener('click', (ev: Event) => {
|
||||
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
|
||||
const entryId = popupItemIdRef.current
|
||||
if (!shot || !entryId) return
|
||||
ev.stopPropagation()
|
||||
onMarkerPhotoClickRef.current?.(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
|
||||
if (!entryId) return
|
||||
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
|
||||
const onPhoto = onMarkerPhotoClickRef.current
|
||||
const onCard = onMarkerCardClickRef.current
|
||||
if (shot && onPhoto) {
|
||||
ev.stopPropagation()
|
||||
onPhoto(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
|
||||
} else if (onCard) {
|
||||
ev.stopPropagation()
|
||||
onCard(entryId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [gl])
|
||||
@@ -397,31 +431,12 @@ function JourneyMapGL(
|
||||
try { map.setConfigProperty('basemap', 'language', basemapLanguage(mapLangRef.current)) } catch { /* style/SDK may not support it */ }
|
||||
}
|
||||
|
||||
// route trail — dashed line connecting entries in time order
|
||||
if (items.length > 1) {
|
||||
const coords = items.map(i => [i.lng, i.lat])
|
||||
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
|
||||
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
|
||||
})
|
||||
else {
|
||||
map.addSource('journey-route', {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'journey-route-line',
|
||||
type: 'line',
|
||||
source: 'journey-route',
|
||||
paint: {
|
||||
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.5,
|
||||
'line-dasharray': [2, 3],
|
||||
},
|
||||
layout: layout('entries', { 'line-cap': 'round', 'line-join': 'round' }),
|
||||
})
|
||||
}
|
||||
}
|
||||
// The faint line between the entries in their order, left off the stretches
|
||||
// a recorded day already draws as travelled (#2888). Those go on a layer of
|
||||
// their own that stands in while the reader hides the recording.
|
||||
const connections = entryConnections(items.map(i => ({ lat: i.lat, lng: i.lng, date: i.time })), stableTracks)
|
||||
addConnectionLayer(map, 'journey-route', connections.open, !!darkRef.current, visibilityRef.current.entries)
|
||||
addConnectionLayer(map, 'journey-route-covered', connections.covered, !!darkRef.current, coveredConnectionsShown(visibilityRef.current))
|
||||
|
||||
// Tracks, one source for all of them, coloured per feature so a journey
|
||||
// with several routes keeps them apart. Casing underneath, same as the
|
||||
@@ -526,9 +541,10 @@ function JourneyMapGL(
|
||||
}, [entries, stableTrail, stableTracks, glProvider, glStyle, mapboxToken, enableMapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// Photo layer (#2453), the GL twin of the Leaflet one: its own effect so photos
|
||||
// arriving do not rebuild the map, redrawn on every move because the clustering
|
||||
// is done in screen space. Same dependencies as the build effect above, so it
|
||||
// attaches to the map that effect just made.
|
||||
// arriving do not rebuild the map. Redrawn after every move, but the clusters
|
||||
// sit on a world grid, so only a zoom regroups them; a pan, a turn or a tilt
|
||||
// leaves them as they were. Same dependencies as the build effect above, so
|
||||
// it attaches to the map that effect just made.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
@@ -539,9 +555,12 @@ function JourneyMapGL(
|
||||
const draw = () => {
|
||||
clear()
|
||||
if (!photos?.length) return
|
||||
for (const cluster of clusterPhotos(photos, (lat, lng) => map.project([lng, lat]))) {
|
||||
for (const cluster of clusterPhotos(photos, glWorldPx(map))) {
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'position:relative;width:48px;height:48px;cursor:pointer'
|
||||
// No inline `position` (#2910): the engine's marker class positions the element
|
||||
// absolutely, and `position:relative` would drop it into the normal flow, where
|
||||
// every thumbnail sits below the ones drawn before it.
|
||||
el.style.cssText = 'width:48px;height:48px;cursor:pointer'
|
||||
el.innerHTML = photoMarkerHtml(cluster.members[0].thumbUrl, cluster.members.length)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
@@ -575,6 +594,10 @@ function JourneyMapGL(
|
||||
markersRef.current.forEach(m => { m.getElement().style.display = layerVisibility.entries ? '' : 'none' })
|
||||
}
|
||||
}
|
||||
const coveredShown = coveredConnectionsShown(layerVisibility)
|
||||
if (coveredConnectionsShown(shownRef.current) !== coveredShown) {
|
||||
try { map.setLayoutProperty(COVERED_CONNECTION_LAYER, 'visibility', coveredShown ? 'visible' : 'none') } catch { /* not drawn on this map */ }
|
||||
}
|
||||
shownRef.current = layerVisibility
|
||||
}, [layerVisibility])
|
||||
|
||||
@@ -589,7 +612,7 @@ function JourneyMapGL(
|
||||
}, [plannedEntryIds, setMarkerStyle])
|
||||
|
||||
// The plan's places and the stays (JRN-04), the GL twin of the Leaflet layer:
|
||||
// regrouped on every move, between the photos and the entry pins.
|
||||
// regrouped on zoom, between the photos and the entry pins.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
@@ -608,7 +631,7 @@ function JourneyMapGL(
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
}
|
||||
for (const hit of clusterPoints(marks, (lat, lng) => map.project([lng, lat]), MARK_CLUSTER_PX)) {
|
||||
for (const hit of clusterPoints(marks, glWorldPx(map), MARK_CLUSTER_PX)) {
|
||||
const look = markHitSvg(hit.members)
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `width:${look.width}px;height:${look.height}px;cursor:pointer`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-020
|
||||
// FE-COMP-JENTRYVIEW-001 to FE-COMP-JENTRYVIEW-022
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -136,6 +136,18 @@ describe('MobileEntryView', () => {
|
||||
expect(container.querySelector('svg.lucide-camera')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-021: the hero holds the proportions of the photo and shows its thumbnail before the original arrives (#2907)', () => {
|
||||
const { container } = renderView(buildEntry({ photos: [{ ...buildPhoto(4), width: 3000, height: 2000 }] }))
|
||||
const hero = container.querySelector('img') as HTMLImageElement
|
||||
expect(hero.style.aspectRatio).toBe('3000 / 2000')
|
||||
expect(hero.style.backgroundImage).toContain('/api/photos/4/thumbnail')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-022: a photo without recorded dimensions still reserves a 4:3 hero', () => {
|
||||
const { container } = renderView(buildEntry({ photos: [buildPhoto(4)] }))
|
||||
expect((container.querySelector('img') as HTMLImageElement).style.aspectRatio).toBe('4 / 3')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYVIEW-008: several photos add a counter and a thumbnail strip', () => {
|
||||
const { container } = renderView(buildEntry({ photos: [buildPhoto(4), buildPhoto(5), buildPhoto(6)] }))
|
||||
expect(screen.getByText('3 photos')).toBeInTheDocument()
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import JournalBody from './JournalBody'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
@@ -39,6 +40,22 @@ function photoUrl(
|
||||
return `/api/photos/${p.photo_id}/${size}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The hero's box before its picture arrives (#2907): the photo's own proportions
|
||||
* when the upload recorded them, else 4:3, so the story below does not jump down
|
||||
* once the original has loaded. The thumbnail, usually cached by the strip or the
|
||||
* feed already, fills that box meanwhile.
|
||||
*/
|
||||
function heroPlaceholderStyle(photo: JourneyPhoto, thumbUrl: string): CSSProperties {
|
||||
const ratio = photo.width && photo.height ? `${photo.width} / ${photo.height}` : '4 / 3'
|
||||
return {
|
||||
aspectRatio: ratio,
|
||||
backgroundImage: `url("${encodeURI(thumbUrl)}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry
|
||||
readOnly?: boolean
|
||||
@@ -114,8 +131,10 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
<img
|
||||
src={photoUrl(photos[0], photos[0].media_type === 'video' ? 'thumbnail' : 'original', publicPhotoUrl)}
|
||||
alt=""
|
||||
className={`w-full max-h-[50vh] ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
decoding="async"
|
||||
className={`block w-full max-h-[50vh] bg-surface-secondary ${photos[0].media_type === 'video' ? 'object-contain' : 'object-cover'
|
||||
}`}
|
||||
style={heroPlaceholderStyle(photos[0], photoUrl(photos[0], 'thumbnail', publicPhotoUrl))}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -143,7 +162,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
key={p.id || i}
|
||||
type="button"
|
||||
aria-label={p.caption || t('journey.photos')}
|
||||
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-transparent'
|
||||
className={`relative h-16 w-16 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-0 p-0 hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all ${p.media_type === 'video' ? 'bg-black' : 'bg-surface-secondary'
|
||||
}`}
|
||||
onClick={() => onPhotoClick(photos, i)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JMAPTL-001 to FE-COMP-JMAPTL-016
|
||||
// FE-COMP-JMAPTL-001 to FE-COMP-JMAPTL-018
|
||||
|
||||
const mapHandle = vi.hoisted(() => ({
|
||||
highlightMarker: vi.fn(),
|
||||
@@ -190,6 +190,16 @@ describe('MobileMapTimeline', () => {
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPTL-018: a tap on a marker card opens that entry straight away (#2885)', () => {
|
||||
const onEntryClick = vi.fn()
|
||||
renderTimeline({ onEntryClick })
|
||||
const onMarkerCardClick = lastMapProps.current.onMarkerCardClick as (id: string) => void
|
||||
|
||||
act(() => { onMarkerCardClick('2') })
|
||||
|
||||
expect(onEntryClick).toHaveBeenCalledWith(entries[1])
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPTL-007: a marker click for an unknown entry is ignored', () => {
|
||||
renderTimeline()
|
||||
const onMarkerClick = lastMapProps.current.onMarkerClick as (id: string) => void
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function MobileMapTimeline({
|
||||
}: Props) {
|
||||
const {
|
||||
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
|
||||
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, jumpToDay,
|
||||
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
|
||||
} = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick })
|
||||
// The delayed initial focus reads both through refs so it always works off
|
||||
// the current map entries, not the ones from the render that armed the timer.
|
||||
@@ -169,6 +169,7 @@ export default function MobileMapTimeline({
|
||||
dark={dark}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMarkerCardClick={openEntryById}
|
||||
fullScreen
|
||||
paddingBottom={250}
|
||||
cartoApiKey={cartoApiKey}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-027
|
||||
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-029
|
||||
|
||||
// Plyr needs a real media pipeline, so the player is stubbed here.
|
||||
vi.mock('./VideoPlayer', () => ({
|
||||
@@ -326,4 +326,28 @@ describe('PhotoLightbox', () => {
|
||||
expect(screen.getByText('Google', { selector: 'p' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
it('FE-COMP-LIGHTBOX-028: the thumbnail and a spinner stand in until the original has loaded (#2907)', () => {
|
||||
const { container } = render(
|
||||
<PhotoLightbox photos={[{ id: 'a', src: '/a/original', thumbSrc: '/a/thumbnail' }]} onClose={vi.fn()} />,
|
||||
);
|
||||
const placeholder = screen.getByTestId('lightbox-loading');
|
||||
expect(placeholder.querySelector('img')).toHaveAttribute('src', '/a/thumbnail');
|
||||
expect(placeholder.querySelector('svg.animate-spin')).toBeInTheDocument();
|
||||
|
||||
fireEvent.load(container.ownerDocument.querySelector('img[src="/a/original"]') as HTMLImageElement);
|
||||
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LIGHTBOX-029: the next photo waits for its own original, and a failed one drops the spinner too', () => {
|
||||
render(
|
||||
<PhotoLightbox photos={[{ id: 'a', src: '/a.jpg' }, { id: 'b', src: '/b.jpg' }]} onClose={vi.fn()} />,
|
||||
);
|
||||
fireEvent.load(document.querySelector('img[src="/a.jpg"]') as HTMLImageElement);
|
||||
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||
expect(screen.getByTestId('lightbox-loading')).toBeInTheDocument();
|
||||
fireEvent.error(document.querySelector('img[src="/b.jpg"]') as HTMLImageElement);
|
||||
expect(screen.queryByTestId('lightbox-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react'
|
||||
import VideoPlayer from './VideoPlayerLazy'
|
||||
import { usePhotoZoom } from './usePhotoZoom'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
@@ -8,6 +8,12 @@ import { isRtlLanguage, useTranslation } from '../../i18n'
|
||||
interface LightboxPhoto {
|
||||
id: string
|
||||
src: string
|
||||
/**
|
||||
* A small rendition of the same photo, usually cached already by the grid that
|
||||
* opened the lightbox. It stands in while the original loads, which on a slow
|
||||
* server or connection can take seconds (#2907).
|
||||
*/
|
||||
thumbSrc?: string
|
||||
caption?: string | null
|
||||
/** Where the caption's credit points to (a photo's source page), opened in a new tab. */
|
||||
captionHref?: string | null
|
||||
@@ -52,6 +58,9 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
|
||||
// Zoom starts over on every photo (#1484).
|
||||
const zoom = usePhotoZoom(photo?.id)
|
||||
const isImage = !!photo && photo.mediaType !== 'video'
|
||||
// The id whose original has finished (or failed) loading; any other photo is still on its way.
|
||||
const [loadedId, setLoadedId] = useState<string | null>(null)
|
||||
const loading = isImage && loadedId !== photo.id
|
||||
const hasPrev = idx > 0
|
||||
const hasNext = idx < photos.length - 1
|
||||
|
||||
@@ -187,6 +196,8 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
|
||||
src={photo.src}
|
||||
alt={photo.caption || ''}
|
||||
draggable={false}
|
||||
onLoad={() => setLoadedId(photo.id)}
|
||||
onError={() => setLoadedId(photo.id)}
|
||||
{...zoom.mouse}
|
||||
style={{
|
||||
maxWidth: '92vw', maxHeight: '92vh',
|
||||
@@ -198,6 +209,17 @@ export default function PhotoLightbox({ photos, startIndex = 0, onClose, label }
|
||||
/>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div data-testid="lightbox-loading" aria-hidden="true" style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
|
||||
{photo.thumbSrc && (
|
||||
<img src={photo.thumbSrc} alt="" draggable={false} style={{ width: '92vw', height: '92vh', objectFit: 'contain', borderRadius: 4 }} />
|
||||
)}
|
||||
<span style={{ ...ROUND_BTN, position: 'absolute', cursor: 'default' }}>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
{hasNext && (
|
||||
<button type="button" onClick={next} aria-label={t('journey.lightbox.next')} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 focus-visible:opacity-100 transition-opacity" style={{
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import L from 'leaflet';
|
||||
import { coveredConnectionsShown } from './journeyMapConnections';
|
||||
import type { JourneyMapLayerVisibility } from './journeyMapLayers';
|
||||
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark } from './journeyMapMarks';
|
||||
import { MARK_CLUSTER_PX, clusterPoints, markHitSvg, type JourneyMapMark, type WorldPx } from './journeyMapMarks';
|
||||
|
||||
/** The layers of the Leaflet journey map a switch takes off and puts back (JRN-04). */
|
||||
export type LeafletLayerKind = keyof JourneyMapLayerVisibility;
|
||||
/**
|
||||
* The layers of the Leaflet journey map a switch takes off and puts back (JRN-04),
|
||||
* plus the stretches between entries a recording already shows, which follow two.
|
||||
*/
|
||||
export type LeafletLayerKind = keyof JourneyMapLayerVisibility | 'coveredConnections';
|
||||
export type DrawnLayers = Record<LeafletLayerKind, L.Layer[]>;
|
||||
|
||||
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [] });
|
||||
export const NOTHING_DRAWN = (): DrawnLayers => ({ plan: [], recorded: [], entries: [], coveredConnections: [] });
|
||||
|
||||
/** Whether a layer of this kind is on the map with these switches. */
|
||||
export function isLeafletLayerShown(kind: LeafletLayerKind, visibility: JourneyMapLayerVisibility): boolean {
|
||||
return kind === 'coveredConnections' ? coveredConnectionsShown(visibility) : visibility[kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts each layer whose switch changed back on the map or takes it off,
|
||||
@@ -20,15 +29,25 @@ export function switchLeafletLayers(
|
||||
): JourneyMapLayerVisibility {
|
||||
if (!map) return shown;
|
||||
for (const kind of Object.keys(drawn) as LeafletLayerKind[]) {
|
||||
if (shown[kind] === next[kind]) continue;
|
||||
const visible = isLeafletLayerShown(kind, next);
|
||||
if (isLeafletLayerShown(kind, shown) === visible) continue;
|
||||
for (const layer of drawn[kind]) {
|
||||
if (next[kind]) layer.addTo(map);
|
||||
if (visible) layer.addTo(map);
|
||||
else layer.remove();
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* World pixels at the map's current zoom, through the map's own CRS so a
|
||||
* GCJ-02 basemap groups where it draws. Only meaningful once the map has a view.
|
||||
*/
|
||||
export function leafletWorldPx(map: L.Map): WorldPx {
|
||||
const zoom = map.getZoom();
|
||||
return (lat, lng) => map.project([lat, lng], zoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan's places and the stays as one layer: below the entry pins, above
|
||||
* the photo thumbnails, joined only where they would overlap on screen.
|
||||
@@ -47,7 +66,7 @@ export function drawLeafletMarks(
|
||||
return null;
|
||||
}
|
||||
const group = L.layerGroup();
|
||||
for (const hit of clusterPoints(marks, (lat, lng) => map.latLngToContainerPoint([lat, lng]), MARK_CLUSTER_PX)) {
|
||||
for (const hit of clusterPoints(marks, leafletWorldPx(map), MARK_CLUSTER_PX)) {
|
||||
const look = markHitSvg(hit.members);
|
||||
const marker = L.marker([hit.lat, hit.lng], {
|
||||
icon: L.divIcon({
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// FE-JRN-CONN-001 to FE-JRN-CONN-008
|
||||
import type { JourneyTrack } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { coveredConnectionsShown, entryConnections } from './journeyMapConnections';
|
||||
|
||||
const stop = (lat: number, date: string) => ({ lat, lng: lat, date });
|
||||
|
||||
function recorded(
|
||||
date: string,
|
||||
kind: JourneyTrack['kind'] = 'day_recording',
|
||||
points: [number, number][] = [
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
]
|
||||
): JourneyTrack {
|
||||
return { place_id: -1, trip_id: 0, name: date, color: null, points, kind, date };
|
||||
}
|
||||
|
||||
describe('entryConnections (#2888)', () => {
|
||||
it('FE-JRN-CONN-001: without a recording the whole line stays open, one polyline through every stop', () => {
|
||||
const result = entryConnections([stop(1, '2026-10-29'), stop(2, '2026-10-29'), stop(3, '2026-10-30')], []);
|
||||
expect(result.open).toEqual([
|
||||
[
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
]);
|
||||
expect(result.covered).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-JRN-CONN-002: a recorded day covers the stretches between its entries, the jump into a day without one stays open', () => {
|
||||
const result = entryConnections(
|
||||
[stop(1, '2026-10-29'), stop(2, '2026-10-29'), stop(3, '2026-10-30'), stop(4, '2026-10-30')],
|
||||
[recorded('2026-10-29', 'dawarich_live')]
|
||||
);
|
||||
expect(result.covered).toEqual([
|
||||
[
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
],
|
||||
]);
|
||||
expect(result.open).toEqual([
|
||||
[
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
[4, 4],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-JRN-CONN-003: the night between two recorded days is covered, a day in between without one is not', () => {
|
||||
const tracks = [recorded('2026-10-29'), recorded('2026-10-30'), recorded('2026-11-01')];
|
||||
expect(entryConnections([stop(1, '2026-10-29'), stop(2, '2026-10-30')], tracks).open).toEqual([]);
|
||||
expect(entryConnections([stop(1, '2026-10-30'), stop(2, '2026-11-01')], tracks).covered).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-JRN-CONN-004: a trip GPX, a line without a day and a recording without points cover nothing', () => {
|
||||
const stops = [stop(1, '2026-10-29'), stop(2, '2026-10-29')];
|
||||
const gpx: JourneyTrack = { ...recorded('2026-10-29'), kind: 'trip_gpx' };
|
||||
const undated: JourneyTrack = { ...recorded('2026-10-29'), date: undefined };
|
||||
const empty = recorded('2026-10-29', 'day_recording', [[1, 1]]);
|
||||
expect(entryConnections(stops, [gpx, undated, empty]).covered).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-JRN-CONN-005: open and covered stretches alternate in the order of the stops', () => {
|
||||
const result = entryConnections(
|
||||
[
|
||||
stop(1, '2026-10-28'),
|
||||
stop(2, '2026-10-29'),
|
||||
stop(3, '2026-10-29'),
|
||||
stop(4, '2026-10-31'),
|
||||
stop(5, '2026-10-31'),
|
||||
],
|
||||
[recorded('2026-10-29'), recorded('2026-10-31')]
|
||||
);
|
||||
expect(result.open).toEqual([
|
||||
[
|
||||
[1, 1],
|
||||
[2, 2],
|
||||
],
|
||||
[
|
||||
[3, 3],
|
||||
[4, 4],
|
||||
],
|
||||
]);
|
||||
expect(result.covered).toEqual([
|
||||
[
|
||||
[2, 2],
|
||||
[3, 3],
|
||||
],
|
||||
[
|
||||
[4, 4],
|
||||
[5, 5],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-JRN-CONN-006: a timestamp is read by its day, and an unreadable date never counts as recorded', () => {
|
||||
expect(
|
||||
entryConnections([stop(1, '2026-10-29T08:00:00Z'), stop(2, '2026-10-29')], [recorded('2026-10-29')]).open
|
||||
).toEqual([]);
|
||||
expect(entryConnections([stop(1, 'someday'), stop(2, '2026-10-29')], [recorded('2026-10-29')]).covered).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-JRN-CONN-007: fewer than two stops make no line', () => {
|
||||
expect(entryConnections([stop(1, '2026-10-29')], [])).toEqual({ open: [], covered: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('coveredConnectionsShown', () => {
|
||||
it('FE-JRN-CONN-008: stands in only while the entries show and the recording is hidden', () => {
|
||||
expect(coveredConnectionsShown({ plan: true, recorded: true, entries: true })).toBe(false);
|
||||
expect(coveredConnectionsShown({ plan: true, recorded: false, entries: true })).toBe(true);
|
||||
expect(coveredConnectionsShown({ plan: true, recorded: false, entries: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { JourneyTrack } from '@trek/shared';
|
||||
import { isPlannedTrack, type JourneyMapLayerVisibility } from './journeyMapLayers';
|
||||
|
||||
/** A point of the line between entries, `[lat, lng]`. */
|
||||
type LatLng = [number, number];
|
||||
|
||||
/** An entry pin as the connection sees it: where it stands and on which day. */
|
||||
interface ConnectionStop {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** `YYYY-MM-DD`; a longer ISO string is read by its date. */
|
||||
date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The faint line between the entries, cut where the map already shows how the
|
||||
* traveller got from one to the next.
|
||||
*
|
||||
* `open` is every stretch nothing else on the map accounts for, so the
|
||||
* straight line is the best guess there is. `covered` is every stretch whose
|
||||
* days were all recorded: the recorded line is the real way, and a straight
|
||||
* one across it only gets in its way (#2888). It is still handed back, so a
|
||||
* reader who hides what was recorded gets the guess back for those stretches.
|
||||
*/
|
||||
interface EntryConnections {
|
||||
open: LatLng[][];
|
||||
covered: LatLng[][];
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function dayNumber(date: string): number | null {
|
||||
const time = Date.parse(`${date.slice(0, 10)}T00:00:00Z`);
|
||||
return Number.isNaN(time) ? null : Math.round(time / DAY_MS);
|
||||
}
|
||||
|
||||
/** The days a recorded line with points stands for; a trip's GPX is the plan and stands for none. */
|
||||
function recordedDays(tracks: readonly JourneyTrack[]): Set<number> {
|
||||
const days = new Set<number>();
|
||||
for (const track of tracks) {
|
||||
if (isPlannedTrack(track) || !track.date || track.points.length < 2) continue;
|
||||
const day = dayNumber(track.date);
|
||||
if (day !== null) days.add(day);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the way between two stops is on the map already: every day from the
|
||||
* first stop's to the second's was recorded, the night between two recorded
|
||||
* days included. One day in between without a recording leaves it open.
|
||||
*/
|
||||
function travelled(from: string, to: string, recorded: ReadonlySet<number>): boolean {
|
||||
const a = dayNumber(from);
|
||||
const b = dayNumber(to);
|
||||
if (a === null || b === null) return false;
|
||||
const first = Math.min(a, b);
|
||||
const last = Math.max(a, b);
|
||||
if (last - first + 1 > recorded.size) return false;
|
||||
for (let day = first; day <= last; day++) {
|
||||
if (!recorded.has(day)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the line through the stops, taken in the order given (the pins'
|
||||
* order on the map), into the stretches still open and those a recording
|
||||
* covers. Neighbouring stretches of the same kind stay one line.
|
||||
*/
|
||||
export function entryConnections(stops: readonly ConnectionStop[], tracks: readonly JourneyTrack[]): EntryConnections {
|
||||
const result: EntryConnections = { open: [], covered: [] };
|
||||
const recorded = recordedDays(tracks);
|
||||
let line: LatLng[] | null = null;
|
||||
let lineCovered = false;
|
||||
for (let i = 1; i < stops.length; i++) {
|
||||
const from = stops[i - 1];
|
||||
const to = stops[i];
|
||||
const covered = travelled(from.date, to.date, recorded);
|
||||
if (!line || covered !== lineCovered) {
|
||||
line = [[from.lat, from.lng]];
|
||||
(covered ? result.covered : result.open).push(line);
|
||||
lineCovered = covered;
|
||||
}
|
||||
line.push([to.lat, to.lng]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The covered stretches stand in for the recording only while the pins are shown and the recording is not. */
|
||||
export function coveredConnectionsShown(visibility: JourneyMapLayerVisibility): boolean {
|
||||
return visibility.entries && !visibility.recorded;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-003
|
||||
// FE-JOURNEY-MAPMARKS-001 to FE-JOURNEY-MAPMARKS-005
|
||||
//
|
||||
// JRN-04: the plan and stay marks both renderers draw, and how they join.
|
||||
import type { JourneyMapPlace, JourneyMapStay } from '@trek/shared';
|
||||
@@ -6,7 +6,9 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MARK_CLUSTER_PX,
|
||||
clusterPoints,
|
||||
glWorldPx,
|
||||
markHitSvg,
|
||||
mercatorWorldPx,
|
||||
planPinSvg,
|
||||
safeColor,
|
||||
type JourneyMapMark,
|
||||
@@ -54,4 +56,39 @@ describe('journey map marks', () => {
|
||||
expect(safeColor(null)).toBe('#64748b');
|
||||
expect(planPinSvg('red" onload="x')).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPMARKS-004: world pixels are Web Mercator at the zoom, doubling per step and clamped at the poles', () => {
|
||||
expect(mercatorWorldPx(0, 0, 0, 512)).toEqual({ x: 256, y: 256 });
|
||||
expect(mercatorWorldPx(0, -180, 0, 512).x).toBe(0);
|
||||
const z5 = mercatorWorldPx(35.01, 135.68, 5, 512);
|
||||
const z6 = mercatorWorldPx(35.01, 135.68, 6, 512);
|
||||
expect(z6.x).toBeCloseTo(z5.x * 2, 6);
|
||||
expect(z6.y).toBeCloseTo(z5.y * 2, 6);
|
||||
expect(mercatorWorldPx(90, 0, 0, 512).y).toBeCloseTo(0, 3);
|
||||
expect(mercatorWorldPx(-90, 0, 0, 512).y).toBeCloseTo(512, 3);
|
||||
});
|
||||
|
||||
it('FE-JOURNEY-MAPMARKS-005: groups on the GL world grid hold however the view is panned, and split on zoom (#2889)', () => {
|
||||
const marks = [planMark('a', 35.0), planMark('b', 35.0005), planMark('c', 35.001)];
|
||||
const keys = (hits: { members: JourneyMapMark[] }[]) => hits.map((h) => h.members.map((m) => m.key));
|
||||
|
||||
// What a container point does: the same world position, less the pan.
|
||||
const onScreen = (pan: number) =>
|
||||
keys(
|
||||
clusterPoints(
|
||||
marks,
|
||||
(lat, lng) => {
|
||||
const world = mercatorWorldPx(lat, lng, 13, 512);
|
||||
return { x: world.x, y: world.y - pan };
|
||||
},
|
||||
MARK_CLUSTER_PX
|
||||
)
|
||||
);
|
||||
const screenGroupings = new Set(Array.from({ length: MARK_CLUSTER_PX }, (_, pan) => JSON.stringify(onScreen(pan))));
|
||||
expect(screenGroupings.size).toBeGreaterThan(1);
|
||||
|
||||
const atZoom = (zoom: number) => keys(clusterPoints(marks, glWorldPx({ getZoom: () => zoom }), MARK_CLUSTER_PX));
|
||||
expect(atZoom(13)).toEqual([['a', 'b', 'c']]);
|
||||
expect(atZoom(18)).toEqual([['a'], ['b'], ['c']]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export type JourneyMapMark =
|
||||
export const PLAN_FALLBACK_COLOR = '#64748b';
|
||||
|
||||
/**
|
||||
* Marks closer than this on screen are one hit: the reader could not pick one
|
||||
* Marks in one cell of this size are one hit: the reader could not pick one
|
||||
* of them anyway. Tighter than the photo grid, since a mark is a pin and not a
|
||||
* thumbnail, so marks a street apart stay apart.
|
||||
*/
|
||||
@@ -27,19 +27,49 @@ const STAY_DOT = 14;
|
||||
const MARK_CLUSTER_SIZE = 28;
|
||||
|
||||
/**
|
||||
* Grid clustering in screen space, shared by both renderers: points whose
|
||||
* projected position falls in the same `px` cell become one, anchored on the
|
||||
* first so the pin points where that member is. `project` is the engine's own
|
||||
* lat/lng to container-pixel conversion.
|
||||
* A position in world pixels: the whole map laid out at the current zoom, with
|
||||
* the origin at its north-west corner. Unlike a container point it does not
|
||||
* move when the map is panned, rotated or tilted.
|
||||
*/
|
||||
export type WorldPx = (lat: number, lng: number) => { x: number; y: number };
|
||||
|
||||
const MAX_MERCATOR_LAT = 85.0511287798;
|
||||
|
||||
/**
|
||||
* Web Mercator world pixels at `zoom` for a tile of `tileSize` pixels. Both GL
|
||||
* engines lay out their world on 512 px tiles; Leaflet projects through its
|
||||
* own CRS instead, so a GCJ-02 basemap keeps its offset.
|
||||
*/
|
||||
export function mercatorWorldPx(lat: number, lng: number, zoom: number, tileSize: number): { x: number; y: number } {
|
||||
const size = tileSize * 2 ** zoom;
|
||||
const phi = (Math.max(-MAX_MERCATOR_LAT, Math.min(MAX_MERCATOR_LAT, lat)) * Math.PI) / 180;
|
||||
return {
|
||||
x: ((lng + 180) / 360) * size,
|
||||
y: ((1 - Math.log(Math.tan(phi) + 1 / Math.cos(phi)) / Math.PI) / 2) * size,
|
||||
};
|
||||
}
|
||||
|
||||
/** World pixels at a GL map's current zoom, free of its centre, bearing and pitch. */
|
||||
export function glWorldPx(map: { getZoom(): number }): WorldPx {
|
||||
const zoom = map.getZoom();
|
||||
return (lat, lng) => mercatorWorldPx(lat, lng, zoom, 512);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid clustering shared by both renderers: points whose world position falls
|
||||
* in the same `px` cell become one, anchored on the first so the pin points
|
||||
* where that member is. The grid is laid over the world and not over the
|
||||
* viewport, so panning keeps every group as it is and only a zoom regroups
|
||||
* (#2889).
|
||||
*/
|
||||
export function clusterPoints<T extends { lat: number; lng: number }>(
|
||||
points: readonly T[],
|
||||
project: (lat: number, lng: number) => { x: number; y: number },
|
||||
toWorldPx: WorldPx,
|
||||
px: number
|
||||
): { lat: number; lng: number; members: T[] }[] {
|
||||
const buckets = new Map<string, T[]>();
|
||||
for (const point of points) {
|
||||
const pt = project(point.lat, point.lng);
|
||||
const pt = toWorldPx(point.lat, point.lng);
|
||||
const key = `${Math.round(pt.x / px)}:${Math.round(pt.y / px)}`;
|
||||
const list = buckets.get(key);
|
||||
if (list) list.push(point);
|
||||
|
||||
@@ -93,11 +93,24 @@ export function ensureJourneyPopupStyle(): void {
|
||||
s.textContent = `
|
||||
.mapboxgl-popup.trek-journey-popup,
|
||||
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
/* The card as a whole never takes the pointer — it sits over the map and would
|
||||
swallow drags. The photo strip is the one exception, and only where the host
|
||||
wired a handler for it. */
|
||||
/* The card sits over the map and takes the pointer only where the host gave it
|
||||
an action: the whole card where it opens its entry (the phone, where a tap on
|
||||
a pin is what brings it up), the photo strip where it opens the pictures.
|
||||
Anywhere else it stays a label the map's drags pass through. */
|
||||
.mapboxgl-popup.trek-journey-popup.is-openable .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup.is-openable .maplibregl-popup-content { pointer-events: auto; cursor: pointer; }
|
||||
.trek-journey-popup.is-interactive .trek-journey-popup-shots { pointer-events: auto; cursor: zoom-in; }
|
||||
.trek-journey-popup.is-interactive .trek-journey-popup-shot:hover { filter: brightness(1.06); }
|
||||
/* The shared map-tooltip style turns the pointer off on every Leaflet tooltip. */
|
||||
.leaflet-tooltip.map-tooltip.trek-journey-tooltip.is-openable { pointer-events: auto; cursor: pointer; }
|
||||
/* The press shows on what the card holds: Leaflet positions the tooltip with its
|
||||
own transform and sets its opacity inline, so the shell itself is off limits. */
|
||||
.mapboxgl-popup.trek-journey-popup.is-openable .mapboxgl-popup-content > *,
|
||||
.maplibregl-popup.trek-journey-popup.is-openable .maplibregl-popup-content > *,
|
||||
.leaflet-tooltip.trek-journey-tooltip.is-openable > * { transition: opacity 120ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup.is-openable .mapboxgl-popup-content:active > *,
|
||||
.maplibregl-popup.trek-journey-popup.is-openable .maplibregl-popup-content:active > *,
|
||||
.leaflet-tooltip.trek-journey-tooltip.is-openable:active > * { opacity: 0.6; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content,
|
||||
.leaflet-tooltip.trek-journey-tooltip {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// FE-JRN-STOPS-001 to FE-JRN-STOPS-007
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { compareJourneyEntries, isJourneyMapEntry, journeyMapStops, type JourneyStopEntry } from './journeyMapStops';
|
||||
|
||||
function entry(over: Partial<JourneyStopEntry> & { id: number }): JourneyStopEntry {
|
||||
return {
|
||||
entry_date: '2026-10-24',
|
||||
sort_order: 0,
|
||||
type: 'entry',
|
||||
title: 'Stop',
|
||||
location_lat: 35,
|
||||
location_lng: 135.7,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function numbers(entries: JourneyStopEntry[]): Record<string, number | null> {
|
||||
return Object.fromEntries(journeyMapStops(entries).map(({ entry: e, stop }) => [String(e.id), stop]));
|
||||
}
|
||||
|
||||
describe('journeyMapStops', () => {
|
||||
it('FE-JRN-STOPS-001: counts through the whole journey instead of restarting each day', () => {
|
||||
const entries = [
|
||||
entry({ id: 1, entry_date: '2026-10-23' }),
|
||||
entry({ id: 2, entry_date: '2026-10-23', sort_order: 1 }),
|
||||
entry({ id: 3, entry_date: '2026-10-23', sort_order: 2 }),
|
||||
entry({ id: 4, entry_date: '2026-10-24' }),
|
||||
entry({ id: 5, entry_date: '2026-10-24', sort_order: 1 }),
|
||||
];
|
||||
|
||||
expect(numbers(entries)).toEqual({ 1: 1, 2: 2, 3: 3, 4: 4, 5: 5 });
|
||||
});
|
||||
|
||||
it('FE-JRN-STOPS-002: numbers in reading order whatever order the entries arrive in', () => {
|
||||
const entries = [
|
||||
entry({ id: 9, entry_date: '2026-10-24', sort_order: 1 }),
|
||||
entry({ id: 7, entry_date: '2026-10-23' }),
|
||||
entry({ id: 8, entry_date: '2026-10-24', sort_order: 0 }),
|
||||
];
|
||||
|
||||
expect(journeyMapStops(entries).map((s) => s.entry.id)).toEqual([7, 8, 9]);
|
||||
expect(numbers(entries)).toEqual({ 7: 1, 8: 2, 9: 3 });
|
||||
});
|
||||
|
||||
it('FE-JRN-STOPS-003: a sort_order tie is broken by id, as the server orders them', () => {
|
||||
const entries = [entry({ id: 12 }), entry({ id: 10 }), entry({ id: 11 })];
|
||||
|
||||
expect(journeyMapStops(entries).map((s) => s.entry.id)).toEqual([10, 11, 12]);
|
||||
expect(compareJourneyEntries(entry({ id: 10 }), entry({ id: 11 }))).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('FE-JRN-STOPS-004: a draft keeps its pin without a number and does not move the stops after it', () => {
|
||||
const entries = [
|
||||
entry({ id: 1, entry_date: '2026-10-23' }),
|
||||
entry({ id: 2, entry_date: '2026-10-23', sort_order: 1, is_draft: true }),
|
||||
entry({ id: 3, entry_date: '2026-10-24' }),
|
||||
];
|
||||
|
||||
expect(numbers(entries)).toEqual({ 1: 1, 2: null, 3: 2 });
|
||||
expect(numbers(entries.filter((e) => !e.is_draft))).toEqual({ 1: 1, 3: 2 });
|
||||
});
|
||||
|
||||
it('FE-JRN-STOPS-005: skeletons, gallery placeholders and entries without coordinates stay off the map', () => {
|
||||
const entries = [
|
||||
entry({ id: 1, type: 'skeleton' }),
|
||||
entry({ id: 2, title: 'Gallery' }),
|
||||
entry({ id: 3, title: '[Trip Photos]' }),
|
||||
entry({ id: 4, location_lat: null, location_lng: null }),
|
||||
entry({ id: 5, sort_order: 1 }),
|
||||
];
|
||||
|
||||
expect(numbers(entries)).toEqual({ 5: 1 });
|
||||
expect(entries.map(isJourneyMapEntry)).toEqual([false, false, false, false, true]);
|
||||
});
|
||||
|
||||
it('FE-JRN-STOPS-006: the owner journal and the share link give every published stop the same number', () => {
|
||||
const owner = [
|
||||
entry({ id: 21, entry_date: '2026-10-23' }),
|
||||
entry({ id: 22, entry_date: '2026-10-23', sort_order: 1, is_draft: true }),
|
||||
entry({ id: 23, entry_date: '2026-10-23', sort_order: 2 }),
|
||||
entry({ id: 24, entry_date: '2026-10-24', type: 'skeleton' }),
|
||||
entry({ id: 25, entry_date: '2026-10-24', sort_order: 1 }),
|
||||
];
|
||||
const shared = owner.filter((e) => !e.is_draft && e.type !== 'skeleton');
|
||||
|
||||
const ownerNumbers = numbers(owner);
|
||||
delete ownerNumbers[22];
|
||||
expect(ownerNumbers).toEqual(numbers(shared));
|
||||
});
|
||||
|
||||
it('FE-JRN-STOPS-007: leaves the caller its list untouched', () => {
|
||||
const entries = [entry({ id: 2, sort_order: 1 }), entry({ id: 1 })];
|
||||
|
||||
journeyMapStops(entries);
|
||||
|
||||
expect(entries.map((e) => e.id)).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Which journal entries stand on a journey's map and the number each one carries.
|
||||
*
|
||||
* The owner's journal and the public share link both draw the same pins, and they
|
||||
* used to number them by two different rules: the share link counted through the
|
||||
* whole journey (#1962) while the owner's map restarted at 1 every day, so the
|
||||
* same stop read 66 on one and 6 on the other (#2886). Both views now take their
|
||||
* numbers from here.
|
||||
*/
|
||||
|
||||
export interface JourneyStopEntry {
|
||||
id: number | string;
|
||||
entry_date: string;
|
||||
sort_order?: number | null;
|
||||
title?: string | null;
|
||||
type?: string | null;
|
||||
is_draft?: boolean | number | null;
|
||||
location_lat?: number | null;
|
||||
location_lng?: number | null;
|
||||
}
|
||||
|
||||
interface JourneyMapStop<E> {
|
||||
entry: E;
|
||||
/** The stop's place in the whole journey, or null for a draft the share link does not show. */
|
||||
stop: number | null;
|
||||
}
|
||||
|
||||
/** Placeholder entries the old gallery model kept in the journal; a migration removes them, older caches may still hold them. */
|
||||
const GALLERY_PLACEHOLDER_TITLES = new Set(['Gallery', '[Trip Photos]']);
|
||||
|
||||
export function isGalleryPlaceholder(entry: Pick<JourneyStopEntry, 'title'>): boolean {
|
||||
return GALLERY_PLACEHOLDER_TITLES.has(entry.title ?? '');
|
||||
}
|
||||
|
||||
/** A skeleton is a suggestion taken from the trip, not a place anyone wrote about. */
|
||||
export function isJourneyMapEntry(entry: JourneyStopEntry): boolean {
|
||||
return !!entry.location_lat && !!entry.location_lng && entry.type !== 'skeleton' && !isGalleryPlaceholder(entry);
|
||||
}
|
||||
|
||||
/** The journal's reading order, the same one the server reads the entries in. */
|
||||
export function compareJourneyEntries(a: JourneyStopEntry, b: JourneyStopEntry): number {
|
||||
if (a.entry_date !== b.entry_date) return a.entry_date < b.entry_date ? -1 : 1;
|
||||
const bySortOrder = (a.sort_order ?? 0) - (b.sort_order ?? 0);
|
||||
if (bySortOrder !== 0) return bySortOrder;
|
||||
return Number(a.id) - Number(b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The located entries in reading order, each with its stop number. A draft keeps
|
||||
* its pin for the people writing the journal but takes no number, so every stop
|
||||
* after it reads the same as on the share link, which leaves drafts out.
|
||||
*/
|
||||
export function journeyMapStops<E extends JourneyStopEntry>(entries: readonly E[]): JourneyMapStop<E>[] {
|
||||
let stop = 0;
|
||||
return entries
|
||||
.filter(isJourneyMapEntry)
|
||||
.sort(compareJourneyEntries)
|
||||
.map((entry) => ({ entry, stop: entry.is_draft ? null : ++stop }));
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { clusterPhotos, photoMarkerHtml } from './journeyPhotoLayer'
|
||||
|
||||
describe('journey photo layer (#2453)', () => {
|
||||
it('FE-JOURNEY-PHOTOLAYER-001: buckets by screen position, anchors on the first photo, badges a count', () => {
|
||||
it('FE-JOURNEY-PHOTOLAYER-001: buckets by world position, anchors on the first photo, badges a count', () => {
|
||||
const photos = [
|
||||
{ id: '1', lat: 1, lng: 1, thumbUrl: '/a.jpg' },
|
||||
{ id: '2', lat: 1.2, lng: 1.2, thumbUrl: '/b.jpg' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clusterPoints } from './journeyMapMarks'
|
||||
import { clusterPoints, type WorldPx } from './journeyMapMarks'
|
||||
|
||||
/** A photo that knows where it was taken (#1614). */
|
||||
export interface MapPhoto {
|
||||
@@ -9,24 +9,23 @@ export interface MapPhoto {
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid clustering in screen space, shared by the Leaflet and the GL journey map.
|
||||
* Grid clustering of the photos, shared by the Leaflet and the GL journey map.
|
||||
*
|
||||
* The Journey maps have never had clustering, and the library the planner uses
|
||||
* hangs off react-leaflet while these maps drive their engines directly.
|
||||
* Bucketing by rounded pixel position is a few lines, is deterministic, and is
|
||||
* enough for the job: photos of one place collapse into one thumbnail with a
|
||||
* count, and pulling the map apart separates them again. `project` is the
|
||||
* engine's own lat/lng to container-pixel conversion.
|
||||
* Bucketing by rounded world-pixel position is a few lines, is deterministic,
|
||||
* and is enough for the job: photos of one place collapse into one thumbnail
|
||||
* with a count, zooming in separates them again, and panning leaves them be.
|
||||
*/
|
||||
export const PHOTO_CLUSTER_PX = 64
|
||||
|
||||
export function clusterPhotos(
|
||||
photos: MapPhoto[],
|
||||
project: (lat: number, lng: number) => { x: number; y: number },
|
||||
toWorldPx: WorldPx,
|
||||
): { lat: number; lng: number; members: MapPhoto[] }[] {
|
||||
// Anchored on the first member rather than the centroid: the thumbnail shown
|
||||
// is that photo's, so the pin should point where that picture was taken.
|
||||
return clusterPoints(photos, project, PHOTO_CLUSTER_PX)
|
||||
return clusterPoints(photos, toWorldPx, PHOTO_CLUSTER_PX)
|
||||
}
|
||||
|
||||
export function photoMarkerHtml(thumbUrl: string, count: number): string {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-007: the card carousel over the journey map,
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
|
||||
// behind both the phone journey screen and the narrow desktop and public timeline.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -136,4 +136,13 @@ describe('useJourneyCarouselSync', () => {
|
||||
expect(hook.result.current.activeIndex).toBe(1);
|
||||
expect(map.highlightMarker).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-JRN-CAROUSEL-008: a marker card opens its entry by id, wherever the carousel stands (#2885)', () => {
|
||||
const { result, props } = setup();
|
||||
act(() => result.current.openEntryById('3'));
|
||||
expect(props.onOpenEntry).toHaveBeenCalledWith(ENTRIES[2]);
|
||||
expect(result.current.activeIndex).toBe(0);
|
||||
act(() => result.current.openEntryById('99'));
|
||||
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,6 +123,16 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
[activeIndex, onOpenEntry, scrollCardIntoCenter, syncMapOnTap, syncMapToCard]
|
||||
);
|
||||
|
||||
// The marker card on the map names one entry, so a tap on it opens that entry
|
||||
// straight away, the same way a tap on the active carousel card does (#2885).
|
||||
const openEntryById = useCallback(
|
||||
(id: string) => {
|
||||
const entry = entries.find((e) => String(e.id) === id);
|
||||
if (entry) onOpenEntry(entry);
|
||||
},
|
||||
[entries, onOpenEntry]
|
||||
);
|
||||
|
||||
// The day bar lands on the first entry of that day, which is where a reader who
|
||||
// asked for "day nine" means.
|
||||
const jumpToDay = useCallback(
|
||||
@@ -146,6 +156,7 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
syncMapToCard,
|
||||
handleMarkerClick,
|
||||
handleCardTap,
|
||||
openEntryById,
|
||||
jumpToDay,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,6 +115,8 @@ describe('useJourneyDayImport', () => {
|
||||
it('FE-JRN-DAYIMPORT-004: the route goes along only when switched on, kept as previewed', async () => {
|
||||
const { result, onKept, onClose } = setup();
|
||||
await waitFor(() => expect(result.current.route.offer).toBe('keep'));
|
||||
expect(result.current.mapTracks.length).toBeGreaterThan(0);
|
||||
expect(result.current.mapTracks.every((track) => track.date === '2026-05-14')).toBe(true);
|
||||
repo.save.mockResolvedValue({ day: buildRecordedDay(), outcome: 'created' });
|
||||
|
||||
act(() => result.current.route.toggle());
|
||||
|
||||
@@ -155,8 +155,9 @@ function useDayMap(
|
||||
color: dayColor,
|
||||
points: segment.points,
|
||||
kind: 'day_recording',
|
||||
date,
|
||||
})),
|
||||
[route, dayColor, t]
|
||||
[route, dayColor, date, t]
|
||||
);
|
||||
return { entries, tracks };
|
||||
}
|
||||
|
||||
@@ -202,6 +202,8 @@ describe('useJourneyMapLayers', () => {
|
||||
expect(result.current.tracks.every((track) => track.kind === 'day_recording' && track.color === '#14b8a6')).toBe(
|
||||
true
|
||||
);
|
||||
// The day travels as a date of its own, so the map can tell which stretches it covers (#2888).
|
||||
expect(result.current.tracks.map((track) => track.date)).toEqual(['2026-05-14', '2026-05-14']);
|
||||
expect(result.current.controls.map((c) => c.layer)).toEqual(['recorded', 'entries', 'photos']);
|
||||
expect(result.current.controls[0]).toEqual({ layer: 'recorded', count: 1, state: 'ready' });
|
||||
});
|
||||
|
||||
@@ -163,6 +163,7 @@ export function useJourneyMapLayers({
|
||||
color,
|
||||
points,
|
||||
kind: 'day_recording',
|
||||
date: day.date,
|
||||
}));
|
||||
}),
|
||||
[sources.recordings, colorOfDay, t, locale]
|
||||
@@ -187,7 +188,7 @@ export function useJourneyMapLayers({
|
||||
[places, stays, visibility, entryIdsOnMap, stayColor]
|
||||
);
|
||||
|
||||
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.name)).size, [recorded]);
|
||||
const recordedDays = useMemo(() => new Set(recorded.map((track) => track.date ?? track.name)).size, [recorded]);
|
||||
const controls = useMemo(
|
||||
() =>
|
||||
buildControls({
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('useJourneyRecordings', () => {
|
||||
expect(result.current.byDate.get('2026-05-15')?.id).toBe(4);
|
||||
expect(result.current.tracks).toHaveLength(3);
|
||||
expect(result.current.tracks.every((track) => track.kind === 'day_recording')).toBe(true);
|
||||
expect(result.current.tracks.map((track) => track.date)).toEqual(['2026-05-14', '2026-05-14', '2026-05-15']);
|
||||
// One line per piece: a gap between two pieces is never drawn over.
|
||||
expect(result.current.tracks.slice(0, 2).map((track) => track.points)).toEqual([
|
||||
WALK_SEGMENT.points,
|
||||
|
||||
@@ -105,6 +105,7 @@ export function useJourneyRecordings(
|
||||
color,
|
||||
points: segment.points,
|
||||
kind: 'day_recording',
|
||||
date: day.date,
|
||||
}));
|
||||
}),
|
||||
[days, colorOf, t, locale]
|
||||
|
||||
@@ -50,16 +50,10 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The cluster bubbles render as GL circles +
|
||||
// a count label; the individual rich HTML markers are then only drawn for the
|
||||
// points the source reports as currently unclustered. Grouping is always on,
|
||||
// matching the Leaflet map's MarkerClusterGroup.
|
||||
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
import {
|
||||
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers, drawClusterBubbles, expandCluster,
|
||||
} from './placeClusterLayers'
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -112,72 +106,6 @@ interface Props extends MapViewProps {
|
||||
gl: any
|
||||
}
|
||||
|
||||
/**
|
||||
* How eagerly place markers merge into a cluster.
|
||||
*
|
||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||
* own, and the stops that share a coordinate are folded together at the pin level
|
||||
* instead — see the reconcile below.
|
||||
*/
|
||||
const CLUSTER_RADIUS = 20
|
||||
const CLUSTER_MAX_ZOOM = 8
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
@@ -612,6 +540,8 @@ export function MapViewGL({
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
/** The count bubbles, one per cluster the source currently reports. */
|
||||
const clusterPinsRef = useRef<PlacePin[]>([])
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
@@ -642,6 +572,7 @@ export function MapViewGL({
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
clusterPinsRef.current.forEach(pin => pin.reposition())
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
@@ -1304,21 +1235,7 @@ export function MapViewGL({
|
||||
? map.queryRenderedFeatures(e.point, { layers: [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID] })
|
||||
: []
|
||||
const feature = features?.[0]
|
||||
const clusterId = feature?.properties?.cluster_id
|
||||
const coordinates = feature?.geometry?.coordinates
|
||||
if (clusterId == null || !Array.isArray(coordinates)) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as any
|
||||
const easeToZoom = (nextZoom: number) => {
|
||||
try { map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 }) } catch { /* noop */ }
|
||||
}
|
||||
try {
|
||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err: Error | null, nextZoom: number) => {
|
||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom)
|
||||
})
|
||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom)
|
||||
else if (maybeZoom && typeof maybeZoom.then === 'function') maybeZoom.then(easeToZoom).catch(() => {})
|
||||
} catch { /* noop */ }
|
||||
expandCluster(map, feature?.properties?.cluster_id, feature?.geometry?.coordinates)
|
||||
}
|
||||
const setClusterCursor = () => {
|
||||
const canvas = typeof map.getCanvas === 'function' ? map.getCanvas() : null
|
||||
@@ -1704,8 +1621,17 @@ export function MapViewGL({
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
const dropClusterPins = () => {
|
||||
clusterPinsRef.current.forEach(pin => pin.remove())
|
||||
clusterPinsRef.current = []
|
||||
}
|
||||
const reconcileClusterPins = () => {
|
||||
dropClusterPins()
|
||||
clusterPinsRef.current = drawClusterBubbles(map, (el, lng, lat) => attachPin(map, gl, pinLayerRef.current, el, lng, lat))
|
||||
}
|
||||
const runReconcile = () => {
|
||||
raf = null
|
||||
reconcileClusterPins()
|
||||
const features = map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['!', ['has', 'point_count']] }) || []
|
||||
const seen = new Set<number>()
|
||||
const visiblePlaces: PlaceWithCoords[] = []
|
||||
@@ -1752,6 +1678,7 @@ export function MapViewGL({
|
||||
|
||||
return () => {
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
dropClusterPins()
|
||||
map.off('moveend', scheduleReconcile)
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { pluginsApi, type PluginRouteResult } from '../../api/client'
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
calculateAlternatives,
|
||||
avoidedClasses,
|
||||
routeEngineFor,
|
||||
osrmLegSegment,
|
||||
} from './RouteCalculator'
|
||||
import { legSegment } from './routeText'
|
||||
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
// demo ignored the profile in the URL, so walking routes followed the road network.
|
||||
@@ -387,6 +387,12 @@ function pluginRouteResult(over: Partial<PluginRouteResult> = {}): PluginRouteRe
|
||||
}
|
||||
}
|
||||
|
||||
// FOSSGIS: OSRM routes the days and Valhalla only what OSRM cannot. Where Valhalla routes
|
||||
// every day is valhallaDayRoute.test.ts.
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_valhalla_provider: 'fossgis' } }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, distance_unit: 'metric' } })
|
||||
@@ -913,7 +919,8 @@ describe('calculateAlternatives', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
|
||||
let valhallaCalls = 0
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
|
||||
// No routing service chosen: the own OSRM alone decides.
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org', routing_valhalla_provider: '' } }))
|
||||
server.use(
|
||||
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
@@ -1232,11 +1239,11 @@ describe('a trip that avoids something', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('osrmLegSegment', () => {
|
||||
describe('legSegment', () => {
|
||||
const waypoints = [{ lat: 48, lng: 2 }, { lat: 50, lng: 4 }, { lat: 52, lng: 6 }]
|
||||
|
||||
it('spans waypoint i to i + 1 with the label at the midpoint', () => {
|
||||
const seg = osrmLegSegment({ distance: 1500, duration: 600 }, 1, waypoints)
|
||||
const seg = legSegment({ distance: 1500, duration: 600 }, 1, waypoints)
|
||||
expect(seg.from).toEqual([50, 4])
|
||||
expect(seg.to).toEqual([52, 6])
|
||||
expect(seg.mid).toEqual([51, 5])
|
||||
@@ -1245,7 +1252,7 @@ describe('osrmLegSegment', () => {
|
||||
})
|
||||
|
||||
it('formats driving as measured and walking at 5 km/h', () => {
|
||||
const seg = osrmLegSegment({ distance: 5000, duration: 600 }, 0, waypoints)
|
||||
const seg = legSegment({ distance: 5000, duration: 600 }, 0, waypoints)
|
||||
expect(seg.drivingText).toBe('10 min')
|
||||
expect(seg.walkingText).toBe('1 h 0 min')
|
||||
expect(seg.durationText).toBeUndefined()
|
||||
@@ -1253,6 +1260,6 @@ describe('osrmLegSegment', () => {
|
||||
|
||||
it('writes short metric distances in metres', () => {
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, distance_unit: 'metric' } })
|
||||
expect(osrmLegSegment({ distance: 420.4, duration: 60 }, 0, waypoints).distanceText).toBe('420 m')
|
||||
expect(legSegment({ distance: 420.4, duration: 60 }, 0, waypoints).distanceText).toBe('420 m')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import type { RouteEngine, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { cacheRoute, rememberRoute, routeInMemory, storedRoute } from './routeStore'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
|
||||
import { formatDuration, formatRouteDistance, getDistanceUnit, legSegment } from './routeText'
|
||||
import type { RouteAlternative } from './routeTypes'
|
||||
import { valhallaAvailable, valhallaPlansDays, valhallaRoutesDays, type AvoidClass } from './valhallaRoute'
|
||||
import { EXCLUDABLE_CLASSES, valhallaDayAlternatives, valhallaDayRoute, valhallaExcluding } from './valhallaDayRoute'
|
||||
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
@@ -213,12 +215,15 @@ export function parsePluginProfile(profile: string): { pluginId: string; profile
|
||||
return { pluginId: rest.slice(0, slash), profileId: rest.slice(slash + 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Which engine priced a route: OSRM, the second engine that weighs road classes away, or
|
||||
* a route provider plugin. Their figures are three different speed models, so a figure
|
||||
* from one read against a figure from another measures the models, not the roads.
|
||||
*/
|
||||
export type RouteEngine = 'osrm' | 'valhalla' | 'plugin'
|
||||
/** One of the three profiles both engines know, or null for a plugin's own. */
|
||||
function builtInProfile(profile: RouteProfileKey): 'driving' | 'walking' | 'cycling' | null {
|
||||
switch (profile) {
|
||||
case 'driving': return 'driving'
|
||||
case 'walking': return 'walking'
|
||||
case 'cycling': return 'cycling'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The classes a request really weighs away, in a stable order, and none when it cannot.
|
||||
@@ -234,10 +239,75 @@ export function avoidedClasses(profile: RouteProfileKey, avoid: readonly AvoidCl
|
||||
: []
|
||||
}
|
||||
|
||||
/** The engine `calculateRouteWithLegs` hands a request with this profile and avoidance to. */
|
||||
/**
|
||||
* The engine a request with this profile and avoidance is planned with: Valhalla for every
|
||||
* day where it routes the days, and for a day that avoids something wherever it can; OSRM
|
||||
* for the rest. Planned, not promised: a day Valhalla cannot answer, or one asked while
|
||||
* the relay rests, is drawn by OSRM, and the answer says so in its `engine`.
|
||||
*/
|
||||
export function routeEngineFor(profile: RouteProfileKey, avoid: readonly AvoidClass[]): RouteEngine {
|
||||
if (parsePluginProfile(profile)) return 'plugin'
|
||||
return avoidedClasses(profile, avoid).length ? 'valhalla' : 'osrm'
|
||||
if (avoidedClasses(profile, avoid).length) return 'valhalla'
|
||||
return builtInProfile(profile) && valhallaPlansDays() ? 'valhalla' : 'osrm'
|
||||
}
|
||||
|
||||
/** The profile Valhalla routes a request under, or null where `routeEngineFor` gives it to another engine. */
|
||||
function valhallaProfile(profile: RouteProfileKey, avoid: readonly AvoidClass[]): 'driving' | 'walking' | 'cycling' | null {
|
||||
return routeEngineFor(profile, avoid) === 'valhalla' ? builtInProfile(profile) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a Valhalla answer is kept: beside the OSRM key, which keeps the shape it always
|
||||
* had. A day that weighs classes away keeps the key it has had since only Valhalla could
|
||||
* answer one, so the days a device already holds stay found; a day that avoids nothing
|
||||
* names the engine instead.
|
||||
*/
|
||||
function valhallaKey(plainKey: string, avoiding: readonly AvoidClass[]): string {
|
||||
return avoiding.length ? `${plainKey}:avoid=${avoiding.join(',')}` : `${plainKey}:valhalla`
|
||||
}
|
||||
|
||||
/**
|
||||
* OSRM's answer for a day Valhalla could not route. A day that avoided nothing is then
|
||||
* routed as it always was, without a word. One that avoided something says that nothing
|
||||
* was avoided, with the reason, so the rail flags the day instead of implying the setting
|
||||
* held on a road nobody weighed.
|
||||
*/
|
||||
function withFallbackNoted(plain: RouteWithLegs, avoiding: AvoidClass[]): RouteWithLegs {
|
||||
const drawn = withEngine(plain, 'osrm')
|
||||
return avoiding.length ? { ...drawn, avoidance: { asked: avoiding, achieved: [], fellBack: true } } : drawn
|
||||
}
|
||||
|
||||
/**
|
||||
* A kept route with the engine that drew it. A key holds one engine's answers only, so an
|
||||
* entry kept before answers named their engine is that key's.
|
||||
*/
|
||||
function withEngine(route: RouteWithLegs, engine: RouteEngine): RouteWithLegs {
|
||||
return route.engine ? route : { ...route, engine }
|
||||
}
|
||||
|
||||
/**
|
||||
* A day planned with Valhalla: its answer, kept under the Valhalla key, or where it gives
|
||||
* none OSRM's for the whole day, kept under the plain key. One engine per day, never one
|
||||
* leg from each. While the relay rests, a day that avoids nothing is not put to it at all.
|
||||
*/
|
||||
async function valhallaOrOsrmDay(
|
||||
waypoints: Waypoint[],
|
||||
{ profile, avoiding, keys, signal }: {
|
||||
profile: 'driving' | 'walking' | 'cycling'
|
||||
avoiding: AvoidClass[]
|
||||
keys: { valhalla: string; plain: string; coords: string }
|
||||
signal?: AbortSignal
|
||||
},
|
||||
): Promise<RouteWithLegs> {
|
||||
const asking = avoiding.length > 0 || valhallaRoutesDays()
|
||||
const routed = asking ? await valhallaDayRoute(waypoints, profile, avoiding, signal) : null
|
||||
if (routed) {
|
||||
cacheRoute(keys.valhalla, routed)
|
||||
return routed
|
||||
}
|
||||
const plain = (await storedRoute(keys.plain)) ?? await osrmWithLegs(waypoints, keys.coords, profile, signal)
|
||||
cacheRoute(keys.plain, plain)
|
||||
return withFallbackNoted(plain, avoiding)
|
||||
}
|
||||
|
||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||
@@ -467,31 +537,11 @@ export async function calculateSegments(
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
|
||||
const legs = data.routes[0].legs
|
||||
return legs.map((leg: OsrmLeg, i: number): RouteSegment => osrmLegSegment(leg, i, waypoints))
|
||||
return legs.map((leg: OsrmLeg, i: number): RouteSegment => legSegment(leg, i, waypoints))
|
||||
}
|
||||
|
||||
interface OsrmLeg { distance: number; duration: number }
|
||||
|
||||
/**
|
||||
* The connector data for OSRM leg i, which runs from waypoint i to waypoint i + 1:
|
||||
* both ends, the midpoint the label sits on, and the walking time at 5 km/h next to
|
||||
* the driving time OSRM measured.
|
||||
*/
|
||||
export function osrmLegSegment(leg: OsrmLeg, i: number, waypoints: Waypoint[]): RouteSegment {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||
const walkingDuration = leg.distance / (5000 / 3600)
|
||||
return {
|
||||
mid, from, to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
|
||||
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
|
||||
@@ -513,6 +563,7 @@ export async function calculateRouteWithLegs(
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const valhallaDay = valhallaProfile(profile, avoid)
|
||||
const avoiding = avoidedClasses(profile, avoid)
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
@@ -520,41 +571,37 @@ export async function calculateRouteWithLegs(
|
||||
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||
// A plugin route is trip-/day-specific (it may return different charging stops for
|
||||
// the same coordinates on a different day), so its key includes tripId/dayId;
|
||||
// the built-in OSRM profiles are context-free and leave those out.
|
||||
// the built-in profiles are context-free and leave those out.
|
||||
//
|
||||
// The engine belongs in the key because the two price roads differently: a day OSRM
|
||||
// drew handed out as Valhalla's after the admin switched engines would keep the old
|
||||
// times on every device that held it, and the other way round. An OSRM answer keeps
|
||||
// the key it always had, so the routes already kept on a device stay good.
|
||||
//
|
||||
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
|
||||
// turning the switch on returns the tolled route out of cache in under a millisecond,
|
||||
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. So the avoiding key only ever holds a Valhalla answer: when Valhalla cannot
|
||||
// answer and OSRM drives the request after all, that answer is filed under the plain
|
||||
// key, where it is exactly what an ordinary caller would have got. Filed under the
|
||||
// avoiding key it stood in for the avoided road until the next reload, however soon
|
||||
// Valhalla came back.
|
||||
// under the plain key would hand them a road nobody drives. So the Valhalla keys only
|
||||
// ever hold a Valhalla answer: when Valhalla cannot answer and OSRM drives the request
|
||||
// after all, that answer is filed under the OSRM key, where it is exactly what an
|
||||
// ordinary caller would have got. Filed under the Valhalla key it stood in for the
|
||||
// weighed road until the next reload, however soon Valhalla came back.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const plainKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const cacheKey = `${plainKey}${avoidScope}`
|
||||
// Which engine the day is planned with does not follow the relay's rest, so a day kept
|
||||
// from Valhalla is still found while the relay rests, offline above all.
|
||||
const cacheKey = valhallaDay ? valhallaKey(plainKey, avoiding) : plainKey
|
||||
// A plugin route stays in memory: its answer depends on the trip and the day,
|
||||
// and may carry live data such as charging stops. The rest is kept on the device
|
||||
// too, so a trip that shows its whole route opens drawn (#2794).
|
||||
const cached = pluginScope ? routeInMemory(cacheKey) : await storedRoute(cacheKey)
|
||||
if (cached) return cached
|
||||
if (cached) return withEngine(cached, routeEngineFor(profile, avoid))
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
// Null is a Valhalla that could not answer at all. OSRM keeps the day drawn rather
|
||||
// than blank, and the answer says that nothing was avoided, with the reason, so the
|
||||
// rail flags the day instead of implying the setting held on a road nobody weighed.
|
||||
const plain = (await storedRoute(plainKey)) ?? await osrmWithLegs(waypoints, coords, profile, signal)
|
||||
cacheRoute(plainKey, plain)
|
||||
return { ...plain, avoidance: { asked: avoiding, achieved: [], fellBack: true } }
|
||||
if (valhallaDay) {
|
||||
const keys = { valhalla: cacheKey, plain: plainKey, coords }
|
||||
return valhallaOrOsrmDay(waypoints, { profile: valhallaDay, avoiding, keys, signal })
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
@@ -569,27 +616,18 @@ export async function calculateRouteWithLegs(
|
||||
waypoints: waypoints.map((p) => ({ lat: p.lat, lng: p.lng })),
|
||||
}, { signal })
|
||||
if (!route) throw new Error('No route found')
|
||||
const legs: RouteSegment[] = route.legs.map((leg, i): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||
return {
|
||||
mid, from, to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
...(leg.note ? { noteText: leg.note } : {}),
|
||||
}
|
||||
})
|
||||
const legs = route.legs.map((leg, i): RouteSegment => ({
|
||||
...legSegment(leg, i, waypoints),
|
||||
durationText: formatDuration(leg.duration),
|
||||
...(leg.note ? { noteText: leg.note } : {}),
|
||||
}))
|
||||
const result: RouteWithLegs = {
|
||||
coordinates: route.coordinates,
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
legs,
|
||||
...(route.viaPoints.length ? { vias: route.viaPoints } : {}),
|
||||
engine: 'plugin',
|
||||
}
|
||||
rememberRoute(cacheKey, result)
|
||||
return result
|
||||
@@ -625,70 +663,13 @@ async function osrmWithLegs(
|
||||
// The day plan's connectors also show the leg's own duration line.
|
||||
const legs: RouteSegment[] = (route.legs || []).map(
|
||||
(leg: OsrmLeg, i: number): RouteSegment => ({
|
||||
...osrmLegSegment(leg, i, waypoints),
|
||||
...legSegment(leg, i, waypoints),
|
||||
durationText: formatDuration(leg.duration),
|
||||
})
|
||||
)
|
||||
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
|
||||
* spoke — same leg list, same formatted text, same snap positions. What it adds is
|
||||
* `avoidance`: what was asked for against what the road actually turned out to be, which
|
||||
* is the only honest way to label a weighting that can fail.
|
||||
*
|
||||
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
|
||||
* and the day stays drawn.
|
||||
*/
|
||||
async function routeAvoidingWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
avoid: AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteWithLegs | null> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: waypoints.length,
|
||||
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
|
||||
}
|
||||
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
|
||||
// Counted by hand because the adapter speaks to a different engine with a different
|
||||
// verb, and the counter exists to answer "how much routing does this instance do" —
|
||||
// an engine it cannot see is exactly the traffic that would go missing from it.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run) return null
|
||||
|
||||
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distance: run.total.distance,
|
||||
duration: run.total.duration,
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
hasFerry: run.total.hasFerry,
|
||||
}
|
||||
return { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}), engine: 'osrm' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -722,75 +703,6 @@ function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] |
|
||||
return out
|
||||
}
|
||||
|
||||
function getDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
}
|
||||
|
||||
function formatRouteDistance(meters: number): string {
|
||||
const unit = getDistanceUnit()
|
||||
if (unit === 'metric' && meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return formatDistance(meters / 1000, unit)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) {
|
||||
return `${h} h ${m} min`
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as a router offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: AvoidClass
|
||||
/**
|
||||
* Which engine priced this route. Absent means OSRM, which is what answers every route
|
||||
* of a trip that avoids nothing, except the avoidance offer on a default install.
|
||||
*
|
||||
* It exists because the engines do not agree on speed: measured over twelve European
|
||||
* legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the
|
||||
* sign depends on the region: Valhalla is faster on Spanish autovía and slower through
|
||||
* a city. So a figure from one of them subtracted from a figure from the other is not a
|
||||
* difference in driving time, it is the gap between two speed models, and the reader
|
||||
* has no way of telling the two apart.
|
||||
*/
|
||||
engine?: RouteEngine
|
||||
/** Whether the way crosses by ferry, where the engine that drew it says so. */
|
||||
hasFerry?: boolean
|
||||
}
|
||||
|
||||
/** What two routes are compared by: their line, and their figures where there is none. */
|
||||
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route — while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*
|
||||
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM
|
||||
* does carry a ferry class, but on a leg where a ferry is the only crossing it answers
|
||||
* NoRoute rather than the long way round, which is not an alternative anybody can use.
|
||||
* Valhalla weights it instead and drives around, which is the whole point.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
|
||||
|
||||
|
||||
/**
|
||||
* Hosts that have already answered "I do not do exclude", by base URL.
|
||||
*
|
||||
@@ -838,49 +750,6 @@ async function routeExcluding(
|
||||
return osrmExcluding(from, to, profile, exclude, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same leg from Valhalla, weighted away from one class, or null.
|
||||
*
|
||||
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
|
||||
* between two points with no untolled connection the answer is still a tolled road.
|
||||
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
|
||||
* OSRM attempt gets its turn.
|
||||
*/
|
||||
async function valhallaExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
if (!valhallaAvailable()) return null
|
||||
const sample = {
|
||||
profile,
|
||||
surface: 'alternatives' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: 2,
|
||||
km: haversineKm(from, to),
|
||||
}
|
||||
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal)
|
||||
// Counted here rather than in the adapter, so the adapter stays a format translator
|
||||
// and every routing request an instance makes still lands in the one counter.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
|
||||
if (!leg || !legAvoids(leg, exclude)) return null
|
||||
return valhallaOffer(leg)
|
||||
}
|
||||
|
||||
/** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */
|
||||
function valhallaOffer(leg: ValhallaLeg): RouteAlternative {
|
||||
return {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
engine: 'valhalla',
|
||||
hasFerry: leg.hasFerry,
|
||||
}
|
||||
}
|
||||
|
||||
/** The same leg from OSRM with the class excluded outright, or null. */
|
||||
async function osrmExcluding(
|
||||
from: Waypoint,
|
||||
@@ -928,20 +797,25 @@ async function osrmExcluding(
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*
|
||||
* `avoid` is the classes the trip weighs away. A driving leg of such a trip is routed by
|
||||
* Valhalla, so its offers come from Valhalla too, weighed the same way, and never from
|
||||
* OSRM: a list holding both engines is what offered a road the rail was not on as the
|
||||
* quicker one, and clicking it changed nothing.
|
||||
* `avoid` is the classes the trip weighs away. A leg Valhalla routes, because it routes
|
||||
* the days or because the trip avoids something, gets its offers from Valhalla too,
|
||||
* weighed the same way, and never from OSRM: a list holding both engines is what offered
|
||||
* a road the rail was not on as the quicker one, and clicking it changed nothing.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3, avoid = [] }: { signal?: AbortSignal; limit?: number; avoid?: readonly AvoidClass[] } = {},
|
||||
{ signal, limit = 3, avoid = [], engine = routeEngineFor(profile, avoid) }: {
|
||||
signal?: AbortSignal
|
||||
limit?: number
|
||||
avoid?: readonly AvoidClass[]
|
||||
/** The engine that drew the leg, where it is known; the planned one otherwise. */
|
||||
engine?: RouteEngine
|
||||
} = {},
|
||||
): Promise<RouteAlternative[]> {
|
||||
const avoiding = avoidedClasses(profile, avoid)
|
||||
const routes = avoiding.length
|
||||
? await alternativesAvoiding(from, to, avoiding, limit, signal)
|
||||
const routes = engine === 'valhalla'
|
||||
? await alternativesFromValhalla(from, to, profile, avoidedClasses(profile, avoid), { limit, signal })
|
||||
: await alternativesFromOsrm(from, to, profile, limit, signal)
|
||||
if (!routes.length) return routes
|
||||
|
||||
@@ -953,54 +827,25 @@ export async function calculateAlternatives(
|
||||
}
|
||||
|
||||
/**
|
||||
* The offers for a leg of a trip that weighs classes away, every one of them Valhalla's.
|
||||
* The offers for a leg of a day Valhalla routes, every one of them Valhalla's.
|
||||
*
|
||||
* Throws when Valhalla has nothing to say, so the picker reports a router that is not
|
||||
* answering. Quietly offering OSRM's roads instead is the very mix this path exists to end.
|
||||
*
|
||||
* One way back gets the question the OSRM path asks too: the same drive with one more
|
||||
* class left out, only of those the trip does not already avoid, and only where the road
|
||||
* that comes back really is without it. Paced like the pieces of a split day, because the
|
||||
* public instance allows one request a second.
|
||||
* A day that weighs classes away has no other engine to ask: this throws when Valhalla has
|
||||
* nothing to say, so the picker reports a router that is not answering. Quietly offering
|
||||
* OSRM's roads instead is the very mix this path exists to end. A day that avoids nothing
|
||||
* went to OSRM whole when Valhalla did not answer it, so its offers do the same.
|
||||
*/
|
||||
async function alternativesAvoiding(
|
||||
async function alternativesFromValhalla(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoiding: AvoidClass[],
|
||||
limit: number,
|
||||
signal?: AbortSignal,
|
||||
{ limit, signal }: { limit: number; signal?: AbortSignal },
|
||||
): Promise<RouteAlternative[]> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'alternatives' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: 2,
|
||||
km: haversineKm(from, to),
|
||||
}
|
||||
const answered = await valhallaAlternates(from, to, 'driving', avoiding, Math.max(0, limit - 1), signal)
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !answered })
|
||||
if (!answered?.length) throw new Error('No route found')
|
||||
|
||||
const routes: RouteAlternative[] = []
|
||||
for (const leg of answered) {
|
||||
const offer = valhallaOffer(leg)
|
||||
if (!routes.some(r => sameRoad(r, offer))) routes.push(offer)
|
||||
}
|
||||
if (routes.length >= 2) return routes
|
||||
|
||||
for (const cls of EXCLUDABLE_CLASSES) {
|
||||
if (avoiding.includes(cls)) continue
|
||||
await valhallaSpacing(signal)
|
||||
if (signal?.aborted) break
|
||||
const run = await valhallaRun([from, to], 'driving', [...avoiding, cls], signal)
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run || !legAvoids(run.total, cls)) continue
|
||||
const detour = valhallaOffer(run.total)
|
||||
if (routes.some(r => sameRoad(r, detour))) continue
|
||||
routes.push({ ...detour, avoids: cls })
|
||||
break
|
||||
}
|
||||
return routes
|
||||
const asking = avoiding.length > 0 || valhallaRoutesDays()
|
||||
const offered = asking ? await valhallaDayAlternatives(from, to, profile, avoiding, { limit, signal }) : null
|
||||
if (offered) return offered
|
||||
if (avoiding.length) throw new Error('No route found')
|
||||
return alternativesFromOsrm(from, to, profile, limit, signal)
|
||||
}
|
||||
|
||||
/** The offers for a leg of a trip that avoids nothing: OSRM's, and one avoidance offer. */
|
||||
|
||||
@@ -100,6 +100,9 @@ describe('TripRouteOverviewPanel', () => {
|
||||
|
||||
expect(screen.getByTestId('trip-overview-unrouted')).toHaveTextContent('map.overview.unrouted')
|
||||
expect(screen.getAllByRole('img', { name: 'map.overview.dayUnrouted' })).toHaveLength(1)
|
||||
// The short figures read as floors, the trip's and the day's, not as measurements (#2876).
|
||||
expect(screen.getAllByText('map.overview.distanceAtLeast')).toHaveLength(2)
|
||||
expect(screen.getByText('140.5 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-010: does not call a leg unrouted while the round is still running', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AlertTriangle, Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapTogglePill } from './MapTogglePill'
|
||||
import { routeModeIcon } from '../Planner/routeModes'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { routeDistanceLabel } from './tripRouteLabels'
|
||||
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
|
||||
@@ -65,7 +65,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
|
||||
{/* Same tier as the label beside it: the two read as one line rather than as a
|
||||
heading with a number stuck under it. */}
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
|
||||
{formatDistance(overview.totalDistance / 1000, unit)}
|
||||
{routeDistanceLabel(overview.totalDistance, unrouted, unit, t)}
|
||||
{/* Still routing: the number is a partial sum, and saying so beats a total
|
||||
that silently grows while you read it. */}
|
||||
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
|
||||
@@ -112,7 +112,7 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
|
||||
/>
|
||||
)}
|
||||
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDistance(day.distance / 1000, unit)}
|
||||
{routeDistanceLabel(day.distance, dayUnrouted, unit, t)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -97,6 +97,8 @@ describe('attachLocationMarker', () => {
|
||||
expect(root.children).toHaveLength(3)
|
||||
// The cone starts hidden — there is no heading until the first fix.
|
||||
expect((root.children[1] as HTMLElement).style.display).toBe('none')
|
||||
// The GL marker class owns `position`; an inline one would drop it into the flow.
|
||||
expect(root.style.position).toBe('')
|
||||
})
|
||||
|
||||
it('FE-COMP-LOCMARKERGL-003: adds the accuracy source and fill layer when the map is already loaded', () => {
|
||||
|
||||
@@ -13,7 +13,9 @@ type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string
|
||||
// and mapbox doesn't get confused about which element to position.
|
||||
function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } {
|
||||
const root = document.createElement('div')
|
||||
root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;'
|
||||
// No inline `position`: the GL marker class keeps the root absolute, which the
|
||||
// children's insets resolve against (see the note on markers in MapViewGL).
|
||||
root.style.cssText = 'width:28px;height:28px;pointer-events:none;'
|
||||
// Accuracy pulse behind the dot
|
||||
const pulse = document.createElement('div')
|
||||
pulse.style.cssText = `
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import L from 'leaflet'
|
||||
import { STACK_RADIUS_PX } from './coincidentPlaces'
|
||||
import { clusterBubbleSize } from './placeClusterLayers'
|
||||
|
||||
/** Up to here the map is an overview, where stops merge to keep the world readable. */
|
||||
export const CLUSTER_UNTIL_ZOOM = 9
|
||||
@@ -66,7 +67,7 @@ export interface ClusterLike {
|
||||
/** A count bubble sized by its count, styled by `.marker-cluster-custom` in index.css. */
|
||||
export function createClusterIcon(cluster: ClusterLike) {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
const size = clusterBubbleSize(count)
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom" style="width:${size}px;height:${size}px;"><span>${count}</span></div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
type ClusterMap,
|
||||
PLACE_CLUSTER_SOURCE_ID,
|
||||
addPlaceClusterLayers,
|
||||
clusterBubbleSize,
|
||||
createClusterBubble,
|
||||
drawClusterBubbles,
|
||||
} from './placeClusterLayers';
|
||||
|
||||
function fakeMap(features: unknown[], expansionZoom: unknown = 11) {
|
||||
const source = { getClusterExpansionZoom: vi.fn(() => expansionZoom) };
|
||||
const map = {
|
||||
addSource: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
getSource: vi.fn((id: string) => (id === PLACE_CLUSTER_SOURCE_ID ? source : undefined)),
|
||||
easeTo: vi.fn(),
|
||||
querySourceFeatures: vi.fn((_id: string, query: { filter: unknown[] }) =>
|
||||
query.filter[0] === 'has' ? features : []
|
||||
),
|
||||
};
|
||||
return { map: map as unknown as ClusterMap & typeof map, source };
|
||||
}
|
||||
|
||||
describe('cluster bubbles', () => {
|
||||
it('FE-COMP-PLACECLUSTER-001: a bubble grows in three steps with its count', () => {
|
||||
expect([1, 9, 10, 49, 50, 500].map(clusterBubbleSize)).toEqual([36, 36, 42, 42, 48, 48]);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-002: the GL bubble is the Leaflet markup inside a bare wrapper the pin can move', () => {
|
||||
const wrapper = createClusterBubble(38);
|
||||
expect(wrapper.className).toBe('');
|
||||
const bubble = wrapper.firstElementChild as HTMLElement;
|
||||
expect(bubble.className).toBe('marker-cluster-custom');
|
||||
expect(bubble.style.width).toBe('42px');
|
||||
expect(bubble.style.height).toBe('42px');
|
||||
expect(bubble.textContent).toBe('38');
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-003: the canvas circle and count stay as an invisible hit area', () => {
|
||||
const { map } = fakeMap([]);
|
||||
addPlaceClusterLayers(map);
|
||||
expect(map.addSource).toHaveBeenCalledWith(PLACE_CLUSTER_SOURCE_ID, expect.objectContaining({ cluster: true }));
|
||||
const layers = map.addLayer.mock.calls.map(([layer]) => layer as { id: string; paint: Record<string, unknown> });
|
||||
expect(layers.find((l) => l.id === 'trip-place-clusters-circle')?.paint['circle-opacity']).toBe(0);
|
||||
expect(layers.find((l) => l.id === 'trip-place-clusters-count')?.paint['text-opacity']).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-004: one bubble per cluster, placed at its centre, that zooms in on a click', () => {
|
||||
const cluster = { properties: { cluster_id: 5, point_count: 12 }, geometry: { coordinates: [139.7, 35.6] } };
|
||||
const { map, source } = fakeMap([
|
||||
cluster,
|
||||
cluster, // the same cluster crossing a tile edge
|
||||
{ properties: { cluster_id: 6 }, geometry: { coordinates: [1, 1] } }, // no count
|
||||
{ properties: { cluster_id: 7, point_count: 3 } }, // no geometry
|
||||
]);
|
||||
const placed: { el: HTMLElement; lng: number; lat: number }[] = [];
|
||||
const pins = drawClusterBubbles(map, (el, lng, lat) => {
|
||||
placed.push({ el, lng, lat });
|
||||
return lng;
|
||||
});
|
||||
|
||||
expect(pins).toEqual([139.7]);
|
||||
expect(placed[0].el.textContent).toBe('12');
|
||||
expect(placed[0].lat).toBe(35.6);
|
||||
|
||||
const onMapClick = vi.fn();
|
||||
document.body.appendChild(placed[0].el);
|
||||
document.body.addEventListener('click', onMapClick);
|
||||
placed[0].el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
document.body.removeEventListener('click', onMapClick);
|
||||
placed[0].el.remove();
|
||||
|
||||
// The click is the bubble's: the map underneath never sees it.
|
||||
expect(onMapClick).not.toHaveBeenCalled();
|
||||
expect(source.getClusterExpansionZoom).toHaveBeenCalledWith(5, expect.any(Function));
|
||||
expect(map.easeTo).toHaveBeenCalledWith({ center: [139.7, 35.6], zoom: 11, duration: 350 });
|
||||
});
|
||||
|
||||
it('FE-COMP-PLACECLUSTER-005: a source that reports nothing draws nothing', () => {
|
||||
const { map } = fakeMap([]);
|
||||
map.querySourceFeatures.mockReturnValue(null as never);
|
||||
expect(drawClusterBubbles(map, () => 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,169 @@
|
||||
*/
|
||||
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
|
||||
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
|
||||
|
||||
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
|
||||
// HTML photo markers *and* cluster them natively, so we feed the place points
|
||||
// into a clustered GeoJSON source. The source decides what is clustered; the
|
||||
// bubbles are drawn as DOM (`drawClusterBubbles`) and the individual rich HTML
|
||||
// markers only for the points the source reports as currently unclustered.
|
||||
// Grouping is always on, matching the Leaflet map's MarkerClusterGroup.
|
||||
export const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters';
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit';
|
||||
|
||||
/**
|
||||
* How eagerly place markers merge into a cluster.
|
||||
*
|
||||
* The overview band the Leaflet map clusters in as well (`CLUSTER_UNTIL_ZOOM` there),
|
||||
* kept as plain numbers rather than shared constants so the GL bundle does not have to
|
||||
* carry Leaflet for them. Above the cutoff supercluster hands every point back on its
|
||||
* own, and the stops that share a coordinate are folded together at the pin level
|
||||
* instead — see the reconcile in MapViewGL.
|
||||
*/
|
||||
const CLUSTER_RADIUS = 20;
|
||||
const CLUSTER_MAX_ZOOM = 8;
|
||||
|
||||
/** The part of either SDK's map the cluster code calls. */
|
||||
export interface ClusterMap {
|
||||
addSource: (id: string, source: object) => void;
|
||||
addLayer: (layer: object) => void;
|
||||
getSource: (id: string) => unknown;
|
||||
easeTo: (options: { center: unknown[]; zoom: number; duration: number }) => void;
|
||||
querySourceFeatures: (id: string, options: { filter: unknown[] }) => ClusterFeature[] | null | undefined;
|
||||
}
|
||||
|
||||
interface ClusterFeature {
|
||||
properties?: { cluster_id?: unknown; point_count?: unknown } | null;
|
||||
geometry?: { coordinates?: unknown };
|
||||
}
|
||||
|
||||
interface ClusterSource {
|
||||
getClusterExpansionZoom?: (id: unknown, done: (err: Error | null, zoom: number) => void) => unknown;
|
||||
}
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
export function addPlaceClusterLayers(map: Pick<ClusterMap, 'addSource' | 'addLayer'>): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
});
|
||||
// Invisible: the bubble the user sees is DOM (createClusterBubble), these two
|
||||
// layers stay as the hit area the track and route layers check a click against.
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: { 'text-opacity': 0 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Eases the map in to the zoom a cluster falls apart at. Both SDKs answer that
|
||||
* question, one through a callback and one through a promise. */
|
||||
export function expandCluster(
|
||||
map: Pick<ClusterMap, 'getSource' | 'easeTo'>,
|
||||
clusterId: unknown,
|
||||
coordinates: unknown
|
||||
): void {
|
||||
if (clusterId == null || !Array.isArray(coordinates)) return;
|
||||
const source = map.getSource(PLACE_CLUSTER_SOURCE_ID) as ClusterSource | undefined;
|
||||
const easeToZoom = (nextZoom: number) => {
|
||||
try {
|
||||
map.easeTo({ center: coordinates, zoom: nextZoom, duration: 350 });
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
try {
|
||||
const maybeZoom = source?.getClusterExpansionZoom?.(clusterId, (err, nextZoom) => {
|
||||
if (!err && typeof nextZoom === 'number') easeToZoom(nextZoom);
|
||||
});
|
||||
if (typeof maybeZoom === 'number') easeToZoom(maybeZoom);
|
||||
else if (maybeZoom instanceof Promise) maybeZoom.then(easeToZoom).catch(() => {});
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts one count bubble on the map per cluster the source currently reports, through
|
||||
* `place` (the caller's pin factory), and returns what `place` handed back. A cluster
|
||||
* crossing a tile edge comes back once per tile, so bubbles are keyed by cluster id.
|
||||
*/
|
||||
export function drawClusterBubbles<T>(map: ClusterMap, place: (el: HTMLElement, lng: number, lat: number) => T): T[] {
|
||||
const pins: T[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
for (const feature of map.querySourceFeatures(PLACE_CLUSTER_SOURCE_ID, { filter: ['has', 'point_count'] }) ?? []) {
|
||||
const clusterId = feature?.properties?.cluster_id;
|
||||
const count = feature?.properties?.point_count;
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
if (typeof count !== 'number' || !Array.isArray(coordinates) || seen.has(clusterId)) continue;
|
||||
seen.add(clusterId);
|
||||
const el = createClusterBubble(count);
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
expandCluster(map, clusterId, coordinates);
|
||||
});
|
||||
pins.push(place(el, coordinates[0], coordinates[1]));
|
||||
}
|
||||
return pins;
|
||||
}
|
||||
|
||||
/** A count bubble's diameter in pixels: both renderers size their bubbles by it. */
|
||||
export function clusterBubbleSize(count: number): number {
|
||||
return count < 10 ? 36 : count < 50 ? 42 : 48;
|
||||
}
|
||||
|
||||
/**
|
||||
* A count bubble as DOM, styled by `.marker-cluster-custom` in index.css.
|
||||
*
|
||||
* The GL map draws its bubbles with this element rather than as canvas text, so they
|
||||
* read in the app's own font and get the same hover the Leaflet map has. A canvas
|
||||
* symbol layer can only use the glyphs the map style ships and knows no `:hover`.
|
||||
* The bubble sits in a bare wrapper because the pin positions its element through
|
||||
* `transform`, which would otherwise override the hover's scale.
|
||||
*/
|
||||
export function createClusterBubble(count: number): HTMLDivElement {
|
||||
const size = clusterBubbleSize(count);
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'marker-cluster-custom';
|
||||
bubble.style.width = `${size}px`;
|
||||
bubble.style.height = `${size}px`;
|
||||
const label = document.createElement('span');
|
||||
label.textContent = String(count);
|
||||
bubble.appendChild(label);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(bubble);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DistanceUnit, RouteSegment, Waypoint } from '../../types';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
|
||||
/** The unit a route's texts are written in, which is why it is part of the route cache key. */
|
||||
export function getDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric';
|
||||
}
|
||||
|
||||
/** A routed distance as the connectors show it: metres below a kilometre, else the user's unit. */
|
||||
export function formatRouteDistance(meters: number): string {
|
||||
const unit = getDistanceUnit();
|
||||
if (unit === 'metric' && meters < 1000) {
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
return formatDistance(meters / 1000, unit);
|
||||
}
|
||||
|
||||
/** A routed time in hours and minutes, the minutes rounded down. */
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) {
|
||||
return `${h} h ${m} min`;
|
||||
}
|
||||
return `${m} min`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The connector data for leg i of a routed chain, which runs from waypoint i to waypoint
|
||||
* i + 1: both ends, the midpoint the label sits on, and the walking time at 5 km/h next to
|
||||
* the time the engine measured. The same for every engine, so a day reads alike whichever
|
||||
* of them drew it.
|
||||
*/
|
||||
export function legSegment(
|
||||
leg: { distance: number; duration: number },
|
||||
i: number,
|
||||
waypoints: Waypoint[]
|
||||
): RouteSegment {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng];
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng];
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { RouteAvoidClass, RouteEngine } from '../../types';
|
||||
|
||||
/** One way of driving a leg, as a router offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][];
|
||||
distance: number;
|
||||
duration: number;
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null;
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: RouteAvoidClass;
|
||||
/**
|
||||
* Which engine priced this route. Absent means OSRM, which answers every route of a
|
||||
* trip that avoids nothing where Valhalla does not route the days, except the
|
||||
* avoidance offer.
|
||||
*
|
||||
* It exists because the engines do not agree on speed: measured over twelve European
|
||||
* legs the per-leg drive times run from 14.7 % under OSRM's to 13.2 % over, and the
|
||||
* sign depends on the region: Valhalla is faster on Spanish autovía and slower through
|
||||
* a city. So a figure from one of them subtracted from a figure from the other is not a
|
||||
* difference in driving time, it is the gap between two speed models, and the reader
|
||||
* has no way of telling the two apart.
|
||||
*/
|
||||
engine?: RouteEngine;
|
||||
/** Whether the way crosses by ferry, where the engine that drew it says so. */
|
||||
hasFerry?: boolean;
|
||||
}
|
||||
|
||||
/** What two routes are compared by: their line, and their figures where there is none. */
|
||||
export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'duration'>;
|
||||
@@ -78,31 +78,61 @@ describe('tripRouteGeometry', () => {
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-005: the deadline gives back what answered and leaves the rest as straight lines', async () => {
|
||||
neverAnswers()
|
||||
// The PDF used to route under an 8 s deadline: with a second between two requests that
|
||||
// is about seven legs, so a month-long trip printed most of its days as straight lines
|
||||
// and a total that looked final, and every export routed a few more days (#2876).
|
||||
it('FE-MAP-TRG-005: a long trip is routed to its last leg, and says how far it has got', async () => {
|
||||
const days = Array.from({ length: 12 }, (_, i) => buildDay({ id: i + 1, day_number: i + 1 }))
|
||||
const assignments: AssignmentsMap = Object.fromEntries(days.map(day => [
|
||||
String(day.id),
|
||||
[at(48 + day.id / 10, 2.35, 0, day.id), at(48 + day.id / 10, 2.45, 1, day.id)],
|
||||
]))
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 8000 })
|
||||
await vi.advanceTimersByTimeAsync(8100)
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
|
||||
setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100)
|
||||
}))
|
||||
const onProgress = vi.fn<(done: number, total: number) => void>()
|
||||
const pending = routeTrip({ ...input, days, assignments }, { profile: 'driving', tripId: 7, onProgress })
|
||||
await vi.advanceTimersByTimeAsync(12 * 1200)
|
||||
const summary = await pending
|
||||
|
||||
// The shape of the trip survives...
|
||||
expect(summary.lines).toHaveLength(2)
|
||||
expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]])
|
||||
// ...and nothing that never answered is counted.
|
||||
expect(summary.totalDistance).toBe(0)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12)
|
||||
expect(summary.unroutedLegs).toBe(0)
|
||||
expect(summary.totalDistance).toBe(120000)
|
||||
expect(onProgress.mock.calls[0]).toEqual([0, 12])
|
||||
expect(onProgress.mock.calls.map(([done]) => done)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
||||
expect(onProgress.mock.calls.every(([, total]) => total === 12)).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-006: a caller\'s own abort cuts the routing short too', async () => {
|
||||
it("FE-MAP-TRG-006: a caller's own abort cuts the routing short, and what never answered is counted", async () => {
|
||||
neverAnswers()
|
||||
const controller = new AbortController()
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7, timeoutMs: 60_000, signal: controller.signal })
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7, signal: controller.signal })
|
||||
controller.abort()
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ totalDistance: 0 })
|
||||
await expect(pending).resolves.toMatchObject({ totalDistance: 0, unroutedLegs: 2 })
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-014: a host that never answers costs one leg its road, not the whole round', async () => {
|
||||
neverAnswers()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1100 + 30_000)
|
||||
const summary = await pending
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
expect(summary.lines).toHaveLength(2)
|
||||
expect(summary.unroutedLegs).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
// The public OSRM hosts allow about one request a second. Every leg of every day at
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface TripOverviewDay {
|
||||
modes: string[]
|
||||
/**
|
||||
* Legs drawn as a straight line because the router gave no road for them: not asked
|
||||
* yet while the round is running, refused or cut off by the deadline once it is over.
|
||||
* yet while the round is running, refused, timed out or skipped by the caller once it is over.
|
||||
* Those legs add nothing to `distance`, so a day with any of them reads too short.
|
||||
*/
|
||||
unroutedLegs?: number
|
||||
@@ -56,7 +56,7 @@ export interface TripRouteSummary {
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string; waypoints: { lat: number; lng: number }[]; stopAt: number[] }
|
||||
|
||||
/** The day inputs plus the road trip's vias, which bend a day the way it was picked there (#2580). */
|
||||
interface TripRouteInputs extends DayRouteInputs {
|
||||
export interface TripRouteInputs extends DayRouteInputs {
|
||||
/** The stored day lists the vias are anchored in, hidden rows included. Absent: `assignments`. */
|
||||
storedAssignments?: AssignmentsMap
|
||||
viasByDay?: Record<number, readonly RoadtripVia[]>
|
||||
@@ -187,6 +187,15 @@ 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
|
||||
/**
|
||||
* The longest one leg may take, its rate-limit retries included. The OSRM request has no
|
||||
* deadline of its own, and a round that waits for every leg must not hang on a host that
|
||||
* accepted the connection and never answered.
|
||||
*/
|
||||
const LEG_TIMEOUT_MS = 30_000
|
||||
|
||||
/** How far a routing round has got: legs answered or given up on, out of all it asks. */
|
||||
export type TripRouteProgress = (done: number, total: number) => void
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
@@ -206,13 +215,15 @@ const CACHE_HIT_MS = 60
|
||||
*/
|
||||
export async function routeTripLegs(
|
||||
plan: TripRoutePlanDay[],
|
||||
{ tripId, signal, onAnswer, unit }: {
|
||||
{ tripId, signal, onAnswer, onProgress, unit }: {
|
||||
tripId: number | null
|
||||
signal?: AbortSignal
|
||||
/** The unit a leg bent through vias has its text written again in. */
|
||||
unit?: DistanceUnit
|
||||
/** Called after each answer, for a caller that wants to draw as they arrive. */
|
||||
onAnswer?: (routed: Answer[][][]) => void
|
||||
/** Called once before the first leg and again after every leg, answered or not. */
|
||||
onProgress?: TripRouteProgress
|
||||
},
|
||||
): Promise<Answer[][][]> {
|
||||
const routed = emptyAnswers(plan)
|
||||
@@ -222,25 +233,34 @@ export async function routeTripLegs(
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
if (signal?.aborted) return
|
||||
const leg = new AbortController()
|
||||
const abortLeg = () => leg.abort()
|
||||
const timer = setTimeout(abortLeg, LEG_TIMEOUT_MS)
|
||||
signal?.addEventListener('abort', abortLeg, { once: true })
|
||||
try {
|
||||
const answer = await calculateRouteWithRetries(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
{ signal: leg.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.
|
||||
// Refused for good, timed out or aborted: the leg keeps its straight line.
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', abortLeg)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onProgress?.(0, tasks.length)
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (signal?.aborted) break
|
||||
const startedAt = performance.now()
|
||||
await tasks[i]()
|
||||
onProgress?.(i + 1, tasks.length)
|
||||
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
|
||||
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, signal)
|
||||
}
|
||||
@@ -250,30 +270,25 @@ export async function routeTripLegs(
|
||||
/**
|
||||
* The whole thing end to end, for a caller with no need to draw the intermediate state.
|
||||
*
|
||||
* `timeoutMs` stops waiting rather than stopping the work: legs that answered are kept
|
||||
* and the rest stay straight lines, which is what makes this usable from an export
|
||||
* somebody is standing in front of. A month-long trip with a cold cache is a few hundred
|
||||
* requests against a shared router, and no document is worth that wait.
|
||||
* Waits for every leg: a cold month-long trip is a few minutes against a shared router,
|
||||
* which is why `onProgress` exists, and the pacing, the retry limit and the per-leg
|
||||
* timeout keep that wait finite. A document printed after a deadline looked finished
|
||||
* while most of its days were straight lines (#2876). The caller's `signal` is the only
|
||||
* way to stop early; the legs that answered by then are kept, the rest stay straight
|
||||
* lines and are counted in `unroutedLegs`.
|
||||
*/
|
||||
export async function routeTrip(
|
||||
input: TripRouteInputs,
|
||||
{ profile, tripId, signal, timeoutMs, unit }: {
|
||||
{ profile, tripId, signal, unit, onProgress }: {
|
||||
profile: RouteProfileKey
|
||||
tripId: number | null
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
unit?: DistanceUnit
|
||||
onProgress?: TripRouteProgress
|
||||
},
|
||||
): Promise<TripRouteSummary> {
|
||||
const plan = planTripRoute(input, profile)
|
||||
if (!plan.length) return summariseTripRoute([])
|
||||
const deadline = timeoutMs ? new AbortController() : null
|
||||
const timer = deadline ? setTimeout(() => deadline.abort(), timeoutMs) : null
|
||||
if (deadline && signal) signal.addEventListener('abort', () => deadline.abort(), { once: true })
|
||||
try {
|
||||
const routed = await routeTripLegs(plan, { tripId, signal: deadline?.signal ?? signal, unit })
|
||||
return summariseTripRoute(assembleTripRoute(plan, routed))
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
const routed = await routeTripLegs(plan, { tripId, signal, unit, onProgress })
|
||||
return summariseTripRoute(assembleTripRoute(plan, routed))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DistanceUnit } from '../../types';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* A day's or the trip's distance as the overview panel and the PDF print it.
|
||||
*
|
||||
* A leg the router gave no road for is drawn straight and adds nothing to the sum, so a
|
||||
* figure with such legs in it is a floor, and a figure made of nothing but such legs is
|
||||
* no figure at all. "0 m" for a day of driving read as a measurement (#2876). One wording
|
||||
* for the screen and the document, so the two cannot drift apart again.
|
||||
*/
|
||||
export function routeDistanceLabel(meters: number, unroutedLegs: number, unit: DistanceUnit, t: Translate): string {
|
||||
const distance = formatDistance(meters / 1000, unit);
|
||||
if (unroutedLegs <= 0) return distance;
|
||||
return meters > 0 ? t('map.overview.distanceAtLeast', { distance }) : t('map.overview.notRouted');
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { encodePolyline6 } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { Settings, Waypoint } from '../../types';
|
||||
import { calculateAlternatives, calculateRouteWithLegs, routeEngineFor } from './RouteCalculator';
|
||||
import { resetValhallaTurns } from './valhallaRoute';
|
||||
|
||||
const RELAY = '/api/routing/valhalla/route';
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route';
|
||||
const OWN_VALHALLA = 'https://valhalla.example.org';
|
||||
const OSRM = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
} as const;
|
||||
const PROFILES = [
|
||||
['driving', 'auto'],
|
||||
['walking', 'pedestrian'],
|
||||
['cycling', 'bicycle'],
|
||||
] as const;
|
||||
|
||||
type Point = { lat: number; lon: number };
|
||||
type RouteBody = { locations: Point[]; costing: string; costing_options: unknown; alternates?: number };
|
||||
|
||||
let seed = 0;
|
||||
/** Points no other test asked for, so neither route cache answers in their place. */
|
||||
function freshDay(count: number): Waypoint[] {
|
||||
seed += 1;
|
||||
return Array.from({ length: count }, (_, i) => ({ lat: 40 + seed / 100, lng: 5 + i / 100 }));
|
||||
}
|
||||
|
||||
/** Valhalla's trip through some points: one leg per pair, two kilometres and five minutes each. */
|
||||
function valhallaTrip(points: Point[]) {
|
||||
const legs = points.slice(1).map((to, i) => ({
|
||||
shape: encodePolyline6([points[i], to]),
|
||||
summary: { length: 2, time: 300, has_toll: false, has_highway: false, has_ferry: false },
|
||||
}));
|
||||
return { legs, summary: { length: 2 * legs.length, time: 300 * legs.length } };
|
||||
}
|
||||
|
||||
/** OSRM's answer for a request's coordinates: one leg per pair, a kilometre and two minutes each. */
|
||||
function osrmAnswer(coords: string) {
|
||||
const points = coords.split(';').map((pair) => pair.split(',').map(Number));
|
||||
const legs = points.slice(1).map(() => ({ distance: 1000, duration: 120 }));
|
||||
return {
|
||||
code: 'Ok',
|
||||
routes: [{ geometry: { coordinates: points }, distance: 1000 * legs.length, duration: 120 * legs.length, legs }],
|
||||
waypoints: points.map((location) => ({ location, distance: 0 })),
|
||||
};
|
||||
}
|
||||
|
||||
interface Traffic {
|
||||
relay: RouteBody[];
|
||||
fossgis: RouteBody[];
|
||||
own: RouteBody[];
|
||||
osrm: string[];
|
||||
}
|
||||
|
||||
const answered = (body: RouteBody) => HttpResponse.json({ trip: valhallaTrip(body.locations) });
|
||||
|
||||
/** Every routing host answering, the relay as `relay` says; what each was asked is recorded. */
|
||||
function listen(relay: (body: RouteBody) => Response = answered): Traffic {
|
||||
const traffic: Traffic = { relay: [], fossgis: [], own: [], osrm: [] };
|
||||
const valhalla =
|
||||
(asked: RouteBody[], answer: (body: RouteBody) => Response) =>
|
||||
async ({ request }: { request: Request }) => {
|
||||
const body = (await request.json()) as RouteBody;
|
||||
asked.push(body);
|
||||
return answer(body);
|
||||
};
|
||||
server.use(
|
||||
http.post(RELAY, valhalla(traffic.relay, relay)),
|
||||
http.post(FOSSGIS_VALHALLA, valhalla(traffic.fossgis, answered)),
|
||||
http.post(`${OWN_VALHALLA}/route`, valhalla(traffic.own, answered)),
|
||||
...Object.values(OSRM).map((base) =>
|
||||
http.get(`${base}/:coords`, ({ request, params }) => {
|
||||
traffic.osrm.push(request.url);
|
||||
return HttpResponse.json(osrmAnswer(String(params.coords)));
|
||||
})
|
||||
)
|
||||
);
|
||||
return traffic;
|
||||
}
|
||||
|
||||
const choose = (patch: Partial<Settings>) =>
|
||||
useSettingsStore.setState((st) => ({ settings: { ...st.settings, ...patch } }));
|
||||
|
||||
afterEach(() => {
|
||||
choose({ routing_valhalla_provider: '', valhalla_base_url: '', routing_base_url: '' });
|
||||
resetValhallaTurns();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('which engine routes a day', () => {
|
||||
it.each(PROFILES)('VDAY-ENGINE-001: the TREK services and an own Valhalla route every %s day', (profile) => {
|
||||
choose({ routing_valhalla_provider: 'trek' });
|
||||
expect([routeEngineFor(profile, []), routeEngineFor(profile, ['toll'])]).toEqual(['valhalla', 'valhalla']);
|
||||
choose({ routing_valhalla_provider: '', valhalla_base_url: OWN_VALHALLA });
|
||||
expect([routeEngineFor(profile, []), routeEngineFor(profile, ['toll'])]).toEqual(['valhalla', 'valhalla']);
|
||||
choose({ routing_valhalla_provider: 'custom' });
|
||||
expect(routeEngineFor(profile, [])).toBe('valhalla');
|
||||
});
|
||||
|
||||
it.each(PROFILES)('VDAY-ENGINE-002: with FOSSGIS a %s day goes to Valhalla only to avoid by car', (profile) => {
|
||||
choose({ routing_valhalla_provider: 'fossgis' });
|
||||
expect(routeEngineFor(profile, [])).toBe('osrm');
|
||||
expect(routeEngineFor(profile, ['toll'])).toBe(profile === 'driving' ? 'valhalla' : 'osrm');
|
||||
});
|
||||
|
||||
it('VDAY-ENGINE-003: an own OSRM alone keeps every day, and a plugin its own', () => {
|
||||
choose({ routing_base_url: 'https://osrm.example.org' });
|
||||
expect(PROFILES.map(([profile]) => routeEngineFor(profile, ['toll']))).toEqual(['osrm', 'osrm', 'osrm']);
|
||||
choose({ routing_valhalla_provider: 'trek' });
|
||||
expect(routeEngineFor('plugin:ev/fast', [])).toBe('plugin');
|
||||
});
|
||||
|
||||
it('VDAY-ENGINE-004: while the relay rests the days stay planned with Valhalla, and OSRM draws them and says so', async () => {
|
||||
const traffic = listen(() => HttpResponse.json({ error: 'resting' }, { status: 503 }));
|
||||
expect((await calculateRouteWithLegs(freshDay(2), { profile: 'driving' })).engine).toBe('osrm');
|
||||
expect((await calculateRouteWithLegs(freshDay(2), { profile: 'driving' })).engine).toBe('osrm');
|
||||
expect(routeEngineFor('driving', [])).toBe('valhalla');
|
||||
expect([traffic.relay.length, traffic.osrm.length]).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a day where Valhalla routes the days', () => {
|
||||
it.each(PROFILES)('VDAY-001: a %s day goes to the relay as %s, OSRM never asked', async (profile, costing) => {
|
||||
const traffic = listen();
|
||||
const route = await calculateRouteWithLegs(freshDay(3), { profile });
|
||||
|
||||
expect(traffic.relay.map((body) => [body.costing, body.costing_options, body.locations.length])).toEqual([
|
||||
[costing, { [costing]: {} }, 3],
|
||||
]);
|
||||
expect(traffic.osrm).toEqual([]);
|
||||
expect(traffic.fossgis).toEqual([]);
|
||||
expect([route.distance, route.duration, route.legs.length, route.snapped?.length]).toEqual([4000, 600, 2, 3]);
|
||||
expect(route.legs[0]).toMatchObject({ distanceText: '2 km', durationText: '5 min' });
|
||||
expect(route).not.toHaveProperty('avoidance');
|
||||
expect(route.engine).toBe('valhalla');
|
||||
});
|
||||
|
||||
it('VDAY-002: the relay takes twenty locations a request, an own Valhalla too', async () => {
|
||||
let traffic = listen();
|
||||
await calculateRouteWithLegs(freshDay(12), { profile: 'driving' });
|
||||
expect(traffic.relay.map((body) => body.locations.length)).toEqual([12]);
|
||||
|
||||
choose({ valhalla_base_url: OWN_VALHALLA });
|
||||
traffic = listen();
|
||||
const route = await calculateRouteWithLegs(freshDay(25), { profile: 'cycling' });
|
||||
const asked = traffic.own.map((body) => body.locations);
|
||||
expect(asked.map((locations) => locations.length)).toEqual([20, 6]);
|
||||
expect(asked[1][0]).toEqual(asked[0][19]);
|
||||
expect(route.legs).toHaveLength(24);
|
||||
expect(traffic.relay).toEqual([]);
|
||||
});
|
||||
|
||||
it('VDAY-003: where the relay cannot answer, OSRM routes the whole day and FOSSGIS is never asked', async () => {
|
||||
const refusals: Array<() => Response> = [
|
||||
() => HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
() => HttpResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
||||
() => HttpResponse.json({ error: 'Too large' }, { status: 413 }),
|
||||
() => HttpResponse.json({ error: 'Too many routing requests' }, { status: 429 }),
|
||||
() => HttpResponse.json({ error: 'down' }, { status: 500 }),
|
||||
() => HttpResponse.json({ error: 'The TREK routing service is resting' }, { status: 503 }),
|
||||
() => HttpResponse.json({ error: 'Expected number, received string' }, { status: 400 }),
|
||||
() => HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 }),
|
||||
() => HttpResponse.error(),
|
||||
];
|
||||
for (const refusal of refusals) {
|
||||
resetValhallaTurns();
|
||||
const traffic = listen(refusal);
|
||||
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'walking' });
|
||||
expect([route.distance, route.legs.length]).toEqual([2000, 2]);
|
||||
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([1, 0, 1]);
|
||||
expect(traffic.osrm[0]).toContain(OSRM.walking);
|
||||
expect(route).not.toHaveProperty('avoidance');
|
||||
expect(route.engine).toBe('osrm');
|
||||
}
|
||||
});
|
||||
|
||||
it('VDAY-004: a relay that does not answer in time hands the day to OSRM as well', async () => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
||||
vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) =>
|
||||
ms === 12_000 ? AbortSignal.abort(new DOMException('signal timed out', 'TimeoutError')) : timeout(ms)
|
||||
);
|
||||
const traffic = listen();
|
||||
const route = await calculateRouteWithLegs(freshDay(2), { profile: 'driving' });
|
||||
expect(route.distance).toBe(1000);
|
||||
expect([traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('VDAY-004c: a day the relay ran out of time on goes to OSRM alone, and the next day asks the relay again', async () => {
|
||||
let slow = true;
|
||||
const traffic = listen((body) =>
|
||||
slow
|
||||
? HttpResponse.json({ error: 'The TREK routing service took too long for this route' }, { status: 504 })
|
||||
: answered(body)
|
||||
);
|
||||
const fallen = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
|
||||
expect([fallen.distance, fallen.engine]).toEqual([2000, 'osrm']);
|
||||
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([1, 0, 1]);
|
||||
|
||||
slow = false;
|
||||
const next = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
|
||||
expect([next.distance, next.engine]).toEqual([4000, 'valhalla']);
|
||||
expect([traffic.relay.length, traffic.osrm.length]).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('VDAY-004b: an own Valhalla that does not answer in time hands the day to OSRM without a second wait', async () => {
|
||||
choose({ valhalla_base_url: OWN_VALHALLA });
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal);
|
||||
const deadlines = vi
|
||||
.spyOn(AbortSignal, 'timeout')
|
||||
.mockImplementation((ms) =>
|
||||
ms === 12_000 ? AbortSignal.abort(new DOMException('signal timed out', 'TimeoutError')) : timeout(ms)
|
||||
);
|
||||
const traffic = listen();
|
||||
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
|
||||
expect([route.distance, route.engine]).toEqual([2000, 'osrm']);
|
||||
expect(deadlines.mock.calls.filter(([ms]) => ms === 12_000)).toHaveLength(1);
|
||||
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
it('VDAY-005: an own Valhalla that cannot answer leaves the day to OSRM', async () => {
|
||||
choose({ valhalla_base_url: OWN_VALHALLA });
|
||||
const traffic = listen();
|
||||
server.use(http.post(`${OWN_VALHALLA}/route`, () => HttpResponse.json({ error: 'down' }, { status: 502 })));
|
||||
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'driving' });
|
||||
expect(route.distance).toBe(2000);
|
||||
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
it('VDAY-006: a day that avoids something keeps FOSSGIS behind the relay and its ten locations', async () => {
|
||||
const traffic = listen(() => HttpResponse.json({ error: 'resting' }, { status: 503 }));
|
||||
const route = await calculateRouteWithLegs(freshDay(12), { profile: 'driving', avoid: ['toll'] });
|
||||
expect(traffic.relay).toHaveLength(1);
|
||||
expect(traffic.fossgis.map((body) => body.locations.length)).toEqual([10, 3]);
|
||||
expect(traffic.osrm).toEqual([]);
|
||||
expect(route.avoidance).toEqual({ asked: ['toll'], achieved: ['toll'] });
|
||||
});
|
||||
|
||||
it('VDAY-007: with FOSSGIS chosen, a day that avoids nothing is OSRM’s as before', async () => {
|
||||
choose({ routing_valhalla_provider: 'fossgis' });
|
||||
const traffic = listen();
|
||||
const route = await calculateRouteWithLegs(freshDay(3), { profile: 'cycling' });
|
||||
expect(route.distance).toBe(2000);
|
||||
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([0, 0, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the route cache keeps the engine apart', () => {
|
||||
it('VDAY-CACHE-001: a day kept from one engine is not handed out as the other’s', async () => {
|
||||
const points = freshDay(3);
|
||||
choose({ routing_valhalla_provider: 'trek' });
|
||||
const traffic = listen();
|
||||
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
|
||||
|
||||
choose({ routing_valhalla_provider: 'fossgis' });
|
||||
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(2000);
|
||||
|
||||
choose({ routing_valhalla_provider: 'trek' });
|
||||
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
|
||||
expect([traffic.relay.length, traffic.osrm.length]).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('VDAY-CACHE-002: OSRM standing in is not filed as the Valhalla day, so the relay is asked again once it is back', async () => {
|
||||
const points = freshDay(3);
|
||||
let up = false;
|
||||
const traffic = listen((body) => (up ? answered(body) : HttpResponse.json({ error: 'down' }, { status: 503 })));
|
||||
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(2000);
|
||||
|
||||
up = true;
|
||||
resetValhallaTurns();
|
||||
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
|
||||
expect([traffic.relay.length, traffic.osrm.length]).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the route cache while the relay rests', () => {
|
||||
it('VDAY-CACHE-003: a day kept from Valhalla is still found while the relay rests, as it is offline', async () => {
|
||||
const points = freshDay(3);
|
||||
let up = true;
|
||||
const traffic = listen((body) => (up ? answered(body) : HttpResponse.json({ error: 'down' }, { status: 503 })));
|
||||
expect((await calculateRouteWithLegs(points, { profile: 'driving' })).distance).toBe(4000);
|
||||
|
||||
up = false;
|
||||
await calculateRouteWithLegs(freshDay(2), { profile: 'driving' });
|
||||
const kept = await calculateRouteWithLegs(points, { profile: 'driving' });
|
||||
expect([kept.distance, kept.engine]).toEqual([4000, 'valhalla']);
|
||||
expect([traffic.relay.length, traffic.osrm.length]).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
it('VDAY-CACHE-004: a day that avoids something keeps its key, whichever Valhalla answered it', async () => {
|
||||
const points = freshDay(3);
|
||||
const traffic = listen();
|
||||
await calculateRouteWithLegs(points, { profile: 'driving', avoid: ['toll'] });
|
||||
choose({ routing_valhalla_provider: 'fossgis' });
|
||||
const kept = await calculateRouteWithLegs(points, { profile: 'driving', avoid: ['toll'] });
|
||||
expect(kept.engine).toBe('valhalla');
|
||||
expect([traffic.relay.length, traffic.fossgis.length, traffic.osrm.length]).toEqual([1, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('other ways for a leg Valhalla routes', () => {
|
||||
const from = { lat: 47, lng: 9 };
|
||||
const to = { lat: 47, lng: 9.2 };
|
||||
|
||||
it('VDAY-ALT-001: the offers come from the relay, weighed like the day, and OSRM is not asked', async () => {
|
||||
const traffic = listen((body) => {
|
||||
const detour = [body.locations[0], { lat: 47.3, lon: 9.1 }, body.locations[1]];
|
||||
return HttpResponse.json({ trip: valhallaTrip(body.locations), alternates: [{ trip: valhallaTrip(detour) }] });
|
||||
});
|
||||
const routes = await calculateAlternatives(from, to, 'driving');
|
||||
expect(traffic.relay.map((body) => [body.costing, body.alternates])).toEqual([['auto', 2]]);
|
||||
expect(routes.map((route) => route.engine)).toEqual(['valhalla', 'valhalla']);
|
||||
expect(routes[1].divergence).not.toBeNull();
|
||||
expect(traffic.osrm).toEqual([]);
|
||||
});
|
||||
|
||||
it('VDAY-ALT-002: where the relay cannot answer, the offers are OSRM’s like the day', async () => {
|
||||
const traffic = listen(() => HttpResponse.json({ error: 'resting' }, { status: 503 }));
|
||||
const routes = await calculateAlternatives(from, to, 'walking');
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0].engine).toBeUndefined();
|
||||
expect(traffic.fossgis).toEqual([]);
|
||||
expect(traffic.osrm[0]).toContain('alternatives=3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { sameRoad } from '@trek/shared';
|
||||
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { RouteSegment, RouteWithLegs, Waypoint } from '../../types';
|
||||
import { haversineKm } from '../../utils/geo';
|
||||
import { formatDuration, legSegment } from './routeText';
|
||||
import type { RouteAlternative } from './routeTypes';
|
||||
import { countRoute } from './routeUsageCounter';
|
||||
import {
|
||||
legAvoids,
|
||||
valhallaAvailable,
|
||||
valhallaDayAlternates,
|
||||
valhallaDayRun,
|
||||
valhallaRouteAvoiding,
|
||||
valhallaSpacing,
|
||||
type AvoidClass,
|
||||
type ValhallaLeg,
|
||||
} from './valhallaRoute';
|
||||
|
||||
type Profile = 'driving' | 'walking' | 'cycling';
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route, while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*
|
||||
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path. OSRM does carry
|
||||
* a ferry class, but on a leg where a ferry is the only crossing it answers NoRoute
|
||||
* rather than the long way round, which is not an alternative anybody can use. Valhalla
|
||||
* weights it instead and drives around, which is the whole point.
|
||||
*/
|
||||
export const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const;
|
||||
|
||||
/**
|
||||
* What the usage counter is told about a question to this engine. Counted by hand
|
||||
* because it speaks to a different engine with a different verb, and the counter exists
|
||||
* to answer "how much routing does this instance do": an engine it cannot see is exactly
|
||||
* the traffic that would go missing from it.
|
||||
*/
|
||||
function usage(profile: Profile, surface: 'legs' | 'alternatives', points: readonly Waypoint[]) {
|
||||
let km = 0;
|
||||
for (let i = 1; i < points.length; i++) km += haversineKm(points[i - 1], points[i]);
|
||||
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim();
|
||||
return { profile, surface, selfHosted, waypoints: points.length, km };
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole day from Valhalla, shaped exactly like the OSRM answer so the callers never
|
||||
* branch on which engine spoke: same leg list, same formatted text, same snap positions.
|
||||
*
|
||||
* A day that weighs classes away adds `avoidance`: what was asked for against what the
|
||||
* road actually turned out to be, which is the only honest way to label a weighting that
|
||||
* can fail. A day that avoids nothing has no such question and leaves it out.
|
||||
*
|
||||
* Null rather than a throw when it cannot answer, so the caller routes the whole day
|
||||
* with OSRM and the day stays drawn.
|
||||
*/
|
||||
export async function valhallaDayRoute(
|
||||
waypoints: Waypoint[],
|
||||
profile: Profile,
|
||||
avoid: AvoidClass[],
|
||||
signal?: AbortSignal
|
||||
): Promise<RouteWithLegs | null> {
|
||||
const sample = usage(profile, 'legs', waypoints);
|
||||
const run = await valhallaDayRun(waypoints, profile, avoid, signal);
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run });
|
||||
if (!run) return null;
|
||||
|
||||
const legs = run.legs.map((leg, i): RouteSegment => ({
|
||||
...legSegment(leg, i, waypoints),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}));
|
||||
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distance: run.total.distance,
|
||||
duration: run.total.duration,
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
...(avoid.length
|
||||
? { avoidance: { asked: avoid, achieved: avoid.filter((cls) => legAvoids(run.total, cls)) } }
|
||||
: {}),
|
||||
hasFerry: run.total.hasFerry,
|
||||
engine: 'valhalla',
|
||||
};
|
||||
}
|
||||
|
||||
/** A Valhalla leg as an offer, carrying the engine that priced it and what it crosses. */
|
||||
function valhallaOffer(leg: ValhallaLeg): RouteAlternative {
|
||||
return {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
engine: 'valhalla',
|
||||
hasFerry: leg.hasFerry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same leg from Valhalla, weighted away from one class, or null.
|
||||
*
|
||||
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
|
||||
* between two points with no untolled connection the answer is still a tolled road.
|
||||
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
|
||||
* OSRM attempt gets its turn.
|
||||
*/
|
||||
export async function valhallaExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: Profile,
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal
|
||||
): Promise<RouteAlternative | null> {
|
||||
if (!valhallaAvailable()) return null;
|
||||
const sample = usage(profile, 'alternatives', [from, to]);
|
||||
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal);
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !leg });
|
||||
if (!leg || !legAvoids(leg, exclude)) return null;
|
||||
return valhallaOffer(leg);
|
||||
}
|
||||
|
||||
/**
|
||||
* The offers for one leg of a day Valhalla routes, every one of them Valhalla's and
|
||||
* weighed the way the day was, or null when it has nothing to say. Quietly offering
|
||||
* OSRM's roads beside a Valhalla day is the mix that offered a road the rail was not on
|
||||
* as the quicker one, and clicking it changed nothing.
|
||||
*
|
||||
* Where the router offers a single way, a driving leg gets the question the OSRM path
|
||||
* asks too: the same drive with one more class left out, only of those the day does not
|
||||
* already avoid, and only where the road that comes back really is without it. Paced
|
||||
* like the pieces of a split day, because the public instance allows one request a second.
|
||||
*/
|
||||
export async function valhallaDayAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: Profile,
|
||||
avoiding: AvoidClass[],
|
||||
{ limit, signal }: { limit: number; signal?: AbortSignal }
|
||||
): Promise<RouteAlternative[] | null> {
|
||||
const sample = usage(profile, 'alternatives', [from, to]);
|
||||
const answered = await valhallaDayAlternates(from, to, profile, avoiding, { count: Math.max(0, limit - 1), signal });
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !answered });
|
||||
if (!answered?.length) return null;
|
||||
|
||||
const routes: RouteAlternative[] = [];
|
||||
for (const leg of answered) {
|
||||
const offer = valhallaOffer(leg);
|
||||
if (!routes.some((r) => sameRoad(r, offer))) routes.push(offer);
|
||||
}
|
||||
if (routes.length >= 2 || profile !== 'driving') return routes;
|
||||
|
||||
for (const cls of EXCLUDABLE_CLASSES) {
|
||||
if (avoiding.includes(cls)) continue;
|
||||
await valhallaSpacing(signal);
|
||||
if (signal?.aborted) break;
|
||||
const run = await valhallaDayRun([from, to], 'driving', [...avoiding, cls], signal);
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run });
|
||||
if (!run || !legAvoids(run.total, cls)) continue;
|
||||
const detour = valhallaOffer(run.total);
|
||||
if (routes.some((r) => sameRoad(r, detour))) continue;
|
||||
routes.push({ ...detour, avoids: cls });
|
||||
break;
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { FerrySpan } from './valhallaRoute';
|
||||
|
||||
/** One step of a Valhalla leg, with the fields the ferry spans are read from. */
|
||||
export interface ValhallaManeuver {
|
||||
type?: number;
|
||||
ferry?: boolean;
|
||||
begin_shape_index?: number;
|
||||
end_shape_index?: number;
|
||||
time?: number;
|
||||
length?: number;
|
||||
}
|
||||
|
||||
/** Valhalla's maneuver type for boarding a ferry; the maneuver also carries `ferry: true` for its crossing. */
|
||||
const FERRY_ENTER = 28;
|
||||
|
||||
/**
|
||||
* CW-12: the stretches of a leg the router takes by boat, read off its
|
||||
* maneuvers: a crossing is the maneuver that boards the ferry, from the point
|
||||
* it starts at to the one it ends at, with its own length and time. Measured
|
||||
* on Venice, the San Tomà to Ca' Rezzonico line came back as one maneuver of
|
||||
* type 28, ferry true, 1.236 km and 745 s over shape points 24 to 46, then the
|
||||
* walk off it. Crossings that touch are one. A leg the summary says has a
|
||||
* ferry but whose maneuvers do not say where is taken whole, without a time:
|
||||
* the crossing is somewhere on it, and none of it is counted as walked.
|
||||
*/
|
||||
export function ferrySpansOf(maneuvers: ValhallaManeuver[] | undefined, points: number): FerrySpan[] {
|
||||
const spans: FerrySpan[] = [];
|
||||
for (const maneuver of maneuvers ?? []) {
|
||||
if (maneuver.ferry !== true && maneuver.type !== FERRY_ENTER) continue;
|
||||
const from = Number(maneuver.begin_shape_index);
|
||||
const to = Number(maneuver.end_shape_index);
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to <= from || to >= points) continue;
|
||||
const metres = Number.isFinite(maneuver.length) ? Number(maneuver.length) * 1000 : 0;
|
||||
const seconds = Number.isFinite(maneuver.time) ? Number(maneuver.time) : null;
|
||||
const previous = spans[spans.length - 1];
|
||||
if (previous && previous.to === from) {
|
||||
previous.to = to;
|
||||
previous.metres += metres;
|
||||
previous.seconds = previous.seconds === null || seconds === null ? null : previous.seconds + seconds;
|
||||
} else {
|
||||
spans.push({ from, to, metres, seconds });
|
||||
}
|
||||
}
|
||||
return spans.length || points < 2 ? spans : [{ from: 0, to: points - 1, metres: 0, seconds: null }];
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaRunOnFoot, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
/** The relay to the TREK services' Valhalla, the base asked when nothing else is configured. */
|
||||
const TREK_VALHALLA = '/api/routing/valhalla'
|
||||
|
||||
/**
|
||||
* A real polyline6, encoded from three known points and verified by decoding it back.
|
||||
@@ -36,7 +38,7 @@ const setSettings = (patch: Record<string, string>) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
|
||||
|
||||
afterEach(() => {
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '' })
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '', routing_valhalla_provider: '' })
|
||||
resetValhallaTurns()
|
||||
})
|
||||
|
||||
@@ -92,11 +94,20 @@ const HAMBURG = { lat: 53.5511, lng: 9.9937 }
|
||||
const BERLIN = { lat: 52.52, lng: 13.405 }
|
||||
|
||||
describe('valhallaBase', () => {
|
||||
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
it('VALHALLA-BASE-001: asks the TREK services through the relay when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe(TREK_VALHALLA)
|
||||
expect(valhallaAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-005: keeps the admin choice of a routing service', () => {
|
||||
setSettings({ routing_valhalla_provider: 'fossgis', valhalla_base_url: 'https://v.example.org' })
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
setSettings({ routing_valhalla_provider: 'custom' })
|
||||
expect(valhallaBase()).toBe('https://v.example.org')
|
||||
setSettings({ routing_valhalla_provider: 'trek', valhalla_base_url: '', routing_base_url: 'https://osrm.example.org' })
|
||||
expect(valhallaBase()).toBe(TREK_VALHALLA)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
|
||||
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
|
||||
expect(valhallaBase()).toBe('https://valhalla.example.org')
|
||||
@@ -123,9 +134,9 @@ describe('runFrom', () => {
|
||||
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
|
||||
const leg = total(answer())
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg!.duration).toBe(11460)
|
||||
expect(leg?.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg?.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg?.duration).toBe(11460)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
|
||||
@@ -153,15 +164,15 @@ describe('runFrom', () => {
|
||||
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
|
||||
},
|
||||
}
|
||||
const run = runFrom(chain)!
|
||||
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run.total.distance).toBe(150000)
|
||||
expect(run.total.duration).toBe(5400)
|
||||
const run = runFrom(chain)
|
||||
expect(run?.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run?.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run?.total.distance).toBe(150000)
|
||||
expect(run?.total.duration).toBe(5400)
|
||||
// One tolled leg makes the drive tolled. `every` here would call a day with a
|
||||
// single toll booth in it toll-free.
|
||||
expect(run.total.hasToll).toBe(true)
|
||||
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
expect(run?.total.hasToll).toBe(true)
|
||||
expect(run?.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
|
||||
@@ -217,7 +228,7 @@ describe('valhallaRouteAvoiding', () => {
|
||||
})
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg?.distance).toBeCloseTo(295700, 0)
|
||||
expect(body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
@@ -353,6 +364,7 @@ describe('valhallaRun over a whole day', () => {
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: difficulty, use_ferry: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
directions_type: 'maneuvers',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -367,8 +379,8 @@ describe('valhallaRun over a whole day', () => {
|
||||
const run = await valhallaRun(stops(6), 'driving', [])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(run!.legs).toHaveLength(5)
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
expect(run?.legs).toHaveLength(5)
|
||||
expect(run?.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-008: forwards caller-specific costing options only when supplied', async () => {
|
||||
@@ -403,8 +415,8 @@ describe('valhallaRun over a whole day', () => {
|
||||
// takes the remaining five. Nine pairs plus four.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
// Thirteen pairs across two requests, none lost and none counted twice.
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run!.total.distance).toBe(130000)
|
||||
expect(run?.legs).toHaveLength(13)
|
||||
expect(run?.total.distance).toBe(130000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
|
||||
@@ -453,11 +465,58 @@ describe('valhallaRun over a whole day', () => {
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run?.legs).toHaveLength(13)
|
||||
// First piece, second piece refused, second piece again.
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-009: asks the relay twenty locations at a time with no pause between the pieces', async () => {
|
||||
const sizes: number[] = []
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post('/api/routing/valhalla/:action', async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(chain(1)) }),
|
||||
)
|
||||
const started = Date.now()
|
||||
|
||||
const avoiding = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
const long = await valhallaRun(stops(25), 'driving', ['toll'])
|
||||
|
||||
expect(avoiding?.legs).toHaveLength(13)
|
||||
expect(long?.legs).toHaveLength(24)
|
||||
expect(sizes).toEqual([14, 20, 6])
|
||||
// The pause between pieces was 1.1 s; the relay and an own Valhalla are not paced.
|
||||
expect(Date.now() - started).toBeLessThan(1000)
|
||||
expect(fossgis).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-010: a day cut for the relay that the relay cannot answer is cut again in tens for FOSSGIS', async () => {
|
||||
const relayed: number[] = []
|
||||
const sizes: number[] = []
|
||||
server.use(
|
||||
http.post('/api/routing/valhalla/:action', async ({ request }) => {
|
||||
relayed.push(((await request.json()) as { locations: unknown[] }).locations.length)
|
||||
return HttpResponse.json({ error: 'down' }, { status: 502 })
|
||||
}),
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}),
|
||||
)
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run?.legs).toHaveLength(13)
|
||||
expect(relayed).toEqual([14])
|
||||
// FOSSGIS never saw the piece of fourteen it would have refused with error 150.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
|
||||
@@ -705,3 +764,158 @@ describe('crossings by boat on a walk (CW-12)', () => {
|
||||
expect(await valhallaRunOnFoot(points, undefined, { use_ferry: 0 }, { exclude_ferry: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('through the TREK services (routing relay)', () => {
|
||||
const RELAY = '/api/routing/valhalla/:action'
|
||||
|
||||
it('VALHALLA-RELAY-001: asks the relay first, and FOSSGIS is not asked while it answers', async () => {
|
||||
const relayed: Array<{ action: string; body: Record<string, unknown> }> = []
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post(RELAY, async ({ params, request }) => {
|
||||
relayed.push({ action: String(params.action), body: await request.json() as Record<string, unknown> })
|
||||
return HttpResponse.json(answer({ has_toll: false }))
|
||||
}),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg?.distance).toBeCloseTo(295700, 0)
|
||||
expect(relayed).toHaveLength(1)
|
||||
expect(relayed[0].action).toBe('route')
|
||||
expect(relayed[0].body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
directions_type: 'maneuvers',
|
||||
})
|
||||
expect(fossgis).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-009: a question the relay ran out of time on has no answer, without resting the relay or asking FOSSGIS', async () => {
|
||||
let relayCalls = 0
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post(RELAY, () => {
|
||||
relayCalls++
|
||||
return HttpResponse.json({ error: 'The TREK routing service took too long for this route' }, { status: 504 })
|
||||
}),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')).resolves.toBeNull()
|
||||
// Asked once: the relay would only run out of time on it again.
|
||||
expect([relayCalls, fossgis]).toEqual([1, 0])
|
||||
expect(valhallaBase()).toBe(TREK_VALHALLA)
|
||||
|
||||
server.use(http.post(RELAY, () => { relayCalls++; return HttpResponse.json(answer()) }))
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
|
||||
expect([relayCalls, fossgis]).toEqual([2, 0])
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-002: turns to FOSSGIS without a word where the relay cannot answer, and leaves it alone a while', async () => {
|
||||
for (const status of [401, 429, 503]) {
|
||||
resetValhallaTurns()
|
||||
let relayCalls = 0
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post(RELAY, () => { relayCalls++; return HttpResponse.json({ error: 'no' }, { status }) }),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(Date.now() + 2000)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
|
||||
vi.useRealTimers()
|
||||
expect([status, relayCalls, fossgis]).toEqual([status, 1, 2])
|
||||
}
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-003: asks the relay again once the rest is over', async () => {
|
||||
let relayCalls = 0
|
||||
server.use(
|
||||
http.post(RELAY, () => { relayCalls++; return HttpResponse.json({ error: 'no' }, { status: 503 }) }),
|
||||
http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(answer())),
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(relayCalls).toBe(1)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(Date.now() + 11 * 60_000)
|
||||
expect(valhallaBase()).toBe(TREK_VALHALLA)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("VALHALLA-RELAY-004: reads Valhalla's own refusal through the relay as no way, without asking FOSSGIS", async () => {
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post(RELAY, () => HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
|
||||
)
|
||||
const points = [HAMBURG, BERLIN]
|
||||
expect(await valhallaRunOnFoot(points, undefined, { use_ferry: 0 }, { exclude_ferry: true })).toEqual({ noPath: true })
|
||||
expect(fossgis).toBe(0)
|
||||
expect(valhallaBase()).toBe(TREK_VALHALLA)
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-005: describes a line through the relay as a polyline', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(RELAY, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ edges: [] })
|
||||
}))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toEqual({ edges: [] })
|
||||
expect(bodies[0]).not.toHaveProperty('shape')
|
||||
expect(bodies[0]).toMatchObject({ encoded_polyline: expect.any(String), costing: 'bicycle', shape_match: 'map_snap' })
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-006: reads a 400 without error_code, and a route the server does not have, as the relay unable to answer', async () => {
|
||||
const refusals = [
|
||||
() => HttpResponse.json({ error: 'Expected number, received string' }, { status: 400 }),
|
||||
() => new HttpResponse('Not Found', { status: 404 }),
|
||||
() => HttpResponse.json({ error: 'Method Not Allowed' }, { status: 405 }),
|
||||
]
|
||||
for (const refusal of refusals) {
|
||||
resetValhallaTurns()
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post(RELAY, refusal),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
|
||||
expect(fossgis).toBe(1)
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
}
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-007: rests the relay as long as its Retry-After says, at most an hour', async () => {
|
||||
for (const [retryAfter, restMs] of [['30', 30_000], ['86400', 60 * 60_000]] as const) {
|
||||
resetValhallaTurns()
|
||||
server.use(
|
||||
http.post(RELAY, () => HttpResponse.json({ error: 'spent' }, { status: 429, headers: { 'Retry-After': retryAfter } })),
|
||||
http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(answer())),
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(Date.now() + restMs - 1000)
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
vi.setSystemTime(Date.now() + 2000)
|
||||
expect(valhallaBase()).toBe(TREK_VALHALLA)
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('VALHALLA-RELAY-008: gives up on a relay that does not answer in time and asks FOSSGIS', async () => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal)
|
||||
const spy = vi.spyOn(AbortSignal, 'timeout').mockImplementation(ms =>
|
||||
ms === 12_000 ? AbortSignal.abort(new DOMException('signal timed out', 'TimeoutError')) : timeout(ms))
|
||||
let fossgis = 0
|
||||
server.use(
|
||||
http.post(RELAY, () => HttpResponse.json(answer())),
|
||||
http.post(FOSSGIS_VALHALLA, () => { fossgis++; return HttpResponse.json(answer()) }),
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.not.toBeNull()
|
||||
expect(spy).toHaveBeenCalledWith(12_000)
|
||||
expect(fossgis).toBe(1)
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import {
|
||||
resolveValhallaProvider,
|
||||
VALHALLA_COSTING,
|
||||
VALHALLA_RELAY_MAX_LOCATIONS,
|
||||
valhallaRoutesEveryDay,
|
||||
type ValhallaProvider,
|
||||
type ValhallaRelayAction,
|
||||
} from '@trek/shared'
|
||||
import { valhallaRelayRepo } from '../../repo/valhallaRelayRepo'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { decodePolyline } from './transitGeometry'
|
||||
import { ferrySpansOf, type ValhallaManeuver } from './valhallaFerries'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
|
||||
@@ -16,7 +26,8 @@ import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
* module is the adapter: same question, different engine, answer shaped like the OSRM
|
||||
* one so the callers do not have to care which engine spoke.
|
||||
*
|
||||
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
|
||||
* On the public instance it is deliberately NOT a replacement for the OSRM path. Three
|
||||
* measured reasons:
|
||||
* - it caps at ten locations, and following a track sends a day's stops plus up to
|
||||
* nine anchors, so the bundled requests would start failing;
|
||||
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
|
||||
@@ -31,13 +42,41 @@ import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
* subtracting one engine's figure from the other's, which is what an offer beside
|
||||
* a route invites, measures the gap between two speed models and nothing else.
|
||||
*
|
||||
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
|
||||
* on demand, drawn as an offer next to the route rather than replacing it.
|
||||
* Which leaves exactly the shape this module serves there: two points, one avoidance,
|
||||
* asked on demand, drawn as an offer next to the route rather than replacing it.
|
||||
*
|
||||
* The TREK services' Valhalla and an own one take twenty locations and no pace of one a
|
||||
* second, so with either of them this engine routes every day (`valhallaDayRun`), and a
|
||||
* day it cannot answer goes to OSRM whole. The third reason holds on every host, which
|
||||
* is why one day is never routed by both.
|
||||
*/
|
||||
|
||||
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
|
||||
/**
|
||||
* The TREK services' Valhalla, reached through this server's relay: same origin, so no
|
||||
* connect-src to add, and the installation key stays on the server.
|
||||
*/
|
||||
const TREK_VALHALLA = '/api/routing/valhalla'
|
||||
|
||||
/**
|
||||
* How long this tab leaves the relay alone after it could not answer: signed out on a
|
||||
* shared page, the allowance spent, the services down. FOSSGIS answers meanwhile.
|
||||
*/
|
||||
const RELAY_REST_MS = 10 * 60_000
|
||||
|
||||
/** The longest Retry-After of the relay this tab follows; a longer one is asked about again after it. */
|
||||
const RELAY_MAX_WAIT_MS = 60 * 60_000
|
||||
|
||||
/**
|
||||
* How long a route or a height question may take through the relay or at an own Valhalla.
|
||||
* The server gives the TREK Valhalla ten seconds; this leaves room for its 504 to arrive
|
||||
* first. Where the relay itself hangs, FOSSGIS answers soon after; where an own Valhalla
|
||||
* does, OSRM routes the day, rather than the day waiting as long as the browser would.
|
||||
*/
|
||||
const ASK_TIMEOUT_MS = 12_000
|
||||
|
||||
/**
|
||||
* How far Valhalla may look for a road under a point, in metres.
|
||||
*
|
||||
@@ -48,13 +87,6 @@ const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
*/
|
||||
const SNAP_RADIUS_M = 50
|
||||
|
||||
/** TREK's profile names against Valhalla's costing models. */
|
||||
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'auto',
|
||||
walking: 'pedestrian',
|
||||
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>
|
||||
|
||||
@@ -77,17 +109,18 @@ const AVOID_OPTION: Record<AvoidClass, string> = {
|
||||
export type AvoidClass = RouteAvoidClass
|
||||
|
||||
/**
|
||||
* Locations one request may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
|
||||
* Locations one request to the public instance may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400. The TREK
|
||||
* Valhalla and an own one take `VALHALLA_RELAY_MAX_LOCATIONS`.
|
||||
*/
|
||||
const MAX_LOCATIONS = 10
|
||||
|
||||
/**
|
||||
* Pause between the pieces of a split day, in milliseconds.
|
||||
* The pace of the public instance, in milliseconds between two questions.
|
||||
*
|
||||
* The public instance allows about one request a second per client, and a day long
|
||||
* enough to be split is exactly the case that would fire two back to back. Matches the
|
||||
* 1100 ms the road trip rail already leaves between its own days.
|
||||
* It allows about one request a second per client, and a day long enough to be split is
|
||||
* exactly the case that would fire two back to back; `valhallaTurn` holds every question
|
||||
* to it there. Matches the 1100 ms the road trip rail already leaves between its own days.
|
||||
*/
|
||||
const CHUNK_PAUSE_MS = 1100
|
||||
|
||||
@@ -152,6 +185,14 @@ export interface ValhallaNoPath {
|
||||
const NO_PATH_CODES = new Set([170, 171, 442])
|
||||
const NO_PATH: ValhallaNoPath = { noPath: true }
|
||||
|
||||
/** A host that let a question run out its deadline, apart from one that refused it. */
|
||||
const TOO_SLOW = { tooSlow: true } as const
|
||||
type TooSlow = typeof TOO_SLOW
|
||||
|
||||
function tooSlow(answer: unknown): answer is TooSlow {
|
||||
return answer === TOO_SLOW
|
||||
}
|
||||
|
||||
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
|
||||
export interface ValhallaRun {
|
||||
legs: ValhallaLeg[]
|
||||
@@ -169,21 +210,59 @@ export interface ValhallaRun {
|
||||
snapped: SnappedWaypoint[]
|
||||
}
|
||||
|
||||
/** Until when this tab asks FOSSGIS instead of a relay that could not answer. */
|
||||
let relayRestingUntil = 0
|
||||
|
||||
/**
|
||||
* The instance's Valhalla, or null when it should not ask one.
|
||||
*
|
||||
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
|
||||
* The admin's choice (`routing_valhalla_provider`) stands. Without one, an own Valhalla
|
||||
* is asked, and otherwise the TREK services through the relay, with FOSSGIS behind it —
|
||||
* with one condition. An operator who filled in their OWN router chose deliberately
|
||||
* against the public hosts, usually because their instance is not supposed to talk to
|
||||
* third parties at all, and quietly adding a second public host they never asked for
|
||||
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* third parties at all, and quietly adding a second host they never asked for would
|
||||
* undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* pipeline answers `exclude` on its own, which is exactly the fallback below.
|
||||
*/
|
||||
export function valhallaBase(): string | null {
|
||||
const { provider, own } = currentProvider()
|
||||
if (provider === 'custom') return own
|
||||
if (provider === 'trek') return TREK_VALHALLA
|
||||
return provider === 'fossgis' ? FOSSGIS_VALHALLA : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider as the settings choose it. Whether the TREK services can answer is the
|
||||
* relay's to say; until it said no, it is asked, and `ignoreRest` reads past its no.
|
||||
*/
|
||||
function currentProvider(ignoreRest = false): { provider: ValhallaProvider | null; own: string } {
|
||||
const settings = useSettingsStore.getState().settings
|
||||
const configured = settings.valhalla_base_url?.trim()
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
|
||||
const own = settings.valhalla_base_url?.trim().replace(/\/+$/, '') ?? ''
|
||||
const provider = resolveValhallaProvider({
|
||||
provider: settings.routing_valhalla_provider,
|
||||
valhallaUrl: own,
|
||||
routingUrl: settings.routing_base_url,
|
||||
trekAvailable: ignoreRest || Date.now() >= relayRestingUntil,
|
||||
})
|
||||
return { provider, own }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the days are asked here right now rather than of OSRM: with the TREK services
|
||||
* or an own Valhalla. While the relay rests after a failure this reads as FOSSGIS, so the
|
||||
* days go to OSRM until it is asked again.
|
||||
*/
|
||||
export function valhallaRoutesDays(): boolean {
|
||||
return valhallaRoutesEveryDay(currentProvider().provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the days are planned with this engine, resting relay or not. The route cache is
|
||||
* keyed by it: a day this device already holds from Valhalla stays found while the relay
|
||||
* rests, offline above all, where every question to it fails and it rests at once.
|
||||
*/
|
||||
export function valhallaPlansDays(): boolean {
|
||||
return valhallaRoutesEveryDay(currentProvider(true).provider)
|
||||
}
|
||||
|
||||
/** True when this engine can be asked at all, so callers can skip building a request. */
|
||||
@@ -194,6 +273,11 @@ export function valhallaAvailable(): boolean {
|
||||
/** Hosts that have already proved they are not a Valhalla, by base URL. */
|
||||
const notValhalla = new Set<string>()
|
||||
|
||||
/** Whether a question may go to this base: a Valhalla, and the public one only where it may stand in. */
|
||||
function mayAsk(base: string | null, publicFallback: boolean): base is string {
|
||||
return !!base && !notValhalla.has(base) && (publicFallback || base !== FOSSGIS_VALHALLA)
|
||||
}
|
||||
|
||||
/**
|
||||
* One leg with a road class weighted away, or null when it cannot be had.
|
||||
*
|
||||
@@ -224,9 +308,9 @@ export async function valhallaRouteAvoiding(
|
||||
* came back within two metres and under a second of the same day asked whole, because
|
||||
* the shared point is routed to and from identically.
|
||||
*
|
||||
* The pieces go out one after another with a pause, since a day long enough to need
|
||||
* splitting is exactly the one that would otherwise fire two requests inside a second
|
||||
* at a host that allows one.
|
||||
* The pieces go out one after another. Those that reach the public instance wait their
|
||||
* turn there (`valhallaTurn`), since a day long enough to need splitting is exactly the
|
||||
* one that would otherwise fire two requests inside a second at a host that allows one.
|
||||
*/
|
||||
export async function valhallaRun(
|
||||
waypoints: Waypoint[],
|
||||
@@ -235,11 +319,35 @@ export async function valhallaRun(
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<ValhallaRun | null> {
|
||||
const run = await chainRun(waypoints, profile, avoid, signal, { costingOptions, searchFilter: null })
|
||||
const ask: RunAsk = { costingOptions, searchFilter: null, publicFallback: true }
|
||||
const run = await chainRun(waypoints, profile, avoid, signal, ask)
|
||||
if (!run || 'noPath' in run) return null
|
||||
return run
|
||||
}
|
||||
|
||||
/**
|
||||
* A day of the planner or the road trip, routed here because this engine routes the
|
||||
* days (`valhallaRoutesDays`) or because the day avoids something only it can avoid.
|
||||
*
|
||||
* A day that avoids nothing is never handed to the public instance: OSRM routes it
|
||||
* there, as it always has, so where the TREK services cannot answer this is null and
|
||||
* OSRM takes the whole day. A day that avoids something goes to FOSSGIS behind the
|
||||
* relay as before, since OSRM cannot weigh a class away.
|
||||
*/
|
||||
export async function valhallaDayRun(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaRun | null> {
|
||||
const run = await chainRun(waypoints, profile, avoid, signal, dayAsk(avoid))
|
||||
return run && !('noPath' in run) ? run : null
|
||||
}
|
||||
|
||||
function dayAsk(avoid: readonly AvoidClass[]): RunAsk {
|
||||
return { costingOptions: {}, searchFilter: null, publicFallback: avoid.length > 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-12: a chain walked on foot, every point bound off the boat lines. Measured
|
||||
* on Venice: a stop by a vaporetto pier bound onto the boat line ended the
|
||||
@@ -253,13 +361,24 @@ export function valhallaRunOnFoot(
|
||||
costingOptions: Readonly<CostingOptions>,
|
||||
searchFilter: Readonly<SearchFilter>,
|
||||
): Promise<ValhallaRun | ValhallaNoPath | null> {
|
||||
return chainRun(waypoints, 'walking', [], signal, { costingOptions, searchFilter })
|
||||
return chainRun(waypoints, 'walking', [], signal, { costingOptions, searchFilter, publicFallback: true })
|
||||
}
|
||||
|
||||
/** How a chain is asked for besides its profile and avoidance. */
|
||||
interface RunAsk {
|
||||
costingOptions: Readonly<CostingOptions>
|
||||
searchFilter: Readonly<SearchFilter> | null
|
||||
/** Whether FOSSGIS answers where the relay cannot; without it the question has no answer then. */
|
||||
publicFallback: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Locations one request carries: ten at the public instance, twenty at the TREK
|
||||
* Valhalla and at an own one. A day cut for the relay that the relay then cannot
|
||||
* answer is cut again for FOSSGIS (`chainRun`).
|
||||
*/
|
||||
function locationsPerRequest(base: string): number {
|
||||
return base === FOSSGIS_VALHALLA ? MAX_LOCATIONS : VALHALLA_RELAY_MAX_LOCATIONS
|
||||
}
|
||||
|
||||
async function chainRun(
|
||||
@@ -270,18 +389,44 @@ async function chainRun(
|
||||
ask: RunAsk,
|
||||
): Promise<ValhallaRun | ValhallaNoPath | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
if (!mayAsk(base, ask.publicFallback) || waypoints.length < 2) return null
|
||||
const size = locationsPerRequest(base)
|
||||
const run = await chainRunSized(base, waypoints, { size, profile, avoid, signal, ask })
|
||||
// The relay fell over during a day cut for it. It rests now, so the base reads as FOSSGIS
|
||||
// and the day is asked again there in pieces of ten.
|
||||
const recut = run === null && base === TREK_VALHALLA && ask.publicFallback && waypoints.length > MAX_LOCATIONS
|
||||
if (recut && Date.now() < relayRestingUntil && !signal?.aborted) {
|
||||
return chainRun(waypoints, profile, avoid, signal, ask)
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
/** How one day is asked: in pieces of `size`, for this profile and these avoidances. */
|
||||
interface SizedRun {
|
||||
size: number
|
||||
profile: 'driving' | 'walking' | 'cycling'
|
||||
avoid: readonly AvoidClass[]
|
||||
signal: AbortSignal | undefined
|
||||
ask: RunAsk
|
||||
}
|
||||
|
||||
async function chainRunSized(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
{ size, profile, avoid, signal, ask }: SizedRun,
|
||||
): Promise<ValhallaRun | ValhallaNoPath | null> {
|
||||
const legs: ValhallaLeg[] = []
|
||||
const chunks = chunkWaypoints(waypoints)
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
// No pause between the pieces: a question that reaches FOSSGIS waits its turn in
|
||||
// valhallaTurn, and the relay and an own Valhalla are not paced.
|
||||
for (const chunk of chunkWaypoints(waypoints, size)) {
|
||||
if (signal?.aborted) return null
|
||||
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
|
||||
// Retried per piece rather than per day. The caller retries the whole task, so a
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
const run = await askWithRetry(base, () => requestRun(base, chunk, profile, avoid, signal, ask), signal)
|
||||
const request = () => requestRun(base, chunk, profile, avoid, signal, ask)
|
||||
// FOSSGIS stands in only for a piece of at most ten; a longer one is cut again instead.
|
||||
const run = await askWithRetry(base, request, signal, ask.publicFallback && chunk.length <= MAX_LOCATIONS)
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
@@ -314,12 +459,12 @@ function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoin
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs of at most ten points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
|
||||
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
|
||||
/** Runs of at most `size` points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[], size: number): Waypoint[][] {
|
||||
if (waypoints.length <= size) return [waypoints]
|
||||
const chunks: Waypoint[][] = []
|
||||
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
|
||||
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
|
||||
for (let start = 0; start < waypoints.length - 1; start += size - 1) {
|
||||
chunks.push(waypoints.slice(start, start + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -355,9 +500,96 @@ export async function valhallaTurn(base: string, signal?: AbortSignal): Promise<
|
||||
lastPublicAskAt = Date.now()
|
||||
}
|
||||
|
||||
/** Forget the last turn, for tests that start from a quiet host. */
|
||||
/** Forget the last turn and a resting relay, for tests that start from a quiet host. */
|
||||
export function resetValhallaTurns(): void {
|
||||
lastPublicAskAt = 0
|
||||
relayRestingUntil = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the relay's answer says it cannot answer for the TREK services right now: no
|
||||
* session (a shared page), no key, a body it does not take, a route this server does not
|
||||
* have yet, an allowance spent, a host that is down. A 400 that carries Valhalla's
|
||||
* `error_code` is its own refusal of the way and is read as such; one without is the
|
||||
* relay refusing the body.
|
||||
*/
|
||||
async function relayCannotAnswer(response: Response): Promise<boolean> {
|
||||
// 504: this one question took too long. The relay still answers the next one, and
|
||||
// FOSSGIS would take as long over this one.
|
||||
if (response.status === 504) return false
|
||||
if (response.status === 400) {
|
||||
// A body that is not JSON cannot be Valhalla's own refusal, so it reads as the relay's.
|
||||
const data: unknown = await response.clone().json().catch(() => ({}))
|
||||
return typeof (data as { error_code?: unknown } | null)?.error_code !== 'number'
|
||||
}
|
||||
return [401, 403, 404, 405, 413, 429].includes(response.status) || response.status >= 500
|
||||
}
|
||||
|
||||
/** The relay's Retry-After in milliseconds, as seconds or an HTTP date, or null without one. */
|
||||
function relayWait(response: Response): number | null {
|
||||
const value = response.headers.get('Retry-After')?.trim()
|
||||
if (!value) return null
|
||||
if (/^\d+$/.test(value)) return Number(value) * 1000
|
||||
const at = Date.parse(value)
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
/** A route or a height question held to `ASK_TIMEOUT_MS`; a description brings its own deadline. */
|
||||
function withDeadline(action: ValhallaRelayAction, signal?: AbortSignal): AbortSignal | undefined {
|
||||
if (action === 'trace_attributes') return signal
|
||||
const deadline = AbortSignal.timeout(ASK_TIMEOUT_MS)
|
||||
return signal ? AbortSignal.any([signal, deadline]) : deadline
|
||||
}
|
||||
|
||||
async function askRelay(action: ValhallaRelayAction, body: object, signal?: AbortSignal): Promise<Response | null> {
|
||||
let wait = RELAY_REST_MS
|
||||
try {
|
||||
const response = await valhallaRelayRepo.ask(action, body, withDeadline(action, signal))
|
||||
if (!(await relayCannotAnswer(response))) return response
|
||||
wait = Math.min(relayWait(response) ?? RELAY_REST_MS, RELAY_MAX_WAIT_MS)
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err
|
||||
}
|
||||
relayRestingUntil = Date.now() + wait
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* One question to a Valhalla, in its turn where the host is the public one. The relay
|
||||
* is asked first where it is the base; where it cannot answer, the same question goes
|
||||
* to FOSSGIS without a word, so a route never fails because of it. Without
|
||||
* `publicFallback` it does not: the answer is then a 503 of this tab's own, and the
|
||||
* caller asks OSRM.
|
||||
*/
|
||||
export async function valhallaPost(
|
||||
base: string,
|
||||
action: ValhallaRelayAction,
|
||||
body: object,
|
||||
signal?: AbortSignal,
|
||||
{ publicFallback = true }: { publicFallback?: boolean } = {},
|
||||
): Promise<Response> {
|
||||
if (base === TREK_VALHALLA && Date.now() >= relayRestingUntil) {
|
||||
const relayed = await askRelay(action, body, signal)
|
||||
if (relayed) return relayed
|
||||
}
|
||||
if (base === TREK_VALHALLA && (!publicFallback || overPublicLimit(action, body))) return new Response(null, { status: 503 })
|
||||
const host = base === TREK_VALHALLA ? FOSSGIS_VALHALLA : base
|
||||
await valhallaTurn(host, signal)
|
||||
return fetch(`${host}/${action}`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
// An own Valhalla routes every day, so one that hangs would hold each of them up.
|
||||
signal: host === FOSSGIS_VALHALLA ? signal : withDeadline(action, signal),
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether a route carries more locations than the public instance takes, so it cannot stand in for the relay. */
|
||||
function overPublicLimit(action: ValhallaRelayAction, body: object): boolean {
|
||||
const locations = (body as { locations?: unknown }).locations
|
||||
return action === 'route' && Array.isArray(locations) && locations.length > MAX_LOCATIONS
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,16 +607,28 @@ export function valhallaSpacing(signal?: AbortSignal): Promise<void> {
|
||||
* One question, and one more try after a pause when it earned no answer.
|
||||
*
|
||||
* Not when the host just proved it is not a Valhalla at all: that answer will not change
|
||||
* on a second ask, and asking anyway is the request the memo exists to save.
|
||||
* on a second ask, and asking anyway is the request the memo exists to save. Nor, where
|
||||
* nothing may stand in for this engine, when the relay now rests or the host used up its
|
||||
* whole deadline: the second ask would not leave this tab or would most likely wait as
|
||||
* long again, and OSRM is waiting for the day. Nor when the relay ran out of time on
|
||||
* it: the relay does not rest for that, so the second ask would wait at the same host.
|
||||
*/
|
||||
async function askWithRetry<T>(base: string, ask: () => Promise<T | null>, signal?: AbortSignal): Promise<T | null> {
|
||||
async function askWithRetry<T>(
|
||||
base: string,
|
||||
ask: () => Promise<T | TooSlow | null>,
|
||||
signal: AbortSignal | undefined,
|
||||
publicFallback: boolean,
|
||||
): Promise<T | null> {
|
||||
let answer = await ask()
|
||||
for (let attempt = 0; !answer && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
for (let attempt = 0; (!answer || tooSlow(answer)) && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
if (notValhalla.has(base)) break
|
||||
if (!publicFallback && (tooSlow(answer) || (base === TREK_VALHALLA && Date.now() < relayRestingUntil))) break
|
||||
// A question the relay ran out of time on would go to the same host again.
|
||||
if (tooSlow(answer) && base === TREK_VALHALLA) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
answer = await ask()
|
||||
}
|
||||
return answer
|
||||
return tooSlow(answer) ? null : answer
|
||||
}
|
||||
|
||||
/** The request body for a chain of waypoints, weighted away from `avoid`. */
|
||||
@@ -395,7 +639,7 @@ function routeBody(
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
searchFilter: Readonly<SearchFilter> | null = null,
|
||||
): Record<string, unknown> {
|
||||
const costing = COSTING[profile]
|
||||
const costing = VALHALLA_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
|
||||
@@ -412,22 +656,23 @@ function routeBody(
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
// The maneuvers, where the ferry spans come from, without the narrative nothing reads.
|
||||
directions_type: 'maneuvers',
|
||||
}
|
||||
}
|
||||
|
||||
/** The parsed answer to one POST, `NO_PATH` when the router found no way, or null for every other failure. */
|
||||
async function postRoute(base: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
||||
/**
|
||||
* The parsed answer to one POST, `NO_PATH` when the router found no way, `TOO_SLOW` when
|
||||
* it did not answer in time, or null for every other failure.
|
||||
*/
|
||||
async function postRoute(
|
||||
base: string,
|
||||
body: Record<string, unknown>,
|
||||
signal: AbortSignal | undefined,
|
||||
publicFallback: boolean,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
await valhallaTurn(base, signal)
|
||||
if (signal?.aborted) return null
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
const response = await valhallaPost(base, 'route', body, signal, { publicFallback })
|
||||
if (!response.ok) {
|
||||
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
|
||||
// or a proxy that swallowed the path. Every other status is about this request:
|
||||
@@ -435,11 +680,13 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
|
||||
// is the public instance's one-per-second.
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
if (response.status === 400 && (await refusesTheWay(response))) return NO_PATH
|
||||
// The relay's word that this question ran out its deadline, read as one that timed out here.
|
||||
if (response.status === 504) return TOO_SLOW
|
||||
return null
|
||||
}
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
} catch (err) {
|
||||
return err instanceof DOMException && err.name === 'TimeoutError' ? TOO_SLOW : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,22 +753,15 @@ export async function valhallaTraceAttributes(
|
||||
const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon }))
|
||||
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS)
|
||||
try {
|
||||
await valhallaTurn(base, signal)
|
||||
if (signal?.aborted) return null
|
||||
const response = await fetch(`${base}/trace_attributes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify({
|
||||
shape,
|
||||
...TRACE_COSTING[matching],
|
||||
shape_match: 'map_snap',
|
||||
filters: {
|
||||
attributes: traceAttributesFor(matching),
|
||||
action: 'include',
|
||||
},
|
||||
}),
|
||||
signal: bounded,
|
||||
})
|
||||
const response = await valhallaPost(base, 'trace_attributes', {
|
||||
shape,
|
||||
...TRACE_COSTING[matching],
|
||||
shape_match: 'map_snap',
|
||||
filters: {
|
||||
attributes: traceAttributesFor(matching),
|
||||
action: 'include',
|
||||
},
|
||||
}, bounded)
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch {
|
||||
@@ -529,17 +769,19 @@ export async function valhallaTraceAttributes(
|
||||
}
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
/** One request, at most ten locations at the public instance and twenty elsewhere. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal: AbortSignal | undefined,
|
||||
{ costingOptions, searchFilter }: RunAsk,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | ValhallaNoPath | null> {
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions, searchFilter), signal)
|
||||
{ costingOptions, searchFilter, publicFallback }: RunAsk,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | ValhallaNoPath | TooSlow | null> {
|
||||
const body = routeBody(waypoints, profile, avoid, costingOptions, searchFilter)
|
||||
const data = await postRoute(base, body, signal, publicFallback)
|
||||
if (data === NO_PATH) return NO_PATH
|
||||
if (tooSlow(data)) return data
|
||||
return data === null ? null : runFrom(data)
|
||||
}
|
||||
|
||||
@@ -563,13 +805,39 @@ export async function valhallaAlternates(
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<ValhallaLeg[] | null> {
|
||||
const ask: RunAsk = { costingOptions, searchFilter: null, publicFallback: true }
|
||||
return alternates([from, to], profile, avoid, { count, signal, ask })
|
||||
}
|
||||
|
||||
/**
|
||||
* The offers for one leg of a day `valhallaDayRun` routes, asked the way that day was:
|
||||
* a leg OSRM would otherwise drive is never asked of the public instance.
|
||||
*/
|
||||
export function valhallaDayAlternates(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
{ count, signal }: { count: number; signal?: AbortSignal },
|
||||
): Promise<ValhallaLeg[] | null> {
|
||||
return alternates([from, to], profile, avoid, { count, signal, ask: dayAsk(avoid) })
|
||||
}
|
||||
|
||||
async function alternates(
|
||||
[from, to]: [Waypoint, Waypoint],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
{ count, signal, ask }: { count: number; signal: AbortSignal | undefined; ask: RunAsk },
|
||||
): Promise<ValhallaLeg[] | null> {
|
||||
const { costingOptions, publicFallback } = ask
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base)) return null
|
||||
if (!mayAsk(base, publicFallback)) return null
|
||||
const body = { ...routeBody([from, to], profile, avoid, costingOptions), ...(count > 0 ? { alternates: count } : {}) }
|
||||
return askWithRetry(base, async () => {
|
||||
const data = await postRoute(base, body, signal)
|
||||
const data = await postRoute(base, body, signal, publicFallback)
|
||||
if (tooSlow(data)) return data
|
||||
return data === null ? null : alternatesFrom(data)
|
||||
}, signal)
|
||||
}, signal, publicFallback)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -664,14 +932,6 @@ export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
|
||||
|
||||
interface ValhallaSummary { [key: string]: number | boolean | undefined }
|
||||
|
||||
interface ValhallaManeuver {
|
||||
type?: number
|
||||
ferry?: boolean
|
||||
begin_shape_index?: number
|
||||
end_shape_index?: number
|
||||
time?: number
|
||||
length?: number
|
||||
}
|
||||
|
||||
function tripOf(data: unknown) {
|
||||
return (data as {
|
||||
@@ -682,40 +942,6 @@ function tripOf(data: unknown) {
|
||||
})?.trip
|
||||
}
|
||||
|
||||
/** Valhalla's maneuver type for boarding a ferry; the maneuver also carries `ferry: true` for its crossing. */
|
||||
const FERRY_ENTER = 28
|
||||
|
||||
/**
|
||||
* CW-12: the stretches of a leg the router takes by boat, read off its
|
||||
* maneuvers: a crossing is the maneuver that boards the ferry, from the point
|
||||
* it starts at to the one it ends at, with its own length and time. Measured
|
||||
* on Venice, the San Tomà to Ca' Rezzonico line came back as one maneuver of
|
||||
* type 28, ferry true, 1.236 km and 745 s over shape points 24 to 46, then the
|
||||
* walk off it. Crossings that touch are one. A leg the summary says has a
|
||||
* ferry but whose maneuvers do not say where is taken whole, without a time:
|
||||
* the crossing is somewhere on it, and none of it is counted as walked.
|
||||
*/
|
||||
function ferrySpansOf(maneuvers: ValhallaManeuver[] | undefined, points: number): FerrySpan[] {
|
||||
const spans: FerrySpan[] = []
|
||||
for (const maneuver of maneuvers ?? []) {
|
||||
if (maneuver.ferry !== true && maneuver.type !== FERRY_ENTER) continue
|
||||
const from = Number(maneuver.begin_shape_index)
|
||||
const to = Number(maneuver.end_shape_index)
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to <= from || to >= points) continue
|
||||
const metres = Number.isFinite(maneuver.length) ? Number(maneuver.length) * 1000 : 0
|
||||
const seconds = Number.isFinite(maneuver.time) ? Number(maneuver.time) : null
|
||||
const previous = spans[spans.length - 1]
|
||||
if (previous && previous.to === from) {
|
||||
previous.to = to
|
||||
previous.metres += metres
|
||||
previous.seconds = previous.seconds === null || seconds === null ? null : previous.seconds + seconds
|
||||
} else {
|
||||
spans.push({ from, to, metres, seconds })
|
||||
}
|
||||
}
|
||||
return spans.length || points < 2 ? spans : [{ from: 0, to: points - 1, metres: 0, seconds: null }]
|
||||
}
|
||||
|
||||
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
|
||||
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
|
||||
if (!coordinates.length) return null
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('../Map/tripRouteGeometry', async (importActual) => {
|
||||
// basemap branch and the outline fallback can be exercised.
|
||||
vi.mock('./tripMapImage', () => ({ renderTripMapImage: vi.fn(async () => null) }))
|
||||
|
||||
const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
|
||||
const { calculateRouteWithLegs, RoutingRefusedError } = await import('../Map/RouteCalculator')
|
||||
const { renderTripMapImage } = await import('./tripMapImage')
|
||||
const { routeTrip } = await import('../Map/tripRouteGeometry')
|
||||
const { downloadTripPDF } = await import('./TripPDF')
|
||||
@@ -324,4 +324,84 @@ describe('trip route map in the PDF', () => {
|
||||
expect(html).not.toContain('pdf.distanceLabel')
|
||||
})
|
||||
|
||||
// The export used to give the router 8 s: about seven legs at a second each, so a
|
||||
// month-long trip printed most of its days as straight lines at "0 m" under a total
|
||||
// that looked final, and every export routed a few more days (#2876).
|
||||
it('FE-COMP-TRIPPDF-MAP-013: a long trip prints every day on the road, however long the router takes', async () => {
|
||||
const days = Array.from({ length: 12 }, (_, i) =>
|
||||
({ id: i + 1, day_number: i + 1, title: null, date: `2025-06-${String(i + 1).padStart(2, '0')}` }))
|
||||
const assignments = Object.fromEntries(days.map(day => [String(day.id), [
|
||||
assign(day.id * 10, day.id, 0, 45 + day.id / 10, 4.8),
|
||||
assign(day.id * 10 + 1, day.id, 1, 45 + day.id / 10, 4.9),
|
||||
]]))
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
|
||||
setTimeout(() => resolve({ coordinates: [[45, 4.8], [45, 4.9]], distance: 12000, duration: 900, legs: [leg(12000)] }), 100)
|
||||
}))
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let settled = false
|
||||
const pending = downloadTripPDF({ ...args, days: days as never, assignments: assignments as never })
|
||||
.finally(() => { settled = true })
|
||||
for (let second = 0; second < 60 && !settled; second++) await vi.advanceTimersByTimeAsync(1000)
|
||||
await pending
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(12)
|
||||
const html = srcdoc()
|
||||
expect(html).toContain('144 km')
|
||||
expect(html).not.toContain('map.overview.unrouted')
|
||||
expect(html).not.toContain('map.overview.notRouted')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-MAP-014: a leg the router refused is marked, and the total says it is short', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new RoutingRefusedError(400, null))
|
||||
await downloadTripPDF(args)
|
||||
const html = srcdoc()
|
||||
const legend = html.slice(html.indexOf('class="trip-map-legend"'))
|
||||
|
||||
// Day 1 drove nowhere the router would say: no "0 m", but that it was not routed...
|
||||
expect(legend).toContain('<span class="trip-map-leg-dist">map.overview.notRouted</span>')
|
||||
expect(legend).not.toContain('>0 m<')
|
||||
// ...and the 12 km of day 2 are a floor for the trip, said as such.
|
||||
expect(html).toContain('pdf.distanceLabel: map.overview.distanceAtLeast')
|
||||
expect(html).toContain('<div class="trip-map-note">map.overview.unrouted</div>')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-MAP-015: routes what the planner map routes, and reports its progress to the caller', async () => {
|
||||
const viasByDay = { 1: [{ id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.88, lng: 2.5, created_at: '' }] }
|
||||
const tourStageStops = new Map()
|
||||
const controller = new AbortController()
|
||||
const onProgress = vi.fn<(done: number, total: number) => void>()
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => ({
|
||||
coordinates: points.map(p => [p.lat, p.lng] as [number, number]),
|
||||
distance: 0, duration: 0,
|
||||
legs: points.slice(1).map(() => leg(12000)),
|
||||
}))
|
||||
await downloadTripPDF({
|
||||
...args,
|
||||
distanceUnit: 'imperial',
|
||||
route: { viasByDay, tourStageStops, signal: controller.signal, onProgress },
|
||||
})
|
||||
|
||||
const [input, opts] = vi.mocked(routeTrip).mock.calls[0]
|
||||
expect(input).toMatchObject({ viasByDay, tourStageStops, storedAssignments: args.assignments })
|
||||
expect(opts).toMatchObject({ unit: 'imperial', signal: controller.signal, onProgress })
|
||||
// The road picked in the road trip bends day 1 here as it does on the planner map.
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls[0][0].map(p => [p.lat, p.lng]))
|
||||
.toEqual([[48.86, 2.35], [48.88, 2.5], [48.90, 2.42]])
|
||||
expect(onProgress).toHaveBeenLastCalledWith(2, 2)
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-MAP-016: an export given up while it routes opens no document', async () => {
|
||||
const givenUp = new AbortController()
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(async (points) => {
|
||||
givenUp.abort()
|
||||
return { coordinates: points.map(p => [p.lat, p.lng] as [number, number]), distance: 0, duration: 0, legs: [leg(12000)] }
|
||||
})
|
||||
await downloadTripPDF({ ...args, signal: givenUp.signal })
|
||||
|
||||
expect(document.getElementById('pdf-preview-overlay')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,14 +16,11 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection'
|
||||
import { planCosts } from '../Planner/planCosts'
|
||||
import { routeTrip, type TripRouteSummary } from '../Map/tripRouteGeometry'
|
||||
import { buildTripMapSvg } from './tripMapSvg'
|
||||
import { renderTripMapImage } from './tripMapImage'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { routeDistanceLabel } from '../Map/tripRouteLabels'
|
||||
import { drawTripRoute, type TripPdfRouteOptions } from './tripPdfRoute'
|
||||
import { fetchExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/flightLegs'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
import { onlyMyPlan } from './pdfScope'
|
||||
import { onlyMyPlan, planAssignments } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
@@ -201,27 +198,15 @@ interface downloadTripPDFProps {
|
||||
costsEnabled?: boolean
|
||||
/** Prints only this member's plan (#2168): the stops and bookings that name them, and the ones that name nobody. */
|
||||
onlyUserId?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The stops the day plan lists, out of everything the store holds for the trip.
|
||||
*
|
||||
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
|
||||
* the day already shows that booking as its accommodation block, and the row would be
|
||||
* the same hotel a second time. The service stops go with the setting. One predicate
|
||||
* for the whole document, so the day lists, the route map, the cover's planned count
|
||||
* and every cost total agree with the plan and with each other, whichever shell asked.
|
||||
*/
|
||||
function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
|
||||
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
|
||||
dayId,
|
||||
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
|
||||
]))
|
||||
/** The planner's road trip vias and tour stages, and the routing progress and stop (#2876). */
|
||||
route?: TripPdfRouteOptions
|
||||
/** Aborted once nobody waits for the document any more (its dialog was closed): it then never opens. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// `assignments` is normalised here once, to the plan's own list; every read below
|
||||
// (and fetchPlacePhotos) relies on it being an object.
|
||||
export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId }: downloadTripPDFProps) {
|
||||
export async function downloadTripPDF({ trip, days, places, assignments: allStored = {}, categories, dayNotes, reservations: allReservations = [], t: _t, locale: _locale, timeFormat: _timeFormat, distanceUnit: _distanceUnit, showServiceStops = true, budgetItems, costsEnabled, onlyUserId, route, signal }: downloadTripPDFProps) {
|
||||
const { assignments: stored, reservations } = onlyUserId != null
|
||||
? onlyMyPlan(allStored, allReservations, onlyUserId)
|
||||
: { assignments: allStored, reservations: allReservations }
|
||||
@@ -252,56 +237,25 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
// just means no extra sections, the core export is untouched.
|
||||
const pluginSections = await pluginsApi.pdfSections(trip.id).then(r => r.sections || []).catch(() => [])
|
||||
|
||||
// The trip's route as one map (#1736), drawn from the same builder the planner map
|
||||
// uses so the document and the screen agree. Fail-safe and time-boxed: whatever the
|
||||
// router answered inside the budget is drawn, the rest stay straight lines, and any
|
||||
// failure at all simply means no map rather than no PDF.
|
||||
const unit: DistanceUnit = (_distanceUnit || useSettingsStore.getState().settings.distance_unit) === 'imperial'
|
||||
? 'imperial' : 'metric'
|
||||
let tripRoute: TripRouteSummary | null = null
|
||||
const toursEnabled = useAddonStore.getState().isEnabled('tours')
|
||||
const hasInvalidTour = toursEnabled && Object.values(assignments).some(list =>
|
||||
projectDayItinerary(list || [], true, places).some(item => item.kind === 'tour' && !item.valid))
|
||||
try {
|
||||
tripRoute = await routeTrip(
|
||||
{
|
||||
days: sorted,
|
||||
assignments,
|
||||
reservations,
|
||||
accommodations: accommodationList,
|
||||
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
|
||||
toursEnabled,
|
||||
places,
|
||||
},
|
||||
{ profile: 'driving', tripId: trip.id, timeoutMs: 8000 },
|
||||
)
|
||||
} catch (err) {
|
||||
// Logged rather than swallowed: a map that is silently absent looks exactly like a
|
||||
// trip that has no route, and the two need different answers from whoever is
|
||||
// looking. The export itself carries on — the itinerary matters more than the map.
|
||||
console.warn('[tripPdfMap] routing the trip failed; the export continues without a map', err)
|
||||
}
|
||||
// A real basemap first — at city scale the bundled outlines are a country-sized
|
||||
// blank, and only streets carry context that small. The outline map is what is left
|
||||
// when there is no WebGL, no network, or a style that will not load.
|
||||
const mapFrame = { width: 720, height: 420, formatDistance: (km: number) => formatDistance(km, unit) }
|
||||
const tripMapSvg = tripRoute
|
||||
? (await renderTripMapImage(tripRoute.days, {
|
||||
...mapFrame,
|
||||
style: useSettingsStore.getState().settings.maplibre_style,
|
||||
})) ?? buildTripMapSvg(tripRoute.days, mapFrame)
|
||||
: null
|
||||
// The other way to end up mapless, and the one that is not a failure: nothing in the
|
||||
// trip routed. Only worth saying when there were stops to route — a trip nobody has
|
||||
// planned yet is meant to print without a map.
|
||||
if (tripRoute && !tripMapSvg && Object.values(assignments).some(list => list?.length)) {
|
||||
console.warn(
|
||||
`[tripPdfMap] no map drawn: ${tripRoute.days.length} of ${sorted.length} day(s) produced a route.`
|
||||
+ ' A day needs two located stops, or one located stop with an accommodation either side of it.',
|
||||
)
|
||||
}
|
||||
const { route: tripRoute, mapSvg: tripMapSvg } = await drawTripRoute({
|
||||
days: sorted,
|
||||
assignments,
|
||||
storedAssignments: allStored,
|
||||
reservations,
|
||||
accommodations: accommodationList,
|
||||
optimizeFromAccommodation: useSettingsStore.getState().settings.optimize_from_accommodation,
|
||||
toursEnabled,
|
||||
places,
|
||||
}, { tripId: trip.id, unit, options: route })
|
||||
// Legs left straight, refused or skipped, make every figure below a floor; each says so.
|
||||
const unroutedLegs = tripRoute?.unroutedLegs ?? 0
|
||||
const totalDistanceLabel = tripRoute && tripRoute.totalDistance > 0
|
||||
? formatDistance(tripRoute.totalDistance / 1000, unit)
|
||||
? routeDistanceLabel(tripRoute.totalDistance, unroutedLegs, unit, tr)
|
||||
: null
|
||||
// Each day named and coloured exactly as the legend on the planner map, so the two
|
||||
// read as the same picture. Days that routed to nothing are already out of `days`.
|
||||
@@ -311,12 +265,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
<span class="trip-map-title">${escHtml(tr('pdf.mapTitle'))}</span>
|
||||
${totalDistanceLabel ? `<span class="trip-map-total">${escHtml(tr('pdf.distanceLabel'))}: ${escHtml(totalDistanceLabel)}</span>` : ''}
|
||||
</div>
|
||||
${unroutedLegs ? `<div class="trip-map-note">${escHtml(tr('map.overview.unrouted', { count: unroutedLegs }))}</div>` : ''}
|
||||
${tripMapSvg}
|
||||
<div class="trip-map-legend">
|
||||
${tripRoute.days.filter(d => d.lines.length).map(d => `<span class="trip-map-leg">
|
||||
<span class="trip-map-dot" style="background:${hexColour(d.color.line)}"></span>
|
||||
${escHtml(d.title || tr('dayplan.dayN', { n: d.dayNumber }))}
|
||||
<span class="trip-map-leg-dist">${escHtml(formatDistance(d.distance / 1000, unit))}</span>
|
||||
<span class="trip-map-leg-dist">${escHtml(routeDistanceLabel(d.distance, d.unroutedLegs ?? 0, unit, tr))}</span>
|
||||
</span>`).join('')}
|
||||
</div>
|
||||
<div class="trip-map-credit">${escHtml(tr('pdf.mapCredit'))}</div>
|
||||
@@ -744,6 +699,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
.trip-map-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.trip-map-title { font-size: 11px; font-weight: 600; letter-spacing: 1.4px; text-transform: uppercase; color: #64748b; }
|
||||
.trip-map-total { font-size: 12px; font-weight: 600; color: #334155; }
|
||||
.trip-map-note { font-size: 9px; font-weight: 600; margin: -4px 0 10px; }
|
||||
.trip-map-svg { width: 100%; height: auto; border-radius: 8px; border: 1px solid #e2e8f0; display: block; }
|
||||
.trip-map-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 12px; }
|
||||
.trip-map-leg { display: flex; align-items: center; gap: 6px; font-size: 9px; color: #475569; }
|
||||
@@ -933,6 +889,7 @@ ${daysHtml}
|
||||
${pluginSectionsHtml}
|
||||
</body></html>`
|
||||
markdown.dispose()
|
||||
if (signal?.aborted) return
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AssignmentsMap } from '../../types'
|
||||
import { isServiceStopType } from '../Roadtrip/roadtripModel'
|
||||
|
||||
interface WithTravelers { travelers?: { user_id: number }[] | null }
|
||||
|
||||
@@ -27,3 +28,19 @@ export function onlyMyPlan<R extends WithTravelers>(
|
||||
reservations: reservations.filter(r => mine(r.travelers)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stops the day plan lists, out of everything the store holds for the trip.
|
||||
*
|
||||
* The stop a booked night wrote onto its check-in day is out whatever the switch says:
|
||||
* the day already shows that booking as its accommodation block, and the row would be
|
||||
* the same hotel a second time. The service stops go with the setting. One predicate
|
||||
* for the whole document, so the day lists, the route map, the cover's planned count
|
||||
* and every cost total agree with the plan and with each other, whichever shell asked.
|
||||
*/
|
||||
export function planAssignments(assignments: AssignmentsMap, showServiceStops: boolean): AssignmentsMap {
|
||||
return Object.fromEntries(Object.entries(assignments).map(([dayId, list]) => [
|
||||
dayId,
|
||||
(list || []).filter(a => a.accommodation_id == null && (showServiceStops || !isServiceStopType(a.place?.stop_type))),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../types';
|
||||
import type { ViasByDay } from '../../utils/dayRouteVias';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import type { TourStageStops } from '../Map/dayTourProjection';
|
||||
import {
|
||||
routeTrip,
|
||||
type TripRouteInputs,
|
||||
type TripRouteProgress,
|
||||
type TripRouteSummary,
|
||||
} from '../Map/tripRouteGeometry';
|
||||
import { renderTripMapImage } from './tripMapImage';
|
||||
import { buildTripMapSvg } from './tripMapSvg';
|
||||
|
||||
/** What the planner knows about the trip's route beyond the plan, and how it follows the routing. */
|
||||
export interface TripPdfRouteOptions {
|
||||
/** The roads picked in the road trip (#2580), so the print bends where the planner map does. */
|
||||
viasByDay?: ViasByDay;
|
||||
/** Where the stages of the trip's multi-day tours run. */
|
||||
tourStageStops?: TourStageStops;
|
||||
/** Stops routing early: the legs asked by then are printed, the rest stay straight and are marked. */
|
||||
signal?: AbortSignal;
|
||||
onProgress?: TripRouteProgress;
|
||||
}
|
||||
|
||||
interface TripPdfRoute {
|
||||
route: TripRouteSummary | null;
|
||||
mapSvg: string | null;
|
||||
}
|
||||
|
||||
const MAP_FRAME = { width: 720, height: 420 };
|
||||
|
||||
/**
|
||||
* The trip's route as one map (#1736), from the builder the planner overview uses and
|
||||
* fed what the overview is fed, so the document and the screen show the same roads.
|
||||
*
|
||||
* Every leg is waited for: a deadline here printed a month-long trip with most of its
|
||||
* days as straight lines and a total that looked final (#2876). Fail-safe all the same:
|
||||
* a routing failure means no map rather than no PDF.
|
||||
*/
|
||||
export async function drawTripRoute(
|
||||
input: Omit<TripRouteInputs, 'viasByDay' | 'tourStageStops'>,
|
||||
{ tripId, unit, options = {} }: { tripId: number; unit: DistanceUnit; options?: TripPdfRouteOptions }
|
||||
): Promise<TripPdfRoute> {
|
||||
const route = await routeTrip(
|
||||
{ ...input, viasByDay: options.viasByDay, tourStageStops: options.tourStageStops },
|
||||
{ profile: 'driving', tripId, unit, signal: options.signal, onProgress: options.onProgress }
|
||||
).catch((err: unknown) => {
|
||||
// Logged rather than swallowed: a map that is silently absent looks exactly like a
|
||||
// trip that has no route, and the two need different answers from whoever is
|
||||
// looking. The export itself carries on — the itinerary matters more than the map.
|
||||
console.warn('[tripPdfMap] routing the trip failed; the export continues without a map', err);
|
||||
return null;
|
||||
});
|
||||
if (!route) return { route: null, mapSvg: null };
|
||||
// A real basemap first — at city scale the bundled outlines are a country-sized
|
||||
// blank, and only streets carry context that small. The outline map is what is left
|
||||
// when there is no WebGL, no network, or a style that will not load.
|
||||
const frame = { ...MAP_FRAME, formatDistance: (km: number) => formatDistance(km, unit) };
|
||||
const style = useSettingsStore.getState().settings.maplibre_style;
|
||||
const mapSvg = (await renderTripMapImage(route.days, { ...frame, style })) ?? buildTripMapSvg(route.days, frame);
|
||||
// The other way to end up mapless, and the one that is not a failure: nothing in the
|
||||
// trip routed. Only worth saying when there were stops to route — a trip nobody has
|
||||
// planned yet is meant to print without a map.
|
||||
if (!mapSvg && Object.values(input.assignments).some((list) => list?.length)) {
|
||||
console.warn(
|
||||
`[tripPdfMap] no map drawn: ${route.days.length} of ${input.days.length} day(s) produced a route.` +
|
||||
' A day needs two located stops, or one located stop with an accommodation either side of it.'
|
||||
);
|
||||
}
|
||||
return { route, mapSvg };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-NOTESFILESCOSTS-001 to FE-PLANNER-NOTESFILESCOSTS-003
|
||||
// FE-PLANNER-NOTESFILESCOSTS-001 to FE-PLANNER-NOTESFILESCOSTS-004
|
||||
import { createRef, type ComponentProps } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -28,16 +28,13 @@ function setup(overrides: Partial<Props> = {}) {
|
||||
linkAndFiles.mockClear();
|
||||
costs.mockClear();
|
||||
const props: Props = {
|
||||
notes: 'Gate B',
|
||||
onNotesChange: vi.fn(),
|
||||
url: 'https://example.com',
|
||||
onUrlChange: vi.fn(),
|
||||
form: { notes: 'Gate B', url: 'https://example.com' },
|
||||
set: vi.fn(),
|
||||
reservationId: 9,
|
||||
tripFiles: [],
|
||||
pendingFiles: [],
|
||||
fileInputRef: createRef<HTMLInputElement>(),
|
||||
attach: {
|
||||
uploadingFile: false,
|
||||
handleFileChange: vi.fn(),
|
||||
attachedFiles: [],
|
||||
removePending: vi.fn(),
|
||||
@@ -45,6 +42,7 @@ function setup(overrides: Partial<Props> = {}) {
|
||||
detachFile: vi.fn(),
|
||||
},
|
||||
canAttach: true,
|
||||
saving: false,
|
||||
showCosts: true,
|
||||
pendingExpense: null,
|
||||
expense: { reset: vi.fn(), take: vi.fn(), create: vi.fn(), edit: vi.fn(), remove: vi.fn() },
|
||||
@@ -61,7 +59,7 @@ describe('BookingNotesFilesCosts', () => {
|
||||
const area = screen.getByPlaceholderText('Additional notes...');
|
||||
expect(area).toHaveValue('Gate B');
|
||||
fireEvent.change(area, { target: { value: 'Gate C' } });
|
||||
expect(props.onNotesChange).toHaveBeenCalledWith('Gate C');
|
||||
expect(props.set).toHaveBeenCalledWith('notes', 'Gate C');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-NOTESFILESCOSTS-002: hands the link, the files and the costs their parts of the booking', () => {
|
||||
@@ -70,7 +68,6 @@ describe('BookingNotesFilesCosts', () => {
|
||||
expect(linkAndFiles).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: 'https://example.com',
|
||||
onUrlChange: props.onUrlChange,
|
||||
reservationId: 9,
|
||||
canAttach: true,
|
||||
uploading: false,
|
||||
@@ -80,6 +77,8 @@ describe('BookingNotesFilesCosts', () => {
|
||||
onDetached: props.attach.detachFile,
|
||||
})
|
||||
);
|
||||
linkAndFiles.mock.calls[0][0].onUrlChange('https://example.org');
|
||||
expect(props.set).toHaveBeenCalledWith('url', 'https://example.org');
|
||||
expect(costs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reservationId: 9,
|
||||
@@ -99,4 +98,10 @@ describe('BookingNotesFilesCosts', () => {
|
||||
expect(screen.queryByTestId('costs')).toBeNull();
|
||||
expect(linkAndFiles).toHaveBeenCalledWith(expect.objectContaining({ reservationId: undefined }));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-NOTESFILESCOSTS-004: shows the files as uploading while the booking saves', () => {
|
||||
setup({ saving: true });
|
||||
|
||||
expect(linkAndFiles).toHaveBeenCalledWith(expect.objectContaining({ uploading: true }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,24 +13,21 @@ import type { useBookingFileAttach } from './useBookingFileAttach';
|
||||
* files row, and the costs linked to this booking when the budget addon is on.
|
||||
*/
|
||||
export function BookingNotesFilesCosts({
|
||||
notes,
|
||||
onNotesChange,
|
||||
url,
|
||||
onUrlChange,
|
||||
form,
|
||||
set,
|
||||
reservationId,
|
||||
tripFiles,
|
||||
pendingFiles,
|
||||
fileInputRef,
|
||||
attach,
|
||||
canAttach,
|
||||
saving,
|
||||
showCosts,
|
||||
pendingExpense,
|
||||
expense,
|
||||
}: {
|
||||
notes: string;
|
||||
onNotesChange: (notes: string) => void;
|
||||
url: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
form: { notes: string; url: string };
|
||||
set: (field: 'notes' | 'url', value: string) => void;
|
||||
/** The saved booking; unset while it is being created. */
|
||||
reservationId: number | undefined;
|
||||
tripFiles: TripFile[];
|
||||
@@ -38,6 +35,8 @@ export function BookingNotesFilesCosts({
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
attach: ReturnType<typeof useBookingFileAttach>;
|
||||
canAttach: boolean;
|
||||
/** While the save runs the picked files are on their way, so no more can join them. */
|
||||
saving: boolean;
|
||||
showCosts: boolean;
|
||||
pendingExpense: { total_price: number; currency?: string | null; category: string } | null;
|
||||
expense: ReturnType<typeof useBookingExpenseIntent>;
|
||||
@@ -47,8 +46,8 @@ export function BookingNotesFilesCosts({
|
||||
<>
|
||||
<EditorField label={t('reservations.notes')}>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
className={TEXTAREA}
|
||||
@@ -56,8 +55,8 @@ export function BookingNotesFilesCosts({
|
||||
</EditorField>
|
||||
|
||||
<BookingLinkAndFiles
|
||||
url={url}
|
||||
onUrlChange={onUrlChange}
|
||||
url={form.url}
|
||||
onUrlChange={(url) => set('url', url)}
|
||||
labelClass={LABEL}
|
||||
inputClass={INPUT}
|
||||
reservationId={reservationId}
|
||||
@@ -68,7 +67,7 @@ export function BookingNotesFilesCosts({
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={attach.handleFileChange}
|
||||
canAttach={canAttach}
|
||||
uploading={attach.uploadingFile}
|
||||
uploading={saving}
|
||||
onLinked={attach.linkFile}
|
||||
onDetached={attach.detachFile}
|
||||
/>
|
||||
|
||||
@@ -1455,7 +1455,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
assignments={assignments}
|
||||
assignments={assignments} roadtripViasByDay={props.roadtripViasByDay} tourStageStops={props.tourStageStops}
|
||||
reservations={reservations}
|
||||
allConnectionsShown={allConnectionsShown}
|
||||
onToggleAllConnections={onToggleAllConnections}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
import type { TourStageStops } from '../Map/dayTourProjection'
|
||||
import type { ViasByDay } from '../../utils/dayRouteVias'
|
||||
|
||||
interface DayPlanSidebarToolbarProps {
|
||||
tripId: number
|
||||
@@ -19,6 +21,9 @@ interface DayPlanSidebarToolbarProps {
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
/** Handed on to the export, so the PDF's route map bends where the planner's does. */
|
||||
roadtripViasByDay?: ViasByDay
|
||||
tourStageStops?: TourStageStops
|
||||
allConnectionsShown?: boolean
|
||||
onToggleAllConnections?: () => void
|
||||
dayNotes: Record<string, DayNote[]>
|
||||
@@ -50,7 +55,7 @@ interface DayPlanSidebarToolbarProps {
|
||||
}
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes, roadtripViasByDay, tourStageStops,
|
||||
allConnectionsShown = false, onToggleAllConnections,
|
||||
t, locale, toast,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
@@ -95,6 +100,8 @@ export function DayPlanSidebarToolbar({
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
dayNotes={dayNotes}
|
||||
roadtripViasByDay={roadtripViasByDay}
|
||||
tourStageStops={tourStageStops}
|
||||
t={t}
|
||||
locale={locale}
|
||||
toast={toast}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-107
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-109
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -9,6 +9,8 @@ import { useAddonStore } from '../../store/addonStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { plannerSave } from '../../../tests/helpers/bookingSave';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||
import {
|
||||
buildUser,
|
||||
buildTrip,
|
||||
@@ -259,7 +261,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' })
|
||||
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -280,7 +283,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'confirmed' })
|
||||
expect.objectContaining({ status: 'confirmed' }),
|
||||
expect.anything(),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
@@ -392,7 +396,7 @@ describe('ReservationModal', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toBeUndefined();
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
||||
@@ -452,7 +456,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -466,7 +471,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Louvre Museum', type: 'event' })
|
||||
expect.objectContaining({ title: 'Louvre Museum', type: 'event' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -480,7 +486,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Airport Parking P1', type: 'parking' })
|
||||
expect.objectContaining({ title: 'Airport Parking P1', type: 'parking' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -502,13 +509,15 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByPlaceholderText(/e\.g\. ABC12345/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
|
||||
it('FE-PLANNER-RESMODAL-036: a file picked on an existing reservation waits in the list and goes up on Update', async () => {
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' });
|
||||
const { save: onSave } = plannerSave(res);
|
||||
render(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
reservation={res}
|
||||
onSave={onSave}
|
||||
onFileUpload={onFileUpload}
|
||||
/>
|
||||
);
|
||||
@@ -516,7 +525,10 @@ describe('ReservationModal', () => {
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const testFile = new File(['content'], 'boarding-pass.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||
expect(fd.get('file')).toBeTruthy();
|
||||
@@ -646,7 +658,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -707,7 +720,7 @@ describe('ReservationModal', () => {
|
||||
await pickType(/^Other$/i);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' }), expect.anything()));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||
@@ -774,7 +787,8 @@ describe('ReservationModal', () => {
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'hotel' })
|
||||
expect.objectContaining({ type: 'hotel' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1064,7 +1078,7 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-062: the parsed source document is seeded as a pending file and uploaded after save', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 81 });
|
||||
const { save: onSave } = plannerSave({ id: 81 });
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const sourceFile = new File(['x'], 'booking.pdf', { type: 'application/pdf' });
|
||||
const prefill = hotelPrefill({ _sourceFiles: [sourceFile] });
|
||||
@@ -1241,6 +1255,7 @@ describe('ReservationModal', () => {
|
||||
notes: 'Bring sunscreen',
|
||||
url: 'https://boats.example',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1252,7 +1267,7 @@ describe('ReservationModal', () => {
|
||||
];
|
||||
|
||||
it('FE-PLANNER-RESMODAL-072: toggling travelers persists them once the booking has an id', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 90 });
|
||||
const { save: onSave } = plannerSave({ id: 90 });
|
||||
let body: { user_ids: number[] } | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/90/travelers', async ({ request }) => {
|
||||
@@ -1292,10 +1307,8 @@ describe('ReservationModal', () => {
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-074: a failing traveler write surfaces an error toast', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 92 });
|
||||
it('FE-PLANNER-RESMODAL-074: a failing traveler write fails the save, so the planner keeps the dialog open', async () => {
|
||||
const { save: onSave, failures } = plannerSave({ id: 92 });
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/92/travelers', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
@@ -1305,8 +1318,8 @@ describe('ReservationModal', () => {
|
||||
await userEvent.click(within(await openTravelers()).getByText('alice'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined));
|
||||
delete window.__addToast;
|
||||
await waitFor(() => expect(failures).toHaveLength(1));
|
||||
expect(screen.getByRole('button', { name: /^Add$/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
// ── Linked cost actions ─────────────────────────────────────────────────────
|
||||
@@ -1466,18 +1479,70 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-078: a failing upload to an existing booking shows the upload error', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
it('FE-PLANNER-RESMODAL-078: a failing upload on Update keeps the file listed and says the booking is saved', async () => {
|
||||
const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full'));
|
||||
const res = buildReservation({ id: 12, title: 'My Trip', type: 'other' });
|
||||
const { save: onSave, failures } = plannerSave(res);
|
||||
|
||||
render(<ReservationModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||
render(<ReservationModal {...defaultProps} reservation={res} onSave={onSave} onFileUpload={onFileUpload} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'fail.pdf', { type: 'application/pdf' })] } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined));
|
||||
delete window.__addToast;
|
||||
await waitFor(() => expect(failures).toHaveLength(1));
|
||||
expect(failures[0].message).toBe('The booking is saved, but some files did not upload. Save again to send them.');
|
||||
expect(screen.getByText('fail.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-108: a new booking whose second file fails retries as an update with only that file (#2883)', async () => {
|
||||
const onFileUpload = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('413'))
|
||||
.mockResolvedValue(undefined);
|
||||
const { save: onSave, failures } = plannerSave({ id: 95 });
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Ferry');
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['a'], 'ok.pdf', { type: 'application/pdf' })] } });
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['b'], 'fail.pdf', { type: 'application/pdf' })] } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(failures).toHaveLength(1));
|
||||
expect(onSave.mock.calls[0][1]?.saved).toBeUndefined();
|
||||
expect(screen.queryByText('ok.pdf')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('fail.pdf')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onFileUpload).toHaveBeenCalledTimes(3));
|
||||
expect(onSave).toHaveBeenCalledTimes(2);
|
||||
expect(onSave.mock.calls[1][1]?.saved).toEqual({ id: 95 });
|
||||
const [fd] = onFileUpload.mock.calls[2] as [FormData];
|
||||
expect((fd.get('file') as File).name).toBe('fail.pdf');
|
||||
expect(fd.get('reservation_id')).toBe('95');
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-109: offline a file picked on an existing booking holds the save back (#2601)', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 14, title: 'My Trip', type: 'other' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} onSave={onSave} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'ticket.pdf', { type: 'application/pdf' })] } });
|
||||
|
||||
setForcedOffline(true);
|
||||
try {
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/need a connection/), 'error', undefined));
|
||||
} finally {
|
||||
_resetNetworkMode();
|
||||
delete window.__addToast;
|
||||
}
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-079: a failing unlink reports the update error but still drops the row', async () => {
|
||||
@@ -1642,6 +1707,7 @@ describe('ReservationModal', () => {
|
||||
// carries a clock, and a bare date is what makes it an all-day event.
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reservation_time: '2026-05-02', reservation_end_time: '2026-05-02' }),
|
||||
expect.anything(),
|
||||
));
|
||||
});
|
||||
|
||||
|
||||
@@ -26,9 +26,10 @@ import { typeInfo } from './bookings/bookingsModel'
|
||||
import type { StatusTone } from './bookings/bookingParts'
|
||||
import { RESERVATION_TYPE_OPTIONS as TYPE_OPTIONS, reservationFieldsFrom, reservationFieldsFromPrefill, selectedDayDate } from './reservationFormModel'
|
||||
import { useReservationForm } from './useReservationForm'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, filesNeedConnection, type BookingSaveSteps } from './bookingFormModel'
|
||||
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
||||
import { useBookingFileAttach } from './useBookingFileAttach'
|
||||
import { useBookingFollowUp } from './useBookingFollowUp'
|
||||
import StayPinStatus from './StayPinStatus'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
@@ -44,7 +45,7 @@ const EMPTY_FORM = {
|
||||
interface ReservationModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null> & { title: string }) => Promise<Reservation | undefined>
|
||||
onSave: (data: Record<string, string | number | null> & { title: string }, steps?: BookingSaveSteps) => Promise<Reservation | undefined>
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
@@ -65,7 +66,6 @@ interface ReservationModalProps {
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense, prefill = null, tripMembers = [] }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
@@ -84,7 +84,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
isEndBeforeStart, endTimeBeforeStartTime, dateBounds: tripDateRange, startDate, startTime, setStartDate, setStartTime, takeStopDay,
|
||||
pickPlace, pickHotelPlace, pickHotelStart, pickHotelEnd, stayPin, saveData: buildSaveData,
|
||||
} = useReservationForm(EMPTY_FORM, days, places)
|
||||
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
|
||||
const attach = useBookingFileAttach({ reservation, files, setPendingFiles })
|
||||
const followUp = useBookingFollowUp({
|
||||
session: isOpen ? (reservation ?? prefill ?? 'new') : null,
|
||||
tripId, original: reservation, travelerIds, pendingFiles, setPendingFiles, upload: onFileUpload, t,
|
||||
})
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -92,6 +96,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// A try that stored the booking and failed on its files keeps the form as the
|
||||
// user left it; the places and stays that save refreshed must not reseed it.
|
||||
if (followUp.holdsSaved()) return
|
||||
// Match an existing place by name (exact, then loose contains) for hotels.
|
||||
const matchPlaceId = (name: string | undefined): string | number => {
|
||||
const n = (name || '').trim().toLowerCase()
|
||||
@@ -142,19 +149,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const entry = importedPriceEntry(prefill.metadata, form.type)
|
||||
if (entry) saveData.create_budget_entry = entry
|
||||
}
|
||||
const saved = await onSave(saveData as Record<string, string | number | null> & { title: string })
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
// result, edit → existing reservation), and only when it actually changed (#1517).
|
||||
const savedId = saved?.id ?? reservation?.id
|
||||
if (savedId && tripId) {
|
||||
const { changed, nextIds } = travelersChanged(reservation, travelerIds)
|
||||
if (changed) {
|
||||
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
}
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||
await uploadBookingFiles(onFileUpload, saved.id, pendingFiles, form.title)
|
||||
}
|
||||
const saved = await onSave(saveData as Record<string, string | number | null> & { title: string }, followUp.steps(form.title))
|
||||
// Open the Costs editor for the saved booking when the user asked to
|
||||
// create/edit its linked expense (gated on saved?.id).
|
||||
const expenseRequest = expenseRequestAfterSave(expense.take(), saved, form, days)
|
||||
@@ -391,16 +386,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
|
||||
<BookingNotesFilesCosts
|
||||
notes={form.notes}
|
||||
onNotesChange={value => set('notes', value)}
|
||||
url={form.url}
|
||||
onUrlChange={value => set('url', value)}
|
||||
form={form}
|
||||
set={set}
|
||||
reservationId={reservation?.id}
|
||||
tripFiles={files}
|
||||
pendingFiles={pendingFiles}
|
||||
fileInputRef={fileInputRef}
|
||||
attach={attach}
|
||||
canAttach={!!onFileUpload}
|
||||
saving={isSaving}
|
||||
showCosts={isBudgetEnabled}
|
||||
pendingExpense={pendingExpense}
|
||||
expense={expense}
|
||||
|
||||
@@ -138,6 +138,34 @@ describe('TransitSearchPanel', () => {
|
||||
expect(payload.metadata.transit.legs[1]).toMatchObject({ mode: 'SUBWAY', line: 'U2', line_color: '#FF3300', headsign: 'Ruhleben' })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003b: routes Google answered name Google Maps and are saved as Google’s (#2850)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onAdd = vi.fn().mockResolvedValue({})
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'google' })
|
||||
render(<TransitSearchPanel {...makeProps({ onAdd })} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
await user.click(await screen.findByText(/08:30 – 09:00/))
|
||||
|
||||
// Google's terms want the words as written, so the browser must not translate them.
|
||||
expect(screen.getByText('Google Maps')).toHaveAttribute('translate', 'no')
|
||||
expect(screen.queryByRole('link', { name: 'Transitous' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }))
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalled())
|
||||
expect(onAdd.mock.calls[0][0].metadata.transit.provider).toBe('google')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-003c: routes Transitous answered credit Transitous with its sources link', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndTo(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByRole('link', { name: 'Transitous' })).toHaveAttribute('href', 'https://transitous.org/sources/')
|
||||
expect(screen.queryByText('Google Maps')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-004: search failure shows the empty state, not a crash', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
@@ -604,7 +604,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
notes: null,
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
provider: provider ?? 'transitous',
|
||||
duration: it.duration,
|
||||
transfers: it.transfers,
|
||||
walk_seconds: it.walkSeconds,
|
||||
@@ -760,7 +760,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
))}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(10.5px * var(--fs-scale-caption, 1))', textAlign: 'center', marginTop: 2 }}>
|
||||
{t('transit.attribution')}{' '}
|
||||
<a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>
|
||||
{/* Named after the backend that answered; Google's terms want "Google Maps" as written, never translated. */}
|
||||
{provider === 'google'
|
||||
? <span translate="no">Google Maps</span>
|
||||
: <a href="https://transitous.org/sources/" target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline' }}>Transitous</a>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-082
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-083
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -9,6 +9,7 @@ import { useAddonStore } from '../../store/addonStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { plannerSave } from '../../../tests/helpers/bookingSave';
|
||||
import {
|
||||
buildUser,
|
||||
buildTrip,
|
||||
@@ -189,7 +190,7 @@ describe('TransportModal', () => {
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }), expect.anything());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
|
||||
@@ -199,7 +200,7 @@ describe('TransportModal', () => {
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }), expect.anything());
|
||||
});
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
@@ -230,7 +231,7 @@ describe('TransportModal', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
expect(onSave.mock.calls[0][0].create_budget_entry).toBeUndefined();
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
||||
@@ -269,16 +270,20 @@ describe('TransportModal', () => {
|
||||
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
|
||||
it('FE-PLANNER-TRANSMODAL-018: a file picked on an existing transport waits and goes up on Update with correct FormData', async () => {
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
|
||||
const { save: onSave } = plannerSave(res);
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} onFileUpload={onFileUpload} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||
expect(fd.get('file')).toBeTruthy();
|
||||
@@ -407,8 +412,7 @@ describe('TransportModal', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
|
||||
const savedReservation = buildReservation({ id: 99, type: 'flight' });
|
||||
const onSave = vi.fn().mockResolvedValue(savedReservation);
|
||||
const { save: onSave } = plannerSave(buildReservation({ id: 99, type: 'flight' }));
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
|
||||
@@ -1078,7 +1082,7 @@ describe('TransportModal', () => {
|
||||
];
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-044: assigned travelers are written back after the save resolves', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 60 });
|
||||
const { save: onSave } = plannerSave({ id: 60 });
|
||||
let body: { user_ids: number[] } | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/60/travelers', async ({ request }) => {
|
||||
@@ -1096,10 +1100,8 @@ describe('TransportModal', () => {
|
||||
expect(body!.user_ids).toEqual([2]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-045: a failing traveler write surfaces an error toast', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 61 });
|
||||
it('FE-PLANNER-TRANSMODAL-045: a failing traveler write fails the save, so the planner keeps the dialog open', async () => {
|
||||
const { save: onSave, failures } = plannerSave({ id: 61 });
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/61/travelers', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
@@ -1109,8 +1111,37 @@ describe('TransportModal', () => {
|
||||
await userEvent.click(within(await openTravelers()).getByText('alice'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined));
|
||||
delete window.__addToast;
|
||||
await waitFor(() => expect(failures).toHaveLength(1));
|
||||
expect(screen.getByRole('button', { name: /^Add$/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-083: a new transport whose upload fails retries as an update with only that file (#2883)', async () => {
|
||||
const onFileUpload = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('413'))
|
||||
.mockResolvedValue(undefined);
|
||||
const { save: onSave, failures } = plannerSave({ id: 64 });
|
||||
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['a'], 'ok.pdf', { type: 'application/pdf' })] } });
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['b'], 'fail.pdf', { type: 'application/pdf' })] } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(failures).toHaveLength(1));
|
||||
expect(screen.queryByText('ok.pdf')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('fail.pdf')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onFileUpload).toHaveBeenCalledTimes(3));
|
||||
expect(onSave.mock.calls[0][1]?.saved).toBeUndefined();
|
||||
expect(onSave.mock.calls[1][1]?.saved).toEqual({ id: 64 });
|
||||
const [fd] = onFileUpload.mock.calls[2] as [FormData];
|
||||
expect((fd.get('file') as File).name).toBe('fail.pdf');
|
||||
expect(fd.get('reservation_id')).toBe('64');
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
// ── Linked cost actions ─────────────────────────────────────────────────────
|
||||
@@ -1192,18 +1223,19 @@ describe('TransportModal', () => {
|
||||
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-050: a failing upload to an existing booking shows the upload error', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
it('FE-PLANNER-TRANSMODAL-050: a failing upload on Update keeps the file listed and says the booking is saved', async () => {
|
||||
const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full'));
|
||||
const res = buildReservation({ id: 20, type: 'train', title: 'Eurostar' });
|
||||
const { save: onSave, failures } = plannerSave(res);
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} onFileUpload={onFileUpload} />);
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'fail.pdf', { type: 'application/pdf' })] } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined));
|
||||
delete window.__addToast;
|
||||
await waitFor(() => expect(failures).toHaveLength(1));
|
||||
expect(failures[0].message).toBe('The booking is saved, but some files did not upload. Save again to send them.');
|
||||
expect(screen.getByText('fail.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-051: a failing unlink reports the update error', async () => {
|
||||
@@ -1271,7 +1303,7 @@ describe('TransportModal', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-055: an existing traveler list is toggled off and written back empty', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 63 });
|
||||
const { save: onSave } = plannerSave({ id: 63 });
|
||||
let body: { user_ids: number[] } | null = null;
|
||||
server.use(
|
||||
http.put('/api/trips/1/reservations/63/travelers', async ({ request }) => {
|
||||
|
||||
@@ -31,9 +31,10 @@ import {
|
||||
transportDayOptions, type StationWaypointForm, type WaypointForm,
|
||||
} from './transportEndpoints'
|
||||
import { useTransportForm } from './useTransportForm'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, travelersChanged, filesNeedConnection, uploadBookingFiles } from './bookingFormModel'
|
||||
import { expenseRequestAfterSave, pendingImportExpense, travelerIdsOf, filesNeedConnection, type BookingSaveSteps } from './bookingFormModel'
|
||||
import { useBookingExpenseIntent } from './useBookingExpenseIntent'
|
||||
import { useBookingFileAttach } from './useBookingFileAttach'
|
||||
import { useBookingFollowUp } from './useBookingFollowUp'
|
||||
|
||||
const defaultForm = {
|
||||
...EMPTY_TRANSPORT_FIELDS,
|
||||
@@ -48,7 +49,7 @@ const defaultForm = {
|
||||
interface TransportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, any> & { title: string }) => Promise<Reservation | undefined>
|
||||
onSave: (data: Record<string, any> & { title: string }, steps?: BookingSaveSteps) => Promise<Reservation | undefined>
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
@@ -84,7 +85,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
const setReservationTravelers = useTripStore(s => s.setReservationTravelers)
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
// Set right before submitting when the user clicked "create/edit expense", so
|
||||
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||
@@ -99,11 +99,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
seed, payload, writesFlightLegs, writesTrainLegs,
|
||||
} = useTransportForm(defaultForm)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const attach = useBookingFileAttach({ reservation, files, onFileUpload, setPendingFiles, toast, t })
|
||||
const attach = useBookingFileAttach({ reservation, files, setPendingFiles })
|
||||
const followUp = useBookingFollowUp({
|
||||
session: isOpen ? (reservation ?? prefill ?? 'new') : null,
|
||||
tripId, original: reservation, travelerIds, pendingFiles, setPendingFiles, upload: onFileUpload, t,
|
||||
})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
// A try that stored the booking and failed on its files keeps the form as the
|
||||
// user left it; the costs that save reloaded must not reseed it.
|
||||
if (!isOpen || followUp.holdsSaved()) return
|
||||
setTravelerIds(travelerIdsOf(reservation))
|
||||
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
|
||||
// Either way the init reads the same fields — `reservation` still decides
|
||||
@@ -143,19 +149,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
try {
|
||||
const saved = await onSave(payload(days, {
|
||||
reservation, prefill, budgetEnabled: isBudgetEnabled, anchorOnStations: false, url: form.url,
|
||||
}))
|
||||
// Persist the traveler assignment once we have the reservation id (create → save
|
||||
// result, edit → existing reservation), and only when it actually changed (#1517).
|
||||
const savedId = saved?.id ?? reservation?.id
|
||||
if (savedId && tripId) {
|
||||
const { changed, nextIds } = travelersChanged(reservation, travelerIds)
|
||||
if (changed) {
|
||||
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
}
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||
await uploadBookingFiles(onFileUpload, saved.id, pendingFiles, form.title)
|
||||
}
|
||||
}), followUp.steps(form.title))
|
||||
// The user asked to create/edit the linked expense — open the Costs editor
|
||||
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
||||
const expenseRequest = expenseRequestAfterSave(expense.take(), saved, form, days)
|
||||
@@ -546,16 +540,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</EditorField>
|
||||
|
||||
<BookingNotesFilesCosts
|
||||
notes={form.notes}
|
||||
onNotesChange={value => set('notes', value)}
|
||||
url={form.url}
|
||||
onUrlChange={value => set('url', value)}
|
||||
form={form}
|
||||
set={set}
|
||||
reservationId={reservation?.id}
|
||||
tripFiles={files}
|
||||
pendingFiles={pendingFiles}
|
||||
fileInputRef={fileInputRef}
|
||||
attach={attach}
|
||||
canAttach={!!onFileUpload}
|
||||
saving={isSaving}
|
||||
showCosts={isBudgetEnabled}
|
||||
pendingExpense={pendingExpense}
|
||||
expense={expense}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-018
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-020
|
||||
import { act, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { buildDay, buildDayNote, buildTrip, buildUser } from '../../../tests/helpers/factories'
|
||||
@@ -111,14 +111,14 @@ describe('TripExportModal', () => {
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-016: while one export works, every row waits', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finish: (() => void) | null = null
|
||||
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = res }))
|
||||
let finish = () => {}
|
||||
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
|
||||
render(<TripExportModal {...makeProps()} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /dayplan\.gpxAll/ })).toBeDisabled())
|
||||
expect(screen.getByRole('button', { name: /mobileTrip\.icsDownload/ })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /dayplan\.pdf/ })).toBeDisabled()
|
||||
finish!()
|
||||
finish()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /dayplan\.gpxAll/ })).toBeEnabled())
|
||||
})
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('TripExportModal', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<TripExportModal {...makeProps({ onClose })} />)
|
||||
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
|
||||
const exportBackdrop = screen.getByRole('dialog', { name: 'dayplan.export' }).parentElement!
|
||||
const exportBackdrop = screen.getByRole('dialog', { name: 'dayplan.export' }).parentElement as HTMLElement
|
||||
const subscribe = screen.getByTestId('ics-subscribe-modal')
|
||||
// Both hang off the body; the later one paints above the earlier.
|
||||
expect(exportBackdrop.compareDocumentPosition(subscribe) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
@@ -291,4 +291,41 @@ describe('TripExportModal', () => {
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ onlyUserId: 7 })
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-019: while the PDF routes the row counts the legs, and the rest can be left out (#2876)', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finish = () => {}
|
||||
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
|
||||
const roadtripViasByDay = { 10: [] }
|
||||
const tourStageStops = new Map()
|
||||
render(<TripExportModal {...makeProps({ roadtripViasByDay, tourStageStops })} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
const { route } = vi.mocked(downloadTripPDF).mock.calls[0][0]
|
||||
expect(route).toMatchObject({ viasByDay: roadtripViasByDay, tourStageStops })
|
||||
|
||||
act(() => route?.onProgress?.(3, 10))
|
||||
expect(screen.getByText('dayplan.pdfRouting|3|10')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'dayplan.pdfSkipRouting' }))
|
||||
expect(route?.signal?.aborted).toBe(true)
|
||||
expect(screen.queryByRole('button', { name: 'dayplan.pdfSkipRouting' })).toBeNull()
|
||||
await act(async () => finish())
|
||||
})
|
||||
|
||||
it('FE-PLANNER-EXPORTMODAL-020: closing the dialog while the PDF routes gives the export up', async () => {
|
||||
const user = userEvent.setup()
|
||||
let finish = () => {}
|
||||
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
|
||||
const props = makeProps()
|
||||
const { rerender } = render(<TripExportModal {...props} />)
|
||||
await user.click(screen.getByText('dayplan.pdf'))
|
||||
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
|
||||
const { route, signal } = vi.mocked(downloadTripPDF).mock.calls[0][0]
|
||||
|
||||
rerender(<TripExportModal {...props} isOpen={false} />)
|
||||
expect(route?.signal?.aborted).toBe(true)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
await act(async () => finish())
|
||||
expect(props.onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import { IcsSubscribeModal } from './IcsSubscribeModal'
|
||||
import { SoftPill } from './planParts'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
import type { TourStageStops } from '../Map/dayTourProjection'
|
||||
import type { ViasByDay } from '../../utils/dayRouteVias'
|
||||
import { useTripExport } from './useTripExport'
|
||||
|
||||
/**
|
||||
@@ -31,6 +33,9 @@ interface TripExportModalProps {
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
dayNotes: Record<string, DayNote[]>
|
||||
/** The road trip's vias and the tour stages, so the PDF's route map matches the planner's. */
|
||||
roadtripViasByDay?: ViasByDay
|
||||
tourStageStops?: TourStageStops
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
toast: ReturnType<typeof useToast>
|
||||
@@ -53,15 +58,16 @@ interface TripExportModalProps {
|
||||
*/
|
||||
export function TripExportModal({
|
||||
isOpen, onClose, tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, canManageShare = true,
|
||||
roadtripViasByDay, tourStageStops, t, locale, toast, canManageShare = true,
|
||||
}: TripExportModalProps) {
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
const titleId = useId()
|
||||
const {
|
||||
fileBase, offerMine, isRunning, anyRunning, exportPdf, downloadIcs, downloadGpx,
|
||||
fileBase, offerMine, isRunning, anyRunning, exportPdf, pdfRouting, pdfRoutingLabel, skipPdfRouting, downloadIcs, downloadGpx,
|
||||
} = useTripExport({
|
||||
tripId,
|
||||
data: { trip, days, places, assignments, categories, reservations, dayNotes },
|
||||
open: isOpen,
|
||||
data: { trip, days, places, assignments, categories, reservations, dayNotes, viasByDay: roadtripViasByDay, tourStageStops },
|
||||
t,
|
||||
locale,
|
||||
toast,
|
||||
@@ -94,7 +100,7 @@ export function TripExportModal({
|
||||
<ExportRow
|
||||
icon={FileText}
|
||||
title={t('dayplan.pdf')}
|
||||
sub={t('dayplan.pdfTooltip')}
|
||||
sub={pdfRoutingLabel('pdf') ?? t('dayplan.pdfTooltip')}
|
||||
busy={isRunning('pdf')}
|
||||
disabled={anyRunning}
|
||||
onClick={() => exportPdf()}
|
||||
@@ -103,12 +109,22 @@ export function TripExportModal({
|
||||
<ExportRow
|
||||
icon={UserRound}
|
||||
title={t('dayplan.pdfMine')}
|
||||
sub={t('dayplan.pdfMineSub')}
|
||||
sub={pdfRoutingLabel('pdf:mine') ?? t('dayplan.pdfMineSub')}
|
||||
busy={isRunning('pdf:mine')}
|
||||
disabled={anyRunning}
|
||||
onClick={() => exportPdf(true)}
|
||||
/>
|
||||
)}
|
||||
{pdfRouting && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={skipPdfRouting}
|
||||
className="self-end rounded-lg px-2 py-1 text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('dayplan.pdfSkipRouting')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-BOOKINGFORM-001 to -010: the travellers, picked files, attached files
|
||||
// FE-PLANNER-BOOKINGFORM-001 to -012: the travellers, picked files, attached files
|
||||
// and expense requests of the transport and booking forms, desktop and phone alike.
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
expenseRequestAfterSave,
|
||||
filesNeedConnection,
|
||||
pendingImportExpense,
|
||||
retriedSave,
|
||||
toggledTraveler,
|
||||
travelerIdsOf,
|
||||
travelersChanged,
|
||||
@@ -62,6 +63,40 @@ describe('bookingFormModel', () => {
|
||||
expect(upload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFORM-011: each landed file is reported, and the first failure stops the rest (#2883)', async () => {
|
||||
const files = [new File(['a'], 'a.pdf'), new File(['b'], 'fail.pdf'), new File(['c'], 'c.pdf')];
|
||||
const upload = vi.fn(async (fd: FormData) => {
|
||||
if ((fd.get('file') as File).name === 'fail.pdf') throw new Error('413');
|
||||
});
|
||||
const onUploaded = vi.fn();
|
||||
await expect(uploadBookingFiles(upload, 42, files, 'Hotel stay', onUploaded)).rejects.toThrow('413');
|
||||
expect(onUploaded.mock.calls).toEqual([[files[0]]]);
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFORM-012: a retried save creates no second cost and keeps the stay it already made', () => {
|
||||
const stored = buildReservation({ id: 6, accommodation_id: 31 } as Partial<Reservation>);
|
||||
expect(
|
||||
retriedSave(
|
||||
{ title: 'Ryokan', type: 'hotel', accommodation_id: null, create_budget_entry: { total_price: 90 } },
|
||||
stored
|
||||
)
|
||||
).toEqual({
|
||||
title: 'Ryokan',
|
||||
type: 'hotel',
|
||||
accommodation_id: 31,
|
||||
});
|
||||
expect(retriedSave({ title: 'Ryokan', type: 'hotel', accommodation_id: 12 }, stored)).toEqual({
|
||||
title: 'Ryokan',
|
||||
type: 'hotel',
|
||||
accommodation_id: 12,
|
||||
});
|
||||
expect(retriedSave({ title: 'ICE', type: 'train', create_budget_entry: true }, stored)).toEqual({
|
||||
title: 'ICE',
|
||||
type: 'train',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFORM-008: without an upload handler the picked files are skipped and the save is not rejected', async () => {
|
||||
await expect(uploadBookingFiles(undefined, 42, [new File(['a'], 'a.pdf')], 'Hotel stay')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -37,12 +37,17 @@ export function travelersChanged(reservation: Pick<Reservation, 'travelers'> | n
|
||||
* uploaded twice, and skipping the edit dropped them without a word (#2534).
|
||||
* Without an upload handler (a dialog opened with no file support) there is
|
||||
* nowhere to send them, so nothing is uploaded and the save carries on.
|
||||
*
|
||||
* Each file that lands is reported before the next one starts, and the first one
|
||||
* that fails stops the rest, so the form can keep exactly the files still to send
|
||||
* and a second save picks up where this one stopped (#2883).
|
||||
*/
|
||||
export async function uploadBookingFiles(
|
||||
upload: ((fd: FormData) => Promise<unknown>) | undefined,
|
||||
savedId: number,
|
||||
files: File[],
|
||||
description: string
|
||||
description: string,
|
||||
onUploaded?: (file: File) => void
|
||||
): Promise<void> {
|
||||
if (!upload) return;
|
||||
for (const file of files) {
|
||||
@@ -51,9 +56,39 @@ export async function uploadBookingFiles(
|
||||
fd.append('reservation_id', String(savedId));
|
||||
fd.append('description', description);
|
||||
await upload(fd);
|
||||
onUploaded?.(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a booking editor hands its save to the planner. The planner stores the
|
||||
* booking, waits for `afterSave` (the travellers and files that hang on it) and
|
||||
* only then reports success and closes the editor. When that part fails the
|
||||
* editor stays open with the booking stored, and passes it back as `saved` on
|
||||
* the next try, so the retry updates it instead of creating a second one.
|
||||
*/
|
||||
export interface BookingSaveSteps {
|
||||
saved?: Reservation;
|
||||
afterSave?: (saved: Reservation) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The update a retry sends for a booking the first try already created: the cost
|
||||
* that try linked is not created again, and a hotel keeps the stay it was given
|
||||
* rather than gaining a second one beside it.
|
||||
*/
|
||||
export function retriedSave<T extends Record<string, unknown>>(
|
||||
data: T,
|
||||
stored: Pick<Reservation, 'accommodation_id'>
|
||||
): T {
|
||||
const { create_budget_entry: _linkedCost, ...rest } = data;
|
||||
const update = rest as Record<string, unknown>;
|
||||
if (update.type === 'hotel' && !update.accommodation_id && stored.accommodation_id) {
|
||||
update.accommodation_id = stored.accommodation_id;
|
||||
}
|
||||
return update as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether saving now would have to upload picked files while TREK is offline. A
|
||||
* file cannot wait in the offline queue the way the booking can, so the form asks
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// FE-PLANNER-BOOKINGFILES-001 to -006: the Files row of the desktop booking and
|
||||
// FE-PLANNER-BOOKINGFILES-001 to -005: the Files row of the desktop booking and
|
||||
// transport dialogs.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTripFile } from '../../../tests/helpers/factories';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||
import type { TripFile } from '../../types';
|
||||
import { type BookingFileAttachOptions, useBookingFileAttach } from './useBookingFileAttach';
|
||||
|
||||
@@ -18,69 +17,36 @@ function changeEvent(file?: File): FileEvent {
|
||||
}
|
||||
|
||||
function setup(overrides: Partial<BookingFileAttachOptions> = {}, files: TripFile[] = []) {
|
||||
const toast = { success: vi.fn(), error: vi.fn() };
|
||||
const t = (key: string) => key;
|
||||
const view = renderHook(() => {
|
||||
return renderHook(() => {
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const attach = useBookingFileAttach({
|
||||
reservation: { id: 5, title: 'Hotel' },
|
||||
files,
|
||||
setPendingFiles,
|
||||
toast,
|
||||
t,
|
||||
...overrides,
|
||||
});
|
||||
const attach = useBookingFileAttach({ reservation: { id: 5 }, files, setPendingFiles, ...overrides });
|
||||
return { attach, pendingFiles };
|
||||
});
|
||||
return { ...view, toast };
|
||||
}
|
||||
|
||||
describe('useBookingFileAttach', () => {
|
||||
it('FE-PLANNER-BOOKINGFILES-001: a file on a saved booking goes up at once and the input clears', async () => {
|
||||
const onFileUpload = vi.fn(async (_fd: FormData) => undefined);
|
||||
const { result, toast } = setup({ onFileUpload });
|
||||
const e = changeEvent(new File(['a'], 'a.pdf'));
|
||||
await act(() => result.current.attach.handleFileChange(e));
|
||||
const fd = onFileUpload.mock.calls[0][0];
|
||||
expect(fd.get('reservation_id')).toBe('5');
|
||||
expect(fd.get('description')).toBe('Hotel');
|
||||
expect(toast.success).toHaveBeenCalledWith('reservations.toast.fileUploaded');
|
||||
it('FE-PLANNER-BOOKINGFILES-001: a file picked on a saved booking waits for the save like on a new one (#2883)', () => {
|
||||
const { result } = setup();
|
||||
const a = new File(['a'], 'a.pdf');
|
||||
const e = changeEvent(a);
|
||||
act(() => result.current.attach.handleFileChange(e));
|
||||
expect(result.current.pendingFiles).toEqual([a]);
|
||||
expect(e.target.value).toBe('');
|
||||
expect(result.current.attach.uploadingFile).toBe(false);
|
||||
expect(result.current.pendingFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFILES-002: a failed upload, or a dialog without one, says so', async () => {
|
||||
const onFileUpload = vi.fn(async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const failing = setup({ onFileUpload });
|
||||
await act(() => failing.result.current.attach.handleFileChange(changeEvent(new File(['a'], 'a.pdf'))));
|
||||
expect(failing.toast.error).toHaveBeenCalledWith('reservations.toast.uploadError');
|
||||
|
||||
const without = setup({ onFileUpload: undefined });
|
||||
await act(() => without.result.current.attach.handleFileChange(changeEvent(new File(['a'], 'a.pdf'))));
|
||||
expect(without.toast.error).toHaveBeenCalledWith('reservations.toast.uploadError');
|
||||
expect(without.toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFILES-003: on a new booking the file waits for the save and can be taken back', async () => {
|
||||
const onFileUpload = vi.fn(async () => undefined);
|
||||
const { result } = setup({ reservation: null, onFileUpload });
|
||||
it('FE-PLANNER-BOOKINGFILES-002: on a new booking the files gather and can be taken back', () => {
|
||||
const { result } = setup({ reservation: null });
|
||||
const a = new File(['a'], 'a.pdf');
|
||||
const b = new File(['b'], 'b.pdf');
|
||||
const e = changeEvent(a);
|
||||
await act(() => result.current.attach.handleFileChange(e));
|
||||
await act(() => result.current.attach.handleFileChange(changeEvent(b)));
|
||||
await act(() => result.current.attach.handleFileChange(changeEvent()));
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
expect(e.target.value).toBe('');
|
||||
act(() => result.current.attach.handleFileChange(changeEvent(a)));
|
||||
act(() => result.current.attach.handleFileChange(changeEvent(b)));
|
||||
act(() => result.current.attach.handleFileChange(changeEvent()));
|
||||
expect(result.current.pendingFiles).toEqual([a, b]);
|
||||
act(() => result.current.attach.removePending(0));
|
||||
expect(result.current.pendingFiles).toEqual([b]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFILES-004: files linked and detached in the dialog show on the booking', () => {
|
||||
it('FE-PLANNER-BOOKINGFILES-003: files linked and detached in the dialog show on the booking', () => {
|
||||
const files = [buildTripFile({ id: 1, reservation_id: 5 }), buildTripFile({ id: 2, reservation_id: null })];
|
||||
const { result } = setup({}, files);
|
||||
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1]);
|
||||
@@ -90,25 +56,8 @@ describe('useBookingFileAttach', () => {
|
||||
expect(result.current.attach.attachedFiles.map((f) => f.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFILES-005: a new booking lists no attached files', () => {
|
||||
it('FE-PLANNER-BOOKINGFILES-004: a new booking lists no attached files', () => {
|
||||
const { result } = setup({ reservation: null }, [buildTripFile({ id: 1, reservation_id: 5 })]);
|
||||
expect(result.current.attach.attachedFiles).toEqual([]);
|
||||
});
|
||||
it('FE-PLANNER-BOOKINGFILES-006: offline a file on a saved booking is not sent and says it needs a connection (#2601)', async () => {
|
||||
const onFileUpload = vi.fn(async (_fd: FormData) => undefined);
|
||||
const { result, toast } = setup({ onFileUpload });
|
||||
const event = changeEvent(new File(['x'], 'ticket.pdf'));
|
||||
setForcedOffline(true);
|
||||
try {
|
||||
await act(async () => {
|
||||
await result.current.attach.handleFileChange(event);
|
||||
});
|
||||
} finally {
|
||||
_resetNetworkMode();
|
||||
}
|
||||
expect(onFileUpload).not.toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith('reservations.needsConnection');
|
||||
expect(event.target.value).toBe('');
|
||||
expect(result.current.pendingFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +1,34 @@
|
||||
import type React from 'react';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode';
|
||||
import type { TripFile } from '../../types';
|
||||
import { attachedBookingFiles } from './bookingFormModel';
|
||||
|
||||
export interface BookingFileAttachOptions {
|
||||
/** The booking being edited; files picked before it is saved wait in `pendingFiles`. */
|
||||
reservation: { id: number; title: string } | null | undefined;
|
||||
/** The booking being edited; unset while it is being created. */
|
||||
reservation: { id: number } | null | undefined;
|
||||
/** The trip's files. */
|
||||
files: TripFile[];
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>;
|
||||
setPendingFiles: Dispatch<SetStateAction<File[]>>;
|
||||
/** The toast and translate the dialog already holds. */
|
||||
toast: { success: (message: string) => void; error: (message: string) => void };
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Files row of the desktop booking and transport dialogs: a file picked on a
|
||||
* saved booking goes up at once, one picked on a new booking waits for the save,
|
||||
* and the files linked or detached in the dialog show on the booking right away.
|
||||
* The Files row of the desktop booking and transport dialogs. A picked file waits
|
||||
* in `pendingFiles` and goes up with the save, on a new booking and a saved one
|
||||
* alike, as on the phone (#2534): one that fails stays in the list to try again
|
||||
* instead of vanishing with its toast (#2883). The files linked or detached in the
|
||||
* dialog show on the booking right away.
|
||||
*/
|
||||
export function useBookingFileAttach({
|
||||
reservation,
|
||||
files,
|
||||
onFileUpload,
|
||||
setPendingFiles,
|
||||
toast,
|
||||
t,
|
||||
}: BookingFileAttachOptions) {
|
||||
const [uploadingFile, setUploadingFile] = useState(false);
|
||||
export function useBookingFileAttach({ reservation, files, setPendingFiles }: BookingFileAttachOptions) {
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([]);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (reservation?.id && isEffectivelyOffline()) {
|
||||
// A file goes up at once on a saved booking, and an upload cannot wait in
|
||||
// the offline queue.
|
||||
toast.error(t('reservations.needsConnection'));
|
||||
e.target.value = '';
|
||||
} else if (reservation?.id) {
|
||||
setUploadingFile(true);
|
||||
try {
|
||||
if (onFileUpload) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('reservation_id', String(reservation.id));
|
||||
fd.append('description', reservation.title);
|
||||
await onFileUpload(fd);
|
||||
toast.success(t('reservations.toast.fileUploaded'));
|
||||
} else {
|
||||
// A dialog without an upload reports a failed upload, as it always did.
|
||||
toast.error(t('reservations.toast.uploadError'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('reservations.toast.uploadError'));
|
||||
} finally {
|
||||
setUploadingFile(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
} else {
|
||||
setPendingFiles((prev) => [...prev, file]);
|
||||
e.target.value = '';
|
||||
}
|
||||
if (file) setPendingFiles((prev) => [...prev, file]);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
return {
|
||||
uploadingFile,
|
||||
handleFileChange,
|
||||
attachedFiles: attachedBookingFiles(files, reservation?.id, linkedFileIds),
|
||||
removePending: (index: number) => setPendingFiles((prev) => prev.filter((_, j) => j !== index)),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// FE-PLANNER-BOOKINGFOLLOWUP-001 to -006: what a booking save does after the
|
||||
// booking itself, shared by the desktop dialogs and the phone sheets.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import { useBookingFollowUp } from './useBookingFollowUp';
|
||||
|
||||
const BOOKING = { id: 99 } as Reservation;
|
||||
const t = (key: string) => key;
|
||||
|
||||
interface Props {
|
||||
session: unknown;
|
||||
travelerIds?: Set<number>;
|
||||
upload?: (fd: FormData) => Promise<unknown>;
|
||||
}
|
||||
|
||||
let setTravelers: ReturnType<typeof vi.fn>;
|
||||
|
||||
function setup(initial: Props, files: File[] = []) {
|
||||
return renderHook(
|
||||
({ session, travelerIds = new Set([7]), upload }: Props) => {
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>(files);
|
||||
const followUp = useBookingFollowUp({
|
||||
session,
|
||||
tripId: 5,
|
||||
original: { travelers: [{ user_id: 7 }] } as Reservation,
|
||||
travelerIds,
|
||||
pendingFiles,
|
||||
setPendingFiles,
|
||||
upload,
|
||||
t,
|
||||
});
|
||||
return { followUp, pendingFiles };
|
||||
},
|
||||
{ initialProps: initial }
|
||||
);
|
||||
}
|
||||
|
||||
async function save(result: { current: { followUp: ReturnType<typeof useBookingFollowUp> } }) {
|
||||
let outcome: unknown = null;
|
||||
await act(async () => {
|
||||
await result.current.followUp
|
||||
.steps('Hotel')
|
||||
.afterSave(BOOKING)
|
||||
.catch((err: unknown) => {
|
||||
outcome = err;
|
||||
});
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
describe('useBookingFollowUp', () => {
|
||||
beforeEach(() => {
|
||||
setTravelers = vi.fn(async () => undefined);
|
||||
useTripStore.setState({ setReservationTravelers: setTravelers as never });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFOLLOWUP-001: a save that goes through leaves nothing to retry', async () => {
|
||||
const upload = vi.fn(async () => undefined);
|
||||
const { result } = setup({ session: 'new', upload }, [new File(['a'], 'a.pdf')]);
|
||||
expect(await save(result)).toBeNull();
|
||||
expect(result.current.followUp.holdsSaved()).toBe(false);
|
||||
expect(result.current.followUp.steps('Hotel').saved).toBeUndefined();
|
||||
expect(result.current.pendingFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFOLLOWUP-002: a failed upload keeps the booking and exactly the files still to send', async () => {
|
||||
const a = new File(['a'], 'a.pdf');
|
||||
const b = new File(['b'], 'b.pdf');
|
||||
const upload = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('413'));
|
||||
const { result } = setup({ session: 'new', upload }, [a, b]);
|
||||
const err = await save(result);
|
||||
expect((err as Error).message).toBe('reservations.toast.filesNotUploaded');
|
||||
expect(result.current.followUp.holdsSaved()).toBe(true);
|
||||
expect(result.current.followUp.steps('Hotel').saved).toBe(BOOKING);
|
||||
expect(result.current.pendingFiles).toEqual([b]);
|
||||
const fd = upload.mock.calls[0][0] as FormData;
|
||||
expect(fd.get('reservation_id')).toBe('99');
|
||||
expect(fd.get('description')).toBe('Hotel');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFOLLOWUP-003: the retry after a failure clears the stored booking once it goes through', async () => {
|
||||
const upload = vi.fn().mockRejectedValueOnce(new Error('413')).mockResolvedValue(undefined);
|
||||
const { result } = setup({ session: 'new', upload }, [new File(['a'], 'a.pdf')]);
|
||||
await save(result);
|
||||
expect(result.current.followUp.holdsSaved()).toBe(true);
|
||||
expect(await save(result)).toBeNull();
|
||||
expect(result.current.followUp.holdsSaved()).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFOLLOWUP-004: another session starts without the booking a failed try stored', async () => {
|
||||
const upload = vi.fn().mockRejectedValue(new Error('413'));
|
||||
const { result, rerender } = setup({ session: 'new', upload }, [new File(['a'], 'a.pdf')]);
|
||||
await save(result);
|
||||
expect(result.current.followUp.holdsSaved()).toBe(true);
|
||||
rerender({ session: { title: 'Next draft' }, upload });
|
||||
expect(result.current.followUp.holdsSaved()).toBe(false);
|
||||
expect(result.current.followUp.steps('Hotel').saved).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFOLLOWUP-005: the travellers are written only when the pick differs from the booking', async () => {
|
||||
const { result, rerender } = setup({ session: 'new' });
|
||||
await save(result);
|
||||
expect(setTravelers).not.toHaveBeenCalled();
|
||||
rerender({ session: 'new', travelerIds: new Set([7, 8]) });
|
||||
await save(result);
|
||||
expect(setTravelers).toHaveBeenCalledWith(5, 99, [7, 8]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-BOOKINGFOLLOWUP-006: a failed traveller write keeps the booking for the retry', async () => {
|
||||
setTravelers.mockRejectedValueOnce(new Error('offline'));
|
||||
const { result } = setup({ session: 'new', travelerIds: new Set([8]) });
|
||||
expect(await save(result)).toBeInstanceOf(Error);
|
||||
expect(result.current.followUp.holdsSaved()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useRef } from 'react';
|
||||
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import { type BookingSaveSteps, travelersChanged, uploadBookingFiles } from './bookingFormModel';
|
||||
|
||||
interface BookingFollowUpOptions {
|
||||
/**
|
||||
* Which booking the editor is open on. A new value starts over: the booking a
|
||||
* failed try stored belongs to the editor session it was saved in.
|
||||
*/
|
||||
session: unknown;
|
||||
tripId: number | string | undefined;
|
||||
/** The booking being edited, whose travellers the picked ones are compared with. */
|
||||
original: Pick<Reservation, 'travelers'> | null | undefined;
|
||||
travelerIds: Set<number>;
|
||||
pendingFiles: File[];
|
||||
setPendingFiles: Dispatch<SetStateAction<File[]>>;
|
||||
/** Where picked files go; without it they stay with the form. */
|
||||
upload: ((fd: FormData) => Promise<unknown>) | undefined;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a booking save that comes after the booking itself, shared by the
|
||||
* desktop dialogs and the phone sheets: the travellers picked for it (#1517) and
|
||||
* the files picked in the form. The planner waits for it before it closes the
|
||||
* editor, so a failure leaves the editor open (#2883). Each file that lands
|
||||
* leaves the list at once, so what is left after a failure is exactly what still
|
||||
* has to go up, and the booking the failed try stored is kept so that saving
|
||||
* again updates it instead of adding a second one.
|
||||
*/
|
||||
export function useBookingFollowUp({
|
||||
session,
|
||||
tripId,
|
||||
original,
|
||||
travelerIds,
|
||||
pendingFiles,
|
||||
setPendingFiles,
|
||||
upload,
|
||||
t,
|
||||
}: BookingFollowUpOptions) {
|
||||
const setReservationTravelers = useTripStore((s) => s.setReservationTravelers);
|
||||
const stored = useRef<Reservation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
stored.current = null;
|
||||
}, [session]);
|
||||
|
||||
const afterSave = async (saved: Reservation, description: string) => {
|
||||
stored.current = saved;
|
||||
if (tripId) {
|
||||
const { changed, nextIds } = travelersChanged(original, travelerIds);
|
||||
if (changed) await setReservationTravelers(tripId, saved.id, nextIds);
|
||||
}
|
||||
// The server's reason for one file says less than what the editor shows: the
|
||||
// booking is there, and the files still listed are the ones to try again.
|
||||
const uploaded = await uploadBookingFiles(upload, saved.id, pendingFiles, description, (file) =>
|
||||
setPendingFiles((prev) => prev.filter((f) => f !== file))
|
||||
).then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (!uploaded) throw new Error(t('reservations.toast.filesNotUploaded'));
|
||||
// Only a failed try is retried. The editor can stay open on to the next
|
||||
// booking without a new session (the import review on the phone), and that
|
||||
// one must be added, not written over this one.
|
||||
stored.current = null;
|
||||
};
|
||||
|
||||
return {
|
||||
/** Whether a try in this session already stored the booking, so the form holds a retry. */
|
||||
holdsSaved: () => stored.current !== null,
|
||||
/** What the save hands the planner, for a booking whose files are described by `description`. */
|
||||
steps: (description: string): BookingSaveSteps & Required<Pick<BookingSaveSteps, 'afterSave'>> => ({
|
||||
saved: stored.current ?? undefined,
|
||||
afterSave: (saved) => afterSave(saved, description),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRIPEXPORT-001 to -010: the trip exports behind the desktop export
|
||||
// FE-PLANNER-TRIPEXPORT-001 to -013: the trip exports behind the desktop export
|
||||
// dialog and the phone's export sheet.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
@@ -19,6 +19,7 @@ const NOTES = { '7': [{ id: 1, text: 'Pack' }] as unknown as DayNote[] };
|
||||
function options(overrides: Partial<TripExportOptions> = {}): TripExportOptions {
|
||||
return {
|
||||
tripId: 3,
|
||||
open: true,
|
||||
data: { trip, days: [], places: [], assignments: {}, categories: [], reservations: [], dayNotes: NOTES },
|
||||
t,
|
||||
locale: 'en-US',
|
||||
@@ -238,4 +239,83 @@ describe('useTripExport', () => {
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.anyRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-011: the PDF routes what the planner draws, counts its legs and can print without the rest', async () => {
|
||||
const pdf = deferred();
|
||||
vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise);
|
||||
const viasByDay = { 7: [] };
|
||||
const tourStageStops = new Map();
|
||||
const opts = options({ data: { ...options().data, viasByDay, tourStageStops } });
|
||||
const { result } = renderHook(() => useTripExport(opts));
|
||||
let running!: Promise<void>;
|
||||
act(() => {
|
||||
running = result.current.exportPdf();
|
||||
});
|
||||
await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled());
|
||||
const { route } = vi.mocked(downloadTripPDF).mock.calls[0][0];
|
||||
expect(route).toMatchObject({ viasByDay, tourStageStops });
|
||||
expect(result.current.pdfRouting).toBeNull();
|
||||
|
||||
act(() => route?.onProgress?.(0, 60));
|
||||
act(() => route?.onProgress?.(23, 60));
|
||||
expect(result.current.pdfRouting).toEqual({ done: 23, total: 60 });
|
||||
expect(result.current.pdfRoutingLabel('pdf')).toBe('dayplan.pdfRouting');
|
||||
expect(result.current.pdfRoutingLabel('pdf:mine')).toBeNull();
|
||||
|
||||
// Skipping stops the routing, not the export: the document still comes out.
|
||||
act(() => result.current.skipPdfRouting());
|
||||
expect(route?.signal?.aborted).toBe(true);
|
||||
expect(vi.mocked(downloadTripPDF).mock.calls[0][0].signal?.aborted).toBe(false);
|
||||
expect(result.current.pdfRouting).toBeNull();
|
||||
// The leg that was already on its way still reports; the row does not come back for it.
|
||||
act(() => route?.onProgress?.(24, 60));
|
||||
expect(result.current.pdfRouting).toBeNull();
|
||||
expect(result.current.isRunning('pdf')).toBe(true);
|
||||
await act(async () => {
|
||||
pdf.resolve();
|
||||
await running;
|
||||
});
|
||||
expect(result.current.anyRunning).toBe(false);
|
||||
expect(opts.onExported).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-012: closing the dialog while the PDF routes gives the export up, and nothing opens', async () => {
|
||||
const pdf = deferred();
|
||||
vi.mocked(downloadTripPDF).mockReturnValue(pdf.promise);
|
||||
const opts = options();
|
||||
const { result, rerender } = renderHook((open: boolean) => useTripExport({ ...opts, open }), {
|
||||
initialProps: true,
|
||||
});
|
||||
let running!: Promise<void>;
|
||||
act(() => {
|
||||
running = result.current.exportPdf();
|
||||
});
|
||||
await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled());
|
||||
const { route, signal } = vi.mocked(downloadTripPDF).mock.calls[0][0];
|
||||
|
||||
rerender(false);
|
||||
expect(route?.signal?.aborted).toBe(true);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => {
|
||||
pdf.resolve();
|
||||
await running;
|
||||
});
|
||||
expect(opts.onExported).not.toHaveBeenCalled();
|
||||
expect(opts.toast.error).not.toHaveBeenCalled();
|
||||
expect(result.current.anyRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRIPEXPORT-013: leaving the trip while the PDF routes gives the export up too', async () => {
|
||||
vi.mocked(downloadTripPDF).mockReturnValue(deferred().promise);
|
||||
const { result, unmount } = renderHook(() => useTripExport(options(PHONE)));
|
||||
act(() => {
|
||||
void result.current.exportPdf();
|
||||
});
|
||||
await vi.waitFor(() => expect(downloadTripPDF).toHaveBeenCalled());
|
||||
const { route, signal } = vi.mocked(downloadTripPDF).mock.calls[0][0];
|
||||
|
||||
unmount();
|
||||
expect(route?.signal?.aborted).toBe(true);
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { AssignmentsMap, Category, Day, DayNote, Place, Reservation, Trip } from '../../types';
|
||||
import { importChunk } from '../../utils/chunkReload';
|
||||
import type { ViasByDay } from '../../utils/dayRouteVias';
|
||||
import type { TourStageStops } from '../Map/dayTourProjection';
|
||||
import { hasPersonalPlan } from '../PDF/pdfScope';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
@@ -21,10 +23,24 @@ export interface TripExportData {
|
||||
categories: Category[];
|
||||
reservations: Reservation[];
|
||||
dayNotes: Record<string, DayNote[]>;
|
||||
/** The road trip's vias and the multi-day tour stages, so the PDF map routes what the planner map does. */
|
||||
viasByDay?: ViasByDay;
|
||||
tourStageStops?: TourStageStops;
|
||||
}
|
||||
|
||||
/** How far the PDF's route map has got: legs answered or given up on, out of all of them. */
|
||||
interface PdfRouting {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TripExportOptions {
|
||||
tripId: number;
|
||||
/**
|
||||
* Whether the dialog or sheet is shown. Closing it, or leaving the trip, gives up a
|
||||
* PDF still routing: its legs stop going out and the document never opens.
|
||||
*/
|
||||
open: boolean;
|
||||
data: TripExportData;
|
||||
t: Translate;
|
||||
locale: string;
|
||||
@@ -67,6 +83,45 @@ export function flatDayNotes(dayNotes: Record<string, DayNote[]>) {
|
||||
return Object.entries(dayNotes).flatMap(([dayId, notes]) => notes.map((n) => ({ ...n, day_id: Number(dayId) })));
|
||||
}
|
||||
|
||||
/**
|
||||
* The PDF's routing round as the export rows show it. A cold month-long trip routes for
|
||||
* minutes, one leg a second against the shared router, so the row counts the legs and
|
||||
* offers to print without the ones still to come; those are marked in the document (#2876).
|
||||
*/
|
||||
function usePdfRouting(open: boolean) {
|
||||
const [progress, setProgress] = useState<PdfRouting | null>(null);
|
||||
const controller = useRef<{ routing: AbortController; job: AbortController } | null>(null);
|
||||
|
||||
const start = () => {
|
||||
const routing = new AbortController();
|
||||
const running = { routing, job: new AbortController() };
|
||||
controller.current = running;
|
||||
// A leg already on its way still reports after a skip; the row stays quiet about it.
|
||||
const onProgress = (done: number, total: number) =>
|
||||
setProgress(!routing.signal.aborted && done < total ? { done, total } : null);
|
||||
return { signal: routing.signal, onProgress, givenUp: running.job.signal };
|
||||
};
|
||||
const finish = () => {
|
||||
controller.current = null;
|
||||
setProgress(null);
|
||||
};
|
||||
const skip = () => {
|
||||
controller.current?.routing.abort();
|
||||
setProgress(null);
|
||||
};
|
||||
// Unlike a skip, nobody is left to print for: the export ends without a document.
|
||||
const giveUp = useCallback(() => {
|
||||
controller.current?.routing.abort();
|
||||
controller.current?.job.abort();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!open) giveUp();
|
||||
}, [open, giveUp]);
|
||||
useEffect(() => giveUp, [giveUp]);
|
||||
|
||||
return { progress, start, finish, skip };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a trip leaves TREK, behind the desktop export dialog and the phone's
|
||||
* export sheet: the day plan as a PDF (the whole plan, or my plan once somebody
|
||||
@@ -75,6 +130,7 @@ export function flatDayNotes(dayNotes: Record<string, DayNote[]>) {
|
||||
*/
|
||||
export function useTripExport({
|
||||
tripId,
|
||||
open,
|
||||
data,
|
||||
t,
|
||||
locale,
|
||||
@@ -88,6 +144,7 @@ export function useTripExport({
|
||||
// Which export is working, per lane, so a row can say so instead of looking inert
|
||||
// while a 226 kB PDF builder is fetched and a document is rendered.
|
||||
const [running, setRunning] = useState<Record<string, ExportJob>>({});
|
||||
const pdfRouting = usePdfRouting(open);
|
||||
// The PDF is built outside React, so it cannot read these itself (#2066).
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h';
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
@@ -108,11 +165,16 @@ export function useTripExport({
|
||||
return next;
|
||||
});
|
||||
|
||||
/** What a PDF row says while its route map is still being routed, instead of its own line. */
|
||||
const pdfRoutingLabel = (job: ExportJob) =>
|
||||
isRunning(job) && pdfRouting.progress ? t('dayplan.pdfRouting', { ...pdfRouting.progress }) : null;
|
||||
|
||||
const exportPdf = async (mine = false) => {
|
||||
const job: ExportJob = mine ? 'pdf:mine' : 'pdf';
|
||||
if ((requireTrip && !data.trip) || running[laneOf(job)]) return;
|
||||
begin(job);
|
||||
const flatNotes = flatDayNotes(data.dayNotes);
|
||||
const { givenUp, ...routing } = pdfRouting.start();
|
||||
try {
|
||||
// Loaded on click: the PDF builder is ~226 kB, so every trip used to pay for it
|
||||
// whether or not anyone exported. A missing chunk lands in the catch and shows
|
||||
@@ -132,12 +194,15 @@ export function useTripExport({
|
||||
distanceUnit,
|
||||
showServiceStops,
|
||||
onlyUserId: mine ? myId : undefined,
|
||||
route: { viasByDay: data.viasByDay, tourStageStops: data.tourStageStops, ...routing },
|
||||
signal: givenUp,
|
||||
});
|
||||
if (closeAfterPdf) onExported();
|
||||
if (closeAfterPdf && !givenUp.aborted) onExported();
|
||||
} catch (e) {
|
||||
if (logPdfErrors) console.error('PDF error:', e);
|
||||
toast.error(`${t('dayplan.pdfError')}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
pdfRouting.finish();
|
||||
end(job);
|
||||
}
|
||||
};
|
||||
@@ -187,6 +252,9 @@ export function useTripExport({
|
||||
isRunning,
|
||||
anyRunning: Object.keys(running).length > 0,
|
||||
exportPdf,
|
||||
pdfRouting: pdfRouting.progress,
|
||||
pdfRoutingLabel,
|
||||
skipPdfRouting: pdfRouting.skip,
|
||||
downloadIcs,
|
||||
downloadGpx,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user