mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 | |||
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 |
@@ -257,6 +257,15 @@ jobs:
|
|||||||
# migration that changes a column regenerates it in the same change.
|
# migration that changes a column regenerates it in the same change.
|
||||||
run: cd server && npm run check:db-types
|
run: cd server && npm run check:db-types
|
||||||
|
|
||||||
|
- name: Fetch the base the warnings are compared with
|
||||||
|
# lint:warnings names the file behind a grown count by linting it as
|
||||||
|
# it is on the base: the pushed-over commit on a push, the target
|
||||||
|
# branch on a pull request. A miss only drops that hint.
|
||||||
|
run: >-
|
||||||
|
git fetch --no-tags --depth=1 origin
|
||||||
|
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||||
|
|| true
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
# Errors fail, and the warnings per rule may not pass
|
# Errors fail, and the warnings per rule may not pass
|
||||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||||
@@ -264,6 +273,7 @@ jobs:
|
|||||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
TREK_LINT_BASE: refs/remotes/lint-base
|
||||||
run: cd server && npm run lint:warnings
|
run: cd server && npm run lint:warnings
|
||||||
|
|
||||||
- name: Formatting check
|
- name: Formatting check
|
||||||
@@ -395,6 +405,15 @@ jobs:
|
|||||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||||
run: cd client && npm run lint:strict
|
run: cd client && npm run lint:strict
|
||||||
|
|
||||||
|
- name: Fetch the base the warnings are compared with
|
||||||
|
# lint:warnings names the file behind a grown count by linting it as
|
||||||
|
# it is on the base: the pushed-over commit on a push, the target
|
||||||
|
# branch on a pull request. A miss only drops that hint.
|
||||||
|
run: >-
|
||||||
|
git fetch --no-tags --depth=1 origin
|
||||||
|
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||||
|
|| true
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||||
# whose warning count rises above scripts/eslint-baseline.json.
|
# whose warning count rises above scripts/eslint-baseline.json.
|
||||||
@@ -402,6 +421,7 @@ jobs:
|
|||||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=8192
|
NODE_OPTIONS: --max-old-space-size=8192
|
||||||
|
TREK_LINT_BASE: refs/remotes/lint-base
|
||||||
run: cd client && npm run lint:warnings
|
run: cd client && npm run lint:warnings
|
||||||
|
|
||||||
- name: Page pattern check
|
- name: Page pattern check
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"src": {
|
"src": {
|
||||||
"@typescript-eslint/no-non-null-assertion": 404,
|
"@typescript-eslint/no-non-null-assertion": 404,
|
||||||
"@typescript-eslint/no-misused-promises": 875,
|
"@typescript-eslint/no-misused-promises": 874,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 230,
|
"complexity": 230,
|
||||||
"@typescript-eslint/no-explicit-any": 147,
|
"@typescript-eslint/no-explicit-any": 147,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||||
*/
|
*/
|
||||||
import { ESLint } from 'eslint';
|
import { ESLint } from 'eslint';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import ts from 'typescript';
|
import ts from 'typescript';
|
||||||
import {
|
import {
|
||||||
@@ -137,6 +138,90 @@ export async function lintClient(root) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||||
|
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||||
|
|
||||||
|
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||||
|
function gitIn(root, args) {
|
||||||
|
try {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd: root,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
maxBuffer: 1 << 28,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The messages of one rule in one area of a single file's lint result. */
|
||||||
|
function ofRule(results, rule, area) {
|
||||||
|
const result = results[0];
|
||||||
|
if (!result) return [];
|
||||||
|
const pool =
|
||||||
|
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||||
|
return pool.filter((m) => ruleOf(m) === rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||||
|
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||||
|
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||||
|
* The count alone names no file, and the files holding the most are old ones;
|
||||||
|
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||||
|
* without it), and the report then keeps to the files holding the most.
|
||||||
|
*/
|
||||||
|
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||||
|
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||||
|
if (changed === null) return null;
|
||||||
|
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||||
|
const found = await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => differs.has(key))
|
||||||
|
.map(async (key) => {
|
||||||
|
const path = join(root, key);
|
||||||
|
// A typed rule can trip over a text that is not the file on disk (the
|
||||||
|
// base version); then the file's own warnings are told, marked so.
|
||||||
|
const lint = async (text) => {
|
||||||
|
try {
|
||||||
|
return { messages: ofRule(await lintText(text, path), rule, area) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const now = await lint(readText(path));
|
||||||
|
if (!now) return [];
|
||||||
|
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||||
|
const was = before === null ? { messages: [] } : await lint(before);
|
||||||
|
if (was && now.messages.length <= was.messages.length) return [];
|
||||||
|
const note = was ? '' : ' (the base version could not be linted)';
|
||||||
|
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return found.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||||
|
async function reportNew(grown, { root, files, lintText, error }) {
|
||||||
|
if (!grown.length || !lintText) return;
|
||||||
|
const found = await Promise.all(
|
||||||
|
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
||||||
|
);
|
||||||
|
grown.forEach(({ area, rule }, i) => {
|
||||||
|
const lines = found[i];
|
||||||
|
if (!lines?.length) return;
|
||||||
|
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||||
|
for (const line of lines) error(` ${line}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
||||||
|
function lintTextClient(root) {
|
||||||
|
const eslint = new ESLint({ cwd: root });
|
||||||
|
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||||
|
}
|
||||||
|
|
||||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +233,7 @@ export async function check({
|
|||||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||||
update = false,
|
update = false,
|
||||||
lint = lintClient,
|
lint = lintClient,
|
||||||
|
lintText = lint === lintClient ? lintTextClient : null,
|
||||||
log = console.log,
|
log = console.log,
|
||||||
error = console.error,
|
error = console.error,
|
||||||
}) {
|
}) {
|
||||||
@@ -160,13 +246,13 @@ export async function check({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const line of errors) error(`FAIL ${line}`);
|
for (const line of errors) error(`FAIL ${line}`);
|
||||||
let grown = 0;
|
const grown = [];
|
||||||
let stale = 0;
|
let stale = 0;
|
||||||
for (const area of AREAS) {
|
for (const area of AREAS) {
|
||||||
for (const [rule, n] of Object.entries(counts[area])) {
|
for (const [rule, n] of Object.entries(counts[area])) {
|
||||||
const allowed = baseline[area][rule] ?? 0;
|
const allowed = baseline[area][rule] ?? 0;
|
||||||
if (n <= allowed) continue;
|
if (n <= allowed) continue;
|
||||||
grown++;
|
grown.push({ area, rule });
|
||||||
const top = Object.entries(files[area][rule])
|
const top = Object.entries(files[area][rule])
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
@@ -182,7 +268,8 @@ export async function check({
|
|||||||
});
|
});
|
||||||
stale += fallen.length;
|
stale += fallen.length;
|
||||||
}
|
}
|
||||||
if (grown) {
|
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||||
|
if (grown.length) {
|
||||||
error(
|
error(
|
||||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||||
'Run npx eslint <file> to see each one.'
|
'Run npx eslint <file> to see each one.'
|
||||||
@@ -191,5 +278,5 @@ export async function check({
|
|||||||
log(
|
log(
|
||||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||||
);
|
);
|
||||||
return errors.length || grown || stale ? 1 : 0;
|
return errors.length || grown.length || stale ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||||
"src/components/Map/MapView.tsx": 1409,
|
"src/components/Map/MapView.tsx": 1402,
|
||||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||||
"src/components/Map/MapViewGL.tsx": 2299,
|
"src/components/Map/MapViewGL.tsx": 2299,
|
||||||
"src/components/Map/RouteCalculator.ts": 1195,
|
"src/components/Map/RouteCalculator.ts": 1195,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
"components/Layout/DemoBanner.tsx": 25,
|
"components/Layout/DemoBanner.tsx": 25,
|
||||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||||
"components/Layout/MobileTopBar.tsx": 51,
|
"components/Layout/MobileTopBar.tsx": 51,
|
||||||
"components/Layout/Navbar.tsx": 22,
|
"components/Layout/Navbar.tsx": 21,
|
||||||
"components/Layout/NewVersionNotice.tsx": 3,
|
"components/Layout/NewVersionNotice.tsx": 3,
|
||||||
"components/Layout/OfflineBanner.tsx": 2,
|
"components/Layout/OfflineBanner.tsx": 2,
|
||||||
"components/Layout/PageSidebar.tsx": 3,
|
"components/Layout/PageSidebar.tsx": 3,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||||
|
const GEOFABRIK = { timeout: 30_000 };
|
||||||
|
|
||||||
|
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
|
||||||
|
export const paddleRegionLoadApi = {
|
||||||
|
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
|
||||||
|
apiClient
|
||||||
|
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
|
||||||
|
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
|
||||||
|
};
|
||||||
@@ -2,17 +2,19 @@ import {
|
|||||||
paddleRegionCatalogResponseSchema,
|
paddleRegionCatalogResponseSchema,
|
||||||
paddleRegionListResponseSchema,
|
paddleRegionListResponseSchema,
|
||||||
paddleRegionSchema,
|
paddleRegionSchema,
|
||||||
|
paddleRegionSettingsSchema,
|
||||||
successResponseSchema,
|
successResponseSchema,
|
||||||
type PaddleRegionRequest,
|
type PaddleRegionRequest,
|
||||||
|
type PaddleRegionSettings,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||||
const GEOFABRIK = { timeout: 30_000 };
|
const GEOFABRIK = { timeout: 30_000 };
|
||||||
|
|
||||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
|
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||||
export const paddleRegionsApi = {
|
export const paddleRegionsApi = {
|
||||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||||
catalog: (q: string, signal?: AbortSignal) =>
|
catalog: (q: string, signal?: AbortSignal) =>
|
||||||
apiClient
|
apiClient
|
||||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||||
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
|
|||||||
apiClient
|
apiClient
|
||||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||||
.then((r) => successResponseSchema.parse(r.data)),
|
.then((r) => successResponseSchema.parse(r.data)),
|
||||||
|
decline: (id: string) =>
|
||||||
|
apiClient
|
||||||
|
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
|
||||||
|
.then((r) => successResponseSchema.parse(r.data)),
|
||||||
|
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||||
|
saveSettings: (settings: PaddleRegionSettings) =>
|
||||||
|
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
||||||
|
export const paddleRouteApi = {
|
||||||
|
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
||||||
|
apiClient
|
||||||
|
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
||||||
|
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type {
|
||||||
|
TripJournalCandidatesResponse,
|
||||||
|
TripJournalLinkMemberRequest,
|
||||||
|
TripJournalLinkResponse,
|
||||||
|
} from '@trek/shared';
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
||||||
|
* and who it is open to. In its own file rather than in the `client.ts` god
|
||||||
|
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
||||||
|
*/
|
||||||
|
export const tripJournalApi = {
|
||||||
|
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||||
|
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||||
|
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
||||||
|
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
||||||
|
link: (
|
||||||
|
tripId: number,
|
||||||
|
journeyId: number,
|
||||||
|
members: TripJournalLinkMemberRequest[]
|
||||||
|
): Promise<TripJournalLinkResponse> =>
|
||||||
|
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
||||||
|
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
||||||
|
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
||||||
|
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||||
|
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||||
|
/** The journals this person can open; the dialog offers the ones they own. */
|
||||||
|
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
||||||
|
apiClient.get('/journeys').then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
|||||||
// the bag ping: what a run changed is per-binding server state, and the
|
// the bag ping: what a run changed is per-binding server state, and the
|
||||||
// files it moved arrive as file:* events.
|
// files it moved arrive as file:* events.
|
||||||
'docsync:changed',
|
'docsync:changed',
|
||||||
|
// Linked journal: components/Trips/useTripJournalLink asks again which journal
|
||||||
|
// the trip shows and who it is open to. Content-free, because that answer is
|
||||||
|
// per person (a member left off the link must not learn its title).
|
||||||
|
'trip:journal:changed',
|
||||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||||
'import:progress',
|
'import:progress',
|
||||||
'import:done',
|
'import:done',
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
|
||||||
|
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||||
|
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||||
|
|
||||||
|
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||||
|
|
||||||
|
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||||
|
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||||
|
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||||
|
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||||
|
|
||||||
|
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||||
|
function useMegabytes() {
|
||||||
|
const { language } = useTranslation();
|
||||||
|
return (mb: number) =>
|
||||||
|
new Intl.NumberFormat(language, {
|
||||||
|
style: 'unit',
|
||||||
|
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||||
|
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||||
|
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
|
||||||
|
* mode, and in the automatic one the largest region it loads without asking.
|
||||||
|
*/
|
||||||
|
export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const megabytes = useMegabytes();
|
||||||
|
const { settings, saving, save } = admin.autoLoad;
|
||||||
|
if (!settings) return null;
|
||||||
|
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||||
|
? SIZES
|
||||||
|
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||||
|
return (
|
||||||
|
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
||||||
|
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||||
|
<Segmented<PaddleRegionAutoMode>
|
||||||
|
value={settings.mode}
|
||||||
|
onChange={(mode) => void save({ mode })}
|
||||||
|
label={t('admin.paddleRegions.auto.title')}
|
||||||
|
fill
|
||||||
|
options={MODES.map((mode) => {
|
||||||
|
const Icon = MODE_ICONS[mode];
|
||||||
|
return {
|
||||||
|
value: mode,
|
||||||
|
label: t(`admin.paddleRegions.auto.mode.${mode}`),
|
||||||
|
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
|
||||||
|
</div>
|
||||||
|
{settings.mode === 'auto' && (
|
||||||
|
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||||
|
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
||||||
|
<ChoiceChips<string>
|
||||||
|
value={String(settings.max_mb)}
|
||||||
|
label={t('admin.paddleRegions.auto.maxSize')}
|
||||||
|
onChange={(value) => void save({ max_mb: Number(value) })}
|
||||||
|
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
|
||||||
|
/>
|
||||||
|
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
||||||
|
const { t, language } = useTranslation();
|
||||||
|
const megabytes = useMegabytes();
|
||||||
|
const busy = admin.busyId === request.id;
|
||||||
|
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||||
|
data-testid="paddle-region-request"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
|
||||||
|
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||||
|
{request.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{request.declined ? (
|
||||||
|
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
|
||||||
|
{t('admin.paddleRegions.requests.declined')}
|
||||||
|
</StatusPill>
|
||||||
|
) : (
|
||||||
|
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
|
||||||
|
)}
|
||||||
|
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
|
||||||
|
{request.requested_by && (
|
||||||
|
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
|
||||||
|
)}
|
||||||
|
<StatusPill>{date}</StatusPill>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex flex-none items-center gap-1.5">
|
||||||
|
{!request.declined && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={admin.busyId !== null}
|
||||||
|
onClick={() => void admin.decline(request.id)}
|
||||||
|
className={SETTINGS_BUTTON}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
{t('admin.paddleRegions.requests.decline')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={admin.busyId !== null}
|
||||||
|
onClick={() => void admin.approve(request.id)}
|
||||||
|
className={SETTINGS_BUTTON}
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
||||||
|
{t('admin.paddleRegions.requests.approve')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The regions paddlers asked for that wait for an admin, the open ones first. */
|
||||||
|
export function RequestList({ admin }: { admin: Admin }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (admin.requests.length === 0) return null;
|
||||||
|
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
||||||
|
const open = admin.requests.filter((request) => !request.declined).length;
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
|
||||||
|
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
|
||||||
|
{t('admin.paddleRegions.requests.title')}
|
||||||
|
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
|
||||||
|
</span>
|
||||||
|
<SettingRows>
|
||||||
|
{ordered.map((request) => (
|
||||||
|
<RequestRow key={request.id} request={request} admin={admin} />
|
||||||
|
))}
|
||||||
|
</SettingRows>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
|
||||||
|
export function AutoOriginPill() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
|
||||||
|
<span className="inline-flex" data-testid="paddle-region-auto">
|
||||||
|
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
|
||||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||||
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
|||||||
id: 'oberpfalz',
|
id: 'oberpfalz',
|
||||||
name: 'Oberpfalz',
|
name: 'Oberpfalz',
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
origin: 'admin',
|
||||||
source_timestamp: '2026-10-03T12:00:00Z',
|
source_timestamp: '2026-10-03T12:00:00Z',
|
||||||
imported_at: '2026-10-04T08:00:00Z',
|
imported_at: '2026-10-04T08:00:00Z',
|
||||||
feature_count: 1234,
|
feature_count: 1234,
|
||||||
error: null,
|
error: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
|
network: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
|||||||
toursOn: true,
|
toursOn: true,
|
||||||
offline: false,
|
offline: false,
|
||||||
regions: [],
|
regions: [],
|
||||||
|
requests: [],
|
||||||
|
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
retry: vi.fn(),
|
retry: vi.fn(),
|
||||||
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
|||||||
busyId: null,
|
busyId: null,
|
||||||
add: vi.fn(async () => {}),
|
add: vi.fn(async () => {}),
|
||||||
refresh: vi.fn(async () => {}),
|
refresh: vi.fn(async () => {}),
|
||||||
|
approve: vi.fn(async () => {}),
|
||||||
|
decline: vi.fn(async () => {}),
|
||||||
removing: null,
|
removing: null,
|
||||||
askRemove: vi.fn(),
|
askRemove: vi.fn(),
|
||||||
cancelRemove: vi.fn(),
|
cancelRemove: vi.fn(),
|
||||||
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PaddleRegionsPanel water network (PAD-11)', () => {
|
||||||
|
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
|
||||||
|
const network = {
|
||||||
|
water_km: 1364.5,
|
||||||
|
lakes: 266,
|
||||||
|
portages: 14,
|
||||||
|
inferred_portages: 232,
|
||||||
|
gaps: 63,
|
||||||
|
locks: 17,
|
||||||
|
built_at: '2026-10-17T15:30:20Z',
|
||||||
|
bytes: 1496816,
|
||||||
|
};
|
||||||
|
show({ regions: [region({ network })] });
|
||||||
|
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
|
||||||
|
expect(pill).toHaveTextContent('1,365 km of water');
|
||||||
|
fireEvent.mouseEnter(pill);
|
||||||
|
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||||
|
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
|
||||||
|
show({
|
||||||
|
regions: [
|
||||||
|
region({ id: 'old', name: 'Old read', network: null }),
|
||||||
|
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
|
||||||
|
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
|
||||||
|
const save = vi.fn(async () => {});
|
||||||
|
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
|
||||||
|
const auto = screen.getByTestId('paddle-auto-load');
|
||||||
|
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
|
||||||
|
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
|
||||||
|
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
||||||
|
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
|
||||||
|
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
|
||||||
|
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
|
||||||
|
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
|
||||||
|
const approve = vi.fn(async () => {});
|
||||||
|
const decline = vi.fn(async () => {});
|
||||||
|
const base = {
|
||||||
|
bytes: 734_003_200,
|
||||||
|
requested_by: 'elmo',
|
||||||
|
requested_at: '2026-10-18T09:00:00Z',
|
||||||
|
};
|
||||||
|
show({
|
||||||
|
approve,
|
||||||
|
decline,
|
||||||
|
requests: [
|
||||||
|
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
|
||||||
|
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const rows = screen.getAllByTestId('paddle-region-request');
|
||||||
|
expect(rows[0]).toHaveTextContent('Brandenburg');
|
||||||
|
expect(rows[0]).toHaveTextContent('Daily limit');
|
||||||
|
expect(rows[0]).toHaveTextContent('by elmo');
|
||||||
|
expect(rows[1]).toHaveTextContent('Declined');
|
||||||
|
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
|
||||||
|
expect(decline).toHaveBeenCalledWith('brandenburg');
|
||||||
|
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
|
||||||
|
expect(approve).toHaveBeenCalledWith('norway');
|
||||||
|
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
|
||||||
|
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
|
||||||
|
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
|
||||||
|
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('paddleRegionsModel', () => {
|
describe('paddleRegionsModel', () => {
|
||||||
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
||||||
expect(isReading({ status: 'queued' })).toBe(true);
|
expect(isReading({ status: 'queued' })).toBe(true);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
|
|||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
|
||||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||||
|
|
||||||
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||||
* picked from Geofabrik's list. Both the desktop and the phone admin show
|
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||||
* this one card under the addons, while the Tours addon is on.
|
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||||
|
* Both the desktop and the phone admin show this one card under the addons,
|
||||||
|
* while the Tours addon is on.
|
||||||
*/
|
*/
|
||||||
export default function PaddleRegionsPanel() {
|
export default function PaddleRegionsPanel() {
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +70,8 @@ function Panel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||||
|
<AutoLoadSettings admin={admin} />
|
||||||
|
<RequestList admin={admin} />
|
||||||
<CatalogSearch admin={admin} />
|
<CatalogSearch admin={admin} />
|
||||||
<RegionList admin={admin} />
|
<RegionList admin={admin} />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11: the water network the router follows in a region, as one figure
|
||||||
|
* with the rest in its tooltip. A region read before routing existed says it
|
||||||
|
* needs reading again; a network is never claimed where none was built.
|
||||||
|
*/
|
||||||
|
function RegionNetwork({ region }: { region: PaddleRegion }) {
|
||||||
|
const { t, language } = useTranslation();
|
||||||
|
const { network } = region;
|
||||||
|
if (!network) {
|
||||||
|
if (!region.imported_at || region.status === 'importing') return null;
|
||||||
|
return (
|
||||||
|
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
|
||||||
|
<span className="inline-flex">
|
||||||
|
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
|
||||||
|
const details = [
|
||||||
|
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
|
||||||
|
t('admin.paddleRegions.network.portages', { count: network.portages }),
|
||||||
|
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
|
||||||
|
t('admin.paddleRegions.network.locks', { count: network.locks }),
|
||||||
|
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
|
||||||
|
].join(', ');
|
||||||
|
return (
|
||||||
|
<Tooltip label={details}>
|
||||||
|
<span className="inline-flex" data-testid="paddle-region-network">
|
||||||
|
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||||
const { t, language } = useTranslation();
|
const { t, language } = useTranslation();
|
||||||
const busy = admin.busyId === region.id;
|
const busy = admin.busyId === region.id;
|
||||||
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex flex-wrap items-center gap-1.5">
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
<RegionStatus region={region} />
|
<RegionStatus region={region} />
|
||||||
|
{region.origin === 'auto' && <AutoOriginPill />}
|
||||||
{region.imported_at && (
|
{region.imported_at && (
|
||||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||||
)}
|
)}
|
||||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||||
|
<RegionNetwork region={region} />
|
||||||
</span>
|
</span>
|
||||||
{region.status === 'failed' && region.error && (
|
{region.status === 'failed' && region.error && (
|
||||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { createElement, type ReactNode } from 'react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||||
|
|
||||||
|
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
|
||||||
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
|
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
|
||||||
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||||
|
|
||||||
|
describe('usePaddleAutoLoad', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
|
||||||
|
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
|
||||||
|
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
|
||||||
|
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
|
||||||
|
expect(repo.settings).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
|
||||||
|
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.save({ mode: 'ask' });
|
||||||
|
});
|
||||||
|
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
|
||||||
|
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
|
||||||
|
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
|
||||||
|
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.save({ max_mb: 2000 });
|
||||||
|
});
|
||||||
|
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
|
||||||
|
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { PaddleRegionSettings } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||||
|
import { getApiErrorMessage } from '../../../types';
|
||||||
|
import { useToast } from '../../shared/Toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11C in the admin area: how TREK loads the water data of a place a
|
||||||
|
* paddler plans in, read once the card shows and saved on every change. A
|
||||||
|
* change shows at once and goes back, with a toast, when the server refuses
|
||||||
|
* it. While one is saved the controls rest, so two saves never cross.
|
||||||
|
*/
|
||||||
|
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const pending = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || offline) return;
|
||||||
|
let cancelled = false;
|
||||||
|
paddleRegionRepo.settings().then(
|
||||||
|
(loaded) => {
|
||||||
|
if (!cancelled) setSettings(loaded);
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [active, offline, t, toast]);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (change: Partial<PaddleRegionSettings>) => {
|
||||||
|
if (!settings || pending.current) return;
|
||||||
|
const before = settings;
|
||||||
|
const next = { ...settings, ...change };
|
||||||
|
pending.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
setSettings(next);
|
||||||
|
try {
|
||||||
|
setSettings(await paddleRegionRepo.saveSettings(next));
|
||||||
|
} catch (err) {
|
||||||
|
setSettings(before);
|
||||||
|
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||||
|
} finally {
|
||||||
|
pending.current = false;
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settings, t, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { settings, saving, save };
|
||||||
|
}
|
||||||
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
|
|||||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||||
|
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||||
|
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
|
||||||
|
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
|
||||||
}));
|
}));
|
||||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||||
paddleRegionRepo: repo,
|
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
||||||
|
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
||||||
}));
|
}));
|
||||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
|
|||||||
id,
|
id,
|
||||||
name: id,
|
name: id,
|
||||||
status,
|
status,
|
||||||
|
origin: 'admin',
|
||||||
source_timestamp: null,
|
source_timestamp: null,
|
||||||
imported_at: null,
|
imported_at: null,
|
||||||
feature_count: 0,
|
feature_count: 0,
|
||||||
error: null,
|
error: null,
|
||||||
progress: null,
|
progress: null,
|
||||||
|
network: null,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
|
|||||||
import { getApiErrorMessage } from '../../../types';
|
import { getApiErrorMessage } from '../../../types';
|
||||||
import { useToast } from '../../shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { isReading } from './paddleRegionsModel';
|
import { isReading } from './paddleRegionsModel';
|
||||||
|
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
|
||||||
|
|
||||||
/** How often the list is asked again while a region waits or is read. */
|
/** How often the list is asked again while a region waits or is read. */
|
||||||
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
||||||
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regions as the server holds them, asked again every few seconds while
|
* The regions as the server holds them, with those paddlers asked for, asked
|
||||||
* any of them is read, and on `reload` after a change. Every answer is set
|
* again every few seconds while any of them is read, and on `reload` after a
|
||||||
|
* change. Every answer is set
|
||||||
* from the request's own callback, so a slow one never overwrites a newer.
|
* from the request's own callback, so a slow one never overwrites a newer.
|
||||||
*/
|
*/
|
||||||
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||||
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
||||||
|
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [asked, setAsked] = useState(0);
|
const [asked, setAsked] = useState(0);
|
||||||
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
|||||||
paddleRegionRepo.list().then(
|
paddleRegionRepo.list().then(
|
||||||
(list) => {
|
(list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRegions(list);
|
setRegions(list.regions);
|
||||||
|
setRequests(list.requests);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
},
|
},
|
||||||
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [active, offline, reading]);
|
}, [active, offline, reading]);
|
||||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||||
return { regions, loading: !loaded && !offline, error, reload };
|
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||||
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
|
|||||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
||||||
* in Geofabrik's list to add one, reading a region again and removing it
|
* in Geofabrik's list to add one, reading a region again and removing it
|
||||||
* after a confirmation. Shown only while the Tours addon is on; the status of
|
* after a confirmation. Shown only while the Tours addon is on; the status of
|
||||||
* a read follows without reloading the page.
|
* a read follows without reloading the page. PAD-11C: how TREK loads regions
|
||||||
|
* for paddlers by itself, and the regions they asked for, to load or decline.
|
||||||
*/
|
*/
|
||||||
export function usePaddleRegionsAdmin() {
|
export function usePaddleRegionsAdmin() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
|
|||||||
const { offline } = useNetworkMode();
|
const { offline } = useNetworkMode();
|
||||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||||
const list = useRegionList(toursOn, offline, t);
|
const list = useRegionList(toursOn, offline, t);
|
||||||
|
const autoLoad = usePaddleAutoLoad(toursOn, offline);
|
||||||
const search = useCatalogSearch(offline, t);
|
const search = useCatalogSearch(offline, t);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||||
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
|
|||||||
[run, setQuery, t]
|
[run, setQuery, t]
|
||||||
);
|
);
|
||||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||||
|
/** A paddler's request let through: the region is added like any other. */
|
||||||
|
const approve = useCallback(
|
||||||
|
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
|
||||||
|
[run, t]
|
||||||
|
);
|
||||||
|
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
|
||||||
const confirmRemove = useCallback(() => {
|
const confirmRemove = useCallback(() => {
|
||||||
const region = removing;
|
const region = removing;
|
||||||
setRemoving(null);
|
setRemoving(null);
|
||||||
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
|
|||||||
toursOn,
|
toursOn,
|
||||||
offline,
|
offline,
|
||||||
regions: list.regions,
|
regions: list.regions,
|
||||||
|
requests: list.requests,
|
||||||
|
autoLoad,
|
||||||
loading: list.loading,
|
loading: list.loading,
|
||||||
error: list.error,
|
error: list.error,
|
||||||
retry: reload,
|
retry: reload,
|
||||||
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
|
|||||||
busyId,
|
busyId,
|
||||||
add,
|
add,
|
||||||
refresh,
|
refresh,
|
||||||
|
approve,
|
||||||
|
decline,
|
||||||
removing,
|
removing,
|
||||||
askRemove: setRemoving,
|
askRemove: setRemoving,
|
||||||
cancelRemove: () => setRemoving(null),
|
cancelRemove: () => setRemoving(null),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const CATEGORIES = [
|
|||||||
{ id: 'members', keys: ['member_manage'] },
|
{ id: 'members', keys: ['member_manage'] },
|
||||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
|
|||||||
await screen.findByRole('button', { name: /create share link/i })
|
await screen.findByRole('button', { name: /create share link/i })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
|
||||||
|
const { unmount } = mountDialog()
|
||||||
|
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
mountDialog(buildJourney({
|
||||||
|
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
|
||||||
|
}))
|
||||||
|
expect(screen.getByText('Through trips')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('lena')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
|
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const { onSaved } = mountDialog()
|
const { onSaved } = mountDialog()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
|||||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||||
import JourneyShareSection from './JourneyShareSection'
|
import JourneyShareSection from './JourneyShareSection'
|
||||||
import { useJourneySettings } from './useJourneySettings'
|
import { useJourneySettings } from './useJourneySettings'
|
||||||
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
|
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||||
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
|||||||
</div>
|
</div>
|
||||||
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
||||||
</DialogSection>
|
</DialogSection>
|
||||||
|
|
||||||
|
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
||||||
|
* contributors: shown here, but managed in that trip's settings, so read only.
|
||||||
|
*/
|
||||||
|
function TripAccessSection({ access, roleLabel }: {
|
||||||
|
access: JourneyTripAccess[] | undefined
|
||||||
|
roleLabel: (role: JourneyContributor['role']) => string
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!access?.length) return null
|
||||||
|
return (
|
||||||
|
<DialogSection label={t('journey.contributors.viaTrips')}>
|
||||||
|
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
||||||
|
<div className={ROWS}>
|
||||||
|
{access.map(a => (
|
||||||
|
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
||||||
|
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
||||||
|
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -169,23 +169,24 @@ describe('Navbar', () => {
|
|||||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
||||||
render(<Navbar onShare={vi.fn()} />);
|
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
const back = screen.getByRole('button', { name: /back/i });
|
||||||
expect(shareBtn).toBeInTheDocument();
|
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
||||||
|
expect(back.parentElement).toBe(settings.closest('div'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onShare = vi.fn();
|
const onSettings = vi.fn();
|
||||||
render(<Navbar onShare={onShare} />);
|
render(<Navbar onSettings={onSettings} />);
|
||||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
||||||
await user.click(shareBtn);
|
expect(onSettings).toHaveBeenCalled();
|
||||||
expect(onShare).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
||||||
render(<Navbar />);
|
render(<Navbar />);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
|
|||||||
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
||||||
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||||
|
|
||||||
const back = screen.getByRole('button', { name: /back/i });
|
const back = screen.getByRole('button', { name: /back/i });
|
||||||
fireEvent.mouseEnter(back);
|
fireEvent.mouseEnter(back);
|
||||||
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
|
|||||||
fireEvent.mouseLeave(back);
|
fireEvent.mouseLeave(back);
|
||||||
expect(back.style.background).toBe('transparent');
|
expect(back.style.background).toBe('transparent');
|
||||||
|
|
||||||
const share = screen.getByRole('button', { name: /share/i });
|
|
||||||
fireEvent.mouseEnter(share);
|
|
||||||
expect(share.style.background).toBe('var(--bg-hover)');
|
|
||||||
fireEvent.mouseLeave(share);
|
|
||||||
expect(share.style.background).toBe('var(--bg-card)');
|
|
||||||
|
|
||||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||||
fireEvent.mouseEnter(theme);
|
fireEvent.mouseEnter(theme);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { usePluginStore } from '../../store/pluginStore'
|
import { usePluginStore } from '../../store/pluginStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||||
import HelpButton from '../Help/HelpButton'
|
import HelpButton from '../Help/HelpButton'
|
||||||
@@ -21,7 +21,8 @@ interface NavbarProps {
|
|||||||
tripId?: number | string
|
tripId?: number | string
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
showBack?: boolean
|
showBack?: boolean
|
||||||
onShare?: () => void
|
/** The trip's Settings: who is on it, its links and its journal. */
|
||||||
|
onSettings?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
@@ -32,7 +33,7 @@ interface Addon {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||||
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* The trip's Settings sit with the trip, beside its pill: they are about this
|
||||||
|
trip, not about the app the right side of the bar belongs to. */}
|
||||||
|
{onSettings && (
|
||||||
|
// The same pill as the trip's own beside it, so the two read as one pair.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSettings}
|
||||||
|
aria-label={t('trip.settings.title')}
|
||||||
|
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
|
||||||
|
<span className="text-caption font-semibold">{t('nav.settings')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Centred liquid-glass tab menu (design handoff).
|
{/* Centred liquid-glass tab menu (design handoff).
|
||||||
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
itself on top of them. */}
|
itself on top of them. */}
|
||||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||||
|
|
||||||
{/* Share button */}
|
|
||||||
{onShare && (
|
|
||||||
<button type="button" onClick={onShare}
|
|
||||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
<span className="hidden sm:inline">{t('nav.share')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||||
{user && <HelpButton />}
|
{user && <HelpButton />}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
|||||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||||
import HazardLayers from './HazardLayers'
|
import HazardLayers from './HazardLayers'
|
||||||
|
import { RouteHitBand } from './RouteHitBand'
|
||||||
import { formatViaDwell } from './routeViaDwell'
|
import { formatViaDwell } from './routeViaDwell'
|
||||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||||
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
|
|||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||||
The casing carries the click when the route can be reshaped: it is the wider of
|
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||||
the two, so it is the one a pointer actually lands on. */}
|
|
||||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||||
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||||
<Polyline
|
<Polyline
|
||||||
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
|
|||||||
key={`${i}-casing`}
|
key={`${i}-casing`}
|
||||||
positions={seg}
|
positions={seg}
|
||||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
interactive={!!onRouteClick}
|
interactive={false}
|
||||||
eventHandlers={onRouteClick ? {
|
|
||||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
|
||||||
// Stops the map's own click, which would otherwise open the add-place menu
|
|
||||||
// underneath the new via.
|
|
||||||
e.originalEvent.stopPropagation()
|
|
||||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
|
||||||
},
|
|
||||||
} : undefined}
|
|
||||||
/>,
|
/>,
|
||||||
<Polyline
|
<Polyline
|
||||||
key={`${i}-core`}
|
key={`${i}-core`}
|
||||||
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
|
|||||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
/>,
|
/>,
|
||||||
|
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
|
||||||
])}
|
])}
|
||||||
|
|
||||||
{/* The last bit to a place the road does not reach.
|
{/* The last bit to a place the road does not reach.
|
||||||
|
|||||||
@@ -2,18 +2,25 @@ import L from 'leaflet';
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||||
|
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||||
|
|
||||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||||
export type PinCss = (size: number, selected: boolean) => string;
|
export type PinCss = (size: number, selected: boolean) => string;
|
||||||
|
|
||||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
function plannerWaypointIcon(
|
||||||
const size = selected ? 30 : 26;
|
pinCss: PinCss,
|
||||||
|
point: PlannerPinPoint,
|
||||||
|
index: number,
|
||||||
|
selected: boolean,
|
||||||
|
draggable: boolean
|
||||||
|
): L.DivIcon {
|
||||||
|
const { size, text } = plannerPinShape(point, index, selected);
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'tour-planner-waypoint-marker',
|
className: 'tour-planner-waypoint-marker',
|
||||||
iconSize: [size, size],
|
iconSize: [size, size],
|
||||||
iconAnchor: [size / 2, size / 2],
|
iconAnchor: [size / 2, size / 2],
|
||||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
|
|||||||
onMove,
|
onMove,
|
||||||
pinCss,
|
pinCss,
|
||||||
}: {
|
}: {
|
||||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
|
||||||
index: number;
|
index: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick?: (id: string) => void;
|
onClick?: (id: string) => void;
|
||||||
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
|
|||||||
handlersRef.current = { onClick, onMove };
|
handlersRef.current = { onClick, onMove };
|
||||||
}, [onClick, onMove]);
|
}, [onClick, onMove]);
|
||||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
const { label, shaping } = point;
|
||||||
|
const icon = useMemo(
|
||||||
|
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
|
||||||
|
[index, label, onMove, pinCss, selected, shaping]
|
||||||
|
);
|
||||||
const eventHandlers = useMemo(
|
const eventHandlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
click: (event: { originalEvent: MouseEvent }) => {
|
click: (event: { originalEvent: MouseEvent }) => {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Polyline } from 'react-leaflet';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
||||||
|
* than the drawn line, that takes the click which reshapes the route, as the
|
||||||
|
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
|
||||||
|
*/
|
||||||
|
export function RouteHitBand({
|
||||||
|
positions,
|
||||||
|
onRouteClick,
|
||||||
|
}: {
|
||||||
|
positions: [number, number][];
|
||||||
|
onRouteClick: (lat: number, lng: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Polyline
|
||||||
|
positions={positions}
|
||||||
|
pathOptions={{
|
||||||
|
color: 'transparent',
|
||||||
|
weight: 20,
|
||||||
|
opacity: 0,
|
||||||
|
lineCap: 'round',
|
||||||
|
lineJoin: 'round',
|
||||||
|
className: 'cursor-copy',
|
||||||
|
}}
|
||||||
|
eventHandlers={{
|
||||||
|
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||||
|
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
onRouteClick(e.latlng.lat, e.latlng.lng);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -110,7 +110,15 @@ export interface MapViewProps {
|
|||||||
onHighlightAlternative?: (index: number | null) => void;
|
onHighlightAlternative?: (index: number | null) => void;
|
||||||
/** Generic numbered control points for list-first route editors. */
|
/** Generic numbered control points for list-first route editors. */
|
||||||
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
||||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
plannerWaypoints?: Array<{
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
name?: string;
|
||||||
|
detail?: string;
|
||||||
|
label?: string;
|
||||||
|
shaping?: boolean;
|
||||||
|
}>;
|
||||||
selectedPlannerWaypointId?: string | null;
|
selectedPlannerWaypointId?: string | null;
|
||||||
onPlannerWaypointClick?: (id: string) => void;
|
onPlannerWaypointClick?: (id: string) => void;
|
||||||
/** Drops a dragged control point; returns false when the move was refused. */
|
/** Drops a dragged control point; returns false when the move was refused. */
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { plannerPinShape } from './plannerPinShape';
|
||||||
|
|
||||||
|
describe('plannerPinShape', () => {
|
||||||
|
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
||||||
|
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
|
||||||
|
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
|
||||||
|
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
||||||
|
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
||||||
|
export interface PlannerPinPoint {
|
||||||
|
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
||||||
|
label?: string;
|
||||||
|
shaping?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
||||||
|
export function plannerPinShape(
|
||||||
|
point: PlannerPinPoint,
|
||||||
|
index: number,
|
||||||
|
selected: boolean
|
||||||
|
): { size: number; text: string } {
|
||||||
|
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
||||||
|
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||||
import type { MapViewProps } from './mapViewContract';
|
import type { MapViewProps } from './mapViewContract';
|
||||||
|
import { plannerPinShape } from './plannerPinShape';
|
||||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||||
|
|
||||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||||
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
|||||||
|
|
||||||
for (const [index, point] of plannerWaypoints.entries()) {
|
for (const [index, point] of plannerWaypoints.entries()) {
|
||||||
const selected = point.id === selectedPlannerWaypointId;
|
const selected = point.id === selectedPlannerWaypointId;
|
||||||
const size = selected ? 30 : 26;
|
const { size, text } = plannerPinShape(point, index, selected);
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
el.textContent = String(index + 1);
|
el.textContent = text;
|
||||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||||
el.setAttribute('aria-label', name);
|
el.setAttribute('aria-label', name);
|
||||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
|
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
|
||||||
<div ref={bodyRef}>
|
<div ref={bodyRef}>
|
||||||
{asking ? (
|
{asking ? (
|
||||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { PaddleLakeStyle } from '@trek/shared';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
|
||||||
|
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
|
||||||
|
* shore is for wind, when a capsize near the bank is a short swim. The wind
|
||||||
|
* card suggests it on a windy day; here it is chosen.
|
||||||
|
*/
|
||||||
|
export function PaddleLakeStyleField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: PaddleLakeStyle;
|
||||||
|
onChange: (style: PaddleLakeStyle) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div data-testid="tour-planner-lake-style" className="min-w-0">
|
||||||
|
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
|
||||||
|
<Segmented<PaddleLakeStyle>
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
label={t('tours.paddle.lakeStyle')}
|
||||||
|
fill
|
||||||
|
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
|
||||||
|
{t(`tours.paddle.lakeStyle.${value}Hint`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@ const PAINT: PaddlePaint = {
|
|||||||
access: '#4338ca',
|
access: '#4338ca',
|
||||||
landing: '#0f766e',
|
landing: '#0f766e',
|
||||||
portage: '#9a6233',
|
portage: '#9a6233',
|
||||||
|
rapids: '#d97706',
|
||||||
|
gap: '#dc2626',
|
||||||
disc: '#ffffff',
|
disc: '#ffffff',
|
||||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type { PaddleRouteLeg } from '@trek/shared';
|
||||||
|
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
|
||||||
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
|
import { formatDistance } from '../../../utils/units';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
|
import type { PaddleLegFailure } from '../planner/paddleRouting';
|
||||||
|
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11: what the waypoint list says about a leg the router planned or
|
||||||
|
* could not plan, and about a point it set itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
|
||||||
|
export function AutoPointPill() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
|
||||||
|
return (
|
||||||
|
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
|
||||||
|
<span
|
||||||
|
data-testid="tour-planner-auto-point"
|
||||||
|
aria-label={t('tours.paddle.auto')}
|
||||||
|
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
|
||||||
|
>
|
||||||
|
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
|
||||||
|
|
||||||
|
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
|
||||||
|
function notesOf(
|
||||||
|
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
|
): Note[] {
|
||||||
|
return leg.segments.flatMap((segment, index): Note[] => {
|
||||||
|
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
|
||||||
|
if (segment.kind === 'rapids' && segment.grade != null) {
|
||||||
|
return [
|
||||||
|
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (segment.kind === 'pass')
|
||||||
|
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
|
||||||
|
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
|
||||||
|
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||||
|
if (!leg || leg.status !== 'ok') return null;
|
||||||
|
const notes = notesOf(leg, t);
|
||||||
|
if (notes.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Tooltip
|
||||||
|
key={note.key}
|
||||||
|
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
|
||||||
|
style={fs(11)}
|
||||||
|
>
|
||||||
|
{note.tone === 'gap' ? (
|
||||||
|
<AlertTriangle
|
||||||
|
size={11}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
className="flex-none"
|
||||||
|
style={{ color: 'var(--map-paddle-gap)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Waves
|
||||||
|
size={11}
|
||||||
|
strokeWidth={2.2}
|
||||||
|
className="flex-none"
|
||||||
|
style={{ color: 'var(--map-paddle-rapids)' }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 truncate">{note.label}</span>
|
||||||
|
<span
|
||||||
|
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
|
||||||
|
style={fs(10)}
|
||||||
|
>
|
||||||
|
{formatDistance(note.length / 1000, unit)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leg the router could not plan: why, in plain words, and what to do about
|
||||||
|
* it. The map draws it straight and red meanwhile, and the tour waits to be
|
||||||
|
* saved until it is planned or drawn by hand on purpose. Where TREK could not
|
||||||
|
* fetch the water data of the leg's region (PAD-11C), `message` says why and
|
||||||
|
* `onLoadRetry` reads the region again.
|
||||||
|
*/
|
||||||
|
export function PaddleLegFailureRow({
|
||||||
|
code,
|
||||||
|
disabled,
|
||||||
|
onDrawStraight,
|
||||||
|
onRetry,
|
||||||
|
message,
|
||||||
|
onLoadRetry,
|
||||||
|
}: {
|
||||||
|
code: PaddleLegFailure;
|
||||||
|
disabled: boolean;
|
||||||
|
onDrawStraight: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
message?: string | null;
|
||||||
|
onLoadRetry?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
|
||||||
|
const action =
|
||||||
|
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
|
||||||
|
return (
|
||||||
|
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||||
|
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
|
||||||
|
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||||
|
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap gap-1">
|
||||||
|
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
|
||||||
|
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
{t('tours.paddle.drawStraight')}
|
||||||
|
</button>
|
||||||
|
{retry && (
|
||||||
|
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
|
||||||
|
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
{t('tours.paddle.retry')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||||
|
import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton';
|
||||||
|
import { placeOf } from './paddleObstaclePlace';
|
||||||
|
|
||||||
|
describe('placeOf', () => {
|
||||||
|
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
|
||||||
|
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
|
||||||
|
expect(
|
||||||
|
placeOf({
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[12.9, 53.2],
|
||||||
|
[12.91, 53.21],
|
||||||
|
[12.92, 53.22],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toEqual({ lat: 53.21, lng: 12.91 });
|
||||||
|
const middle = placeOf({
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[12, 53],
|
||||||
|
[12.02, 53],
|
||||||
|
[12.02, 53.02],
|
||||||
|
[12, 53.02],
|
||||||
|
[12, 53],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(middle?.lat).toBeCloseTo(53.01, 9);
|
||||||
|
expect(middle?.lng).toBeCloseTo(12.01, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PaddleObstaclePlaceButton', () => {
|
||||||
|
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
|
||||||
|
const onPlace = vi.fn();
|
||||||
|
render(
|
||||||
|
<PaddleObstaclePlaceButton
|
||||||
|
kind="slipway"
|
||||||
|
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||||
|
onPlace={onPlace}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
|
||||||
|
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
|
||||||
|
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
|
||||||
|
render(
|
||||||
|
<PaddleObstaclePlaceButton
|
||||||
|
kind="egress"
|
||||||
|
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||||
|
onPlace={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
|
||||||
|
render(
|
||||||
|
<PaddleObstaclePlaceButton
|
||||||
|
kind="weir"
|
||||||
|
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||||
|
onPlace={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
|
||||||
|
import { MapPinPlus } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { placeOf } from './paddleObstaclePlace';
|
||||||
|
|
||||||
|
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
|
||||||
|
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
|
||||||
|
put_in: 'put_in',
|
||||||
|
slipway: 'put_in',
|
||||||
|
egress: 'egress',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
|
||||||
|
* waypoint from its card on the map, so the point sits where the paddler
|
||||||
|
* really gets in or out. Set after a portage asked for at the last point, it
|
||||||
|
* is where the portage ends.
|
||||||
|
*/
|
||||||
|
export function PaddleObstaclePlaceButton({
|
||||||
|
kind,
|
||||||
|
geometry,
|
||||||
|
onPlace,
|
||||||
|
}: {
|
||||||
|
kind: PaddleObstacleKind;
|
||||||
|
geometry: PaddleGeometry;
|
||||||
|
onPlace: (lat: number, lng: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const role = PLACEABLE[kind];
|
||||||
|
const spot = role ? placeOf(geometry) : null;
|
||||||
|
if (!role || !spot) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="paddle-obstacle-place"
|
||||||
|
onClick={() => onPlace(spot.lat, spot.lng)}
|
||||||
|
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
|
||||||
|
style={fs(12, 'body')}
|
||||||
|
>
|
||||||
|
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
|
||||||
|
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||||
|
import { PaddleRegionLoadRow } from './PaddleRegionLoadRow';
|
||||||
|
import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad';
|
||||||
|
|
||||||
|
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||||
|
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
|
||||||
|
|
||||||
|
describe('PaddleRegionLoadRow', () => {
|
||||||
|
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
|
||||||
|
const onDrawStraight = vi.fn();
|
||||||
|
render(
|
||||||
|
<PaddleRegionLoadRow
|
||||||
|
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
|
||||||
|
disabled={false}
|
||||||
|
onDrawStraight={onDrawStraight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const row = screen.getByTestId('tour-planner-region-load');
|
||||||
|
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
|
||||||
|
expect(row).toHaveTextContent('Downloading 45%');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
|
||||||
|
expect(onDrawStraight).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
|
||||||
|
render(
|
||||||
|
<PaddleRegionLoadRow
|
||||||
|
state={{ state: 'requested', region: REF, reason: 'too_large' }}
|
||||||
|
disabled={false}
|
||||||
|
onDrawStraight={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const row = screen.getByTestId('tour-planner-region-load');
|
||||||
|
expect(row).toHaveTextContent('has been requested');
|
||||||
|
expect(row).toHaveTextContent('larger than TREK loads by itself');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
|
||||||
|
const { unmount } = render(
|
||||||
|
<PaddleRegionLoadRow
|
||||||
|
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
|
||||||
|
disabled={false}
|
||||||
|
onDrawStraight={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
|
||||||
|
unmount();
|
||||||
|
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
|
||||||
|
expect(screen.queryByRole('button')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('region load states', () => {
|
||||||
|
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
|
||||||
|
const pending: PaddleRegionLoadState[] = [
|
||||||
|
{ state: 'ready', region: REF },
|
||||||
|
{ state: 'requested', region: REF, reason: 'ask' },
|
||||||
|
];
|
||||||
|
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
|
||||||
|
expect(isRegionLoadPending(undefined)).toBe(false);
|
||||||
|
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
|
||||||
|
'tours.paddle.load.declined{"region":"Oberpfalz"}'
|
||||||
|
);
|
||||||
|
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
|
||||||
|
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
|
||||||
|
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
|
||||||
|
'tours.paddle.load.noExtract'
|
||||||
|
);
|
||||||
|
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
|
||||||
|
'tours.paddle.load.catalogUnavailable'
|
||||||
|
);
|
||||||
|
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
|
||||||
|
expect(regionLoadFailure(undefined, t)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||||
|
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
|
||||||
|
import { useTranslation } from '../../../i18n';
|
||||||
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||||
|
|
||||||
|
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
|
||||||
|
const region = state.region.name;
|
||||||
|
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
|
||||||
|
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
|
||||||
|
return t('tours.paddle.load.loading', { region });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phase of a load in words, with its share where it is known. */
|
||||||
|
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
|
||||||
|
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
|
||||||
|
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
|
||||||
|
const share = state.progress.fraction;
|
||||||
|
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
|
||||||
|
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
|
||||||
|
* the data is on its way, and the leg is planned on its own once it is there.
|
||||||
|
* Drawing it straight stays possible for whoever does not want to wait.
|
||||||
|
*/
|
||||||
|
export function PaddleRegionLoadRow({
|
||||||
|
state,
|
||||||
|
disabled,
|
||||||
|
onDrawStraight,
|
||||||
|
}: {
|
||||||
|
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
|
||||||
|
disabled: boolean;
|
||||||
|
onDrawStraight: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, language } = useTranslation();
|
||||||
|
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
|
||||||
|
const busy = state.state !== 'requested';
|
||||||
|
return (
|
||||||
|
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span className="min-w-0">{headline(state, t)}</span>
|
||||||
|
</span>
|
||||||
|
{state.state === 'loading' && (
|
||||||
|
<span className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1">
|
||||||
|
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||||
|
<span className="truncate">{phaseText(state, t, language)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
|
||||||
|
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||||
|
{t('tours.paddle.load.hint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.state === 'requested' && state.reason !== 'ask' && (
|
||||||
|
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
|
||||||
|
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state.state !== 'ready' && (
|
||||||
|
<span className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onDrawStraight}
|
||||||
|
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
|
||||||
|
style={fs(10.5)}
|
||||||
|
>
|
||||||
|
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||||
|
{t('tours.paddle.drawStraight')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
|||||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||||
import { useSettingsStore } from '../../../store/settingsStore';
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
|
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
|
||||||
|
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||||
import { portageLegBetween } from '../planner/tourRouteActions';
|
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||||
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
|
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
|
||||||
|
|
||||||
/** What the card writes for a time it cannot give: an en dash. */
|
/** What the card writes for a time it cannot give: an en dash. */
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03).
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||||
|
import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts';
|
||||||
|
|
||||||
|
const way = (extra: Partial<PortageWay> = {}): PortageWay => ({
|
||||||
|
routed: false,
|
||||||
|
noWay: false,
|
||||||
|
searching: false,
|
||||||
|
onFollow: vi.fn(),
|
||||||
|
onStraighten: vi.fn(),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPortage(props: Partial<Parameters<typeof PortageLegRow>[0]> = {}) {
|
||||||
|
render(<PortageLegRow lengthM={320} extraMinutes={null} disabled={false} onExtraChange={vi.fn()} {...props} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { expanded: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PendingPortageRow', () => {
|
||||||
|
it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => {
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
render(<PendingPortageRow disabled={false} onRemove={onRemove} />);
|
||||||
|
expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' }));
|
||||||
|
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => {
|
||||||
|
render(<PendingPortageRow disabled onRemove={vi.fn()} />);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PortageLegRow way', () => {
|
||||||
|
it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => {
|
||||||
|
const straight = way();
|
||||||
|
openPortage({ way: straight });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Follow the path' }));
|
||||||
|
expect(straight.onFollow).toHaveBeenCalledTimes(1);
|
||||||
|
const routed = way({ routed: true });
|
||||||
|
openPortage({ way: routed });
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]);
|
||||||
|
expect(routed.onStraighten).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => {
|
||||||
|
openPortage({ way: way({ noWay: true }) });
|
||||||
|
expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour');
|
||||||
|
openPortage({ way: way({ searching: true }) });
|
||||||
|
expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
|
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
|
||||||
import { ChevronDown, X } from 'lucide-react';
|
import { ChevronDown, Loader2, X } from 'lucide-react';
|
||||||
import { useCallback, useState, type ReactNode } from 'react';
|
import { useCallback, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
import { useSettingsStore } from '../../../store/settingsStore';
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
import { formatDistance } from '../../../utils/units';
|
import { formatDistance } from '../../../utils/units';
|
||||||
|
import { Segmented } from '../../shared/dialogParts';
|
||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
import { PaddleGlyph } from './PaddleGlyph';
|
import { PaddleGlyph } from './PaddleGlyph';
|
||||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||||
@@ -199,6 +200,99 @@ function StopCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A portage asked for at the last point, before the point it leads to is set:
|
||||||
|
* dashed in the portage brown, it says where to click next, and can be taken
|
||||||
|
* back. Once the next point is set it becomes a portage like any other.
|
||||||
|
*/
|
||||||
|
export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<li data-testid="tour-planner-portage-pending" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||||
|
<div
|
||||||
|
className="flex w-full min-w-0 items-start gap-1.5 rounded-[14px] border border-dashed bg-surface-card py-1 pe-1 ps-1"
|
||||||
|
style={{ borderColor: PORTAGE_CSS_COLOR }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-surface-card"
|
||||||
|
style={{ background: PORTAGE_CSS_COLOR }}
|
||||||
|
>
|
||||||
|
<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col py-[2px]">
|
||||||
|
<span className="truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||||
|
{t('tours.paddle.portagePending')}
|
||||||
|
</span>
|
||||||
|
<span className="leading-snug text-content-muted" style={fs(11, 'body')}>
|
||||||
|
{t('tours.paddle.portagePendingHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={t('tours.paddle.portagePendingOff')}
|
||||||
|
title={t('tours.paddle.portagePendingOff')}
|
||||||
|
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||||
|
>
|
||||||
|
<X size={12} strokeWidth={2.4} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortageWay {
|
||||||
|
/** The portage follows the ways on land (its line has a shape of its own). */
|
||||||
|
routed: boolean;
|
||||||
|
/** The router was asked and found no way without a big detour. */
|
||||||
|
noWay: boolean;
|
||||||
|
/** The router is being asked right now. */
|
||||||
|
searching: boolean;
|
||||||
|
onFollow: () => void;
|
||||||
|
onStraighten: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */
|
||||||
|
function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const value = way.routed ? 'follow' : 'straight';
|
||||||
|
const hint = way.searching
|
||||||
|
? t('tours.paddle.portageWay.searching')
|
||||||
|
: !way.routed && way.noWay
|
||||||
|
? t('tours.paddle.portageWay.noWay')
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1" data-testid="tour-planner-portage-way">
|
||||||
|
<span className="block truncate text-content" style={fs(12, 'body')}>
|
||||||
|
{t('tours.paddle.portageWay')}
|
||||||
|
</span>
|
||||||
|
<fieldset disabled={disabled || way.searching} className="m-0 min-w-0 border-0 p-0">
|
||||||
|
<Segmented<'follow' | 'straight'>
|
||||||
|
value={value}
|
||||||
|
fill
|
||||||
|
label={t('tours.paddle.portageWay')}
|
||||||
|
onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'follow',
|
||||||
|
label: t('tours.paddle.portageWay.follow'),
|
||||||
|
icon: way.searching ? <Loader2 size={12} className="animate-spin" aria-hidden="true" /> : undefined,
|
||||||
|
},
|
||||||
|
{ value: 'straight', label: t('tours.paddle.portageWay.straight') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
{hint && (
|
||||||
|
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A portage under the point it starts at, in the earth brown the map draws it
|
* A portage under the point it starts at, in the earth brown the map draws it
|
||||||
* in: its length on land and the time for getting out, unloading and back in
|
* in: its length on land and the time for getting out, unloading and back in
|
||||||
@@ -211,6 +305,7 @@ export function PortageLegRow({
|
|||||||
onExtraChange,
|
onExtraChange,
|
||||||
position,
|
position,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
way,
|
||||||
}: {
|
}: {
|
||||||
lengthM: number;
|
lengthM: number;
|
||||||
extraMinutes: number | null;
|
extraMinutes: number | null;
|
||||||
@@ -220,6 +315,8 @@ export function PortageLegRow({
|
|||||||
position?: string;
|
position?: string;
|
||||||
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
|
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
|
/** On a planned tour: whether the portage follows the ways on land or goes straight, and the switch between them. */
|
||||||
|
way?: PortageWay;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||||
@@ -242,6 +339,7 @@ export function PortageLegRow({
|
|||||||
{t('tours.paddle.portageInfo')}
|
{t('tours.paddle.portageInfo')}
|
||||||
</p>
|
</p>
|
||||||
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
|
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
|
||||||
|
{way && <PortageWayField way={way} disabled={disabled} />}
|
||||||
<StopField
|
<StopField
|
||||||
label={t('tours.paddle.portageExtra')}
|
label={t('tours.paddle.portageExtra')}
|
||||||
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
|
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-018: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
||||||
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
|
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -340,6 +340,30 @@ describe('PaddleWindSection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PaddleWindCard on the lakes (PAD-11)', () => {
|
||||||
|
it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => {
|
||||||
|
const onChoose = vi.fn();
|
||||||
|
const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] })));
|
||||||
|
const { rerender } = render(<PaddleWindCard wind={windy} lakes={{ value: 'direct', onChoose }} />);
|
||||||
|
const advice = screen.getByTestId('paddle-wind-shore-advice');
|
||||||
|
expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.');
|
||||||
|
fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' }));
|
||||||
|
expect(onChoose).toHaveBeenCalledWith('shore');
|
||||||
|
|
||||||
|
rerender(<PaddleWindCard wind={windy} lakes={{ value: 'shore', onChoose }} />);
|
||||||
|
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => {
|
||||||
|
const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] })));
|
||||||
|
const { rerender } = render(<PaddleWindCard wind={calm} lakes={{ value: 'direct', onChoose: vi.fn() }} />);
|
||||||
|
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||||
|
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ hours: [hourOf(12, 30, 50, 270)] })))} />);
|
||||||
|
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('FigureBadge', () => {
|
describe('FigureBadge', () => {
|
||||||
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
|
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
|
||||||
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
|
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
|
import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||||
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
|
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
@@ -8,6 +8,7 @@ import { fs } from '../../shared/DialogShell';
|
|||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
import { RailCard, TourNotice } from '../tourParts';
|
import { RailCard, TourNotice } from '../tourParts';
|
||||||
import { FigureBadge } from './FigureBadge';
|
import { FigureBadge } from './FigureBadge';
|
||||||
|
import { windForShore } from './lakeStyleAdvice';
|
||||||
import { useTourWind, type TourWind } from './useTourWind';
|
import { useTourWind, type TourWind } from './useTourWind';
|
||||||
|
|
||||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||||
@@ -209,15 +210,58 @@ function WindHints({ t }: { t: Translate }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */
|
||||||
|
export interface LakeStyleChoice {
|
||||||
|
value: PaddleLakeStyle;
|
||||||
|
onChoose: (style: PaddleLakeStyle) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11: on a windy day, the offer to keep near the shores on the lakes,
|
||||||
|
* with the wind that prompts it; once taken, it says the lakes are planned so.
|
||||||
|
*/
|
||||||
|
function ShoreAdvice({
|
||||||
|
wind,
|
||||||
|
lakes,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
wind: { meanKmh: number; gustsKmh: number };
|
||||||
|
lakes: LakeStyleChoice;
|
||||||
|
t: Translate;
|
||||||
|
}) {
|
||||||
|
if (lakes.value === 'shore') {
|
||||||
|
return (
|
||||||
|
<TourNotice role="status" icon={<Wind size={13} strokeWidth={2} />}>
|
||||||
|
{t('tours.paddle.wind.shoreTaken')}
|
||||||
|
</TourNotice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TourNotice tone="warning" icon={<Wind size={13} strokeWidth={2} />}>
|
||||||
|
<span data-testid="paddle-wind-shore-advice" className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => lakes.onChoose('shore')}
|
||||||
|
className="font-semibold underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
{t('tours.paddle.wind.shoreAdopt')}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</TourNotice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
|
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
|
||||||
* mean, the gusts and the direction are figures of their own, with the source
|
* mean, the gusts and the direction are figures of their own, with the source
|
||||||
* and when it was asked; no value is turned into a verdict, and missing data
|
* and when it was asked; no value is turned into a verdict, and missing data
|
||||||
* is never shown as calm.
|
* is never shown as calm.
|
||||||
*/
|
*/
|
||||||
export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) {
|
||||||
const { t, locale } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
|
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
|
||||||
|
const shoreWind = ready && lakes ? windForShore(ready) : null;
|
||||||
return (
|
return (
|
||||||
<RailCard
|
<RailCard
|
||||||
icon={<Wind size={14} strokeWidth={2} />}
|
icon={<Wind size={14} strokeWidth={2} />}
|
||||||
@@ -249,6 +293,7 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
|||||||
) : (
|
) : (
|
||||||
<Unavailable wind={wind} t={t} locale={locale} />
|
<Unavailable wind={wind} t={t} locale={locale} />
|
||||||
)}
|
)}
|
||||||
|
{shoreWind && lakes && <ShoreAdvice wind={shoreWind} lakes={lakes} t={t} />}
|
||||||
<WindHints t={t} />
|
<WindHints t={t} />
|
||||||
</div>
|
</div>
|
||||||
</RailCard>
|
</RailCard>
|
||||||
@@ -259,10 +304,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
|||||||
export function PaddleWindSection({
|
export function PaddleWindSection({
|
||||||
placeId,
|
placeId,
|
||||||
start,
|
start,
|
||||||
|
lakes,
|
||||||
}: {
|
}: {
|
||||||
placeId: number | null;
|
placeId: number | null;
|
||||||
start: { lat: number; lng: number } | null;
|
start: { lat: number; lng: number } | null;
|
||||||
|
/** Given while the tour is edited and its route crosses a lake: the card may suggest the shore. */
|
||||||
|
lakes?: LakeStyleChoice;
|
||||||
}) {
|
}) {
|
||||||
const wind = useTourWind(placeId, start);
|
const wind = useTourWind(placeId, start);
|
||||||
return <PaddleWindCard wind={wind} />;
|
return <PaddleWindCard wind={wind} lakes={lakes} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08).
|
||||||
|
import type { WindForecastResponse, WindHour } from '@trek/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { crossesLake, windForShore } from './lakeStyleAdvice';
|
||||||
|
|
||||||
|
function forecast(hours: Array<Partial<WindHour> & { hour: number }>): WindForecastResponse {
|
||||||
|
return {
|
||||||
|
status: 'forecast',
|
||||||
|
date: '2026-06-01',
|
||||||
|
lat: 53.4,
|
||||||
|
lng: 12.7,
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
source: 'Open-Meteo',
|
||||||
|
fetched_at: '2026-05-30T08:00:00Z',
|
||||||
|
horizon_days: 16,
|
||||||
|
day: null,
|
||||||
|
hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })),
|
||||||
|
} as WindForecastResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lakeStyleAdvice', () => {
|
||||||
|
it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => {
|
||||||
|
expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull();
|
||||||
|
expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({
|
||||||
|
meanKmh: 21,
|
||||||
|
gustsKmh: 26,
|
||||||
|
});
|
||||||
|
expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 });
|
||||||
|
expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull();
|
||||||
|
// An hour without a value is no calm hour, but it is no wind either.
|
||||||
|
expect(windForShore(forecast([{ hour: 12 }]))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => {
|
||||||
|
const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null };
|
||||||
|
const leg = (kind: 'lake' | 'water') => ({
|
||||||
|
status: 'ok' as const,
|
||||||
|
geometry: [
|
||||||
|
[53.4, 12.7],
|
||||||
|
[53.5, 12.7],
|
||||||
|
] as [number, number][],
|
||||||
|
segments: [{ ...lake, kind }],
|
||||||
|
distance_m: 1000,
|
||||||
|
snapped_m: { from: 0, to: 0 },
|
||||||
|
});
|
||||||
|
expect(crossesLake(null)).toBe(false);
|
||||||
|
expect(crossesLake({ legs: [leg('water'), null] })).toBe(false);
|
||||||
|
expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true);
|
||||||
|
expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { WindForecastResponse } from '@trek/shared';
|
||||||
|
import type { PaddleTourRoute } from '../planner/paddleRouting';
|
||||||
|
|
||||||
|
/** The hours a day tour is out on the water, as the wind strip shows them. */
|
||||||
|
const FIRST_HOUR = 6;
|
||||||
|
const LAST_HOUR = 21;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wind from which open water gets rough for a small boat: a moderate breeze
|
||||||
|
* (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into
|
||||||
|
* work and a capsize far from the bank into a long swim.
|
||||||
|
*/
|
||||||
|
const MEAN_KMH = 20;
|
||||||
|
const GUSTS_KMH = 30;
|
||||||
|
|
||||||
|
/** Whether the route as planned crosses a lake, so its lake style changes anything. */
|
||||||
|
export function crossesLake(route: Pick<PaddleTourRoute, 'legs'> | null): boolean {
|
||||||
|
return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11 with PAD-08: the strongest wind of the paddling hours when it is
|
||||||
|
* enough to keep near the shores on the lakes, else null. Lakes are crossed
|
||||||
|
* straight over as a rule and along the shore only in wind; this says when.
|
||||||
|
*/
|
||||||
|
export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null {
|
||||||
|
const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
|
||||||
|
const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0));
|
||||||
|
const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0));
|
||||||
|
if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null;
|
||||||
|
return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) };
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
|
|||||||
access: '#4338ca',
|
access: '#4338ca',
|
||||||
landing: '#0f766e',
|
landing: '#0f766e',
|
||||||
portage: '#9a6233',
|
portage: '#9a6233',
|
||||||
|
rapids: '#d97706',
|
||||||
|
gap: '#dc2626',
|
||||||
disc: '#ffffff',
|
disc: '#ffffff',
|
||||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PaddleGeometry } from '@trek/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spot a waypoint goes to: the place itself, the middle of a way (a
|
||||||
|
* slipway drawn as a ramp), or the middle of an area's outline.
|
||||||
|
*/
|
||||||
|
export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null {
|
||||||
|
const line =
|
||||||
|
geometry.type === 'Point'
|
||||||
|
? [geometry.coordinates]
|
||||||
|
: geometry.type === 'LineString'
|
||||||
|
? geometry.coordinates
|
||||||
|
: geometry.coordinates[0];
|
||||||
|
if (!line?.length) return null;
|
||||||
|
if (geometry.type === 'Polygon') {
|
||||||
|
const ring = line.slice(0, -1);
|
||||||
|
const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
|
||||||
|
const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length;
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? [];
|
||||||
|
return lat === undefined || lng === undefined ? null : { lat, lng };
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ const PADDLE_PAINT_TOKENS = {
|
|||||||
access: '--map-paddle-access',
|
access: '--map-paddle-access',
|
||||||
landing: '--map-paddle-landing',
|
landing: '--map-paddle-landing',
|
||||||
portage: '--map-portage',
|
portage: '--map-portage',
|
||||||
|
rapids: '--map-paddle-rapids',
|
||||||
|
gap: '--map-paddle-gap',
|
||||||
disc: '--map-paddle-disc',
|
disc: '--map-paddle-disc',
|
||||||
shadow: '--map-paddle-shadow',
|
shadow: '--map-paddle-shadow',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||||
|
import type { useTranslation } from '../../../i18n';
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||||
|
|
||||||
|
/** The states in which the data is on its way: the leg waits rather than fails. */
|
||||||
|
export function isRegionLoadPending(
|
||||||
|
state: PaddleRegionLoadState | undefined
|
||||||
|
): state is Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }> {
|
||||||
|
return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a leg stays unplanned once the data cannot come: declined, failed, no
|
||||||
|
* water near the place, nothing to load. Null where the leg's own reason says
|
||||||
|
* it best (loading is off, or nothing was asked yet).
|
||||||
|
*/
|
||||||
|
export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null {
|
||||||
|
if (!state) return null;
|
||||||
|
const region = state.region?.name ?? '';
|
||||||
|
switch (state.state) {
|
||||||
|
case 'declined':
|
||||||
|
return t('tours.paddle.load.declined', { region });
|
||||||
|
case 'failed':
|
||||||
|
return t('tours.paddle.load.failed', { region });
|
||||||
|
case 'no_water':
|
||||||
|
return t('tours.paddle.load.noWater', { region });
|
||||||
|
case 'unavailable':
|
||||||
|
if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract');
|
||||||
|
if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable');
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03).
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const valhallaRun = vi.fn();
|
||||||
|
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) }));
|
||||||
|
|
||||||
|
const { routePortageLine } = await import('./portageRoute');
|
||||||
|
|
||||||
|
const FROM = { lat: 53.2232, lng: 12.9668 };
|
||||||
|
const TO = { lat: 53.2229, lng: 12.9701 };
|
||||||
|
|
||||||
|
function answer(coordinates: [number, number][]) {
|
||||||
|
return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routePortageLine', () => {
|
||||||
|
beforeEach(() => valhallaRun.mockReset());
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => {
|
||||||
|
valhallaRun.mockResolvedValue(
|
||||||
|
answer([
|
||||||
|
[53.2233, 12.9672],
|
||||||
|
[53.2226, 12.9686],
|
||||||
|
[53.223, 12.9698],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const line = await routePortageLine(FROM, TO);
|
||||||
|
expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 });
|
||||||
|
expect(line?.[0]).toEqual([FROM.lat, FROM.lng]);
|
||||||
|
expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]);
|
||||||
|
expect(line).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => {
|
||||||
|
valhallaRun.mockResolvedValue(
|
||||||
|
answer([
|
||||||
|
[53.2233, 12.9672],
|
||||||
|
[53.24, 12.968],
|
||||||
|
[53.223, 12.9698],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => {
|
||||||
|
valhallaRun.mockResolvedValue(null);
|
||||||
|
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => {
|
||||||
|
valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]]));
|
||||||
|
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { haversineKm } from '../../../utils/geo';
|
||||||
|
import { valhallaRun } from '../../Map/valhallaRoute';
|
||||||
|
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||||
|
|
||||||
|
type Point = { lat: number; lng: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much longer than the straight line a carry along the ways may be before
|
||||||
|
* it counts as a detour: a way round a fenced lock is fine, a walk round the
|
||||||
|
* whole lake is not, and then the straight line says more.
|
||||||
|
*/
|
||||||
|
const DETOUR = { factor: 2.5, extraM: 300 } as const;
|
||||||
|
|
||||||
|
/** Paths a paddler carries a boat on: plain hiking ground, never a scramble. */
|
||||||
|
const CARRY_DIFFICULTY = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-03: the way a portage takes on land, from where the boat leaves the
|
||||||
|
* water to where it goes in again, on the ways a walker would take (the
|
||||||
|
* instance's Valhalla, the router the hikes use). The line starts and ends at
|
||||||
|
* the two points themselves, so the steps from the bank onto the path are
|
||||||
|
* part of it. Null when no router answers, or when the way is a detour.
|
||||||
|
*/
|
||||||
|
export async function routePortageLine(
|
||||||
|
from: Point,
|
||||||
|
to: Point,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<[number, number][] | null> {
|
||||||
|
// The router answers null, never throws, when it cannot be reached; the carry then stays straight.
|
||||||
|
const run = await valhallaRun([from, to], 'walking', [], signal, { max_hiking_difficulty: CARRY_DIFFICULTY });
|
||||||
|
if (!run || run.total.coordinates.length < 2) return null;
|
||||||
|
const line: [number, number][] = [[from.lat, from.lng], ...run.total.coordinates, [to.lat, to.lng]];
|
||||||
|
const straightM = haversineKm(from, to) * 1000;
|
||||||
|
if (lineLengthM(line) > Math.max(straightM * DETOUR.factor, straightM + DETOUR.extraM)) return null;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@ import {
|
|||||||
selectPaddleObstacle,
|
selectPaddleObstacle,
|
||||||
usePaddleObstacleStore,
|
usePaddleObstacleStore,
|
||||||
} from '../../../store/paddleObstacleStore';
|
} from '../../../store/paddleObstacleStore';
|
||||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||||
|
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||||
import { usePaddleChecklist } from './usePaddleChecklist';
|
import { usePaddleChecklist } from './usePaddleChecklist';
|
||||||
|
|
||||||
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
|
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
|
|||||||
access: '#4338ca',
|
access: '#4338ca',
|
||||||
landing: '#0f766e',
|
landing: '#0f766e',
|
||||||
portage: '#9a6233',
|
portage: '#9a6233',
|
||||||
|
rapids: '#d97706',
|
||||||
|
gap: '#dc2626',
|
||||||
disc: '#ffffff',
|
disc: '#ffffff',
|
||||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// FE-PADDLE-REGION-LOAD-001 to 005: the planner fetches the water data of a place no region covers (PAD-11C).
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const load = vi.fn();
|
||||||
|
vi.mock('../../../repo/paddleRegionLoadRepo', () => ({
|
||||||
|
paddleRegionLoadRepo: { load: (...args: unknown[]) => load(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { usePaddleRegionLoad } = await import('./usePaddleRegionLoad');
|
||||||
|
|
||||||
|
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||||
|
const AT = { lat: 49.0123456, lng: 12.0987654 };
|
||||||
|
const failures = [
|
||||||
|
{ index: 0, code: 'no_region' as const, at: AT },
|
||||||
|
{ index: 1, code: 'no_region' as const, at: AT },
|
||||||
|
{ index: 2, code: 'no_water_path' as const, at: null },
|
||||||
|
];
|
||||||
|
const loading = (fraction: number): PaddleRegionLoadState => ({
|
||||||
|
state: 'loading',
|
||||||
|
region: REF,
|
||||||
|
progress: { phase: 'download', fraction },
|
||||||
|
ahead: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('usePaddleRegionLoad', () => {
|
||||||
|
beforeEach(() => load.mockReset());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-001: asks once per place, and shows its state on every leg ending there', async () => {
|
||||||
|
load.mockResolvedValue([loading(0.4)]);
|
||||||
|
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||||
|
await waitFor(() => expect(result.current.byLeg.get(0)).toEqual(loading(0.4)));
|
||||||
|
expect(load).toHaveBeenCalledWith([{ lat: 49.01235, lng: 12.09877 }], false, expect.any(AbortSignal));
|
||||||
|
expect(result.current.byLeg.get(1)).toEqual(loading(0.4));
|
||||||
|
expect(result.current.byLeg.has(2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-002: follows a load and plans again once, when the data is there', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
load.mockResolvedValueOnce([loading(0.2)]).mockResolvedValue([{ state: 'ready', region: REF }]);
|
||||||
|
const onReady = vi.fn();
|
||||||
|
const { rerender } = renderHook(() => usePaddleRegionLoad(failures, onReady));
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
});
|
||||||
|
expect(onReady).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
});
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onReady).toHaveBeenCalledTimes(1);
|
||||||
|
rerender();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
});
|
||||||
|
expect(onReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-003: asks for nothing without a leg that lacks the data', async () => {
|
||||||
|
renderHook(() => usePaddleRegionLoad([{ index: 0, code: 'not_on_water', at: AT }], vi.fn()));
|
||||||
|
renderHook(() => usePaddleRegionLoad(null, vi.fn()));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(load).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-004: reads a failed region again on request', async () => {
|
||||||
|
load.mockResolvedValue([{ state: 'failed', region: REF }]);
|
||||||
|
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||||
|
await waitFor(() => expect(result.current.byLeg.get(0)?.state).toBe('failed'));
|
||||||
|
act(() => result.current.retry());
|
||||||
|
await waitFor(() => expect(load).toHaveBeenLastCalledWith(expect.any(Array), true, expect.any(AbortSignal)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-REGION-LOAD-005: keeps asking after a failed look, more slowly', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
load.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([{ state: 'declined', region: REF }]);
|
||||||
|
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
});
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.current.byLeg.get(0)?.state).toBe('declined');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { MAX_PADDLE_LOAD_POINTS, type PaddleRegionLoadState, type PaddleRoutePoint } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||||
|
import { paddleRegionLoadRepo } from '../../../repo/paddleRegionLoadRepo';
|
||||||
|
import type { PaddleTourRoute } from '../planner/paddleRouting';
|
||||||
|
|
||||||
|
/** How soon the planner asks again: often while a region loads, now and then while it waits for an admin. */
|
||||||
|
const POLL_MS = { loading: 2_000, requested: 30_000, error: 10_000 } as const;
|
||||||
|
|
||||||
|
type Failure = PaddleTourRoute['failures'][number];
|
||||||
|
|
||||||
|
/** A place as the key it is asked and remembered by; five decimals are about a metre. */
|
||||||
|
function keyOf(point: PaddleRoutePoint): string {
|
||||||
|
return `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeOf(key: string): PaddleRoutePoint {
|
||||||
|
const [lat, lng] = key.split(',').map(Number);
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The places the planner asks about: where its legs ran out of water data, each once. */
|
||||||
|
function placeKeysOf(failures: readonly Failure[] | null | undefined): string {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const failure of failures ?? []) {
|
||||||
|
if (failure.code === 'no_region' && failure.at) keys.add(keyOf(failure.at));
|
||||||
|
}
|
||||||
|
return [...keys].slice(0, MAX_PADDLE_LOAD_POINTS).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPoll(states: readonly PaddleRegionLoadState[]): number | null {
|
||||||
|
if (states.some((state) => state.state === 'loading')) return POLL_MS.loading;
|
||||||
|
if (states.some((state) => state.state === 'requested')) return POLL_MS.requested;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11C: where a paddle leg failed for want of water data, the planner
|
||||||
|
* asks the server for it right away; the server loads the region around the
|
||||||
|
* place or asks an admin for it, as the instance is set up. While it loads
|
||||||
|
* the leg shows how far it is, and once it is there the route is planned
|
||||||
|
* again on its own. `onReady` plans again; it is called once per place.
|
||||||
|
*/
|
||||||
|
export function usePaddleRegionLoad(failures: readonly Failure[] | null | undefined, onReady: () => void) {
|
||||||
|
const placesKey = placeKeysOf(failures);
|
||||||
|
const places = useMemo(() => (placesKey ? placesKey.split('|').map(placeOf) : []), [placesKey]);
|
||||||
|
const [states, setStates] = useState<ReadonlyMap<string, PaddleRegionLoadState>>(new Map());
|
||||||
|
const [retryToken, setRetryToken] = useState(0);
|
||||||
|
const readied = useRef(new Set<string>());
|
||||||
|
const retrying = useRef(false);
|
||||||
|
|
||||||
|
/** The answers taken in; the route is planned again where a place's data has just arrived. */
|
||||||
|
const take = useEffectEvent((answers: readonly PaddleRegionLoadState[]) => {
|
||||||
|
setStates(new Map(places.flatMap((place, index) => (answers[index] ? [[keyOf(place), answers[index]]] : []))));
|
||||||
|
const fresh = places.filter((place, index) => {
|
||||||
|
const key = keyOf(place);
|
||||||
|
if (answers[index]?.state !== 'ready' || readied.current.has(key)) return false;
|
||||||
|
readied.current.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (fresh.length > 0) onReady();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (places.length === 0) return undefined;
|
||||||
|
const abort = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const ask = async () => {
|
||||||
|
const retry = retrying.current;
|
||||||
|
retrying.current = false;
|
||||||
|
let answers: PaddleRegionLoadState[] | null;
|
||||||
|
try {
|
||||||
|
answers = await paddleRegionLoadRepo.load(places, retry, abort.signal);
|
||||||
|
} catch {
|
||||||
|
// The failure row keeps saying why the leg is unplanned; a later look may get through.
|
||||||
|
if (!abort.signal.aborted) timer = setTimeout(() => void ask(), POLL_MS.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (abort.signal.aborted || !answers) return;
|
||||||
|
take(answers);
|
||||||
|
const wait = nextPoll(answers);
|
||||||
|
if (wait !== null) timer = setTimeout(() => void ask(), wait);
|
||||||
|
};
|
||||||
|
void ask();
|
||||||
|
return () => {
|
||||||
|
abort.abort();
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [places, retryToken]);
|
||||||
|
|
||||||
|
/** Reads a region again whose last read failed. */
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
retrying.current = true;
|
||||||
|
setRetryToken((token) => token + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** The state of each failed leg's place, by the leg's index. */
|
||||||
|
const byLeg = useMemo(() => {
|
||||||
|
const result = new Map<number, PaddleRegionLoadState>();
|
||||||
|
for (const failure of failures ?? []) {
|
||||||
|
if (failure.code !== 'no_region' || !failure.at) continue;
|
||||||
|
const state = states.get(keyOf(failure.at));
|
||||||
|
if (state) result.set(failure.index, state);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [failures, states]);
|
||||||
|
|
||||||
|
return { byLeg, retry };
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// FE-PORTAGE-ROUTING-001 to 005: portages set in the planner follow the ways on land (PAD-03).
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||||
|
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||||
|
|
||||||
|
const routePortageLine = vi.fn();
|
||||||
|
vi.mock('./portageRoute', () => ({ routePortageLine: (...args: unknown[]) => routePortageLine(...args) }));
|
||||||
|
|
||||||
|
const { usePortageRouting } = await import('./usePortageRouting');
|
||||||
|
|
||||||
|
const A: TourPlannerWaypoint = { id: 'a', lat: 53.2232, lng: 12.9668, role: 'start' };
|
||||||
|
const B: TourPlannerWaypoint = { id: 'b', lat: 53.2229, lng: 12.9701, role: 'end' };
|
||||||
|
const WAY: [number, number][] = [
|
||||||
|
[A.lat, A.lng],
|
||||||
|
[53.2226, 12.9686],
|
||||||
|
[B.lat, B.lng],
|
||||||
|
];
|
||||||
|
|
||||||
|
function plannerWith(waypoints: TourPlannerWaypoint[], hasUnsavedChanges: boolean) {
|
||||||
|
const planner = {
|
||||||
|
waypoints,
|
||||||
|
isPaddle: true,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
amendWaypoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||||
|
planner.waypoints = change(planner.waypoints);
|
||||||
|
}),
|
||||||
|
editPaddlePoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||||
|
planner.waypoints = change(planner.waypoints);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return planner;
|
||||||
|
}
|
||||||
|
|
||||||
|
const carried = (): TourPlannerWaypoint[] => [{ ...A, fixedLeg: portageLegBetween(A, B) }, B];
|
||||||
|
|
||||||
|
describe('usePortageRouting', () => {
|
||||||
|
beforeEach(() => routePortageLine.mockReset());
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTING-001: lays a portage the paddler just set on the way the router found', async () => {
|
||||||
|
routePortageLine.mockResolvedValue(WAY);
|
||||||
|
const planner = plannerWith(carried(), true);
|
||||||
|
renderHook(() => usePortageRouting(planner, true));
|
||||||
|
await waitFor(() => expect(planner.amendWaypoints).toHaveBeenCalled());
|
||||||
|
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTING-002: leaves a tour as saved alone, and asks nobody while it cannot be edited', async () => {
|
||||||
|
const saved = plannerWith(carried(), false);
|
||||||
|
renderHook(() => usePortageRouting(saved, true));
|
||||||
|
const locked = plannerWith(carried(), true);
|
||||||
|
renderHook(() => usePortageRouting(locked, false));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(routePortageLine).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTING-003: keeps a carry straight when the router found no way, and says so', async () => {
|
||||||
|
routePortageLine.mockResolvedValue(null);
|
||||||
|
const planner = plannerWith(carried(), true);
|
||||||
|
const { result } = renderHook(() => usePortageRouting(planner, true));
|
||||||
|
await waitFor(() => expect(routePortageLine).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(result.current.noWay(['a'])).toBe(true));
|
||||||
|
expect(planner.waypoints[0].fixedLeg?.geometry).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTING-004: lays a saved straight portage on the way when asked to', async () => {
|
||||||
|
routePortageLine.mockResolvedValue(WAY);
|
||||||
|
const planner = plannerWith(carried(), false);
|
||||||
|
const { result } = renderHook(() => usePortageRouting(planner, true));
|
||||||
|
let found = false;
|
||||||
|
await act(async () => {
|
||||||
|
found = await result.current.follow(['a']);
|
||||||
|
});
|
||||||
|
expect(found).toBe(true);
|
||||||
|
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PORTAGE-ROUTING-005: draws a portage straight on request and keeps it so', async () => {
|
||||||
|
const planner = plannerWith([{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry: WAY } }, B], true);
|
||||||
|
const { result, rerender } = renderHook(() => usePortageRouting(planner, true));
|
||||||
|
act(() => result.current.straighten(['a']));
|
||||||
|
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual([
|
||||||
|
[A.lat, A.lng],
|
||||||
|
[B.lat, B.lng],
|
||||||
|
]);
|
||||||
|
rerender();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(routePortageLine).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { TourFixedLeg } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||||
|
import type { PortageWay } from './PaddleWaypointParts';
|
||||||
|
import { routePortageLine } from './portageRoute';
|
||||||
|
|
||||||
|
type Planner = Pick<
|
||||||
|
TourPlannerController,
|
||||||
|
'waypoints' | 'isPaddle' | 'hasUnsavedChanges' | 'amendWaypoints' | 'editPaddlePoints'
|
||||||
|
>;
|
||||||
|
type Point = TourPlannerController['waypoints'][number];
|
||||||
|
type Line = [number, number][];
|
||||||
|
|
||||||
|
/** A portage leg by its two ends, to six decimals (about ten centimetres): the same carry asks once. */
|
||||||
|
function keyOf(from: Point, to: Point): string {
|
||||||
|
return `${from.lat.toFixed(6)},${from.lng.toFixed(6)}>${to.lat.toFixed(6)},${to.lng.toFixed(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStraightPortage(leg: TourFixedLeg | null | undefined): boolean {
|
||||||
|
return leg?.kind === 'portage' && leg.geometry.length === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The portages still drawn straight, with the point after each. */
|
||||||
|
function straightPortages(points: readonly Point[]) {
|
||||||
|
return points.flatMap((point, index) => {
|
||||||
|
const next = points[index + 1];
|
||||||
|
// A portage the water router found is its own: it already follows the mapped way.
|
||||||
|
return next && !point.auto && isStraightPortage(point.fixedLeg)
|
||||||
|
? [{ id: point.id, from: point, to: next, key: keyOf(point, next) }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-03: a portage follows the ways on land instead of cutting straight
|
||||||
|
* across them. A portage the paddler sets, or whose ends they move, is asked
|
||||||
|
* of the router the hikes use and takes its line, unless that line is a
|
||||||
|
* detour; then it stays straight. A tour opened as saved keeps its straight
|
||||||
|
* portages until the paddler asks for the way (`follow`), and one they set
|
||||||
|
* straight on purpose (`straighten`) stays so.
|
||||||
|
*/
|
||||||
|
export function usePortageRouting(planner: Planner, editable: boolean) {
|
||||||
|
const { waypoints, isPaddle, hasUnsavedChanges, amendWaypoints, editPaddlePoints } = planner;
|
||||||
|
/** Carries kept straight: as the tour was saved, or as the paddler set them. */
|
||||||
|
const kept = useRef(new Set<string>());
|
||||||
|
/** What the router answered per carry; null when it found no way worth taking. */
|
||||||
|
const lines = useRef(new Map<string, Line | null>());
|
||||||
|
|
||||||
|
const layIn = useCallback(() => {
|
||||||
|
amendWaypoints((current) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = current.map((point, index) => {
|
||||||
|
const after = current[index + 1];
|
||||||
|
const leg = point.fixedLeg;
|
||||||
|
if (!after || point.auto || !leg || leg.kind !== 'portage' || leg.geometry.length !== 2) return point;
|
||||||
|
const key = keyOf(point, after);
|
||||||
|
const line = kept.current.has(key) ? null : lines.current.get(key);
|
||||||
|
if (!line) return point;
|
||||||
|
changed = true;
|
||||||
|
return { ...point, fixedLeg: { ...leg, geometry: line } };
|
||||||
|
});
|
||||||
|
return changed ? next : current;
|
||||||
|
});
|
||||||
|
}, [amendWaypoints]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPaddle || !editable) return undefined;
|
||||||
|
const legs = straightPortages(waypoints);
|
||||||
|
if (!hasUnsavedChanges) {
|
||||||
|
for (const leg of legs) kept.current.add(leg.key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const wanted = legs.filter((leg) => !kept.current.has(leg.key));
|
||||||
|
if (wanted.some((leg) => lines.current.get(leg.key))) layIn();
|
||||||
|
const missing = wanted.filter((leg) => !lines.current.has(leg.key));
|
||||||
|
if (missing.length === 0) return undefined;
|
||||||
|
const abort = new AbortController();
|
||||||
|
// One after the other: the public router allows about one request a second.
|
||||||
|
void missing
|
||||||
|
.reduce(async (previous, leg) => {
|
||||||
|
await previous;
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
const line = await routePortageLine(leg.from, leg.to, abort.signal);
|
||||||
|
if (!abort.signal.aborted) lines.current.set(leg.key, line);
|
||||||
|
}, Promise.resolve())
|
||||||
|
.then(() => {
|
||||||
|
if (!abort.signal.aborted) layIn();
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [editable, hasUnsavedChanges, isPaddle, layIn, waypoints]);
|
||||||
|
|
||||||
|
/** The carries starting at these points, each with the point after it. */
|
||||||
|
const legsOf = useCallback(
|
||||||
|
(ids: readonly string[]) =>
|
||||||
|
waypoints.flatMap((point, index) => {
|
||||||
|
const next = waypoints[index + 1];
|
||||||
|
return ids.includes(point.id) && next ? [{ point, next, key: keyOf(point, next) }] : [];
|
||||||
|
}),
|
||||||
|
[waypoints]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A portage laid on the ways, after it was straight (as saved, or set so),
|
||||||
|
* leg by leg: true once one follows them, false when there is no way worth
|
||||||
|
* taking and it stays straight.
|
||||||
|
*/
|
||||||
|
const follow = useCallback(
|
||||||
|
async (ids: readonly string[]) => {
|
||||||
|
const legs = legsOf(ids);
|
||||||
|
const found = await legs.reduce(async (previous, { point, next, key }) => {
|
||||||
|
const any = await previous;
|
||||||
|
kept.current.delete(key);
|
||||||
|
const line = await routePortageLine(point, next);
|
||||||
|
lines.current.set(key, line);
|
||||||
|
return any || !!line;
|
||||||
|
}, Promise.resolve(false));
|
||||||
|
if (found) layIn();
|
||||||
|
return found;
|
||||||
|
},
|
||||||
|
[layIn, legsOf]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A portage drawn straight, and kept so. */
|
||||||
|
const straighten = useCallback(
|
||||||
|
(ids: readonly string[]) => {
|
||||||
|
for (const { key } of legsOf(ids)) kept.current.add(key);
|
||||||
|
editPaddlePoints((current) =>
|
||||||
|
current.map((point, index) => {
|
||||||
|
const next = current[index + 1];
|
||||||
|
const leg = point.fixedLeg;
|
||||||
|
if (!ids.includes(point.id) || !next || leg?.kind !== 'portage') return point;
|
||||||
|
return {
|
||||||
|
...point,
|
||||||
|
fixedLeg: {
|
||||||
|
...leg,
|
||||||
|
geometry: [
|
||||||
|
[point.lat, point.lng],
|
||||||
|
[next.lat, next.lng],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[editPaddlePoints, legsOf]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Whether the router found no way worth taking for every leg of a portage. */
|
||||||
|
const noWay = useCallback(
|
||||||
|
(ids: readonly string[]) => {
|
||||||
|
const legs = legsOf(ids);
|
||||||
|
return legs.length > 0 && legs.every(({ key }) => lines.current.get(key) === null);
|
||||||
|
},
|
||||||
|
[legsOf]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { follow, straighten, noWay };
|
||||||
|
}
|
||||||
|
|
||||||
|
type PortageRouting = ReturnType<typeof usePortageRouting>;
|
||||||
|
|
||||||
|
/** The waypoints whose legs make up the portage starting at `index`: one leg, or several in a row. */
|
||||||
|
function portageRunIds(points: readonly Point[], index: number): string[] {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let at = index; at < points.length - 1 && points[at]?.fixedLeg?.kind === 'portage'; at += 1) {
|
||||||
|
ids.push(points[at].id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-03: the way of the portage starting at a waypoint, for its card:
|
||||||
|
* whether it follows the ways on land, why it stayed straight, and the switch
|
||||||
|
* between the two. Undefined where no portage starts.
|
||||||
|
*/
|
||||||
|
export function usePortageWay(
|
||||||
|
points: readonly Point[],
|
||||||
|
routing: PortageRouting,
|
||||||
|
index: number
|
||||||
|
): PortageWay | undefined {
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const ids = portageRunIds(points, index);
|
||||||
|
if (ids.length === 0) return undefined;
|
||||||
|
return {
|
||||||
|
routed: ids.every((id) => (points.find((point) => point.id === id)?.fixedLeg?.geometry.length ?? 0) > 2),
|
||||||
|
noWay: routing.noWay(ids),
|
||||||
|
searching,
|
||||||
|
onFollow: () => {
|
||||||
|
setSearching(true);
|
||||||
|
void routing.follow(ids).finally(() => setSearching(false));
|
||||||
|
},
|
||||||
|
onStraighten: () => routing.straighten(ids),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -926,6 +926,24 @@ describe('the paddle checklist in the rails (PAD-06)', () => {
|
|||||||
expect(safetyRow()).toBeInTheDocument()
|
expect(safetyRow()).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('PCR-006: legs the water router could not plan are counted in the head, without the retry the rows already offer', () => {
|
||||||
|
const failed = paddleEditor({
|
||||||
|
status: 'routing-failed',
|
||||||
|
error: 'paddle-route',
|
||||||
|
paddleRoute: { coordinates: [], parts: [], distanceMeters: 0, legs: [], legLengths: [], failures: [
|
||||||
|
{ index: 0, code: 'not_on_water', at: null },
|
||||||
|
{ index: 1, code: 'busy', at: null },
|
||||||
|
] },
|
||||||
|
})
|
||||||
|
const { rerender } = render(<TourPlannerRail planner={failed} />)
|
||||||
|
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('2 legs not planned')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'routing-failed', error: 'routing' })} />)
|
||||||
|
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('Route calculation failed. The last good route is still shown.')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
|
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
|
||||||
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
|
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../
|
|||||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
|
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
|
||||||
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
||||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||||
|
import { crossesLake } from '../paddle/lakeStyleAdvice'
|
||||||
import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
|
import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
|
||||||
import { PaddleSafetyRow } from '../paddle/PaddleSafety'
|
import { PaddleSafetyRow } from '../paddle/PaddleSafety'
|
||||||
import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
|
import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
|
||||||
@@ -121,6 +122,18 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
|
||||||
|
function retryable(planner: TourPlannerController): boolean {
|
||||||
|
if (planner.error === 'paddle-route') return false
|
||||||
|
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */
|
||||||
|
function statusText(planner: TourPlannerController, key: string, t: Translate): string {
|
||||||
|
if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 })
|
||||||
|
return t(key)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the editor is doing right now, as icon badges beside the head's title:
|
* What the editor is doing right now, as icon badges beside the head's title:
|
||||||
* a restored draft, routing or saving, and a failure with its retry. Nothing
|
* a restored draft, routing or saving, and a failure with its retry. Nothing
|
||||||
@@ -132,10 +145,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
|||||||
const restored = planner.draftRestored && planner.hasUnsavedChanges
|
const restored = planner.draftRestored && planner.hasUnsavedChanges
|
||||||
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
|
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
|
||||||
const failed = planner.error === 'save'
|
const failed = planner.error === 'save'
|
||||||
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
const canRetry = retryable(planner)
|
||||||
if (!restored && !statusKey && !failed && !onFerrata) return null
|
if (!restored && !statusKey && !failed && !onFerrata) return null
|
||||||
const warning = planner.error != null || canRetry
|
const warning = planner.error != null || canRetry
|
||||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
|
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? statusText(planner, statusKey, t) : null
|
||||||
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
|
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
|
||||||
return (
|
return (
|
||||||
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
|
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
|
||||||
@@ -326,7 +339,11 @@ function PaddleEditorCards({ planner, locked, checklist }: {
|
|||||||
<>
|
<>
|
||||||
{checklist && <PaddleCheckCard checklist={checklist} />}
|
{checklist && <PaddleCheckCard checklist={checklist} />}
|
||||||
<PaddleTimeCard planner={planner} locked={locked} />
|
<PaddleTimeCard planner={planner} locked={locked} />
|
||||||
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
|
<PaddleWindSection
|
||||||
|
placeId={planner.editingPlaceId}
|
||||||
|
start={planner.waypoints[0] ?? null}
|
||||||
|
lakes={!locked && crossesLake(planner.paddleRoute) ? { value: planner.paddle.lakeStyle, onChoose: planner.paddle.setLakeStyle } : undefined}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,3 +75,33 @@ describe('TourTypeFields (PAD-01)', () => {
|
|||||||
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
|
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TourTypeFields lake style (PAD-11)', () => {
|
||||||
|
it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => {
|
||||||
|
const river = planner({}, { waterContext: 'river' });
|
||||||
|
const { rerender } = render(<TourTypeFields planner={river} locked={false} />);
|
||||||
|
expect(screen.queryByTestId('tour-planner-lake-style')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const lake = planner({}, { waterContext: 'lake' });
|
||||||
|
rerender(<TourTypeFields planner={lake} locked={false} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Straight over' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByText('The shortest line across open water.')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Near the shore' }));
|
||||||
|
expect(lake.paddle.setLakeStyle).toHaveBeenCalledWith('shore');
|
||||||
|
|
||||||
|
const lakeLeg = {
|
||||||
|
status: 'ok',
|
||||||
|
geometry: [
|
||||||
|
[53.4, 12.7],
|
||||||
|
[53.5, 12.7],
|
||||||
|
],
|
||||||
|
segments: [{ kind: 'lake', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
|
||||||
|
distance_m: 11000,
|
||||||
|
snapped_m: { from: 0, to: 0 },
|
||||||
|
};
|
||||||
|
const crossing = planner({ paddleRoute: { legs: [lakeLeg] } }, { waterContext: 'river', lakeStyle: 'shore' });
|
||||||
|
rerender(<TourTypeFields planner={crossing} locked={false} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Near the shore' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByText('Stays near the bank, for windy days: longer, but land is never far.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useTranslation } from '../../../i18n';
|
|||||||
import { SoftPill } from '../../Planner/planParts';
|
import { SoftPill } from '../../Planner/planParts';
|
||||||
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
|
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
|
||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
|
import { crossesLake } from '../paddle/lakeStyleAdvice';
|
||||||
|
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
|
||||||
import { TourNotice } from '../tourParts';
|
import { TourNotice } from '../tourParts';
|
||||||
import type { TourPlannerController } from './useTourPlanner';
|
import type { TourPlannerController } from './useTourPlanner';
|
||||||
|
|
||||||
@@ -126,6 +128,9 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
|
|||||||
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
|
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{(paddle.waterContext === 'lake' || crossesLake(planner.paddleRoute)) && (
|
||||||
|
<PaddleLakeStyleField value={paddle.lakeStyle} onChange={paddle.setLakeStyle} />
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5">
|
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5">
|
||||||
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed">
|
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed">
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { PaddleRouteLeg } from '@trek/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||||
|
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||||
|
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||||
|
import { useSettingsStore } from '../../../store/settingsStore';
|
||||||
|
import type { PaddleTourRoute } from './paddleRouting';
|
||||||
|
import TourWaypointList from './TourWaypointList';
|
||||||
|
import type { TourPlannerController } from './useTourPlanner';
|
||||||
|
|
||||||
|
/** The item at `i`, or a failure that names the index instead of a later undefined. */
|
||||||
|
function at<T>(list: readonly T[], i: number): T {
|
||||||
|
const item = list[i];
|
||||||
|
if (item === undefined) throw new Error(`nothing at ${i}`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
|
||||||
|
|
||||||
|
const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const };
|
||||||
|
const SHAPE = { id: 's', lat: 49.08, lng: 12.11, role: 'via' as const, shaping: true };
|
||||||
|
const STOP = { id: 'b', lat: 49.05, lng: 12.12, role: 'via' as const };
|
||||||
|
const END = { id: 'c', lat: 49.0, lng: 12.1, role: 'end' as const };
|
||||||
|
|
||||||
|
const segment = (kind: 'water' | 'rapids' | 'pass' | 'gap', from: number, to: number, grade: number | null = null) => ({
|
||||||
|
kind,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
name: null,
|
||||||
|
source: null,
|
||||||
|
osm_ref: null,
|
||||||
|
grade,
|
||||||
|
});
|
||||||
|
|
||||||
|
const WHITEWATER: PaddleRouteLeg = {
|
||||||
|
status: 'ok',
|
||||||
|
geometry: [
|
||||||
|
[49.05, 12.12],
|
||||||
|
[49.04, 12.12],
|
||||||
|
[49.03, 12.115],
|
||||||
|
[49.0, 12.1],
|
||||||
|
],
|
||||||
|
segments: [segment('water', 0, 1), segment('rapids', 1, 2, 2), segment('gap', 2, 3)],
|
||||||
|
distance_m: 5800,
|
||||||
|
snapped_m: { from: 0, to: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||||
|
return {
|
||||||
|
...hikePlannerDefaults(),
|
||||||
|
waypoints: [START, SHAPE, STOP, END],
|
||||||
|
selectedWaypointId: null,
|
||||||
|
setSelectedWaypointId: vi.fn(),
|
||||||
|
reorderWaypoint: vi.fn(),
|
||||||
|
moveWaypoint: vi.fn(),
|
||||||
|
removeWaypoint: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as TourPlannerController;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paddleRoute(legs: PaddleTourRoute['legs'], failures: PaddleTourRoute['failures'] = []): PaddleTourRoute {
|
||||||
|
return { coordinates: [], parts: [], distanceMeters: 0, legs, legLengths: legs.map(() => 0), failures };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAllStores();
|
||||||
|
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TourWaypointList', () => {
|
||||||
|
it('FE-TOUR-WAYPOINT-LIST-001: numbers the stops only; a shaping point is a dot and does not count', () => {
|
||||||
|
render(<TourWaypointList planner={planner()} canEdit isSaving={false} />);
|
||||||
|
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||||
|
expect(rows).toHaveLength(4);
|
||||||
|
expect(within(at(rows, 1)).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true');
|
||||||
|
expect(
|
||||||
|
[...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number'))
|
||||||
|
).toEqual(['1', '2', '3']);
|
||||||
|
expect(screen.getByText('Click the route on the map to add a shaping point there.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => {
|
||||||
|
const controller = planner({ selectedWaypointId: 'b' });
|
||||||
|
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||||
|
const row = at(screen.getAllByTestId('tour-planner-waypoint-row'), 2);
|
||||||
|
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' }));
|
||||||
|
expect(controller.recomputeLeg).toHaveBeenCalledWith('b');
|
||||||
|
|
||||||
|
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Make it a shaping point' }));
|
||||||
|
expect(controller.setWaypointShaping).toHaveBeenCalledWith('b', true);
|
||||||
|
|
||||||
|
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Draw the next leg by hand' }));
|
||||||
|
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINT-LIST-003: a shaping point becomes a stop again; the first point offers no shaping', async () => {
|
||||||
|
const controller = planner({ selectedWaypointId: 's' });
|
||||||
|
const { rerender } = render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||||
|
await userEvent.click(
|
||||||
|
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 1)).getByRole('button', { name: 'More actions' })
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' }));
|
||||||
|
expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false);
|
||||||
|
|
||||||
|
rerender(<TourWaypointList planner={planner({ selectedWaypointId: 'a' })} canEdit isSaving={false} />);
|
||||||
|
await userEvent.click(
|
||||||
|
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 0)).getByRole('button', { name: 'More actions' })
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINT-LIST-004: a paddle leg shows its whitewater and its data gap with their lengths', () => {
|
||||||
|
const route = paddleRoute([null, null, WHITEWATER]);
|
||||||
|
render(
|
||||||
|
<TourWaypointList
|
||||||
|
planner={planner({ ...{ isPaddle: true, tourType: 'kayak' }, paddleRoute: route, routeCurrent: true })}
|
||||||
|
canEdit
|
||||||
|
isSaving={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const notes = screen.getByTestId('tour-planner-leg-notes');
|
||||||
|
expect(within(notes).getByText('Whitewater 2')).toBeInTheDocument();
|
||||||
|
expect(within(notes).getByText('Gap in the map data')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINT-LIST-005: a leg the router could not plan says why and offers the way out', async () => {
|
||||||
|
const route = paddleRoute(
|
||||||
|
[{ status: 'failed', code: 'busy', at: null }, null, null],
|
||||||
|
[{ index: 0, code: 'busy', at: null }]
|
||||||
|
);
|
||||||
|
const controller = planner({ isPaddle: true, tourType: 'kayak', paddleRoute: route, routeCurrent: true });
|
||||||
|
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||||
|
const failure = screen.getByTestId('tour-planner-leg-failure');
|
||||||
|
expect(within(failure).getByRole('alert')).toHaveTextContent('The route planner is busy. Try again in a moment.');
|
||||||
|
await userEvent.click(within(failure).getByRole('button', { name: 'Try again' }));
|
||||||
|
expect(controller.recomputeLeg).toHaveBeenCalledWith('a');
|
||||||
|
await userEvent.click(within(failure).getByRole('button', { name: 'Draw straight' }));
|
||||||
|
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINT-LIST-006: a point off the water offers no retry; legs drawn by hand can go back to the water in one step', async () => {
|
||||||
|
const drawn = {
|
||||||
|
kind: 'free' as const,
|
||||||
|
geometry: [
|
||||||
|
[49.1, 12.1],
|
||||||
|
[49.08, 12.11],
|
||||||
|
] as [number, number][],
|
||||||
|
};
|
||||||
|
const route = paddleRoute(
|
||||||
|
[null, { status: 'failed', code: 'not_on_water', at: null }, null],
|
||||||
|
[{ index: 1, code: 'not_on_water', at: null }]
|
||||||
|
);
|
||||||
|
const auto = { from: { id: 'a', lat: 49.1, lng: 12.1 }, to: { id: 'c', lat: 49.0, lng: 12.1 } };
|
||||||
|
const controller = planner({
|
||||||
|
isPaddle: true,
|
||||||
|
tourType: 'kayak',
|
||||||
|
paddleRoute: route,
|
||||||
|
routeCurrent: true,
|
||||||
|
waypoints: [{ ...START, fixedLeg: drawn }, SHAPE, { ...STOP, auto }, END],
|
||||||
|
});
|
||||||
|
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId('tour-planner-leg-failure')).queryByRole('button', { name: 'Try again' })
|
||||||
|
).toBeNull();
|
||||||
|
expect(screen.getByTestId('tour-planner-auto-point')).toHaveAttribute('aria-label', 'Found');
|
||||||
|
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
|
||||||
|
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,24 @@
|
|||||||
import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared';
|
import {
|
||||||
|
paddleLayoutOfPoints,
|
||||||
|
type PaddleLayout,
|
||||||
|
type PaddleRegionLoadState,
|
||||||
|
type PaddleRole,
|
||||||
|
type TourFixedLeg,
|
||||||
|
} from '@trek/shared';
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
Info,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
MapPinPlus,
|
MapPinPlus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Navigation,
|
||||||
|
RefreshCw,
|
||||||
Repeat,
|
Repeat,
|
||||||
Spline,
|
Spline,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Waves,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
|
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
|
||||||
import { useTranslation } from '../../../i18n';
|
import { useTranslation } from '../../../i18n';
|
||||||
@@ -28,10 +36,16 @@ import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
|||||||
import { fs } from '../../shared/DialogShell';
|
import { fs } from '../../shared/DialogShell';
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
import { Tooltip } from '../../shared/Tooltip';
|
||||||
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
|
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
|
||||||
import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
|
||||||
|
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
|
||||||
|
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
|
||||||
|
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||||
|
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
|
||||||
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
||||||
import { RailCard, TourNotice } from '../tourParts';
|
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
|
||||||
|
import { RailCard } from '../tourParts';
|
||||||
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
||||||
|
import type { PaddleLegFailure } from './paddleRouting';
|
||||||
import { lineLengthM } from './tourFixedLegs';
|
import { lineLengthM } from './tourFixedLegs';
|
||||||
import type { TourPlannerController } from './useTourPlanner';
|
import type { TourPlannerController } from './useTourPlanner';
|
||||||
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
||||||
@@ -207,18 +221,35 @@ function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
|||||||
* by the tooltip.
|
* by the tooltip.
|
||||||
*/
|
*/
|
||||||
function WaypointNumber({
|
function WaypointNumber({
|
||||||
index,
|
stop,
|
||||||
selected,
|
selected,
|
||||||
ferrata,
|
ferrata,
|
||||||
loopEnd = false,
|
loopEnd = false,
|
||||||
|
shaping = false,
|
||||||
}: {
|
}: {
|
||||||
index: number;
|
/** The stop's number; shaping points do not count, as on the map. */
|
||||||
|
stop: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
ferrata: TourWaypointFerrata | null;
|
ferrata: TourWaypointFerrata | null;
|
||||||
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
||||||
loopEnd?: boolean;
|
loopEnd?: boolean;
|
||||||
|
/** P2A-03: a shaping point, a small dot as on the map. */
|
||||||
|
shaping?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
if (shaping) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-label={t('tours.planner.shapingPoint')}
|
||||||
|
data-waypoint-shaping="true"
|
||||||
|
className="grid h-7 w-7 flex-none place-items-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`h-3 w-3 rounded-full ring-2 ring-surface-card ${selected ? 'bg-accent' : 'bg-content-faint'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (loopEnd) {
|
if (loopEnd) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -230,16 +261,16 @@ function WaypointNumber({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const label = t('tours.planner.waypointLabel', { n: index + 1 });
|
const label = t('tours.planner.waypointLabel', { n: stop });
|
||||||
const number = (
|
const number = (
|
||||||
<span
|
<span
|
||||||
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
||||||
data-waypoint-number={index + 1}
|
data-waypoint-number={stop}
|
||||||
data-ferrata-end={ferrata ? 'true' : undefined}
|
data-ferrata-end={ferrata ? 'true' : undefined}
|
||||||
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
|
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
|
||||||
style={fs(11.5)}
|
style={fs(11.5)}
|
||||||
>
|
>
|
||||||
{index + 1}
|
{stop}
|
||||||
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -278,9 +309,18 @@ function WaypointRowLabel({
|
|||||||
>
|
>
|
||||||
{name ?? coordinates}
|
{name ?? coordinates}
|
||||||
</span>
|
</span>
|
||||||
{paddleRole !== undefined
|
{point.shaping ? (
|
||||||
? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
<Tooltip label={t('tours.planner.shapingHint')}>
|
||||||
: point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
|
<span className="inline-flex">
|
||||||
|
<SoftPill>{t('tours.planner.shapingPoint')}</SoftPill>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : paddleRole !== undefined ? (
|
||||||
|
paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
||||||
|
) : (
|
||||||
|
point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
|
||||||
|
)}
|
||||||
|
{point.auto && <AutoPointPill />}
|
||||||
</span>
|
</span>
|
||||||
{name && !loopEnd && (
|
{name && !loopEnd && (
|
||||||
<span
|
<span
|
||||||
@@ -302,6 +342,82 @@ function WaypointRowLabel({
|
|||||||
* and remove beside it. Two controls keep the place name readable in a narrow
|
* and remove beside it. Two controls keep the place name readable in a narrow
|
||||||
* rail however many tools a tour type adds.
|
* rail however many tools a tour type adds.
|
||||||
*/
|
*/
|
||||||
|
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
|
||||||
|
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||||
|
type WaypointPoint = TourPlannerController['waypoints'][number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tools for the leg after a point: drawn by hand or routed (on a paddle
|
||||||
|
* tour: on the water), planned again (P2A-05), and the point a stop or a
|
||||||
|
* shaping point (P2A-03; never the first or the last).
|
||||||
|
*/
|
||||||
|
function routeMenuItems(
|
||||||
|
planner: TourPlannerController,
|
||||||
|
point: WaypointPoint,
|
||||||
|
index: number,
|
||||||
|
lastIndex: number,
|
||||||
|
t: Translate
|
||||||
|
): MenuItems {
|
||||||
|
const hasLeg = index < lastIndex;
|
||||||
|
const free = point.fixedLeg?.kind === 'free';
|
||||||
|
const routed = hasLeg && !point.fixedLeg;
|
||||||
|
const items: MenuItems = [];
|
||||||
|
if (free || routed) {
|
||||||
|
const routeAgain = planner.isPaddle ? t('tours.paddle.routeOnWater') : t('tours.planner.routeLeg');
|
||||||
|
items.push(
|
||||||
|
{ divider: true },
|
||||||
|
{
|
||||||
|
label: free ? routeAgain : t('tours.planner.freeLeg'),
|
||||||
|
icon: free && planner.isPaddle ? Waves : Spline,
|
||||||
|
onClick: () => planner.toggleWaypointFreeLeg(point.id),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (routed)
|
||||||
|
items.push({
|
||||||
|
label: t('tours.planner.recomputeLeg'),
|
||||||
|
icon: RefreshCw,
|
||||||
|
onClick: () => planner.recomputeLeg(point.id),
|
||||||
|
});
|
||||||
|
if (index > 0 && index < lastIndex) {
|
||||||
|
items.push({
|
||||||
|
label: t(point.shaping ? 'tours.planner.makeStop' : 'tours.planner.makeShaping'),
|
||||||
|
icon: Navigation,
|
||||||
|
onClick: () => planner.setWaypointShaping(point.id, !point.shaping),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paddle tour's tools at a point: carry the boat from here, and a lock here. */
|
||||||
|
function paddleMenuItems(
|
||||||
|
paddle: ReturnType<typeof usePaddleWaypointActions>,
|
||||||
|
point: WaypointPoint,
|
||||||
|
hasLeg: boolean,
|
||||||
|
t: Translate
|
||||||
|
): MenuItems {
|
||||||
|
const portage = point.fixedLeg?.kind === 'portage';
|
||||||
|
const items: MenuItems = [{ divider: true }];
|
||||||
|
// At the last point too: the portage starts here and reaches the point set next.
|
||||||
|
items.push({
|
||||||
|
label: t(
|
||||||
|
!hasLeg && point.carryNext
|
||||||
|
? 'tours.paddle.portagePendingOff'
|
||||||
|
: portage
|
||||||
|
? 'tours.paddle.portageOff'
|
||||||
|
: 'tours.paddle.portageOn'
|
||||||
|
),
|
||||||
|
icon: PortageIcon,
|
||||||
|
onClick: () => paddle.togglePortage(point.id),
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
|
||||||
|
icon: LockIcon,
|
||||||
|
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
function WaypointRowActions({
|
function WaypointRowActions({
|
||||||
planner,
|
planner,
|
||||||
point,
|
point,
|
||||||
@@ -311,7 +427,7 @@ function WaypointRowActions({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}: {
|
}: {
|
||||||
planner: TourPlannerController;
|
planner: TourPlannerController;
|
||||||
point: TourPlannerController['waypoints'][number];
|
point: WaypointPoint;
|
||||||
index: number;
|
index: number;
|
||||||
lastIndex: number;
|
lastIndex: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -320,10 +436,7 @@ function WaypointRowActions({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const menu = useContextMenu();
|
const menu = useContextMenu();
|
||||||
const paddle = usePaddleWaypointActions(planner);
|
const paddle = usePaddleWaypointActions(planner);
|
||||||
const free = point.fixedLeg?.kind === 'free';
|
const items: MenuItems = [
|
||||||
const portage = point.fixedLeg?.kind === 'portage';
|
|
||||||
const hasLeg = index < lastIndex;
|
|
||||||
const items = [
|
|
||||||
{
|
{
|
||||||
label: t('tours.planner.moveUp'),
|
label: t('tours.planner.moveUp'),
|
||||||
icon: ArrowUp,
|
icon: ArrowUp,
|
||||||
@@ -336,35 +449,8 @@ function WaypointRowActions({
|
|||||||
disabled: index === lastIndex,
|
disabled: index === lastIndex,
|
||||||
onClick: () => planner.moveWaypoint(point.id, 1),
|
onClick: () => planner.moveWaypoint(point.id, 1),
|
||||||
},
|
},
|
||||||
...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata'
|
...routeMenuItems(planner, point, index, lastIndex, t),
|
||||||
? [
|
...(planner.isPaddle ? paddleMenuItems(paddle, point, index < lastIndex, t) : []),
|
||||||
{ divider: true },
|
|
||||||
{
|
|
||||||
label: t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg'),
|
|
||||||
icon: Spline,
|
|
||||||
onClick: () => planner.toggleWaypointFreeLeg(point.id),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(planner.isPaddle
|
|
||||||
? [
|
|
||||||
{ divider: true },
|
|
||||||
...(hasLeg
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: t(portage ? 'tours.paddle.portageOff' : 'tours.paddle.portageOn'),
|
|
||||||
icon: PortageIcon,
|
|
||||||
onClick: () => paddle.togglePortage(point.id),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{
|
|
||||||
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
|
|
||||||
icon: LockIcon,
|
|
||||||
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -402,24 +488,29 @@ function PaddleRowExtras({
|
|||||||
index,
|
index,
|
||||||
layout,
|
layout,
|
||||||
editable,
|
editable,
|
||||||
|
routing,
|
||||||
}: {
|
}: {
|
||||||
planner: TourPlannerController;
|
planner: TourPlannerController;
|
||||||
point: TourPlannerController['waypoints'][number];
|
point: TourPlannerController['waypoints'][number];
|
||||||
index: number;
|
index: number;
|
||||||
layout: PaddleLayout;
|
layout: PaddleLayout;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
routing: ReturnType<typeof usePortageRouting>;
|
||||||
}) {
|
}) {
|
||||||
const paddle = usePaddleWaypointActions(planner);
|
const paddle = usePaddleWaypointActions(planner);
|
||||||
|
const way = usePortageWay(planner.waypoints, routing, index);
|
||||||
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
|
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
|
||||||
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
|
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
|
||||||
{portage && (
|
{portage && (
|
||||||
<PortageLegRow
|
<PortageLegRow
|
||||||
lengthM={portage.toM - portage.fromM}
|
lengthM={portage.toM - portage.fromM}
|
||||||
extraMinutes={portage.extraMinutes}
|
extraMinutes={portage.extraMinutes}
|
||||||
disabled={!editable}
|
disabled={!editable}
|
||||||
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
|
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
|
||||||
|
way={point.auto ? undefined : way}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{point.lock && (
|
{point.lock && (
|
||||||
@@ -463,6 +554,154 @@ function AddPointRow({ disabled }: { disabled: boolean }) {
|
|||||||
* reorderable by drag or by the arrow controls on each row. The coordinates
|
* reorderable by drag or by the arrow controls on each row. The coordinates
|
||||||
* stand in for the name while it loads, offline, or when the lookup has none.
|
* stand in for the name while it loads, offline, or when the lookup has none.
|
||||||
*/
|
*/
|
||||||
|
/** P2A-03: how a shaping point is set, said once under the list while there is a route to click. */
|
||||||
|
function ShapeOnLineHint({ planner, canEdit }: { planner: TourPlannerController; canEdit: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!canEdit || planner.waypoints.length < 2) return null;
|
||||||
|
return (
|
||||||
|
<p className="m-0 mt-1.5 px-1.5 text-content-faint" style={fs(11, 'body')}>
|
||||||
|
{t('tours.planner.shapeOnLine')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops are numbered on their own, as on the map: a shaping point (P2A-03) takes no number. */
|
||||||
|
function stopNumbersOf(points: ReadonlyArray<WaypointPoint>): Map<string, number> {
|
||||||
|
const numbers = new Map<string, number>();
|
||||||
|
for (const point of points) if (!point.shaping) numbers.set(point.id, numbers.size + 1);
|
||||||
|
return numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The route tools in the card head: reverse, plan the drawn legs on the water (PAD-11), back to the start. */
|
||||||
|
function WaypointListTools({ planner, isSaving }: { planner: TourPlannerController; isSaving: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasDrawnLegs = planner.isPaddle && planner.waypoints.some((point) => point.fixedLeg?.kind === 'free');
|
||||||
|
return (
|
||||||
|
<span className="flex flex-none items-center gap-0.5">
|
||||||
|
<RouteToolButton
|
||||||
|
testId="tour-planner-reverse"
|
||||||
|
label={t('tours.planner.reverse')}
|
||||||
|
disabled={isSaving || planner.waypoints.length < 2}
|
||||||
|
onAct={planner.reverse}
|
||||||
|
>
|
||||||
|
<ArrowDownUp size={13} strokeWidth={2.2} />
|
||||||
|
</RouteToolButton>
|
||||||
|
{hasDrawnLegs && (
|
||||||
|
<RouteToolButton
|
||||||
|
testId="tour-planner-route-on-water"
|
||||||
|
label={t('tours.paddle.routeAllOnWater')}
|
||||||
|
disabled={isSaving}
|
||||||
|
onAct={planner.routeDrawnLegsOnWater}
|
||||||
|
>
|
||||||
|
<Waves size={13} strokeWidth={2.2} />
|
||||||
|
</RouteToolButton>
|
||||||
|
)}
|
||||||
|
<RouteToolButton
|
||||||
|
testId="tour-planner-return-to-start"
|
||||||
|
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
|
||||||
|
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
|
||||||
|
onAct={planner.returnToStart}
|
||||||
|
>
|
||||||
|
<Repeat size={13} strokeWidth={2.2} />
|
||||||
|
</RouteToolButton>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A leg the router could not plan. Where it lacked the water data of the
|
||||||
|
* region (PAD-11C), the row follows the data instead: loading or asked for,
|
||||||
|
* the leg waits calmly; declined, failed or not to be had, it says why.
|
||||||
|
*/
|
||||||
|
function LegFailure({
|
||||||
|
code,
|
||||||
|
load,
|
||||||
|
disabled,
|
||||||
|
onDrawStraight,
|
||||||
|
onRetry,
|
||||||
|
onLoadRetry,
|
||||||
|
}: {
|
||||||
|
code: PaddleLegFailure;
|
||||||
|
load: PaddleRegionLoadState | undefined;
|
||||||
|
disabled: boolean;
|
||||||
|
onDrawStraight: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onLoadRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (isRegionLoadPending(load)) {
|
||||||
|
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PaddleLegFailureRow
|
||||||
|
code={code}
|
||||||
|
disabled={disabled}
|
||||||
|
onDrawStraight={onDrawStraight}
|
||||||
|
onRetry={onRetry}
|
||||||
|
message={regionLoadFailure(load, t)}
|
||||||
|
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows under a waypoint for the leg that starts there: the via ferrata it
|
||||||
|
* crosses, a line drawn by hand, and on a paddle tour what the router found on
|
||||||
|
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
|
||||||
|
*/
|
||||||
|
function WaypointLegRows({
|
||||||
|
planner,
|
||||||
|
point,
|
||||||
|
index,
|
||||||
|
crossings = [],
|
||||||
|
routedLegs,
|
||||||
|
paddleLayout,
|
||||||
|
editable,
|
||||||
|
portageRouting,
|
||||||
|
regionLoad,
|
||||||
|
}: {
|
||||||
|
planner: TourPlannerController;
|
||||||
|
point: WaypointPoint;
|
||||||
|
index: number;
|
||||||
|
crossings?: TourFerrataCrossing[];
|
||||||
|
routedLegs: TourPlannerController['paddleRoute'] | null;
|
||||||
|
paddleLayout: ReturnType<typeof paddleLayoutOfPoints> | null;
|
||||||
|
editable: boolean;
|
||||||
|
portageRouting: ReturnType<typeof usePortageRouting>;
|
||||||
|
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
|
||||||
|
}) {
|
||||||
|
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{crossings.map((crossing) => (
|
||||||
|
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||||
|
))}
|
||||||
|
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
|
||||||
|
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
|
||||||
|
{failure && (
|
||||||
|
<LegFailure
|
||||||
|
code={failure.code}
|
||||||
|
load={regionLoad.byLeg.get(index)}
|
||||||
|
disabled={!editable}
|
||||||
|
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
|
||||||
|
onRetry={() => planner.recomputeLeg(point.id)}
|
||||||
|
onLoadRetry={regionLoad.retry}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{paddleLayout && (
|
||||||
|
<PaddleRowExtras
|
||||||
|
planner={planner}
|
||||||
|
point={point}
|
||||||
|
index={index}
|
||||||
|
layout={paddleLayout}
|
||||||
|
editable={editable}
|
||||||
|
routing={portageRouting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function TourWaypointList({
|
export default function TourWaypointList({
|
||||||
planner,
|
planner,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -489,34 +728,22 @@ export default function TourWaypointList({
|
|||||||
// A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler.
|
// A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler.
|
||||||
const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined);
|
const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined);
|
||||||
const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null;
|
const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null;
|
||||||
|
// PAD-03: portages the paddler sets follow the ways on land.
|
||||||
|
const portageRouting = usePortageRouting(planner, canEdit && !isSaving);
|
||||||
|
// The router's answer per leg, while it still belongs to these points (PAD-11).
|
||||||
|
const routedLegs = planner.isPaddle && planner.routeCurrent ? planner.paddleRoute : null;
|
||||||
|
// PAD-11C: where a leg lacks the water data of its region, TREK fetches it and plans the leg again.
|
||||||
|
const regionLoad = usePaddleRegionLoad(routedLegs?.failures, planner.retry);
|
||||||
|
const stopNumbers = stopNumbersOf(planner.waypoints);
|
||||||
const endDrag = () => {
|
const endDrag = () => {
|
||||||
setDraggedWaypointId(null);
|
setDraggedWaypointId(null);
|
||||||
setDropTarget(null);
|
setDropTarget(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// One card like a day in the plan: the head counts the points, the rows sit below.
|
// One card like a day in the plan: the head counts the points, the rows sit below.
|
||||||
const tools = canEdit && planner.waypoints.length > 0 && (
|
const tools = canEdit && planner.waypoints.length > 0 && <WaypointListTools planner={planner} isSaving={isSaving} />;
|
||||||
<span className="flex flex-none items-center gap-0.5">
|
// The way back of a loop is no stop of its own, and a shaping point no stop at all, so the count leaves them out.
|
||||||
<RouteToolButton
|
const stops = stopNumbers.size - (planner.isLoop ? 1 : 0);
|
||||||
testId="tour-planner-reverse"
|
|
||||||
label={t('tours.planner.reverse')}
|
|
||||||
disabled={isSaving || planner.waypoints.length < 2}
|
|
||||||
onAct={planner.reverse}
|
|
||||||
>
|
|
||||||
<ArrowDownUp size={13} strokeWidth={2.2} />
|
|
||||||
</RouteToolButton>
|
|
||||||
<RouteToolButton
|
|
||||||
testId="tour-planner-return-to-start"
|
|
||||||
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
|
|
||||||
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
|
|
||||||
onAct={planner.returnToStart}
|
|
||||||
>
|
|
||||||
<Repeat size={13} strokeWidth={2.2} />
|
|
||||||
</RouteToolButton>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
// The way back of a loop is no stop of its own, so the count leaves it out.
|
|
||||||
const stops = planner.waypoints.length - (planner.isLoop ? 1 : 0);
|
|
||||||
const card = (children: ReactNode) => (
|
const card = (children: ReactNode) => (
|
||||||
<RailCard
|
<RailCard
|
||||||
icon={<MapPin size={14} strokeWidth={2} />}
|
icon={<MapPin size={14} strokeWidth={2} />}
|
||||||
@@ -608,10 +835,11 @@ export default function TourWaypointList({
|
|||||||
<GripVertical size={14} strokeWidth={1.8} />
|
<GripVertical size={14} strokeWidth={1.8} />
|
||||||
</span>
|
</span>
|
||||||
<WaypointNumber
|
<WaypointNumber
|
||||||
index={index}
|
stop={stopNumbers.get(point.id) ?? index + 1}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
ferrata={ferrataEnds[point.id] ?? null}
|
ferrata={ferrataEnds[point.id] ?? null}
|
||||||
loopEnd={loopEnd}
|
loopEnd={loopEnd}
|
||||||
|
shaping={!!point.shaping}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -638,30 +866,23 @@ export default function TourWaypointList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{(legCrossings.get(point.id) ?? []).map((crossing) => (
|
<WaypointLegRows
|
||||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
|
||||||
))}
|
|
||||||
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
|
|
||||||
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
|
|
||||||
{paddleLayout && (
|
|
||||||
<PaddleRowExtras
|
|
||||||
planner={planner}
|
planner={planner}
|
||||||
point={point}
|
point={point}
|
||||||
index={index}
|
index={index}
|
||||||
layout={paddleLayout}
|
crossings={legCrossings.get(point.id)}
|
||||||
|
routedLegs={routedLegs}
|
||||||
|
paddleLayout={paddleLayout}
|
||||||
editable={canEdit && !isSaving}
|
editable={canEdit && !isSaving}
|
||||||
|
portageRouting={portageRouting}
|
||||||
|
regionLoad={regionLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||||
{planner.isPaddle && (
|
<ShapeOnLineHint planner={planner} canEdit={canEdit} />
|
||||||
<div className="mt-2">
|
|
||||||
<TourNotice icon={<Info size={13} strokeWidth={2} />}>{t('tours.paddle.drawnByHand')}</TourNotice>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type PaddleRole,
|
type PaddleRole,
|
||||||
type PaddleTimeEstimate,
|
type PaddleTimeEstimate,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import type { TourPlannerWaypoint } from './useTourPlanner';
|
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||||
|
|
||||||
/** The planner's control points as the shared paddle plan reads them. */
|
/** The planner's control points as the shared paddle plan reads them. */
|
||||||
export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] {
|
export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] {
|
||||||
@@ -26,15 +26,17 @@ export function paddleRolesById(points: readonly TourPlannerWaypoint[]): Map<str
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-07: the estimate of a paddle tour as planned, without its breaks (the
|
* PAD-07: the estimate of a paddle tour as planned, without its breaks (the
|
||||||
* planner adds those as it does for a hike). A speed the paddler typed that
|
* planner adds those as it does for a hike), with the routed length of each
|
||||||
|
* leg where the router planned it (PAD-11). A speed the paddler typed that
|
||||||
* does not hold leaves the water time unknown rather than wrong.
|
* does not hold leaves the water time unknown rather than wrong.
|
||||||
*/
|
*/
|
||||||
export function paddleEstimateOf(
|
export function paddleEstimateOf(
|
||||||
points: readonly TourPlannerWaypoint[],
|
points: readonly TourPlannerWaypoint[],
|
||||||
paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null },
|
paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null },
|
||||||
speedInvalid: boolean
|
speedInvalid: boolean,
|
||||||
|
legLengths?: ReadonlyArray<number | null>
|
||||||
): PaddleTimeEstimate {
|
): PaddleTimeEstimate {
|
||||||
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), {
|
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points), legLengths), {
|
||||||
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
|
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
|
||||||
portageSpeedKmh: paddle.portageSpeedKmh,
|
portageSpeedKmh: paddle.portageSpeedKmh,
|
||||||
breakMinutes: null,
|
breakMinutes: null,
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
|
||||||
|
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
|
||||||
|
import {
|
||||||
|
adoptChangedPoints,
|
||||||
|
materializePaddleLegs,
|
||||||
|
planPaddleStep,
|
||||||
|
pruneAutoPoints,
|
||||||
|
routePaddleTour,
|
||||||
|
type PaddlePoint,
|
||||||
|
} from './paddleRouting';
|
||||||
|
|
||||||
|
/** The item at `i`, or a failure that names the index instead of a later undefined. */
|
||||||
|
function at<T>(list: readonly T[], i: number): T {
|
||||||
|
const item = list[i];
|
||||||
|
if (item === undefined) throw new Error(`nothing at ${i}`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } }));
|
||||||
|
|
||||||
|
const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const };
|
||||||
|
const A: PaddlePoint = { id: 'a', lat: 49.1, lng: 12.1 };
|
||||||
|
const B: PaddlePoint = { id: 'b', lat: 49.05, lng: 12.12 };
|
||||||
|
const C: PaddlePoint = { id: 'c', lat: 49.0, lng: 12.1 };
|
||||||
|
|
||||||
|
function segment(kind: PaddleRouteSegment['kind'], from: number, to: number, extra: Partial<PaddleRouteSegment> = {}) {
|
||||||
|
return { kind, from, to, name: null, source: null, osm_ref: null, grade: null, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A routed leg through five points: water, a portage round a weir, water, a lock, water. */
|
||||||
|
const WEIR_AND_LOCK: PaddleRouteLeg = {
|
||||||
|
status: 'ok',
|
||||||
|
geometry: [
|
||||||
|
[49.1, 12.1],
|
||||||
|
[49.09, 12.11],
|
||||||
|
[49.08, 12.11],
|
||||||
|
[49.07, 12.115],
|
||||||
|
[49.06, 12.118],
|
||||||
|
[49.05, 12.12],
|
||||||
|
],
|
||||||
|
segments: [
|
||||||
|
segment('water', 0, 1),
|
||||||
|
segment('portage', 1, 2, { source: 'tagged' }),
|
||||||
|
segment('water', 2, 3),
|
||||||
|
segment('lock', 3, 4, { name: 'Schleuse' }),
|
||||||
|
segment('water', 4, 5),
|
||||||
|
],
|
||||||
|
distance_m: 6100,
|
||||||
|
snapped_m: { from: 0, to: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const straight = (from: PaddlePoint, to: PaddlePoint, distance = 1000): PaddleRouteLeg => ({
|
||||||
|
status: 'ok',
|
||||||
|
geometry: [
|
||||||
|
[from.lat, from.lng],
|
||||||
|
[to.lat, to.lng],
|
||||||
|
],
|
||||||
|
segments: [segment('water', 0, 1)],
|
||||||
|
distance_m: distance,
|
||||||
|
snapped_m: { from: 0, to: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
let ids = 0;
|
||||||
|
const newId = () => `auto-${(ids += 1)}`;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ids = 0;
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('routePaddleTour', () => {
|
||||||
|
it('FE-PADDLE-ROUTING-001: asks only for the legs without a line, and keeps a drawn or carried leg as it is', async () => {
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([straight(B, C, 5500)]);
|
||||||
|
const drawn = {
|
||||||
|
kind: 'free' as const,
|
||||||
|
geometry: [
|
||||||
|
[A.lat, A.lng],
|
||||||
|
[B.lat, B.lng],
|
||||||
|
] as [number, number][],
|
||||||
|
};
|
||||||
|
const route = await routePaddleTour([{ ...A, fixedLeg: drawn }, B, C], 7, KAYAK);
|
||||||
|
|
||||||
|
expect(vi.mocked(paddleRouteRepo.plan).mock.calls[0]?.[1]).toEqual([{ from: B, to: C }]);
|
||||||
|
expect(route.legs[0]).toBeNull();
|
||||||
|
expect(route.legs[1]?.status).toBe('ok');
|
||||||
|
expect(route.parts.map((part) => [part.free, !!part.land])).toEqual([
|
||||||
|
[true, false],
|
||||||
|
[false, false],
|
||||||
|
]);
|
||||||
|
expect(route.legLengths[1]).toBe(5500);
|
||||||
|
expect(route.distanceMeters).toBeCloseTo(at(route.legLengths, 0) + 5500);
|
||||||
|
expect(route.coordinates[0]).toEqual([A.lat, A.lng]);
|
||||||
|
expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-002: draws a portage as land, whitewater and a boat pass amber, a gap red, and merges the rest', async () => {
|
||||||
|
const marked: PaddleRouteLeg = {
|
||||||
|
...WEIR_AND_LOCK,
|
||||||
|
segments: [
|
||||||
|
segment('water', 0, 1),
|
||||||
|
segment('portage', 1, 2),
|
||||||
|
segment('rapids', 2, 3, { grade: 2 }),
|
||||||
|
segment('pass', 3, 4),
|
||||||
|
segment('gap', 4, 5),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([marked]);
|
||||||
|
const route = await routePaddleTour([A, B], 7, KAYAK);
|
||||||
|
expect(route.parts.map((part) => part.land ?? part.mark ?? 'water')).toEqual(['water', true, 'rapids', 'gap']);
|
||||||
|
// Whitewater and the boat pass next to it are one amber line.
|
||||||
|
expect(route.parts[2]?.coordinates).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-003: a leg the router could not plan stays a straight unrouted stretch, counted as a failure', async () => {
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([
|
||||||
|
straight(A, B),
|
||||||
|
{ status: 'failed', code: 'not_on_water', at: { lat: C.lat, lng: C.lng } },
|
||||||
|
]);
|
||||||
|
const route = await routePaddleTour([A, B, C], 7, KAYAK);
|
||||||
|
expect(route.failures).toEqual([{ index: 1, code: 'not_on_water', at: { lat: C.lat, lng: C.lng } }]);
|
||||||
|
expect(route.parts[route.parts.length - 1]).toMatchObject({
|
||||||
|
mark: 'unrouted',
|
||||||
|
coordinates: [
|
||||||
|
[B.lat, B.lng],
|
||||||
|
[C.lat, C.lng],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// The straight line counts in the length, so the time does not shrink while a leg waits.
|
||||||
|
expect(route.legLengths[1]).toBeGreaterThan(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-004: a leg the answer leaves out is unrouted, never silently straight water', async () => {
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([]);
|
||||||
|
const route = await routePaddleTour([A, B], 7, KAYAK);
|
||||||
|
expect(route.failures).toEqual([{ index: 0, code: 'no_water_path', at: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-005: a tour with every leg drawn asks nothing', async () => {
|
||||||
|
const carried = {
|
||||||
|
kind: 'portage' as const,
|
||||||
|
geometry: [
|
||||||
|
[A.lat, A.lng],
|
||||||
|
[B.lat, B.lng],
|
||||||
|
] as [number, number][],
|
||||||
|
extra_minutes: null,
|
||||||
|
};
|
||||||
|
const route = await routePaddleTour([{ ...A, fixedLeg: carried }, B], 7, KAYAK);
|
||||||
|
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
|
||||||
|
expect(route.parts[0]?.land).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('automatic points', () => {
|
||||||
|
it("FE-PADDLE-ROUTING-006: sets a portage pair and a lock where the router found them, on the paddler's own leg", () => {
|
||||||
|
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||||
|
expect(points.map((point) => point.id)).toEqual(['a', 'auto-1', 'auto-2', 'auto-3', 'b']);
|
||||||
|
expect(points[1]).toMatchObject({ lat: 49.09, lng: 12.11, fixedLeg: { kind: 'portage', extra_minutes: null } });
|
||||||
|
expect(points[2]).toMatchObject({ lat: 49.08, lng: 12.11 });
|
||||||
|
expect(points[2]?.fixedLeg).toBeUndefined();
|
||||||
|
expect(points[3]).toMatchObject({ lock: { wait_minutes: null, transit_minutes: null } });
|
||||||
|
expect(points[1]?.auto).toEqual({
|
||||||
|
from: { id: 'a', lat: A.lat, lng: A.lng },
|
||||||
|
to: { id: 'b', lat: B.lat, lng: B.lng },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-007: planning the same leg again keeps the ids, and a leg at an automatic point adds nothing', () => {
|
||||||
|
const once = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||||
|
const legs = once.slice(0, -1).map(() => WEIR_AND_LOCK);
|
||||||
|
const twice = materializePaddleLegs(once, { legs }, newId);
|
||||||
|
expect(twice).toEqual(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-008: drops the automatic points of a leg whose end moved or went, and keeps them otherwise', () => {
|
||||||
|
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
|
||||||
|
expect(pruneAutoPoints(points)).toEqual(points);
|
||||||
|
const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
|
||||||
|
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
const shaped = [at(points, 0), { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)];
|
||||||
|
expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PADDLE-ROUTING-009: a point the paddler moves or edits is theirs; an untouched one stays automatic', () => {
|
||||||
|
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||||
|
const edited = points.map((point) =>
|
||||||
|
point.id === 'auto-3' ? { ...point, lock: { wait_minutes: 20, transit_minutes: null } } : point
|
||||||
|
);
|
||||||
|
const adopted = adoptChangedPoints(points, edited);
|
||||||
|
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
|
||||||
|
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('planPaddleStep', () => {
|
||||||
|
it('FE-PADDLE-ROUTING-010: prunes first, then sets what the router found, then answers the route', async () => {
|
||||||
|
const signal = new AbortController().signal;
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([WEIR_AND_LOCK]);
|
||||||
|
const first = await planPaddleStep([A, B], 7, KAYAK, signal, newId);
|
||||||
|
expect('points' in first && first.points).toHaveLength(5);
|
||||||
|
|
||||||
|
const found = 'points' in first ? first.points : [];
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockResolvedValue(found.slice(0, -1).map(() => straight(A, B, 1500)));
|
||||||
|
const second = await planPaddleStep(found, 7, KAYAK, signal, newId);
|
||||||
|
expect('route' in second && second.route.legLengths).toHaveLength(4);
|
||||||
|
|
||||||
|
const stale = found.map((point) => (point.id === 'a' ? { ...point, lng: point.lng + 0.01 } : point));
|
||||||
|
vi.mocked(paddleRouteRepo.plan).mockClear();
|
||||||
|
const third = await planPaddleStep(stale, 7, KAYAK, signal, newId);
|
||||||
|
expect('points' in third && third.points.map((point) => point.id)).toEqual(['a', 'b']);
|
||||||
|
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import type { PaddleLock, PaddleRouteFailureCode, PaddleRouteLeg, TourFixedLeg } from '@trek/shared';
|
||||||
|
import { paddleRouteRepo, type PaddleLegAnswer, type PaddleRouteProfile } from '../../../repo/paddleRouteRepo';
|
||||||
|
import { haversineKm } from '../../../utils/geo';
|
||||||
|
import { lineLengthM } from './tourFixedLegs';
|
||||||
|
import type { TourRoutePart } from './tourRouting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PAD-11: a paddle tour's route in the planner. A leg without a line of its
|
||||||
|
* own is planned on the water by the server's router, as a hike's legs are by
|
||||||
|
* Valhalla; a leg drawn by hand or carried as a portage keeps its line. Where
|
||||||
|
* the router carries round a weir or goes through a lock between two of the
|
||||||
|
* paddler's own points, the planner sets points of its own there (`auto`), so
|
||||||
|
* the portage and the lock count like any planned by hand: in the time, in the
|
||||||
|
* checklist, on the map. They are derived again whenever the points they came
|
||||||
|
* from change, and become the paddler's own once touched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where an automatic point came from: the two points of the paddler's leg it was found on, as they were. */
|
||||||
|
export interface AutoBase {
|
||||||
|
from: { id: string; lat: number; lng: number };
|
||||||
|
to: { id: string; lat: number; lng: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaddlePoint {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
fixedLeg?: TourFixedLeg | null;
|
||||||
|
lock?: PaddleLock | null;
|
||||||
|
shaping?: boolean;
|
||||||
|
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||||
|
auto?: AutoBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaddleLegFailure = PaddleRouteFailureCode | 'offline';
|
||||||
|
|
||||||
|
export interface PaddleTourRoute {
|
||||||
|
coordinates: [number, number][];
|
||||||
|
parts: TourRoutePart[];
|
||||||
|
distanceMeters: number;
|
||||||
|
/** Per leg (index of its first point): the router's answer, or null for a leg with a line of its own. */
|
||||||
|
legs: Array<PaddleLegAnswer | null>;
|
||||||
|
/** The routed length of every leg, metres, for the time estimate. */
|
||||||
|
legLengths: number[];
|
||||||
|
failures: Array<{ index: number; code: PaddleLegFailure; at: { lat: number; lng: number } | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a leg holds when its answer is somehow missing: unrouted, never silently straight. */
|
||||||
|
const NOT_ASKED: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
|
||||||
|
|
||||||
|
/** Two points this close are one spot, in degrees (about ten centimetres). */
|
||||||
|
const SAME_SPOT = 1e-6;
|
||||||
|
|
||||||
|
function samePlace(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
|
||||||
|
return Math.abs(a.lat - b.lat) < SAME_SPOT && Math.abs(a.lng - b.lng) < SAME_SPOT;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartKind = 'water' | 'rapids' | 'gap' | 'unrouted' | 'land' | 'manual';
|
||||||
|
|
||||||
|
function partOf(kind: PartKind, coordinates: [number, number][]): TourRoutePart {
|
||||||
|
if (kind === 'land') return { coordinates, free: false, land: true };
|
||||||
|
if (kind === 'manual') return { coordinates, free: true };
|
||||||
|
return { coordinates, free: false, ...(kind === 'water' ? {} : { mark: kind }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parts of one routed leg: water as one line, carried and marked stretches as their own. */
|
||||||
|
function routedParts(leg: Extract<PaddleRouteLeg, { status: 'ok' }>): TourRoutePart[] {
|
||||||
|
return leg.segments.map((segment) => {
|
||||||
|
const line = leg.geometry.slice(segment.from, segment.to + 1) as [number, number][];
|
||||||
|
if (segment.kind === 'portage') return partOf('land', line);
|
||||||
|
if (segment.kind === 'rapids' || segment.kind === 'pass') return partOf('rapids', line);
|
||||||
|
if (segment.kind === 'gap') return partOf('gap', line);
|
||||||
|
return partOf('water', line);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neighbouring parts drawn alike as one line, as the hike's route does. */
|
||||||
|
function mergeParts(parts: TourRoutePart[]): TourRoutePart[] {
|
||||||
|
const merged: TourRoutePart[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const previous = merged[merged.length - 1];
|
||||||
|
if (previous && previous.free === part.free && !!previous.land === !!part.land && previous.mark === part.mark) {
|
||||||
|
previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)];
|
||||||
|
} else {
|
||||||
|
merged.push({ ...part, coordinates: [...part.coordinates] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plans the legs that need it and puts the whole line together. */
|
||||||
|
export async function routePaddleTour(
|
||||||
|
points: readonly PaddlePoint[],
|
||||||
|
tripId: number | string,
|
||||||
|
profile: PaddleRouteProfile,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<PaddleTourRoute> {
|
||||||
|
const routed = points.slice(0, -1).flatMap((point, index) => (point.fixedLeg ? [] : [index]));
|
||||||
|
const answers = routed.length
|
||||||
|
? await paddleRouteRepo.plan(
|
||||||
|
tripId,
|
||||||
|
routed.map((index) => ({ from: points[index], to: points[index + 1] })),
|
||||||
|
profile,
|
||||||
|
signal
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const byLeg = new Map(routed.map((index, at) => [index, answers[at]]));
|
||||||
|
const parts: TourRoutePart[] = [];
|
||||||
|
const legs: PaddleTourRoute['legs'] = [];
|
||||||
|
const legLengths: number[] = [];
|
||||||
|
const failures: PaddleTourRoute['failures'] = [];
|
||||||
|
for (let index = 0; index < points.length - 1; index += 1) {
|
||||||
|
const point = points[index];
|
||||||
|
const next = points[index + 1];
|
||||||
|
const leg = point.fixedLeg;
|
||||||
|
if (leg) {
|
||||||
|
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||||
|
parts.push(partOf(leg.kind === 'portage' ? 'land' : 'manual', line));
|
||||||
|
legs.push(null);
|
||||||
|
legLengths.push(lineLengthM(line));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const answer = byLeg.get(index) ?? NOT_ASKED;
|
||||||
|
legs.push(answer);
|
||||||
|
if (answer.status === 'ok') {
|
||||||
|
parts.push(...routedParts(answer));
|
||||||
|
legLengths.push(answer.distance_m);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A leg the router could not plan stays visible as what it is: a straight stretch nobody checked.
|
||||||
|
failures.push({ index, code: answer.code, at: answer.at });
|
||||||
|
parts.push(
|
||||||
|
partOf('unrouted', [
|
||||||
|
[point.lat, point.lng],
|
||||||
|
[next.lat, next.lng],
|
||||||
|
])
|
||||||
|
);
|
||||||
|
legLengths.push(haversineKm(point, next) * 1000);
|
||||||
|
}
|
||||||
|
const lines = mergeParts(parts);
|
||||||
|
const coordinates = lines.flatMap((part, index) => (index === 0 ? part.coordinates : part.coordinates.slice(1)));
|
||||||
|
return {
|
||||||
|
coordinates,
|
||||||
|
parts: lines,
|
||||||
|
distanceMeters: legLengths.reduce((sum, length) => sum + length, 0),
|
||||||
|
legs,
|
||||||
|
legLengths,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseOf(points: readonly PaddlePoint[], index: number): AutoBase {
|
||||||
|
const from = points[index];
|
||||||
|
const to = points[index + 1];
|
||||||
|
return { from: { id: from.id, lat: from.lat, lng: from.lng }, to: { id: to.id, lat: to.lat, lng: to.lng } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The automatic points that no longer stand on the leg they were found on
|
||||||
|
* (an end of it moved, or went), dropped; the next plan finds them again
|
||||||
|
* where they belong.
|
||||||
|
*/
|
||||||
|
export function pruneAutoPoints<T extends PaddlePoint>(points: readonly T[]): T[] {
|
||||||
|
const own = points.filter((point) => !point.auto);
|
||||||
|
return points.filter((point) => {
|
||||||
|
const base = point.auto;
|
||||||
|
if (!base) return true;
|
||||||
|
const from = own.find((candidate) => candidate.id === base.from.id);
|
||||||
|
const to = own.find((candidate) => candidate.id === base.to.id);
|
||||||
|
if (!from || !to || !samePlace(from, base.from) || !samePlace(to, base.to)) return false;
|
||||||
|
return own.indexOf(to) === own.indexOf(from) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the portages and locks the router found on the paddler's own legs as
|
||||||
|
* points. An existing automatic point at the same spot keeps its id, so
|
||||||
|
* planning the same leg again changes nothing.
|
||||||
|
*/
|
||||||
|
export function materializePaddleLegs<T extends PaddlePoint>(
|
||||||
|
points: readonly T[],
|
||||||
|
route: Pick<PaddleTourRoute, 'legs'>,
|
||||||
|
newId: () => string
|
||||||
|
): T[] {
|
||||||
|
const existing = points.filter((point) => point.auto);
|
||||||
|
const idAt = (lat: number, lng: number) => existing.find((point) => samePlace(point, { lat, lng }))?.id ?? newId();
|
||||||
|
const result: T[] = [];
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
result.push(point);
|
||||||
|
const next = points[index + 1];
|
||||||
|
const answer = route.legs[index];
|
||||||
|
// Only a leg between two of the paddler's own points: a leg at an automatic point is part of one already set.
|
||||||
|
if (!next || point.auto || next.auto || !answer || answer.status !== 'ok') return;
|
||||||
|
const base = baseOf(points, index);
|
||||||
|
const last = answer.geometry.length - 1;
|
||||||
|
for (const segment of answer.segments) {
|
||||||
|
if (segment.kind === 'portage' && segment.from > 0 && segment.to < last) {
|
||||||
|
const line = answer.geometry.slice(segment.from, segment.to + 1) as [number, number][];
|
||||||
|
const [outLat, outLng] = line[0];
|
||||||
|
const [inLat, inLng] = line[line.length - 1];
|
||||||
|
result.push(
|
||||||
|
{
|
||||||
|
id: idAt(outLat, outLng),
|
||||||
|
lat: outLat,
|
||||||
|
lng: outLng,
|
||||||
|
auto: base,
|
||||||
|
fixedLeg: { kind: 'portage', geometry: line, extra_minutes: null },
|
||||||
|
} as unknown as T,
|
||||||
|
{ id: idAt(inLat, inLng), lat: inLat, lng: inLng, auto: base } as unknown as T
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (segment.kind === 'lock' && segment.from > 0 && segment.to < last) {
|
||||||
|
const [lat, lng] = answer.geometry[Math.round((segment.from + segment.to) / 2)];
|
||||||
|
result.push({
|
||||||
|
id: idAt(lat, lng),
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
auto: base,
|
||||||
|
lock: { wait_minutes: null, transit_minutes: null },
|
||||||
|
} as unknown as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
|
||||||
|
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
|
||||||
|
const previous = new Map(before.map((point) => [point.id, point]));
|
||||||
|
return after.map((point) => {
|
||||||
|
if (!point.auto) return point;
|
||||||
|
const was = previous.get(point.id);
|
||||||
|
const same =
|
||||||
|
!!was &&
|
||||||
|
samePlace(was, point) &&
|
||||||
|
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
|
||||||
|
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
|
||||||
|
if (same) return point;
|
||||||
|
const { auto: _auto, ...own } = point;
|
||||||
|
return own as T;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One run of the paddle router over the points: either points to set first
|
||||||
|
* (automatic ones dropped from a leg that changed, or found on a planned
|
||||||
|
* leg), after which the planner runs again, or the finished route.
|
||||||
|
*/
|
||||||
|
export async function planPaddleStep<T extends PaddlePoint>(
|
||||||
|
points: readonly T[],
|
||||||
|
tripId: number | string,
|
||||||
|
profile: PaddleRouteProfile,
|
||||||
|
signal: AbortSignal,
|
||||||
|
newId: () => string
|
||||||
|
): Promise<{ points: T[] } | { route: PaddleTourRoute }> {
|
||||||
|
const pruned = pruneAutoPoints(points);
|
||||||
|
if (pruned.length !== points.length) return { points: pruned };
|
||||||
|
const route = await routePaddleTour(points, tripId, profile, signal);
|
||||||
|
// Points are only ever added: the same count means nothing new was found.
|
||||||
|
const materialized = materializePaddleLegs(points, route, newId);
|
||||||
|
return materialized.length !== points.length ? { points: materialized } : { route };
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// FE-ROUTE-LEG-AT-001 to FE-ROUTE-LEG-AT-003: which leg a click on the drawn route lands in (P2A-03).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { legStartAt } from './routeLegAt';
|
||||||
|
|
||||||
|
const A = { id: 'a', lat: 49.0, lng: 12.0 };
|
||||||
|
const B = { id: 'b', lat: 49.0, lng: 12.1 };
|
||||||
|
const C = { id: 'c', lat: 49.1, lng: 12.1 };
|
||||||
|
// The route bends between the points, as a river does.
|
||||||
|
const LINE: [number, number][] = [
|
||||||
|
[49.0, 12.0],
|
||||||
|
[49.01, 12.05],
|
||||||
|
[49.0, 12.1],
|
||||||
|
[49.05, 12.11],
|
||||||
|
[49.1, 12.1],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('legStartAt', () => {
|
||||||
|
it('FE-ROUTE-LEG-AT-001: a spot on the line belongs to the leg whose two points enclose it', () => {
|
||||||
|
expect(legStartAt([A, B, C], LINE, 49.009, 12.05)).toBe('a');
|
||||||
|
expect(legStartAt([A, B, C], LINE, 49.05, 12.11)).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ROUTE-LEG-AT-002: a loop keeps its order, so a spot near the start on the way back is the last leg', () => {
|
||||||
|
const back = { id: 'back', lat: A.lat, lng: A.lng };
|
||||||
|
const loop: [number, number][] = [...LINE, [49.05, 12.02], [49.0, 12.0]];
|
||||||
|
expect(legStartAt([A, B, C, back], loop, 49.05, 12.02)).toBe('c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-ROUTE-LEG-AT-003: without a line or a leg there is nothing to shape', () => {
|
||||||
|
expect(legStartAt([A, B], null, 49, 12.05)).toBeNull();
|
||||||
|
expect(legStartAt([A], LINE, 49, 12.05)).toBeNull();
|
||||||
|
expect(legStartAt([A, B], [[49, 12]], 49, 12.05)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* P2A-03: which leg of a tour a spot on its drawn route belongs to. Every
|
||||||
|
* waypoint is placed along the line (searching on from where the one before
|
||||||
|
* lay, so a loop or a route that passes a point twice keeps its order), then
|
||||||
|
* the spot; the leg is the one whose two points enclose it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Where a spot falls along a line, as the distance from its start in the line's own degrees (flat earth is plenty for ordering). */
|
||||||
|
function alongLine(
|
||||||
|
line: readonly [number, number][],
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
fromIndex: number
|
||||||
|
): { at: number; index: number } {
|
||||||
|
const cos = Math.cos((lat * Math.PI) / 180);
|
||||||
|
let travelled = 0;
|
||||||
|
let best = { at: 0, index: fromIndex, distance: Infinity };
|
||||||
|
for (let i = 0; i < line.length - 1; i += 1) {
|
||||||
|
const [aLat, aLng] = line[i];
|
||||||
|
const [bLat, bLng] = line[i + 1];
|
||||||
|
const dx = (bLng - aLng) * cos;
|
||||||
|
const dy = bLat - aLat;
|
||||||
|
const length = Math.hypot(dx, dy);
|
||||||
|
if (i >= fromIndex) {
|
||||||
|
const t =
|
||||||
|
length === 0 ? 0 : Math.max(0, Math.min(1, ((lng - aLng) * cos * dx + (lat - aLat) * dy) / (length * length)));
|
||||||
|
const distance = Math.hypot(aLng * cos + dx * t - lng * cos, aLat + dy * t - lat);
|
||||||
|
if (distance < best.distance) best = { at: travelled + length * t, index: i, distance };
|
||||||
|
}
|
||||||
|
travelled += length;
|
||||||
|
}
|
||||||
|
return { at: best.at, index: best.index };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id of the waypoint that starts the leg holding the spot, or null when the route has no such leg. */
|
||||||
|
export function legStartAt(
|
||||||
|
points: ReadonlyArray<{ id: string; lat: number; lng: number }>,
|
||||||
|
line: readonly [number, number][] | null,
|
||||||
|
lat: number,
|
||||||
|
lng: number
|
||||||
|
): string | null {
|
||||||
|
if (!line || line.length < 2 || points.length < 2) return null;
|
||||||
|
const positions: number[] = [];
|
||||||
|
let from = 0;
|
||||||
|
for (const point of points) {
|
||||||
|
const placed = alongLine(line, point.lat, point.lng, from);
|
||||||
|
positions.push(placed.at);
|
||||||
|
from = placed.index;
|
||||||
|
}
|
||||||
|
const spot = alongLine(line, lat, lng, 0).at;
|
||||||
|
for (let i = 0; i < points.length - 1; i += 1) {
|
||||||
|
if (spot >= positions[i] && spot <= positions[i + 1]) return points[i].id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
type PlannableTourType,
|
type PlannableTourType,
|
||||||
type TourMaxHikingDifficulty,
|
type TourMaxHikingDifficulty,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
|
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||||
import type { PaddleFieldValues } from './usePaddleFields';
|
import type { PaddleFieldValues } from './usePaddleFields';
|
||||||
import type { TourPlannerWaypoint } from './useTourPlanner';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tour planner's draft as it is kept in local storage between visits, and
|
* The tour planner's draft as it is kept in local storage between visits, and
|
||||||
@@ -15,7 +15,7 @@ import type { TourPlannerWaypoint } from './useTourPlanner';
|
|||||||
* half read.
|
* half read.
|
||||||
*/
|
*/
|
||||||
export interface DraftPayload {
|
export interface DraftPayload {
|
||||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
@@ -30,7 +30,7 @@ export interface DraftPayload {
|
|||||||
paddle?: PaddleFieldValues;
|
paddle?: PaddleFieldValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8];
|
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
function optionalString(value: unknown): boolean {
|
function optionalString(value: unknown): boolean {
|
||||||
return value === undefined || typeof value === 'string';
|
return value === undefined || typeof value === 'string';
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizePlannerWaypoints,
|
||||||
|
sameWaypointSnapshot,
|
||||||
|
tourWaypointKey,
|
||||||
|
withDrawnLegsRouted,
|
||||||
|
withShaping,
|
||||||
|
withShapingPoint,
|
||||||
|
type TourPlannerWaypoint,
|
||||||
|
} from './tourPlannerWaypoints';
|
||||||
|
|
||||||
|
const A: TourPlannerWaypoint = { id: 'a', lat: 49, lng: 12, role: 'start' };
|
||||||
|
const B: TourPlannerWaypoint = { id: 'b', lat: 49.01, lng: 12.01, role: 'via' };
|
||||||
|
const C: TourPlannerWaypoint = { id: 'c', lat: 49.02, lng: 12.02, role: 'end' };
|
||||||
|
|
||||||
|
describe('tourPlannerWaypoints', () => {
|
||||||
|
it('FE-TOUR-WAYPOINTS-001: a shaping point goes into the leg after the point named, and nowhere past the end', () => {
|
||||||
|
const shaped = withShapingPoint([A, B, C], 'b', { id: 's', lat: 49.015, lng: 12.015 });
|
||||||
|
expect(shaped.map((point) => point.id)).toEqual(['a', 'b', 's', 'c']);
|
||||||
|
expect(shaped[2]).toEqual({ id: 's', lat: 49.015, lng: 12.015, role: 'via', shaping: true });
|
||||||
|
const points = [A, B, C];
|
||||||
|
expect(withShapingPoint(points, 'c', { id: 's', lat: 0, lng: 0 })).toBe(points);
|
||||||
|
expect(withShapingPoint(points, 'missing', { id: 's', lat: 0, lng: 0 })).toBe(points);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINTS-002: a stop becomes a shaping point and back; the ends always stay stops', () => {
|
||||||
|
const shaped = withShaping([A, B, C], 'b', true);
|
||||||
|
expect(shaped[1]?.shaping).toBe(true);
|
||||||
|
expect(withShaping(shaped, 'b', false)[1]).not.toHaveProperty('shaping');
|
||||||
|
expect(withShaping([A, B, C], 'a', true)[0]).toBe(A);
|
||||||
|
expect(withShaping([A, B, C], 'c', true)[2]).toBe(C);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINTS-003: every drawn leg goes back to the router; a portage stays', () => {
|
||||||
|
const free = {
|
||||||
|
kind: 'free' as const,
|
||||||
|
geometry: [
|
||||||
|
[49, 12],
|
||||||
|
[49.01, 12.01],
|
||||||
|
] as [number, number][],
|
||||||
|
};
|
||||||
|
const carried = {
|
||||||
|
kind: 'portage' as const,
|
||||||
|
geometry: [
|
||||||
|
[49.01, 12.01],
|
||||||
|
[49.02, 12.02],
|
||||||
|
] as [number, number][],
|
||||||
|
extra_minutes: 5,
|
||||||
|
};
|
||||||
|
const routed = withDrawnLegsRouted([{ ...A, fixedLeg: free }, { ...B, fixedLeg: carried }, C]);
|
||||||
|
expect(routed[0]).not.toHaveProperty('fixedLeg');
|
||||||
|
expect(routed[1]?.fixedLeg).toEqual(carried);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINTS-004: the key changes when a point shapes instead of stops, or a line gains a bend', () => {
|
||||||
|
const key = tourWaypointKey([A, B, C]);
|
||||||
|
expect(tourWaypointKey(withShaping([A, B, C], 'b', true))).not.toBe(key);
|
||||||
|
const two = {
|
||||||
|
kind: 'free' as const,
|
||||||
|
geometry: [
|
||||||
|
[49, 12],
|
||||||
|
[49.01, 12.01],
|
||||||
|
] as [number, number][],
|
||||||
|
};
|
||||||
|
const three = {
|
||||||
|
kind: 'free' as const,
|
||||||
|
geometry: [
|
||||||
|
[49, 12],
|
||||||
|
[49.005, 12.0],
|
||||||
|
[49.01, 12.01],
|
||||||
|
] as [number, number][],
|
||||||
|
};
|
||||||
|
expect(tourWaypointKey([{ ...A, fixedLeg: two }, B])).not.toBe(tourWaypointKey([{ ...A, fixedLeg: three }, B]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-WAYPOINTS-005: a point the router set is part of the snapshot, and roles follow the order', () => {
|
||||||
|
const auto = { from: { id: 'a', lat: 49, lng: 12 }, to: { id: 'c', lat: 49.02, lng: 12.02 } };
|
||||||
|
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, auto }, C])).toBe(false);
|
||||||
|
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
|
||||||
|
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
|
||||||
|
import type { AutoBase } from './paddleRouting';
|
||||||
|
import { keepJoinedFixedLegs } from './tourFixedLegs';
|
||||||
|
import { alignFreeLegs } from './tourRouteActions';
|
||||||
|
|
||||||
|
/** A control point of a planned tour, as the editor holds it. */
|
||||||
|
export interface TourPlannerWaypoint {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
role: TourWaypointRole;
|
||||||
|
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
|
||||||
|
fixedLeg?: TourFixedLeg | null;
|
||||||
|
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
|
||||||
|
lock?: PaddleLock | null;
|
||||||
|
/** P2A-03: a shaping point, there only to steer the route; no stop. */
|
||||||
|
shaping?: boolean;
|
||||||
|
/** PAD-11: set where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||||
|
auto?: AutoBase;
|
||||||
|
/** PAD-03: at the last point, the boat is carried on to the next point once it is set; never saved. */
|
||||||
|
carryNext?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
|
||||||
|
export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () => string): TourPlannerWaypoint[] {
|
||||||
|
return saved.map((point) => ({
|
||||||
|
id: newId(),
|
||||||
|
lat: point.lat,
|
||||||
|
lng: point.lng,
|
||||||
|
role: point.role,
|
||||||
|
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
||||||
|
...(point.lock ? { lock: point.lock } : {}),
|
||||||
|
...(point.shaping ? { shaping: true } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's waypoints as they are saved, in their order: what the server
|
||||||
|
* keeps and nothing of the editor's own (ids, automatic marks, a portage
|
||||||
|
* waiting for its next point).
|
||||||
|
*/
|
||||||
|
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
|
||||||
|
return points.map((point, sequence) => ({
|
||||||
|
lat: point.lat,
|
||||||
|
lng: point.lng,
|
||||||
|
role: point.role,
|
||||||
|
sequence,
|
||||||
|
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
||||||
|
...(point.lock ? { lock: point.lock } : {}),
|
||||||
|
...(point.shaping ? { shaping: true } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
|
||||||
|
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
|
||||||
|
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
|
||||||
|
...point,
|
||||||
|
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
||||||
|
if (!leg) return '';
|
||||||
|
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}:${leg.geometry.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the route depends on: every point, where it is, the fixed line after it and whether it only shapes. */
|
||||||
|
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||||
|
return points
|
||||||
|
.map(
|
||||||
|
(point) =>
|
||||||
|
`${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}${point.shaping ? ':shape' : ''}`
|
||||||
|
)
|
||||||
|
.join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock, a point the router set. */
|
||||||
|
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||||
|
const extras = (points: TourPlannerWaypoint[]) =>
|
||||||
|
JSON.stringify(
|
||||||
|
points.map((point) => [
|
||||||
|
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
|
||||||
|
point.lock ?? null,
|
||||||
|
!!point.auto,
|
||||||
|
!!point.carryNext,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
|
||||||
|
export function withShapingPoint(
|
||||||
|
points: TourPlannerWaypoint[],
|
||||||
|
afterId: string,
|
||||||
|
shape: Pick<TourPlannerWaypoint, 'id' | 'lat' | 'lng'>
|
||||||
|
): TourPlannerWaypoint[] {
|
||||||
|
const index = points.findIndex((point) => point.id === afterId);
|
||||||
|
if (index < 0 || index >= points.length - 1) return points;
|
||||||
|
return [...points.slice(0, index + 1), { ...shape, role: 'via', shaping: true }, ...points.slice(index + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P2A-03: a stop made a shaping point, or a shaping point a stop. The ends of a tour are always stops. */
|
||||||
|
export function withShaping(points: TourPlannerWaypoint[], id: string, shaping: boolean): TourPlannerWaypoint[] {
|
||||||
|
return points.map((point, index) => {
|
||||||
|
if (point.id !== id || index === 0 || index === points.length - 1) return point;
|
||||||
|
if (shaping) return { ...point, shaping: true };
|
||||||
|
const { shaping: _dropped, ...stop } = point;
|
||||||
|
return stop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PAD-11: every leg drawn by hand handed back to the router. */
|
||||||
|
export function withDrawnLegsRouted(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
|
||||||
|
return points.map((point) => {
|
||||||
|
if (point.fixedLeg?.kind !== 'free') return point;
|
||||||
|
const { fixedLeg: _drawn, ...routed } = point;
|
||||||
|
return routed;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -108,10 +108,25 @@ describe('free legs (P2A-04)', () => {
|
|||||||
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('draws every leg by hand for a paddle tour', () => {
|
it('routes a paddle tour on the water: a via ferrata goes back to the router, a leg drawn by hand follows its points', () => {
|
||||||
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
|
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
|
||||||
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual([undefined, undefined, undefined]);
|
||||||
expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C));
|
const drawn = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, C) }, B, C], true);
|
||||||
|
expect(drawn[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the shape of a drawn paddle leg while its ends still meet their points, and redraws it once one moves', () => {
|
||||||
|
const shaped = {
|
||||||
|
kind: 'free' as const,
|
||||||
|
geometry: [
|
||||||
|
[A.lat, A.lng],
|
||||||
|
[A.lat + 0.001, A.lng + 0.002],
|
||||||
|
[B.lat, B.lng],
|
||||||
|
] as [number, number][],
|
||||||
|
};
|
||||||
|
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, B], true)[0].fixedLeg).toEqual(shaped);
|
||||||
|
const moved = { ...B, lat: B.lat + 0.01 };
|
||||||
|
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, moved, C], true)[0].fixedLeg).toEqual(freeLegBetween(A, moved));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +137,31 @@ describe('portages and locks (PAD-03, PAD-07)', () => {
|
|||||||
const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a');
|
const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a');
|
||||||
expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B));
|
expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B));
|
||||||
expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
||||||
expect(togglePortageLeg(carried, 'c')).toEqual(carried);
|
});
|
||||||
|
|
||||||
|
it('asks for a portage at the last point and draws it to the point set next', () => {
|
||||||
|
const asked = togglePortageLeg(alignFreeLegs([A, B], true), 'b');
|
||||||
|
expect(asked[1]).toMatchObject({ carryNext: true });
|
||||||
|
// Redrawn with no point after it, the ask waits.
|
||||||
|
expect(alignFreeLegs(asked, true)[1]).toMatchObject({ carryNext: true });
|
||||||
|
const placed = alignFreeLegs([...asked, C], true);
|
||||||
|
expect(placed[1].fixedLeg).toEqual(portageLegBetween(B, C));
|
||||||
|
expect(placed[1]).not.toHaveProperty('carryNext');
|
||||||
|
// A second press takes the ask back, and a hike forgets it.
|
||||||
|
expect(togglePortageLeg(asked, 'b')[1]).not.toHaveProperty('carryNext');
|
||||||
|
expect(alignFreeLegs([...asked, C])[1]).not.toHaveProperty('carryNext');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a portage that follows a way while its ends stay, and draws it straight once one moves', () => {
|
||||||
|
const geometry: [number, number][] = [
|
||||||
|
[A.lat, A.lng],
|
||||||
|
[47.004, 11.006],
|
||||||
|
[B.lat, B.lng],
|
||||||
|
];
|
||||||
|
const points: RoutePoint[] = [{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry } }, B, C];
|
||||||
|
expect(alignFreeLegs(points, true)[0].fixedLeg).toEqual(points[0].fixedLeg);
|
||||||
|
const moved = alignFreeLegs([points[0], { ...B, lat: 47.012 }, C], true);
|
||||||
|
expect(moved[0].fixedLeg).toEqual(portageLegBetween(A, { lat: 47.012, lng: B.lng }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => {
|
it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export interface RoutePoint {
|
|||||||
lng: number;
|
lng: number;
|
||||||
fixedLeg?: TourFixedLeg | null;
|
fixedLeg?: TourFixedLeg | null;
|
||||||
lock?: PaddleLock | null;
|
lock?: PaddleLock | null;
|
||||||
|
/**
|
||||||
|
* PAD-03: the boat is carried on from this point, the last of the tour so
|
||||||
|
* far; the portage is drawn to the next point once it is set. The planner's
|
||||||
|
* own, never saved.
|
||||||
|
*/
|
||||||
|
carryNext?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
|
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
|
||||||
@@ -114,25 +120,46 @@ function extrasOnFirstLeg<T extends RoutePoint>(points: T[]): T[] {
|
|||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A line still joins its points when its ends lie this close to them, in degrees (about two metres). */
|
||||||
|
const JOINS_DEG = 2e-5;
|
||||||
|
|
||||||
|
function stillJoins(leg: TourFixedLeg, from: { lat: number; lng: number }, to: { lat: number; lng: number }): boolean {
|
||||||
|
const first = leg.geometry[0];
|
||||||
|
const last = leg.geometry[leg.geometry.length - 1];
|
||||||
|
const near = (end: readonly unknown[] | undefined, point: { lat: number; lng: number }) =>
|
||||||
|
!!end && Math.abs(Number(end[0]) - point.lat) < JOINS_DEG && Math.abs(Number(end[1]) - point.lng) < JOINS_DEG;
|
||||||
|
return near(first, from) && near(last, to);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every hand-drawn leg, and every portage, redrawn straight to the waypoint
|
* Every hand-drawn leg redrawn straight to the waypoint that now follows it,
|
||||||
* that now follows it, so moving a point drags them along instead of tearing
|
* so moving a point drags it along instead of tearing it off; a leg on the
|
||||||
* them off. A leg on the last point leads nowhere and goes, and so does a lock
|
* last point leads nowhere and goes. On a hike a portage and a lock are a
|
||||||
* nobody paddles to on a tour that is no paddle tour. With `allFree` every
|
* paddler's and go. On a paddle tour (`paddle`) a via ferrata is a hiker's
|
||||||
* leg that is not a portage becomes hand-drawn, the way a paddle tour is planned.
|
* and goes back to the router; a portage and a lock stay, a portage is
|
||||||
|
* dragged along like a drawn leg, and a portage or drawn leg whose line has
|
||||||
|
* a shape of its own (a portage way the router followed) keeps it while its
|
||||||
|
* ends still meet their points. A leg without a line stays routed. A portage
|
||||||
|
* asked for at the last point of a paddle tour becomes one once a point follows.
|
||||||
*/
|
*/
|
||||||
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] {
|
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle = false): T[] {
|
||||||
const aligned = points.map((point, index) => {
|
const aligned = points.map((current, index) => {
|
||||||
const next = points[index + 1];
|
const next = points[index + 1];
|
||||||
const leg = point.fixedLeg;
|
const { carryNext, ...own } = current;
|
||||||
const kept = allFree ? point : dropPaddleOnly(point);
|
if (carryNext && paddle) {
|
||||||
const free = allFree || leg?.kind === 'free';
|
if (!next) return current;
|
||||||
if (!free && !(allFree && isPortage(leg))) return kept;
|
return { ...own, fixedLeg: portageLegBetween(current, next) } as T;
|
||||||
|
}
|
||||||
|
const point = (carryNext ? own : current) as T;
|
||||||
|
const kept = paddle ? dropHikeOnly(point) : dropPaddleOnly(point);
|
||||||
|
const leg = kept.fixedLeg;
|
||||||
|
const follows = leg?.kind === 'free' || (paddle && isPortage(leg));
|
||||||
|
if (!follows || !leg) return kept;
|
||||||
if (!next) {
|
if (!next) {
|
||||||
if (!kept.fixedLeg) return kept;
|
|
||||||
const { fixedLeg: _dropped, ...rest } = kept;
|
const { fixedLeg: _dropped, ...rest } = kept;
|
||||||
return rest as T;
|
return rest as T;
|
||||||
}
|
}
|
||||||
|
if (paddle && leg.geometry.length > 2 && stillJoins(leg, point, next)) return kept;
|
||||||
return {
|
return {
|
||||||
...kept,
|
...kept,
|
||||||
fixedLeg: isPortage(leg)
|
fixedLeg: isPortage(leg)
|
||||||
@@ -140,7 +167,14 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFre
|
|||||||
: freeLegBetween(point, next),
|
: freeLegBetween(point, next),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return allFree ? extrasOnFirstLeg(aligned) : aligned;
|
return paddle ? extrasOnFirstLeg(aligned) : aligned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A boat climbs no via ferrata: its leg goes back to the router, on the water. */
|
||||||
|
function dropHikeOnly<T extends RoutePoint>(point: T): T {
|
||||||
|
if (point.fixedLeg?.kind !== 'ferrata') return point;
|
||||||
|
const { fixedLeg: _ferrata, ...routed } = point;
|
||||||
|
return routed as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */
|
/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */
|
||||||
@@ -151,13 +185,21 @@ function dropPaddleOnly<T extends RoutePoint>(point: T): T {
|
|||||||
return plain as T;
|
return plain as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PAD-03: the leg after this point carried as a portage, or paddled again. */
|
/**
|
||||||
|
* PAD-03: the leg after this point carried as a portage, or paddled again. At
|
||||||
|
* the last point there is no leg yet: the portage waits for the next point.
|
||||||
|
*/
|
||||||
export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
||||||
const index = points.findIndex((point) => point.id === id);
|
const index = points.findIndex((point) => point.id === id);
|
||||||
const point = points[index];
|
const point = points[index];
|
||||||
const next = points[index + 1];
|
const next = points[index + 1];
|
||||||
if (!point || !next) return [...points];
|
if (!point) return [...points];
|
||||||
const copy = [...points];
|
const copy = [...points];
|
||||||
|
if (!next) {
|
||||||
|
const { carryNext, ...own } = point;
|
||||||
|
copy[index] = (carryNext ? own : { ...point, carryNext: true }) as T;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
copy[index] = {
|
copy[index] = {
|
||||||
...point,
|
...point,
|
||||||
fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next),
|
fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next),
|
||||||
@@ -189,14 +231,15 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The leg after this waypoint drawn by hand, or routed again. A via ferrata
|
* The leg after this waypoint drawn by hand, or routed again (on a paddle
|
||||||
* keeps its line; the last point has no leg to change.
|
* tour: on the water). A via ferrata and a portage keep their line; the last
|
||||||
|
* point has no leg to change.
|
||||||
*/
|
*/
|
||||||
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
||||||
const index = points.findIndex((point) => point.id === id);
|
const index = points.findIndex((point) => point.id === id);
|
||||||
const point = points[index];
|
const point = points[index];
|
||||||
const next = points[index + 1];
|
const next = points[index + 1];
|
||||||
if (!point || !next || point.fixedLeg?.kind === 'ferrata') return [...points];
|
if (!point || !next || point.fixedLeg?.kind === 'ferrata' || isPortage(point.fixedLeg)) return [...points];
|
||||||
const copy = [...points];
|
const copy = [...points];
|
||||||
if (point.fixedLeg?.kind === 'free') {
|
if (point.fixedLeg?.kind === 'free') {
|
||||||
const { fixedLeg: _dropped, ...rest } = point;
|
const { fixedLeg: _dropped, ...rest } = point;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-003: how the planner map draws the parts of a tour (PAD-03, PAD-11).
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { tourRouteStyle } from './tourRouteStyle';
|
||||||
|
import type { TourRoutePart } from './tourRouting';
|
||||||
|
|
||||||
|
const PAINT = { portage: 'brown', rapids: 'amber', gap: 'red', casing: 'white' };
|
||||||
|
const line: [number, number][] = [
|
||||||
|
[49, 12],
|
||||||
|
[49.01, 12.01],
|
||||||
|
];
|
||||||
|
const part = (extra: Partial<TourRoutePart> = {}): TourRoutePart => ({ coordinates: line, free: false, ...extra });
|
||||||
|
|
||||||
|
describe('tourRouteStyle', () => {
|
||||||
|
it('FE-TOUR-ROUTE-STYLE-001: a routed tour is one plain line, and no parts draw nothing', () => {
|
||||||
|
expect(tourRouteStyle([part(), part()], true, PAINT)).toEqual({ walking: null, colors: null });
|
||||||
|
expect(tourRouteStyle(null, true, PAINT)).toEqual({ walking: null, colors: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-ROUTE-STYLE-002: a paddle tour dots and colours what no router planned and what the paddler should see', () => {
|
||||||
|
const style = tourRouteStyle(
|
||||||
|
[
|
||||||
|
part(),
|
||||||
|
part({ land: true }),
|
||||||
|
part({ mark: 'rapids' }),
|
||||||
|
part({ mark: 'gap' }),
|
||||||
|
part({ mark: 'unrouted' }),
|
||||||
|
part({ free: true }),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
PAINT
|
||||||
|
);
|
||||||
|
expect(style.walking).toEqual([false, true, false, true, true, true]);
|
||||||
|
expect(style.colors).toEqual([
|
||||||
|
undefined,
|
||||||
|
{ line: 'brown', casing: 'white' },
|
||||||
|
{ line: 'amber', casing: 'white' },
|
||||||
|
{ line: 'red', casing: 'white' },
|
||||||
|
{ line: 'red', casing: 'white' },
|
||||||
|
undefined,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TOUR-ROUTE-STYLE-003: a hike dots its hand-drawn stretches and keeps its own colour', () => {
|
||||||
|
const style = tourRouteStyle([part(), part({ free: true })], false, PAINT);
|
||||||
|
expect(style).toEqual({ walking: [false, true], colors: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,47 @@
|
|||||||
import type { TourRoutePart } from './tourRouting';
|
import type { TourRoutePart } from './tourRouting';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-03: a portage on the map, in an earth brown over a rim in the map's own
|
* PAD-03, PAD-11: the paddle paints of the planner map, resolved from the
|
||||||
* white, dotted the way the map draws a walk: the boat is carried there, not
|
* paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and
|
||||||
* paddled. The colours come resolved from the paddle tokens (`usePaddlePaint`).
|
* boat passes in amber, a gap in the data or a leg no route was found for in
|
||||||
|
* red, each over a rim in the map's own white.
|
||||||
*/
|
*/
|
||||||
export interface PortagePaint {
|
export interface RoutePaint {
|
||||||
line: string;
|
portage: string;
|
||||||
|
rapids: string;
|
||||||
|
gap: string;
|
||||||
casing: string;
|
casing: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TourRouteStyle {
|
export interface TourRouteStyle {
|
||||||
/** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */
|
/** Per line: dotted (a stretch nobody routed: drawn by hand, carried, or a gap) or solid. */
|
||||||
walking: boolean[] | null;
|
walking: boolean[] | null;
|
||||||
/** Per line: the portage brown, or the route's own colour where undefined. */
|
/** Per line: a paddle paint, or the route's own colour where undefined. */
|
||||||
colors: ({ line: string; casing: string } | undefined)[] | null;
|
colors: ({ line: string; casing: string } | undefined)[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paddleColor(part: TourRoutePart, paint: RoutePaint): { line: string; casing: string } | undefined {
|
||||||
|
if (part.land) return { line: paint.portage, casing: paint.casing };
|
||||||
|
if (part.mark === 'rapids') return { line: paint.rapids, casing: paint.casing };
|
||||||
|
if (part.mark === 'gap' || part.mark === 'unrouted') return { line: paint.gap, casing: paint.casing };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How the planner map draws the parts of a tour being edited. A hike draws
|
* How the planner map draws the parts of a tour being edited. What no router
|
||||||
* what no router planned dotted, so a routed path and a hand-drawn one never
|
* planned is dotted, so a routed path and a hand-drawn one never look alike:
|
||||||
* look alike. A paddle tour is drawn by hand throughout, so its water is the
|
* a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs
|
||||||
* plain line and only its portages stand out, dotted and brown.
|
* and stretches drawn by hand. A paddle tour also colours what the paddler
|
||||||
|
* should look at.
|
||||||
*/
|
*/
|
||||||
export function tourRouteStyle(
|
export function tourRouteStyle(
|
||||||
parts: readonly TourRoutePart[] | null,
|
parts: readonly TourRoutePart[] | null,
|
||||||
isPaddle: boolean,
|
isPaddle: boolean,
|
||||||
portage: PortagePaint
|
paint: RoutePaint
|
||||||
): TourRouteStyle {
|
): TourRouteStyle {
|
||||||
if (!parts?.length) return { walking: null, colors: null };
|
if (!parts?.length) return { walking: null, colors: null };
|
||||||
const walking = parts.map((part) => (isPaddle ? !!part.land : part.free));
|
const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted');
|
||||||
const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : undefined));
|
const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined));
|
||||||
return {
|
return {
|
||||||
walking: walking.some(Boolean) ? walking : null,
|
walking: walking.some(Boolean) ? walking : null,
|
||||||
colors: colors.some(Boolean) ? colors : null,
|
colors: colors.some(Boolean) ? colors : null,
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export interface TourRoutePart {
|
|||||||
free: boolean
|
free: boolean
|
||||||
/** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */
|
/** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */
|
||||||
land?: boolean
|
land?: boolean
|
||||||
|
/**
|
||||||
|
* PAD-11: on a routed paddle tour, a stretch the paddler should look at:
|
||||||
|
* whitewater or a boat pass, a gap in the data the router bridged, or a leg
|
||||||
|
* it could not plan (drawn straight, and nothing checked it).
|
||||||
|
*/
|
||||||
|
mark?: 'rapids' | 'gap' | 'unrouted'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TourRouteResult {
|
export interface TourRouteResult {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
recommendedPaddleSpeedKmh,
|
recommendedPaddleSpeedKmh,
|
||||||
type PaddleBoatType,
|
type PaddleBoatType,
|
||||||
|
type PaddleLakeStyle,
|
||||||
type PaddleWaterContext,
|
type PaddleWaterContext,
|
||||||
type TourListItem,
|
type TourListItem,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
@@ -13,6 +14,8 @@ export interface PaddleFieldValues {
|
|||||||
speedKmh: number | null;
|
speedKmh: number | null;
|
||||||
/** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */
|
/** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */
|
||||||
portageSpeedKmh?: number | null;
|
portageSpeedKmh?: number | null;
|
||||||
|
/** PAD-11: how lakes are routed; null (or absent, in an older draft) is straight over. */
|
||||||
|
lakeStyle?: PaddleLakeStyle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
||||||
@@ -20,17 +23,22 @@ export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
|||||||
waterContext: null,
|
waterContext: null,
|
||||||
speedKmh: null,
|
speedKmh: null,
|
||||||
portageSpeedKmh: null,
|
portageSpeedKmh: null,
|
||||||
|
lakeStyle: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
|
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
|
||||||
export function paddleFieldsOf(
|
export function paddleFieldsOf(
|
||||||
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'>
|
tour: Pick<
|
||||||
|
TourListItem,
|
||||||
|
'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh' | 'paddle_lake_style'
|
||||||
|
>
|
||||||
): PaddleFieldValues {
|
): PaddleFieldValues {
|
||||||
return {
|
return {
|
||||||
boatType: tour.boat_type ?? null,
|
boatType: tour.boat_type ?? null,
|
||||||
waterContext: tour.water_context ?? null,
|
waterContext: tour.water_context ?? null,
|
||||||
speedKmh: tour.paddle_speed_kmh ?? null,
|
speedKmh: tour.paddle_speed_kmh ?? null,
|
||||||
portageSpeedKmh: tour.portage_speed_kmh ?? null,
|
portageSpeedKmh: tour.portage_speed_kmh ?? null,
|
||||||
|
lakeStyle: tour.paddle_lake_style ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +59,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
|||||||
merged.boatType === values.boatType &&
|
merged.boatType === values.boatType &&
|
||||||
merged.waterContext === values.waterContext &&
|
merged.waterContext === values.waterContext &&
|
||||||
Object.is(merged.speedKmh, values.speedKmh) &&
|
Object.is(merged.speedKmh, values.speedKmh) &&
|
||||||
Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null);
|
Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null) &&
|
||||||
|
(merged.lakeStyle ?? null) === (values.lakeStyle ?? null);
|
||||||
if (!same && onEdit()) setValues(merged);
|
if (!same && onEdit()) setValues(merged);
|
||||||
},
|
},
|
||||||
[onEdit, values]
|
[onEdit, values]
|
||||||
@@ -61,6 +70,7 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
|||||||
return {
|
return {
|
||||||
...values,
|
...values,
|
||||||
portageSpeedKmh: values.portageSpeedKmh ?? null,
|
portageSpeedKmh: values.portageSpeedKmh ?? null,
|
||||||
|
lakeStyle: values.lakeStyle ?? 'direct',
|
||||||
recommendedSpeedKmh: recommended,
|
recommendedSpeedKmh: recommended,
|
||||||
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
|
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
|
||||||
effectiveSpeedKmh: values.speedKmh ?? recommended,
|
effectiveSpeedKmh: values.speedKmh ?? recommended,
|
||||||
@@ -69,6 +79,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
|||||||
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
|
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
|
||||||
/** PAD-07: the carrying speed, or back to the starting value with null. */
|
/** PAD-07: the carrying speed, or back to the starting value with null. */
|
||||||
setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]),
|
setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]),
|
||||||
|
/** PAD-11: straight over the lakes, or along their shores. */
|
||||||
|
setLakeStyle: useCallback((lakeStyle: PaddleLakeStyle) => edit({ lakeStyle }), [edit]),
|
||||||
/** Back to the boat's suggestion, by choice. */
|
/** Back to the boat's suggestion, by choice. */
|
||||||
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
|
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
|
||||||
/** Loaded from a tour or a draft, or cleared; not an edit. */
|
/** Loaded from a tour or a draft, or cleared; not an edit. */
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useCallback, type RefObject } from 'react';
|
||||||
|
import { randomId } from '../../../utils/randomId';
|
||||||
|
import { withDrawnLegsRouted, withShaping, withShapingPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||||
|
|
||||||
|
type Commit = (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2A-03, P2A-05 and PAD-11: the editor's tools for the line between the
|
||||||
|
* stops. A shaping point steers the route without being a stop, a routed leg
|
||||||
|
* can be planned afresh, and a paddle tour's legs drawn by hand go back to the
|
||||||
|
* router in one step. Each change is one step of the history, through the
|
||||||
|
* planner's own commit; nothing here plans a route itself.
|
||||||
|
*/
|
||||||
|
export function useRouteShaping({
|
||||||
|
commit,
|
||||||
|
waypointsRef,
|
||||||
|
mayEdit,
|
||||||
|
isPaddle,
|
||||||
|
retry,
|
||||||
|
forgetLeg,
|
||||||
|
}: {
|
||||||
|
commit: Commit;
|
||||||
|
waypointsRef: RefObject<TourPlannerWaypoint[]>;
|
||||||
|
/** Whether the route may change now: the permission held and no save under way. */
|
||||||
|
mayEdit: () => boolean;
|
||||||
|
isPaddle: () => boolean;
|
||||||
|
/** Plans the route again as it stands. */
|
||||||
|
retry: () => void;
|
||||||
|
/** Drops the answer kept for a paddle leg, so the router is asked again. */
|
||||||
|
forgetLeg: (from: TourPlannerWaypoint, to: TourPlannerWaypoint) => void;
|
||||||
|
}) {
|
||||||
|
/** A shaping point into the leg after `afterId`; its id, or null when there is no such leg. */
|
||||||
|
const insertShapingPoint = useCallback(
|
||||||
|
(afterId: string, lat: number, lng: number) => {
|
||||||
|
if (!mayEdit() || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||||
|
const points = waypointsRef.current;
|
||||||
|
const shape = { id: randomId(), lat, lng };
|
||||||
|
if (withShapingPoint(points, afterId, shape) === points) return null;
|
||||||
|
commit((current) => withShapingPoint(current, afterId, shape));
|
||||||
|
return shape.id;
|
||||||
|
},
|
||||||
|
[commit, mayEdit, waypointsRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setWaypointShaping = useCallback(
|
||||||
|
(id: string, shaping: boolean) => {
|
||||||
|
if (mayEdit()) commit((current) => withShaping(current, id, shaping));
|
||||||
|
},
|
||||||
|
[commit, mayEdit]
|
||||||
|
);
|
||||||
|
|
||||||
|
const routeDrawnLegsOnWater = useCallback(() => {
|
||||||
|
if (mayEdit() && isPaddle()) commit(withDrawnLegsRouted);
|
||||||
|
}, [commit, isPaddle, mayEdit]);
|
||||||
|
|
||||||
|
/** The routed leg after this waypoint planned afresh, past any answer kept from before. */
|
||||||
|
const recomputeLeg = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const points = waypointsRef.current;
|
||||||
|
const index = points.findIndex((point) => point.id === id);
|
||||||
|
const point = points[index];
|
||||||
|
const next = points[index + 1];
|
||||||
|
if (!point || !next || point.fixedLeg) return;
|
||||||
|
if (isPaddle()) forgetLeg(point, next);
|
||||||
|
retry();
|
||||||
|
},
|
||||||
|
[forgetLeg, isPaddle, retry, waypointsRef]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { insertShapingPoint, setWaypointShaping, routeDrawnLegsOnWater, recomputeLeg };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { legStartAt } from './routeLegAt';
|
||||||
|
import type { TourPlannerController } from './useTourPlanner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* P2A-03: a click on the route being edited sets a shaping point where it
|
||||||
|
* landed, in the leg that runs there, and selects it, ready to be dragged
|
||||||
|
* where the route should go. Undefined while the points cannot be moved, or
|
||||||
|
* while a click on the line picks a spot for something else.
|
||||||
|
*/
|
||||||
|
export function useShapingClick(
|
||||||
|
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'>,
|
||||||
|
{ editable, picking }: { editable: boolean; picking: boolean }
|
||||||
|
): ((lat: number, lng: number) => void) | undefined {
|
||||||
|
const { waypoints, route, insertShapingPoint, setSelectedWaypointId } = planner;
|
||||||
|
const shapeAt = useCallback(
|
||||||
|
(lat: number, lng: number) => {
|
||||||
|
const after = legStartAt(waypoints, route, lat, lng);
|
||||||
|
if (!after) return;
|
||||||
|
const id = insertShapingPoint(after, lat, lng);
|
||||||
|
if (id) setSelectedWaypointId(id);
|
||||||
|
},
|
||||||
|
[insertShapingPoint, route, setSelectedWaypointId, waypoints]
|
||||||
|
);
|
||||||
|
return editable && !picking ? shapeAt : undefined;
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
routeWalkingTour: vi.fn(),
|
routeWalkingTour: vi.fn(),
|
||||||
enrichTourElevations: vi.fn(),
|
enrichTourElevations: vi.fn(),
|
||||||
createTour: vi.fn(),
|
createTour: vi.fn(),
|
||||||
detailTour: vi.fn(),
|
detailTour: vi.fn(),
|
||||||
updateTour: vi.fn(),
|
updateTour: vi.fn(),
|
||||||
|
paddlePlan: vi.fn(),
|
||||||
|
paddleForget: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
||||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||||
|
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
|
||||||
vi.mock('../useTourPermissions', () => ({
|
vi.mock('../useTourPermissions', () => ({
|
||||||
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
|
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -20,6 +24,12 @@ vi.mock('../useTourPermissions', () => ({
|
|||||||
|
|
||||||
import { useTourPlanner } from './useTourPlanner';
|
import { useTourPlanner } from './useTourPlanner';
|
||||||
|
|
||||||
|
/** The value, or a failure saying it is missing instead of a later undefined. */
|
||||||
|
function must<T>(value: T | null | undefined): T {
|
||||||
|
if (value === null || value === undefined) throw new Error('expected a value');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
const routed = {
|
const routed = {
|
||||||
coordinates: [
|
coordinates: [
|
||||||
[48, 11],
|
[48, 11],
|
||||||
@@ -64,6 +74,20 @@ function plannerWith(points: Array<[number, number]>) {
|
|||||||
return hook;
|
return hook;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LegAsked = { from: { lat: number; lng: number }; to: { lat: number; lng: number } };
|
||||||
|
/** The water router's answer for every leg asked: a straight stretch on the water, 1.2 km long. */
|
||||||
|
const onWater = (legs: LegAsked[]) =>
|
||||||
|
legs.map(({ from, to }) => ({
|
||||||
|
status: 'ok' as const,
|
||||||
|
geometry: [
|
||||||
|
[from.lat, from.lng],
|
||||||
|
[to.lat, to.lng],
|
||||||
|
],
|
||||||
|
segments: [{ kind: 'water' as const, from: 0, to: 1 }],
|
||||||
|
distance_m: 1200,
|
||||||
|
snapped_m: { from: 0, to: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]);
|
const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]);
|
||||||
|
|
||||||
describe('useTourPlanner route tools', () => {
|
describe('useTourPlanner route tools', () => {
|
||||||
@@ -71,6 +95,8 @@ describe('useTourPlanner route tools', () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||||
|
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
|
||||||
|
paddleForget.mockReset();
|
||||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||||
createTour.mockReset();
|
createTour.mockReset();
|
||||||
detailTour.mockReset();
|
detailTour.mockReset();
|
||||||
@@ -221,7 +247,7 @@ describe('useTourPlanner route tools', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('paddle tours (PAD-01)', () => {
|
describe('paddle tours (PAD-01)', () => {
|
||||||
it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => {
|
it('plans every leg on the water once paddle is chosen, and one undo takes the type back', async () => {
|
||||||
const { result } = plannerWith([
|
const { result } = plannerWith([
|
||||||
[48, 11],
|
[48, 11],
|
||||||
[48.01, 11.02],
|
[48.01, 11.02],
|
||||||
@@ -229,9 +255,22 @@ describe('useTourPlanner route tools', () => {
|
|||||||
]);
|
]);
|
||||||
act(() => result.current.setTourType('kayak'));
|
act(() => result.current.setTourType('kayak'));
|
||||||
expect(result.current.isPaddle).toBe(true);
|
expect(result.current.isPaddle).toBe(true);
|
||||||
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||||
|
routeWalkingTour.mockClear();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(450);
|
||||||
|
});
|
||||||
|
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||||
|
expect(paddlePlan).toHaveBeenLastCalledWith(
|
||||||
|
31,
|
||||||
|
expect.any(Array),
|
||||||
|
{ boat: 'kayak', lakeStyle: 'direct' },
|
||||||
|
expect.any(AbortSignal)
|
||||||
|
);
|
||||||
|
expect(result.current.status).toBe('ready');
|
||||||
|
expect(result.current.paddleRoute?.legLengths).toEqual([1200, 1200]);
|
||||||
act(() => result.current.addWaypoint(48.03, 11.01));
|
act(() => result.current.addWaypoint(48.03, 11.01));
|
||||||
expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free');
|
expect(result.current.waypoints[2].fixedLeg).toBeUndefined();
|
||||||
act(() => result.current.undo());
|
act(() => result.current.undo());
|
||||||
act(() => result.current.undo());
|
act(() => result.current.undo());
|
||||||
expect(result.current.tourType).toBe('hike');
|
expect(result.current.tourType).toBe('hike');
|
||||||
@@ -240,7 +279,6 @@ describe('useTourPlanner route tools', () => {
|
|||||||
|
|
||||||
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
|
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
|
||||||
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
|
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
|
||||||
routeWalkingTour.mockResolvedValue({ ...routed, parts: [{ ...routed.parts[0], free: true }] });
|
|
||||||
const { result } = plannerWith([
|
const { result } = plannerWith([
|
||||||
[48, 11],
|
[48, 11],
|
||||||
[48.01, 11.02],
|
[48.01, 11.02],
|
||||||
@@ -264,11 +302,14 @@ describe('useTourPlanner route tools', () => {
|
|||||||
boat_type: 'canoe',
|
boat_type: 'canoe',
|
||||||
water_context: 'lake',
|
water_context: 'lake',
|
||||||
paddle_speed_kmh: null,
|
paddle_speed_kmh: null,
|
||||||
|
paddle_lake_style: 'direct',
|
||||||
max_hiking_difficulty: null,
|
max_hiking_difficulty: null,
|
||||||
});
|
});
|
||||||
|
// A paddle tour carries no heights to look up.
|
||||||
|
expect(enrichTourElevations).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('times the water at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
it('times the water by the routed length at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
||||||
const { result } = plannerWith([
|
const { result } = plannerWith([
|
||||||
[48, 11],
|
[48, 11],
|
||||||
[48.01, 11.02],
|
[48.01, 11.02],
|
||||||
@@ -278,13 +319,13 @@ describe('useTourPlanner route tools', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(450);
|
await vi.advanceTimersByTimeAsync(450);
|
||||||
});
|
});
|
||||||
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
|
// 1.2 km on the water the router found, at 4 km/h.
|
||||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60));
|
expect(result.current.walkingDurationMinutes).toBe(18);
|
||||||
|
|
||||||
act(() => result.current.paddle.setSpeedKmh(6));
|
act(() => result.current.paddle.setSpeedKmh(6));
|
||||||
act(() => result.current.paddle.setBoatType('other'));
|
act(() => result.current.paddle.setBoatType('other'));
|
||||||
expect(result.current.paddle.speedKmh).toBe(6);
|
expect(result.current.paddle.speedKmh).toBe(6);
|
||||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60));
|
expect(result.current.walkingDurationMinutes).toBe(12);
|
||||||
|
|
||||||
act(() => result.current.paddle.adoptRecommendedSpeed());
|
act(() => result.current.paddle.adoptRecommendedSpeed());
|
||||||
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
||||||
@@ -343,4 +384,137 @@ describe('useTourPlanner route tools', () => {
|
|||||||
expect(old.result.current.name).toBe('Old');
|
expect(old.result.current.name).toBe('Old');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('shaping and water routing (P2A-03, P2A-05, PAD-11)', () => {
|
||||||
|
const settle = () =>
|
||||||
|
act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets a shaping point into a leg, makes it a stop and back, and never past the end', async () => {
|
||||||
|
const { result } = plannerWith([
|
||||||
|
[48, 11],
|
||||||
|
[48.02, 11.02],
|
||||||
|
]);
|
||||||
|
const [start, end] = result.current.waypoints;
|
||||||
|
let id: string | null = null;
|
||||||
|
act(() => {
|
||||||
|
id = result.current.insertShapingPoint(must(start).id, 48.01, 11.005);
|
||||||
|
});
|
||||||
|
expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]);
|
||||||
|
expect(result.current.waypoints[1]?.id).toBe(id);
|
||||||
|
act(() => {
|
||||||
|
expect(result.current.insertShapingPoint(must(end).id, 48.03, 11.03)).toBeNull();
|
||||||
|
expect(result.current.insertShapingPoint(must(start).id, Number.NaN, 11)).toBeNull();
|
||||||
|
});
|
||||||
|
act(() => result.current.setWaypointShaping(must(id), false));
|
||||||
|
expect(result.current.waypoints[1]).not.toHaveProperty('shaping');
|
||||||
|
act(() => result.current.setWaypointShaping(must(start).id, true));
|
||||||
|
expect(result.current.waypoints[0]).not.toHaveProperty('shaping');
|
||||||
|
// One undo takes back one change.
|
||||||
|
act(() => result.current.undo());
|
||||||
|
expect(result.current.waypoints[1]?.shaping).toBe(true);
|
||||||
|
await settle();
|
||||||
|
expect(routeWalkingTour).toHaveBeenLastCalledWith(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ shaping: true })]),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plans a leg afresh: a paddle leg is forgotten and asked again, a fixed leg is left alone', async () => {
|
||||||
|
const { result } = plannerWith([
|
||||||
|
[48, 11],
|
||||||
|
[48.01, 11.02],
|
||||||
|
]);
|
||||||
|
act(() => result.current.setTourType('kayak'));
|
||||||
|
await settle();
|
||||||
|
expect(paddlePlan).toHaveBeenCalledTimes(1);
|
||||||
|
const [start, end] = result.current.waypoints;
|
||||||
|
act(() => result.current.recomputeLeg(must(start).id));
|
||||||
|
expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' });
|
||||||
|
await settle();
|
||||||
|
expect(paddlePlan).toHaveBeenCalledTimes(2);
|
||||||
|
act(() => result.current.recomputeLeg(must(end).id));
|
||||||
|
expect(paddleForget).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends every leg drawn by hand back to the water router in one step', async () => {
|
||||||
|
const { result } = plannerWith([
|
||||||
|
[48, 11],
|
||||||
|
[48.01, 11.02],
|
||||||
|
[48.02, 11],
|
||||||
|
]);
|
||||||
|
act(() => result.current.setTourType('kayak'));
|
||||||
|
for (const point of result.current.waypoints.slice(0, 2))
|
||||||
|
act(() => result.current.toggleWaypointFreeLeg(point.id));
|
||||||
|
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||||
|
act(() => result.current.routeDrawnLegsOnWater());
|
||||||
|
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||||
|
act(() => result.current.undo());
|
||||||
|
expect(result.current.waypoints[0]?.fixedLeg?.kind).toBe('free');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a tour with an unplanned leg from being saved, and says it per leg', async () => {
|
||||||
|
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
|
||||||
|
legs.map(() => ({ status: 'failed', code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } }))
|
||||||
|
);
|
||||||
|
const { result } = plannerWith([
|
||||||
|
[48, 11],
|
||||||
|
[48.01, 11.02],
|
||||||
|
]);
|
||||||
|
act(() => result.current.setName('Off the water'));
|
||||||
|
act(() => result.current.setTourType('kayak'));
|
||||||
|
act(() => result.current.paddle.setBoatType('kayak'));
|
||||||
|
act(() => result.current.paddle.setWaterContext('river'));
|
||||||
|
await settle();
|
||||||
|
expect(result.current.status).toBe('routing-failed');
|
||||||
|
expect(result.current.error).toBe('paddle-route');
|
||||||
|
expect(result.current.paddleRoute?.failures).toEqual([
|
||||||
|
{ index: 0, code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } },
|
||||||
|
]);
|
||||||
|
expect(result.current.routeCurrent).toBe(true);
|
||||||
|
expect(result.current.canSave).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the portage and the lock the router found as points of their own, and they go when their leg changes', async () => {
|
||||||
|
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
|
||||||
|
legs.map(({ from, to }) => {
|
||||||
|
const mid = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||||
|
const at = (t: number): [number, number] => [mid(from.lat, to.lat, t), mid(from.lng, to.lng, t)];
|
||||||
|
const geometry = [at(0), at(0.3), at(0.4), at(1)];
|
||||||
|
const segments = [
|
||||||
|
{ kind: 'water', from: 0, to: 1 },
|
||||||
|
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
|
||||||
|
{ kind: 'water', from: 2, to: 3 },
|
||||||
|
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
|
||||||
|
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { result } = plannerWith([
|
||||||
|
[48, 11],
|
||||||
|
[48.1, 11],
|
||||||
|
]);
|
||||||
|
act(() => result.current.setTourType('kayak'));
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
expect(result.current.waypoints).toHaveLength(4);
|
||||||
|
expect(result.current.waypoints[1]).toMatchObject({ fixedLeg: { kind: 'portage' } });
|
||||||
|
expect(result.current.waypoints.filter((point) => point.auto)).toHaveLength(2);
|
||||||
|
// Found by the router, not done by the paddler: no step of the history of its own.
|
||||||
|
act(() => result.current.undo());
|
||||||
|
expect(result.current.tourType).toBe('hike');
|
||||||
|
act(() => result.current.redo());
|
||||||
|
await settle();
|
||||||
|
await settle();
|
||||||
|
const end = result.current.waypoints[result.current.waypoints.length - 1];
|
||||||
|
act(() => {
|
||||||
|
void result.current.setWaypointPosition(end.id, 48.2, 11);
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1596,7 +1596,7 @@ describe('useTourPlanner', () => {
|
|||||||
|
|
||||||
expect(result.current.mode).toEqual({ type: 'new-draft' })
|
expect(result.current.mode).toEqual({ type: 'new-draft' })
|
||||||
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
|
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
|
||||||
version: 8,
|
version: 9,
|
||||||
tourType: 'hike',
|
tourType: 'hike',
|
||||||
name: '',
|
name: '',
|
||||||
waypoints: [],
|
waypoints: [],
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty, type PaddleLock } from '@trek/shared'
|
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||||
|
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||||
import { tourRepo } from '../../../repo/tourRepo'
|
import { tourRepo } from '../../../repo/tourRepo'
|
||||||
import { randomId } from '../../../utils/randomId'
|
import { randomId } from '../../../utils/randomId'
|
||||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||||
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
|
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
|
||||||
import { keepJoinedFixedLegs } from './tourFixedLegs'
|
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
||||||
import { alignFreeLegs, closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
import { normalizePlannerWaypoints, plannerWaypointsOf, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints'
|
||||||
|
import { useRouteShaping } from './useRouteShaping'
|
||||||
import { useTourMapView } from './useTourMapView'
|
import { useTourMapView } from './useTourMapView'
|
||||||
import { validDraft, type DraftPayload } from './tourPlannerDraft'
|
import { validDraft, type DraftPayload } from './tourPlannerDraft'
|
||||||
import { paddleEstimateOf } from './paddlePlanOf'
|
import { paddleEstimateOf } from './paddlePlanOf'
|
||||||
|
import { adoptChangedPoints, planPaddleStep, type PaddleTourRoute } from './paddleRouting'
|
||||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||||
import { hikingTimeMinutes } from '../hikingTime'
|
import { hikingTimeMinutes } from '../hikingTime'
|
||||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||||
|
|
||||||
export interface TourPlannerWaypoint {
|
|
||||||
id: string
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
role: TourWaypointRole
|
|
||||||
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
|
|
||||||
fixedLeg?: TourFixedLeg | null
|
|
||||||
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
|
|
||||||
lock?: PaddleLock | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a tour planned from an import starts with. */
|
/** What a tour planned from an import starts with. */
|
||||||
export interface DerivedTourDraft {
|
export interface DerivedTourDraft {
|
||||||
name: string
|
name: string
|
||||||
@@ -69,29 +61,6 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
|
|||||||
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
|
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Roles from the order; free legs follow their points, and a paddle tour draws every leg by hand. */
|
|
||||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], allFree = false): TourPlannerWaypoint[] {
|
|
||||||
return keepJoinedFixedLegs(alignFreeLegs(points, allFree)).map((point, index) => ({
|
|
||||||
...point,
|
|
||||||
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
|
||||||
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}`).join('|')
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
|
||||||
if (!leg) return ''
|
|
||||||
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock. */
|
|
||||||
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
|
||||||
const extras = (points: TourPlannerWaypoint[]) => JSON.stringify(points.map(point => [point.fixedLeg?.kind === 'portage' ? point.fixedLeg.extra_minutes ?? null : null, point.lock ?? null]))
|
|
||||||
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where a new tour was planned from; an edit leaves it as it was saved. */
|
/** Where a new tour was planned from; an edit leaves it as it was saved. */
|
||||||
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
|
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
|
||||||
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
|
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
|
||||||
@@ -157,6 +126,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
|
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
|
||||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||||
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
|
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
|
||||||
|
const [paddleRoute, setPaddleRoute] = useState<PaddleTourRoute | null>(null)
|
||||||
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
|
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
|
||||||
const [routeForKey, setRouteForKey] = useState<string | null>(null)
|
const [routeForKey, setRouteForKey] = useState<string | null>(null)
|
||||||
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
|
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
|
||||||
@@ -241,7 +211,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
|
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
|
||||||
if (!editPermissionRef.current) return
|
if (!editPermissionRef.current) return
|
||||||
const current = waypointsRef.current
|
const current = waypointsRef.current
|
||||||
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue, tourTypeRef.current === 'kayak')
|
const changed = typeof nextValue === 'function' ? nextValue(current) : nextValue
|
||||||
|
const paddle = tourTypeRef.current === 'kayak'
|
||||||
|
// A point the router set that the paddler moves or edits is theirs from then on.
|
||||||
|
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
|
||||||
if (sameWaypointSnapshot(current, next)) return
|
if (sameWaypointSnapshot(current, next)) return
|
||||||
invalidateDetailRequest()
|
invalidateDetailRequest()
|
||||||
interruptRouting()
|
interruptRouting()
|
||||||
@@ -256,6 +229,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
setError(null)
|
setError(null)
|
||||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||||
|
|
||||||
|
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
|
||||||
|
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||||
|
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||||
|
const changed = change(waypointsRef.current)
|
||||||
|
if (changed === waypointsRef.current) return
|
||||||
|
interruptRouting()
|
||||||
|
draftRevisionRef.current += 1
|
||||||
|
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current === 'kayak')
|
||||||
|
setWaypoints(waypointsRef.current)
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
|
setStatus('dirty')
|
||||||
|
}, [interruptRouting])
|
||||||
|
|
||||||
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
|
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
|
||||||
if (!editPermissionRef.current) return
|
if (!editPermissionRef.current) return
|
||||||
if (value === difficultyRef.current) return
|
if (value === difficultyRef.current) return
|
||||||
@@ -333,9 +319,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
commitWaypoints(change)
|
commitWaypoints(change)
|
||||||
}, [commitWaypoints])
|
}, [commitWaypoints])
|
||||||
|
|
||||||
/** P2A-04: the leg after this waypoint drawn by hand, or routed again. */
|
/** P2A-04: the leg after this waypoint drawn by hand, or routed again (a paddle tour's on the water). */
|
||||||
const toggleWaypointFreeLeg = useCallback((id: string) => {
|
const toggleWaypointFreeLeg = useCallback((id: string) => {
|
||||||
if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current === 'kayak') return
|
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||||
commitWaypoints(current => toggleFreeLeg(current, id))
|
commitWaypoints(current => toggleFreeLeg(current, id))
|
||||||
}, [commitWaypoints])
|
}, [commitWaypoints])
|
||||||
|
|
||||||
@@ -449,6 +435,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
|
|
||||||
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
|
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
|
||||||
const loadPaddle = paddle.load
|
const loadPaddle = paddle.load
|
||||||
|
const paddleBoat = paddle.boatType
|
||||||
|
const paddleLakeStyle = paddle.lakeStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-01: hike or paddle tour, as one step of the history with the route,
|
* PAD-01: hike or paddle tour, as one step of the history with the route,
|
||||||
@@ -487,8 +475,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null)
|
const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null)
|
||||||
// A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in.
|
// A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in.
|
||||||
const { effectiveSpeedKmh, portageSpeedKmh } = paddle
|
const { effectiveSpeedKmh, portageSpeedKmh } = paddle
|
||||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid) : null,
|
// The routed length of each leg while the route is the current points' (PAD-11); straight lines before that.
|
||||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, waypoints])
|
const routedLegLengths = paddleRoute && routeForKey === tourWaypointKey(waypoints) ? paddleRoute.legLengths : undefined
|
||||||
|
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
|
||||||
|
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
|
||||||
const walkingDurationMinutes = paddleEstimate
|
const walkingDurationMinutes = paddleEstimate
|
||||||
? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes
|
? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes
|
||||||
: routeAnalysis?.gain != null && routeAnalysis.loss != null
|
: routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||||
@@ -501,11 +491,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
|
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||||
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
|
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
|
||||||
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
|
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
|
||||||
const { boatType, waterContext, speedKmh } = paddle
|
const { boatType, waterContext, speedKmh, lakeStyle } = paddle
|
||||||
const typeFields = useMemo(() => tourType === 'kayak'
|
const typeFields = useMemo(() => tourType === 'kayak'
|
||||||
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh }
|
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh, paddle_lake_style: lakeStyle }
|
||||||
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null },
|
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, paddle_lake_style: null },
|
||||||
[boatType, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
|
[boatType, lakeStyle, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
|
||||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
|
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
|
||||||
const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60
|
const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60
|
||||||
const plannedTotalInvalid = plannedDurationMinutes === null
|
const plannedTotalInvalid = plannedDurationMinutes === null
|
||||||
@@ -537,6 +527,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
setStatus('empty')
|
setStatus('empty')
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
setRouteParts(null)
|
setRouteParts(null)
|
||||||
|
setPaddleRoute(null)
|
||||||
setEnrichedGeometry(null)
|
setEnrichedGeometry(null)
|
||||||
setRouteForKey(null)
|
setRouteForKey(null)
|
||||||
setDistanceMeters(null)
|
setDistanceMeters(null)
|
||||||
@@ -676,14 +667,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
|
|
||||||
interruptRouting()
|
interruptRouting()
|
||||||
const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike'
|
const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike'
|
||||||
const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({
|
const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), openedType === 'kayak')
|
||||||
id: randomId(),
|
|
||||||
lat: point.lat,
|
|
||||||
lng: point.lng,
|
|
||||||
role: point.role,
|
|
||||||
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
|
||||||
...(point.lock ? { lock: point.lock } : {}),
|
|
||||||
})), openedType === 'kayak')
|
|
||||||
localStorage.removeItem(storageKey)
|
localStorage.removeItem(storageKey)
|
||||||
contextGenerationRef.current += 1
|
contextGenerationRef.current += 1
|
||||||
draftRevisionRef.current += 1
|
draftRevisionRef.current += 1
|
||||||
@@ -704,6 +688,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
setStatus('dirty')
|
setStatus('dirty')
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
setRouteParts(null)
|
setRouteParts(null)
|
||||||
|
setPaddleRoute(null)
|
||||||
setEnrichedGeometry(null)
|
setEnrichedGeometry(null)
|
||||||
setRouteForKey(null)
|
setRouteForKey(null)
|
||||||
setDistanceMeters(null)
|
setDistanceMeters(null)
|
||||||
@@ -736,6 +721,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
setRetryToken(value => value + 1)
|
setRetryToken(value => value + 1)
|
||||||
}, [interruptRouting])
|
}, [interruptRouting])
|
||||||
|
|
||||||
|
// P2A-03/P2A-05/PAD-11: shaping points, a leg planned afresh, drawn legs handed back to the router.
|
||||||
|
const shaping = useRouteShaping({
|
||||||
|
commit: commitWaypoints, waypointsRef, retry,
|
||||||
|
mayEdit: useCallback(() => editPermissionRef.current && !saveInFlightRef.current, []),
|
||||||
|
isPaddle: useCallback(() => tourTypeRef.current === 'kayak', []),
|
||||||
|
forgetLeg: useCallback((from: TourPlannerWaypoint, to: TourPlannerWaypoint) =>
|
||||||
|
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
|
||||||
|
})
|
||||||
|
|
||||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
|
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
|
||||||
const readOnlyGpxAnalysis = useMemo(
|
const readOnlyGpxAnalysis = useMemo(
|
||||||
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
|
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
|
||||||
@@ -764,10 +758,40 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current?.abort()
|
abortRef.current?.abort()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
const timer = window.setTimeout(async () => {
|
const stale = () => controller.signal.aborted || generation !== generationRef.current
|
||||||
|
// Points the router set, changed by its own planning and not by the paddler: no step of the history.
|
||||||
|
const patch = (next: TourPlannerWaypoint[]) => {
|
||||||
|
waypointsRef.current = next
|
||||||
|
setWaypoints(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
|
||||||
|
const routePaddle = async () => {
|
||||||
|
setStatus('routing')
|
||||||
|
const step = await planPaddleStep(waypoints, tripId, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }, controller.signal, randomId)
|
||||||
|
if (stale()) return
|
||||||
|
if ('points' in step) return patch(normalizePlannerWaypoints(step.points, true))
|
||||||
|
const planned = step.route
|
||||||
|
setRoute(planned.coordinates)
|
||||||
|
setRouteParts(planned.parts)
|
||||||
|
setPaddleRoute(planned)
|
||||||
|
setDistanceMeters(planned.distanceMeters)
|
||||||
|
setDurationSeconds(null)
|
||||||
|
setRouteForKey(waypointKey)
|
||||||
|
setEnrichedGeometry(planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]))
|
||||||
|
if (planned.failures.length > 0) {
|
||||||
|
setStatus('routing-failed')
|
||||||
|
setError('paddle-route')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus('ready')
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeHike = async () => {
|
||||||
setStatus('routing')
|
setStatus('routing')
|
||||||
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
|
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
|
||||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
if (stale()) return
|
||||||
if (!routed) {
|
if (!routed) {
|
||||||
setStatus('routing-failed')
|
setStatus('routing-failed')
|
||||||
setError('routing')
|
setError('routing')
|
||||||
@@ -776,6 +800,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
|
|
||||||
setRoute(routed.coordinates)
|
setRoute(routed.coordinates)
|
||||||
setRouteParts(routed.parts)
|
setRouteParts(routed.parts)
|
||||||
|
setPaddleRoute(null)
|
||||||
setDistanceMeters(routed.distanceMeters)
|
setDistanceMeters(routed.distanceMeters)
|
||||||
setDurationSeconds(routed.durationSeconds)
|
setDurationSeconds(routed.durationSeconds)
|
||||||
setRouteForKey(waypointKey)
|
setRouteForKey(waypointKey)
|
||||||
@@ -783,7 +808,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
setStatus('enriching-elevation')
|
setStatus('enriching-elevation')
|
||||||
|
|
||||||
const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
|
const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
|
||||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
if (stale()) return
|
||||||
if (!enriched) {
|
if (!enriched) {
|
||||||
setStatus('elevation-failed')
|
setStatus('elevation-failed')
|
||||||
setError('elevation')
|
setError('elevation')
|
||||||
@@ -797,24 +822,33 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
}
|
}
|
||||||
setStatus('ready')
|
setStatus('ready')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const run = isPaddle ? routePaddle() : routeHike()
|
||||||
|
run.catch(() => {
|
||||||
|
if (stale()) return
|
||||||
|
setStatus('routing-failed')
|
||||||
|
setError('routing')
|
||||||
|
})
|
||||||
}, ROUTE_DEBOUNCE_MS)
|
}, ROUTE_DEBOUNCE_MS)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer)
|
window.clearTimeout(timer)
|
||||||
controller.abort()
|
controller.abort()
|
||||||
}
|
}
|
||||||
}, [interruptRouting, maxHikingDifficulty, retryToken, waypointKey, waypoints])
|
}, [interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasUnsavedChanges) return
|
if (!hasUnsavedChanges) return
|
||||||
const draft: DraftPayload = { version: 8, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
const draft: DraftPayload = { version: 9, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||||
derivedFromPlaceId, tourType,
|
derivedFromPlaceId, tourType,
|
||||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh },
|
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||||
}
|
}
|
||||||
localStorage.setItem(storageKey, JSON.stringify(draft))
|
localStorage.setItem(storageKey, JSON.stringify(draft))
|
||||||
}, [breakAdditionalMinutes,
|
}, [breakAdditionalMinutes,
|
||||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website,
|
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website,
|
||||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh])
|
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasUnsavedChanges) return
|
if (!hasUnsavedChanges) return
|
||||||
@@ -844,14 +878,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
website: parsedWebsiteOrNull(website),
|
website: parsedWebsiteOrNull(website),
|
||||||
...typeFields,
|
...typeFields,
|
||||||
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
||||||
waypoints: waypoints.map((point, sequence) => ({
|
waypoints: savedWaypointsOf(waypoints),
|
||||||
lat: point.lat,
|
|
||||||
lng: point.lng,
|
|
||||||
role: point.role,
|
|
||||||
sequence,
|
|
||||||
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
|
||||||
...(point.lock ? { lock: point.lock } : {}),
|
|
||||||
})),
|
|
||||||
duration_seconds: savedDurationSeconds,
|
duration_seconds: savedDurationSeconds,
|
||||||
planned_duration_minutes: plannedDurationMinutes,
|
planned_duration_minutes: plannedDurationMinutes,
|
||||||
break_additional_minutes: breakAdditionalMinutes,
|
break_additional_minutes: breakAdditionalMinutes,
|
||||||
@@ -909,7 +936,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
|||||||
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
|
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
|
||||||
waypoints, selectedWaypointId, setSelectedWaypointId,
|
waypoints, selectedWaypointId, setSelectedWaypointId,
|
||||||
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
|
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
|
||||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, paddleEstimate,
|
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
|
||||||
|
...shaping, paddleRoute, routeCurrent: routeForKey === waypointKey,
|
||||||
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
|
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
|
||||||
status, error, retry, isSaving,
|
status, error, retry, isSaving,
|
||||||
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
|
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
|
||||||
|
|||||||
@@ -360,6 +360,20 @@ describe('TourTerrainMap', () => {
|
|||||||
expect(screen.queryByText('18 to 27 %')).toBeNull();
|
expect(screen.queryByText('18 to 27 %')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TERRAIN-012b: a paddle tour keeps its tour colour, without the grade switch or the grade mix', async () => {
|
||||||
|
await renderTerrain({ samples: TOUR, water: true });
|
||||||
|
loadStyle();
|
||||||
|
await act(async () => openTiles());
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Grade' })).toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Grade mix' })).toBeNull();
|
||||||
|
const setData = gl.sources.get('tour-terrain-track')?.setData as ReturnType<typeof vi.fn> | undefined;
|
||||||
|
const drawCalls = setData?.mock.calls ?? [];
|
||||||
|
const drawn = drawCalls[drawCalls.length - 1]?.[0] as { features: { properties: { color: string } }[] } | undefined;
|
||||||
|
expect(drawn?.features.length).toBeGreaterThan(0);
|
||||||
|
expect(drawn?.features.every((feature) => feature.properties.color === '#0a84ff')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-COMP-TERRAIN-013: swaps the style in place for another base layer and draws on it again', async () => {
|
it('FE-COMP-TERRAIN-013: swaps the style in place for another base layer and draws on it again', async () => {
|
||||||
const { rerender } = await renderTerrain({ samples: TOUR });
|
const { rerender } = await renderTerrain({ samples: TOUR });
|
||||||
loadStyle();
|
loadStyle();
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export interface TourTerrainMapProps {
|
|||||||
rightWidth: number;
|
rightWidth: number;
|
||||||
waypoints: TerrainWaypoints;
|
waypoints: TerrainWaypoints;
|
||||||
framePoints: [number, number][];
|
framePoints: [number, number][];
|
||||||
|
/** A paddle tour: no grade colours, no grade legend and no switch for them, the water is flat. */
|
||||||
|
water?: boolean;
|
||||||
/** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */
|
/** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */
|
||||||
ferrataLayer?: FerrataMapLayer;
|
ferrataLayer?: FerrataMapLayer;
|
||||||
/** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */
|
/** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */
|
||||||
@@ -78,7 +80,10 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
|||||||
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
||||||
const stats = useMemo(() => terrainStats(samples), [samples]);
|
const stats = useMemo(() => terrainStats(samples), [samples]);
|
||||||
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
||||||
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} /> : null;
|
const legend =
|
||||||
|
terrain.canReplay && !props.water ? (
|
||||||
|
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
|
||||||
|
) : null;
|
||||||
const legendAbove = fit === 'stacked';
|
const legendAbove = fit === 'stacked';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,7 +125,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
|||||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||||
<span />
|
<span />
|
||||||
{terrain.canReplay ? (
|
{terrain.canReplay ? (
|
||||||
<TerrainDock terrain={terrain} />
|
<TerrainDock terrain={terrain} water={props.water} />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
||||||
@@ -173,8 +178,14 @@ function Divider() {
|
|||||||
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
|
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour. */
|
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
|
||||||
function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
|
function TerrainDock({
|
||||||
|
terrain,
|
||||||
|
water,
|
||||||
|
}: {
|
||||||
|
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
|
||||||
|
water?: boolean;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
|
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
|
||||||
@@ -231,6 +242,16 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
|
|||||||
<span className="w-7 font-semibold tabular-nums">{terrain.exaggeration.toFixed(1)}×</span>
|
<span className="w-7 font-semibold tabular-nums">{terrain.exaggeration.toFixed(1)}×</span>
|
||||||
</label>
|
</label>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{!water && <GradeSwitch terrain={terrain} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour the route by grade or in the tour colour. */
|
||||||
|
function GradeSwitch({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -242,7 +263,7 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
|
|||||||
<Palette size={15} strokeWidth={2} />
|
<Palette size={15} strokeWidth={2} />
|
||||||
{t('tours.terrain.colorByGrade')}
|
{t('tours.terrain.colorByGrade')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ describe('camera and markers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('numbers a control point and shows whether it can be dragged', () => {
|
it('numbers a control point and shows whether it can be dragged', () => {
|
||||||
const pin = waypointPinElement(2, true, true);
|
const pin = waypointPinElement({}, 2, true, true);
|
||||||
expect(pin.textContent).toBe('3');
|
expect(pin.textContent).toBe('3');
|
||||||
expect(pin.getAttribute('aria-label')).toBe('Waypoint 3');
|
expect(pin.getAttribute('aria-label')).toBe('Waypoint 3');
|
||||||
expect(pin.style.width).toBe('30px');
|
expect(pin.style.width).toBe('30px');
|
||||||
expect(pin.style.cursor).toBe('grab');
|
expect(pin.style.cursor).toBe('grab');
|
||||||
const fixed = waypointPinElement(0, false, false);
|
const fixed = waypointPinElement({}, 0, false, false);
|
||||||
expect(fixed.style.width).toBe('26px');
|
expect(fixed.style.width).toBe('26px');
|
||||||
expect(fixed.style.cursor).toBe('pointer');
|
expect(fixed.style.cursor).toBe('pointer');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OPENTOPOMAP_TILE_URL, SATELLITE_TILE_URL } from '../../../constants/map
|
|||||||
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry';
|
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry';
|
||||||
import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders';
|
import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders';
|
||||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||||
|
import { plannerPinShape, type PlannerPinPoint } from '../../Map/plannerPinShape';
|
||||||
import type { TileTemplate } from './terrainPrefetch';
|
import type { TileTemplate } from './terrainPrefetch';
|
||||||
import { gradeBand, gradeSegments } from './tourTerrainModel';
|
import { gradeBand, gradeSegments } from './tourTerrainModel';
|
||||||
|
|
||||||
@@ -198,11 +199,16 @@ export function riderElement(trackColor: string): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A numbered control point, the same pin the flat planner map draws. */
|
/** A numbered control point, the same pin the flat planner map draws. */
|
||||||
export function waypointPinElement(index: number, selected: boolean, editable: boolean): HTMLButtonElement {
|
export function waypointPinElement(
|
||||||
const size = selected ? 30 : 26;
|
point: PlannerPinPoint,
|
||||||
|
index: number,
|
||||||
|
selected: boolean,
|
||||||
|
editable: boolean
|
||||||
|
): HTMLButtonElement {
|
||||||
|
const { size, text } = plannerPinShape(point, index, selected);
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
el.textContent = String(index + 1);
|
el.textContent = text;
|
||||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
|
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
|
||||||
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:${PIN_BLUE};color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${editable ? 'grab' : 'pointer'};padding:0`;
|
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:${PIN_BLUE};color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${editable ? 'grab' : 'pointer'};padding:0`;
|
||||||
return el;
|
return el;
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import type { TerrainGl } from './useTerrainMap';
|
|||||||
|
|
||||||
export interface TerrainWaypoints {
|
export interface TerrainWaypoints {
|
||||||
/** `name` and `detail` (its role) show on hover. */
|
/** `name` and `detail` (its role) show on hover. */
|
||||||
points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
points: Array<{
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
name?: string;
|
||||||
|
detail?: string;
|
||||||
|
label?: string;
|
||||||
|
shaping?: boolean;
|
||||||
|
}>;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/** Absent while the tour cannot be edited; the pins then stay put. */
|
/** Absent while the tour cannot be edited; the pins then stay put. */
|
||||||
@@ -38,7 +46,7 @@ export function useTerrainPins(
|
|||||||
gl.map.on('movestart', hideOnMove);
|
gl.map.on('movestart', hideOnMove);
|
||||||
const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)];
|
const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)];
|
||||||
pinsRef.current = points.map((point, index) => {
|
pinsRef.current = points.map((point, index) => {
|
||||||
const el = waypointPinElement(index, point.id === selectedId, editable);
|
const el = waypointPinElement(point, index, point.id === selectedId, editable);
|
||||||
const name = point.name ?? String(index + 1);
|
const name = point.name ?? String(index + 1);
|
||||||
el.setAttribute('aria-label', name);
|
el.setAttribute('aria-label', name);
|
||||||
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
|
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
|
||||||
|
|||||||
@@ -30,13 +30,17 @@ interface Options {
|
|||||||
tourLayers?: TourLayerOffer;
|
tourLayers?: TourLayerOffer;
|
||||||
/** The visible area, for the via ferrata layer to ask for. */
|
/** The visible area, for the via ferrata layer to ask for. */
|
||||||
onViewportChange?: (bbox: FerrataBbox) => void;
|
onViewportChange?: (bbox: FerrataBbox) => void;
|
||||||
|
/** A paddle tour: the route is never coloured by grade. */
|
||||||
|
water?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */
|
/** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */
|
||||||
export function useTourTerrain(options: Options) {
|
export function useTourTerrain(options: Options) {
|
||||||
const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options;
|
const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options;
|
||||||
const [exaggeration, setExaggeration] = useState(1.5);
|
const [exaggeration, setExaggeration] = useState(1.5);
|
||||||
const [colorMode, setColorMode] = useState<TrackColorMode>('grade');
|
const [chosenColorMode, setColorMode] = useState<TrackColorMode>('grade');
|
||||||
|
// Water is flat: a paddle tour keeps its tour colour, whatever was picked for a hike.
|
||||||
|
const colorMode: TrackColorMode = options.water ? 'tour' : chosenColorMode;
|
||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() => trackData(samples, routes, colorMode, trackColor),
|
() => trackData(samples, routes, colorMode, trackColor),
|
||||||
[samples, routes, colorMode, trackColor]
|
[samples, routes, colorMode, trackColor]
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BookOpen, Link2 } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip's Journal tab: a book with a link on its corner, because the journal
|
||||||
|
* is not the trip's own but one from the Journey addon tied to it. Takes the
|
||||||
|
* props the tab bars hand a lucide icon.
|
||||||
|
*
|
||||||
|
* The book is masked out under the link rather than the link given a backing:
|
||||||
|
* a backing has to match whatever the icon sits on (a card, the accent of a
|
||||||
|
* picked tab), and the moment it does not, the link vanishes into it.
|
||||||
|
*/
|
||||||
|
export function LinkedJournalIcon({
|
||||||
|
size = 16,
|
||||||
|
strokeWidth = 2,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const badge = Math.round(size * 0.6);
|
||||||
|
// The hole: a circle round the link's centre, a little wider than the link.
|
||||||
|
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
|
||||||
|
return (
|
||||||
|
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
|
||||||
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
className={`relative inline-flex flex-none ${className ?? ''}`}
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
|
||||||
|
<BookOpen size={size} strokeWidth={strokeWidth} />
|
||||||
|
</span>
|
||||||
|
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Journal part of the trip's Settings, rendered: what someone sees with no
|
||||||
|
* journal linked, with one linked, while picking, and the warning that names
|
||||||
|
* the people a save would let in for the first time.
|
||||||
|
*/
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||||
|
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||||
|
import { TripJournalLinkSection } from './TripJournalLinkSection';
|
||||||
|
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||||
|
const LINKED: TripJournalLinkResponse = {
|
||||||
|
link: {
|
||||||
|
journey_id: 9,
|
||||||
|
title: 'Japan',
|
||||||
|
subtitle: null,
|
||||||
|
cover_image: 'covers/j.jpg',
|
||||||
|
owner_id: 1,
|
||||||
|
linked_by: 1,
|
||||||
|
created_at: 1,
|
||||||
|
my_role: 'owner',
|
||||||
|
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||||
|
},
|
||||||
|
can_link: true,
|
||||||
|
can_unlink: true,
|
||||||
|
can_edit_members: true,
|
||||||
|
};
|
||||||
|
const CANDIDATES: TripJournalCandidate[] = [
|
||||||
|
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
|
||||||
|
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
let answer: TripJournalLinkResponse = EMPTY;
|
||||||
|
let deletes = 0;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
answer = EMPTY;
|
||||||
|
deletes = 0;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||||
|
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
|
||||||
|
useAddonStore.setState({
|
||||||
|
loaded: true,
|
||||||
|
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
|
||||||
|
});
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
|
||||||
|
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||||
|
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
|
||||||
|
http.delete('/api/trips/:tripId/journal', () => {
|
||||||
|
deletes += 1;
|
||||||
|
return HttpResponse.json(EMPTY);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TripJournalLinkSection', () => {
|
||||||
|
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
|
||||||
|
useAddonStore.setState({ addons: [] });
|
||||||
|
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
|
||||||
|
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||||
|
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Journal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
|
||||||
|
await user.click(await screen.findByText('Link a journal'));
|
||||||
|
await user.click(await screen.findByText('Choose a journal'));
|
||||||
|
// The select opens its list in a portal, one button per journal.
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Japan' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Has access')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
|
||||||
|
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText('Everyone can read'));
|
||||||
|
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText('Nobody'));
|
||||||
|
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText('Cancel'));
|
||||||
|
expect(screen.getByText('Link a journal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
|
||||||
|
answer = LINKED;
|
||||||
|
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||||
|
expect(await screen.findByText('Japan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Your journal')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Linked')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('lena')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
answer = LINKED;
|
||||||
|
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
|
||||||
|
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
|
||||||
|
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
|
||||||
|
await user.click(buttons[buttons.length - 1]);
|
||||||
|
await waitFor(() => expect(deletes).toBe(1));
|
||||||
|
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
|
||||||
|
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||||
|
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText('Edit access'));
|
||||||
|
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
|
||||||
|
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
|
||||||
|
const root = container.firstElementChild as HTMLElement;
|
||||||
|
expect(root.getAttribute('dir')).toBe('ltr');
|
||||||
|
expect(root.style.width).toBe('20px');
|
||||||
|
expect(container.querySelectorAll('svg')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
|
||||||
|
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||||
|
import CustomSelect from '../shared/CustomSelect';
|
||||||
|
import { DialogButton, fs } from '../shared/DialogShell';
|
||||||
|
import { AddRowButton } from '../shared/dialogParts';
|
||||||
|
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||||
|
import { TripMemberAvatar } from './TripMemberAvatar';
|
||||||
|
import { LinkCard } from './TripShareDialog';
|
||||||
|
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
|
||||||
|
|
||||||
|
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
|
||||||
|
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
|
||||||
|
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
|
||||||
|
|
||||||
|
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
|
||||||
|
{ value: null, labelKey: 'trip.journal.roleNone' },
|
||||||
|
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
|
||||||
|
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Journal part of the trip's Settings, for the desktop dialog and the phone
|
||||||
|
* sheet alike: the journal the trip shows, and who of the trip it is open to.
|
||||||
|
* Hidden with the Journey addon off, and for someone who can neither link one
|
||||||
|
* nor see one that is linked.
|
||||||
|
*/
|
||||||
|
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const j = useTripJournalLink(tripId);
|
||||||
|
const [askUnlink, setAskUnlink] = useState(false);
|
||||||
|
if (!j.visible) return null;
|
||||||
|
|
||||||
|
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
|
||||||
|
const Frame = framed ? JournalCard : Bare;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Frame>
|
||||||
|
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
|
||||||
|
{j.mode === 'view' && !j.link && j.canLink && (
|
||||||
|
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
|
||||||
|
)}
|
||||||
|
{j.mode === 'view' && j.link && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{j.canEditMembers && (
|
||||||
|
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
|
||||||
|
{t('trip.journal.editMembers')}
|
||||||
|
</DialogButton>
|
||||||
|
)}
|
||||||
|
{j.canLink && (
|
||||||
|
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
|
||||||
|
{t('trip.journal.change')}
|
||||||
|
</DialogButton>
|
||||||
|
)}
|
||||||
|
{j.canUnlink && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAskUnlink(true)}
|
||||||
|
disabled={j.busy}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
|
||||||
|
style={fs(12.5, 'body')}
|
||||||
|
>
|
||||||
|
<Unlink size={13} strokeWidth={2.2} />
|
||||||
|
{t('trip.journal.unlink')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{j.mode === 'pick' && <JournalPicker j={j} />}
|
||||||
|
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
|
||||||
|
{j.mode !== 'view' && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<DialogButton onClick={j.cancel} disabled={j.busy}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</DialogButton>
|
||||||
|
<DialogButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void j.save()}
|
||||||
|
disabled={!j.canSave}
|
||||||
|
icon={<Link2 size={14} strokeWidth={2.2} />}
|
||||||
|
>
|
||||||
|
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
|
||||||
|
</DialogButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Frame>
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={askUnlink}
|
||||||
|
onClose={() => setAskUnlink(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setAskUnlink(false);
|
||||||
|
void j.unlink();
|
||||||
|
}}
|
||||||
|
title={t('trip.journal.unlinkTitle')}
|
||||||
|
message={t('trip.journal.unlinkMessage')}
|
||||||
|
confirmLabel={t('trip.journal.unlink')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JournalCard({ children }: { children: ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<LinkCard
|
||||||
|
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
|
||||||
|
title={t('trip.journal.title')}
|
||||||
|
hint={t('trip.journal.hint')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LinkCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bare({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="flex flex-col gap-4">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type JournalLink = ReturnType<typeof useTripJournalLink>;
|
||||||
|
|
||||||
|
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
|
||||||
|
function LinkedJournal({ j }: { j: JournalLink }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const link = j.link;
|
||||||
|
if (!link) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
|
||||||
|
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||||
|
{link.cover_image ? (
|
||||||
|
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<BookOpen size={18} strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||||
|
{link.title}
|
||||||
|
</div>
|
||||||
|
{link.my_role && (
|
||||||
|
<div className="truncate text-content-faint" style={fs(11.5)}>
|
||||||
|
{t(`trip.journal.yourRole.${link.my_role}`)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
|
||||||
|
style={fs(10.5)}
|
||||||
|
>
|
||||||
|
<Link2 size={10} strokeWidth={2.6} />
|
||||||
|
{t('trip.journal.linkedBadge')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{link.members && link.members.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{link.members.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m.user_id}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
|
||||||
|
<span className="font-medium text-content">{m.username}</span>
|
||||||
|
<span className="text-content-faint">
|
||||||
|
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{link.members && link.members.length === 0 && (
|
||||||
|
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||||
|
{t('trip.journal.onlyYou')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
|
||||||
|
function JournalPicker({ j }: { j: JournalLink }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
|
||||||
|
if (j.journals.length === 0)
|
||||||
|
return (
|
||||||
|
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||||
|
{t('trip.journal.noJournals')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<CustomSelect
|
||||||
|
value={j.pickedId == null ? '' : String(j.pickedId)}
|
||||||
|
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
|
||||||
|
placeholder={t('trip.journal.pickPlaceholder')}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('trip.journal.pickPlaceholder') },
|
||||||
|
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everyone of the trip, each with no access, read or edit. Before saving, the
|
||||||
|
* people the link would let in for the first time are named: from then on they
|
||||||
|
* see the journal in their own Journey list as well.
|
||||||
|
*/
|
||||||
|
function MemberChooser({ j }: { j: JournalLink }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
|
||||||
|
if (j.candidates.length === 0)
|
||||||
|
return (
|
||||||
|
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||||
|
{t('trip.journal.noMembers')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
|
||||||
|
{t('trip.journal.members')}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => j.setAll('viewer')}
|
||||||
|
className="text-content-muted hover:text-content"
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
{t('trip.journal.allRead')}
|
||||||
|
</button>
|
||||||
|
<span className="text-content-faint" aria-hidden>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => j.setAll(null)}
|
||||||
|
className="text-content-muted hover:text-content"
|
||||||
|
style={fs(11.5)}
|
||||||
|
>
|
||||||
|
{t('trip.journal.allNone')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={ROWS}>
|
||||||
|
{j.candidates.map((c) => (
|
||||||
|
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{j.newlyGranted.length > 0 && (
|
||||||
|
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
|
||||||
|
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
|
||||||
|
<p className="m-0 leading-normal" style={fs(12)}>
|
||||||
|
{t('trip.journal.warnNew', {
|
||||||
|
count: j.newlyGranted.length,
|
||||||
|
names: j.newlyGranted.map((c) => c.username).join(', '),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemberRow({
|
||||||
|
c,
|
||||||
|
choice,
|
||||||
|
onRole,
|
||||||
|
showAccess,
|
||||||
|
}: {
|
||||||
|
c: TripJournalCandidate;
|
||||||
|
choice: JournalMemberChoice;
|
||||||
|
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
|
||||||
|
showAccess: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const current = choice[c.user_id] ?? null;
|
||||||
|
return (
|
||||||
|
<div className={ROW}>
|
||||||
|
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||||
|
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||||
|
{c.username}
|
||||||
|
</span>
|
||||||
|
{c.is_trip_owner && (
|
||||||
|
<span
|
||||||
|
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
|
||||||
|
style={fs(10.5)}
|
||||||
|
>
|
||||||
|
{t('members.owner')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showAccess && c.access && (
|
||||||
|
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
|
||||||
|
{t('trip.journal.hasAccess')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={c.username}
|
||||||
|
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.labelKey}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={current === r.value}
|
||||||
|
onClick={() => onRole(c.user_id, r.value)}
|
||||||
|
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
|
||||||
|
style={fs(11, 'body')}
|
||||||
|
>
|
||||||
|
{t(r.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
|
|||||||
expect(document.body).toBeInTheDocument();
|
expect(document.body).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
|
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
|
||||||
render(<TripMembersModal {...defaultProps} />);
|
render(<TripMembersModal {...defaultProps} />);
|
||||||
// members.shareTrip = "Share Trip"
|
// trip.settings.title = "Trip settings"
|
||||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
|
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
|
||||||
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
|
|||||||
|
|
||||||
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
|
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
|
||||||
render(<TripMembersModal {...defaultProps} isOpen={true} />);
|
render(<TripMembersModal {...defaultProps} isOpen={true} />);
|
||||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Share Link Section (016-021) ───────────────────────────────────────────
|
// ── Share Link Section (016-021) ───────────────────────────────────────────
|
||||||
@@ -291,6 +291,7 @@ describe('TripMembersModal', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(<TripMembersModal {...defaultProps} />);
|
render(<TripMembersModal {...defaultProps} />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||||
const createBtn = await screen.findByText('Create link');
|
const createBtn = await screen.findByText('Create link');
|
||||||
await user.click(createBtn);
|
await user.click(createBtn);
|
||||||
|
|
||||||
@@ -689,6 +690,7 @@ describe('TripMembersModal', () => {
|
|||||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
|
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
|
||||||
);
|
);
|
||||||
const view = render(<TripMembersModal {...defaultProps} />);
|
const view = render(<TripMembersModal {...defaultProps} />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||||
|
|
||||||
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
|
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
|
||||||
fireEvent.click(createBtn);
|
fireEvent.click(createBtn);
|
||||||
@@ -719,6 +721,7 @@ describe('TripMembersModal', () => {
|
|||||||
asShareOwner();
|
asShareOwner();
|
||||||
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
|
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
|
||||||
render(<TripMembersModal {...defaultProps} />);
|
render(<TripMembersModal {...defaultProps} />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
|
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
|
||||||
|
|
||||||
@@ -734,6 +737,7 @@ describe('TripMembersModal', () => {
|
|||||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
|
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
|
||||||
);
|
);
|
||||||
render(<TripMembersModal {...defaultProps} />);
|
render(<TripMembersModal {...defaultProps} />);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: /Disable/i }));
|
await user.click(await screen.findByRole('button', { name: /Disable/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
|
||||||
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
|||||||
<TripInviteLinkSection tripId={tripId} t={t} />
|
<TripInviteLinkSection tripId={tripId} t={t} />
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
<TripJournalLinkSection tripId={tripId} />
|
||||||
|
|
||||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useId, useState, type ReactNode } from 'react'
|
import { useId, useState, type ReactNode } from 'react'
|
||||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||||
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||||
|
import { LinkedJournalIcon } from './LinkedJournalIcon'
|
||||||
|
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||||
import { TripMemberAvatar } from './TripMemberAvatar'
|
import { TripMemberAvatar } from './TripMemberAvatar'
|
||||||
|
import { useTripJournalSectionVisible } from './useTripJournalLink'
|
||||||
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
|
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
|
||||||
|
|
||||||
interface TripShareDialogProps {
|
interface TripShareDialogProps {
|
||||||
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
|
|||||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
||||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
|
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
|
||||||
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
|
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
|
||||||
|
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
|
||||||
|
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
|
||||||
|
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
|
||||||
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
||||||
|
|
||||||
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
|
/**
|
||||||
|
* The trip's Settings on the desktop: who is on the trip, its guests, the two
|
||||||
|
* links that let others in, and the journal the trip shows.
|
||||||
|
*/
|
||||||
export default function TripShareDialog(props: TripShareDialogProps) {
|
export default function TripShareDialog(props: TripShareDialogProps) {
|
||||||
return props.isOpen ? <OpenDialog {...props} /> : null
|
return props.isOpen ? <OpenDialog {...props} /> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Area = 'people' | 'sharing' | 'journal'
|
||||||
|
|
||||||
|
/** The areas of the dialog, each with its icon, its name and the line under the name. */
|
||||||
|
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
|
||||||
|
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
|
||||||
|
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
|
||||||
|
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
|
||||||
|
]
|
||||||
|
|
||||||
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
|
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
|
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
|
||||||
const [ask, setAsk] = useState<Ask | null>(null)
|
const [ask, setAsk] = useState<Ask | null>(null)
|
||||||
|
const [area, setArea] = useState<Area>('people')
|
||||||
|
const journalSection = useTripJournalSectionVisible(tripId)
|
||||||
|
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
|
||||||
|
const current = areas.find(a => a.id === area) ?? areas[0]
|
||||||
|
const panelOf = (id: Area) => areas.find(a => a.id === id)
|
||||||
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
|
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,15 +71,16 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
|||||||
<DialogShell
|
<DialogShell
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
labelledBy={labelId}
|
labelledBy={labelId}
|
||||||
width={m.canManageShare ? 'wide' : 'detail'}
|
width={areas.length > 1 ? 'wide' : 'detail'}
|
||||||
blocked={!!ask}
|
blocked={!!ask}
|
||||||
|
bodyClassName={BODY}
|
||||||
header={(
|
header={(
|
||||||
<DialogHeader
|
<DialogHeader
|
||||||
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||||
tint={NEUTRAL_TINT}
|
tint={NEUTRAL_TINT}
|
||||||
labelId={labelId}
|
labelId={labelId}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
eyebrow={t('members.shareTrip')}
|
eyebrow={t('trip.settings.title')}
|
||||||
title={tripTitle}
|
title={tripTitle}
|
||||||
pills={m.loading ? undefined : (
|
pills={m.loading ? undefined : (
|
||||||
<>
|
<>
|
||||||
@@ -67,17 +91,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
|
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
|
||||||
<div className="flex flex-col gap-5">
|
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
|
||||||
|
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
|
||||||
|
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
|
||||||
{m.canManageMembers && <InviteUser m={m} />}
|
{m.canManageMembers && <InviteUser m={m} />}
|
||||||
<MembersList m={m} onAsk={setAsk} />
|
<MembersList m={m} onAsk={setAsk} />
|
||||||
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
|
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
|
||||||
</div>
|
</AreaPanel>
|
||||||
{m.canManageShare && (
|
{m.canManageShare && (
|
||||||
<div className="flex flex-col gap-5">
|
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
|
||||||
<PublicLink tripId={tripId} />
|
<PublicLink tripId={tripId} />
|
||||||
<InviteLink tripId={tripId} />
|
<InviteLink tripId={tripId} />
|
||||||
</div>
|
</AreaPanel>
|
||||||
|
)}
|
||||||
|
{journalSection && (
|
||||||
|
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
|
||||||
|
<TripJournalLinkSection tripId={tripId} framed={false} />
|
||||||
|
</AreaPanel>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogShell>
|
</DialogShell>
|
||||||
@@ -93,6 +124,45 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
|
||||||
|
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<nav aria-label={t('trip.settings.title')} className={NAV}>
|
||||||
|
{areas.map(a => {
|
||||||
|
const on = a.id === current
|
||||||
|
return (
|
||||||
|
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
|
||||||
|
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
|
||||||
|
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
|
||||||
|
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
|
||||||
|
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
|
||||||
|
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
|
||||||
|
{titled && area && (
|
||||||
|
<header>
|
||||||
|
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
|
||||||
|
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function InviteUser({ m }: { m: TripMembersState }) {
|
function InviteUser({ m }: { m: TripMembersState }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
@@ -291,7 +361,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className={PANEL}>
|
<section className={PANEL}>
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip's linked journal as the planner and the Settings dialog read it.
|
||||||
|
*
|
||||||
|
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
|
||||||
|
* the Journey addon on, only once the server answered, and live on every
|
||||||
|
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
|
||||||
|
* of your own journals, everybody of the trip ticked as a reader by default,
|
||||||
|
* the people who would get in for the first time named before the save.
|
||||||
|
*/
|
||||||
|
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { buildUser } from '../../../tests/helpers/factories';
|
||||||
|
import { server } from '../../../tests/helpers/msw/server';
|
||||||
|
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||||
|
import { seedStore } from '../../../tests/helpers/store';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||||
|
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||||
|
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
|
||||||
|
|
||||||
|
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||||
|
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
|
const listeners = new Set<(e: Record<string, unknown>) => void>();
|
||||||
|
vi.mock('../../api/websocket', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('../../api/websocket')>()),
|
||||||
|
addListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||||
|
listeners.add(fn);
|
||||||
|
},
|
||||||
|
removeListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TRIP = 4;
|
||||||
|
const ME = 1;
|
||||||
|
|
||||||
|
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||||
|
const LINKED: TripJournalLinkResponse = {
|
||||||
|
link: {
|
||||||
|
journey_id: 9,
|
||||||
|
title: 'Japan',
|
||||||
|
subtitle: null,
|
||||||
|
cover_image: null,
|
||||||
|
owner_id: ME,
|
||||||
|
linked_by: ME,
|
||||||
|
created_at: 1,
|
||||||
|
my_role: 'owner',
|
||||||
|
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||||
|
},
|
||||||
|
can_link: true,
|
||||||
|
can_unlink: true,
|
||||||
|
can_edit_members: true,
|
||||||
|
};
|
||||||
|
const CANDIDATES: TripJournalCandidate[] = [
|
||||||
|
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
|
||||||
|
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
let gets = 0;
|
||||||
|
let answer: TripJournalLinkResponse = EMPTY;
|
||||||
|
const writes: { method: string; body: unknown }[] = [];
|
||||||
|
|
||||||
|
function routes({ writeStatus = 200 } = {}) {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/:tripId/journal', () => {
|
||||||
|
gets += 1;
|
||||||
|
return HttpResponse.json(answer);
|
||||||
|
}),
|
||||||
|
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||||
|
http.get('/api/journeys', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
journeys: [
|
||||||
|
{ id: 9, user_id: ME, title: 'Japan' },
|
||||||
|
{ id: 10, user_id: 99, title: 'Not mine' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.put('/api/trips/:tripId/journal', async ({ request }) => {
|
||||||
|
writes.push({ method: 'PUT', body: await request.json() });
|
||||||
|
return writeStatus === 200
|
||||||
|
? HttpResponse.json(LINKED)
|
||||||
|
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
|
||||||
|
}),
|
||||||
|
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
|
||||||
|
writes.push({ method: 'PATCH', body: await request.json() });
|
||||||
|
return HttpResponse.json(LINKED);
|
||||||
|
}),
|
||||||
|
http.delete('/api/trips/:tripId/journal', () => {
|
||||||
|
writes.push({ method: 'DELETE', body: null });
|
||||||
|
return HttpResponse.json(EMPTY);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function journeyAddon(enabled: boolean): void {
|
||||||
|
useAddonStore.setState({
|
||||||
|
loaded: true,
|
||||||
|
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
gets = 0;
|
||||||
|
answer = EMPTY;
|
||||||
|
writes.length = 0;
|
||||||
|
listeners.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||||
|
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
|
||||||
|
journeyAddon(true);
|
||||||
|
routes();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => _resetNetworkMode());
|
||||||
|
|
||||||
|
describe('useTripJournal', () => {
|
||||||
|
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
|
||||||
|
journeyAddon(false);
|
||||||
|
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||||
|
expect(result.current.openJournalId).toBeNull();
|
||||||
|
expect(result.current.settled).toBe(true);
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(gets).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
|
||||||
|
answer = LINKED;
|
||||||
|
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||||
|
expect(result.current.settled).toBe(false);
|
||||||
|
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||||
|
expect(result.current.settled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
|
||||||
|
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
|
||||||
|
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||||
|
expect(result.current.openJournalId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
|
||||||
|
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||||
|
await waitFor(() => expect(gets).toBe(1));
|
||||||
|
answer = LINKED;
|
||||||
|
act(() => {
|
||||||
|
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
|
||||||
|
setForcedOffline(true);
|
||||||
|
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||||
|
expect(result.current.settled).toBe(true);
|
||||||
|
expect(gets).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
|
||||||
|
renderHook(() => useTripJournal(null));
|
||||||
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
|
expect(gets).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
|
||||||
|
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
|
||||||
|
await waitFor(() => expect(result.current).toBe(true));
|
||||||
|
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
|
||||||
|
rerender();
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useTripJournalLink', () => {
|
||||||
|
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
|
||||||
|
act(() => result.current.startPick());
|
||||||
|
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
|
||||||
|
act(() => result.current.pickJournal(9));
|
||||||
|
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||||
|
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
|
||||||
|
// lena is in already; tom would be let in for the first time.
|
||||||
|
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
|
||||||
|
expect(result.current.canSave).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
act(() => result.current.startPick());
|
||||||
|
act(() => result.current.pickJournal(9));
|
||||||
|
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||||
|
act(() => {
|
||||||
|
result.current.setRole(2, 'editor');
|
||||||
|
result.current.setRole(3, null);
|
||||||
|
});
|
||||||
|
expect(result.current.newlyGranted).toEqual([]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.save();
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('trip.journal.linked');
|
||||||
|
expect(result.current.mode).toBe('view');
|
||||||
|
expect(result.current.link?.title).toBe('Japan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
|
||||||
|
answer = LINKED;
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||||
|
act(() => result.current.editMembers());
|
||||||
|
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||||
|
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
|
||||||
|
|
||||||
|
act(() => result.current.setAll('viewer'));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.save();
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
members: [
|
||||||
|
{ user_id: 2, role: 'viewer' },
|
||||||
|
{ user_id: 3, role: 'viewer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('trip.journal.membersSaved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
|
||||||
|
answer = LINKED;
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.canUnlink).toBe(true));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlink();
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
|
||||||
|
expect(result.current.link).toBeNull();
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('trip.journal.unlinked');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
|
||||||
|
routes({ writeStatus: 403 });
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
act(() => result.current.startPick());
|
||||||
|
act(() => result.current.pickJournal(9));
|
||||||
|
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.save();
|
||||||
|
});
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('trip.journal.errorForbidden');
|
||||||
|
expect(result.current.mode).toBe('pick');
|
||||||
|
expect(result.current.busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
setForcedOffline(true);
|
||||||
|
act(() => result.current.startPick());
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorOffline'));
|
||||||
|
expect(result.current.journals).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||||
|
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||||
|
act(() => result.current.startPick());
|
||||||
|
act(() => result.current.pickJournal(9));
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorGeneric'));
|
||||||
|
expect(result.current.candidates).toEqual([]);
|
||||||
|
act(() => result.current.cancel());
|
||||||
|
expect(result.current.mode).toBe('view');
|
||||||
|
expect(result.current.canSave).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import type { TripJournalCandidate, TripJournalLinkMember, TripJournalLinkRole } from '@trek/shared';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||||
|
import { useTranslation } from '../../i18n';
|
||||||
|
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
|
||||||
|
import { useAddonStore } from '../../store/addonStore';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||||
|
import { useServerPing } from '../../sync/useServerPing';
|
||||||
|
import { useToast } from '../shared/Toast';
|
||||||
|
|
||||||
|
/** Who a link lets in, as the member list edits it: a role, or left out. */
|
||||||
|
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
|
||||||
|
|
||||||
|
/** One empty list for "the link holds nobody", so the member effect does not rerun on every render. */
|
||||||
|
const NOBODY: TripJournalLinkMember[] = [];
|
||||||
|
|
||||||
|
/** The status of a failed request, when the server gave one. */
|
||||||
|
function statusOf(err: unknown): number | undefined {
|
||||||
|
return (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip's linked journal as everybody on the trip reads it: answered once per
|
||||||
|
* trip from the store, asked again on mount, when the connection returns and on
|
||||||
|
* every `trip:journal:changed`. With the Journey addon off there is no journal.
|
||||||
|
*/
|
||||||
|
export function useTripJournal(tripId: number | null | undefined) {
|
||||||
|
const addonsLoaded = useAddonStore((s) => s.loaded);
|
||||||
|
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
|
||||||
|
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
|
||||||
|
const refresh = useTripJournalLinkStore((s) => s.refresh);
|
||||||
|
const { offline } = useNetworkMode();
|
||||||
|
const active = journeyOn && tripId != null && !offline;
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (tripId != null) await refresh(tripId);
|
||||||
|
}, [tripId, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) void reload();
|
||||||
|
}, [active, reload]);
|
||||||
|
useServerPing('trip:journal:changed', active, reload);
|
||||||
|
|
||||||
|
const link = journeyOn ? (answer?.link ?? null) : null;
|
||||||
|
return {
|
||||||
|
journeyOn,
|
||||||
|
answer: journeyOn ? answer : undefined,
|
||||||
|
link,
|
||||||
|
/** The journal the trip's Journal tab opens: linked, and open to this person. */
|
||||||
|
openJournalId: link?.my_role ? link.journey_id : null,
|
||||||
|
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
|
||||||
|
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
|
||||||
|
export function useTripJournalSectionVisible(tripId: number): boolean {
|
||||||
|
const { journeyOn, answer } = useTripJournal(tripId);
|
||||||
|
return journeyOn && !!answer && (answer.can_link || !!answer.link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The toast for a failed journal request: offline, refused, or anything else. */
|
||||||
|
function useJournalFailure() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
return useCallback(
|
||||||
|
(err: unknown) => {
|
||||||
|
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
|
||||||
|
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
|
||||||
|
else toast.error(t('trip.journal.errorGeneric'));
|
||||||
|
},
|
||||||
|
[t, toast]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The people of the trip for one journal, each with the role the save would
|
||||||
|
* give them. `current` is who the link holds now, to start from; without it
|
||||||
|
* everybody starts as a reader, the default the section promises.
|
||||||
|
*
|
||||||
|
* What loaded is kept with the journal it belongs to, so picking another one
|
||||||
|
* shows nothing until its own people are in, never the last journal's.
|
||||||
|
*/
|
||||||
|
function useJournalMemberChoice(
|
||||||
|
tripId: number,
|
||||||
|
journeyId: number | null,
|
||||||
|
current: TripJournalLinkMember[] | null,
|
||||||
|
fail: (err: unknown) => void
|
||||||
|
) {
|
||||||
|
const [loaded, setLoaded] = useState<{ journeyId: number; list: TripJournalCandidate[] } | null>(null);
|
||||||
|
const [choice, setChoice] = useState<JournalMemberChoice>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (journeyId == null) return;
|
||||||
|
let cancelled = false;
|
||||||
|
tripJournalLinkRepo
|
||||||
|
.candidates(tripId, journeyId)
|
||||||
|
.then((list) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const held = new Map((current ?? []).map((m) => [m.user_id, m.role]));
|
||||||
|
setLoaded({ journeyId, list });
|
||||||
|
setChoice(Object.fromEntries(list.map((c) => [c.user_id, current ? (held.get(c.user_id) ?? null) : 'viewer'])));
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
fail(err);
|
||||||
|
setLoaded({ journeyId, list: [] });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [tripId, journeyId, current, fail]);
|
||||||
|
|
||||||
|
const candidates = journeyId != null && loaded?.journeyId === journeyId ? loaded.list : null;
|
||||||
|
|
||||||
|
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
|
||||||
|
setChoice((prev) => ({ ...prev, [memberId]: role }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAll = useCallback(
|
||||||
|
(role: TripJournalLinkRole | null) =>
|
||||||
|
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role]))),
|
||||||
|
[candidates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const chosenMembers = useMemo(
|
||||||
|
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
|
||||||
|
[choice]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The people the save would let into the journal for the first time; the warning names them. */
|
||||||
|
const newlyGranted = useMemo(
|
||||||
|
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
|
||||||
|
[candidates, choice]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { candidates, choice, setRole, setAll, chosenMembers, newlyGranted };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Journal section of the trip's Settings: link one of your journals, choose
|
||||||
|
* who it is open to and as what, change it, or take it off. One hook for the
|
||||||
|
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
|
||||||
|
*
|
||||||
|
* Picking a journal asks the server who of the trip already has access to it;
|
||||||
|
* whoever would be let in for the first time is named before anything is saved,
|
||||||
|
* because they will see the journal in their own Journey list from then on.
|
||||||
|
*/
|
||||||
|
export function useTripJournalLink(tripId: number) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const toast = useToast();
|
||||||
|
const fail = useJournalFailure();
|
||||||
|
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||||
|
const { journeyOn, answer, link } = useTripJournal(tripId);
|
||||||
|
const store = useTripJournalLinkStore.getState;
|
||||||
|
|
||||||
|
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
|
||||||
|
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
|
||||||
|
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
|
||||||
|
const [pickedId, setPickedId] = useState<number | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
|
||||||
|
// Who the link holds, to start from: when editing it, or when re-picking the
|
||||||
|
// journal it already shows with people on it.
|
||||||
|
const held = link?.members ?? NOBODY;
|
||||||
|
const current = mode === 'members' || (link?.journey_id === pickedId && held.length > 0) ? held : null;
|
||||||
|
const members = useJournalMemberChoice(tripId, journeyId, current, fail);
|
||||||
|
const { chosenMembers } = members;
|
||||||
|
|
||||||
|
const startPick = useCallback(() => {
|
||||||
|
setMode('pick');
|
||||||
|
setPickedId(null);
|
||||||
|
if (userId == null) return;
|
||||||
|
tripJournalLinkRepo
|
||||||
|
.ownJournals(userId)
|
||||||
|
.then(setJournals)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
fail(err);
|
||||||
|
setJournals([]);
|
||||||
|
});
|
||||||
|
}, [userId, fail]);
|
||||||
|
|
||||||
|
const editMembers = useCallback(() => setMode('members'), []);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
setMode('view');
|
||||||
|
setPickedId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const run = useCallback(
|
||||||
|
async (write: () => Promise<void>, done: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await write();
|
||||||
|
toast.success(done);
|
||||||
|
cancel();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
fail(err);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancel, fail, toast]
|
||||||
|
);
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
if (mode === 'pick' && pickedId != null) {
|
||||||
|
await run(() => store().link(tripId, pickedId, chosenMembers), t('trip.journal.linked'));
|
||||||
|
} else if (mode === 'members') {
|
||||||
|
await run(() => store().setMembers(tripId, chosenMembers), t('trip.journal.membersSaved'));
|
||||||
|
}
|
||||||
|
}, [mode, pickedId, run, store, tripId, chosenMembers, t]);
|
||||||
|
|
||||||
|
const unlink = useCallback(async () => {
|
||||||
|
await run(() => store().unlink(tripId), t('trip.journal.unlinked'));
|
||||||
|
}, [run, store, tripId, t]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
|
||||||
|
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
|
||||||
|
link,
|
||||||
|
canLink: !!answer?.can_link,
|
||||||
|
canUnlink: !!answer?.can_unlink,
|
||||||
|
canEditMembers: !!answer?.can_edit_members,
|
||||||
|
mode,
|
||||||
|
startPick,
|
||||||
|
cancel,
|
||||||
|
editMembers,
|
||||||
|
journals,
|
||||||
|
pickedId,
|
||||||
|
pickJournal: setPickedId,
|
||||||
|
candidates: members.candidates,
|
||||||
|
choice: members.choice,
|
||||||
|
setRole: members.setRole,
|
||||||
|
setAll: members.setAll,
|
||||||
|
newlyGranted: members.newlyGranted,
|
||||||
|
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
|
||||||
|
busy,
|
||||||
|
save,
|
||||||
|
unlink,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
|
|||||||
'finanzplan',
|
'finanzplan',
|
||||||
'dateien',
|
'dateien',
|
||||||
'collab',
|
'collab',
|
||||||
|
'journal',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
|
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
|
||||||
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
|
|||||||
finanzplan: 'trip.tabs.budget',
|
finanzplan: 'trip.tabs.budget',
|
||||||
dateien: 'trip.tabs.files',
|
dateien: 'trip.tabs.files',
|
||||||
collab: 'admin.addons.catalog.collab.name',
|
collab: 'admin.addons.catalog.collab.name',
|
||||||
|
journal: 'trip.tabs.journal',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isTripTabId = (value: unknown): value is TripTabId =>
|
export const isTripTabId = (value: unknown): value is TripTabId =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
|
|||||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||||
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||||
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||||
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
|
import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||||
import { useJourneyStore } from '../../../store/journeyStore'
|
import { useJourneyStore } from '../../../store/journeyStore'
|
||||||
import { useAddonStore } from '../../../store/addonStore'
|
import { useAddonStore } from '../../../store/addonStore'
|
||||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||||
@@ -32,8 +32,12 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
|
|||||||
* Journey detail — integrated map with the horizontal 280px card timeline
|
* Journey detail — integrated map with the horizontal 280px card timeline
|
||||||
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
|
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
|
||||||
* markers stay in sync; tapping the centered card opens the entry sheet.
|
* markers stay in sync; tapping the centered card opens the entry sheet.
|
||||||
|
*
|
||||||
|
* A trip that links the journal shows this same screen in its own tab
|
||||||
|
* (`embedded`): the trip's back button and controls sit on top, so the screen
|
||||||
|
* drops its own back button and leaves the trip's URL alone.
|
||||||
*/
|
*/
|
||||||
export default function MJourneyDetail() {
|
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||||
const {
|
const {
|
||||||
id, navigate, toast, t,
|
id, navigate, toast, t,
|
||||||
current, loading,
|
current, loading,
|
||||||
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
|
|||||||
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
||||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
||||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||||
} = useJourneyDetail()
|
} = useJourneyDetail({ journeyId, embedded })
|
||||||
|
|
||||||
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
||||||
// upload button, so it has to know which tab is open — and it stops knowing
|
// upload button, so it has to know which tab is open — and it stops knowing
|
||||||
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
|
|||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
const [pendingUpload, setPendingUpload] = useState(false)
|
const [pendingUpload, setPendingUpload] = useState(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.get('create') !== 'photo') return
|
if (embedded || params.get('create') !== 'photo') return
|
||||||
setParams(prev => {
|
setParams(prev => {
|
||||||
const next = new URLSearchParams(prev)
|
const next = new URLSearchParams(prev)
|
||||||
next.delete('create')
|
next.delete('create')
|
||||||
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
|
|||||||
if (!canEditEntries) return
|
if (!canEditEntries) return
|
||||||
setView('gallery')
|
setView('gallery')
|
||||||
setPendingUpload(true)
|
setPendingUpload(true)
|
||||||
}, [params, setParams, canEditEntries, setView])
|
}, [params, setParams, canEditEntries, setView, embedded])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingUpload || !providersReady) return
|
if (!pendingUpload || !providersReady) return
|
||||||
@@ -331,6 +335,9 @@ export default function MJourneyDetail() {
|
|||||||
|
|
||||||
{/* Header: back — segment — upload / overflow menu */}
|
{/* Header: back — segment — upload / overflow menu */}
|
||||||
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
|
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
|
||||||
|
{embedded ? (
|
||||||
|
<span className="w-[38px] flex-none" aria-hidden />
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/journey')}
|
onClick={() => navigate('/journey')}
|
||||||
@@ -339,6 +346,7 @@ export default function MJourneyDetail() {
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
|
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import {
|
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } from 'lucide-react'
|
||||||
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
|
|
||||||
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import MSheet from '../../components/MSheet'
|
import MSheet from '../../components/MSheet'
|
||||||
import MIconBtn from '../../components/MIconBtn'
|
import MIconBtn from '../../components/MIconBtn'
|
||||||
import MToggle from '../../components/MToggle'
|
import MToggle from '../../components/MToggle'
|
||||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||||
import { useTranslation } from '../../../i18n'
|
import { useTranslation } from '../../../i18n'
|
||||||
import type { JourneyDetail } from '../../../store/journeyStore'
|
import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
|
||||||
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
|
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
|
||||||
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
|
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
|
||||||
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||||
@@ -237,6 +234,8 @@ export default function MJourneySettingsSheet({
|
|||||||
{t('journey.contributors.invite')}
|
{t('journey.contributors.invite')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
|
||||||
|
|
||||||
{/* Public share */}
|
{/* Public share */}
|
||||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
||||||
{!shareLink ? (
|
{!shareLink ? (
|
||||||
@@ -333,3 +332,27 @@ export default function MJourneySettingsSheet({
|
|||||||
</MSheet>
|
</MSheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
|
||||||
|
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!access?.length) return null
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
|
||||||
|
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
|
||||||
|
{access.map(a => (
|
||||||
|
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||||
|
<Link2 size={15} className="flex-none text-m-muted" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-caption font-bold">{a.username}</span>
|
||||||
|
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
|
||||||
|
{a.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
|
import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import MSheet from '../../../components/MSheet'
|
import MSheet from '../../../components/MSheet'
|
||||||
import type { MTripSheetsProps } from '../MTripShell'
|
import type { MTripSheetsProps } from '../MTripShell'
|
||||||
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
|
|||||||
finanzplan: '#E8A33D',
|
finanzplan: '#E8A33D',
|
||||||
dateien: '#68686F',
|
dateien: '#68686F',
|
||||||
collab: '#EC4899',
|
collab: '#EC4899',
|
||||||
|
journal: '#C2410C',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
|
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
|
||||||
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
|
{ id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
|
||||||
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
|
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
|
||||||
...(canEditTrip
|
...(canEditTrip
|
||||||
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
|
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
|
|||||||
import MCollabTab from './MCollabTab'
|
import MCollabTab from './MCollabTab'
|
||||||
import MListsTab from './MListsTab'
|
import MListsTab from './MListsTab'
|
||||||
import MRoadtripTab from '../roadtrip/MRoadtripTab'
|
import MRoadtripTab from '../roadtrip/MRoadtripTab'
|
||||||
|
import MJourneyDetail from '../../journey/MJourneyDetail'
|
||||||
import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
|
|||||||
// switch between them must not lose it.
|
// switch between them must not lose it.
|
||||||
case 'roadtrip':
|
case 'roadtrip':
|
||||||
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
|
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
|
||||||
|
// The journal the trip links: the Journey addon's own phone screen, moved
|
||||||
|
// below the trip's floating top controls: its bars measure from --m-safe-top.
|
||||||
|
case 'journal':
|
||||||
|
return planner.journalId == null ? null : (
|
||||||
|
<div className="absolute inset-0 overflow-hidden [--m-safe-top:calc(env(safe-area-inset-top,0px)+62px)]">
|
||||||
|
<MJourneyDetail journeyId={planner.journalId} embedded />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return <TabScroller>{null}</TabScroller>
|
return <TabScroller>{null}</TabScroller>
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user