mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 |
@@ -257,6 +257,15 @@ jobs:
|
||||
# migration that changes a column regenerates it in the same change.
|
||||
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
|
||||
# Errors fail, and the warnings per rule may not pass
|
||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||
@@ -264,6 +273,7 @@ jobs:
|
||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd server && npm run lint:warnings
|
||||
|
||||
- name: Formatting check
|
||||
@@ -395,6 +405,15 @@ jobs:
|
||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||
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
|
||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||
# whose warning count rises above scripts/eslint-baseline.json.
|
||||
@@ -402,6 +421,7 @@ jobs:
|
||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd client && npm run lint:warnings
|
||||
|
||||
- name: Page pattern check
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 875,
|
||||
"@typescript-eslint/no-misused-promises": 874,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||
*/
|
||||
import { ESLint } from 'eslint';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -137,6 +138,90 @@ export async function lintClient(root) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||
|
||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||
function gitIn(root, args) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 1 << 28,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The messages of one rule in one area of a single file's lint result. */
|
||||
function ofRule(results, rule, area) {
|
||||
const result = results[0];
|
||||
if (!result) return [];
|
||||
const pool =
|
||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||
return pool.filter((m) => ruleOf(m) === rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||
* The count alone names no file, and the files holding the most are old ones;
|
||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||
* without it), and the report then keeps to the files holding the most.
|
||||
*/
|
||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||
if (changed === null) return null;
|
||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||
const found = await Promise.all(
|
||||
keys
|
||||
.filter((key) => differs.has(key))
|
||||
.map(async (key) => {
|
||||
const path = join(root, key);
|
||||
// A typed rule can trip over a text that is not the file on disk (the
|
||||
// base version); then the file's own warnings are told, marked so.
|
||||
const lint = async (text) => {
|
||||
try {
|
||||
return { messages: ofRule(await lintText(text, path), rule, area) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const now = await lint(readText(path));
|
||||
if (!now) return [];
|
||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||
const was = before === null ? { messages: [] } : await lint(before);
|
||||
if (was && now.messages.length <= was.messages.length) return [];
|
||||
const note = was ? '' : ' (the base version could not be linted)';
|
||||
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
|
||||
})
|
||||
);
|
||||
return found.flat();
|
||||
}
|
||||
|
||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||
async function reportNew(grown, { root, files, lintText, error }) {
|
||||
if (!grown.length || !lintText) return;
|
||||
const found = await Promise.all(
|
||||
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
||||
);
|
||||
grown.forEach(({ area, rule }, i) => {
|
||||
const lines = found[i];
|
||||
if (!lines?.length) return;
|
||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||
for (const line of lines) error(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
||||
function lintTextClient(root) {
|
||||
const eslint = new ESLint({ cwd: root });
|
||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
@@ -148,6 +233,7 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint = lintClient,
|
||||
lintText = lint === lintClient ? lintTextClient : null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -160,13 +246,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -182,7 +268,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -191,5 +278,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1409,
|
||||
"src/components/Map/MapView.tsx": 1402,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2299,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
|
||||
export const paddleRegionLoadApi = {
|
||||
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
|
||||
apiClient
|
||||
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
|
||||
};
|
||||
@@ -2,17 +2,19 @@ import {
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionSchema,
|
||||
paddleRegionSettingsSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegionRequest,
|
||||
type PaddleRegionSettings,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
decline: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
saveSettings: (settings: PaddleRegionSettings) =>
|
||||
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
||||
export const paddleRouteApi = {
|
||||
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
||||
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
|
||||
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
function useMegabytes() {
|
||||
const { language } = useTranslation();
|
||||
return (mb: number) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
|
||||
* mode, and in the automatic one the largest region it loads without asking.
|
||||
*/
|
||||
export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||
? SIZES
|
||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||
return (
|
||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<Segmented<PaddleRegionAutoMode>
|
||||
value={settings.mode}
|
||||
onChange={(mode) => void save({ mode })}
|
||||
label={t('admin.paddleRegions.auto.title')}
|
||||
fill
|
||||
options={MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
return {
|
||||
value: mode,
|
||||
label: t(`admin.paddleRegions.auto.mode.${mode}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
|
||||
</div>
|
||||
{settings.mode === 'auto' && (
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
||||
<ChoiceChips<string>
|
||||
value={String(settings.max_mb)}
|
||||
label={t('admin.paddleRegions.auto.maxSize')}
|
||||
onChange={(value) => void save({ max_mb: Number(value) })}
|
||||
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const busy = admin.busyId === request.id;
|
||||
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-request"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{request.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{request.declined ? (
|
||||
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
|
||||
{t('admin.paddleRegions.requests.declined')}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
|
||||
)}
|
||||
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
|
||||
{request.requested_by && (
|
||||
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
|
||||
)}
|
||||
<StatusPill>{date}</StatusPill>
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
{!request.declined && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.decline(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.paddleRegions.requests.decline')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.approve(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
||||
{t('admin.paddleRegions.requests.approve')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The regions paddlers asked for that wait for an admin, the open ones first. */
|
||||
export function RequestList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.requests.length === 0) return null;
|
||||
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
||||
const open = admin.requests.filter((request) => !request.declined).length;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
|
||||
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
|
||||
{t('admin.paddleRegions.requests.title')}
|
||||
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
|
||||
</span>
|
||||
<SettingRows>
|
||||
{ordered.map((request) => (
|
||||
<RequestRow key={request.id} request={request} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
|
||||
export function AutoOriginPill() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
|
||||
<span className="inline-flex" data-testid="paddle-region-auto">
|
||||
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
|
||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-10-03T12:00:00Z',
|
||||
imported_at: '2026-10-04T08:00:00Z',
|
||||
feature_count: 1234,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
||||
toursOn: true,
|
||||
offline: false,
|
||||
regions: [],
|
||||
requests: [],
|
||||
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
||||
busyId: null,
|
||||
add: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
approve: vi.fn(async () => {}),
|
||||
decline: vi.fn(async () => {}),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsPanel water network (PAD-11)', () => {
|
||||
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
|
||||
const network = {
|
||||
water_km: 1364.5,
|
||||
lakes: 266,
|
||||
portages: 14,
|
||||
inferred_portages: 232,
|
||||
gaps: 63,
|
||||
locks: 17,
|
||||
built_at: '2026-10-17T15:30:20Z',
|
||||
bytes: 1496816,
|
||||
};
|
||||
show({ regions: [region({ network })] });
|
||||
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
|
||||
expect(pill).toHaveTextContent('1,365 km of water');
|
||||
fireEvent.mouseEnter(pill);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
|
||||
show({
|
||||
regions: [
|
||||
region({ id: 'old', name: 'Old read', network: null }),
|
||||
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
|
||||
],
|
||||
});
|
||||
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
|
||||
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
|
||||
const save = vi.fn(async () => {});
|
||||
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
|
||||
const auto = screen.getByTestId('paddle-auto-load');
|
||||
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
|
||||
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
|
||||
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
||||
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
|
||||
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
|
||||
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
|
||||
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
|
||||
const approve = vi.fn(async () => {});
|
||||
const decline = vi.fn(async () => {});
|
||||
const base = {
|
||||
bytes: 734_003_200,
|
||||
requested_by: 'elmo',
|
||||
requested_at: '2026-10-18T09:00:00Z',
|
||||
};
|
||||
show({
|
||||
approve,
|
||||
decline,
|
||||
requests: [
|
||||
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
|
||||
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
|
||||
],
|
||||
});
|
||||
const rows = screen.getAllByTestId('paddle-region-request');
|
||||
expect(rows[0]).toHaveTextContent('Brandenburg');
|
||||
expect(rows[0]).toHaveTextContent('Daily limit');
|
||||
expect(rows[0]).toHaveTextContent('by elmo');
|
||||
expect(rows[1]).toHaveTextContent('Declined');
|
||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
|
||||
expect(decline).toHaveBeenCalledWith('brandenburg');
|
||||
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
|
||||
expect(approve).toHaveBeenCalledWith('norway');
|
||||
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
|
||||
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
|
||||
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
|
||||
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddleRegionsModel', () => {
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
||||
expect(isReading({ status: 'queued' })).toBe(true);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
|
||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
/**
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* picked from Geofabrik's list. Both the desktop and the phone admin show
|
||||
* this one card under the addons, while the Tours addon is on.
|
||||
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||
* Both the desktop and the phone admin show this one card under the addons,
|
||||
* while the Tours addon is on.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
@@ -67,6 +70,8 @@ function Panel() {
|
||||
</div>
|
||||
)}
|
||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||
<AutoLoadSettings admin={admin} />
|
||||
<RequestList admin={admin} />
|
||||
<CatalogSearch admin={admin} />
|
||||
<RegionList admin={admin} />
|
||||
</fieldset>
|
||||
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: the water network the router follows in a region, as one figure
|
||||
* with the rest in its tooltip. A region read before routing existed says it
|
||||
* needs reading again; a network is never claimed where none was built.
|
||||
*/
|
||||
function RegionNetwork({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const { network } = region;
|
||||
if (!network) {
|
||||
if (!region.imported_at || region.status === 'importing') return null;
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
|
||||
const details = [
|
||||
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
|
||||
t('admin.paddleRegions.network.portages', { count: network.portages }),
|
||||
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
|
||||
t('admin.paddleRegions.network.locks', { count: network.locks }),
|
||||
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
|
||||
].join(', ');
|
||||
return (
|
||||
<Tooltip label={details}>
|
||||
<span className="inline-flex" data-testid="paddle-region-network">
|
||||
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<RegionStatus region={region} />
|
||||
{region.origin === 'auto' && <AutoOriginPill />}
|
||||
{region.imported_at && (
|
||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||
)}
|
||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||
<RegionNetwork region={region} />
|
||||
</span>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
|
||||
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||
|
||||
describe('usePaddleAutoLoad', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
|
||||
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
|
||||
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
|
||||
expect(repo.settings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ mode: 'ask' });
|
||||
});
|
||||
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
|
||||
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
|
||||
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ max_mb: 2000 });
|
||||
});
|
||||
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PaddleRegionSettings } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/**
|
||||
* PAD-11C in the admin area: how TREK loads the water data of a place a
|
||||
* paddler plans in, read once the card shows and saved on every change. A
|
||||
* change shows at once and goes back, with a toast, when the server refuses
|
||||
* it. While one is saved the controls rest, so two saves never cross.
|
||||
*/
|
||||
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.settings().then(
|
||||
(loaded) => {
|
||||
if (!cancelled) setSettings(loaded);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, toast]);
|
||||
|
||||
const save = useCallback(
|
||||
async (change: Partial<PaddleRegionSettings>) => {
|
||||
if (!settings || pending.current) return;
|
||||
const before = settings;
|
||||
const next = { ...settings, ...change };
|
||||
pending.current = true;
|
||||
setSaving(true);
|
||||
setSettings(next);
|
||||
try {
|
||||
setSettings(await paddleRegionRepo.saveSettings(next));
|
||||
} catch (err) {
|
||||
setSettings(before);
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[settings, t, toast]
|
||||
);
|
||||
|
||||
return { settings, saving, save };
|
||||
}
|
||||
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
|
||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
|
||||
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||
paddleRegionRepo: repo,
|
||||
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
||||
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
|
||||
id,
|
||||
name: id,
|
||||
status,
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { isReading } from './paddleRegionsModel';
|
||||
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
|
||||
|
||||
/** How often the list is asked again while a region waits or is read. */
|
||||
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
||||
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The regions as the server holds them, asked again every few seconds while
|
||||
* any of them is read, and on `reload` after a change. Every answer is set
|
||||
* The regions as the server holds them, with those paddlers asked for, asked
|
||||
* again every few seconds while any of them is read, and on `reload` after a
|
||||
* change. Every answer is set
|
||||
* from the request's own callback, so a slow one never overwrites a newer.
|
||||
*/
|
||||
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
||||
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [asked, setAsked] = useState(0);
|
||||
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list);
|
||||
setRegions(list.regions);
|
||||
setRequests(list.requests);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, offline, reading]);
|
||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||
return { regions, loading: !loaded && !offline, error, reload };
|
||||
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
||||
* in Geofabrik's list to add one, reading a region again and removing it
|
||||
* after a confirmation. Shown only while the Tours addon is on; the status of
|
||||
* a read follows without reloading the page.
|
||||
* a read follows without reloading the page. PAD-11C: how TREK loads regions
|
||||
* for paddlers by itself, and the regions they asked for, to load or decline.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
|
||||
const { offline } = useNetworkMode();
|
||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||
const list = useRegionList(toursOn, offline, t);
|
||||
const autoLoad = usePaddleAutoLoad(toursOn, offline);
|
||||
const search = useCatalogSearch(offline, t);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
|
||||
[run, setQuery, t]
|
||||
);
|
||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||
/** A paddler's request let through: the region is added like any other. */
|
||||
const approve = useCallback(
|
||||
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
|
||||
[run, t]
|
||||
);
|
||||
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
|
||||
const confirmRemove = useCallback(() => {
|
||||
const region = removing;
|
||||
setRemoving(null);
|
||||
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
requests: list.requests,
|
||||
autoLoad,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
approve,
|
||||
decline,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { RouteHitBand } from './RouteHitBand'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||
<Polyline
|
||||
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!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}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
|
||||
])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
|
||||
@@ -2,18 +2,25 @@ import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
export type PinCss = (size: number, selected: boolean) => string;
|
||||
|
||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26;
|
||||
function plannerWaypointIcon(
|
||||
pinCss: PinCss,
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean,
|
||||
draggable: boolean
|
||||
): L.DivIcon {
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
|
||||
onMove,
|
||||
pinCss,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||
const { label, shaping } = point;
|
||||
const icon = useMemo(
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
|
||||
[index, label, onMove, pinCss, selected, shaping]
|
||||
);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Polyline } from 'react-leaflet';
|
||||
|
||||
/**
|
||||
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
||||
* than the drawn line, that takes the click which reshapes the route, as the
|
||||
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
|
||||
*/
|
||||
export function RouteHitBand({
|
||||
positions,
|
||||
onRouteClick,
|
||||
}: {
|
||||
positions: [number, number][];
|
||||
onRouteClick: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: 'transparent',
|
||||
weight: 20,
|
||||
opacity: 0,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
className: 'cursor-copy',
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
||||
e.originalEvent.stopPropagation();
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +110,15 @@ export interface MapViewProps {
|
||||
onHighlightAlternative?: (index: number | null) => void;
|
||||
/** Generic numbered control points for list-first route editors. */
|
||||
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
plannerWaypoints?: Array<{
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
/** Drops a dragged control point; returns false when the move was refused. */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
|
||||
describe('plannerPinShape', () => {
|
||||
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
||||
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
|
||||
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
|
||||
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
||||
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
||||
export interface PlannerPinPoint {
|
||||
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}
|
||||
|
||||
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
||||
export function plannerPinShape(
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): { size: number; text: string } {
|
||||
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
||||
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId;
|
||||
const size = selected ? 30 : 26;
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.textContent = text;
|
||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||
el.setAttribute('aria-label', name);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
|
||||
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
|
||||
<div ref={bodyRef}>
|
||||
{asking ? (
|
||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PaddleLakeStyle } from '@trek/shared';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
|
||||
|
||||
/**
|
||||
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
|
||||
* shore is for wind, when a capsize near the bank is a short swim. The wind
|
||||
* card suggests it on a windy day; here it is chosen.
|
||||
*/
|
||||
export function PaddleLakeStyleField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PaddleLakeStyle;
|
||||
onChange: (style: PaddleLakeStyle) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid="tour-planner-lake-style" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
|
||||
<Segmented<PaddleLakeStyle>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={t('tours.paddle.lakeStyle')}
|
||||
fill
|
||||
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
|
||||
/>
|
||||
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tours.paddle.lakeStyle.${value}Hint`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { PaddleLegFailure } from '../planner/paddleRouting';
|
||||
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||
|
||||
/**
|
||||
* PAD-11: what the waypoint list says about a leg the router planned or
|
||||
* could not plan, and about a point it set itself.
|
||||
*/
|
||||
|
||||
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
|
||||
export function AutoPointPill() {
|
||||
const { t } = useTranslation();
|
||||
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
|
||||
return (
|
||||
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
|
||||
<span
|
||||
data-testid="tour-planner-auto-point"
|
||||
aria-label={t('tours.paddle.auto')}
|
||||
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
|
||||
|
||||
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
|
||||
function notesOf(
|
||||
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
): Note[] {
|
||||
return leg.segments.flatMap((segment, index): Note[] => {
|
||||
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
|
||||
if (segment.kind === 'rapids' && segment.grade != null) {
|
||||
return [
|
||||
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
|
||||
];
|
||||
}
|
||||
if (segment.kind === 'pass')
|
||||
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
|
||||
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
|
||||
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
if (!leg || leg.status !== 'ok') return null;
|
||||
const notes = notesOf(leg, t);
|
||||
if (notes.length === 0) return null;
|
||||
return (
|
||||
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
|
||||
{notes.map((note) => (
|
||||
<Tooltip
|
||||
key={note.key}
|
||||
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
|
||||
style={fs(11)}
|
||||
>
|
||||
{note.tone === 'gap' ? (
|
||||
<AlertTriangle
|
||||
size={11}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: 'var(--map-paddle-gap)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Waves
|
||||
size={11}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: 'var(--map-paddle-rapids)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{note.label}</span>
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
|
||||
style={fs(10)}
|
||||
>
|
||||
{formatDistance(note.length / 1000, unit)}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg the router could not plan: why, in plain words, and what to do about
|
||||
* it. The map draws it straight and red meanwhile, and the tour waits to be
|
||||
* saved until it is planned or drawn by hand on purpose. Where TREK could not
|
||||
* fetch the water data of the leg's region (PAD-11C), `message` says why and
|
||||
* `onLoadRetry` reads the region again.
|
||||
*/
|
||||
export function PaddleLegFailureRow({
|
||||
code,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
onRetry,
|
||||
message,
|
||||
onLoadRetry,
|
||||
}: {
|
||||
code: PaddleLegFailure;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
onRetry: () => void;
|
||||
message?: string | null;
|
||||
onLoadRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
|
||||
const action =
|
||||
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
|
||||
return (
|
||||
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
|
||||
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
|
||||
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.drawStraight')}
|
||||
</button>
|
||||
{retry && (
|
||||
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
|
||||
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.retry')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton';
|
||||
import { placeOf } from './paddleObstaclePlace';
|
||||
|
||||
describe('placeOf', () => {
|
||||
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
|
||||
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
|
||||
expect(
|
||||
placeOf({
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[12.9, 53.2],
|
||||
[12.91, 53.21],
|
||||
[12.92, 53.22],
|
||||
],
|
||||
})
|
||||
).toEqual({ lat: 53.21, lng: 12.91 });
|
||||
const middle = placeOf({
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[12, 53],
|
||||
[12.02, 53],
|
||||
[12.02, 53.02],
|
||||
[12, 53.02],
|
||||
[12, 53],
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(middle?.lat).toBeCloseTo(53.01, 9);
|
||||
expect(middle?.lng).toBeCloseTo(12.01, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleObstaclePlaceButton', () => {
|
||||
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
|
||||
const onPlace = vi.fn();
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="slipway"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={onPlace}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
|
||||
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
|
||||
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="egress"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="weir"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
|
||||
import { MapPinPlus } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { placeOf } from './paddleObstaclePlace';
|
||||
|
||||
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
|
||||
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
|
||||
put_in: 'put_in',
|
||||
slipway: 'put_in',
|
||||
egress: 'egress',
|
||||
};
|
||||
|
||||
/**
|
||||
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
|
||||
* waypoint from its card on the map, so the point sits where the paddler
|
||||
* really gets in or out. Set after a portage asked for at the last point, it
|
||||
* is where the portage ends.
|
||||
*/
|
||||
export function PaddleObstaclePlaceButton({
|
||||
kind,
|
||||
geometry,
|
||||
onPlace,
|
||||
}: {
|
||||
kind: PaddleObstacleKind;
|
||||
geometry: PaddleGeometry;
|
||||
onPlace: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const role = PLACEABLE[kind];
|
||||
const spot = role ? placeOf(geometry) : null;
|
||||
if (!role || !spot) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="paddle-obstacle-place"
|
||||
onClick={() => onPlace(spot.lat, spot.lng)}
|
||||
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PaddleRegionLoadRow } from './PaddleRegionLoadRow';
|
||||
import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad';
|
||||
|
||||
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
|
||||
|
||||
describe('PaddleRegionLoadRow', () => {
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
|
||||
const onDrawStraight = vi.fn();
|
||||
render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
|
||||
disabled={false}
|
||||
onDrawStraight={onDrawStraight}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId('tour-planner-region-load');
|
||||
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
|
||||
expect(row).toHaveTextContent('Downloading 45%');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
|
||||
expect(onDrawStraight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
|
||||
render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'requested', region: REF, reason: 'too_large' }}
|
||||
disabled={false}
|
||||
onDrawStraight={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId('tour-planner-region-load');
|
||||
expect(row).toHaveTextContent('has been requested');
|
||||
expect(row).toHaveTextContent('larger than TREK loads by itself');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
|
||||
const { unmount } = render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
|
||||
disabled={false}
|
||||
onDrawStraight={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
|
||||
unmount();
|
||||
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('region load states', () => {
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
|
||||
const pending: PaddleRegionLoadState[] = [
|
||||
{ state: 'ready', region: REF },
|
||||
{ state: 'requested', region: REF, reason: 'ask' },
|
||||
];
|
||||
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
|
||||
expect(isRegionLoadPending(undefined)).toBe(false);
|
||||
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
|
||||
'tours.paddle.load.declined{"region":"Oberpfalz"}'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
|
||||
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
|
||||
'tours.paddle.load.noExtract'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
|
||||
'tours.paddle.load.catalogUnavailable'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
|
||||
expect(regionLoadFailure(undefined, t)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
|
||||
const region = state.region.name;
|
||||
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
|
||||
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
|
||||
return t('tours.paddle.load.loading', { region });
|
||||
}
|
||||
|
||||
/** The phase of a load in words, with its share where it is known. */
|
||||
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
|
||||
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
|
||||
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
|
||||
const share = state.progress.fraction;
|
||||
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
|
||||
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
|
||||
* the data is on its way, and the leg is planned on its own once it is there.
|
||||
* Drawing it straight stays possible for whoever does not want to wait.
|
||||
*/
|
||||
export function PaddleRegionLoadRow({
|
||||
state,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
}: {
|
||||
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
}) {
|
||||
const { t, language } = useTranslation();
|
||||
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
|
||||
const busy = state.state !== 'requested';
|
||||
return (
|
||||
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{busy ? (
|
||||
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0">{headline(state, t)}</span>
|
||||
</span>
|
||||
{state.state === 'loading' && (
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="truncate">{phaseText(state, t, language)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
|
||||
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.load.hint')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{state.state === 'requested' && state.reason !== 'ask' && (
|
||||
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
|
||||
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
|
||||
</span>
|
||||
)}
|
||||
{state.state !== 'ready' && (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onDrawStraight}
|
||||
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.drawStraight')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
|
||||
|
||||
/** What the card writes for a time it cannot give: an en dash. */
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03).
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts';
|
||||
|
||||
const way = (extra: Partial<PortageWay> = {}): PortageWay => ({
|
||||
routed: false,
|
||||
noWay: false,
|
||||
searching: false,
|
||||
onFollow: vi.fn(),
|
||||
onStraighten: vi.fn(),
|
||||
...extra,
|
||||
});
|
||||
|
||||
function openPortage(props: Partial<Parameters<typeof PortageLegRow>[0]> = {}) {
|
||||
render(<PortageLegRow lengthM={320} extraMinutes={null} disabled={false} onExtraChange={vi.fn()} {...props} />);
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }));
|
||||
}
|
||||
|
||||
describe('PendingPortageRow', () => {
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => {
|
||||
const onRemove = vi.fn();
|
||||
render(<PendingPortageRow disabled={false} onRemove={onRemove} />);
|
||||
expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' }));
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => {
|
||||
render(<PendingPortageRow disabled onRemove={vi.fn()} />);
|
||||
expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortageLegRow way', () => {
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => {
|
||||
const straight = way();
|
||||
openPortage({ way: straight });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow the path' }));
|
||||
expect(straight.onFollow).toHaveBeenCalledTimes(1);
|
||||
const routed = way({ routed: true });
|
||||
openPortage({ way: routed });
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]);
|
||||
expect(routed.onStraighten).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => {
|
||||
openPortage({ way: way({ noWay: true }) });
|
||||
expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour');
|
||||
openPortage({ way: way({ searching: true }) });
|
||||
expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { ChevronDown, Loader2, X } from 'lucide-react';
|
||||
import { useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
@@ -199,6 +200,99 @@ function StopCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portage asked for at the last point, before the point it leads to is set:
|
||||
* dashed in the portage brown, it says where to click next, and can be taken
|
||||
* back. Once the next point is set it becomes a portage like any other.
|
||||
*/
|
||||
export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<li data-testid="tour-planner-portage-pending" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div
|
||||
className="flex w-full min-w-0 items-start gap-1.5 rounded-[14px] border border-dashed bg-surface-card py-1 pe-1 ps-1"
|
||||
style={{ borderColor: PORTAGE_CSS_COLOR }}
|
||||
>
|
||||
<span
|
||||
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-surface-card"
|
||||
style={{ background: PORTAGE_CSS_COLOR }}
|
||||
>
|
||||
<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col py-[2px]">
|
||||
<span className="truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{t('tours.paddle.portagePending')}
|
||||
</span>
|
||||
<span className="leading-snug text-content-muted" style={fs(11, 'body')}>
|
||||
{t('tours.paddle.portagePendingHint')}
|
||||
</span>
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t('tours.paddle.portagePendingOff')}
|
||||
title={t('tours.paddle.portagePendingOff')}
|
||||
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PortageWay {
|
||||
/** The portage follows the ways on land (its line has a shape of its own). */
|
||||
routed: boolean;
|
||||
/** The router was asked and found no way without a big detour. */
|
||||
noWay: boolean;
|
||||
/** The router is being asked right now. */
|
||||
searching: boolean;
|
||||
onFollow: () => void;
|
||||
onStraighten: () => void;
|
||||
}
|
||||
|
||||
/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */
|
||||
function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const value = way.routed ? 'follow' : 'straight';
|
||||
const hint = way.searching
|
||||
? t('tours.paddle.portageWay.searching')
|
||||
: !way.routed && way.noWay
|
||||
? t('tours.paddle.portageWay.noWay')
|
||||
: null;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1" data-testid="tour-planner-portage-way">
|
||||
<span className="block truncate text-content" style={fs(12, 'body')}>
|
||||
{t('tours.paddle.portageWay')}
|
||||
</span>
|
||||
<fieldset disabled={disabled || way.searching} className="m-0 min-w-0 border-0 p-0">
|
||||
<Segmented<'follow' | 'straight'>
|
||||
value={value}
|
||||
fill
|
||||
label={t('tours.paddle.portageWay')}
|
||||
onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())}
|
||||
options={[
|
||||
{
|
||||
value: 'follow',
|
||||
label: t('tours.paddle.portageWay.follow'),
|
||||
icon: way.searching ? <Loader2 size={12} className="animate-spin" aria-hidden="true" /> : undefined,
|
||||
},
|
||||
{ value: 'straight', label: t('tours.paddle.portageWay.straight') },
|
||||
]}
|
||||
/>
|
||||
</fieldset>
|
||||
{hint && (
|
||||
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portage under the point it starts at, in the earth brown the map draws it
|
||||
* in: its length on land and the time for getting out, unloading and back in
|
||||
@@ -211,6 +305,7 @@ export function PortageLegRow({
|
||||
onExtraChange,
|
||||
position,
|
||||
onRemove,
|
||||
way,
|
||||
}: {
|
||||
lengthM: number;
|
||||
extraMinutes: number | null;
|
||||
@@ -220,6 +315,8 @@ export function PortageLegRow({
|
||||
position?: string;
|
||||
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
|
||||
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 unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
@@ -242,6 +339,7 @@ export function PortageLegRow({
|
||||
{t('tours.paddle.portageInfo')}
|
||||
</p>
|
||||
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
|
||||
{way && <PortageWayField way={way} disabled={disabled} />}
|
||||
<StopField
|
||||
label={t('tours.paddle.portageExtra')}
|
||||
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
||||
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-018: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
||||
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -340,6 +340,30 @@ describe('PaddleWindSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleWindCard on the lakes (PAD-11)', () => {
|
||||
it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => {
|
||||
const onChoose = vi.fn();
|
||||
const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] })));
|
||||
const { rerender } = render(<PaddleWindCard wind={windy} lakes={{ value: 'direct', onChoose }} />);
|
||||
const advice = screen.getByTestId('paddle-wind-shore-advice');
|
||||
expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.');
|
||||
fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' }));
|
||||
expect(onChoose).toHaveBeenCalledWith('shore');
|
||||
|
||||
rerender(<PaddleWindCard wind={windy} lakes={{ value: 'shore', onChoose }} />);
|
||||
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => {
|
||||
const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] })));
|
||||
const { rerender } = render(<PaddleWindCard wind={calm} lakes={{ value: 'direct', onChoose: vi.fn() }} />);
|
||||
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ hours: [hourOf(12, 30, 50, 270)] })))} />);
|
||||
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FigureBadge', () => {
|
||||
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" />);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||
import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -8,6 +8,7 @@ import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { windForShore } from './lakeStyleAdvice';
|
||||
import { useTourWind, type TourWind } from './useTourWind';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -209,15 +210,58 @@ function WindHints({ t }: { t: Translate }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */
|
||||
export interface LakeStyleChoice {
|
||||
value: PaddleLakeStyle;
|
||||
onChoose: (style: PaddleLakeStyle) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: on a windy day, the offer to keep near the shores on the lakes,
|
||||
* with the wind that prompts it; once taken, it says the lakes are planned so.
|
||||
*/
|
||||
function ShoreAdvice({
|
||||
wind,
|
||||
lakes,
|
||||
t,
|
||||
}: {
|
||||
wind: { meanKmh: number; gustsKmh: number };
|
||||
lakes: LakeStyleChoice;
|
||||
t: Translate;
|
||||
}) {
|
||||
if (lakes.value === 'shore') {
|
||||
return (
|
||||
<TourNotice role="status" icon={<Wind size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.wind.shoreTaken')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<Wind size={13} strokeWidth={2} />}>
|
||||
<span data-testid="paddle-wind-shore-advice" className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => lakes.onChoose('shore')}
|
||||
className="font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
{t('tours.paddle.wind.shoreAdopt')}
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
|
||||
* 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
|
||||
* is never shown as calm.
|
||||
*/
|
||||
export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
|
||||
const shoreWind = ready && lakes ? windForShore(ready) : null;
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Wind size={14} strokeWidth={2} />}
|
||||
@@ -249,6 +293,7 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
) : (
|
||||
<Unavailable wind={wind} t={t} locale={locale} />
|
||||
)}
|
||||
{shoreWind && lakes && <ShoreAdvice wind={shoreWind} lakes={lakes} t={t} />}
|
||||
<WindHints t={t} />
|
||||
</div>
|
||||
</RailCard>
|
||||
@@ -259,10 +304,13 @@ export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
export function PaddleWindSection({
|
||||
placeId,
|
||||
start,
|
||||
lakes,
|
||||
}: {
|
||||
placeId: 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);
|
||||
return <PaddleWindCard wind={wind} />;
|
||||
return <PaddleWindCard wind={wind} lakes={lakes} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08).
|
||||
import type { WindForecastResponse, WindHour } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { crossesLake, windForShore } from './lakeStyleAdvice';
|
||||
|
||||
function forecast(hours: Array<Partial<WindHour> & { hour: number }>): WindForecastResponse {
|
||||
return {
|
||||
status: 'forecast',
|
||||
date: '2026-06-01',
|
||||
lat: 53.4,
|
||||
lng: 12.7,
|
||||
timezone: 'Europe/Berlin',
|
||||
source: 'Open-Meteo',
|
||||
fetched_at: '2026-05-30T08:00:00Z',
|
||||
horizon_days: 16,
|
||||
day: null,
|
||||
hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })),
|
||||
} as WindForecastResponse;
|
||||
}
|
||||
|
||||
describe('lakeStyleAdvice', () => {
|
||||
it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => {
|
||||
expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull();
|
||||
expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({
|
||||
meanKmh: 21,
|
||||
gustsKmh: 26,
|
||||
});
|
||||
expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 });
|
||||
expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull();
|
||||
// An hour without a value is no calm hour, but it is no wind either.
|
||||
expect(windForShore(forecast([{ hour: 12 }]))).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => {
|
||||
const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null };
|
||||
const leg = (kind: 'lake' | 'water') => ({
|
||||
status: 'ok' as const,
|
||||
geometry: [
|
||||
[53.4, 12.7],
|
||||
[53.5, 12.7],
|
||||
] as [number, number][],
|
||||
segments: [{ ...lake, kind }],
|
||||
distance_m: 1000,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
});
|
||||
expect(crossesLake(null)).toBe(false);
|
||||
expect(crossesLake({ legs: [leg('water'), null] })).toBe(false);
|
||||
expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true);
|
||||
expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { WindForecastResponse } from '@trek/shared';
|
||||
import type { PaddleTourRoute } from '../planner/paddleRouting';
|
||||
|
||||
/** The hours a day tour is out on the water, as the wind strip shows them. */
|
||||
const FIRST_HOUR = 6;
|
||||
const LAST_HOUR = 21;
|
||||
|
||||
/**
|
||||
* Wind from which open water gets rough for a small boat: a moderate breeze
|
||||
* (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into
|
||||
* work and a capsize far from the bank into a long swim.
|
||||
*/
|
||||
const MEAN_KMH = 20;
|
||||
const GUSTS_KMH = 30;
|
||||
|
||||
/** Whether the route as planned crosses a lake, so its lake style changes anything. */
|
||||
export function crossesLake(route: Pick<PaddleTourRoute, 'legs'> | null): boolean {
|
||||
return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake'));
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11 with PAD-08: the strongest wind of the paddling hours when it is
|
||||
* enough to keep near the shores on the lakes, else null. Lakes are crossed
|
||||
* straight over as a rule and along the shore only in wind; this says when.
|
||||
*/
|
||||
export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null {
|
||||
const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
|
||||
const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0));
|
||||
const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0));
|
||||
if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null;
|
||||
return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) };
|
||||
}
|
||||
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PaddleGeometry } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The spot a waypoint goes to: the place itself, the middle of a way (a
|
||||
* slipway drawn as a ramp), or the middle of an area's outline.
|
||||
*/
|
||||
export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null {
|
||||
const line =
|
||||
geometry.type === 'Point'
|
||||
? [geometry.coordinates]
|
||||
: geometry.type === 'LineString'
|
||||
? geometry.coordinates
|
||||
: geometry.coordinates[0];
|
||||
if (!line?.length) return null;
|
||||
if (geometry.type === 'Polygon') {
|
||||
const ring = line.slice(0, -1);
|
||||
const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
|
||||
const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length;
|
||||
return { lat, lng };
|
||||
}
|
||||
const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? [];
|
||||
return lat === undefined || lng === undefined ? null : { lat, lng };
|
||||
}
|
||||
@@ -12,6 +12,8 @@ const PADDLE_PAINT_TOKENS = {
|
||||
access: '--map-paddle-access',
|
||||
landing: '--map-paddle-landing',
|
||||
portage: '--map-portage',
|
||||
rapids: '--map-paddle-rapids',
|
||||
gap: '--map-paddle-gap',
|
||||
disc: '--map-paddle-disc',
|
||||
shadow: '--map-paddle-shadow',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import type { useTranslation } from '../../../i18n';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** The states in which the data is on its way: the leg waits rather than fails. */
|
||||
export function isRegionLoadPending(
|
||||
state: PaddleRegionLoadState | undefined
|
||||
): state is Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }> {
|
||||
return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested';
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a leg stays unplanned once the data cannot come: declined, failed, no
|
||||
* water near the place, nothing to load. Null where the leg's own reason says
|
||||
* it best (loading is off, or nothing was asked yet).
|
||||
*/
|
||||
export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null {
|
||||
if (!state) return null;
|
||||
const region = state.region?.name ?? '';
|
||||
switch (state.state) {
|
||||
case 'declined':
|
||||
return t('tours.paddle.load.declined', { region });
|
||||
case 'failed':
|
||||
return t('tours.paddle.load.failed', { region });
|
||||
case 'no_water':
|
||||
return t('tours.paddle.load.noWater', { region });
|
||||
case 'unavailable':
|
||||
if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract');
|
||||
if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable');
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03).
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const valhallaRun = vi.fn();
|
||||
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) }));
|
||||
|
||||
const { routePortageLine } = await import('./portageRoute');
|
||||
|
||||
const FROM = { lat: 53.2232, lng: 12.9668 };
|
||||
const TO = { lat: 53.2229, lng: 12.9701 };
|
||||
|
||||
function answer(coordinates: [number, number][]) {
|
||||
return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } };
|
||||
}
|
||||
|
||||
describe('routePortageLine', () => {
|
||||
beforeEach(() => valhallaRun.mockReset());
|
||||
|
||||
it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => {
|
||||
valhallaRun.mockResolvedValue(
|
||||
answer([
|
||||
[53.2233, 12.9672],
|
||||
[53.2226, 12.9686],
|
||||
[53.223, 12.9698],
|
||||
])
|
||||
);
|
||||
const line = await routePortageLine(FROM, TO);
|
||||
expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 });
|
||||
expect(line?.[0]).toEqual([FROM.lat, FROM.lng]);
|
||||
expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]);
|
||||
expect(line).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => {
|
||||
valhallaRun.mockResolvedValue(
|
||||
answer([
|
||||
[53.2233, 12.9672],
|
||||
[53.24, 12.968],
|
||||
[53.223, 12.9698],
|
||||
])
|
||||
);
|
||||
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => {
|
||||
valhallaRun.mockResolvedValue(null);
|
||||
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => {
|
||||
valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]]));
|
||||
expect(await routePortageLine(FROM, TO)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import { valhallaRun } from '../../Map/valhallaRoute';
|
||||
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||
|
||||
type Point = { lat: number; lng: number };
|
||||
|
||||
/**
|
||||
* How much longer than the straight line a carry along the ways may be before
|
||||
* it counts as a detour: a way round a fenced lock is fine, a walk round the
|
||||
* whole lake is not, and then the straight line says more.
|
||||
*/
|
||||
const DETOUR = { factor: 2.5, extraM: 300 } as const;
|
||||
|
||||
/** Paths a paddler carries a boat on: plain hiking ground, never a scramble. */
|
||||
const CARRY_DIFFICULTY = 1;
|
||||
|
||||
/**
|
||||
* PAD-03: the way a portage takes on land, from where the boat leaves the
|
||||
* water to where it goes in again, on the ways a walker would take (the
|
||||
* instance's Valhalla, the router the hikes use). The line starts and ends at
|
||||
* the two points themselves, so the steps from the bank onto the path are
|
||||
* part of it. Null when no router answers, or when the way is a detour.
|
||||
*/
|
||||
export async function routePortageLine(
|
||||
from: Point,
|
||||
to: Point,
|
||||
signal?: AbortSignal
|
||||
): Promise<[number, number][] | null> {
|
||||
// The router answers null, never throws, when it cannot be reached; the carry then stays straight.
|
||||
const run = await valhallaRun([from, to], 'walking', [], signal, { max_hiking_difficulty: CARRY_DIFFICULTY });
|
||||
if (!run || run.total.coordinates.length < 2) return null;
|
||||
const line: [number, number][] = [[from.lat, from.lng], ...run.total.coordinates, [to.lat, to.lng]];
|
||||
const straightM = haversineKm(from, to) * 1000;
|
||||
if (lineLengthM(line) > Math.max(straightM * DETOUR.factor, straightM + DETOUR.extraM)) return null;
|
||||
return line;
|
||||
}
|
||||
@@ -37,7 +37,8 @@ import {
|
||||
selectPaddleObstacle,
|
||||
usePaddleObstacleStore,
|
||||
} from '../../../store/paddleObstacleStore';
|
||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { usePaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
|
||||
|
||||
@@ -34,6 +34,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// FE-PADDLE-REGION-LOAD-001 to 005: the planner fetches the water data of a place no region covers (PAD-11C).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const load = vi.fn();
|
||||
vi.mock('../../../repo/paddleRegionLoadRepo', () => ({
|
||||
paddleRegionLoadRepo: { load: (...args: unknown[]) => load(...args) },
|
||||
}));
|
||||
|
||||
const { usePaddleRegionLoad } = await import('./usePaddleRegionLoad');
|
||||
|
||||
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||
const AT = { lat: 49.0123456, lng: 12.0987654 };
|
||||
const failures = [
|
||||
{ index: 0, code: 'no_region' as const, at: AT },
|
||||
{ index: 1, code: 'no_region' as const, at: AT },
|
||||
{ index: 2, code: 'no_water_path' as const, at: null },
|
||||
];
|
||||
const loading = (fraction: number): PaddleRegionLoadState => ({
|
||||
state: 'loading',
|
||||
region: REF,
|
||||
progress: { phase: 'download', fraction },
|
||||
ahead: 0,
|
||||
});
|
||||
|
||||
describe('usePaddleRegionLoad', () => {
|
||||
beforeEach(() => load.mockReset());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-001: asks once per place, and shows its state on every leg ending there', async () => {
|
||||
load.mockResolvedValue([loading(0.4)]);
|
||||
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||
await waitFor(() => expect(result.current.byLeg.get(0)).toEqual(loading(0.4)));
|
||||
expect(load).toHaveBeenCalledWith([{ lat: 49.01235, lng: 12.09877 }], false, expect.any(AbortSignal));
|
||||
expect(result.current.byLeg.get(1)).toEqual(loading(0.4));
|
||||
expect(result.current.byLeg.has(2)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-002: follows a load and plans again once, when the data is there', async () => {
|
||||
vi.useFakeTimers();
|
||||
load.mockResolvedValueOnce([loading(0.2)]).mockResolvedValue([{ state: 'ready', region: REF }]);
|
||||
const onReady = vi.fn();
|
||||
const { rerender } = renderHook(() => usePaddleRegionLoad(failures, onReady));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(onReady).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
rerender();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
});
|
||||
expect(onReady).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-003: asks for nothing without a leg that lacks the data', async () => {
|
||||
renderHook(() => usePaddleRegionLoad([{ index: 0, code: 'not_on_water', at: AT }], vi.fn()));
|
||||
renderHook(() => usePaddleRegionLoad(null, vi.fn()));
|
||||
await Promise.resolve();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-004: reads a failed region again on request', async () => {
|
||||
load.mockResolvedValue([{ state: 'failed', region: REF }]);
|
||||
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||
await waitFor(() => expect(result.current.byLeg.get(0)?.state).toBe('failed'));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(load).toHaveBeenLastCalledWith(expect.any(Array), true, expect.any(AbortSignal)));
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-005: keeps asking after a failed look, more slowly', async () => {
|
||||
vi.useFakeTimers();
|
||||
load.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([{ state: 'declined', region: REF }]);
|
||||
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.byLeg.get(0)?.state).toBe('declined');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { MAX_PADDLE_LOAD_POINTS, type PaddleRegionLoadState, type PaddleRoutePoint } from '@trek/shared';
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { paddleRegionLoadRepo } from '../../../repo/paddleRegionLoadRepo';
|
||||
import type { PaddleTourRoute } from '../planner/paddleRouting';
|
||||
|
||||
/** How soon the planner asks again: often while a region loads, now and then while it waits for an admin. */
|
||||
const POLL_MS = { loading: 2_000, requested: 30_000, error: 10_000 } as const;
|
||||
|
||||
type Failure = PaddleTourRoute['failures'][number];
|
||||
|
||||
/** A place as the key it is asked and remembered by; five decimals are about a metre. */
|
||||
function keyOf(point: PaddleRoutePoint): string {
|
||||
return `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
|
||||
}
|
||||
|
||||
function placeOf(key: string): PaddleRoutePoint {
|
||||
const [lat, lng] = key.split(',').map(Number);
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
/** The places the planner asks about: where its legs ran out of water data, each once. */
|
||||
function placeKeysOf(failures: readonly Failure[] | null | undefined): string {
|
||||
const keys = new Set<string>();
|
||||
for (const failure of failures ?? []) {
|
||||
if (failure.code === 'no_region' && failure.at) keys.add(keyOf(failure.at));
|
||||
}
|
||||
return [...keys].slice(0, MAX_PADDLE_LOAD_POINTS).join('|');
|
||||
}
|
||||
|
||||
function nextPoll(states: readonly PaddleRegionLoadState[]): number | null {
|
||||
if (states.some((state) => state.state === 'loading')) return POLL_MS.loading;
|
||||
if (states.some((state) => state.state === 'requested')) return POLL_MS.requested;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11C: where a paddle leg failed for want of water data, the planner
|
||||
* asks the server for it right away; the server loads the region around the
|
||||
* place or asks an admin for it, as the instance is set up. While it loads
|
||||
* the leg shows how far it is, and once it is there the route is planned
|
||||
* again on its own. `onReady` plans again; it is called once per place.
|
||||
*/
|
||||
export function usePaddleRegionLoad(failures: readonly Failure[] | null | undefined, onReady: () => void) {
|
||||
const placesKey = placeKeysOf(failures);
|
||||
const places = useMemo(() => (placesKey ? placesKey.split('|').map(placeOf) : []), [placesKey]);
|
||||
const [states, setStates] = useState<ReadonlyMap<string, PaddleRegionLoadState>>(new Map());
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const readied = useRef(new Set<string>());
|
||||
const retrying = useRef(false);
|
||||
|
||||
/** The answers taken in; the route is planned again where a place's data has just arrived. */
|
||||
const take = useEffectEvent((answers: readonly PaddleRegionLoadState[]) => {
|
||||
setStates(new Map(places.flatMap((place, index) => (answers[index] ? [[keyOf(place), answers[index]]] : []))));
|
||||
const fresh = places.filter((place, index) => {
|
||||
const key = keyOf(place);
|
||||
if (answers[index]?.state !== 'ready' || readied.current.has(key)) return false;
|
||||
readied.current.add(key);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length > 0) onReady();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (places.length === 0) return undefined;
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const ask = async () => {
|
||||
const retry = retrying.current;
|
||||
retrying.current = false;
|
||||
let answers: PaddleRegionLoadState[] | null;
|
||||
try {
|
||||
answers = await paddleRegionLoadRepo.load(places, retry, abort.signal);
|
||||
} catch {
|
||||
// The failure row keeps saying why the leg is unplanned; a later look may get through.
|
||||
if (!abort.signal.aborted) timer = setTimeout(() => void ask(), POLL_MS.error);
|
||||
return;
|
||||
}
|
||||
if (abort.signal.aborted || !answers) return;
|
||||
take(answers);
|
||||
const wait = nextPoll(answers);
|
||||
if (wait !== null) timer = setTimeout(() => void ask(), wait);
|
||||
};
|
||||
void ask();
|
||||
return () => {
|
||||
abort.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [places, retryToken]);
|
||||
|
||||
/** Reads a region again whose last read failed. */
|
||||
const retry = useCallback(() => {
|
||||
retrying.current = true;
|
||||
setRetryToken((token) => token + 1);
|
||||
}, []);
|
||||
|
||||
/** The state of each failed leg's place, by the leg's index. */
|
||||
const byLeg = useMemo(() => {
|
||||
const result = new Map<number, PaddleRegionLoadState>();
|
||||
for (const failure of failures ?? []) {
|
||||
if (failure.code !== 'no_region' || !failure.at) continue;
|
||||
const state = states.get(keyOf(failure.at));
|
||||
if (state) result.set(failure.index, state);
|
||||
}
|
||||
return result;
|
||||
}, [failures, states]);
|
||||
|
||||
return { byLeg, retry };
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// FE-PORTAGE-ROUTING-001 to 005: portages set in the planner follow the ways on land (PAD-03).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||
|
||||
const routePortageLine = vi.fn();
|
||||
vi.mock('./portageRoute', () => ({ routePortageLine: (...args: unknown[]) => routePortageLine(...args) }));
|
||||
|
||||
const { usePortageRouting } = await import('./usePortageRouting');
|
||||
|
||||
const A: TourPlannerWaypoint = { id: 'a', lat: 53.2232, lng: 12.9668, role: 'start' };
|
||||
const B: TourPlannerWaypoint = { id: 'b', lat: 53.2229, lng: 12.9701, role: 'end' };
|
||||
const WAY: [number, number][] = [
|
||||
[A.lat, A.lng],
|
||||
[53.2226, 12.9686],
|
||||
[B.lat, B.lng],
|
||||
];
|
||||
|
||||
function plannerWith(waypoints: TourPlannerWaypoint[], hasUnsavedChanges: boolean) {
|
||||
const planner = {
|
||||
waypoints,
|
||||
isPaddle: true,
|
||||
hasUnsavedChanges,
|
||||
amendWaypoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
planner.waypoints = change(planner.waypoints);
|
||||
}),
|
||||
editPaddlePoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
planner.waypoints = change(planner.waypoints);
|
||||
}),
|
||||
};
|
||||
return planner;
|
||||
}
|
||||
|
||||
const carried = (): TourPlannerWaypoint[] => [{ ...A, fixedLeg: portageLegBetween(A, B) }, B];
|
||||
|
||||
describe('usePortageRouting', () => {
|
||||
beforeEach(() => routePortageLine.mockReset());
|
||||
|
||||
it('FE-PORTAGE-ROUTING-001: lays a portage the paddler just set on the way the router found', async () => {
|
||||
routePortageLine.mockResolvedValue(WAY);
|
||||
const planner = plannerWith(carried(), true);
|
||||
renderHook(() => usePortageRouting(planner, true));
|
||||
await waitFor(() => expect(planner.amendWaypoints).toHaveBeenCalled());
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-002: leaves a tour as saved alone, and asks nobody while it cannot be edited', async () => {
|
||||
const saved = plannerWith(carried(), false);
|
||||
renderHook(() => usePortageRouting(saved, true));
|
||||
const locked = plannerWith(carried(), true);
|
||||
renderHook(() => usePortageRouting(locked, false));
|
||||
await Promise.resolve();
|
||||
expect(routePortageLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-003: keeps a carry straight when the router found no way, and says so', async () => {
|
||||
routePortageLine.mockResolvedValue(null);
|
||||
const planner = plannerWith(carried(), true);
|
||||
const { result } = renderHook(() => usePortageRouting(planner, true));
|
||||
await waitFor(() => expect(routePortageLine).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.noWay(['a'])).toBe(true));
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-004: lays a saved straight portage on the way when asked to', async () => {
|
||||
routePortageLine.mockResolvedValue(WAY);
|
||||
const planner = plannerWith(carried(), false);
|
||||
const { result } = renderHook(() => usePortageRouting(planner, true));
|
||||
let found = false;
|
||||
await act(async () => {
|
||||
found = await result.current.follow(['a']);
|
||||
});
|
||||
expect(found).toBe(true);
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
|
||||
});
|
||||
|
||||
it('FE-PORTAGE-ROUTING-005: draws a portage straight on request and keeps it so', async () => {
|
||||
const planner = plannerWith([{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry: WAY } }, B], true);
|
||||
const { result, rerender } = renderHook(() => usePortageRouting(planner, true));
|
||||
act(() => result.current.straighten(['a']));
|
||||
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual([
|
||||
[A.lat, A.lng],
|
||||
[B.lat, B.lng],
|
||||
]);
|
||||
rerender();
|
||||
await Promise.resolve();
|
||||
expect(routePortageLine).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { TourFixedLeg } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import type { PortageWay } from './PaddleWaypointParts';
|
||||
import { routePortageLine } from './portageRoute';
|
||||
|
||||
type Planner = Pick<
|
||||
TourPlannerController,
|
||||
'waypoints' | 'isPaddle' | 'hasUnsavedChanges' | 'amendWaypoints' | 'editPaddlePoints'
|
||||
>;
|
||||
type Point = TourPlannerController['waypoints'][number];
|
||||
type Line = [number, number][];
|
||||
|
||||
/** A portage leg by its two ends, to six decimals (about ten centimetres): the same carry asks once. */
|
||||
function keyOf(from: Point, to: Point): string {
|
||||
return `${from.lat.toFixed(6)},${from.lng.toFixed(6)}>${to.lat.toFixed(6)},${to.lng.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function isStraightPortage(leg: TourFixedLeg | null | undefined): boolean {
|
||||
return leg?.kind === 'portage' && leg.geometry.length === 2;
|
||||
}
|
||||
|
||||
/** The portages still drawn straight, with the point after each. */
|
||||
function straightPortages(points: readonly Point[]) {
|
||||
return points.flatMap((point, index) => {
|
||||
const next = points[index + 1];
|
||||
// A portage the water router found is its own: it already follows the mapped way.
|
||||
return next && !point.auto && isStraightPortage(point.fixedLeg)
|
||||
? [{ id: point.id, from: point, to: next, key: keyOf(point, next) }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-03: a portage follows the ways on land instead of cutting straight
|
||||
* across them. A portage the paddler sets, or whose ends they move, is asked
|
||||
* of the router the hikes use and takes its line, unless that line is a
|
||||
* detour; then it stays straight. A tour opened as saved keeps its straight
|
||||
* portages until the paddler asks for the way (`follow`), and one they set
|
||||
* straight on purpose (`straighten`) stays so.
|
||||
*/
|
||||
export function usePortageRouting(planner: Planner, editable: boolean) {
|
||||
const { waypoints, isPaddle, hasUnsavedChanges, amendWaypoints, editPaddlePoints } = planner;
|
||||
/** Carries kept straight: as the tour was saved, or as the paddler set them. */
|
||||
const kept = useRef(new Set<string>());
|
||||
/** What the router answered per carry; null when it found no way worth taking. */
|
||||
const lines = useRef(new Map<string, Line | null>());
|
||||
|
||||
const layIn = useCallback(() => {
|
||||
amendWaypoints((current) => {
|
||||
let changed = false;
|
||||
const next = current.map((point, index) => {
|
||||
const after = current[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
if (!after || point.auto || !leg || leg.kind !== 'portage' || leg.geometry.length !== 2) return point;
|
||||
const key = keyOf(point, after);
|
||||
const line = kept.current.has(key) ? null : lines.current.get(key);
|
||||
if (!line) return point;
|
||||
changed = true;
|
||||
return { ...point, fixedLeg: { ...leg, geometry: line } };
|
||||
});
|
||||
return changed ? next : current;
|
||||
});
|
||||
}, [amendWaypoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaddle || !editable) return undefined;
|
||||
const legs = straightPortages(waypoints);
|
||||
if (!hasUnsavedChanges) {
|
||||
for (const leg of legs) kept.current.add(leg.key);
|
||||
return undefined;
|
||||
}
|
||||
const wanted = legs.filter((leg) => !kept.current.has(leg.key));
|
||||
if (wanted.some((leg) => lines.current.get(leg.key))) layIn();
|
||||
const missing = wanted.filter((leg) => !lines.current.has(leg.key));
|
||||
if (missing.length === 0) return undefined;
|
||||
const abort = new AbortController();
|
||||
// One after the other: the public router allows about one request a second.
|
||||
void missing
|
||||
.reduce(async (previous, leg) => {
|
||||
await previous;
|
||||
if (abort.signal.aborted) return;
|
||||
const line = await routePortageLine(leg.from, leg.to, abort.signal);
|
||||
if (!abort.signal.aborted) lines.current.set(leg.key, line);
|
||||
}, Promise.resolve())
|
||||
.then(() => {
|
||||
if (!abort.signal.aborted) layIn();
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [editable, hasUnsavedChanges, isPaddle, layIn, waypoints]);
|
||||
|
||||
/** The carries starting at these points, each with the point after it. */
|
||||
const legsOf = useCallback(
|
||||
(ids: readonly string[]) =>
|
||||
waypoints.flatMap((point, index) => {
|
||||
const next = waypoints[index + 1];
|
||||
return ids.includes(point.id) && next ? [{ point, next, key: keyOf(point, next) }] : [];
|
||||
}),
|
||||
[waypoints]
|
||||
);
|
||||
|
||||
/**
|
||||
* A portage laid on the ways, after it was straight (as saved, or set so),
|
||||
* leg by leg: true once one follows them, false when there is no way worth
|
||||
* taking and it stays straight.
|
||||
*/
|
||||
const follow = useCallback(
|
||||
async (ids: readonly string[]) => {
|
||||
const legs = legsOf(ids);
|
||||
const found = await legs.reduce(async (previous, { point, next, key }) => {
|
||||
const any = await previous;
|
||||
kept.current.delete(key);
|
||||
const line = await routePortageLine(point, next);
|
||||
lines.current.set(key, line);
|
||||
return any || !!line;
|
||||
}, Promise.resolve(false));
|
||||
if (found) layIn();
|
||||
return found;
|
||||
},
|
||||
[layIn, legsOf]
|
||||
);
|
||||
|
||||
/** A portage drawn straight, and kept so. */
|
||||
const straighten = useCallback(
|
||||
(ids: readonly string[]) => {
|
||||
for (const { key } of legsOf(ids)) kept.current.add(key);
|
||||
editPaddlePoints((current) =>
|
||||
current.map((point, index) => {
|
||||
const next = current[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
if (!ids.includes(point.id) || !next || leg?.kind !== 'portage') return point;
|
||||
return {
|
||||
...point,
|
||||
fixedLeg: {
|
||||
...leg,
|
||||
geometry: [
|
||||
[point.lat, point.lng],
|
||||
[next.lat, next.lng],
|
||||
],
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
[editPaddlePoints, legsOf]
|
||||
);
|
||||
|
||||
/** Whether the router found no way worth taking for every leg of a portage. */
|
||||
const noWay = useCallback(
|
||||
(ids: readonly string[]) => {
|
||||
const legs = legsOf(ids);
|
||||
return legs.length > 0 && legs.every(({ key }) => lines.current.get(key) === null);
|
||||
},
|
||||
[legsOf]
|
||||
);
|
||||
|
||||
return { follow, straighten, noWay };
|
||||
}
|
||||
|
||||
type PortageRouting = ReturnType<typeof usePortageRouting>;
|
||||
|
||||
/** The waypoints whose legs make up the portage starting at `index`: one leg, or several in a row. */
|
||||
function portageRunIds(points: readonly Point[], index: number): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let at = index; at < points.length - 1 && points[at]?.fixedLeg?.kind === 'portage'; at += 1) {
|
||||
ids.push(points[at].id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-03: the way of the portage starting at a waypoint, for its card:
|
||||
* whether it follows the ways on land, why it stayed straight, and the switch
|
||||
* between the two. Undefined where no portage starts.
|
||||
*/
|
||||
export function usePortageWay(
|
||||
points: readonly Point[],
|
||||
routing: PortageRouting,
|
||||
index: number
|
||||
): PortageWay | undefined {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const ids = portageRunIds(points, index);
|
||||
if (ids.length === 0) return undefined;
|
||||
return {
|
||||
routed: ids.every((id) => (points.find((point) => point.id === id)?.fixedLeg?.geometry.length ?? 0) > 2),
|
||||
noWay: routing.noWay(ids),
|
||||
searching,
|
||||
onFollow: () => {
|
||||
setSearching(true);
|
||||
void routing.follow(ids).finally(() => setSearching(false));
|
||||
},
|
||||
onStraighten: () => routing.straighten(ids),
|
||||
};
|
||||
}
|
||||
@@ -926,6 +926,24 @@ describe('the paddle checklist in the rails (PAD-06)', () => {
|
||||
expect(safetyRow()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../
|
||||
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
|
||||
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
|
||||
import { TourDetailsSection, TourNameField } from './TourEditorFields'
|
||||
import { crossesLake } from '../paddle/lakeStyleAdvice'
|
||||
import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
|
||||
import { PaddleSafetyRow } from '../paddle/PaddleSafety'
|
||||
import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
|
||||
@@ -121,6 +122,18 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
|
||||
function retryable(planner: TourPlannerController): boolean {
|
||||
if (planner.error === 'paddle-route') return false
|
||||
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
}
|
||||
|
||||
/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */
|
||||
function statusText(planner: TourPlannerController, key: string, t: Translate): string {
|
||||
if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 })
|
||||
return t(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor is doing right now, as icon badges beside the head's title:
|
||||
* a restored draft, routing or saving, and a failure with its retry. Nothing
|
||||
@@ -132,10 +145,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
|
||||
const restored = planner.draftRestored && planner.hasUnsavedChanges
|
||||
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
|
||||
const failed = planner.error === 'save'
|
||||
const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
|
||||
const canRetry = retryable(planner)
|
||||
if (!restored && !statusKey && !failed && !onFerrata) return null
|
||||
const warning = planner.error != null || canRetry
|
||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
|
||||
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? statusText(planner, statusKey, t) : null
|
||||
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
|
||||
return (
|
||||
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
|
||||
@@ -326,7 +339,11 @@ function PaddleEditorCards({ planner, locked, checklist }: {
|
||||
<>
|
||||
{checklist && <PaddleCheckCard checklist={checklist} />}
|
||||
<PaddleTimeCard planner={planner} locked={locked} />
|
||||
<PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
|
||||
<PaddleWindSection
|
||||
placeId={planner.editingPlaceId}
|
||||
start={planner.waypoints[0] ?? null}
|
||||
lakes={!locked && crossesLake(planner.paddleRoute) ? { value: planner.paddle.lakeStyle, onChoose: planner.paddle.setLakeStyle } : undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,3 +75,33 @@ describe('TourTypeFields (PAD-01)', () => {
|
||||
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourTypeFields lake style (PAD-11)', () => {
|
||||
it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => {
|
||||
const river = planner({}, { waterContext: 'river' });
|
||||
const { rerender } = render(<TourTypeFields planner={river} locked={false} />);
|
||||
expect(screen.queryByTestId('tour-planner-lake-style')).not.toBeInTheDocument();
|
||||
|
||||
const lake = planner({}, { waterContext: 'lake' });
|
||||
rerender(<TourTypeFields planner={lake} locked={false} />);
|
||||
expect(screen.getByRole('button', { name: 'Straight over' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('The shortest line across open water.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Near the shore' }));
|
||||
expect(lake.paddle.setLakeStyle).toHaveBeenCalledWith('shore');
|
||||
|
||||
const lakeLeg = {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[53.4, 12.7],
|
||||
[53.5, 12.7],
|
||||
],
|
||||
segments: [{ kind: 'lake', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
|
||||
distance_m: 11000,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
const crossing = planner({ paddleRoute: { legs: [lakeLeg] } }, { waterContext: 'river', lakeStyle: 'shore' });
|
||||
rerender(<TourTypeFields planner={crossing} locked={false} />);
|
||||
expect(screen.getByRole('button', { name: 'Near the shore' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Stays near the bank, for windy days: longer, but land is never far.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useTranslation } from '../../../i18n';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { crossesLake } from '../paddle/lakeStyleAdvice';
|
||||
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
@@ -126,6 +128,9 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
|
||||
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{(paddle.waterContext === 'lake' || crossesLake(planner.paddleRoute)) && (
|
||||
<PaddleLakeStyleField value={paddle.lakeStyle} onChange={paddle.setLakeStyle} />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { PaddleTourRoute } from './paddleRouting';
|
||||
import TourWaypointList from './TourWaypointList';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/** The item at `i`, or a failure that names the index instead of a later undefined. */
|
||||
function at<T>(list: readonly T[], i: number): T {
|
||||
const item = list[i];
|
||||
if (item === undefined) throw new Error(`nothing at ${i}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
|
||||
|
||||
const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const };
|
||||
const SHAPE = { id: 's', lat: 49.08, lng: 12.11, role: 'via' as const, shaping: true };
|
||||
const STOP = { id: 'b', lat: 49.05, lng: 12.12, role: 'via' as const };
|
||||
const END = { id: 'c', lat: 49.0, lng: 12.1, role: 'end' as const };
|
||||
|
||||
const segment = (kind: 'water' | 'rapids' | 'pass' | 'gap', from: number, to: number, grade: number | null = null) => ({
|
||||
kind,
|
||||
from,
|
||||
to,
|
||||
name: null,
|
||||
source: null,
|
||||
osm_ref: null,
|
||||
grade,
|
||||
});
|
||||
|
||||
const WHITEWATER: PaddleRouteLeg = {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[49.05, 12.12],
|
||||
[49.04, 12.12],
|
||||
[49.03, 12.115],
|
||||
[49.0, 12.1],
|
||||
],
|
||||
segments: [segment('water', 0, 1), segment('rapids', 1, 2, 2), segment('gap', 2, 3)],
|
||||
distance_m: 5800,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||
return {
|
||||
...hikePlannerDefaults(),
|
||||
waypoints: [START, SHAPE, STOP, END],
|
||||
selectedWaypointId: null,
|
||||
setSelectedWaypointId: vi.fn(),
|
||||
reorderWaypoint: vi.fn(),
|
||||
moveWaypoint: vi.fn(),
|
||||
removeWaypoint: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TourPlannerController;
|
||||
}
|
||||
|
||||
function paddleRoute(legs: PaddleTourRoute['legs'], failures: PaddleTourRoute['failures'] = []): PaddleTourRoute {
|
||||
return { coordinates: [], parts: [], distanceMeters: 0, legs, legLengths: legs.map(() => 0), failures };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
});
|
||||
|
||||
describe('TourWaypointList', () => {
|
||||
it('FE-TOUR-WAYPOINT-LIST-001: numbers the stops only; a shaping point is a dot and does not count', () => {
|
||||
render(<TourWaypointList planner={planner()} canEdit isSaving={false} />);
|
||||
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
|
||||
expect(rows).toHaveLength(4);
|
||||
expect(within(at(rows, 1)).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true');
|
||||
expect(
|
||||
[...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number'))
|
||||
).toEqual(['1', '2', '3']);
|
||||
expect(screen.getByText('Click the route on the map to add a shaping point there.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => {
|
||||
const controller = planner({ selectedWaypointId: 'b' });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const row = at(screen.getAllByTestId('tour-planner-waypoint-row'), 2);
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' }));
|
||||
expect(controller.recomputeLeg).toHaveBeenCalledWith('b');
|
||||
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Make it a shaping point' }));
|
||||
expect(controller.setWaypointShaping).toHaveBeenCalledWith('b', true);
|
||||
|
||||
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Draw the next leg by hand' }));
|
||||
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-003: a shaping point becomes a stop again; the first point offers no shaping', async () => {
|
||||
const controller = planner({ selectedWaypointId: 's' });
|
||||
const { rerender } = render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
await userEvent.click(
|
||||
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 1)).getByRole('button', { name: 'More actions' })
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' }));
|
||||
expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false);
|
||||
|
||||
rerender(<TourWaypointList planner={planner({ selectedWaypointId: 'a' })} canEdit isSaving={false} />);
|
||||
await userEvent.click(
|
||||
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 0)).getByRole('button', { name: 'More actions' })
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-004: a paddle leg shows its whitewater and its data gap with their lengths', () => {
|
||||
const route = paddleRoute([null, null, WHITEWATER]);
|
||||
render(
|
||||
<TourWaypointList
|
||||
planner={planner({ ...{ isPaddle: true, tourType: 'kayak' }, paddleRoute: route, routeCurrent: true })}
|
||||
canEdit
|
||||
isSaving={false}
|
||||
/>
|
||||
);
|
||||
const notes = screen.getByTestId('tour-planner-leg-notes');
|
||||
expect(within(notes).getByText('Whitewater 2')).toBeInTheDocument();
|
||||
expect(within(notes).getByText('Gap in the map data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-005: a leg the router could not plan says why and offers the way out', async () => {
|
||||
const route = paddleRoute(
|
||||
[{ status: 'failed', code: 'busy', at: null }, null, null],
|
||||
[{ index: 0, code: 'busy', at: null }]
|
||||
);
|
||||
const controller = planner({ isPaddle: true, tourType: 'kayak', paddleRoute: route, routeCurrent: true });
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
const failure = screen.getByTestId('tour-planner-leg-failure');
|
||||
expect(within(failure).getByRole('alert')).toHaveTextContent('The route planner is busy. Try again in a moment.');
|
||||
await userEvent.click(within(failure).getByRole('button', { name: 'Try again' }));
|
||||
expect(controller.recomputeLeg).toHaveBeenCalledWith('a');
|
||||
await userEvent.click(within(failure).getByRole('button', { name: 'Draw straight' }));
|
||||
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINT-LIST-006: a point off the water offers no retry; legs drawn by hand can go back to the water in one step', async () => {
|
||||
const drawn = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49.1, 12.1],
|
||||
[49.08, 12.11],
|
||||
] as [number, number][],
|
||||
};
|
||||
const route = paddleRoute(
|
||||
[null, { status: 'failed', code: 'not_on_water', at: null }, null],
|
||||
[{ index: 1, code: 'not_on_water', at: null }]
|
||||
);
|
||||
const auto = { from: { id: 'a', lat: 49.1, lng: 12.1 }, to: { id: 'c', lat: 49.0, lng: 12.1 } };
|
||||
const controller = planner({
|
||||
isPaddle: true,
|
||||
tourType: 'kayak',
|
||||
paddleRoute: route,
|
||||
routeCurrent: true,
|
||||
waypoints: [{ ...START, fixedLeg: drawn }, SHAPE, { ...STOP, auto }, END],
|
||||
});
|
||||
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
|
||||
expect(
|
||||
within(screen.getByTestId('tour-planner-leg-failure')).queryByRole('button', { name: 'Try again' })
|
||||
).toBeNull();
|
||||
expect(screen.getByTestId('tour-planner-auto-point')).toHaveAttribute('aria-label', 'Found');
|
||||
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
|
||||
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,24 @@
|
||||
import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared';
|
||||
import {
|
||||
paddleLayoutOfPoints,
|
||||
type PaddleLayout,
|
||||
type PaddleRegionLoadState,
|
||||
type PaddleRole,
|
||||
type TourFixedLeg,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowDownUp,
|
||||
ArrowUp,
|
||||
GripVertical,
|
||||
Info,
|
||||
MapPin,
|
||||
MapPinPlus,
|
||||
MoreHorizontal,
|
||||
Navigation,
|
||||
RefreshCw,
|
||||
Repeat,
|
||||
Spline,
|
||||
Trash2,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -28,10 +36,16 @@ import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
|
||||
import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts';
|
||||
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
|
||||
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
|
||||
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
|
||||
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
|
||||
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting';
|
||||
import { RailCard } from '../tourParts';
|
||||
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
|
||||
import type { PaddleLegFailure } from './paddleRouting';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
import { useWaypointPlaceNames } from './useWaypointPlaceNames';
|
||||
@@ -207,18 +221,35 @@ function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
|
||||
* by the tooltip.
|
||||
*/
|
||||
function WaypointNumber({
|
||||
index,
|
||||
stop,
|
||||
selected,
|
||||
ferrata,
|
||||
loopEnd = false,
|
||||
shaping = false,
|
||||
}: {
|
||||
index: number;
|
||||
/** The stop's number; shaping points do not count, as on the map. */
|
||||
stop: number;
|
||||
selected: boolean;
|
||||
ferrata: TourWaypointFerrata | null;
|
||||
/** The way back to the start of a loop: shown as the return, not as one more stop. */
|
||||
loopEnd?: boolean;
|
||||
/** P2A-03: a shaping point, a small dot as on the map. */
|
||||
shaping?: boolean;
|
||||
}) {
|
||||
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) {
|
||||
return (
|
||||
<span
|
||||
@@ -230,16 +261,16 @@ function WaypointNumber({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label = t('tours.planner.waypointLabel', { n: index + 1 });
|
||||
const label = t('tours.planner.waypointLabel', { n: stop });
|
||||
const number = (
|
||||
<span
|
||||
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
|
||||
data-waypoint-number={index + 1}
|
||||
data-waypoint-number={stop}
|
||||
data-ferrata-end={ferrata ? 'true' : undefined}
|
||||
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)}
|
||||
>
|
||||
{index + 1}
|
||||
{stop}
|
||||
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
|
||||
</span>
|
||||
);
|
||||
@@ -278,9 +309,18 @@ function WaypointRowLabel({
|
||||
>
|
||||
{name ?? coordinates}
|
||||
</span>
|
||||
{paddleRole !== undefined
|
||||
? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
||||
: point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
|
||||
{point.shaping ? (
|
||||
<Tooltip label={t('tours.planner.shapingHint')}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill>{t('tours.planner.shapingPoint')}</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : paddleRole !== undefined ? (
|
||||
paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
|
||||
) : (
|
||||
point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
|
||||
)}
|
||||
{point.auto && <AutoPointPill />}
|
||||
</span>
|
||||
{name && !loopEnd && (
|
||||
<span
|
||||
@@ -302,6 +342,82 @@ function WaypointRowLabel({
|
||||
* and remove beside it. Two controls keep the place name readable in a narrow
|
||||
* 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({
|
||||
planner,
|
||||
point,
|
||||
@@ -311,7 +427,7 @@ function WaypointRowActions({
|
||||
isSaving,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
point: WaypointPoint;
|
||||
index: number;
|
||||
lastIndex: number;
|
||||
selected: boolean;
|
||||
@@ -320,10 +436,7 @@ function WaypointRowActions({
|
||||
const { t } = useTranslation();
|
||||
const menu = useContextMenu();
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const free = point.fixedLeg?.kind === 'free';
|
||||
const portage = point.fixedLeg?.kind === 'portage';
|
||||
const hasLeg = index < lastIndex;
|
||||
const items = [
|
||||
const items: MenuItems = [
|
||||
{
|
||||
label: t('tours.planner.moveUp'),
|
||||
icon: ArrowUp,
|
||||
@@ -336,35 +449,8 @@ function WaypointRowActions({
|
||||
disabled: index === lastIndex,
|
||||
onClick: () => planner.moveWaypoint(point.id, 1),
|
||||
},
|
||||
...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata'
|
||||
? [
|
||||
{ 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 }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...routeMenuItems(planner, point, index, lastIndex, t),
|
||||
...(planner.isPaddle ? paddleMenuItems(paddle, point, index < lastIndex, t) : []),
|
||||
];
|
||||
return (
|
||||
<span
|
||||
@@ -402,24 +488,29 @@ function PaddleRowExtras({
|
||||
index,
|
||||
layout,
|
||||
editable,
|
||||
routing,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
point: TourPlannerController['waypoints'][number];
|
||||
index: number;
|
||||
layout: PaddleLayout;
|
||||
editable: boolean;
|
||||
routing: ReturnType<typeof usePortageRouting>;
|
||||
}) {
|
||||
const paddle = usePaddleWaypointActions(planner);
|
||||
const way = usePortageWay(planner.waypoints, routing, 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;
|
||||
return (
|
||||
<>
|
||||
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
|
||||
{portage && (
|
||||
<PortageLegRow
|
||||
lengthM={portage.toM - portage.fromM}
|
||||
extraMinutes={portage.extraMinutes}
|
||||
disabled={!editable}
|
||||
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
|
||||
way={point.auto ? undefined : way}
|
||||
/>
|
||||
)}
|
||||
{point.lock && (
|
||||
@@ -463,6 +554,154 @@ function AddPointRow({ disabled }: { disabled: boolean }) {
|
||||
* 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.
|
||||
*/
|
||||
/** 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({
|
||||
planner,
|
||||
canEdit,
|
||||
@@ -489,34 +728,22 @@ export default function TourWaypointList({
|
||||
// A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler.
|
||||
const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined);
|
||||
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 = () => {
|
||||
setDraggedWaypointId(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
|
||||
// One card like a day in the plan: the head counts the points, the rows sit below.
|
||||
const tools = canEdit && planner.waypoints.length > 0 && (
|
||||
<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>
|
||||
<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 tools = canEdit && planner.waypoints.length > 0 && <WaypointListTools planner={planner} isSaving={isSaving} />;
|
||||
// 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.
|
||||
const stops = stopNumbers.size - (planner.isLoop ? 1 : 0);
|
||||
const card = (children: ReactNode) => (
|
||||
<RailCard
|
||||
icon={<MapPin size={14} strokeWidth={2} />}
|
||||
@@ -608,10 +835,11 @@ export default function TourWaypointList({
|
||||
<GripVertical size={14} strokeWidth={1.8} />
|
||||
</span>
|
||||
<WaypointNumber
|
||||
index={index}
|
||||
stop={stopNumbers.get(point.id) ?? index + 1}
|
||||
selected={selected}
|
||||
ferrata={ferrataEnds[point.id] ?? null}
|
||||
loopEnd={loopEnd}
|
||||
shaping={!!point.shaping}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -638,30 +866,23 @@ export default function TourWaypointList({
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
{(legCrossings.get(point.id) ?? []).map((crossing) => (
|
||||
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
|
||||
))}
|
||||
{/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
|
||||
{point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
|
||||
{paddleLayout && (
|
||||
<PaddleRowExtras
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
layout={paddleLayout}
|
||||
editable={canEdit && !isSaving}
|
||||
/>
|
||||
)}
|
||||
<WaypointLegRows
|
||||
planner={planner}
|
||||
point={point}
|
||||
index={index}
|
||||
crossings={legCrossings.get(point.id)}
|
||||
routedLegs={routedLegs}
|
||||
paddleLayout={paddleLayout}
|
||||
editable={canEdit && !isSaving}
|
||||
portageRouting={portageRouting}
|
||||
regionLoad={regionLoad}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{canEdit && <AddPointRow disabled={isSaving} />}
|
||||
{planner.isPaddle && (
|
||||
<div className="mt-2">
|
||||
<TourNotice icon={<Info size={13} strokeWidth={2} />}>{t('tours.paddle.drawnByHand')}</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
<ShapeOnLineHint planner={planner} canEdit={canEdit} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type PaddleRole,
|
||||
type PaddleTimeEstimate,
|
||||
} from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './useTourPlanner';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
/** The planner's control points as the shared paddle plan reads them. */
|
||||
export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] {
|
||||
@@ -26,15 +26,17 @@ export function paddleRolesById(points: readonly TourPlannerWaypoint[]): Map<str
|
||||
|
||||
/**
|
||||
* PAD-07: the estimate of a paddle tour as planned, without its breaks (the
|
||||
* planner adds those as it does for a hike). A speed the paddler typed that
|
||||
* planner adds those as it does for a hike), with the routed length of each
|
||||
* leg where the router planned it (PAD-11). A speed the paddler typed that
|
||||
* does not hold leaves the water time unknown rather than wrong.
|
||||
*/
|
||||
export function paddleEstimateOf(
|
||||
points: readonly TourPlannerWaypoint[],
|
||||
paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null },
|
||||
speedInvalid: boolean
|
||||
speedInvalid: boolean,
|
||||
legLengths?: ReadonlyArray<number | null>
|
||||
): PaddleTimeEstimate {
|
||||
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), {
|
||||
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points), legLengths), {
|
||||
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
|
||||
portageSpeedKmh: paddle.portageSpeedKmh,
|
||||
breakMinutes: null,
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
|
||||
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
|
||||
import {
|
||||
adoptChangedPoints,
|
||||
materializePaddleLegs,
|
||||
planPaddleStep,
|
||||
pruneAutoPoints,
|
||||
routePaddleTour,
|
||||
type PaddlePoint,
|
||||
} from './paddleRouting';
|
||||
|
||||
/** The item at `i`, or a failure that names the index instead of a later undefined. */
|
||||
function at<T>(list: readonly T[], i: number): T {
|
||||
const item = list[i];
|
||||
if (item === undefined) throw new Error(`nothing at ${i}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } }));
|
||||
|
||||
const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const };
|
||||
const A: PaddlePoint = { id: 'a', lat: 49.1, lng: 12.1 };
|
||||
const B: PaddlePoint = { id: 'b', lat: 49.05, lng: 12.12 };
|
||||
const C: PaddlePoint = { id: 'c', lat: 49.0, lng: 12.1 };
|
||||
|
||||
function segment(kind: PaddleRouteSegment['kind'], from: number, to: number, extra: Partial<PaddleRouteSegment> = {}) {
|
||||
return { kind, from, to, name: null, source: null, osm_ref: null, grade: null, ...extra };
|
||||
}
|
||||
|
||||
/** A routed leg through five points: water, a portage round a weir, water, a lock, water. */
|
||||
const WEIR_AND_LOCK: PaddleRouteLeg = {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[49.1, 12.1],
|
||||
[49.09, 12.11],
|
||||
[49.08, 12.11],
|
||||
[49.07, 12.115],
|
||||
[49.06, 12.118],
|
||||
[49.05, 12.12],
|
||||
],
|
||||
segments: [
|
||||
segment('water', 0, 1),
|
||||
segment('portage', 1, 2, { source: 'tagged' }),
|
||||
segment('water', 2, 3),
|
||||
segment('lock', 3, 4, { name: 'Schleuse' }),
|
||||
segment('water', 4, 5),
|
||||
],
|
||||
distance_m: 6100,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
|
||||
const straight = (from: PaddlePoint, to: PaddlePoint, distance = 1000): PaddleRouteLeg => ({
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
],
|
||||
segments: [segment('water', 0, 1)],
|
||||
distance_m: distance,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
});
|
||||
|
||||
let ids = 0;
|
||||
const newId = () => `auto-${(ids += 1)}`;
|
||||
|
||||
beforeEach(() => {
|
||||
ids = 0;
|
||||
vi.mocked(paddleRouteRepo.plan).mockReset();
|
||||
});
|
||||
|
||||
describe('routePaddleTour', () => {
|
||||
it('FE-PADDLE-ROUTING-001: asks only for the legs without a line, and keeps a drawn or carried leg as it is', async () => {
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([straight(B, C, 5500)]);
|
||||
const drawn = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[B.lat, B.lng],
|
||||
] as [number, number][],
|
||||
};
|
||||
const route = await routePaddleTour([{ ...A, fixedLeg: drawn }, B, C], 7, KAYAK);
|
||||
|
||||
expect(vi.mocked(paddleRouteRepo.plan).mock.calls[0]?.[1]).toEqual([{ from: B, to: C }]);
|
||||
expect(route.legs[0]).toBeNull();
|
||||
expect(route.legs[1]?.status).toBe('ok');
|
||||
expect(route.parts.map((part) => [part.free, !!part.land])).toEqual([
|
||||
[true, false],
|
||||
[false, false],
|
||||
]);
|
||||
expect(route.legLengths[1]).toBe(5500);
|
||||
expect(route.distanceMeters).toBeCloseTo(at(route.legLengths, 0) + 5500);
|
||||
expect(route.coordinates[0]).toEqual([A.lat, A.lng]);
|
||||
expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-002: draws a portage as land, whitewater and a boat pass amber, a gap red, and merges the rest', async () => {
|
||||
const marked: PaddleRouteLeg = {
|
||||
...WEIR_AND_LOCK,
|
||||
segments: [
|
||||
segment('water', 0, 1),
|
||||
segment('portage', 1, 2),
|
||||
segment('rapids', 2, 3, { grade: 2 }),
|
||||
segment('pass', 3, 4),
|
||||
segment('gap', 4, 5),
|
||||
],
|
||||
};
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([marked]);
|
||||
const route = await routePaddleTour([A, B], 7, KAYAK);
|
||||
expect(route.parts.map((part) => part.land ?? part.mark ?? 'water')).toEqual(['water', true, 'rapids', 'gap']);
|
||||
// Whitewater and the boat pass next to it are one amber line.
|
||||
expect(route.parts[2]?.coordinates).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-003: a leg the router could not plan stays a straight unrouted stretch, counted as a failure', async () => {
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([
|
||||
straight(A, B),
|
||||
{ status: 'failed', code: 'not_on_water', at: { lat: C.lat, lng: C.lng } },
|
||||
]);
|
||||
const route = await routePaddleTour([A, B, C], 7, KAYAK);
|
||||
expect(route.failures).toEqual([{ index: 1, code: 'not_on_water', at: { lat: C.lat, lng: C.lng } }]);
|
||||
expect(route.parts[route.parts.length - 1]).toMatchObject({
|
||||
mark: 'unrouted',
|
||||
coordinates: [
|
||||
[B.lat, B.lng],
|
||||
[C.lat, C.lng],
|
||||
],
|
||||
});
|
||||
// The straight line counts in the length, so the time does not shrink while a leg waits.
|
||||
expect(route.legLengths[1]).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-004: a leg the answer leaves out is unrouted, never silently straight water', async () => {
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([]);
|
||||
const route = await routePaddleTour([A, B], 7, KAYAK);
|
||||
expect(route.failures).toEqual([{ index: 0, code: 'no_water_path', at: null }]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-005: a tour with every leg drawn asks nothing', async () => {
|
||||
const carried = {
|
||||
kind: 'portage' as const,
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[B.lat, B.lng],
|
||||
] as [number, number][],
|
||||
extra_minutes: null,
|
||||
};
|
||||
const route = await routePaddleTour([{ ...A, fixedLeg: carried }, B], 7, KAYAK);
|
||||
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
|
||||
expect(route.parts[0]?.land).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('automatic points', () => {
|
||||
it("FE-PADDLE-ROUTING-006: sets a portage pair and a lock where the router found them, on the paddler's own leg", () => {
|
||||
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||
expect(points.map((point) => point.id)).toEqual(['a', 'auto-1', 'auto-2', 'auto-3', 'b']);
|
||||
expect(points[1]).toMatchObject({ lat: 49.09, lng: 12.11, fixedLeg: { kind: 'portage', extra_minutes: null } });
|
||||
expect(points[2]).toMatchObject({ lat: 49.08, lng: 12.11 });
|
||||
expect(points[2]?.fixedLeg).toBeUndefined();
|
||||
expect(points[3]).toMatchObject({ lock: { wait_minutes: null, transit_minutes: null } });
|
||||
expect(points[1]?.auto).toEqual({
|
||||
from: { id: 'a', lat: A.lat, lng: A.lng },
|
||||
to: { id: 'b', lat: B.lat, lng: B.lng },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-007: planning the same leg again keeps the ids, and a leg at an automatic point adds nothing', () => {
|
||||
const once = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||
const legs = once.slice(0, -1).map(() => WEIR_AND_LOCK);
|
||||
const twice = materializePaddleLegs(once, { legs }, newId);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-008: drops the automatic points of a leg whose end moved or went, and keeps them otherwise', () => {
|
||||
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
|
||||
expect(pruneAutoPoints(points)).toEqual(points);
|
||||
const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
|
||||
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']);
|
||||
const shaped = [at(points, 0), { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)];
|
||||
expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ROUTING-009: a point the paddler moves or edits is theirs; an untouched one stays automatic', () => {
|
||||
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
|
||||
const edited = points.map((point) =>
|
||||
point.id === 'auto-3' ? { ...point, lock: { wait_minutes: 20, transit_minutes: null } } : point
|
||||
);
|
||||
const adopted = adoptChangedPoints(points, edited);
|
||||
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
|
||||
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaddleStep', () => {
|
||||
it('FE-PADDLE-ROUTING-010: prunes first, then sets what the router found, then answers the route', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([WEIR_AND_LOCK]);
|
||||
const first = await planPaddleStep([A, B], 7, KAYAK, signal, newId);
|
||||
expect('points' in first && first.points).toHaveLength(5);
|
||||
|
||||
const found = 'points' in first ? first.points : [];
|
||||
vi.mocked(paddleRouteRepo.plan).mockResolvedValue(found.slice(0, -1).map(() => straight(A, B, 1500)));
|
||||
const second = await planPaddleStep(found, 7, KAYAK, signal, newId);
|
||||
expect('route' in second && second.route.legLengths).toHaveLength(4);
|
||||
|
||||
const stale = found.map((point) => (point.id === 'a' ? { ...point, lng: point.lng + 0.01 } : point));
|
||||
vi.mocked(paddleRouteRepo.plan).mockClear();
|
||||
const third = await planPaddleStep(stale, 7, KAYAK, signal, newId);
|
||||
expect('points' in third && third.points.map((point) => point.id)).toEqual(['a', 'b']);
|
||||
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { PaddleLock, PaddleRouteFailureCode, PaddleRouteLeg, TourFixedLeg } from '@trek/shared';
|
||||
import { paddleRouteRepo, type PaddleLegAnswer, type PaddleRouteProfile } from '../../../repo/paddleRouteRepo';
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import { lineLengthM } from './tourFixedLegs';
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
/**
|
||||
* PAD-11: a paddle tour's route in the planner. A leg without a line of its
|
||||
* own is planned on the water by the server's router, as a hike's legs are by
|
||||
* Valhalla; a leg drawn by hand or carried as a portage keeps its line. Where
|
||||
* the router carries round a weir or goes through a lock between two of the
|
||||
* paddler's own points, the planner sets points of its own there (`auto`), so
|
||||
* the portage and the lock count like any planned by hand: in the time, in the
|
||||
* checklist, on the map. They are derived again whenever the points they came
|
||||
* from change, and become the paddler's own once touched.
|
||||
*/
|
||||
|
||||
/** Where an automatic point came from: the two points of the paddler's leg it was found on, as they were. */
|
||||
export interface AutoBase {
|
||||
from: { id: string; lat: number; lng: number };
|
||||
to: { id: string; lat: number; lng: number };
|
||||
}
|
||||
|
||||
export interface PaddlePoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
lock?: PaddleLock | null;
|
||||
shaping?: boolean;
|
||||
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||
auto?: AutoBase;
|
||||
}
|
||||
|
||||
export type PaddleLegFailure = PaddleRouteFailureCode | 'offline';
|
||||
|
||||
export interface PaddleTourRoute {
|
||||
coordinates: [number, number][];
|
||||
parts: TourRoutePart[];
|
||||
distanceMeters: number;
|
||||
/** Per leg (index of its first point): the router's answer, or null for a leg with a line of its own. */
|
||||
legs: Array<PaddleLegAnswer | null>;
|
||||
/** The routed length of every leg, metres, for the time estimate. */
|
||||
legLengths: number[];
|
||||
failures: Array<{ index: number; code: PaddleLegFailure; at: { lat: number; lng: number } | null }>;
|
||||
}
|
||||
|
||||
/** What a leg holds when its answer is somehow missing: unrouted, never silently straight. */
|
||||
const NOT_ASKED: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
|
||||
|
||||
/** Two points this close are one spot, in degrees (about ten centimetres). */
|
||||
const SAME_SPOT = 1e-6;
|
||||
|
||||
function samePlace(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
|
||||
return Math.abs(a.lat - b.lat) < SAME_SPOT && Math.abs(a.lng - b.lng) < SAME_SPOT;
|
||||
}
|
||||
|
||||
type PartKind = 'water' | 'rapids' | 'gap' | 'unrouted' | 'land' | 'manual';
|
||||
|
||||
function partOf(kind: PartKind, coordinates: [number, number][]): TourRoutePart {
|
||||
if (kind === 'land') return { coordinates, free: false, land: true };
|
||||
if (kind === 'manual') return { coordinates, free: true };
|
||||
return { coordinates, free: false, ...(kind === 'water' ? {} : { mark: kind }) };
|
||||
}
|
||||
|
||||
/** The parts of one routed leg: water as one line, carried and marked stretches as their own. */
|
||||
function routedParts(leg: Extract<PaddleRouteLeg, { status: 'ok' }>): TourRoutePart[] {
|
||||
return leg.segments.map((segment) => {
|
||||
const line = leg.geometry.slice(segment.from, segment.to + 1) as [number, number][];
|
||||
if (segment.kind === 'portage') return partOf('land', line);
|
||||
if (segment.kind === 'rapids' || segment.kind === 'pass') return partOf('rapids', line);
|
||||
if (segment.kind === 'gap') return partOf('gap', line);
|
||||
return partOf('water', line);
|
||||
});
|
||||
}
|
||||
|
||||
/** Neighbouring parts drawn alike as one line, as the hike's route does. */
|
||||
function mergeParts(parts: TourRoutePart[]): TourRoutePart[] {
|
||||
const merged: TourRoutePart[] = [];
|
||||
for (const part of parts) {
|
||||
const previous = merged[merged.length - 1];
|
||||
if (previous && previous.free === part.free && !!previous.land === !!part.land && previous.mark === part.mark) {
|
||||
previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)];
|
||||
} else {
|
||||
merged.push({ ...part, coordinates: [...part.coordinates] });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Plans the legs that need it and puts the whole line together. */
|
||||
export async function routePaddleTour(
|
||||
points: readonly PaddlePoint[],
|
||||
tripId: number | string,
|
||||
profile: PaddleRouteProfile,
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleTourRoute> {
|
||||
const routed = points.slice(0, -1).flatMap((point, index) => (point.fixedLeg ? [] : [index]));
|
||||
const answers = routed.length
|
||||
? await paddleRouteRepo.plan(
|
||||
tripId,
|
||||
routed.map((index) => ({ from: points[index], to: points[index + 1] })),
|
||||
profile,
|
||||
signal
|
||||
)
|
||||
: [];
|
||||
const byLeg = new Map(routed.map((index, at) => [index, answers[at]]));
|
||||
const parts: TourRoutePart[] = [];
|
||||
const legs: PaddleTourRoute['legs'] = [];
|
||||
const legLengths: number[] = [];
|
||||
const failures: PaddleTourRoute['failures'] = [];
|
||||
for (let index = 0; index < points.length - 1; index += 1) {
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
if (leg) {
|
||||
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
parts.push(partOf(leg.kind === 'portage' ? 'land' : 'manual', line));
|
||||
legs.push(null);
|
||||
legLengths.push(lineLengthM(line));
|
||||
continue;
|
||||
}
|
||||
const answer = byLeg.get(index) ?? NOT_ASKED;
|
||||
legs.push(answer);
|
||||
if (answer.status === 'ok') {
|
||||
parts.push(...routedParts(answer));
|
||||
legLengths.push(answer.distance_m);
|
||||
continue;
|
||||
}
|
||||
// A leg the router could not plan stays visible as what it is: a straight stretch nobody checked.
|
||||
failures.push({ index, code: answer.code, at: answer.at });
|
||||
parts.push(
|
||||
partOf('unrouted', [
|
||||
[point.lat, point.lng],
|
||||
[next.lat, next.lng],
|
||||
])
|
||||
);
|
||||
legLengths.push(haversineKm(point, next) * 1000);
|
||||
}
|
||||
const lines = mergeParts(parts);
|
||||
const coordinates = lines.flatMap((part, index) => (index === 0 ? part.coordinates : part.coordinates.slice(1)));
|
||||
return {
|
||||
coordinates,
|
||||
parts: lines,
|
||||
distanceMeters: legLengths.reduce((sum, length) => sum + length, 0),
|
||||
legs,
|
||||
legLengths,
|
||||
failures,
|
||||
};
|
||||
}
|
||||
|
||||
function baseOf(points: readonly PaddlePoint[], index: number): AutoBase {
|
||||
const from = points[index];
|
||||
const to = points[index + 1];
|
||||
return { from: { id: from.id, lat: from.lat, lng: from.lng }, to: { id: to.id, lat: to.lat, lng: to.lng } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The automatic points that no longer stand on the leg they were found on
|
||||
* (an end of it moved, or went), dropped; the next plan finds them again
|
||||
* where they belong.
|
||||
*/
|
||||
export function pruneAutoPoints<T extends PaddlePoint>(points: readonly T[]): T[] {
|
||||
const own = points.filter((point) => !point.auto);
|
||||
return points.filter((point) => {
|
||||
const base = point.auto;
|
||||
if (!base) return true;
|
||||
const from = own.find((candidate) => candidate.id === base.from.id);
|
||||
const to = own.find((candidate) => candidate.id === base.to.id);
|
||||
if (!from || !to || !samePlace(from, base.from) || !samePlace(to, base.to)) return false;
|
||||
return own.indexOf(to) === own.indexOf(from) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the portages and locks the router found on the paddler's own legs as
|
||||
* points. An existing automatic point at the same spot keeps its id, so
|
||||
* planning the same leg again changes nothing.
|
||||
*/
|
||||
export function materializePaddleLegs<T extends PaddlePoint>(
|
||||
points: readonly T[],
|
||||
route: Pick<PaddleTourRoute, 'legs'>,
|
||||
newId: () => string
|
||||
): T[] {
|
||||
const existing = points.filter((point) => point.auto);
|
||||
const idAt = (lat: number, lng: number) => existing.find((point) => samePlace(point, { lat, lng }))?.id ?? newId();
|
||||
const result: T[] = [];
|
||||
points.forEach((point, index) => {
|
||||
result.push(point);
|
||||
const next = points[index + 1];
|
||||
const answer = route.legs[index];
|
||||
// Only a leg between two of the paddler's own points: a leg at an automatic point is part of one already set.
|
||||
if (!next || point.auto || next.auto || !answer || answer.status !== 'ok') return;
|
||||
const base = baseOf(points, index);
|
||||
const last = answer.geometry.length - 1;
|
||||
for (const segment of answer.segments) {
|
||||
if (segment.kind === 'portage' && segment.from > 0 && segment.to < last) {
|
||||
const line = answer.geometry.slice(segment.from, segment.to + 1) as [number, number][];
|
||||
const [outLat, outLng] = line[0];
|
||||
const [inLat, inLng] = line[line.length - 1];
|
||||
result.push(
|
||||
{
|
||||
id: idAt(outLat, outLng),
|
||||
lat: outLat,
|
||||
lng: outLng,
|
||||
auto: base,
|
||||
fixedLeg: { kind: 'portage', geometry: line, extra_minutes: null },
|
||||
} as unknown as T,
|
||||
{ id: idAt(inLat, inLng), lat: inLat, lng: inLng, auto: base } as unknown as T
|
||||
);
|
||||
}
|
||||
if (segment.kind === 'lock' && segment.from > 0 && segment.to < last) {
|
||||
const [lat, lng] = answer.geometry[Math.round((segment.from + segment.to) / 2)];
|
||||
result.push({
|
||||
id: idAt(lat, lng),
|
||||
lat,
|
||||
lng,
|
||||
auto: base,
|
||||
lock: { wait_minutes: null, transit_minutes: null },
|
||||
} as unknown as T);
|
||||
}
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
|
||||
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
|
||||
const previous = new Map(before.map((point) => [point.id, point]));
|
||||
return after.map((point) => {
|
||||
if (!point.auto) return point;
|
||||
const was = previous.get(point.id);
|
||||
const same =
|
||||
!!was &&
|
||||
samePlace(was, point) &&
|
||||
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
|
||||
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
|
||||
if (same) return point;
|
||||
const { auto: _auto, ...own } = point;
|
||||
return own as T;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One run of the paddle router over the points: either points to set first
|
||||
* (automatic ones dropped from a leg that changed, or found on a planned
|
||||
* leg), after which the planner runs again, or the finished route.
|
||||
*/
|
||||
export async function planPaddleStep<T extends PaddlePoint>(
|
||||
points: readonly T[],
|
||||
tripId: number | string,
|
||||
profile: PaddleRouteProfile,
|
||||
signal: AbortSignal,
|
||||
newId: () => string
|
||||
): Promise<{ points: T[] } | { route: PaddleTourRoute }> {
|
||||
const pruned = pruneAutoPoints(points);
|
||||
if (pruned.length !== points.length) return { points: pruned };
|
||||
const route = await routePaddleTour(points, tripId, profile, signal);
|
||||
// Points are only ever added: the same count means nothing new was found.
|
||||
const materialized = materializePaddleLegs(points, route, newId);
|
||||
return materialized.length !== points.length ? { points: materialized } : { route };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-ROUTE-LEG-AT-001 to FE-ROUTE-LEG-AT-003: which leg a click on the drawn route lands in (P2A-03).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { legStartAt } from './routeLegAt';
|
||||
|
||||
const A = { id: 'a', lat: 49.0, lng: 12.0 };
|
||||
const B = { id: 'b', lat: 49.0, lng: 12.1 };
|
||||
const C = { id: 'c', lat: 49.1, lng: 12.1 };
|
||||
// The route bends between the points, as a river does.
|
||||
const LINE: [number, number][] = [
|
||||
[49.0, 12.0],
|
||||
[49.01, 12.05],
|
||||
[49.0, 12.1],
|
||||
[49.05, 12.11],
|
||||
[49.1, 12.1],
|
||||
];
|
||||
|
||||
describe('legStartAt', () => {
|
||||
it('FE-ROUTE-LEG-AT-001: a spot on the line belongs to the leg whose two points enclose it', () => {
|
||||
expect(legStartAt([A, B, C], LINE, 49.009, 12.05)).toBe('a');
|
||||
expect(legStartAt([A, B, C], LINE, 49.05, 12.11)).toBe('b');
|
||||
});
|
||||
|
||||
it('FE-ROUTE-LEG-AT-002: a loop keeps its order, so a spot near the start on the way back is the last leg', () => {
|
||||
const back = { id: 'back', lat: A.lat, lng: A.lng };
|
||||
const loop: [number, number][] = [...LINE, [49.05, 12.02], [49.0, 12.0]];
|
||||
expect(legStartAt([A, B, C, back], loop, 49.05, 12.02)).toBe('c');
|
||||
});
|
||||
|
||||
it('FE-ROUTE-LEG-AT-003: without a line or a leg there is nothing to shape', () => {
|
||||
expect(legStartAt([A, B], null, 49, 12.05)).toBeNull();
|
||||
expect(legStartAt([A], LINE, 49, 12.05)).toBeNull();
|
||||
expect(legStartAt([A, B], [[49, 12]], 49, 12.05)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* P2A-03: which leg of a tour a spot on its drawn route belongs to. Every
|
||||
* waypoint is placed along the line (searching on from where the one before
|
||||
* lay, so a loop or a route that passes a point twice keeps its order), then
|
||||
* the spot; the leg is the one whose two points enclose it.
|
||||
*/
|
||||
|
||||
/** Where a spot falls along a line, as the distance from its start in the line's own degrees (flat earth is plenty for ordering). */
|
||||
function alongLine(
|
||||
line: readonly [number, number][],
|
||||
lat: number,
|
||||
lng: number,
|
||||
fromIndex: number
|
||||
): { at: number; index: number } {
|
||||
const cos = Math.cos((lat * Math.PI) / 180);
|
||||
let travelled = 0;
|
||||
let best = { at: 0, index: fromIndex, distance: Infinity };
|
||||
for (let i = 0; i < line.length - 1; i += 1) {
|
||||
const [aLat, aLng] = line[i];
|
||||
const [bLat, bLng] = line[i + 1];
|
||||
const dx = (bLng - aLng) * cos;
|
||||
const dy = bLat - aLat;
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (i >= fromIndex) {
|
||||
const t =
|
||||
length === 0 ? 0 : Math.max(0, Math.min(1, ((lng - aLng) * cos * dx + (lat - aLat) * dy) / (length * length)));
|
||||
const distance = Math.hypot(aLng * cos + dx * t - lng * cos, aLat + dy * t - lat);
|
||||
if (distance < best.distance) best = { at: travelled + length * t, index: i, distance };
|
||||
}
|
||||
travelled += length;
|
||||
}
|
||||
return { at: best.at, index: best.index };
|
||||
}
|
||||
|
||||
/** The id of the waypoint that starts the leg holding the spot, or null when the route has no such leg. */
|
||||
export function legStartAt(
|
||||
points: ReadonlyArray<{ id: string; lat: number; lng: number }>,
|
||||
line: readonly [number, number][] | null,
|
||||
lat: number,
|
||||
lng: number
|
||||
): string | null {
|
||||
if (!line || line.length < 2 || points.length < 2) return null;
|
||||
const positions: number[] = [];
|
||||
let from = 0;
|
||||
for (const point of points) {
|
||||
const placed = alongLine(line, point.lat, point.lng, from);
|
||||
positions.push(placed.at);
|
||||
from = placed.index;
|
||||
}
|
||||
const spot = alongLine(line, lat, lng, 0).at;
|
||||
for (let i = 0; i < points.length - 1; i += 1) {
|
||||
if (spot >= positions[i] && spot <= positions[i + 1]) return points[i].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type PlannableTourType,
|
||||
type TourMaxHikingDifficulty,
|
||||
} from '@trek/shared';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
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
|
||||
@@ -15,7 +15,7 @@ import type { TourPlannerWaypoint } from './useTourPlanner';
|
||||
* half read.
|
||||
*/
|
||||
export interface DraftPayload {
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
|
||||
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
||||
name: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
@@ -30,7 +30,7 @@ export interface DraftPayload {
|
||||
paddle?: PaddleFieldValues;
|
||||
}
|
||||
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
function optionalString(value: unknown): boolean {
|
||||
return value === undefined || typeof value === 'string';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
normalizePlannerWaypoints,
|
||||
sameWaypointSnapshot,
|
||||
tourWaypointKey,
|
||||
withDrawnLegsRouted,
|
||||
withShaping,
|
||||
withShapingPoint,
|
||||
type TourPlannerWaypoint,
|
||||
} from './tourPlannerWaypoints';
|
||||
|
||||
const A: TourPlannerWaypoint = { id: 'a', lat: 49, lng: 12, role: 'start' };
|
||||
const B: TourPlannerWaypoint = { id: 'b', lat: 49.01, lng: 12.01, role: 'via' };
|
||||
const C: TourPlannerWaypoint = { id: 'c', lat: 49.02, lng: 12.02, role: 'end' };
|
||||
|
||||
describe('tourPlannerWaypoints', () => {
|
||||
it('FE-TOUR-WAYPOINTS-001: a shaping point goes into the leg after the point named, and nowhere past the end', () => {
|
||||
const shaped = withShapingPoint([A, B, C], 'b', { id: 's', lat: 49.015, lng: 12.015 });
|
||||
expect(shaped.map((point) => point.id)).toEqual(['a', 'b', 's', 'c']);
|
||||
expect(shaped[2]).toEqual({ id: 's', lat: 49.015, lng: 12.015, role: 'via', shaping: true });
|
||||
const points = [A, B, C];
|
||||
expect(withShapingPoint(points, 'c', { id: 's', lat: 0, lng: 0 })).toBe(points);
|
||||
expect(withShapingPoint(points, 'missing', { id: 's', lat: 0, lng: 0 })).toBe(points);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-002: a stop becomes a shaping point and back; the ends always stay stops', () => {
|
||||
const shaped = withShaping([A, B, C], 'b', true);
|
||||
expect(shaped[1]?.shaping).toBe(true);
|
||||
expect(withShaping(shaped, 'b', false)[1]).not.toHaveProperty('shaping');
|
||||
expect(withShaping([A, B, C], 'a', true)[0]).toBe(A);
|
||||
expect(withShaping([A, B, C], 'c', true)[2]).toBe(C);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-003: every drawn leg goes back to the router; a portage stays', () => {
|
||||
const free = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49, 12],
|
||||
[49.01, 12.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
const carried = {
|
||||
kind: 'portage' as const,
|
||||
geometry: [
|
||||
[49.01, 12.01],
|
||||
[49.02, 12.02],
|
||||
] as [number, number][],
|
||||
extra_minutes: 5,
|
||||
};
|
||||
const routed = withDrawnLegsRouted([{ ...A, fixedLeg: free }, { ...B, fixedLeg: carried }, C]);
|
||||
expect(routed[0]).not.toHaveProperty('fixedLeg');
|
||||
expect(routed[1]?.fixedLeg).toEqual(carried);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-004: the key changes when a point shapes instead of stops, or a line gains a bend', () => {
|
||||
const key = tourWaypointKey([A, B, C]);
|
||||
expect(tourWaypointKey(withShaping([A, B, C], 'b', true))).not.toBe(key);
|
||||
const two = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49, 12],
|
||||
[49.01, 12.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
const three = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[49, 12],
|
||||
[49.005, 12.0],
|
||||
[49.01, 12.01],
|
||||
] as [number, number][],
|
||||
};
|
||||
expect(tourWaypointKey([{ ...A, fixedLeg: two }, B])).not.toBe(tourWaypointKey([{ ...A, fixedLeg: three }, B]));
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYPOINTS-005: a point the router set is part of the snapshot, and roles follow the order', () => {
|
||||
const auto = { from: { id: 'a', lat: 49, lng: 12 }, to: { id: 'c', lat: 49.02, lng: 12.02 } };
|
||||
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, auto }, C])).toBe(false);
|
||||
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
|
||||
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
|
||||
import type { AutoBase } from './paddleRouting';
|
||||
import { keepJoinedFixedLegs } from './tourFixedLegs';
|
||||
import { alignFreeLegs } from './tourRouteActions';
|
||||
|
||||
/** A control point of a planned tour, as the editor holds it. */
|
||||
export interface TourPlannerWaypoint {
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
role: TourWaypointRole;
|
||||
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
|
||||
fixedLeg?: TourFixedLeg | null;
|
||||
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
|
||||
lock?: PaddleLock | null;
|
||||
/** P2A-03: a shaping point, there only to steer the route; no stop. */
|
||||
shaping?: boolean;
|
||||
/** PAD-11: set where the router found a portage or a lock; gone once the paddler touches the point. */
|
||||
auto?: AutoBase;
|
||||
/** PAD-03: at the last point, the boat is carried on to the next point once it is set; never saved. */
|
||||
carryNext?: boolean;
|
||||
}
|
||||
|
||||
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
|
||||
export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () => string): TourPlannerWaypoint[] {
|
||||
return saved.map((point) => ({
|
||||
id: newId(),
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
...(point.shaping ? { shaping: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's waypoints as they are saved, in their order: what the server
|
||||
* keeps and nothing of the editor's own (ids, automatic marks, a portage
|
||||
* waiting for its next point).
|
||||
*/
|
||||
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
|
||||
return points.map((point, sequence) => ({
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
sequence,
|
||||
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
...(point.shaping ? { shaping: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
|
||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
|
||||
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
|
||||
...point,
|
||||
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
||||
}));
|
||||
}
|
||||
|
||||
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
||||
if (!leg) return '';
|
||||
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}:${leg.geometry.length}`;
|
||||
}
|
||||
|
||||
/** What the route depends on: every point, where it is, the fixed line after it and whether it only shapes. */
|
||||
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||
return points
|
||||
.map(
|
||||
(point) =>
|
||||
`${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}${point.shaping ? ':shape' : ''}`
|
||||
)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock, a point the router set. */
|
||||
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||
const extras = (points: TourPlannerWaypoint[]) =>
|
||||
JSON.stringify(
|
||||
points.map((point) => [
|
||||
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
|
||||
point.lock ?? null,
|
||||
!!point.auto,
|
||||
!!point.carryNext,
|
||||
])
|
||||
);
|
||||
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
|
||||
}
|
||||
|
||||
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
|
||||
export function withShapingPoint(
|
||||
points: TourPlannerWaypoint[],
|
||||
afterId: string,
|
||||
shape: Pick<TourPlannerWaypoint, 'id' | 'lat' | 'lng'>
|
||||
): TourPlannerWaypoint[] {
|
||||
const index = points.findIndex((point) => point.id === afterId);
|
||||
if (index < 0 || index >= points.length - 1) return points;
|
||||
return [...points.slice(0, index + 1), { ...shape, role: 'via', shaping: true }, ...points.slice(index + 1)];
|
||||
}
|
||||
|
||||
/** P2A-03: a stop made a shaping point, or a shaping point a stop. The ends of a tour are always stops. */
|
||||
export function withShaping(points: TourPlannerWaypoint[], id: string, shaping: boolean): TourPlannerWaypoint[] {
|
||||
return points.map((point, index) => {
|
||||
if (point.id !== id || index === 0 || index === points.length - 1) return point;
|
||||
if (shaping) return { ...point, shaping: true };
|
||||
const { shaping: _dropped, ...stop } = point;
|
||||
return stop;
|
||||
});
|
||||
}
|
||||
|
||||
/** PAD-11: every leg drawn by hand handed back to the router. */
|
||||
export function withDrawnLegsRouted(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
|
||||
return points.map((point) => {
|
||||
if (point.fixedLeg?.kind !== 'free') return point;
|
||||
const { fixedLeg: _drawn, ...routed } = point;
|
||||
return routed;
|
||||
});
|
||||
}
|
||||
@@ -108,10 +108,25 @@ describe('free legs (P2A-04)', () => {
|
||||
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
it('draws every leg by hand for a paddle tour', () => {
|
||||
it('routes a paddle tour on the water: a via ferrata goes back to the router, a leg drawn by hand follows its points', () => {
|
||||
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
|
||||
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C));
|
||||
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual([undefined, undefined, undefined]);
|
||||
const drawn = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, C) }, B, C], true);
|
||||
expect(drawn[0].fixedLeg).toEqual(freeLegBetween(A, B));
|
||||
});
|
||||
|
||||
it('keeps the shape of a drawn paddle leg while its ends still meet their points, and redraws it once one moves', () => {
|
||||
const shaped = {
|
||||
kind: 'free' as const,
|
||||
geometry: [
|
||||
[A.lat, A.lng],
|
||||
[A.lat + 0.001, A.lng + 0.002],
|
||||
[B.lat, B.lng],
|
||||
] as [number, number][],
|
||||
};
|
||||
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, B], true)[0].fixedLeg).toEqual(shaped);
|
||||
const moved = { ...B, lat: B.lat + 0.01 };
|
||||
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, moved, C], true)[0].fixedLeg).toEqual(freeLegBetween(A, moved));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +137,31 @@ describe('portages and locks (PAD-03, PAD-07)', () => {
|
||||
const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a');
|
||||
expect(carried[0].fixedLeg).toEqual(portageLegBetween(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', () => {
|
||||
|
||||
@@ -14,6 +14,12 @@ export interface RoutePoint {
|
||||
lng: number;
|
||||
fixedLeg?: TourFixedLeg | 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). */
|
||||
@@ -114,25 +120,46 @@ function extrasOnFirstLeg<T extends RoutePoint>(points: T[]): T[] {
|
||||
return points;
|
||||
}
|
||||
|
||||
/** A line still joins its points when its ends lie this close to them, in degrees (about two metres). */
|
||||
const JOINS_DEG = 2e-5;
|
||||
|
||||
function stillJoins(leg: TourFixedLeg, from: { lat: number; lng: number }, to: { lat: number; lng: number }): boolean {
|
||||
const first = leg.geometry[0];
|
||||
const last = leg.geometry[leg.geometry.length - 1];
|
||||
const near = (end: readonly unknown[] | undefined, point: { lat: number; lng: number }) =>
|
||||
!!end && Math.abs(Number(end[0]) - point.lat) < JOINS_DEG && Math.abs(Number(end[1]) - point.lng) < JOINS_DEG;
|
||||
return near(first, from) && near(last, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every hand-drawn leg, and every portage, redrawn straight to the waypoint
|
||||
* that now follows it, so moving a point drags them along instead of tearing
|
||||
* them off. A leg on the last point leads nowhere and goes, and so does a lock
|
||||
* nobody paddles to on a tour that is no paddle tour. With `allFree` every
|
||||
* leg that is not a portage becomes hand-drawn, the way a paddle tour is planned.
|
||||
* Every hand-drawn leg redrawn straight to the waypoint that now follows it,
|
||||
* so moving a point drags it along instead of tearing it off; a leg on the
|
||||
* last point leads nowhere and goes. On a hike a portage and a lock are a
|
||||
* paddler's and go. On a paddle tour (`paddle`) a via ferrata is a hiker's
|
||||
* and goes back to the router; a portage and a lock stay, a portage is
|
||||
* dragged along like a drawn leg, and a portage or drawn leg whose line has
|
||||
* a shape of its own (a portage way the router followed) keeps it while its
|
||||
* ends still meet their points. A leg without a line stays routed. A portage
|
||||
* asked for at the last point of a paddle tour becomes one once a point follows.
|
||||
*/
|
||||
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] {
|
||||
const aligned = points.map((point, index) => {
|
||||
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle = false): T[] {
|
||||
const aligned = points.map((current, index) => {
|
||||
const next = points[index + 1];
|
||||
const leg = point.fixedLeg;
|
||||
const kept = allFree ? point : dropPaddleOnly(point);
|
||||
const free = allFree || leg?.kind === 'free';
|
||||
if (!free && !(allFree && isPortage(leg))) return kept;
|
||||
const { carryNext, ...own } = current;
|
||||
if (carryNext && paddle) {
|
||||
if (!next) return current;
|
||||
return { ...own, fixedLeg: portageLegBetween(current, next) } as T;
|
||||
}
|
||||
const point = (carryNext ? own : current) as T;
|
||||
const kept = paddle ? dropHikeOnly(point) : dropPaddleOnly(point);
|
||||
const leg = kept.fixedLeg;
|
||||
const follows = leg?.kind === 'free' || (paddle && isPortage(leg));
|
||||
if (!follows || !leg) return kept;
|
||||
if (!next) {
|
||||
if (!kept.fixedLeg) return kept;
|
||||
const { fixedLeg: _dropped, ...rest } = kept;
|
||||
return rest as T;
|
||||
}
|
||||
if (paddle && leg.geometry.length > 2 && stillJoins(leg, point, next)) return kept;
|
||||
return {
|
||||
...kept,
|
||||
fixedLeg: isPortage(leg)
|
||||
@@ -140,7 +167,14 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFre
|
||||
: freeLegBetween(point, next),
|
||||
};
|
||||
});
|
||||
return allFree ? extrasOnFirstLeg(aligned) : aligned;
|
||||
return paddle ? extrasOnFirstLeg(aligned) : aligned;
|
||||
}
|
||||
|
||||
/** A boat climbs no via ferrata: its leg goes back to the router, on the water. */
|
||||
function dropHikeOnly<T extends RoutePoint>(point: T): T {
|
||||
if (point.fixedLeg?.kind !== 'ferrata') return point;
|
||||
const { fixedLeg: _ferrata, ...routed } = point;
|
||||
return routed as T;
|
||||
}
|
||||
|
||||
/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */
|
||||
@@ -151,13 +185,21 @@ function dropPaddleOnly<T extends RoutePoint>(point: T): 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[] {
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next) return [...points];
|
||||
if (!point) return [...points];
|
||||
const copy = [...points];
|
||||
if (!next) {
|
||||
const { carryNext, ...own } = point;
|
||||
copy[index] = (carryNext ? own : { ...point, carryNext: true }) as T;
|
||||
return copy;
|
||||
}
|
||||
copy[index] = {
|
||||
...point,
|
||||
fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next),
|
||||
@@ -189,14 +231,15 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg after this waypoint drawn by hand, or routed again. A via ferrata
|
||||
* keeps its line; the last point has no leg to change.
|
||||
* The leg after this waypoint drawn by hand, or routed again (on a paddle
|
||||
* tour: on the water). A via ferrata and a portage keep their line; the last
|
||||
* point has no leg to change.
|
||||
*/
|
||||
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next || point.fixedLeg?.kind === 'ferrata') return [...points];
|
||||
if (!point || !next || point.fixedLeg?.kind === 'ferrata' || isPortage(point.fixedLeg)) return [...points];
|
||||
const copy = [...points];
|
||||
if (point.fixedLeg?.kind === 'free') {
|
||||
const { fixedLeg: _dropped, ...rest } = point;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-003: how the planner map draws the parts of a tour (PAD-03, PAD-11).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { tourRouteStyle } from './tourRouteStyle';
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
const PAINT = { portage: 'brown', rapids: 'amber', gap: 'red', casing: 'white' };
|
||||
const line: [number, number][] = [
|
||||
[49, 12],
|
||||
[49.01, 12.01],
|
||||
];
|
||||
const part = (extra: Partial<TourRoutePart> = {}): TourRoutePart => ({ coordinates: line, free: false, ...extra });
|
||||
|
||||
describe('tourRouteStyle', () => {
|
||||
it('FE-TOUR-ROUTE-STYLE-001: a routed tour is one plain line, and no parts draw nothing', () => {
|
||||
expect(tourRouteStyle([part(), part()], true, PAINT)).toEqual({ walking: null, colors: null });
|
||||
expect(tourRouteStyle(null, true, PAINT)).toEqual({ walking: null, colors: null });
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUTE-STYLE-002: a paddle tour dots and colours what no router planned and what the paddler should see', () => {
|
||||
const style = tourRouteStyle(
|
||||
[
|
||||
part(),
|
||||
part({ land: true }),
|
||||
part({ mark: 'rapids' }),
|
||||
part({ mark: 'gap' }),
|
||||
part({ mark: 'unrouted' }),
|
||||
part({ free: true }),
|
||||
],
|
||||
true,
|
||||
PAINT
|
||||
);
|
||||
expect(style.walking).toEqual([false, true, false, true, true, true]);
|
||||
expect(style.colors).toEqual([
|
||||
undefined,
|
||||
{ line: 'brown', casing: 'white' },
|
||||
{ line: 'amber', casing: 'white' },
|
||||
{ line: 'red', casing: 'white' },
|
||||
{ line: 'red', casing: 'white' },
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUTE-STYLE-003: a hike dots its hand-drawn stretches and keeps its own colour', () => {
|
||||
const style = tourRouteStyle([part(), part({ free: true })], false, PAINT);
|
||||
expect(style).toEqual({ walking: [false, true], colors: null });
|
||||
});
|
||||
});
|
||||
@@ -1,36 +1,47 @@
|
||||
import type { TourRoutePart } from './tourRouting';
|
||||
|
||||
/**
|
||||
* PAD-03: a portage on the map, in an earth brown over a rim in the map's own
|
||||
* white, dotted the way the map draws a walk: the boat is carried there, not
|
||||
* paddled. The colours come resolved from the paddle tokens (`usePaddlePaint`).
|
||||
* PAD-03, PAD-11: the paddle paints of the planner map, resolved from the
|
||||
* paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and
|
||||
* boat passes in amber, a gap in the data or a leg no route was found for in
|
||||
* red, each over a rim in the map's own white.
|
||||
*/
|
||||
export interface PortagePaint {
|
||||
line: string;
|
||||
export interface RoutePaint {
|
||||
portage: string;
|
||||
rapids: string;
|
||||
gap: string;
|
||||
casing: string;
|
||||
}
|
||||
|
||||
export interface TourRouteStyle {
|
||||
/** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */
|
||||
/** Per line: dotted (a stretch nobody routed: drawn by hand, carried, or a gap) or solid. */
|
||||
walking: boolean[] | null;
|
||||
/** Per line: the portage brown, or the route's own colour where undefined. */
|
||||
/** Per line: a paddle paint, or the route's own colour where undefined. */
|
||||
colors: ({ line: string; casing: string } | undefined)[] | null;
|
||||
}
|
||||
|
||||
function paddleColor(part: TourRoutePart, paint: RoutePaint): { line: string; casing: string } | undefined {
|
||||
if (part.land) return { line: paint.portage, casing: paint.casing };
|
||||
if (part.mark === 'rapids') return { line: paint.rapids, casing: paint.casing };
|
||||
if (part.mark === 'gap' || part.mark === 'unrouted') return { line: paint.gap, casing: paint.casing };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the planner map draws the parts of a tour being edited. A hike draws
|
||||
* what no router planned dotted, so a routed path and a hand-drawn one never
|
||||
* look alike. A paddle tour is drawn by hand throughout, so its water is the
|
||||
* plain line and only its portages stand out, dotted and brown.
|
||||
* How the planner map draws the parts of a tour being edited. What no router
|
||||
* planned is dotted, so a routed path and a hand-drawn one never look alike:
|
||||
* a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs
|
||||
* and stretches drawn by hand. A paddle tour also colours what the paddler
|
||||
* should look at.
|
||||
*/
|
||||
export function tourRouteStyle(
|
||||
parts: readonly TourRoutePart[] | null,
|
||||
isPaddle: boolean,
|
||||
portage: PortagePaint
|
||||
paint: RoutePaint
|
||||
): TourRouteStyle {
|
||||
if (!parts?.length) return { walking: null, colors: null };
|
||||
const walking = parts.map((part) => (isPaddle ? !!part.land : part.free));
|
||||
const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : undefined));
|
||||
const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted');
|
||||
const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined));
|
||||
return {
|
||||
walking: walking.some(Boolean) ? walking : null,
|
||||
colors: colors.some(Boolean) ? colors : null,
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface TourRoutePart {
|
||||
free: boolean
|
||||
/** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
recommendedPaddleSpeedKmh,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleWaterContext,
|
||||
type TourListItem,
|
||||
} from '@trek/shared';
|
||||
@@ -13,6 +14,8 @@ export interface PaddleFieldValues {
|
||||
speedKmh: number | null;
|
||||
/** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */
|
||||
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 = {
|
||||
@@ -20,17 +23,22 @@ export const NO_PADDLE_FIELDS: PaddleFieldValues = {
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
lakeStyle: null,
|
||||
};
|
||||
|
||||
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
|
||||
export function paddleFieldsOf(
|
||||
tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'>
|
||||
tour: Pick<
|
||||
TourListItem,
|
||||
'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh' | 'paddle_lake_style'
|
||||
>
|
||||
): PaddleFieldValues {
|
||||
return {
|
||||
boatType: tour.boat_type ?? null,
|
||||
waterContext: tour.water_context ?? null,
|
||||
speedKmh: tour.paddle_speed_kmh ?? null,
|
||||
portageSpeedKmh: tour.portage_speed_kmh ?? null,
|
||||
lakeStyle: tour.paddle_lake_style ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +59,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
||||
merged.boatType === values.boatType &&
|
||||
merged.waterContext === values.waterContext &&
|
||||
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);
|
||||
},
|
||||
[onEdit, values]
|
||||
@@ -61,6 +70,7 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
||||
return {
|
||||
...values,
|
||||
portageSpeedKmh: values.portageSpeedKmh ?? null,
|
||||
lakeStyle: values.lakeStyle ?? 'direct',
|
||||
recommendedSpeedKmh: recommended,
|
||||
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
|
||||
effectiveSpeedKmh: values.speedKmh ?? recommended,
|
||||
@@ -69,6 +79,8 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
|
||||
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
|
||||
/** PAD-07: the carrying speed, or back to the starting value with null. */
|
||||
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. */
|
||||
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
|
||||
/** Loaded from a tour or a draft, or cleared; not an edit. */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, type RefObject } from 'react';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { withDrawnLegsRouted, withShaping, withShapingPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
|
||||
type Commit = (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
|
||||
|
||||
/**
|
||||
* P2A-03, P2A-05 and PAD-11: the editor's tools for the line between the
|
||||
* stops. A shaping point steers the route without being a stop, a routed leg
|
||||
* can be planned afresh, and a paddle tour's legs drawn by hand go back to the
|
||||
* router in one step. Each change is one step of the history, through the
|
||||
* planner's own commit; nothing here plans a route itself.
|
||||
*/
|
||||
export function useRouteShaping({
|
||||
commit,
|
||||
waypointsRef,
|
||||
mayEdit,
|
||||
isPaddle,
|
||||
retry,
|
||||
forgetLeg,
|
||||
}: {
|
||||
commit: Commit;
|
||||
waypointsRef: RefObject<TourPlannerWaypoint[]>;
|
||||
/** Whether the route may change now: the permission held and no save under way. */
|
||||
mayEdit: () => boolean;
|
||||
isPaddle: () => boolean;
|
||||
/** Plans the route again as it stands. */
|
||||
retry: () => void;
|
||||
/** Drops the answer kept for a paddle leg, so the router is asked again. */
|
||||
forgetLeg: (from: TourPlannerWaypoint, to: TourPlannerWaypoint) => void;
|
||||
}) {
|
||||
/** A shaping point into the leg after `afterId`; its id, or null when there is no such leg. */
|
||||
const insertShapingPoint = useCallback(
|
||||
(afterId: string, lat: number, lng: number) => {
|
||||
if (!mayEdit() || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
const points = waypointsRef.current;
|
||||
const shape = { id: randomId(), lat, lng };
|
||||
if (withShapingPoint(points, afterId, shape) === points) return null;
|
||||
commit((current) => withShapingPoint(current, afterId, shape));
|
||||
return shape.id;
|
||||
},
|
||||
[commit, mayEdit, waypointsRef]
|
||||
);
|
||||
|
||||
const setWaypointShaping = useCallback(
|
||||
(id: string, shaping: boolean) => {
|
||||
if (mayEdit()) commit((current) => withShaping(current, id, shaping));
|
||||
},
|
||||
[commit, mayEdit]
|
||||
);
|
||||
|
||||
const routeDrawnLegsOnWater = useCallback(() => {
|
||||
if (mayEdit() && isPaddle()) commit(withDrawnLegsRouted);
|
||||
}, [commit, isPaddle, mayEdit]);
|
||||
|
||||
/** The routed leg after this waypoint planned afresh, past any answer kept from before. */
|
||||
const recomputeLeg = useCallback(
|
||||
(id: string) => {
|
||||
const points = waypointsRef.current;
|
||||
const index = points.findIndex((point) => point.id === id);
|
||||
const point = points[index];
|
||||
const next = points[index + 1];
|
||||
if (!point || !next || point.fixedLeg) return;
|
||||
if (isPaddle()) forgetLeg(point, next);
|
||||
retry();
|
||||
},
|
||||
[forgetLeg, isPaddle, retry, waypointsRef]
|
||||
);
|
||||
|
||||
return { insertShapingPoint, setWaypointShaping, routeDrawnLegsOnWater, recomputeLeg };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from 'react';
|
||||
import { legStartAt } from './routeLegAt';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
/**
|
||||
* P2A-03: a click on the route being edited sets a shaping point where it
|
||||
* landed, in the leg that runs there, and selects it, ready to be dragged
|
||||
* where the route should go. Undefined while the points cannot be moved, or
|
||||
* while a click on the line picks a spot for something else.
|
||||
*/
|
||||
export function useShapingClick(
|
||||
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'>,
|
||||
{ editable, picking }: { editable: boolean; picking: boolean }
|
||||
): ((lat: number, lng: number) => void) | undefined {
|
||||
const { waypoints, route, insertShapingPoint, setSelectedWaypointId } = planner;
|
||||
const shapeAt = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
const after = legStartAt(waypoints, route, lat, lng);
|
||||
if (!after) return;
|
||||
const id = insertShapingPoint(after, lat, lng);
|
||||
if (id) setSelectedWaypointId(id);
|
||||
},
|
||||
[insertShapingPoint, route, setSelectedWaypointId, waypoints]
|
||||
);
|
||||
return editable && !picking ? shapeAt : undefined;
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
}));
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } =
|
||||
vi.hoisted(() => ({
|
||||
routeWalkingTour: vi.fn(),
|
||||
enrichTourElevations: vi.fn(),
|
||||
createTour: vi.fn(),
|
||||
detailTour: vi.fn(),
|
||||
updateTour: vi.fn(),
|
||||
paddlePlan: vi.fn(),
|
||||
paddleForget: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
|
||||
vi.mock('../useTourPermissions', () => ({
|
||||
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
|
||||
canEdit,
|
||||
@@ -20,6 +24,12 @@ vi.mock('../useTourPermissions', () => ({
|
||||
|
||||
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 = {
|
||||
coordinates: [
|
||||
[48, 11],
|
||||
@@ -64,6 +74,20 @@ function plannerWith(points: Array<[number, number]>) {
|
||||
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]);
|
||||
|
||||
describe('useTourPlanner route tools', () => {
|
||||
@@ -71,6 +95,8 @@ describe('useTourPlanner route tools', () => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
routeWalkingTour.mockReset().mockResolvedValue(routed);
|
||||
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
|
||||
paddleForget.mockReset();
|
||||
enrichTourElevations.mockReset().mockResolvedValue(enriched);
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
@@ -221,7 +247,7 @@ describe('useTourPlanner route tools', () => {
|
||||
});
|
||||
|
||||
describe('paddle tours (PAD-01)', () => {
|
||||
it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => {
|
||||
it('plans every leg on the water once paddle is chosen, and one undo takes the type back', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
@@ -229,9 +255,22 @@ describe('useTourPlanner route tools', () => {
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
expect(result.current.isPaddle).toBe(true);
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
routeWalkingTour.mockClear();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
expect(routeWalkingTour).not.toHaveBeenCalled();
|
||||
expect(paddlePlan).toHaveBeenLastCalledWith(
|
||||
31,
|
||||
expect.any(Array),
|
||||
{ boat: 'kayak', lakeStyle: 'direct' },
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.paddleRoute?.legLengths).toEqual([1200, 1200]);
|
||||
act(() => result.current.addWaypoint(48.03, 11.01));
|
||||
expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free');
|
||||
expect(result.current.waypoints[2].fixedLeg).toBeUndefined();
|
||||
act(() => result.current.undo());
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.tourType).toBe('hike');
|
||||
@@ -240,7 +279,6 @@ describe('useTourPlanner route tools', () => {
|
||||
|
||||
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
|
||||
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
|
||||
routeWalkingTour.mockResolvedValue({ ...routed, parts: [{ ...routed.parts[0], free: true }] });
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
@@ -264,11 +302,14 @@ describe('useTourPlanner route tools', () => {
|
||||
boat_type: 'canoe',
|
||||
water_context: 'lake',
|
||||
paddle_speed_kmh: null,
|
||||
paddle_lake_style: 'direct',
|
||||
max_hiking_difficulty: null,
|
||||
});
|
||||
// A paddle tour carries no heights to look up.
|
||||
expect(enrichTourElevations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('times the water at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
||||
it('times the water by the routed length at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
@@ -278,13 +319,13 @@ describe('useTourPlanner route tools', () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60));
|
||||
// 1.2 km on the water the router found, at 4 km/h.
|
||||
expect(result.current.walkingDurationMinutes).toBe(18);
|
||||
|
||||
act(() => result.current.paddle.setSpeedKmh(6));
|
||||
act(() => result.current.paddle.setBoatType('other'));
|
||||
expect(result.current.paddle.speedKmh).toBe(6);
|
||||
expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60));
|
||||
expect(result.current.walkingDurationMinutes).toBe(12);
|
||||
|
||||
act(() => result.current.paddle.adoptRecommendedSpeed());
|
||||
expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
|
||||
@@ -343,4 +384,137 @@ describe('useTourPlanner route tools', () => {
|
||||
expect(old.result.current.name).toBe('Old');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shaping and water routing (P2A-03, P2A-05, PAD-11)', () => {
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
});
|
||||
|
||||
it('sets a shaping point into a leg, makes it a stop and back, and never past the end', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.02, 11.02],
|
||||
]);
|
||||
const [start, end] = result.current.waypoints;
|
||||
let id: string | null = null;
|
||||
act(() => {
|
||||
id = result.current.insertShapingPoint(must(start).id, 48.01, 11.005);
|
||||
});
|
||||
expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]);
|
||||
expect(result.current.waypoints[1]?.id).toBe(id);
|
||||
act(() => {
|
||||
expect(result.current.insertShapingPoint(must(end).id, 48.03, 11.03)).toBeNull();
|
||||
expect(result.current.insertShapingPoint(must(start).id, Number.NaN, 11)).toBeNull();
|
||||
});
|
||||
act(() => result.current.setWaypointShaping(must(id), false));
|
||||
expect(result.current.waypoints[1]).not.toHaveProperty('shaping');
|
||||
act(() => result.current.setWaypointShaping(must(start).id, true));
|
||||
expect(result.current.waypoints[0]).not.toHaveProperty('shaping');
|
||||
// One undo takes back one change.
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[1]?.shaping).toBe(true);
|
||||
await settle();
|
||||
expect(routeWalkingTour).toHaveBeenLastCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ shaping: true })]),
|
||||
expect.any(AbortSignal),
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it('plans a leg afresh: a paddle leg is forgotten and asked again, a fixed leg is left alone', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await settle();
|
||||
expect(paddlePlan).toHaveBeenCalledTimes(1);
|
||||
const [start, end] = result.current.waypoints;
|
||||
act(() => result.current.recomputeLeg(must(start).id));
|
||||
expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' });
|
||||
await settle();
|
||||
expect(paddlePlan).toHaveBeenCalledTimes(2);
|
||||
act(() => result.current.recomputeLeg(must(end).id));
|
||||
expect(paddleForget).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends every leg drawn by hand back to the water router in one step', async () => {
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
[48.02, 11],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
for (const point of result.current.waypoints.slice(0, 2))
|
||||
act(() => result.current.toggleWaypointFreeLeg(point.id));
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
|
||||
act(() => result.current.routeDrawnLegsOnWater());
|
||||
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.waypoints[0]?.fixedLeg?.kind).toBe('free');
|
||||
});
|
||||
|
||||
it('keeps a tour with an unplanned leg from being saved, and says it per leg', async () => {
|
||||
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
|
||||
legs.map(() => ({ status: 'failed', code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } }))
|
||||
);
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.01, 11.02],
|
||||
]);
|
||||
act(() => result.current.setName('Off the water'));
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
act(() => result.current.paddle.setBoatType('kayak'));
|
||||
act(() => result.current.paddle.setWaterContext('river'));
|
||||
await settle();
|
||||
expect(result.current.status).toBe('routing-failed');
|
||||
expect(result.current.error).toBe('paddle-route');
|
||||
expect(result.current.paddleRoute?.failures).toEqual([
|
||||
{ index: 0, code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } },
|
||||
]);
|
||||
expect(result.current.routeCurrent).toBe(true);
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
|
||||
it('sets the portage and the lock the router found as points of their own, and they go when their leg changes', async () => {
|
||||
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
|
||||
legs.map(({ from, to }) => {
|
||||
const mid = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const at = (t: number): [number, number] => [mid(from.lat, to.lat, t), mid(from.lng, to.lng, t)];
|
||||
const geometry = [at(0), at(0.3), at(0.4), at(1)];
|
||||
const segments = [
|
||||
{ kind: 'water', from: 0, to: 1 },
|
||||
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
|
||||
{ kind: 'water', from: 2, to: 3 },
|
||||
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
|
||||
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
|
||||
})
|
||||
);
|
||||
const { result } = plannerWith([
|
||||
[48, 11],
|
||||
[48.1, 11],
|
||||
]);
|
||||
act(() => result.current.setTourType('kayak'));
|
||||
await settle();
|
||||
await settle();
|
||||
expect(result.current.waypoints).toHaveLength(4);
|
||||
expect(result.current.waypoints[1]).toMatchObject({ fixedLeg: { kind: 'portage' } });
|
||||
expect(result.current.waypoints.filter((point) => point.auto)).toHaveLength(2);
|
||||
// Found by the router, not done by the paddler: no step of the history of its own.
|
||||
act(() => result.current.undo());
|
||||
expect(result.current.tourType).toBe('hike');
|
||||
act(() => result.current.redo());
|
||||
await settle();
|
||||
await settle();
|
||||
const end = result.current.waypoints[result.current.waypoints.length - 1];
|
||||
act(() => {
|
||||
void result.current.setWaypointPosition(end.id, 48.2, 11);
|
||||
});
|
||||
await settle();
|
||||
expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1596,7 +1596,7 @@ describe('useTourPlanner', () => {
|
||||
|
||||
expect(result.current.mode).toEqual({ type: 'new-draft' })
|
||||
expect(JSON.parse(localStorage.getItem('tour-draft-23') || 'null')).toMatchObject({
|
||||
version: 8,
|
||||
version: 9,
|
||||
tourType: 'hike',
|
||||
name: '',
|
||||
waypoints: [],
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourWaypointRole, type TourMaxHikingDifficulty, type PaddleLock } from '@trek/shared'
|
||||
import { MAX_PLANNED_TOUR_DURATION_MINUTES, paddleSpeedKmhSchema, tourWebsiteSchema, type PlannableTourType, type TourCreateResponse, type TourFixedLeg, type TourListItem, type TourMaxHikingDifficulty } from '@trek/shared'
|
||||
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo'
|
||||
import { tourRepo } from '../../../repo/tourRepo'
|
||||
import { randomId } from '../../../utils/randomId'
|
||||
import { analyzeRouteGeometry, type RouteProfileFocus } from '../../../utils/routeGeometry'
|
||||
import { enrichTourElevations, routeWalkingTour, type TourElevationPoint, type TourRoutePart } from './tourRouting'
|
||||
import { keepJoinedFixedLegs } from './tourFixedLegs'
|
||||
import { alignFreeLegs, closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
||||
import { closeLoop, isClosedLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions'
|
||||
import { normalizePlannerWaypoints, plannerWaypointsOf, sameWaypointSnapshot, savedWaypointsOf, tourWaypointKey, type TourPlannerWaypoint } from './tourPlannerWaypoints'
|
||||
import { useRouteShaping } from './useRouteShaping'
|
||||
import { useTourMapView } from './useTourMapView'
|
||||
import { validDraft, type DraftPayload } from './tourPlannerDraft'
|
||||
import { paddleEstimateOf } from './paddlePlanOf'
|
||||
import { adoptChangedPoints, planPaddleStep, type PaddleTourRoute } from './paddleRouting'
|
||||
import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValues } from './usePaddleFields'
|
||||
import { hikingTimeMinutes } from '../hikingTime'
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
|
||||
export interface TourPlannerWaypoint {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
role: TourWaypointRole
|
||||
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
|
||||
fixedLeg?: TourFixedLeg | null
|
||||
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
|
||||
lock?: PaddleLock | null
|
||||
}
|
||||
|
||||
/** What a tour planned from an import starts with. */
|
||||
export interface DerivedTourDraft {
|
||||
name: string
|
||||
@@ -69,29 +61,6 @@ function hasCompleteElevation(points: TourElevationPoint[] | null): points is [n
|
||||
return !!points && points.every(point => point.length === 3 && Number.isFinite(point[2]))
|
||||
}
|
||||
|
||||
/** Roles from the order; free legs follow their points, and a paddle tour draws every leg by hand. */
|
||||
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], allFree = false): TourPlannerWaypoint[] {
|
||||
return keepJoinedFixedLegs(alignFreeLegs(points, allFree)).map((point, index) => ({
|
||||
...point,
|
||||
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
|
||||
}))
|
||||
}
|
||||
|
||||
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
|
||||
return points.map(point => `${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}`).join('|')
|
||||
}
|
||||
|
||||
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
|
||||
if (!leg) return ''
|
||||
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}`
|
||||
}
|
||||
|
||||
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock. */
|
||||
function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
|
||||
const extras = (points: TourPlannerWaypoint[]) => JSON.stringify(points.map(point => [point.fixedLeg?.kind === 'portage' ? point.fixedLeg.extra_minutes ?? null : null, point.lock ?? null]))
|
||||
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b)
|
||||
}
|
||||
|
||||
/** Where a new tour was planned from; an edit leaves it as it was saved. */
|
||||
function derivedFromField(placeId: number | null, derivedFrom: number | null): { derived_from_place_id?: number } {
|
||||
return placeId === null && derivedFrom !== null ? { derived_from_place_id: derivedFrom } : {}
|
||||
@@ -157,6 +126,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const [status, setStatus] = useState<TourPlannerStatus>(restored?.waypoints.length ? 'dirty' : 'empty')
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [routeParts, setRouteParts] = useState<TourRoutePart[] | null>(null)
|
||||
const [paddleRoute, setPaddleRoute] = useState<PaddleTourRoute | null>(null)
|
||||
const [enrichedGeometry, setEnrichedGeometry] = useState<TourElevationPoint[] | null>(null)
|
||||
const [routeForKey, setRouteForKey] = useState<string | null>(null)
|
||||
const [distanceMeters, setDistanceMeters] = useState<number | null>(null)
|
||||
@@ -241,7 +211,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
|
||||
if (!editPermissionRef.current) return
|
||||
const current = waypointsRef.current
|
||||
const next = normalizePlannerWaypoints(typeof nextValue === 'function' ? nextValue(current) : nextValue, tourTypeRef.current === 'kayak')
|
||||
const changed = typeof nextValue === 'function' ? nextValue(current) : nextValue
|
||||
const paddle = tourTypeRef.current === 'kayak'
|
||||
// A point the router set that the paddler moves or edits is theirs from then on.
|
||||
const next = normalizePlannerWaypoints(paddle ? adoptChangedPoints(current, changed) : changed, paddle)
|
||||
if (sameWaypointSnapshot(current, next)) return
|
||||
invalidateDetailRequest()
|
||||
interruptRouting()
|
||||
@@ -256,6 +229,19 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setError(null)
|
||||
}, [invalidateDetailRequest, interruptRouting, snapshot])
|
||||
|
||||
// Detail laid in after the step that asked for it (a portage's way on land), with no step of its own.
|
||||
const amendWaypoints = useCallback((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||
const changed = change(waypointsRef.current)
|
||||
if (changed === waypointsRef.current) return
|
||||
interruptRouting()
|
||||
draftRevisionRef.current += 1
|
||||
waypointsRef.current = normalizePlannerWaypoints(changed, tourTypeRef.current === 'kayak')
|
||||
setWaypoints(waypointsRef.current)
|
||||
setHasUnsavedChanges(true)
|
||||
setStatus('dirty')
|
||||
}, [interruptRouting])
|
||||
|
||||
const setMaxHikingDifficulty = useCallback((value: TourMaxHikingDifficulty) => {
|
||||
if (!editPermissionRef.current) return
|
||||
if (value === difficultyRef.current) return
|
||||
@@ -333,9 +319,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
commitWaypoints(change)
|
||||
}, [commitWaypoints])
|
||||
|
||||
/** P2A-04: the leg after this waypoint drawn by hand, or routed again. */
|
||||
/** P2A-04: the leg after this waypoint drawn by hand, or routed again (a paddle tour's on the water). */
|
||||
const toggleWaypointFreeLeg = useCallback((id: string) => {
|
||||
if (!editPermissionRef.current || saveInFlightRef.current || tourTypeRef.current === 'kayak') return
|
||||
if (!editPermissionRef.current || saveInFlightRef.current) return
|
||||
commitWaypoints(current => toggleFreeLeg(current, id))
|
||||
}, [commitWaypoints])
|
||||
|
||||
@@ -449,6 +435,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
const paddle = usePaddleFields(restored?.paddle ?? NO_PADDLE_FIELDS, markEdited)
|
||||
const loadPaddle = paddle.load
|
||||
const paddleBoat = paddle.boatType
|
||||
const paddleLakeStyle = paddle.lakeStyle
|
||||
|
||||
/**
|
||||
* PAD-01: hike or paddle tour, as one step of the history with the route,
|
||||
@@ -487,8 +475,10 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const paddleIncomplete = isPaddle && (paddle.boatType === null || paddle.waterContext === null)
|
||||
// A paddle tour is timed on the water at its speed; a hike by the signposts' rule once the heights are in.
|
||||
const { effectiveSpeedKmh, portageSpeedKmh } = paddle
|
||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid) : null,
|
||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, waypoints])
|
||||
// The routed length of each leg while the route is the current points' (PAD-11); straight lines before that.
|
||||
const routedLegLengths = paddleRoute && routeForKey === tourWaypointKey(waypoints) ? paddleRoute.legLengths : undefined
|
||||
const paddleEstimate = useMemo(() => isPaddle ? paddleEstimateOf(waypoints, { effectiveSpeedKmh, portageSpeedKmh }, paddleSpeedInvalid, routedLegLengths) : null,
|
||||
[effectiveSpeedKmh, isPaddle, paddleSpeedInvalid, portageSpeedKmh, routedLegLengths, waypoints])
|
||||
const walkingDurationMinutes = paddleEstimate
|
||||
? paddleEstimate.waterMinutes === null ? null : paddleEstimate.estimateMinutes
|
||||
: routeAnalysis?.gain != null && routeAnalysis.loss != null
|
||||
@@ -501,11 +491,11 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
&& (!Number.isInteger(breakAdditionalMinutes) || breakAdditionalMinutes < 0 || breakAdditionalMinutes > MAX_PLANNED_TOUR_DURATION_MINUTES)
|
||||
const plannedTotalMinutes = plannedDurationMinutes ?? automaticPlannedTotalMinutes
|
||||
// What the type adds to a save: a hike its routing limit, a paddle tour its boat, water and speed.
|
||||
const { boatType, waterContext, speedKmh } = paddle
|
||||
const { boatType, waterContext, speedKmh, lakeStyle } = paddle
|
||||
const typeFields = useMemo(() => tourType === 'kayak'
|
||||
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh }
|
||||
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null },
|
||||
[boatType, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
|
||||
? { tour_type: tourType, max_hiking_difficulty: null, boat_type: boatType, water_context: waterContext, paddle_speed_kmh: speedKmh, portage_speed_kmh: portageSpeedKmh, paddle_lake_style: lakeStyle }
|
||||
: { tour_type: tourType, max_hiking_difficulty: maxHikingDifficulty, boat_type: null, water_context: null, paddle_speed_kmh: null, portage_speed_kmh: null, paddle_lake_style: null },
|
||||
[boatType, lakeStyle, maxHikingDifficulty, portageSpeedKmh, speedKmh, tourType, waterContext])
|
||||
// The router's own time stands in for a hike without heights; a paddle tour has only its speed.
|
||||
const savedDurationSeconds = walkingDurationMinutes === null ? (isPaddle ? null : durationSeconds) : walkingDurationMinutes * 60
|
||||
const plannedTotalInvalid = plannedDurationMinutes === null
|
||||
@@ -537,6 +527,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('empty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setPaddleRoute(null)
|
||||
setEnrichedGeometry(null)
|
||||
setRouteForKey(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -676,14 +667,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
interruptRouting()
|
||||
const openedType: PlannableTourType = result.tour.tour_type === 'kayak' ? 'kayak' : 'hike'
|
||||
const nextWaypoints = normalizePlannerWaypoints(result.waypoints.map(point => ({
|
||||
id: randomId(),
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
})), openedType === 'kayak')
|
||||
const nextWaypoints = normalizePlannerWaypoints(plannerWaypointsOf(result.waypoints, randomId), openedType === 'kayak')
|
||||
localStorage.removeItem(storageKey)
|
||||
contextGenerationRef.current += 1
|
||||
draftRevisionRef.current += 1
|
||||
@@ -704,6 +688,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('dirty')
|
||||
setRoute(null)
|
||||
setRouteParts(null)
|
||||
setPaddleRoute(null)
|
||||
setEnrichedGeometry(null)
|
||||
setRouteForKey(null)
|
||||
setDistanceMeters(null)
|
||||
@@ -736,6 +721,15 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setRetryToken(value => value + 1)
|
||||
}, [interruptRouting])
|
||||
|
||||
// P2A-03/P2A-05/PAD-11: shaping points, a leg planned afresh, drawn legs handed back to the router.
|
||||
const shaping = useRouteShaping({
|
||||
commit: commitWaypoints, waypointsRef, retry,
|
||||
mayEdit: useCallback(() => editPermissionRef.current && !saveInFlightRef.current, []),
|
||||
isPaddle: useCallback(() => tourTypeRef.current === 'kayak', []),
|
||||
forgetLeg: useCallback((from: TourPlannerWaypoint, to: TourPlannerWaypoint) =>
|
||||
paddleRouteRepo.forget(tripId, from, to, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }), [paddleBoat, paddleLakeStyle, tripId]),
|
||||
})
|
||||
|
||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints])
|
||||
const readOnlyGpxAnalysis = useMemo(
|
||||
() => analyzeRouteGeometry(readOnlyGpxTour?.routeGeometry),
|
||||
@@ -764,10 +758,40 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const controller = new AbortController()
|
||||
abortRef.current?.abort()
|
||||
abortRef.current = controller
|
||||
const timer = window.setTimeout(async () => {
|
||||
const stale = () => controller.signal.aborted || generation !== generationRef.current
|
||||
// Points the router set, changed by its own planning and not by the paddler: no step of the history.
|
||||
const patch = (next: TourPlannerWaypoint[]) => {
|
||||
waypointsRef.current = next
|
||||
setWaypoints(next)
|
||||
}
|
||||
|
||||
// PAD-11: a paddle tour is planned on the water by the server's router; its heights are no concern.
|
||||
const routePaddle = async () => {
|
||||
setStatus('routing')
|
||||
const step = await planPaddleStep(waypoints, tripId, { boat: paddleBoat ?? 'kayak', lakeStyle: paddleLakeStyle }, controller.signal, randomId)
|
||||
if (stale()) return
|
||||
if ('points' in step) return patch(normalizePlannerWaypoints(step.points, true))
|
||||
const planned = step.route
|
||||
setRoute(planned.coordinates)
|
||||
setRouteParts(planned.parts)
|
||||
setPaddleRoute(planned)
|
||||
setDistanceMeters(planned.distanceMeters)
|
||||
setDurationSeconds(null)
|
||||
setRouteForKey(waypointKey)
|
||||
setEnrichedGeometry(planned.coordinates.map(([lat, lng]): [number, number, number] => [lat, lng, 0]))
|
||||
if (planned.failures.length > 0) {
|
||||
setStatus('routing-failed')
|
||||
setError('paddle-route')
|
||||
return
|
||||
}
|
||||
setStatus('ready')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const routeHike = async () => {
|
||||
setStatus('routing')
|
||||
const routed = await routeWalkingTour(waypoints, controller.signal, maxHikingDifficulty)
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
||||
if (stale()) return
|
||||
if (!routed) {
|
||||
setStatus('routing-failed')
|
||||
setError('routing')
|
||||
@@ -776,6 +800,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
|
||||
setRoute(routed.coordinates)
|
||||
setRouteParts(routed.parts)
|
||||
setPaddleRoute(null)
|
||||
setDistanceMeters(routed.distanceMeters)
|
||||
setDurationSeconds(routed.durationSeconds)
|
||||
setRouteForKey(waypointKey)
|
||||
@@ -783,7 +808,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStatus('enriching-elevation')
|
||||
|
||||
const enriched = await enrichTourElevations(routed.coordinates, controller.signal)
|
||||
if (controller.signal.aborted || generation !== generationRef.current) return
|
||||
if (stale()) return
|
||||
if (!enriched) {
|
||||
setStatus('elevation-failed')
|
||||
setError('elevation')
|
||||
@@ -797,24 +822,33 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
}
|
||||
setStatus('ready')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const run = isPaddle ? routePaddle() : routeHike()
|
||||
run.catch(() => {
|
||||
if (stale()) return
|
||||
setStatus('routing-failed')
|
||||
setError('routing')
|
||||
})
|
||||
}, ROUTE_DEBOUNCE_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [interruptRouting, maxHikingDifficulty, retryToken, waypointKey, waypoints])
|
||||
}, [interruptRouting, isPaddle, maxHikingDifficulty, paddleBoat, paddleLakeStyle, retryToken, tripId, waypointKey, waypoints])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
const draft: DraftPayload = { version: 8, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
const draft: DraftPayload = { version: 9, name, description, website, plannedDurationMinutes, breakAdditionalMinutes, waypoints, maxHikingDifficulty, editingPlaceId,
|
||||
derivedFromPlaceId, tourType,
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh },
|
||||
paddle: { boatType: paddle.boatType, waterContext: paddle.waterContext, speedKmh: paddle.speedKmh, portageSpeedKmh: paddle.portageSpeedKmh, lakeStyle: paddle.lakeStyle },
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(draft))
|
||||
}, [breakAdditionalMinutes,
|
||||
derivedFromPlaceId, description, editingPlaceId, hasUnsavedChanges, maxHikingDifficulty, name, plannedDurationMinutes, storageKey, waypoints, website,
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh])
|
||||
tourType, paddle.boatType, paddle.waterContext, paddle.speedKmh, paddle.portageSpeedKmh, paddle.lakeStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
@@ -844,14 +878,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
website: parsedWebsiteOrNull(website),
|
||||
...typeFields,
|
||||
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
|
||||
waypoints: waypoints.map((point, sequence) => ({
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
role: point.role,
|
||||
sequence,
|
||||
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
|
||||
...(point.lock ? { lock: point.lock } : {}),
|
||||
})),
|
||||
waypoints: savedWaypointsOf(waypoints),
|
||||
duration_seconds: savedDurationSeconds,
|
||||
planned_duration_minutes: plannedDurationMinutes,
|
||||
break_additional_minutes: breakAdditionalMinutes,
|
||||
@@ -909,7 +936,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
walkingDurationMinutes, plannedTotalMinutes, plannedTotalInvalid,
|
||||
waypoints, selectedWaypointId, setSelectedWaypointId,
|
||||
addWaypoint, appendFixedLeg, setWaypointPosition, removeWaypoint, moveWaypoint, reorderWaypoint,
|
||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, paddleEstimate,
|
||||
reverse, returnToStart, isLoop: isClosedLoop(waypoints), toggleWaypointFreeLeg, editPaddlePoints, amendWaypoints, paddleEstimate,
|
||||
...shaping, paddleRoute, routeCurrent: routeForKey === waypointKey,
|
||||
undo, redo, canUndo: history.past.length > 0, canRedo: history.future.length > 0,
|
||||
status, error, retry, isSaving,
|
||||
route, routeParts, enrichedGeometry, routeAnalysis, distanceMeters, durationSeconds,
|
||||
|
||||
@@ -360,6 +360,20 @@ describe('TourTerrainMap', () => {
|
||||
expect(screen.queryByText('18 to 27 %')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-TERRAIN-012b: a paddle tour keeps its tour colour, without the grade switch or the grade mix', async () => {
|
||||
await renderTerrain({ samples: TOUR, water: true });
|
||||
loadStyle();
|
||||
await act(async () => openTiles());
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Grade' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Grade mix' })).toBeNull();
|
||||
const setData = gl.sources.get('tour-terrain-track')?.setData as ReturnType<typeof vi.fn> | undefined;
|
||||
const drawCalls = setData?.mock.calls ?? [];
|
||||
const drawn = drawCalls[drawCalls.length - 1]?.[0] as { features: { properties: { color: string } }[] } | undefined;
|
||||
expect(drawn?.features.length).toBeGreaterThan(0);
|
||||
expect(drawn?.features.every((feature) => feature.properties.color === '#0a84ff')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-TERRAIN-013: swaps the style in place for another base layer and draws on it again', async () => {
|
||||
const { rerender } = await renderTerrain({ samples: TOUR });
|
||||
loadStyle();
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TourTerrainMapProps {
|
||||
rightWidth: number;
|
||||
waypoints: TerrainWaypoints;
|
||||
framePoints: [number, number][];
|
||||
/** A paddle tour: no grade colours, no grade legend and no switch for them, the water is flat. */
|
||||
water?: boolean;
|
||||
/** The planner map's via ferrata layer: its switch joins the layer pill, its lines the terrain. */
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/** What the tour's type offers: its base layers, and the overlays drawn on the terrain too. */
|
||||
@@ -78,7 +80,10 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
const { setContainer, ...terrain } = useTourTerrain({ ...props, panels: { leftWidth, rightWidth } });
|
||||
const stats = useMemo(() => terrainStats(samples), [samples]);
|
||||
const { fit, height: controlsHeight, attachRow, attachStack } = useControlRowFit();
|
||||
const legend = terrain.canReplay ? <GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} /> : null;
|
||||
const legend =
|
||||
terrain.canReplay && !props.water ? (
|
||||
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
|
||||
) : null;
|
||||
const legendAbove = fit === 'stacked';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,7 +125,7 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
|
||||
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
|
||||
<span />
|
||||
{terrain.canReplay ? (
|
||||
<TerrainDock terrain={terrain} />
|
||||
<TerrainDock terrain={terrain} water={props.water} />
|
||||
) : (
|
||||
<div
|
||||
className="pointer-events-auto rounded-full px-4 py-2.5 text-content-muted"
|
||||
@@ -173,8 +178,14 @@ function Divider() {
|
||||
return <span aria-hidden className="mx-0.5 h-5 w-px flex-none bg-edge" />;
|
||||
}
|
||||
|
||||
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour. */
|
||||
function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
|
||||
/** One pill like the layer switcher: play, restart, overview · speed · relief · colour (not on water). */
|
||||
function TerrainDock({
|
||||
terrain,
|
||||
water,
|
||||
}: {
|
||||
terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'>;
|
||||
water?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full p-1" style={GLASS}>
|
||||
@@ -231,6 +242,16 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
|
||||
<span className="w-7 font-semibold tabular-nums">{terrain.exaggeration.toFixed(1)}×</span>
|
||||
</label>
|
||||
</Tooltip>
|
||||
{!water && <GradeSwitch terrain={terrain} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Colour the route by grade or in the tour colour. */
|
||||
function GradeSwitch({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerrain>, 'setContainer'> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Divider />
|
||||
<button
|
||||
type="button"
|
||||
@@ -242,7 +263,7 @@ function TerrainDock({ terrain }: { terrain: Omit<ReturnType<typeof useTourTerra
|
||||
<Palette size={15} strokeWidth={2} />
|
||||
{t('tours.terrain.colorByGrade')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,12 +147,12 @@ describe('camera and markers', () => {
|
||||
});
|
||||
|
||||
it('numbers a control point and shows whether it can be dragged', () => {
|
||||
const pin = waypointPinElement(2, true, true);
|
||||
const pin = waypointPinElement({}, 2, true, true);
|
||||
expect(pin.textContent).toBe('3');
|
||||
expect(pin.getAttribute('aria-label')).toBe('Waypoint 3');
|
||||
expect(pin.style.width).toBe('30px');
|
||||
expect(pin.style.cursor).toBe('grab');
|
||||
const fixed = waypointPinElement(0, false, false);
|
||||
const fixed = waypointPinElement({}, 0, false, false);
|
||||
expect(fixed.style.width).toBe('26px');
|
||||
expect(fixed.style.cursor).toBe('pointer');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OPENTOPOMAP_TILE_URL, SATELLITE_TILE_URL } from '../../../constants/map
|
||||
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry';
|
||||
import { OPENFREEMAP_DEFAULT_STYLE } from '../../Map/glProviders';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
import { plannerPinShape, type PlannerPinPoint } from '../../Map/plannerPinShape';
|
||||
import type { TileTemplate } from './terrainPrefetch';
|
||||
import { gradeBand, gradeSegments } from './tourTerrainModel';
|
||||
|
||||
@@ -198,11 +199,16 @@ export function riderElement(trackColor: string): HTMLElement {
|
||||
}
|
||||
|
||||
/** A numbered control point, the same pin the flat planner map draws. */
|
||||
export function waypointPinElement(index: number, selected: boolean, editable: boolean): HTMLButtonElement {
|
||||
const size = selected ? 30 : 26;
|
||||
export function waypointPinElement(
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean,
|
||||
editable: boolean
|
||||
): HTMLButtonElement {
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.textContent = text;
|
||||
el.setAttribute('aria-label', `Waypoint ${index + 1}`);
|
||||
el.style.cssText = `display:flex;width:${size}px;height:${size}px;align-items:center;justify-content:center;border-radius:9999px;background:${PIN_BLUE};color:white;border:${selected ? 3 : 2}px solid white;box-shadow:0 2px 7px rgba(0,0,0,.4);font:700 11px system-ui;cursor:${editable ? 'grab' : 'pointer'};padding:0`;
|
||||
return el;
|
||||
|
||||
@@ -6,7 +6,15 @@ import type { TerrainGl } from './useTerrainMap';
|
||||
|
||||
export interface TerrainWaypoints {
|
||||
/** `name` and `detail` (its role) show on hover. */
|
||||
points: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
points: Array<{
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
/** Absent while the tour cannot be edited; the pins then stay put. */
|
||||
@@ -38,7 +46,7 @@ export function useTerrainPins(
|
||||
gl.map.on('movestart', hideOnMove);
|
||||
const unbind: (() => void)[] = [() => gl.map.off('movestart', hideOnMove)];
|
||||
pinsRef.current = points.map((point, index) => {
|
||||
const el = waypointPinElement(index, point.id === selectedId, editable);
|
||||
const el = waypointPinElement(point, index, point.id === selectedId, editable);
|
||||
const name = point.name ?? String(index + 1);
|
||||
el.setAttribute('aria-label', name);
|
||||
unbind.push(bindPinHoverLabel(el, hoverLabel, gl.map.getContainer(), { name, detail: point.detail ?? null }));
|
||||
|
||||
@@ -30,13 +30,17 @@ interface Options {
|
||||
tourLayers?: TourLayerOffer;
|
||||
/** The visible area, for the via ferrata layer to ask for. */
|
||||
onViewportChange?: (bbox: FerrataBbox) => void;
|
||||
/** A paddle tour: the route is never coloured by grade. */
|
||||
water?: boolean;
|
||||
}
|
||||
|
||||
/** The planner map's 3D mode: map, pins, corridor preload and the flyover sharing the rail's focus. */
|
||||
export function useTourTerrain(options: Options) {
|
||||
const { samples, routes, trackColor, baseLayer, focus, onFocusChange, panels, waypoints, framePoints } = options;
|
||||
const [exaggeration, setExaggeration] = useState(1.5);
|
||||
const [colorMode, setColorMode] = useState<TrackColorMode>('grade');
|
||||
const [chosenColorMode, setColorMode] = useState<TrackColorMode>('grade');
|
||||
// Water is flat: a paddle tour keeps its tour colour, whatever was picked for a hike.
|
||||
const colorMode: TrackColorMode = options.water ? 'tour' : chosenColorMode;
|
||||
const data = useMemo(
|
||||
() => trackData(samples, routes, colorMode, trackColor),
|
||||
[samples, routes, colorMode, trackColor]
|
||||
|
||||
@@ -5,7 +5,7 @@ import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { JourneyDetail } from '../../../store/journeyStore'
|
||||
import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
|
||||
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
|
||||
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
|
||||
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
@@ -234,25 +234,7 @@ export default function MJourneySettingsSheet({
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
|
||||
{/* Through a trip's linked journal (#2683): shown, but changed in that trip. */}
|
||||
{!!journey.trip_access?.length && (
|
||||
<>
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
|
||||
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
|
||||
{journey.trip_access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||
<Link2 size={15} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-caption font-bold">{a.username}</span>
|
||||
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
|
||||
{a.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
|
||||
|
||||
{/* Public share */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
||||
@@ -350,3 +332,27 @@ export default function MJourneySettingsSheet({
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
|
||||
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<>
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
|
||||
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||
<Link2 size={15} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-caption font-bold">{a.username}</span>
|
||||
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
|
||||
{a.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export const ADMIN_EVENT_LABEL_KEYS: Record<string, string> = {
|
||||
version_available: 'settings.notifyVersionAvailable',
|
||||
replica_failure: 'settings.notifyReplicaFailure',
|
||||
paddle_region_requested: 'settings.notifyPaddleRegionRequested',
|
||||
}
|
||||
|
||||
export const ADMIN_CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-016
|
||||
// FE-PAGE-TOURSTAGE-001 to FE-PAGE-TOURSTAGE-018
|
||||
import type { Ferrata, PaddleFeature } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
@@ -486,6 +486,54 @@ describe('TourPlannerMapStage', () => {
|
||||
expect(screen.getByTestId('tour-map-surface')).not.toHaveAttribute('data-placing');
|
||||
});
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-017: a click on the route being edited sets a shaping point in the leg there and selects it (P2A-03)', () => {
|
||||
const tourPlanner = planner('new-draft', {
|
||||
waypoints: [
|
||||
{ id: 'w1', lat: 46, lng: 7 },
|
||||
{ id: 'w2', lat: 46.1, lng: 7.1, shaping: true },
|
||||
{ id: 'w3', lat: 46.2, lng: 7.2 },
|
||||
],
|
||||
route: [
|
||||
[46, 7],
|
||||
[46.1, 7.1],
|
||||
[46.2, 7.2],
|
||||
],
|
||||
insertShapingPoint: vi.fn(() => 'shape-1'),
|
||||
});
|
||||
renderStage(tourPlanner);
|
||||
const props = stage.flatMap.props;
|
||||
expect(props?.plannerWaypoints?.map((point) => [point.label ?? null, !!point.shaping])).toEqual([
|
||||
['1', false],
|
||||
[null, true],
|
||||
['2', false],
|
||||
]);
|
||||
props?.onRouteClick?.(46.15, 7.15);
|
||||
expect(tourPlanner.insertShapingPoint).toHaveBeenCalledWith('w2', 46.15, 7.15);
|
||||
expect(tourPlanner.setSelectedWaypointId).toHaveBeenCalledWith('shape-1');
|
||||
});
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-018: the route takes no shaping clicks outside editing or without edit rights', () => {
|
||||
renderStage(
|
||||
planner('view-saved', {
|
||||
route: [
|
||||
[46, 7],
|
||||
[46.1, 7.1],
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(stage.flatMap.props?.onRouteClick).toBeUndefined();
|
||||
renderStage(
|
||||
planner('new-draft', {
|
||||
route: [
|
||||
[46, 7],
|
||||
[46.1, 7.1],
|
||||
],
|
||||
}),
|
||||
false
|
||||
);
|
||||
expect(stage.flatMap.props?.onRouteClick).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('map layers per tour type (P2B-06/07, PAD-04, PAD-10)', () => {
|
||||
const flat = () => stage.flatMap.props as MapViewProps;
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
|
||||
import { useShapingClick } from '../../components/Tours/planner/useShapingClick';
|
||||
import type { TerrainLegendLayout } from '../../components/Tours/terrain/TourTerrainMap';
|
||||
import { isRtlLanguage, useTranslation } from '../../i18n';
|
||||
import { usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
@@ -137,6 +139,7 @@ export function TourPlannerMapStage({
|
||||
const closingPress = useOutsidePressGuard();
|
||||
const paddleOpen = usePaddleObstacleStore((state) => state.selectedRef !== null);
|
||||
const adoptFerrata = useFerrataAdopt(tourPlanner, canPlaceEdit, ferrata.close);
|
||||
const placeFromCard = paddlePlaceFromCard(tourPlanner, canPlaceEdit);
|
||||
const ferrataOnTour = useMemo(() => new Set(ferrataCrossings.map(({ group }) => group.id)), [ferrataCrossings]);
|
||||
const distanceUnit = useSettingsStore((s) => s.settings.distance_unit);
|
||||
// The "set points" tool, per mode; only a real click places, never the end of a drag.
|
||||
@@ -150,9 +153,13 @@ export function TourPlannerMapStage({
|
||||
});
|
||||
// PAD-03 on a recorded paddle track: with a marking tool on, a click on the line marks the spot on it.
|
||||
const { pickOnLine, onAddOrMark } = useTrackLinePick(tourPlanner, canPlaceEdit, closingPress.guard, waypoints.onAdd);
|
||||
// P2A-03: a click on the route being edited sets a shaping point there.
|
||||
const shapeAt = useShapingClick(tourPlanner, { editable: !!waypoints.onMove, picking: pickOnLine });
|
||||
// What each panel covers of the map, a collapsed one its rail.
|
||||
const leftInset = leftHidden ? 56 : leftPanelPx;
|
||||
const rightInset = rightHidden ? 56 : rightPanelPx;
|
||||
// The cards over the map keep clear of the layer switcher, on the side the text ends.
|
||||
const cardInsetEnd = (isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET;
|
||||
const bottomRowTop = terrain3d ? CONTROL_GAP + terrainControlsPx + CONTROL_GAP : FERRATA_CARD_BOTTOM;
|
||||
return (
|
||||
<>
|
||||
@@ -180,6 +187,7 @@ export function TourPlannerMapStage({
|
||||
rightWidth={rightInset}
|
||||
waypoints={waypoints}
|
||||
framePoints={tourMap.focusPoints}
|
||||
water={tourPlanner.isPaddle}
|
||||
ferrataLayer={ferrataLayer}
|
||||
onViewportChange={viewportChanged}
|
||||
onControlsHeightChange={setTerrainControlsPx}
|
||||
@@ -209,6 +217,7 @@ export function TourPlannerMapStage({
|
||||
selectedPlannerWaypointId={waypoints.selectedId}
|
||||
onPlannerWaypointClick={tourPlanner.setSelectedWaypointId}
|
||||
onPlannerWaypointMove={waypoints.onMove}
|
||||
onRouteClick={shapeAt}
|
||||
routeProfileFocus={tourPlanner.routeProfileFocus}
|
||||
viewBaseLayer={baseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
@@ -226,20 +235,27 @@ export function TourPlannerMapStage({
|
||||
selection={ferrataSelected}
|
||||
onClose={ferrata.close}
|
||||
onOutsidePress={closingPress.arm}
|
||||
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
|
||||
insetEnd={cardInsetEnd}
|
||||
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
|
||||
onAdopt={adoptFerrata}
|
||||
onTourIds={ferrataOnTour}
|
||||
/>
|
||||
<PaddleObstacleOverlay
|
||||
onOutsidePress={closingPress.arm}
|
||||
insetEnd={(isRtlLanguage(language) ? leftInset : rightInset) + MAP_LAYER_SWITCHER_INSET}
|
||||
insetEnd={cardInsetEnd}
|
||||
{...ferrataCardPlacement(terrain3d, bottomRowTop, terrainLegend)}
|
||||
extra={(detail) =>
|
||||
paddleChecklist ? (
|
||||
<ChecklistObstaclePanel checklist={paddleChecklist} featureRef={detail.feature.ref} />
|
||||
) : null
|
||||
}
|
||||
extra={(detail) => (
|
||||
<>
|
||||
{paddleChecklist && <ChecklistObstaclePanel checklist={paddleChecklist} featureRef={detail.feature.ref} />}
|
||||
{placeFromCard && (
|
||||
<PaddleObstaclePlaceButton
|
||||
kind={detail.feature.kind}
|
||||
geometry={detail.feature.geometry}
|
||||
onPlace={placeFromCard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
{/* "Point set", with the way back, above the bottom row and between the panels. */}
|
||||
{placedHint.shown && (
|
||||
@@ -298,3 +314,20 @@ export function TourPlannerMapStage({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05B: a put-in, slipway or egress taken over from its card as the tour's
|
||||
* next point; undefined where the tour cannot take one now (not a paddle tour,
|
||||
* saving, a GPX shown read only, or no right to edit).
|
||||
*/
|
||||
function paddlePlaceFromCard(
|
||||
tourPlanner: PageState['tourPlanner'],
|
||||
canPlaceEdit: boolean
|
||||
): ((lat: number, lng: number) => void) | undefined {
|
||||
if (!canPlaceEdit || !tourPlanner.isPaddle || tourPlanner.isSaving || tourPlanner.mode.type === 'view-gpx')
|
||||
return undefined;
|
||||
return (lat, lng) => {
|
||||
tourPlanner.addWaypoint(lat, lng);
|
||||
selectPaddleObstacle(null);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,13 +76,24 @@ export function useTourMapPoints({ tourPlanner, canPlaceEdit, mode, cardOpen, gu
|
||||
const shown = isClosedLoop(waypoints) ? waypoints.slice(0, -1) : waypoints;
|
||||
// A paddle tour names a pin for the paddler (put-in, take-out, re-entry) and says where a lock is.
|
||||
const roles = isPaddle ? paddleRolesById(waypoints) : null;
|
||||
return shown.map((point, index) => {
|
||||
// Stops are numbered on their own: a shaping point (P2A-03) only steers and takes no number.
|
||||
let stop = 0;
|
||||
return shown.map((point) => {
|
||||
if (point.shaping) {
|
||||
return {
|
||||
...point,
|
||||
name: placeNames[point.id] ?? t('tours.planner.shapingPoint'),
|
||||
detail: t('tours.planner.shapingPoint'),
|
||||
};
|
||||
}
|
||||
stop += 1;
|
||||
const role = roles?.get(point.id);
|
||||
const detail = role ? t(`tours.paddle.role.${role}`) : roles ? '' : t(ROLE_KEY[point.role]);
|
||||
const lock = point.lock ? t('tours.paddle.lock') : '';
|
||||
return {
|
||||
...point,
|
||||
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: index + 1 }),
|
||||
label: String(stop),
|
||||
name: placeNames[point.id] ?? t('tours.planner.waypointLabel', { n: stop }),
|
||||
detail: [detail, lock].filter(Boolean).join(', ') || t(ROLE_KEY[point.role]),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,12 +67,12 @@ export function useTripPlannerPage() {
|
||||
tourConversion.line, trackParts,
|
||||
])
|
||||
const editingRoute = tourPlanner.mode.type === 'new-draft' || tourPlanner.mode.type === 'edit-saved'
|
||||
// A hand-drawn stretch of a hike is dotted, a paddle tour's portages dotted and brown.
|
||||
// What no router planned is dotted; a paddle tour's portages brown, whitewater amber, gaps red.
|
||||
const paddlePaint = usePaddlePaint()
|
||||
const partStyle = tourRouteStyle(
|
||||
!wandererBrowser.open && editingRoute ? tourPlanner.routeParts : !tourConversion.line ? trackParts : null,
|
||||
tourPlanner.isPaddle || !!trackParts,
|
||||
{ line: paddlePaint.portage, casing: paddlePaint.disc },
|
||||
{ portage: paddlePaint.portage, rapids: paddlePaint.rapids, gap: paddlePaint.gap, casing: paddlePaint.disc },
|
||||
)
|
||||
const routeWalking = partStyle.walking ?? (route && route.length === 2 && tourConversion.line ? [true, false] : null)
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// FE-REPO-PADDLE-REGION-LOAD-001 to 003: the water data of places asked for online only (PAD-11C).
|
||||
import { paddleRegionLoadRequestSchema, paddleRegionLoadResponseSchema } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { contractHandler } from '../../tests/helpers/msw/contract';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { setForcedOffline } from '../sync/networkMode';
|
||||
import { paddleRegionLoadRepo } from './paddleRegionLoadRepo';
|
||||
|
||||
const LOAD = '/api/paddle/regions/load';
|
||||
const PLACE = { lat: 53.2, lng: 12.9 };
|
||||
|
||||
describe('paddleRegionLoadRepo', () => {
|
||||
afterEach(() => setForcedOffline(false));
|
||||
|
||||
it('FE-REPO-PADDLE-REGION-LOAD-001: asks the server for the places and answers its states in order', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
contractHandler(
|
||||
'post',
|
||||
LOAD,
|
||||
{ request: paddleRegionLoadRequestSchema, response: paddleRegionLoadResponseSchema },
|
||||
async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return { points: [{ state: 'ready' as const, region: { id: 'mv', name: 'Mecklenburg' } }] };
|
||||
}
|
||||
)
|
||||
);
|
||||
await expect(paddleRegionLoadRepo.load([PLACE], true)).resolves.toEqual([
|
||||
{ state: 'ready', region: { id: 'mv', name: 'Mecklenburg' } },
|
||||
]);
|
||||
expect(bodies).toEqual([{ points: [PLACE], retry: true }]);
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLE-REGION-LOAD-002: asks nothing offline or about no place', async () => {
|
||||
setForcedOffline(true);
|
||||
await expect(paddleRegionLoadRepo.load([PLACE], false)).resolves.toBeNull();
|
||||
setForcedOffline(false);
|
||||
await expect(paddleRegionLoadRepo.load([], false)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLE-REGION-LOAD-003: refuses an answer that is not the contract', async () => {
|
||||
server.use(http.post(LOAD, () => HttpResponse.json({ points: [{ state: 'maybe' }] })));
|
||||
await expect(paddleRegionLoadRepo.load([PLACE], false)).rejects.toMatchObject({ name: 'ZodError' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaddleRegionLoadState, PaddleRoutePoint } from '@trek/shared';
|
||||
import { paddleRegionLoadApi } from '../api/paddleRegionLoad';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/**
|
||||
* PAD-11C: the water data around places no paddle region covers, online
|
||||
* only on purpose: the server loads it, and a load asked for offline would
|
||||
* only start long after the paddler stopped waiting for it. Offline it
|
||||
* answers null, and the planner keeps saying why the leg is unplanned.
|
||||
*/
|
||||
export const paddleRegionLoadRepo = {
|
||||
async load(
|
||||
points: readonly PaddleRoutePoint[],
|
||||
retry: boolean,
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleRegionLoadState[] | null> {
|
||||
if (points.length === 0 || isEffectivelyOffline()) return null;
|
||||
return await paddleRegionLoadApi.load({ points: [...points], retry }, signal);
|
||||
},
|
||||
};
|
||||
@@ -32,15 +32,18 @@ const REGION: PaddleRegion = {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-09-30T20:21:02Z',
|
||||
imported_at: '2026-10-01T03:00:00.000Z',
|
||||
feature_count: 412,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
};
|
||||
const QUEUED: PaddleRegion = {
|
||||
...REGION,
|
||||
status: 'queued',
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
@@ -51,7 +54,7 @@ const ENTRY: PaddleRegionCatalogEntry = { id: 'oberpfalz', name: 'Oberpfalz', pa
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
vi.mocked(paddleRegionsApi.list).mockResolvedValue([REGION]);
|
||||
vi.mocked(paddleRegionsApi.list).mockResolvedValue({ regions: [REGION], requests: [] });
|
||||
vi.mocked(paddleRegionsApi.catalog).mockResolvedValue([ENTRY]);
|
||||
vi.mocked(paddleRegionsApi.add).mockResolvedValue(QUEUED);
|
||||
vi.mocked(paddleRegionsApi.refresh).mockResolvedValue(READING);
|
||||
@@ -61,7 +64,7 @@ beforeEach(() => {
|
||||
describe('paddleRegionRepo online', () => {
|
||||
it('FE-REPO-PADDLEREGION-001: the list and the catalogue search go to the api and answer what it answers', async () => {
|
||||
const signal = new AbortController().signal;
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]);
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual({ regions: [REGION], requests: [] });
|
||||
await expect(paddleRegionRepo.catalog('ober', signal)).resolves.toEqual([ENTRY]);
|
||||
expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1);
|
||||
expect(paddleRegionsApi.catalog).toHaveBeenCalledWith('ober', signal);
|
||||
@@ -113,7 +116,7 @@ describe('paddleRegionRepo offline', () => {
|
||||
it('FE-REPO-PADDLEREGION-006: the connection is read at each call, so the next call once it is back goes through', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValueOnce(true);
|
||||
await expect(paddleRegionRepo.list()).rejects.toBeInstanceOf(PaddleRegionsOfflineError);
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual([REGION]);
|
||||
await expect(paddleRegionRepo.list()).resolves.toEqual({ regions: [REGION], requests: [] });
|
||||
expect(paddleRegionsApi.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -159,6 +162,7 @@ describe('paddleRegionsApi', () => {
|
||||
server.use(
|
||||
contractHandler('get', LIST, { response: paddleRegionListResponseSchema }, () => ({
|
||||
regions: [REGION, READING],
|
||||
requests: [],
|
||||
})),
|
||||
contractHandler('get', CATALOG, { response: paddleRegionCatalogResponseSchema }, ({ request }) => {
|
||||
searches.push(new URL(request.url).searchParams.get('q'));
|
||||
@@ -166,7 +170,7 @@ describe('paddleRegionsApi', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await expect(api.list()).resolves.toEqual([REGION, READING]);
|
||||
await expect(api.list()).resolves.toEqual({ regions: [REGION, READING], requests: [] });
|
||||
await expect(api.catalog('ober')).resolves.toEqual([ENTRY]);
|
||||
expect(searches).toEqual(['ober']);
|
||||
});
|
||||
|
||||
@@ -31,4 +31,7 @@ export const paddleRegionRepo = {
|
||||
add: online(paddleRegionsApi.add),
|
||||
refresh: online(paddleRegionsApi.refresh),
|
||||
remove: online(paddleRegionsApi.remove),
|
||||
decline: online(paddleRegionsApi.decline),
|
||||
settings: online(paddleRegionsApi.settings),
|
||||
saveSettings: online(paddleRegionsApi.saveSettings),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// FE-REPO-PADDLEROUTE-001 to FE-REPO-PADDLEROUTE-006: paddle legs planned on the water, online only, remembered per leg.
|
||||
// FE-API-PADDLEROUTE-001 to FE-API-PADDLEROUTE-002: the call behind them, held to the @trek/shared contract.
|
||||
import {
|
||||
MAX_PADDLE_ROUTE_LEGS,
|
||||
paddleRouteRequestSchema,
|
||||
paddleRouteResponseSchema,
|
||||
type PaddleRouteLeg,
|
||||
type PaddleRoutePoint,
|
||||
} from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { contractHandler } from '../../tests/helpers/msw/contract';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { paddleRouteApi } from '../api/paddleRoute';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { paddleRouteRepo, type PaddleRouteProfile } from './paddleRouteRepo';
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
|
||||
vi.mock('../api/paddleRoute', () => ({ paddleRouteApi: { plan: vi.fn() } }));
|
||||
|
||||
const KAYAK: PaddleRouteProfile = { boat: 'kayak', lakeStyle: 'direct' };
|
||||
const A = { lat: 49.1283, lng: 12.1303 };
|
||||
const B = { lat: 49.0922, lng: 12.1108 };
|
||||
const C = { lat: 49.022, lng: 12.111 };
|
||||
|
||||
/** The router's answer for a leg: straight on the water, its length the leg's index in metres. */
|
||||
function okLeg(from: PaddleRoutePoint, to: PaddleRoutePoint, distance = 1000): PaddleRouteLeg {
|
||||
return {
|
||||
status: 'ok',
|
||||
geometry: [
|
||||
[from.lat, from.lng],
|
||||
[to.lat, to.lng],
|
||||
],
|
||||
segments: [{ kind: 'water', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
|
||||
distance_m: distance,
|
||||
snapped_m: { from: 0, to: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/** Answers every leg it is asked for on the water. */
|
||||
function answerAll() {
|
||||
vi.mocked(paddleRouteApi.plan).mockImplementation(async (_trip, request) => ({
|
||||
legs: request.legs.map(({ from, to }) => okLeg(from, to)),
|
||||
graph_rev: 'rev-1',
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false);
|
||||
answerAll();
|
||||
});
|
||||
|
||||
describe('paddleRouteRepo', () => {
|
||||
it('FE-REPO-PADDLEROUTE-001: asks the server for every leg once, with the boat and the lake style', async () => {
|
||||
const legs = await paddleRouteRepo.plan(
|
||||
101,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(legs.map((leg) => leg.status)).toEqual(['ok', 'ok']);
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(paddleRouteApi.plan).mock.calls[0]?.[1]).toEqual({
|
||||
legs: [
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
boat: 'kayak',
|
||||
lake_style: 'direct',
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-002: a leg planned before comes from memory; moving one point asks only for its legs', async () => {
|
||||
await paddleRouteRepo.plan(
|
||||
102,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
vi.mocked(paddleRouteApi.plan).mockClear();
|
||||
await paddleRouteRepo.plan(
|
||||
102,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(paddleRouteApi.plan).not.toHaveBeenCalled();
|
||||
|
||||
const moved = { lat: C.lat + 0.001, lng: C.lng };
|
||||
await paddleRouteRepo.plan(
|
||||
102,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: moved },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(vi.mocked(paddleRouteApi.plan).mock.calls[0]?.[1].legs).toEqual([{ from: B, to: moved }]);
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-003: another boat or lake style is another leg; a failed leg is asked again next time', async () => {
|
||||
await paddleRouteRepo.plan(103, [{ from: A, to: B }], KAYAK);
|
||||
await paddleRouteRepo.plan(103, [{ from: A, to: B }], { boat: 'sup', lakeStyle: 'direct' });
|
||||
await paddleRouteRepo.plan(103, [{ from: A, to: B }], { boat: 'kayak', lakeStyle: 'shore' });
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(3);
|
||||
|
||||
vi.mocked(paddleRouteApi.plan).mockResolvedValue({
|
||||
legs: [{ status: 'failed', code: 'busy', at: null }],
|
||||
graph_rev: null,
|
||||
});
|
||||
await expect(paddleRouteRepo.plan(103, [{ from: B, to: C }], KAYAK)).resolves.toEqual([
|
||||
{ status: 'failed', code: 'busy', at: null },
|
||||
]);
|
||||
answerAll();
|
||||
const again = await paddleRouteRepo.plan(103, [{ from: B, to: C }], KAYAK);
|
||||
expect(again[0]?.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-004: forgetting a leg asks the server again (P2A-05)', async () => {
|
||||
await paddleRouteRepo.plan(104, [{ from: A, to: B }], KAYAK);
|
||||
paddleRouteRepo.forget(104, A, B, KAYAK);
|
||||
await paddleRouteRepo.plan(104, [{ from: A, to: B }], KAYAK);
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-005: offline, a leg not planned before comes back failed as offline; a remembered one still answers', async () => {
|
||||
await paddleRouteRepo.plan(105, [{ from: A, to: B }], KAYAK);
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true);
|
||||
vi.mocked(paddleRouteApi.plan).mockClear();
|
||||
const legs = await paddleRouteRepo.plan(
|
||||
105,
|
||||
[
|
||||
{ from: A, to: B },
|
||||
{ from: B, to: C },
|
||||
],
|
||||
KAYAK
|
||||
);
|
||||
expect(legs.map((leg) => leg.status)).toEqual(['ok', 'failed']);
|
||||
expect(legs[1]).toEqual({ status: 'failed', code: 'offline', at: null });
|
||||
expect(paddleRouteApi.plan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-REPO-PADDLEROUTE-006: more legs than one request takes go in batches; a leg the answer leaves out stays unrouted', async () => {
|
||||
const many = Array.from({ length: MAX_PADDLE_ROUTE_LEGS + 3 }, (_, index) => ({
|
||||
from: { lat: 49 + index * 0.001, lng: 12 },
|
||||
to: { lat: 49 + (index + 1) * 0.001, lng: 12 },
|
||||
}));
|
||||
const legs = await paddleRouteRepo.plan(106, many, KAYAK);
|
||||
expect(paddleRouteApi.plan).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(paddleRouteApi.plan).mock.calls[1]?.[1].legs).toHaveLength(3);
|
||||
expect(legs.every((leg) => leg.status === 'ok')).toBe(true);
|
||||
|
||||
vi.mocked(paddleRouteApi.plan).mockResolvedValue({ legs: [], graph_rev: null });
|
||||
await expect(paddleRouteRepo.plan(106, [{ from: C, to: A }], KAYAK)).resolves.toEqual([
|
||||
{ status: 'failed', code: 'no_water_path', at: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The real call; the repo tests above run against a mock of this module. */
|
||||
async function realApi() {
|
||||
const actual = await vi.importActual<typeof import('../api/paddleRoute')>('../api/paddleRoute');
|
||||
return actual.paddleRouteApi;
|
||||
}
|
||||
|
||||
describe('paddleRouteApi', () => {
|
||||
it('FE-API-PADDLEROUTE-001: posts the legs to the trip and answers the parsed reply', async () => {
|
||||
const api = await realApi();
|
||||
server.use(
|
||||
contractHandler(
|
||||
'post',
|
||||
'/api/trips/7/paddle/route',
|
||||
{ request: paddleRouteRequestSchema, response: paddleRouteResponseSchema },
|
||||
({ body }) => ({ legs: body.legs.map(({ from, to }) => okLeg(from, to, 2400)), graph_rev: 'abc' })
|
||||
)
|
||||
);
|
||||
const answer = await api.plan(7, { legs: [{ from: A, to: B }], boat: 'canoe' });
|
||||
expect(answer.legs[0]).toMatchObject({ status: 'ok', distance_m: 2400 });
|
||||
expect(answer.graph_rev).toBe('abc');
|
||||
});
|
||||
|
||||
it('FE-API-PADDLEROUTE-002: an answer off the contract is refused instead of handed on', async () => {
|
||||
const api = await realApi();
|
||||
server.use(http.post('/api/trips/7/paddle/route', () => HttpResponse.json({ legs: [{ status: 'maybe' }] })));
|
||||
await expect(api.plan(7, { legs: [{ from: A, to: B }] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
MAX_PADDLE_ROUTE_LEGS,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleRouteLeg,
|
||||
type PaddleRoutePoint,
|
||||
} from '@trek/shared';
|
||||
import { paddleRouteApi } from '../api/paddleRoute';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
export interface PaddleRouteProfile {
|
||||
boat: PaddleBoatType;
|
||||
lakeStyle: PaddleLakeStyle;
|
||||
}
|
||||
|
||||
/** A leg the router could not be asked for: offline, or the answer did not come. */
|
||||
export type PaddleLegAnswer = PaddleRouteLeg | { status: 'failed'; code: 'offline'; at: null };
|
||||
|
||||
const OFFLINE: PaddleLegAnswer = { status: 'failed', code: 'offline', at: null };
|
||||
const NO_ANSWER: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
|
||||
|
||||
/** How many planned legs stay remembered; a leg asked again comes from here and costs no request. */
|
||||
const REMEMBERED_LEGS = 300;
|
||||
|
||||
const remembered = new Map<string, PaddleRouteLeg>();
|
||||
|
||||
function keyOf(
|
||||
tripId: number | string,
|
||||
from: PaddleRoutePoint,
|
||||
to: PaddleRoutePoint,
|
||||
profile: PaddleRouteProfile
|
||||
): string {
|
||||
return [
|
||||
tripId,
|
||||
from.lat.toFixed(6),
|
||||
from.lng.toFixed(6),
|
||||
to.lat.toFixed(6),
|
||||
to.lng.toFixed(6),
|
||||
profile.boat,
|
||||
profile.lakeStyle,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function remember(key: string, leg: PaddleRouteLeg): void {
|
||||
remembered.delete(key);
|
||||
remembered.set(key, leg);
|
||||
if (remembered.size > REMEMBERED_LEGS) remembered.delete(remembered.keys().next().value as string);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: legs planned on the water, online only on purpose: the network lives
|
||||
* on the server. A leg planned before comes from memory, so moving one point
|
||||
* asks only for the two legs at it; a leg that failed is asked again next time.
|
||||
* Offline every leg comes back failed as `offline`, and the planner keeps the
|
||||
* stretch visibly unrouted.
|
||||
*/
|
||||
export const paddleRouteRepo = {
|
||||
async plan(
|
||||
tripId: number | string,
|
||||
legs: ReadonlyArray<{ from: PaddleRoutePoint; to: PaddleRoutePoint }>,
|
||||
profile: PaddleRouteProfile,
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleLegAnswer[]> {
|
||||
const keys = legs.map(({ from, to }) => keyOf(tripId, from, to, profile));
|
||||
const answers: Array<PaddleLegAnswer | undefined> = keys.map((key) => remembered.get(key));
|
||||
const missing = answers.flatMap((answer, index) => (answer ? [] : [index]));
|
||||
if (missing.length > 0 && isEffectivelyOffline()) return answers.map((answer) => answer ?? OFFLINE);
|
||||
for (let start = 0; start < missing.length; start += MAX_PADDLE_ROUTE_LEGS) {
|
||||
const batch = missing.slice(start, start + MAX_PADDLE_ROUTE_LEGS);
|
||||
const asked = batch.flatMap((index) => {
|
||||
const leg = legs[index];
|
||||
return leg ? [{ index, leg }] : [];
|
||||
});
|
||||
const planned = await paddleRouteApi.plan(
|
||||
tripId,
|
||||
{ legs: asked.map(({ leg }) => leg), boat: profile.boat, lake_style: profile.lakeStyle },
|
||||
signal
|
||||
);
|
||||
asked.forEach(({ index }, at) => {
|
||||
const leg = planned.legs[at];
|
||||
const key = keys[index];
|
||||
if (!leg || key === undefined) return;
|
||||
answers[index] = leg;
|
||||
if (leg.status === 'ok') remember(key, leg);
|
||||
});
|
||||
}
|
||||
return answers.map((answer) => answer ?? NO_ANSWER);
|
||||
},
|
||||
|
||||
/** P2A-05: forget a leg, so the next plan asks the server again (on its current data). */
|
||||
forget(tripId: number | string, from: PaddleRoutePoint, to: PaddleRoutePoint, profile: PaddleRouteProfile): void {
|
||||
const key = keyOf(tripId, from, to, profile);
|
||||
remembered.delete(key);
|
||||
},
|
||||
};
|
||||
@@ -7,12 +7,16 @@
|
||||
|
||||
/* Obstacles in slate, access rules in indigo, places to get in and out in
|
||||
teal, a carried stretch in earth brown; each marker a disc in the map's own
|
||||
white, which keeps it readable over a satellite tile. */
|
||||
white, which keeps it readable over a satellite tile. A routed paddle tour
|
||||
(PAD-11) marks what the paddler should look at: whitewater and boat passes in
|
||||
amber, a gap in the data or a stretch no route was found for in red. */
|
||||
:root {
|
||||
--map-paddle-obstacle: #334155;
|
||||
--map-paddle-access: #4338ca;
|
||||
--map-paddle-landing: #0f766e;
|
||||
--map-portage: #9a6233;
|
||||
--map-paddle-rapids: #d97706;
|
||||
--map-paddle-gap: #dc2626;
|
||||
--map-paddle-disc: #ffffff;
|
||||
--map-paddle-shadow: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
@@ -22,6 +26,8 @@
|
||||
--map-paddle-access: #a5b4fc;
|
||||
--map-paddle-landing: #5eead4;
|
||||
--map-portage: #d6a06e;
|
||||
--map-paddle-rapids: #f59e0b;
|
||||
--map-paddle-gap: #ef4444;
|
||||
--map-paddle-disc: #1e293b;
|
||||
--map-paddle-shadow: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ export function hikePlannerDefaults() {
|
||||
waterContext: null,
|
||||
speedKmh: null,
|
||||
portageSpeedKmh: null,
|
||||
lakeStyle: 'direct' as const,
|
||||
recommendedSpeedKmh: null,
|
||||
effectiveSpeedKmh: null,
|
||||
setBoatType: vi.fn(),
|
||||
setWaterContext: vi.fn(),
|
||||
setSpeedKmh: vi.fn(),
|
||||
setPortageSpeedKmh: vi.fn(),
|
||||
setLakeStyle: vi.fn(),
|
||||
adoptRecommendedSpeed: vi.fn(),
|
||||
load: vi.fn(),
|
||||
},
|
||||
@@ -28,10 +30,17 @@ export function hikePlannerDefaults() {
|
||||
paddleIncomplete: false,
|
||||
paddleEstimate: null,
|
||||
editPaddlePoints: vi.fn(),
|
||||
amendWaypoints: vi.fn(),
|
||||
isLoop: false,
|
||||
reverse: vi.fn(),
|
||||
returnToStart: vi.fn(),
|
||||
toggleWaypointFreeLeg: vi.fn(),
|
||||
insertShapingPoint: vi.fn(() => null),
|
||||
setWaypointShaping: vi.fn(),
|
||||
recomputeLeg: vi.fn(),
|
||||
routeDrawnLegsOnWater: vi.fn(),
|
||||
paddleRoute: null,
|
||||
routeCurrent: false,
|
||||
routeParts: null,
|
||||
mapOverlays: new Set<string>(),
|
||||
toggleMapOverlay: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { areaCounts, check, inlineConfigsOf, lintClient, tally } from '../../../scripts/lib/eslint-warnings.mjs';
|
||||
import {
|
||||
areaCounts,
|
||||
check,
|
||||
inlineConfigsOf,
|
||||
lintClient,
|
||||
newWarningsOf,
|
||||
tally,
|
||||
} from '../../../scripts/lib/eslint-warnings.mjs';
|
||||
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
|
||||
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
|
||||
|
||||
@@ -183,4 +191,33 @@ describe('lint:warnings', () => {
|
||||
expect(byKey['b.js'].messages.map((m) => m.ruleId)).toEqual(['no-empty']);
|
||||
expect(byKey['b.js'].inlineConfigs).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'src'));
|
||||
writeFileSync(join(root, 'src/new.ts'), 'two');
|
||||
writeFileSync(join(root, 'src/old.ts'), 'one');
|
||||
// new.ts held none of the rule on the base, old.ts already held its one
|
||||
const git = (_root: string, args: string[]) =>
|
||||
args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1]?.endsWith('old.ts') ? 'one' : 'none';
|
||||
const counts: Record<string, number> = { two: 2, one: 1, none: 0 };
|
||||
const lintText = async (text: string) => [
|
||||
{
|
||||
messages: Array.from({ length: counts[text] ?? 0 }, (_, i) => ({
|
||||
ruleId: 'no-empty',
|
||||
severity: 1,
|
||||
message: `w${i}`,
|
||||
line: i + 1,
|
||||
})),
|
||||
},
|
||||
];
|
||||
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
|
||||
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
|
||||
expect(lines).toEqual(['src/new.ts:1 w0', 'src/new.ts:2 w1']);
|
||||
expect(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null })).toBeNull();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"client/src/components/Roadtrip/useFollowTrack.ts": 2,
|
||||
"client/src/components/Roadtrip/useHotelBookends.ts": 1,
|
||||
"client/src/components/Studio/countryShapes.ts": 1,
|
||||
"client/src/components/Tours/planner/useTourPlanner.ts": 3,
|
||||
"client/src/components/Tours/planner/useTourPlanner.ts": 1,
|
||||
"client/src/components/shared/ErrorBoundary.tsx": 1,
|
||||
"client/src/components/shared/Toast.tsx": 1,
|
||||
"client/src/constants/mapDefaults.ts": 4,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* own major and has to lint with the one its eslint.config.mjs was written
|
||||
* for. This file never imports eslint, so it resolves no version of its own.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -145,6 +146,89 @@ export function lintWith(ESLint) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 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}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** A one-file lint for newWarningsOf, with the ESLint the workspace imports (see lintWith). */
|
||||
export function lintTextWith(ESLint) {
|
||||
return (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);
|
||||
|
||||
/**
|
||||
@@ -156,6 +240,8 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint,
|
||||
/** lintTextWith(ESLint): names the files behind a grown count; left out, only the counts are told. */
|
||||
lintText = null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -169,13 +255,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -191,7 +277,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -200,5 +287,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ESLint } from 'eslint';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import { areaCounts, check, inlineConfigsOf, lintWith, tally } from './eslint-warnings.mjs';
|
||||
import { areaCounts, check, inlineConfigsOf, lintWith, newWarningsOf, tally } from './eslint-warnings.mjs';
|
||||
import { RatchetError } from './ratchet.mjs';
|
||||
import { ratchetTree } from './tree-fixture.mjs';
|
||||
|
||||
@@ -92,6 +93,26 @@ describe('scripts/lib/eslint-warnings', () => {
|
||||
assert.match(out, /Fix the new warning rather than silencing it/);
|
||||
});
|
||||
|
||||
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'src'));
|
||||
writeFileSync(join(root, 'src/new.ts'), 'two');
|
||||
writeFileSync(join(root, 'src/old.ts'), 'one');
|
||||
// new.ts held none of the rule on the base, old.ts already held its one
|
||||
const git = (_root, args) => (args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1].endsWith('old.ts') ? 'one' : 'none');
|
||||
const lintText = async (text) => [
|
||||
{ messages: Array.from({ length: { two: 2, one: 1, none: 0 }[text] }, (_, i) => ({ ...warn('no-empty', `w${i}`), line: i + 1 })) },
|
||||
];
|
||||
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
|
||||
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
|
||||
assert.deepEqual(lines, ['src/new.ts:1 w0', 'src/new.ts:2 w1']);
|
||||
assert.equal(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null }), null);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('counts a message an eslint-disable comment silenced', async () => {
|
||||
assert.equal(await run(empty, (root) => [result(root, 'src/a.ts', [], [warn('no-console')])]), 1);
|
||||
assert.match(tree.error.join('\n'), /suppressed: 1 no-console warning/);
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
* ESLint comes from this workspace, so the server lints with the major its
|
||||
* config was written for.
|
||||
*/
|
||||
import { check, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
|
||||
import { check, lintTextWith, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
|
||||
import { runCli } from '../../scripts/lib/ratchet.mjs';
|
||||
|
||||
import { ESLint } from 'eslint';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update'), lint: lintWith(ESLint) }));
|
||||
await runCli('lint:warnings', (args) =>
|
||||
check({ root, update: args.includes('--update'), lint: lintWith(ESLint), lintText: lintTextWith(ESLint) }),
|
||||
);
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"PackingTemplateItems": "packing",
|
||||
"PackingTemplates": "packing",
|
||||
"PaddleFeatures": "paddle-obstacles",
|
||||
"PaddleRegionRequests": "paddle-obstacles",
|
||||
"PaddleRegions": "paddle-obstacles",
|
||||
"PaddleWaterGraphs": "paddle-routing",
|
||||
"PasswordResetTokens": "auth",
|
||||
"PhotoProviderFields": "addons",
|
||||
"PhotoProviders": "addons",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
|
||||
import { PaddleRegionRequestsRepository } from '../repositories/PaddleRegionRequests.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
|
||||
export class PaddleRegionRequests {
|
||||
[EntityRepositoryType]?: PaddleRegionRequestsRepository;
|
||||
[PrimaryKeyProp]?: 'region_id';
|
||||
region_id!: string;
|
||||
name!: string;
|
||||
reason!: TPaddleRegionRequestsReason;
|
||||
bytes?: number | null;
|
||||
requested_by?: string | null;
|
||||
requested_at!: string & Opt;
|
||||
declined_at?: string | null;
|
||||
}
|
||||
|
||||
export const PaddleRegionRequestsReason = {
|
||||
ASK: 'ask',
|
||||
TOO_LARGE: 'too_large',
|
||||
LIMIT: 'limit',
|
||||
} as const;
|
||||
|
||||
export type TPaddleRegionRequestsReason = (typeof PaddleRegionRequestsReason)[keyof typeof PaddleRegionRequestsReason];
|
||||
|
||||
export const PaddleRegionRequestsSchema = defineEntity({
|
||||
class: PaddleRegionRequests,
|
||||
repository: () => PaddleRegionRequestsRepository,
|
||||
properties: {
|
||||
region_id: p.text().primary(),
|
||||
name: p.text(),
|
||||
reason: p.enum(() => PaddleRegionRequestsReason),
|
||||
bytes: p.integer().nullable(),
|
||||
requested_by: p.text().nullable(),
|
||||
requested_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
|
||||
declined_at: p.type(DbTimestampType).nullable(),
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user