mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2654e651c7 | |||
| ce1fa68c84 |
@@ -257,15 +257,6 @@ 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
|
||||||
@@ -273,7 +264,6 @@ 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
|
||||||
@@ -405,15 +395,6 @@ jobs:
|
|||||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||||
run: cd client && npm run lint:strict
|
run: cd client && npm run lint:strict
|
||||||
|
|
||||||
- name: 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.
|
||||||
@@ -421,7 +402,6 @@ 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
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/bebas-neue": "^5.3.0",
|
"@fontsource/bebas-neue": "^5.3.0",
|
||||||
|
"@fontsource/creepster": "^5.3.0",
|
||||||
"@fontsource/eb-garamond": "^5.3.0",
|
"@fontsource/eb-garamond": "^5.3.0",
|
||||||
"@fontsource/geist-sans": "^5.3.0",
|
"@fontsource/geist-sans": "^5.3.0",
|
||||||
"@fontsource/inter": "^5.3.0",
|
"@fontsource/inter": "^5.3.0",
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
if (isShared) dark = false;
|
if (isShared) dark = false;
|
||||||
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
else dark = s.darkMode === true || s.darkMode === 'dark';
|
else dark = s.darkMode === true || s.darkMode === 'dark';
|
||||||
|
// Special themes (APPEARANCE_SPECIAL_SCHEMES in @trek/shared) are dark-only.
|
||||||
|
if (!isShared && s.scheme === 'halloween') dark = true;
|
||||||
root.classList.toggle('dark', dark);
|
root.classList.toggle('dark', dark);
|
||||||
|
|
||||||
var scheme = isShared ? 'default' : s.scheme;
|
var scheme = isShared ? 'default' : s.scheme;
|
||||||
|
|||||||
@@ -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": 874,
|
"@typescript-eslint/no-misused-promises": 875,
|
||||||
"no-console": 88,
|
"no-console": 88,
|
||||||
"complexity": 230,
|
"complexity": 230,
|
||||||
"@typescript-eslint/no-explicit-any": 147,
|
"@typescript-eslint/no-explicit-any": 147,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* 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 {
|
||||||
@@ -138,90 +137,6 @@ export async function lintClient(root) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
|
||||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
|
||||||
|
|
||||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
|
||||||
function gitIn(root, args) {
|
|
||||||
try {
|
|
||||||
return execFileSync('git', args, {
|
|
||||||
cwd: root,
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
maxBuffer: 1 << 28,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The messages of one rule in one area of a single file's lint result. */
|
|
||||||
function ofRule(results, rule, area) {
|
|
||||||
const result = results[0];
|
|
||||||
if (!result) return [];
|
|
||||||
const pool =
|
|
||||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
|
||||||
return pool.filter((m) => ruleOf(m) === rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
|
||||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
|
||||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
|
||||||
* The count alone names no file, and the files holding the most are old ones;
|
|
||||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
|
||||||
* without it), and the report then keeps to the files holding the most.
|
|
||||||
*/
|
|
||||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
|
||||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
|
||||||
if (changed === null) return null;
|
|
||||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
|
||||||
const found = await Promise.all(
|
|
||||||
keys
|
|
||||||
.filter((key) => differs.has(key))
|
|
||||||
.map(async (key) => {
|
|
||||||
const path = join(root, key);
|
|
||||||
// A typed rule can trip over a text that is not the file on disk (the
|
|
||||||
// base version); then the file's own warnings are told, marked so.
|
|
||||||
const lint = async (text) => {
|
|
||||||
try {
|
|
||||||
return { messages: ofRule(await lintText(text, path), rule, area) };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const now = await lint(readText(path));
|
|
||||||
if (!now) return [];
|
|
||||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
|
||||||
const was = before === null ? { messages: [] } : await lint(before);
|
|
||||||
if (was && now.messages.length <= was.messages.length) return [];
|
|
||||||
const note = was ? '' : ' (the base version could not be linted)';
|
|
||||||
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
return found.flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
|
||||||
async function reportNew(grown, { root, files, lintText, error }) {
|
|
||||||
if (!grown.length || !lintText) return;
|
|
||||||
const found = await Promise.all(
|
|
||||||
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
|
||||||
);
|
|
||||||
grown.forEach(({ area, rule }, i) => {
|
|
||||||
const lines = found[i];
|
|
||||||
if (!lines?.length) return;
|
|
||||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
|
||||||
for (const line of lines) error(` ${line}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
|
||||||
function lintTextClient(root) {
|
|
||||||
const eslint = new ESLint({ cwd: root });
|
|
||||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,7 +148,6 @@ export async function check({
|
|||||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
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,
|
||||||
}) {
|
}) {
|
||||||
@@ -246,13 +160,13 @@ export async function check({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const line of errors) error(`FAIL ${line}`);
|
for (const line of errors) error(`FAIL ${line}`);
|
||||||
const grown = [];
|
let grown = 0;
|
||||||
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.push({ area, rule });
|
grown++;
|
||||||
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)
|
||||||
@@ -268,8 +182,7 @@ export async function check({
|
|||||||
});
|
});
|
||||||
stale += fallen.length;
|
stale += fallen.length;
|
||||||
}
|
}
|
||||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
if (grown) {
|
||||||
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.'
|
||||||
@@ -278,5 +191,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.length || stale ? 1 : 0;
|
return errors.length || grown || 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": 1402,
|
"src/components/Map/MapView.tsx": 1409,
|
||||||
"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": 21,
|
"components/Layout/Navbar.tsx": 22,
|
||||||
"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,
|
||||||
@@ -269,7 +269,7 @@
|
|||||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
|
|||||||
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||||
import HelpPanel from './components/Help/HelpPanel'
|
import HelpPanel from './components/Help/HelpPanel'
|
||||||
|
import SpecialThemeLayer from './theme/special/SpecialThemeLayer'
|
||||||
// Notice action registrations (side-effect imports):
|
// Notice action registrations (side-effect imports):
|
||||||
import './pages/Trips/noticeActions.js'
|
import './pages/Trips/noticeActions.js'
|
||||||
import { managedRoutes } from './managed'
|
import { managedRoutes } from './managed'
|
||||||
@@ -405,6 +406,7 @@ export default function App() {
|
|||||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||||
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
|
||||||
|
<ErrorBoundary boundaryId="widget:special-theme" fallback={null}><SpecialThemeLayer /></ErrorBoundary>
|
||||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||||
across navigations. react-router runs location updates inside a transition,
|
across navigations. react-router runs location updates inside a transition,
|
||||||
so a mounted boundary keeps the current page on screen instead of flashing
|
so a mounted boundary keeps the current page on screen instead of flashing
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
|
|
||||||
import apiClient from './client';
|
|
||||||
|
|
||||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
|
||||||
const GEOFABRIK = { timeout: 30_000 };
|
|
||||||
|
|
||||||
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
|
|
||||||
export const paddleRegionLoadApi = {
|
|
||||||
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
|
|
||||||
apiClient
|
|
||||||
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
|
|
||||||
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
|
|
||||||
};
|
|
||||||
@@ -2,19 +2,17 @@ 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, and those paddlers asked for (PAD-11C). */
|
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
|
||||||
export const paddleRegionsApi = {
|
export const paddleRegionsApi = {
|
||||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||||
catalog: (q: string, signal?: AbortSignal) =>
|
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 })
|
||||||
@@ -31,11 +29,4 @@ 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)),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
|
||||||
import apiClient from './client';
|
|
||||||
|
|
||||||
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
|
||||||
export const paddleRouteApi = {
|
|
||||||
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
|
||||||
apiClient
|
|
||||||
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
|
||||||
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type {
|
|
||||||
TripJournalCandidatesResponse,
|
|
||||||
TripJournalLinkMemberRequest,
|
|
||||||
TripJournalLinkResponse,
|
|
||||||
} from '@trek/shared';
|
|
||||||
import apiClient from './client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
|
||||||
* and who it is open to. In its own file rather than in the `client.ts` god
|
|
||||||
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
|
||||||
*/
|
|
||||||
export const tripJournalApi = {
|
|
||||||
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
|
||||||
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
|
||||||
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
|
||||||
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
|
||||||
link: (
|
|
||||||
tripId: number,
|
|
||||||
journeyId: number,
|
|
||||||
members: TripJournalLinkMemberRequest[]
|
|
||||||
): Promise<TripJournalLinkResponse> =>
|
|
||||||
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
|
||||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
|
||||||
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
|
||||||
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
|
||||||
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
|
||||||
/** The journals this person can open; the dialog offers the ones they own. */
|
|
||||||
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
|
||||||
apiClient.get('/journeys').then((r) => r.data),
|
|
||||||
};
|
|
||||||
@@ -93,10 +93,6 @@ 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',
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
|
|
||||||
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
|
|
||||||
import { useTranslation } from '../../../i18n';
|
|
||||||
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
|
||||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
|
||||||
import { fs } from '../../shared/DialogShell';
|
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
|
||||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
|
||||||
|
|
||||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
|
||||||
|
|
||||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
|
||||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
|
||||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
|
||||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
|
||||||
|
|
||||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
|
||||||
function useMegabytes() {
|
|
||||||
const { language } = useTranslation();
|
|
||||||
return (mb: number) =>
|
|
||||||
new Intl.NumberFormat(language, {
|
|
||||||
style: 'unit',
|
|
||||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
|
||||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
|
||||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MB = 1024 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
|
|
||||||
* mode, and in the automatic one the largest region it loads without asking.
|
|
||||||
*/
|
|
||||||
export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const megabytes = useMegabytes();
|
|
||||||
const { settings, saving, save } = admin.autoLoad;
|
|
||||||
if (!settings) return null;
|
|
||||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
|
||||||
? SIZES
|
|
||||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
|
||||||
return (
|
|
||||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
|
||||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
|
||||||
<Segmented<PaddleRegionAutoMode>
|
|
||||||
value={settings.mode}
|
|
||||||
onChange={(mode) => void save({ mode })}
|
|
||||||
label={t('admin.paddleRegions.auto.title')}
|
|
||||||
fill
|
|
||||||
options={MODES.map((mode) => {
|
|
||||||
const Icon = MODE_ICONS[mode];
|
|
||||||
return {
|
|
||||||
value: mode,
|
|
||||||
label: t(`admin.paddleRegions.auto.mode.${mode}`),
|
|
||||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
|
|
||||||
</div>
|
|
||||||
{settings.mode === 'auto' && (
|
|
||||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
|
||||||
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
|
||||||
<ChoiceChips<string>
|
|
||||||
value={String(settings.max_mb)}
|
|
||||||
label={t('admin.paddleRegions.auto.maxSize')}
|
|
||||||
onChange={(value) => void save({ max_mb: Number(value) })}
|
|
||||||
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
|
|
||||||
/>
|
|
||||||
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
|
||||||
const { t, language } = useTranslation();
|
|
||||||
const megabytes = useMegabytes();
|
|
||||||
const busy = admin.busyId === request.id;
|
|
||||||
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
|
||||||
data-testid="paddle-region-request"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
|
|
||||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
|
||||||
{request.name}
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-wrap items-center gap-1.5">
|
|
||||||
{request.declined ? (
|
|
||||||
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
|
|
||||||
{t('admin.paddleRegions.requests.declined')}
|
|
||||||
</StatusPill>
|
|
||||||
) : (
|
|
||||||
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
|
|
||||||
)}
|
|
||||||
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
|
|
||||||
{request.requested_by && (
|
|
||||||
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
|
|
||||||
)}
|
|
||||||
<StatusPill>{date}</StatusPill>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="flex flex-none items-center gap-1.5">
|
|
||||||
{!request.declined && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={admin.busyId !== null}
|
|
||||||
onClick={() => void admin.decline(request.id)}
|
|
||||||
className={SETTINGS_BUTTON}
|
|
||||||
style={fs(12.5, 'body')}
|
|
||||||
>
|
|
||||||
{t('admin.paddleRegions.requests.decline')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={admin.busyId !== null}
|
|
||||||
onClick={() => void admin.approve(request.id)}
|
|
||||||
className={SETTINGS_BUTTON}
|
|
||||||
style={fs(12.5, 'body')}
|
|
||||||
>
|
|
||||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
|
||||||
{t('admin.paddleRegions.requests.approve')}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The regions paddlers asked for that wait for an admin, the open ones first. */
|
|
||||||
export function RequestList({ admin }: { admin: Admin }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (admin.requests.length === 0) return null;
|
|
||||||
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
|
||||||
const open = admin.requests.filter((request) => !request.declined).length;
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
|
|
||||||
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
|
|
||||||
{t('admin.paddleRegions.requests.title')}
|
|
||||||
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
|
|
||||||
</span>
|
|
||||||
<SettingRows>
|
|
||||||
{ordered.map((request) => (
|
|
||||||
<RequestRow key={request.id} request={request} admin={admin} />
|
|
||||||
))}
|
|
||||||
</SettingRows>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
|
|
||||||
export function AutoOriginPill() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
|
|
||||||
<span className="inline-flex" data-testid="paddle-region-auto">
|
|
||||||
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
|
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
||||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
// 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,13 +25,11 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -58,8 +56,6 @@ 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(),
|
||||||
@@ -67,8 +63,6 @@ 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(),
|
||||||
@@ -301,91 +295,6 @@ describe('PaddleRegionsPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PaddleRegionsPanel water network (PAD-11)', () => {
|
|
||||||
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
|
|
||||||
const network = {
|
|
||||||
water_km: 1364.5,
|
|
||||||
lakes: 266,
|
|
||||||
portages: 14,
|
|
||||||
inferred_portages: 232,
|
|
||||||
gaps: 63,
|
|
||||||
locks: 17,
|
|
||||||
built_at: '2026-10-17T15:30:20Z',
|
|
||||||
bytes: 1496816,
|
|
||||||
};
|
|
||||||
show({ regions: [region({ network })] });
|
|
||||||
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
|
|
||||||
expect(pill).toHaveTextContent('1,365 km of water');
|
|
||||||
fireEvent.mouseEnter(pill);
|
|
||||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
|
||||||
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
|
|
||||||
show({
|
|
||||||
regions: [
|
|
||||||
region({ id: 'old', name: 'Old read', network: null }),
|
|
||||||
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
|
|
||||||
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
|
|
||||||
const save = vi.fn(async () => {});
|
|
||||||
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
|
|
||||||
const auto = screen.getByTestId('paddle-auto-load');
|
|
||||||
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
|
|
||||||
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
|
|
||||||
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
|
||||||
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
|
|
||||||
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
|
|
||||||
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
|
|
||||||
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
|
|
||||||
const approve = vi.fn(async () => {});
|
|
||||||
const decline = vi.fn(async () => {});
|
|
||||||
const base = {
|
|
||||||
bytes: 734_003_200,
|
|
||||||
requested_by: 'elmo',
|
|
||||||
requested_at: '2026-10-18T09:00:00Z',
|
|
||||||
};
|
|
||||||
show({
|
|
||||||
approve,
|
|
||||||
decline,
|
|
||||||
requests: [
|
|
||||||
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
|
|
||||||
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const rows = screen.getAllByTestId('paddle-region-request');
|
|
||||||
expect(rows[0]).toHaveTextContent('Brandenburg');
|
|
||||||
expect(rows[0]).toHaveTextContent('Daily limit');
|
|
||||||
expect(rows[0]).toHaveTextContent('by elmo');
|
|
||||||
expect(rows[1]).toHaveTextContent('Declined');
|
|
||||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
|
|
||||||
expect(decline).toHaveBeenCalledWith('brandenburg');
|
|
||||||
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
|
|
||||||
expect(approve).toHaveBeenCalledWith('norway');
|
|
||||||
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
|
|
||||||
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
|
|
||||||
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
|
|
||||||
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('paddleRegionsModel', () => {
|
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,7 +15,6 @@ 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';
|
||||||
|
|
||||||
@@ -23,10 +22,8 @@ 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, and (PAD-11C) how TREK loads the region of a
|
* picked from Geofabrik's list. Both the desktop and the phone admin show
|
||||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
* this one card under the addons, while the Tours addon is on.
|
||||||
* 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 (
|
||||||
@@ -70,8 +67,6 @@ 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>
|
||||||
@@ -221,41 +216,6 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* PAD-11: the water network the router follows in a region, as one figure
|
|
||||||
* with the rest in its tooltip. A region read before routing existed says it
|
|
||||||
* needs reading again; a network is never claimed where none was built.
|
|
||||||
*/
|
|
||||||
function RegionNetwork({ region }: { region: PaddleRegion }) {
|
|
||||||
const { t, language } = useTranslation();
|
|
||||||
const { network } = region;
|
|
||||||
if (!network) {
|
|
||||||
if (!region.imported_at || region.status === 'importing') return null;
|
|
||||||
return (
|
|
||||||
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
|
|
||||||
<span className="inline-flex">
|
|
||||||
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
|
|
||||||
const details = [
|
|
||||||
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
|
|
||||||
t('admin.paddleRegions.network.portages', { count: network.portages }),
|
|
||||||
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
|
|
||||||
t('admin.paddleRegions.network.locks', { count: network.locks }),
|
|
||||||
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
|
|
||||||
].join(', ');
|
|
||||||
return (
|
|
||||||
<Tooltip label={details}>
|
|
||||||
<span className="inline-flex" data-testid="paddle-region-network">
|
|
||||||
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
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;
|
||||||
@@ -279,12 +239,10 @@ 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}>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
|
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
||||||
import { createElement, type ReactNode } from 'react';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
|
||||||
|
|
||||||
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
|
|
||||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
|
||||||
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
|
|
||||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
|
||||||
|
|
||||||
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
|
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
|
||||||
|
|
||||||
describe('usePaddleAutoLoad', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
|
|
||||||
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
|
|
||||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
|
||||||
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
|
|
||||||
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
|
|
||||||
expect(repo.settings).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
|
|
||||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
|
||||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.save({ mode: 'ask' });
|
|
||||||
});
|
|
||||||
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
|
|
||||||
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
|
|
||||||
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
|
|
||||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
|
||||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.save({ max_mb: 2000 });
|
|
||||||
});
|
|
||||||
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
|
|
||||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import type { PaddleRegionSettings } from '@trek/shared';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTranslation } from '../../../i18n';
|
|
||||||
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
|
||||||
import { getApiErrorMessage } from '../../../types';
|
|
||||||
import { useToast } from '../../shared/Toast';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PAD-11C in the admin area: how TREK loads the water data of a place a
|
|
||||||
* paddler plans in, read once the card shows and saved on every change. A
|
|
||||||
* change shows at once and goes back, with a toast, when the server refuses
|
|
||||||
* it. While one is saved the controls rest, so two saves never cross.
|
|
||||||
*/
|
|
||||||
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const toast = useToast();
|
|
||||||
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const pending = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!active || offline) return;
|
|
||||||
let cancelled = false;
|
|
||||||
paddleRegionRepo.settings().then(
|
|
||||||
(loaded) => {
|
|
||||||
if (!cancelled) setSettings(loaded);
|
|
||||||
},
|
|
||||||
(err: unknown) => {
|
|
||||||
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [active, offline, t, toast]);
|
|
||||||
|
|
||||||
const save = useCallback(
|
|
||||||
async (change: Partial<PaddleRegionSettings>) => {
|
|
||||||
if (!settings || pending.current) return;
|
|
||||||
const before = settings;
|
|
||||||
const next = { ...settings, ...change };
|
|
||||||
pending.current = true;
|
|
||||||
setSaving(true);
|
|
||||||
setSettings(next);
|
|
||||||
try {
|
|
||||||
setSettings(await paddleRegionRepo.saveSettings(next));
|
|
||||||
} catch (err) {
|
|
||||||
setSettings(before);
|
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
|
||||||
} finally {
|
|
||||||
pending.current = false;
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[settings, t, toast]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { settings, saving, save };
|
|
||||||
}
|
|
||||||
@@ -24,15 +24,11 @@ const repo = vi.hoisted(() => ({
|
|||||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
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')>()),
|
||||||
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
paddleRegionRepo: repo,
|
||||||
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
|
||||||
}));
|
}));
|
||||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||||
|
|
||||||
@@ -44,13 +40,11 @@ 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, PaddleRegionPending } from '@trek/shared';
|
import type { PaddleRegion, PaddleRegionCatalogEntry } 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,7 +7,6 @@ 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;
|
||||||
@@ -24,14 +23,12 @@ function errorText(t: Translate, err: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regions as the server holds them, with those paddlers asked for, asked
|
* The regions as the server holds them, asked again every few seconds while
|
||||||
* again every few seconds while any of them is read, and on `reload` after a
|
* any of them is read, and on `reload` after a change. Every answer is set
|
||||||
* 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);
|
||||||
@@ -41,8 +38,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
|||||||
paddleRegionRepo.list().then(
|
paddleRegionRepo.list().then(
|
||||||
(list) => {
|
(list) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRegions(list.regions);
|
setRegions(list);
|
||||||
setRequests(list.requests);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
},
|
},
|
||||||
@@ -63,7 +59,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, requests, loading: !loaded && !offline, error, reload };
|
return { regions, loading: !loaded && !offline, error, reload };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||||
@@ -103,8 +99,7 @@ function useCatalogSearch(offline: boolean, t: Translate) {
|
|||||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
* 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. PAD-11C: how TREK loads regions
|
* a read follows without reloading the page.
|
||||||
* 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();
|
||||||
@@ -112,7 +107,6 @@ 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);
|
||||||
@@ -152,12 +146,6 @@ 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);
|
||||||
@@ -169,8 +157,6 @@ 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,
|
||||||
@@ -179,8 +165,6 @@ 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', 'journal_link'] },
|
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useId, useState } from 'react'
|
import { useId, useState } from 'react'
|
||||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||||
import { Tooltip } from '../../shared/Tooltip'
|
|
||||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||||
import { useConnectForm } from './useConnectForm'
|
import { useConnectForm } from './useConnectForm'
|
||||||
import { Badge } from './DocSyncBits'
|
import { Badge } from './DocSyncBits'
|
||||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
import type { DocSyncProvider, useDocSync } from './useDocSync'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
||||||
@@ -33,7 +32,7 @@ export default function DocSyncConnectModal({
|
|||||||
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
||||||
|
|
||||||
const form = useConnectForm(provider, existing, sync)
|
const form = useConnectForm(provider, existing, sync)
|
||||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||||
|
|
||||||
const test = async () => setVerdict(await form.probe())
|
const test = async () => setVerdict(await form.probe())
|
||||||
const labelId = useId()
|
const labelId = useId()
|
||||||
@@ -135,7 +134,7 @@ function Verdict({
|
|||||||
busy,
|
busy,
|
||||||
failure,
|
failure,
|
||||||
}: {
|
}: {
|
||||||
verdict: DocSyncVerdict | null
|
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||||
busy: boolean
|
busy: boolean
|
||||||
/** A call that failed outside the connection test (saving, most of all). */
|
/** A call that failed outside the connection test (saving, most of all). */
|
||||||
failure: string | null
|
failure: string | null
|
||||||
@@ -173,16 +172,10 @@ function Verdict({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
||||||
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
|
||||||
// The footer shares its row with the buttons, and a refusal cut to "The
|
|
||||||
// credentials were re..." with no way to read the rest left nothing to go on
|
|
||||||
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
|
||||||
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
|
||||||
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
</span>
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The credential form's rules, for both shells.
|
* The credential form's rules, for both shells.
|
||||||
@@ -39,7 +39,7 @@ export interface ConnectForm {
|
|||||||
setInsecureTls: (on: boolean) => void
|
setInsecureTls: (on: boolean) => void
|
||||||
/** Every required field is either filled in or already stored. */
|
/** Every required field is either filled in or already stored. */
|
||||||
complete: boolean
|
complete: boolean
|
||||||
probe: () => Promise<DocSyncVerdict>
|
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
|
||||||
save: () => Promise<boolean>
|
save: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ import { useServerPing } from '../../../sync/useServerPing'
|
|||||||
* reason for the same problem.
|
* reason for the same problem.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
|
|
||||||
export interface DocSyncVerdict {
|
|
||||||
connected: boolean
|
|
||||||
account?: string
|
|
||||||
error?: string
|
|
||||||
detail?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocSyncField {
|
export interface DocSyncField {
|
||||||
field_key: string
|
field_key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -272,9 +264,11 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
|||||||
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
||||||
setBusy('test')
|
setBusy('test')
|
||||||
try {
|
try {
|
||||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
|
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
|
||||||
|
connected: boolean; account?: string; error?: string; detail?: string
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
|
return { connected: false, error: readError(e) }
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,19 +98,6 @@ 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, JourneyTripAccess } from '../../store/journeyStore'
|
import type { JourneyContributor, JourneyDetail } 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,8 +277,6 @@ 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>
|
||||||
|
|
||||||
@@ -326,32 +324,3 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
|
||||||
* contributors: shown here, but managed in that trip's settings, so read only.
|
|
||||||
*/
|
|
||||||
function TripAccessSection({ access, roleLabel }: {
|
|
||||||
access: JourneyTripAccess[] | undefined
|
|
||||||
roleLabel: (role: JourneyContributor['role']) => string
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
if (!access?.length) return null
|
|
||||||
return (
|
|
||||||
<DialogSection label={t('journey.contributors.viaTrips')}>
|
|
||||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
|
||||||
<div className={ROWS}>
|
|
||||||
{access.map(a => (
|
|
||||||
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
|
||||||
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
|
||||||
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
|
||||||
</div>
|
|
||||||
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
|
|||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { visibleManagedNavItems } from '../../managed'
|
import { visibleManagedNavItems } from '../../managed'
|
||||||
import { isNavItemActive, useCreateAction } from './useCreateAction'
|
import { isNavItemActive, useCreateAction } from './useCreateAction'
|
||||||
|
import { useIsDark } from '../../hooks/useIsDark'
|
||||||
|
|
||||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||||
@@ -22,8 +23,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
|
|||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const darkMode = useSettingsStore(s => s.settings.dark_mode)
|
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = useIsDark()
|
||||||
const addons = useAddonStore(s => s.addons)
|
const addons = useAddonStore(s => s.addons)
|
||||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||||
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop
|
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
|
||||||
|
import { useIsDark } from '../../hooks/useIsDark'
|
||||||
|
|
||||||
export default function InAppNotificationBell(): React.ReactElement {
|
export default function InAppNotificationBell(): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
const darkMode = settings.dark_mode
|
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = useIsDark()
|
||||||
|
|
||||||
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
const isAuthenticated = useAuthStore(s => s.isAuthenticated)
|
||||||
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
|
||||||
|
|||||||
@@ -169,24 +169,23 @@ describe('Navbar', () => {
|
|||||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||||
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
render(<Navbar onShare={vi.fn()} />);
|
||||||
const back = screen.getByRole('button', { name: /back/i });
|
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||||
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
expect(shareBtn).toBeInTheDocument();
|
||||||
expect(back.parentElement).toBe(settings.closest('div'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSettings = vi.fn();
|
const onShare = vi.fn();
|
||||||
render(<Navbar onSettings={onSettings} />);
|
render(<Navbar onShare={onShare} />);
|
||||||
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||||
expect(onSettings).toHaveBeenCalled();
|
await user.click(shareBtn);
|
||||||
|
expect(onShare).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||||
render(<Navbar />);
|
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -454,8 +453,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 and theme buttons reset their hover background', () => {
|
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
||||||
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
||||||
|
|
||||||
const back = screen.getByRole('button', { name: /back/i });
|
const back = screen.getByRole('button', { name: /back/i });
|
||||||
fireEvent.mouseEnter(back);
|
fireEvent.mouseEnter(back);
|
||||||
@@ -463,6 +462,11 @@ 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,13 +6,14 @@ 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, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import 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'
|
||||||
import { resolvePluginIcon } from '../shared/PluginIcon'
|
import { resolvePluginIcon } from '../shared/PluginIcon'
|
||||||
import { visibleManagedNavItems } from '../../managed'
|
import { visibleManagedNavItems } from '../../managed'
|
||||||
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
|
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
|
||||||
|
import { useIsDark } from '../../hooks/useIsDark'
|
||||||
|
|
||||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
|
||||||
|
|
||||||
@@ -21,8 +22,7 @@ interface NavbarProps {
|
|||||||
tripId?: number | string
|
tripId?: number | string
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
showBack?: boolean
|
showBack?: boolean
|
||||||
/** The trip's Settings: who is on it, its links and its journal. */
|
onShare?: () => void
|
||||||
onSettings?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Addon {
|
interface Addon {
|
||||||
@@ -33,7 +33,7 @@ interface Addon {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||||
@@ -42,8 +42,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||||
const [scrolled, setScrolled] = useState<boolean>(false)
|
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||||
const darkMode = settings.dark_mode
|
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = useIsDark()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
||||||
@@ -122,7 +122,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
|||||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
height: 'var(--nav-h)',
|
height: 'var(--nav-h)',
|
||||||
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
}} className="trek-navbar hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
|
||||||
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so
|
||||||
the tab pill between them sits in the middle of the bar rather than
|
the tab pill between them sits in the middle of the bar rather than
|
||||||
being centred on top of both (#1983). Both columns keep at least their
|
being centred on top of both (#1983). Both columns keep at least their
|
||||||
@@ -174,21 +174,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
|||||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
<span 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).
|
||||||
@@ -261,6 +246,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
|||||||
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,7 +8,6 @@ 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'
|
||||||
@@ -1159,7 +1158,8 @@ 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.
|
||||||
When the route can be reshaped an invisible band wider than both takes the click. */}
|
The casing carries the click when the route can be reshaped: it is the wider of
|
||||||
|
the two, so it is the one a pointer actually lands on. */}
|
||||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
{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,7 +1173,15 @@ 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={false}
|
interactive={!!onRouteClick}
|
||||||
|
eventHandlers={onRouteClick ? {
|
||||||
|
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||||
|
// Stops the map's own click, which would otherwise open the add-place menu
|
||||||
|
// underneath the new via.
|
||||||
|
e.originalEvent.stopPropagation()
|
||||||
|
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||||
|
},
|
||||||
|
} : undefined}
|
||||||
/>,
|
/>,
|
||||||
<Polyline
|
<Polyline
|
||||||
key={`${i}-core`}
|
key={`${i}-core`}
|
||||||
@@ -1181,7 +1189,6 @@ export const MapView = memo(function MapView({
|
|||||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
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,25 +2,18 @@ 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(
|
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||||
pinCss: PinCss,
|
const size = selected ? 30 : 26;
|
||||||
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">${text}</span>`,
|
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +25,7 @@ export default function PlannerWaypointMarker({
|
|||||||
onMove,
|
onMove,
|
||||||
pinCss,
|
pinCss,
|
||||||
}: {
|
}: {
|
||||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
|
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
||||||
index: number;
|
index: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick?: (id: string) => void;
|
onClick?: (id: string) => void;
|
||||||
@@ -46,11 +39,7 @@ 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 { label, shaping } = point;
|
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||||
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 }) => {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Polyline } from 'react-leaflet';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
|
||||||
* than the drawn line, that takes the click which reshapes the route, as the
|
|
||||||
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
|
|
||||||
*/
|
|
||||||
export function RouteHitBand({
|
|
||||||
positions,
|
|
||||||
onRouteClick,
|
|
||||||
}: {
|
|
||||||
positions: [number, number][];
|
|
||||||
onRouteClick: (lat: number, lng: number) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Polyline
|
|
||||||
positions={positions}
|
|
||||||
pathOptions={{
|
|
||||||
color: 'transparent',
|
|
||||||
weight: 20,
|
|
||||||
opacity: 0,
|
|
||||||
lineCap: 'round',
|
|
||||||
lineJoin: 'round',
|
|
||||||
className: 'cursor-copy',
|
|
||||||
}}
|
|
||||||
eventHandlers={{
|
|
||||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
|
||||||
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
|
||||||
e.originalEvent.stopPropagation();
|
|
||||||
onRouteClick(e.latlng.lat, e.latlng.lng);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -110,15 +110,7 @@ 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<{
|
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||||
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. */
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { plannerPinShape } from './plannerPinShape';
|
|
||||||
|
|
||||||
describe('plannerPinShape', () => {
|
|
||||||
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
|
||||||
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
|
|
||||||
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
|
|
||||||
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
|
||||||
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
|
||||||
export interface PlannerPinPoint {
|
|
||||||
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
|
||||||
label?: string;
|
|
||||||
shaping?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
|
||||||
export function plannerPinShape(
|
|
||||||
point: PlannerPinPoint,
|
|
||||||
index: number,
|
|
||||||
selected: boolean
|
|
||||||
): { size: number; text: string } {
|
|
||||||
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
|
||||||
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
|
||||||
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
|
|
||||||
|
|
||||||
vi.mock('./RouteCalculator', async (importActual) => {
|
|
||||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
|
||||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
|
||||||
});
|
|
||||||
|
|
||||||
const WAYPOINTS = [
|
|
||||||
{ lat: 47.5, lng: 19.04 },
|
|
||||||
{ lat: 47.49, lng: 19.05 },
|
|
||||||
];
|
|
||||||
const ROUTED = {
|
|
||||||
coordinates: [
|
|
||||||
[47.5, 19.04],
|
|
||||||
[47.49, 19.05],
|
|
||||||
],
|
|
||||||
legs: [],
|
|
||||||
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
describe('calculateRouteWithRetries', () => {
|
|
||||||
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
|
|
||||||
vi.mocked(calculateRouteWithLegs)
|
|
||||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
|
||||||
.mockResolvedValueOnce(ROUTED);
|
|
||||||
|
|
||||||
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
|
|
||||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
|
||||||
|
|
||||||
await expect(answer).resolves.toBe(ROUTED);
|
|
||||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits as long as the host asks when that is longer', async () => {
|
|
||||||
vi.mocked(calculateRouteWithLegs)
|
|
||||||
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
|
|
||||||
.mockResolvedValueOnce(ROUTED);
|
|
||||||
|
|
||||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
|
||||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
|
||||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
|
||||||
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
|
|
||||||
|
|
||||||
await expect(answer).resolves.toBe(ROUTED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives up after the last delay and throws the refusal', async () => {
|
|
||||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
|
|
||||||
|
|
||||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
|
||||||
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
|
|
||||||
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
|
|
||||||
|
|
||||||
await settled;
|
|
||||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a refusal the host meant at once', async () => {
|
|
||||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
|
|
||||||
|
|
||||||
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
|
|
||||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops waiting when the day changes', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
vi.mocked(calculateRouteWithLegs)
|
|
||||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
|
||||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
|
|
||||||
|
|
||||||
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
|
|
||||||
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
await settled;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
|
||||||
|
|
||||||
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
|
|
||||||
export type { RouteProfileKey } from './RouteCalculator';
|
|
||||||
|
|
||||||
/** How often a rate-limited route is asked for again, and how long after. */
|
|
||||||
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
|
|
||||||
|
|
||||||
/** A pause that ends early, without throwing, when the signal aborts. */
|
|
||||||
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
if (signal?.aborted || ms <= 0) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(done, ms);
|
|
||||||
function done(): void {
|
|
||||||
clearTimeout(timer);
|
|
||||||
signal?.removeEventListener('abort', done);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
signal?.addEventListener('abort', done, { once: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `calculateRouteWithLegs`, asked again while the host says it is busy.
|
|
||||||
*
|
|
||||||
* The public OSRM hosts allow about one request a second and answer 429 above that. A
|
|
||||||
* day with a change of mode, or a quick switch between days, asks more often than that,
|
|
||||||
* and the day route used to give up on the first refusal and keep its straight lines
|
|
||||||
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
|
|
||||||
* as long as the host asks when it says; a refusal the host meant is thrown at once,
|
|
||||||
* because the same request earns the same answer. An abort ends the wait, and the next
|
|
||||||
* attempt then throws the AbortError the caller already handles.
|
|
||||||
*/
|
|
||||||
export async function calculateRouteWithRetries(
|
|
||||||
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
|
|
||||||
): ReturnType<typeof calculateRouteWithLegs> {
|
|
||||||
for (let attempt = 0; ; attempt++) {
|
|
||||||
try {
|
|
||||||
return await calculateRouteWithLegs(waypoints, opts);
|
|
||||||
} catch (err) {
|
|
||||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
|
|
||||||
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
|
|
||||||
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { RouteProfileKey } from './RouteCalculator'
|
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||||
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
|
|
||||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
||||||
import { projectDayItinerary } from './dayTourProjection'
|
import { projectDayItinerary } from './dayTourProjection'
|
||||||
@@ -186,6 +185,20 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
|||||||
const REQUEST_SPACING_MS = 1100
|
const REQUEST_SPACING_MS = 1100
|
||||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||||
const CACHE_HIT_MS = 60
|
const CACHE_HIT_MS = 60
|
||||||
|
/** How often a rate-limited leg is tried again, and how long after. */
|
||||||
|
const RETRY_DELAYS_MS = [1500, 4000]
|
||||||
|
|
||||||
|
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||||
|
const timer = setTimeout(done, ms)
|
||||||
|
function done(): void {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal?.removeEventListener('abort', done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', done, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ask the router for every leg of every day, one at a time.
|
* Ask the router for every leg of every day, one at a time.
|
||||||
@@ -220,16 +233,23 @@ export async function routeTripLegs(
|
|||||||
runs.forEach((chunks, r) => {
|
runs.forEach((chunks, r) => {
|
||||||
chunks.forEach((chunk, c) => {
|
chunks.forEach((chunk, c) => {
|
||||||
tasks.push(async () => {
|
tasks.push(async () => {
|
||||||
if (signal?.aborted) return
|
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||||
try {
|
if (signal?.aborted) return
|
||||||
const answer = await calculateRouteWithRetries(
|
try {
|
||||||
chunk.waypoints,
|
const answer = await calculateRouteWithLegs(
|
||||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
chunk.waypoints,
|
||||||
)
|
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
)
|
||||||
onAnswer?.(routed)
|
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||||
} catch {
|
onAnswer?.(routed)
|
||||||
// Refused for good, or aborted: the leg keeps its straight line.
|
return
|
||||||
|
} catch (err) {
|
||||||
|
if (signal?.aborted) return
|
||||||
|
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||||
|
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||||
|
// When the host says how long to wait, waiting less is just a second refusal.
|
||||||
|
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
import { 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. */
|
||||||
@@ -72,10 +71,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, text } = plannerPinShape(point, index, selected);
|
const size = selected ? 30 : 26;
|
||||||
const el = document.createElement('button');
|
const el = document.createElement('button');
|
||||||
el.type = 'button';
|
el.type = 'button';
|
||||||
el.textContent = text;
|
el.textContent = String(index + 1);
|
||||||
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`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
import type { InAppNotification } from '../../store/inAppNotificationStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
|
||||||
|
import { useIsDark } from '../../hooks/useIsDark'
|
||||||
|
|
||||||
function relativeTime(dateStr: string, locale: string): string {
|
function relativeTime(dateStr: string, locale: string): string {
|
||||||
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
|
||||||
@@ -17,8 +18,8 @@ interface NotificationItemProps {
|
|||||||
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { settings } = useSettingsStore()
|
const { settings } = useSettingsStore()
|
||||||
const darkMode = settings.dark_mode
|
// Off the .dark class: a special theme is dark whatever the stored mode says.
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = useIsDark()
|
||||||
const { responding, handleRespond, handleNavigate, markRead, deleteNotification } =
|
const { responding, handleRespond, handleNavigate, markRead, deleteNotification } =
|
||||||
useNotificationItemActions(notification, onClose)
|
useNotificationItemActions(notification, onClose)
|
||||||
|
|
||||||
|
|||||||
@@ -271,9 +271,7 @@ describe('PackingListPanel', () => {
|
|||||||
];
|
];
|
||||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||||
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
// Only the category grip stays: the order of the categories is not the A to Z of their items.
|
expect(container.querySelector('[draggable="true"]')).toBeNull();
|
||||||
const draggable = [...container.querySelectorAll('[draggable="true"]')];
|
|
||||||
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import {
|
import {
|
||||||
Trash2, Plus, ChevronDown, ChevronRight,
|
Trash2, Plus, ChevronDown, ChevronRight,
|
||||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
|
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { PackingItem, PackingBag } from '../../types'
|
import type { PackingItem, PackingBag } from '../../types'
|
||||||
import { katColor } from './packingListPanel.helpers'
|
import { katColor } from './packingListPanel.helpers'
|
||||||
@@ -38,16 +38,6 @@ interface KategorieGruppeProps {
|
|||||||
// Off while the list is shown A-Z: a drop there would move an item in an order
|
// Off while the list is shown A-Z: a drop there would move an item in an order
|
||||||
// the reader cannot see.
|
// the reader cannot see.
|
||||||
reorderable?: boolean
|
reorderable?: boolean
|
||||||
// Moving the whole category among the others, wired by the list, which sees every
|
|
||||||
// card. `onDrop` gets the category that was dragged onto this one.
|
|
||||||
categoryDrag?: {
|
|
||||||
isDragging: boolean
|
|
||||||
isOver: boolean
|
|
||||||
onStart: () => void
|
|
||||||
onOver: () => void
|
|
||||||
onEnd: () => void
|
|
||||||
onDrop: (dragged: string) => void
|
|
||||||
}
|
|
||||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||||
currentUserId?: number
|
currentUserId?: number
|
||||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||||
@@ -56,7 +46,7 @@ interface KategorieGruppeProps {
|
|||||||
onLeave?: (id: number, userId: number) => void
|
onLeave?: (id: number, userId: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||||
const [offen, setOffen] = useState(true)
|
const [offen, setOffen] = useState(true)
|
||||||
const [dragId, setDragId] = useState<number | null>(null)
|
const [dragId, setDragId] = useState<number | null>(null)
|
||||||
const [overId, setOverId] = useState<number | null>(null)
|
const [overId, setOverId] = useState<number | null>(null)
|
||||||
@@ -130,9 +120,8 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
return (
|
return (
|
||||||
// Shaped after the phone's category card: a tinted head band carrying the
|
// Shaped after the phone's category card: a tinted head band carrying the
|
||||||
// name as written, the count, and the fold arrow on the far right.
|
// name as written, the count, and the fold arrow on the far right.
|
||||||
<CategoryDropFrame drag={categoryDrag}>
|
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||||
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
|
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||||
|
|
||||||
{editingName && canEdit ? (
|
{editingName && canEdit ? (
|
||||||
@@ -355,65 +344,6 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CategoryDropFrame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
|
|
||||||
|
|
||||||
/** The data type a dragged category carries, its name as the value. */
|
|
||||||
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The card, and while a category is dragged its drop target. Captured, because
|
|
||||||
* the item rows stop their own drops from bubbling.
|
|
||||||
*/
|
|
||||||
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
|
|
||||||
// Told apart by the drag's own data type, which is there from the first event on:
|
|
||||||
// a flag set in state on dragstart can still be unrendered when a quick drag
|
|
||||||
// arrives, and a dragged item must pass through to its rows untouched.
|
|
||||||
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onDragOverCapture={drag ? (e => {
|
|
||||||
if (!isCategoryDrag(e)) return
|
|
||||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
|
|
||||||
}) : undefined}
|
|
||||||
onDropCapture={drag ? (e => {
|
|
||||||
if (!isCategoryDrag(e)) return
|
|
||||||
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
|
|
||||||
}) : undefined}
|
|
||||||
style={{
|
|
||||||
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
|
|
||||||
opacity: drag?.isDragging ? 0.4 : 1,
|
|
||||||
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
|
|
||||||
transition: 'opacity 0.15s, box-shadow 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The handle a category is dragged by, in the head band before its colour dot. */
|
|
||||||
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
|
|
||||||
if (!drag) return null
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
draggable
|
|
||||||
role="button"
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
onDragStart={e => {
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
|
|
||||||
e.dataTransfer.setData('text/plain', kategorie)
|
|
||||||
drag.onStart()
|
|
||||||
}}
|
|
||||||
onDragEnd={drag.onEnd}
|
|
||||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
|
|
||||||
>
|
|
||||||
<GripVertical size={14} />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import type { PackingState } from './usePackingListPanel'
|
import type { PackingState } from './usePackingListPanel'
|
||||||
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||||
import EmptyState from '../shared/EmptyState'
|
import EmptyState from '../shared/EmptyState'
|
||||||
@@ -8,13 +7,8 @@ export function PackingList(S: PackingState) {
|
|||||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
|
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
|
||||||
} = S
|
} = S
|
||||||
// Which category is being dragged and which card it is over. Held here, above
|
|
||||||
// the cards, because the drag starts in one card and ends in another.
|
|
||||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
|
||||||
const [overCategory, setOverCategory] = useState<string | null>(null)
|
|
||||||
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
@@ -46,14 +40,6 @@ export function PackingList(S: PackingState) {
|
|||||||
allItems={items}
|
allItems={items}
|
||||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||||
reorderable={sort === 'manual'}
|
reorderable={sort === 'manual'}
|
||||||
categoryDrag={canEdit ? {
|
|
||||||
isDragging: draggedCategory === kat,
|
|
||||||
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
|
|
||||||
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
|
|
||||||
onOver: () => setOverCategory(kat),
|
|
||||||
onEnd: endCategoryDrag,
|
|
||||||
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
|
|
||||||
} : undefined}
|
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onSetSharing={handleSetSharing}
|
onSetSharing={handleSetSharing}
|
||||||
onClone={handleCloneItem}
|
onClone={handleCloneItem}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
groupPackingItems,
|
groupPackingItems,
|
||||||
isLastCustomItemInCategory,
|
isLastCustomItemInCategory,
|
||||||
isPackingPlaceholder,
|
isPackingPlaceholder,
|
||||||
itemOrderWithCategoryMoved,
|
|
||||||
packingCategoryOrder,
|
packingCategoryOrder,
|
||||||
packingItemWeight,
|
packingItemWeight,
|
||||||
packingProgress,
|
packingProgress,
|
||||||
@@ -15,7 +14,7 @@ import {
|
|||||||
} from './packingListModel';
|
} from './packingListModel';
|
||||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||||
|
|
||||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
|
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||||
|
|
||||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||||
@@ -77,28 +76,4 @@ describe('packingListModel', () => {
|
|||||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||||
});
|
});
|
||||||
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
|
|
||||||
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
|
|
||||||
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
|
|
||||||
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
|
|
||||||
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
|
|
||||||
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
|
|
||||||
const items = [day1, day2a, own, day3, day2b];
|
|
||||||
const common = packingViewItems(items, 'common');
|
|
||||||
|
|
||||||
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
|
|
||||||
expect(order).toEqual([14, 11, 12, 15, 13]);
|
|
||||||
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
|
|
||||||
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
|
|
||||||
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
|
|
||||||
|
|
||||||
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
|
|
||||||
const items = [shared, stove, loose];
|
|
||||||
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
|
|
||||||
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
|
|
||||||
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,34 +25,6 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
|
|||||||
return seen;
|
return seen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole list's item order with category `from` moved to where `to` stands.
|
|
||||||
*
|
|
||||||
* A category has no position of its own: it shows where its first item is. So a
|
|
||||||
* category is moved by moving its items, as one block, through the item reorder
|
|
||||||
* the list already has; nothing new is stored, and the order reaches every device
|
|
||||||
* the way a dragged item does. The view's items come first, grouped in the new
|
|
||||||
* order with each group in its own order; the other view's items follow in theirs,
|
|
||||||
* which leaves that view's categories as they were. Null when nothing would move.
|
|
||||||
*/
|
|
||||||
export function itemOrderWithCategoryMoved(
|
|
||||||
items: PackingItem[],
|
|
||||||
viewItems: PackingItem[],
|
|
||||||
from: string,
|
|
||||||
to: string,
|
|
||||||
defaultCategory: string
|
|
||||||
): number[] | null {
|
|
||||||
const order = packingCategoryOrder(viewItems, defaultCategory);
|
|
||||||
const fromIndex = order.indexOf(from);
|
|
||||||
const toIndex = order.indexOf(to);
|
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
|
||||||
order.splice(fromIndex, 1);
|
|
||||||
order.splice(toIndex, 0, from);
|
|
||||||
const inView = new Set(viewItems.map((i) => i.id));
|
|
||||||
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
|
|
||||||
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||||
if (status === 'open') return items.filter((i) => !i.checked);
|
if (status === 'open') return items.filter((i) => !i.checked);
|
||||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
|
|||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import type { PackingItem } from '../../types'
|
import type { PackingItem } from '../../types'
|
||||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||||
import { usePackingBags } from './usePackingBags'
|
import { usePackingBags } from './usePackingBags'
|
||||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||||
import { usePackingImport } from './usePackingImport'
|
import { usePackingImport } from './usePackingImport'
|
||||||
@@ -106,13 +106,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
|||||||
|
|
||||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||||
|
|
||||||
// A category dropped onto another takes its place; the slice reorders
|
|
||||||
// optimistically and rolls back with a toast of its own.
|
|
||||||
const moveCategory = (from: string, to: string) => {
|
|
||||||
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
|
|
||||||
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||||
@@ -220,7 +213,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
|||||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||||
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
|
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
|
||||||
<div ref={bodyRef}>
|
<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">
|
||||||
|
|||||||
@@ -308,10 +308,9 @@ export default function AccountTab(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="text"
|
inputMode="numeric"
|
||||||
autoComplete="one-time-code"
|
|
||||||
value={mfaDisableCode}
|
value={mfaDisableCode}
|
||||||
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
placeholder={t('settings.mfa.codePlaceholder')}
|
placeholder={t('settings.mfa.codePlaceholder')}
|
||||||
aria-label={t('settings.mfa.codePlaceholder')}
|
aria-label={t('settings.mfa.codePlaceholder')}
|
||||||
className={`${INPUT} font-geist tabular-nums`}
|
className={`${INPUT} font-geist tabular-nums`}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { Segmented } from '../shared/dialogParts'
|
|||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
import ToggleSwitch from './ToggleSwitch'
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
|
||||||
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes'
|
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS, SPECIAL_THEMES } from '../../theme/schemes'
|
||||||
|
import SpecialThemeCard from '../../theme/special/SpecialThemeCard'
|
||||||
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
|
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
|
||||||
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
|
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
|
||||||
import { useAppearanceEditor } from './useAppearanceEditor'
|
import { useAppearanceEditor } from './useAppearanceEditor'
|
||||||
@@ -35,7 +36,7 @@ const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border
|
|||||||
export default function AppearanceSettingsTab(): React.ReactElement {
|
export default function AppearanceSettingsTab(): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const tr = (key: string, fallback: string) => t(key) || fallback
|
const tr = (key: string, fallback: string) => t(key) || fallback
|
||||||
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
|
const { cfg, darkMode, isDark, modeLocked, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
|
||||||
useAppearanceEditor()
|
useAppearanceEditor()
|
||||||
|
|
||||||
// The stored mode, read the way the old boolean values meant it.
|
// The stored mode, read the way the old boolean values meant it.
|
||||||
@@ -50,7 +51,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
|||||||
{/* Color mode */}
|
{/* Color mode */}
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={tr('settings.colorMode', 'Color mode')}
|
label={tr('settings.colorMode', 'Color mode')}
|
||||||
|
hint={modeLocked ? t('settings.appearance.special.modeLocked') : undefined}
|
||||||
control={
|
control={
|
||||||
|
<div className={modeLocked ? 'pointer-events-none opacity-45' : undefined} aria-disabled={modeLocked || undefined}>
|
||||||
<Segmented
|
<Segmented
|
||||||
label={tr('settings.colorMode', 'Color mode')}
|
label={tr('settings.colorMode', 'Color mode')}
|
||||||
value={mode}
|
value={mode}
|
||||||
@@ -70,9 +73,20 @@ export default function AppearanceSettingsTab(): React.ReactElement {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Special themes: a whole look, not just an accent */}
|
||||||
|
<SettingRow label={t('settings.appearance.special')} stacked>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{SPECIAL_THEMES.map((s) => (
|
||||||
|
<SpecialThemeCard key={s.id} theme={s} active={cfg.schemeId === s.id}
|
||||||
|
onSelect={() => update({ schemeId: s.id })} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{/* Color scheme swatches */}
|
{/* Color scheme swatches */}
|
||||||
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
|
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
@@ -290,6 +304,7 @@ function schemeFallback(id: string): string {
|
|||||||
rose: 'Rose',
|
rose: 'Rose',
|
||||||
amber: 'Amber',
|
amber: 'Amber',
|
||||||
violet: 'Violet',
|
violet: 'Violet',
|
||||||
|
halloween: 'Halloween',
|
||||||
}
|
}
|
||||||
return map[id] || id
|
return map[id] || id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { DEFAULT_APPEARANCE, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
|
import { DEFAULT_APPEARANCE, isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useIsDark } from '../../hooks/useIsDark';
|
||||||
import { useTranslation } from '../../i18n';
|
import { useTranslation } from '../../i18n';
|
||||||
import { useSettingsStore } from '../../store/settingsStore';
|
import { useSettingsStore } from '../../store/settingsStore';
|
||||||
import { applyAppearance } from '../../theme/applyAppearance';
|
import { applyAppearance } from '../../theme/applyAppearance';
|
||||||
@@ -42,10 +43,10 @@ export function useAppearanceEditor() {
|
|||||||
[updateSetting]
|
[updateSetting]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDark =
|
// Off the `.dark` class: a special theme forces dark whatever the stored mode says.
|
||||||
settings.dark_mode === true ||
|
const isDark = useIsDark();
|
||||||
settings.dark_mode === 'dark' ||
|
// While a special theme is on, the color mode has nothing to switch.
|
||||||
(settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const modeLocked = isSpecialScheme(cfg.schemeId);
|
||||||
|
|
||||||
// Live preview now (DOM), persist after a short debounce (API).
|
// Live preview now (DOM), persist after a short debounce (API).
|
||||||
const update = (patch: Partial<AppearanceConfig>) => {
|
const update = (patch: Partial<AppearanceConfig>) => {
|
||||||
@@ -83,6 +84,7 @@ export function useAppearanceEditor() {
|
|||||||
cfg,
|
cfg,
|
||||||
darkMode: settings.dark_mode,
|
darkMode: settings.dark_mode,
|
||||||
isDark,
|
isDark,
|
||||||
|
modeLocked,
|
||||||
update,
|
update,
|
||||||
setMode,
|
setMode,
|
||||||
setWidget,
|
setWidget,
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { PaddleLakeStyle } from '@trek/shared';
|
|
||||||
import { useTranslation } from '../../../i18n';
|
|
||||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
|
||||||
import { fs } from '../../shared/DialogShell';
|
|
||||||
|
|
||||||
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
|
|
||||||
* shore is for wind, when a capsize near the bank is a short swim. The wind
|
|
||||||
* card suggests it on a windy day; here it is chosen.
|
|
||||||
*/
|
|
||||||
export function PaddleLakeStyleField({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value: PaddleLakeStyle;
|
|
||||||
onChange: (style: PaddleLakeStyle) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div data-testid="tour-planner-lake-style" className="min-w-0">
|
|
||||||
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
|
|
||||||
<Segmented<PaddleLakeStyle>
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
label={t('tours.paddle.lakeStyle')}
|
|
||||||
fill
|
|
||||||
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
|
|
||||||
/>
|
|
||||||
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
|
|
||||||
{t(`tours.paddle.lakeStyle.${value}Hint`)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -33,8 +33,6 @@ 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)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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,9 +7,8 @@ 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 } from '../planner/useTourPlanner';
|
import type { TourPlannerController, TourPlannerWaypoint } 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. */
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
// 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,10 +1,9 @@
|
|||||||
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, Loader2, X } from 'lucide-react';
|
import { ChevronDown, 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';
|
||||||
@@ -200,99 +199,6 @@ 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
|
||||||
@@ -305,7 +211,6 @@ export function PortageLegRow({
|
|||||||
onExtraChange,
|
onExtraChange,
|
||||||
position,
|
position,
|
||||||
onRemove,
|
onRemove,
|
||||||
way,
|
|
||||||
}: {
|
}: {
|
||||||
lengthM: number;
|
lengthM: number;
|
||||||
extraMinutes: number | null;
|
extraMinutes: number | null;
|
||||||
@@ -315,8 +220,6 @@ 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);
|
||||||
@@ -339,7 +242,6 @@ 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-018: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
// 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-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,30 +340,6 @@ 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 PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared';
|
import { compassPointOf, 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,7 +8,6 @@ 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;
|
||||||
@@ -210,58 +209,15 @@ 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, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) {
|
export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||||
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} />}
|
||||||
@@ -293,7 +249,6 @@ export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeSt
|
|||||||
) : (
|
) : (
|
||||||
<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>
|
||||||
@@ -304,13 +259,10 @@ export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeSt
|
|||||||
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} lakes={lakes} />;
|
return <PaddleWindCard wind={wind} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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,8 +34,6 @@ 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)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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,8 +12,6 @@ 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;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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,8 +37,7 @@ import {
|
|||||||
selectPaddleObstacle,
|
selectPaddleObstacle,
|
||||||
usePaddleObstacleStore,
|
usePaddleObstacleStore,
|
||||||
} from '../../../store/paddleObstacleStore';
|
} from '../../../store/paddleObstacleStore';
|
||||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
||||||
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,8 +34,6 @@ 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)',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
// 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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
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,24 +926,6 @@ 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,7 +19,6 @@ 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'
|
||||||
@@ -122,18 +121,6 @@ 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
|
||||||
@@ -145,10 +132,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 = retryable(planner)
|
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||||
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 ? statusText(planner, statusKey, t) : null
|
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : 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">
|
||||||
@@ -339,11 +326,7 @@ function PaddleEditorCards({ planner, locked, checklist }: {
|
|||||||
<>
|
<>
|
||||||
{checklist && <PaddleCheckCard checklist={checklist} />}
|
{checklist && <PaddleCheckCard checklist={checklist} />}
|
||||||
<PaddleTimeCard planner={planner} locked={locked} />
|
<PaddleTimeCard planner={planner} locked={locked} />
|
||||||
<PaddleWindSection
|
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
|
||||||
placeId={planner.editingPlaceId}
|
|
||||||
start={planner.waypoints[0] ?? null}
|
|
||||||
lakes={!locked && crossesLake(planner.paddleRoute) ? { value: planner.paddle.lakeStyle, onChoose: planner.paddle.setLakeStyle } : undefined}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,33 +75,3 @@ 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,8 +10,6 @@ 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';
|
||||||
|
|
||||||
@@ -128,9 +126,6 @@ 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">
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
// 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,24 +1,16 @@
|
|||||||
import {
|
import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared';
|
||||||
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';
|
||||||
@@ -36,16 +28,10 @@ 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 { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
|
import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||||
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 { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
|
import { RailCard, TourNotice } from '../tourParts';
|
||||||
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';
|
||||||
@@ -221,35 +207,18 @@ function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
|||||||
* by the tooltip.
|
* by the tooltip.
|
||||||
*/
|
*/
|
||||||
function WaypointNumber({
|
function WaypointNumber({
|
||||||
stop,
|
index,
|
||||||
selected,
|
selected,
|
||||||
ferrata,
|
ferrata,
|
||||||
loopEnd = false,
|
loopEnd = false,
|
||||||
shaping = false,
|
|
||||||
}: {
|
}: {
|
||||||
/** The stop's number; shaping points do not count, as on the map. */
|
index: number;
|
||||||
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
|
||||||
@@ -261,16 +230,16 @@ function WaypointNumber({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const label = t('tours.planner.waypointLabel', { n: stop });
|
const label = t('tours.planner.waypointLabel', { n: index + 1 });
|
||||||
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={stop}
|
data-waypoint-number={index + 1}
|
||||||
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)}
|
||||||
>
|
>
|
||||||
{stop}
|
{index + 1}
|
||||||
{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>
|
||||||
);
|
);
|
||||||
@@ -309,18 +278,9 @@ function WaypointRowLabel({
|
|||||||
>
|
>
|
||||||
{name ?? coordinates}
|
{name ?? coordinates}
|
||||||
</span>
|
</span>
|
||||||
{point.shaping ? (
|
{paddleRole !== undefined
|
||||||
<Tooltip label={t('tours.planner.shapingHint')}>
|
? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
||||||
<span className="inline-flex">
|
: point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
|
||||||
<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
|
||||||
@@ -342,82 +302,6 @@ 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,
|
||||||
@@ -427,7 +311,7 @@ function WaypointRowActions({
|
|||||||
isSaving,
|
isSaving,
|
||||||
}: {
|
}: {
|
||||||
planner: TourPlannerController;
|
planner: TourPlannerController;
|
||||||
point: WaypointPoint;
|
point: TourPlannerController['waypoints'][number];
|
||||||
index: number;
|
index: number;
|
||||||
lastIndex: number;
|
lastIndex: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
@@ -436,7 +320,10 @@ function WaypointRowActions({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const menu = useContextMenu();
|
const menu = useContextMenu();
|
||||||
const paddle = usePaddleWaypointActions(planner);
|
const paddle = usePaddleWaypointActions(planner);
|
||||||
const items: MenuItems = [
|
const free = point.fixedLeg?.kind === 'free';
|
||||||
|
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,
|
||||||
@@ -449,8 +336,35 @@ function WaypointRowActions({
|
|||||||
disabled: index === lastIndex,
|
disabled: index === lastIndex,
|
||||||
onClick: () => planner.moveWaypoint(point.id, 1),
|
onClick: () => planner.moveWaypoint(point.id, 1),
|
||||||
},
|
},
|
||||||
...routeMenuItems(planner, point, index, lastIndex, t),
|
...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata'
|
||||||
...(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
|
||||||
@@ -488,29 +402,24 @@ 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 && (
|
||||||
@@ -554,154 +463,6 @@ 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,
|
||||||
@@ -728,22 +489,34 @@ 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 && <WaypointListTools planner={planner} isSaving={isSaving} />;
|
const tools = canEdit && planner.waypoints.length > 0 && (
|
||||||
// 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.
|
<span className="flex flex-none items-center gap-0.5">
|
||||||
const stops = stopNumbers.size - (planner.isLoop ? 1 : 0);
|
<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>
|
||||||
|
<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} />}
|
||||||
@@ -835,11 +608,10 @@ export default function TourWaypointList({
|
|||||||
<GripVertical size={14} strokeWidth={1.8} />
|
<GripVertical size={14} strokeWidth={1.8} />
|
||||||
</span>
|
</span>
|
||||||
<WaypointNumber
|
<WaypointNumber
|
||||||
stop={stopNumbers.get(point.id) ?? index + 1}
|
index={index}
|
||||||
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"
|
||||||
@@ -866,23 +638,30 @@ export default function TourWaypointList({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<WaypointLegRows
|
{(legCrossings.get(point.id) ?? []).map((crossing) => (
|
||||||
planner={planner}
|
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||||
point={point}
|
))}
|
||||||
index={index}
|
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
|
||||||
crossings={legCrossings.get(point.id)}
|
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
|
||||||
routedLegs={routedLegs}
|
{paddleLayout && (
|
||||||
paddleLayout={paddleLayout}
|
<PaddleRowExtras
|
||||||
editable={canEdit && !isSaving}
|
planner={planner}
|
||||||
portageRouting={portageRouting}
|
point={point}
|
||||||
regionLoad={regionLoad}
|
index={index}
|
||||||
/>
|
layout={paddleLayout}
|
||||||
|
editable={canEdit && !isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||||
<ShapeOnLineHint planner={planner} canEdit={canEdit} />
|
{planner.isPaddle && (
|
||||||
|
<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 './tourPlannerWaypoints';
|
import type { TourPlannerWaypoint } from './useTourPlanner';
|
||||||
|
|
||||||
/** 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,17 +26,15 @@ 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), with the routed length of each
|
* planner adds those as it does for a hike). A speed the paddler typed that
|
||||||
* 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), legLengths), {
|
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), {
|
||||||
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
|
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
|
||||||
portageSpeedKmh: paddle.portageSpeedKmh,
|
portageSpeedKmh: paddle.portageSpeedKmh,
|
||||||
breakMinutes: null,
|
breakMinutes: null,
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { PaddleFieldValues } from './usePaddleFields';
|
|||||||
* half read.
|
* half read.
|
||||||
*/
|
*/
|
||||||
export interface DraftPayload {
|
export interface DraftPayload {
|
||||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||||
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, 9];
|
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||||
|
|
||||||
function optionalString(value: unknown): boolean {
|
function optionalString(value: unknown): boolean {
|
||||||
return value === undefined || typeof value === 'string';
|
return value === undefined || typeof value === 'string';
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
// 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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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,25 +108,10 @@ 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('routes a paddle tour on the water: a via ferrata goes back to the router, a leg drawn by hand follows its points', () => {
|
it('draws every leg by hand for a paddle tour', () => {
|
||||||
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([undefined, undefined, undefined]);
|
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||||
const drawn = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, C) }, B, C], true);
|
expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C));
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,31 +122,7 @@ 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,12 +14,6 @@ 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). */
|
||||||
@@ -120,46 +114,25 @@ 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 redrawn straight to the waypoint that now follows it,
|
* Every hand-drawn leg, and every portage, redrawn straight to the waypoint
|
||||||
* so moving a point drags it along instead of tearing it off; a leg on the
|
* that now follows it, so moving a point drags them along instead of tearing
|
||||||
* last point leads nowhere and goes. On a hike a portage and a lock are a
|
* them off. A leg on the last point leads nowhere and goes, and so does a lock
|
||||||
* paddler's and go. On a paddle tour (`paddle`) a via ferrata is a hiker's
|
* nobody paddles to on a tour that is no paddle tour. With `allFree` every
|
||||||
* and goes back to the router; a portage and a lock stay, a portage is
|
* leg that is not a portage becomes hand-drawn, the way a paddle tour is planned.
|
||||||
* 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[], paddle = false): T[] {
|
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] {
|
||||||
const aligned = points.map((current, index) => {
|
const aligned = points.map((point, index) => {
|
||||||
const next = points[index + 1];
|
const next = points[index + 1];
|
||||||
const { carryNext, ...own } = current;
|
const leg = point.fixedLeg;
|
||||||
if (carryNext && paddle) {
|
const kept = allFree ? point : dropPaddleOnly(point);
|
||||||
if (!next) return current;
|
const free = allFree || leg?.kind === 'free';
|
||||||
return { ...own, fixedLeg: portageLegBetween(current, next) } as T;
|
if (!free && !(allFree && isPortage(leg))) return kept;
|
||||||
}
|
|
||||||
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)
|
||||||
@@ -167,14 +140,7 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle
|
|||||||
: freeLegBetween(point, next),
|
: freeLegBetween(point, next),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return paddle ? extrasOnFirstLeg(aligned) : aligned;
|
return allFree ? 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. */
|
||||||
@@ -185,21 +151,13 @@ 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) return [...points];
|
if (!point || !next) 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),
|
||||||
@@ -231,15 +189,14 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The leg after this waypoint drawn by hand, or routed again (on a paddle
|
* The leg after this waypoint drawn by hand, or routed again. A via ferrata
|
||||||
* tour: on the water). A via ferrata and a portage keep their line; the last
|
* keeps its line; the last point has no leg to change.
|
||||||
* 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' || isPortage(point.fixedLeg)) return [...points];
|
if (!point || !next || point.fixedLeg?.kind === 'ferrata') 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;
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
// 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,47 +1,36 @@
|
|||||||
import type { TourRoutePart } from './tourRouting';
|
import type { TourRoutePart } from './tourRouting';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAD-03, PAD-11: the paddle paints of the planner map, resolved from the
|
* PAD-03: a portage on the map, in an earth brown over a rim in the map's own
|
||||||
* paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and
|
* white, dotted the way the map draws a walk: the boat is carried there, not
|
||||||
* boat passes in amber, a gap in the data or a leg no route was found for in
|
* paddled. The colours come resolved from the paddle tokens (`usePaddlePaint`).
|
||||||
* red, each over a rim in the map's own white.
|
|
||||||
*/
|
*/
|
||||||
export interface RoutePaint {
|
export interface PortagePaint {
|
||||||
portage: string;
|
line: string;
|
||||||
rapids: string;
|
|
||||||
gap: string;
|
|
||||||
casing: string;
|
casing: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TourRouteStyle {
|
export interface TourRouteStyle {
|
||||||
/** Per line: dotted (a stretch nobody routed: drawn by hand, carried, or a gap) or solid. */
|
/** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */
|
||||||
walking: boolean[] | null;
|
walking: boolean[] | null;
|
||||||
/** Per line: a paddle paint, or the route's own colour where undefined. */
|
/** Per line: the portage brown, 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. What no router
|
* How the planner map draws the parts of a tour being edited. A hike draws
|
||||||
* planned is dotted, so a routed path and a hand-drawn one never look alike:
|
* what no router planned dotted, so a routed path and a hand-drawn one never
|
||||||
* a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs
|
* look alike. A paddle tour is drawn by hand throughout, so its water is the
|
||||||
* and stretches drawn by hand. A paddle tour also colours what the paddler
|
* plain line and only its portages stand out, dotted and brown.
|
||||||
* should look at.
|
|
||||||
*/
|
*/
|
||||||
export function tourRouteStyle(
|
export function tourRouteStyle(
|
||||||
parts: readonly TourRoutePart[] | null,
|
parts: readonly TourRoutePart[] | null,
|
||||||
isPaddle: boolean,
|
isPaddle: boolean,
|
||||||
paint: RoutePaint
|
portage: PortagePaint
|
||||||
): TourRouteStyle {
|
): TourRouteStyle {
|
||||||
if (!parts?.length) return { walking: null, colors: null };
|
if (!parts?.length) return { walking: null, colors: null };
|
||||||
const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted');
|
const walking = parts.map((part) => (isPaddle ? !!part.land : part.free));
|
||||||
const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined));
|
const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : 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,12 +9,6 @@ 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,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
recommendedPaddleSpeedKmh,
|
recommendedPaddleSpeedKmh,
|
||||||
type PaddleBoatType,
|
type PaddleBoatType,
|
||||||
type PaddleLakeStyle,
|
|
||||||
type PaddleWaterContext,
|
type PaddleWaterContext,
|
||||||
type TourListItem,
|
type TourListItem,
|
||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
@@ -14,8 +13,6 @@ 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 = {
|
||||||
@@ -23,22 +20,17 @@ 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<
|
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'>
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +51,7 @@ 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]
|
||||||
@@ -70,7 +61,6 @@ 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,
|
||||||
@@ -79,8 +69,6 @@ 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. */
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,20 +1,16 @@
|
|||||||
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, paddlePlan, paddleForget } =
|
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
||||||
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,
|
||||||
@@ -24,12 +20,6 @@ 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],
|
||||||
@@ -74,20 +64,6 @@ 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', () => {
|
||||||
@@ -95,8 +71,6 @@ 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();
|
||||||
@@ -247,7 +221,7 @@ describe('useTourPlanner route tools', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('paddle tours (PAD-01)', () => {
|
describe('paddle tours (PAD-01)', () => {
|
||||||
it('plans every leg on the water once paddle is chosen, and one undo takes the type back', async () => {
|
it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => {
|
||||||
const { result } = plannerWith([
|
const { result } = plannerWith([
|
||||||
[48, 11],
|
[48, 11],
|
||||||
[48.01, 11.02],
|
[48.01, 11.02],
|
||||||
@@ -255,22 +229,9 @@ 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)).toEqual([undefined, undefined, undefined]);
|
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', 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).toBeUndefined();
|
expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free');
|
||||||
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');
|
||||||
@@ -279,6 +240,7 @@ 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],
|
||||||
@@ -302,14 +264,11 @@ 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 by the routed length at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
it('times the water 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],
|
||||||
@@ -319,13 +278,13 @@ describe('useTourPlanner route tools', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
await vi.advanceTimersByTimeAsync(450);
|
await vi.advanceTimersByTimeAsync(450);
|
||||||
});
|
});
|
||||||
// 1.2 km on the water the router found, at 4 km/h.
|
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
|
||||||
expect(result.current.walkingDurationMinutes).toBe(18);
|
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60));
|
||||||
|
|
||||||
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(12);
|
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60));
|
||||||
|
|
||||||
act(() => result.current.paddle.adoptRecommendedSpeed());
|
act(() => result.current.paddle.adoptRecommendedSpeed());
|
||||||
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
||||||
@@ -384,137 +343,4 @@ 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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user