Compare commits

..

2 Commits

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

- Palette for the whole app (surfaces, dashboard, Vacay glass, Journey,
  mobile chrome) in theme/special/halloween.tokens.css, loaded with index.css.
- Decor as a lazy chunk mounted by SpecialThemeLayer while the scheme is on:
  fog, embers, bat flocks, a passing ghost, corner cobwebs, spiders that drop
  on a thread and flee when poked, a pumpkin patch and an ember running along
  the navbar. Figures are pre-rendered 3D sprite sheets.
- Nothing is drawn over a map: the Atlas gets no decor, the trip planner only
  dresses its side panels. Marker clusters turn into lanterns.
- The 3D mascot plays its own Halloween clips in every scene (witch hat, a
  spider on the brim, a lit pumpkin beside it); the SVG fallback wears the
  same costume.
- Reduced motion stills everything, transparency off drops the fog, shared
  pages never show it.
- Navbar, bottom nav, notifications, Atlas and Collections read the dark flag
  off the .dark class instead of settings.dark_mode, so a forced dark theme
  reaches them.
2026-10-18 12:59:36 +02:00
706 changed files with 3921 additions and 21314 deletions
-20
View File
@@ -257,15 +257,6 @@ jobs:
# migration that changes a column regenerates it in the same change. # migration that changes a column regenerates it in the same change.
run: cd server && npm run check:db-types run: cd server && npm run check:db-types
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint - name: Lint
# Errors fail, and the warnings per rule may not pass # Errors fail, and the warnings per rule may not pass
# scripts/eslint-baseline.json (src, tests and eslint-disable'd # scripts/eslint-baseline.json (src, tests and eslint-disable'd
@@ -273,7 +264,6 @@ jobs:
# program, which outgrows Node's default ~4 GB heap on the runner. # program, which outgrows Node's default ~4 GB heap on the runner.
env: env:
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd server && npm run lint:warnings run: cd server && npm run lint:warnings
- name: Formatting check - name: Formatting check
@@ -405,15 +395,6 @@ jobs:
# one included, has to be clean (scripts/strict-lint.mjs). # one included, has to be clean (scripts/strict-lint.mjs).
run: cd client && npm run lint:strict run: cd client && npm run lint:strict
- name: Fetch the base the warnings are compared with
# lint:warnings names the file behind a grown count by linting it as
# it is on the base: the pushed-over commit on a push, the target
# branch on a pull request. A miss only drops that hint.
run: >-
git fetch --no-tags --depth=1 origin
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|| true
- name: Lint - name: Lint
# Fails on any ESLint error, like lint:check did, and on any rule # Fails on any ESLint error, like lint:check did, and on any rule
# whose warning count rises above scripts/eslint-baseline.json. # whose warning count rises above scripts/eslint-baseline.json.
@@ -421,7 +402,6 @@ jobs:
# whole TypeScript program, so it gets the heap the server lint gets. # whole TypeScript program, so it gets the heap the server lint gets.
env: env:
NODE_OPTIONS: --max-old-space-size=8192 NODE_OPTIONS: --max-old-space-size=8192
TREK_LINT_BASE: refs/remotes/lint-base
run: cd client && npm run lint:warnings run: cd client && npm run lint:warnings
- name: Page pattern check - name: Page pattern check
+1
View File
@@ -47,6 +47,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/bebas-neue": "^5.3.0", "@fontsource/bebas-neue": "^5.3.0",
"@fontsource/creepster": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0", "@fontsource/geist-sans": "^5.3.0",
"@fontsource/inter": "^5.3.0", "@fontsource/inter": "^5.3.0",
+2
View File
@@ -24,6 +24,8 @@
if (isShared) dark = false; if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches; else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark'; else dark = s.darkMode === true || s.darkMode === 'dark';
// Special themes (APPEARANCE_SPECIAL_SCHEMES in @trek/shared) are dark-only.
if (!isShared && s.scheme === 'halloween') dark = true;
root.classList.toggle('dark', dark); root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme; var scheme = isShared ? 'default' : s.scheme;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"src": { "src": {
"@typescript-eslint/no-non-null-assertion": 404, "@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 874, "@typescript-eslint/no-misused-promises": 875,
"no-console": 88, "no-console": 88,
"complexity": 230, "complexity": 230,
"@typescript-eslint/no-explicit-any": 147, "@typescript-eslint/no-explicit-any": 147,
+4 -91
View File
@@ -2,7 +2,6 @@
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs). * The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
*/ */
import { ESLint } from 'eslint'; import { ESLint } from 'eslint';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path'; import { join } from 'node:path';
import ts from 'typescript'; import ts from 'typescript';
import { import {
@@ -138,90 +137,6 @@ export async function lintClient(root) {
})); }));
} }
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
function gitIn(root, args) {
try {
return execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 1 << 28,
});
} catch {
return null;
}
}
/** The messages of one rule in one area of a single file's lint result. */
function ofRule(results, rule, area) {
const result = results[0];
if (!result) return [];
const pool =
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
return pool.filter((m) => ruleOf(m) === rule);
}
/**
* Where a grown rule's new warnings sit: every file among `keys` (the files
* holding the rule now) that differs from `ref` and holds more of the rule
* than it did there, each of its warnings of the rule as `file:line message`.
* The count alone names no file, and the files holding the most are old ones;
* this names the file that tipped it. Null when `ref` is not at hand (a clone
* without it), and the report then keeps to the files holding the most.
*/
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
if (changed === null) return null;
const differs = new Set(changed.split('\n').filter(Boolean));
const found = await Promise.all(
keys
.filter((key) => differs.has(key))
.map(async (key) => {
const path = join(root, key);
// A typed rule can trip over a text that is not the file on disk (the
// base version); then the file's own warnings are told, marked so.
const lint = async (text) => {
try {
return { messages: ofRule(await lintText(text, path), rule, area) };
} catch {
return null;
}
};
const now = await lint(readText(path));
if (!now) return [];
const before = git(root, ['show', `${ref}:./${key}`]);
const was = before === null ? { messages: [] } : await lint(before);
if (was && now.messages.length <= was.messages.length) return [];
const note = was ? '' : ' (the base version could not be linted)';
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
})
);
return found.flat();
}
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
async function reportNew(grown, { root, files, lintText, error }) {
if (!grown.length || !lintText) return;
const found = await Promise.all(
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
);
grown.forEach(({ area, rule }, i) => {
const lines = found[i];
if (!lines?.length) return;
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
for (const line of lines) error(` ${line}`);
});
}
/** Lints one file's text with the client's own config, for newWarningsOf. */
function lintTextClient(root) {
const eslint = new ESLint({ cwd: root });
return (text, filePath) => eslint.lintText(text, { filePath });
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0); const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
/** /**
@@ -233,7 +148,6 @@ export async function check({
baselinePath = join(root, 'scripts/eslint-baseline.json'), baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false, update = false,
lint = lintClient, lint = lintClient,
lintText = lint === lintClient ? lintTextClient : null,
log = console.log, log = console.log,
error = console.error, error = console.error,
}) { }) {
@@ -246,13 +160,13 @@ export async function check({
} }
for (const line of errors) error(`FAIL ${line}`); for (const line of errors) error(`FAIL ${line}`);
const grown = []; let grown = 0;
let stale = 0; let stale = 0;
for (const area of AREAS) { for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) { for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0; const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue; if (n <= allowed) continue;
grown.push({ area, rule }); grown++;
const top = Object.entries(files[area][rule]) const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a) .sort(([, a], [, b]) => b - a)
.slice(0, 5) .slice(0, 5)
@@ -268,8 +182,7 @@ export async function check({
}); });
stale += fallen.length; stale += fallen.length;
} }
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error }); if (grown) {
if (grown.length) {
error( error(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' + 'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.' 'Run npx eslint <file> to see each one.'
@@ -278,5 +191,5 @@ export async function check({
log( log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)` `eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
); );
return errors.length || grown.length || stale ? 1 : 0; return errors.length || grown || stale ? 1 : 0;
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667, "src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2764, "src/components/Budget/CostsPanel.test.tsx": 2764,
"src/components/Budget/CostsPanel.tsx": 1816, "src/components/Budget/CostsPanel.tsx": 1816,
"src/components/Map/MapView.tsx": 1402, "src/components/Map/MapView.tsx": 1409,
"src/components/Map/MapViewGL.test.tsx": 2920, "src/components/Map/MapViewGL.test.tsx": 2920,
"src/components/Map/MapViewGL.tsx": 2299, "src/components/Map/MapViewGL.tsx": 2299,
"src/components/Map/RouteCalculator.ts": 1195, "src/components/Map/RouteCalculator.ts": 1195,
+2 -2
View File
@@ -55,7 +55,7 @@
"components/Layout/DemoBanner.tsx": 25, "components/Layout/DemoBanner.tsx": 25,
"components/Layout/InAppNotificationBell.tsx": 10, "components/Layout/InAppNotificationBell.tsx": 10,
"components/Layout/MobileTopBar.tsx": 51, "components/Layout/MobileTopBar.tsx": 51,
"components/Layout/Navbar.tsx": 21, "components/Layout/Navbar.tsx": 22,
"components/Layout/NewVersionNotice.tsx": 3, "components/Layout/NewVersionNotice.tsx": 3,
"components/Layout/OfflineBanner.tsx": 2, "components/Layout/OfflineBanner.tsx": 2,
"components/Layout/PageSidebar.tsx": 3, "components/Layout/PageSidebar.tsx": 3,
@@ -269,7 +269,7 @@
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7, "mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3, "mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13, "mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2, "mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
"mobile/screens/trip/tabs/MCollabChat.tsx": 22, "mobile/screens/trip/tabs/MCollabChat.tsx": 22,
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19, "mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18, "mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
+2
View File
@@ -33,6 +33,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
import NewVersionNotice from './components/Layout/NewVersionNotice' import NewVersionNotice from './components/Layout/NewVersionNotice'
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
import HelpPanel from './components/Help/HelpPanel' import HelpPanel from './components/Help/HelpPanel'
import SpecialThemeLayer from './theme/special/SpecialThemeLayer'
// Notice action registrations (side-effect imports): // Notice action registrations (side-effect imports):
import './pages/Trips/noticeActions.js' import './pages/Trips/noticeActions.js'
import { managedRoutes } from './managed' import { managedRoutes } from './managed'
@@ -405,6 +406,7 @@ export default function App() {
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)} {!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary> <ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary> <ErrorBoundary boundaryId="widget:new-version" fallback={null}><NewVersionNotice /></ErrorBoundary>
<ErrorBoundary boundaryId="widget:special-theme" fallback={null}><SpecialThemeLayer /></ErrorBoundary>
{/* One boundary for all route chunks, above <Routes> so it stays mounted {/* One boundary for all route chunks, above <Routes> so it stays mounted
across navigations. react-router runs location updates inside a transition, across navigations. react-router runs location updates inside a transition,
so a mounted boundary keeps the current page on screen instead of flashing so a mounted boundary keeps the current page on screen instead of flashing
-13
View File
@@ -1,13 +0,0 @@
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 };
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
export const paddleRegionLoadApi = {
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
apiClient
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
};
+2 -11
View File
@@ -2,19 +2,17 @@ import {
paddleRegionCatalogResponseSchema, paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema, paddleRegionListResponseSchema,
paddleRegionSchema, paddleRegionSchema,
paddleRegionSettingsSchema,
successResponseSchema, successResponseSchema,
type PaddleRegionRequest, type PaddleRegionRequest,
type PaddleRegionSettings,
} from '@trek/shared'; } from '@trek/shared';
import apiClient from './client'; import apiClient from './client';
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */ /** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
const GEOFABRIK = { timeout: 30_000 }; const GEOFABRIK = { timeout: 30_000 };
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */ /** PAD-05A, the admin side: the regions TREK reads paddle data for. */
export const paddleRegionsApi = { export const paddleRegionsApi = {
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)), list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
catalog: (q: string, signal?: AbortSignal) => catalog: (q: string, signal?: AbortSignal) =>
apiClient apiClient
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK }) .get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
@@ -31,11 +29,4 @@ export const paddleRegionsApi = {
apiClient apiClient
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest) .post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)), .then((r) => successResponseSchema.parse(r.data)),
decline: (id: string) =>
apiClient
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
.then((r) => successResponseSchema.parse(r.data)),
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
saveSettings: (settings: PaddleRegionSettings) =>
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
}; };
-10
View File
@@ -1,10 +0,0 @@
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
import apiClient from './client';
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
export const paddleRouteApi = {
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
apiClient
.post(`/trips/${tripId}/paddle/route`, request, { signal })
.then((r) => paddleRouteResponseSchema.parse(r.data)),
};
-31
View File
@@ -1,31 +0,0 @@
import type {
TripJournalCandidatesResponse,
TripJournalLinkMemberRequest,
TripJournalLinkResponse,
} from '@trek/shared';
import apiClient from './client';
/**
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
* and who it is open to. In its own file rather than in the `client.ts` god
* module; repo/tripJournalLinkRepo.ts is the one caller.
*/
export const tripJournalApi = {
get: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
link: (
tripId: number,
journeyId: number,
members: TripJournalLinkMemberRequest[]
): Promise<TripJournalLinkResponse> =>
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
/** The journals this person can open; the dialog offers the ones they own. */
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
apiClient.get('/journeys').then((r) => r.data),
};
-4
View File
@@ -93,10 +93,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// the bag ping: what a run changed is per-binding server state, and the // the bag ping: what a run changed is per-binding server state, and the
// files it moved arrive as file:* events. // files it moved arrive as file:* events.
'docsync:changed', 'docsync:changed',
// Linked journal: components/Trips/useTripJournalLink asks again which journal
// the trip shows and who it is open to. Content-free, because that answer is
// per person (a member left off the link must not learn its title).
'trip:journal:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress', 'import:progress',
'import:done', 'import:done',
@@ -1,167 +0,0 @@
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
const SIZES = [100, 250, 500, 1000, 2000] as const;
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
function useMegabytes() {
const { language } = useTranslation();
return (mb: number) =>
new Intl.NumberFormat(language, {
style: 'unit',
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
maximumFractionDigits: mb >= 1000 ? 1 : 0,
}).format(mb >= 1000 ? mb / 1000 : mb);
}
const MB = 1024 * 1024;
/**
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
* mode, and in the automatic one the largest region it loads without asking.
*/
export function AutoLoadSettings({ admin }: { admin: Admin }) {
const { t } = useTranslation();
const megabytes = useMegabytes();
const { settings, saving, save } = admin.autoLoad;
if (!settings) return null;
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
? SIZES
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
return (
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
<div className="min-w-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<Segmented<PaddleRegionAutoMode>
value={settings.mode}
onChange={(mode) => void save({ mode })}
label={t('admin.paddleRegions.auto.title')}
fill
options={MODES.map((mode) => {
const Icon = MODE_ICONS[mode];
return {
value: mode,
label: t(`admin.paddleRegions.auto.mode.${mode}`),
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
};
})}
/>
</fieldset>
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
</div>
{settings.mode === 'auto' && (
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
<ChoiceChips<string>
value={String(settings.max_mb)}
label={t('admin.paddleRegions.auto.maxSize')}
onChange={(value) => void save({ max_mb: Number(value) })}
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
/>
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
</fieldset>
)}
</div>
);
}
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
const { t, language } = useTranslation();
const megabytes = useMegabytes();
const busy = admin.busyId === request.id;
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
return (
<div
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
data-testid="paddle-region-request"
>
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
{request.name}
</span>
<span className="flex flex-wrap items-center gap-1.5">
{request.declined ? (
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
{t('admin.paddleRegions.requests.declined')}
</StatusPill>
) : (
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
)}
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
{request.requested_by && (
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
)}
<StatusPill>{date}</StatusPill>
</span>
</div>
<span className="flex flex-none items-center gap-1.5">
{!request.declined && (
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.decline(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{t('admin.paddleRegions.requests.decline')}
</button>
)}
<button
type="button"
disabled={admin.busyId !== null}
onClick={() => void admin.approve(request.id)}
className={SETTINGS_BUTTON}
style={fs(12.5, 'body')}
>
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
{t('admin.paddleRegions.requests.approve')}
</button>
</span>
</div>
);
}
/** The regions paddlers asked for that wait for an admin, the open ones first. */
export function RequestList({ admin }: { admin: Admin }) {
const { t } = useTranslation();
if (admin.requests.length === 0) return null;
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
const open = admin.requests.filter((request) => !request.declined).length;
return (
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
{t('admin.paddleRegions.requests.title')}
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
</span>
<SettingRows>
{ordered.map((request) => (
<RequestRow key={request.id} request={request} admin={admin} />
))}
</SettingRows>
</div>
);
}
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
export function AutoOriginPill() {
const { t } = useTranslation();
return (
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
<span className="inline-flex" data-testid="paddle-region-auto">
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
</span>
</Tooltip>
);
}
@@ -1,4 +1,4 @@
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a // FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
// controlled usePaddleRegionsAdmin (the hook has its own test). // controlled usePaddleRegionsAdmin (the hook has its own test).
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel. // FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared'; import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
@@ -25,13 +25,11 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
id: 'oberpfalz', id: 'oberpfalz',
name: 'Oberpfalz', name: 'Oberpfalz',
status: 'ready', status: 'ready',
origin: 'admin',
source_timestamp: '2026-10-03T12:00:00Z', source_timestamp: '2026-10-03T12:00:00Z',
imported_at: '2026-10-04T08:00:00Z', imported_at: '2026-10-04T08:00:00Z',
feature_count: 1234, feature_count: 1234,
error: null, error: null,
progress: null, progress: null,
network: null,
...overrides, ...overrides,
}; };
} }
@@ -58,8 +56,6 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
toursOn: true, toursOn: true,
offline: false, offline: false,
regions: [], regions: [],
requests: [],
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
loading: false, loading: false,
error: null, error: null,
retry: vi.fn(), retry: vi.fn(),
@@ -67,8 +63,6 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
busyId: null, busyId: null,
add: vi.fn(async () => {}), add: vi.fn(async () => {}),
refresh: vi.fn(async () => {}), refresh: vi.fn(async () => {}),
approve: vi.fn(async () => {}),
decline: vi.fn(async () => {}),
removing: null, removing: null,
askRemove: vi.fn(), askRemove: vi.fn(),
cancelRemove: vi.fn(), cancelRemove: vi.fn(),
@@ -301,91 +295,6 @@ describe('PaddleRegionsPanel', () => {
}); });
}); });
describe('PaddleRegionsPanel water network (PAD-11)', () => {
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
const network = {
water_km: 1364.5,
lakes: 266,
portages: 14,
inferred_portages: 232,
gaps: 63,
locks: 17,
built_at: '2026-10-17T15:30:20Z',
bytes: 1496816,
};
show({ regions: [region({ network })] });
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
expect(pill).toHaveTextContent('1,365 km of water');
fireEvent.mouseEnter(pill);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
);
});
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
show({
regions: [
region({ id: 'old', name: 'Old read', network: null }),
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
],
});
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
const save = vi.fn(async () => {});
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
const auto = screen.getByTestId('paddle-auto-load');
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
});
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
});
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
const approve = vi.fn(async () => {});
const decline = vi.fn(async () => {});
const base = {
bytes: 734_003_200,
requested_by: 'elmo',
requested_at: '2026-10-18T09:00:00Z',
};
show({
approve,
decline,
requests: [
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
],
});
const rows = screen.getAllByTestId('paddle-region-request');
expect(rows[0]).toHaveTextContent('Brandenburg');
expect(rows[0]).toHaveTextContent('Daily limit');
expect(rows[0]).toHaveTextContent('by elmo');
expect(rows[1]).toHaveTextContent('Declined');
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
expect(decline).toHaveBeenCalledWith('brandenburg');
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
expect(approve).toHaveBeenCalledWith('norway');
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
});
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
});
});
describe('paddleRegionsModel', () => { describe('paddleRegionsModel', () => {
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => { it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
expect(isReading({ status: 'queued' })).toBe(true); expect(isReading({ status: 'queued' })).toBe(true);
@@ -15,7 +15,6 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import ErrorBoundary from '../../shared/ErrorBoundary'; import ErrorBoundary from '../../shared/ErrorBoundary';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
import { progressPercent, regionStatusTone } from './paddleRegionsModel'; import { progressPercent, regionStatusTone } from './paddleRegionsModel';
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin'; import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
@@ -23,10 +22,8 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
/** /**
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for, * PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a * picked from Geofabrik's list. Both the desktop and the phone admin show
* place a paddler plans in by itself, with the regions that wait for an admin. * this one card under the addons, while the Tours addon is on.
* Both the desktop and the phone admin show this one card under the addons,
* while the Tours addon is on.
*/ */
export default function PaddleRegionsPanel() { export default function PaddleRegionsPanel() {
return ( return (
@@ -70,8 +67,6 @@ function Panel() {
</div> </div>
)} )}
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0"> <fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
<AutoLoadSettings admin={admin} />
<RequestList admin={admin} />
<CatalogSearch admin={admin} /> <CatalogSearch admin={admin} />
<RegionList admin={admin} /> <RegionList admin={admin} />
</fieldset> </fieldset>
@@ -221,41 +216,6 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
); );
} }
/**
* PAD-11: the water network the router follows in a region, as one figure
* with the rest in its tooltip. A region read before routing existed says it
* needs reading again; a network is never claimed where none was built.
*/
function RegionNetwork({ region }: { region: PaddleRegion }) {
const { t, language } = useTranslation();
const { network } = region;
if (!network) {
if (!region.imported_at || region.status === 'importing') return null;
return (
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
<span className="inline-flex">
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
</span>
</Tooltip>
);
}
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
const details = [
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
t('admin.paddleRegions.network.portages', { count: network.portages }),
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
t('admin.paddleRegions.network.locks', { count: network.locks }),
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
].join(', ');
return (
<Tooltip label={details}>
<span className="inline-flex" data-testid="paddle-region-network">
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
</span>
</Tooltip>
);
}
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
const { t, language } = useTranslation(); const { t, language } = useTranslation();
const busy = admin.busyId === region.id; const busy = admin.busyId === region.id;
@@ -279,12 +239,10 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
</span> </span>
<span className="flex flex-wrap items-center gap-1.5"> <span className="flex flex-wrap items-center gap-1.5">
<RegionStatus region={region} /> <RegionStatus region={region} />
{region.origin === 'auto' && <AutoOriginPill />}
{region.imported_at && ( {region.imported_at && (
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill> <StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
)} )}
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>} {region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
<RegionNetwork region={region} />
</span> </span>
{region.status === 'failed' && region.error && ( {region.status === 'failed' && region.error && (
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}> <p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
@@ -1,50 +0,0 @@
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
import { act, renderHook, waitFor } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TranslationProvider } from '../../../i18n/TranslationContext';
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
describe('usePaddleAutoLoad', () => {
beforeEach(() => {
vi.clearAllMocks();
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
});
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
expect(repo.settings).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ mode: 'ask' });
});
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
});
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
await waitFor(() => expect(result.current.settings).not.toBeNull());
await act(async () => {
await result.current.save({ max_mb: 2000 });
});
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
expect(toast.error).toHaveBeenCalledTimes(1);
});
});
@@ -1,59 +0,0 @@
import type { PaddleRegionSettings } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
/**
* PAD-11C in the admin area: how TREK loads the water data of a place a
* paddler plans in, read once the card shows and saved on every change. A
* change shows at once and goes back, with a toast, when the server refuses
* it. While one is saved the controls rest, so two saves never cross.
*/
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
const { t } = useTranslation();
const toast = useToast();
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
const [saving, setSaving] = useState(false);
const pending = useRef(false);
useEffect(() => {
if (!active || offline) return;
let cancelled = false;
paddleRegionRepo.settings().then(
(loaded) => {
if (!cancelled) setSettings(loaded);
},
(err: unknown) => {
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
}
);
return () => {
cancelled = true;
};
}, [active, offline, t, toast]);
const save = useCallback(
async (change: Partial<PaddleRegionSettings>) => {
if (!settings || pending.current) return;
const before = settings;
const next = { ...settings, ...change };
pending.current = true;
setSaving(true);
setSettings(next);
try {
setSettings(await paddleRegionRepo.saveSettings(next));
} catch (err) {
setSettings(before);
toast.error(getApiErrorMessage(err, t('common.error')));
} finally {
pending.current = false;
setSaving(false);
}
},
[settings, t, toast]
);
return { settings, saving, save };
}
@@ -24,15 +24,11 @@ const repo = vi.hoisted(() => ({
add: vi.fn<(id: string) => Promise<PaddleRegion>>(), add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(), refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
remove: vi.fn<(id: string) => Promise<{ success: true }>>(), remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
})); }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({ vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()), ...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
// The list answers its regions and no requests unless a test says otherwise (PAD-11C). paddleRegionRepo: repo,
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
})); }));
vi.mock('../../shared/Toast', () => ({ useToast: () => toast })); vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
@@ -44,13 +40,11 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
id, id,
name: id, name: id,
status, status,
origin: 'admin',
source_timestamp: null, source_timestamp: null,
imported_at: null, imported_at: null,
feature_count: 0, feature_count: 0,
error: null, error: null,
progress: null, progress: null,
network: null,
...over, ...over,
}; };
} }
@@ -1,4 +1,4 @@
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared'; import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNetworkMode } from '../../../hooks/useNetworkMode'; import { useNetworkMode } from '../../../hooks/useNetworkMode';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
@@ -7,7 +7,6 @@ import { useAddonStore } from '../../../store/addonStore';
import { getApiErrorMessage } from '../../../types'; import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast'; import { useToast } from '../../shared/Toast';
import { isReading } from './paddleRegionsModel'; import { isReading } from './paddleRegionsModel';
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
/** How often the list is asked again while a region waits or is read. */ /** How often the list is asked again while a region waits or is read. */
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000; export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
@@ -24,14 +23,12 @@ function errorText(t: Translate, err: unknown): string {
} }
/** /**
* The regions as the server holds them, with those paddlers asked for, asked * The regions as the server holds them, asked again every few seconds while
* again every few seconds while any of them is read, and on `reload` after a * any of them is read, and on `reload` after a change. Every answer is set
* change. Every answer is set
* from the request's own callback, so a slow one never overwrites a newer. * from the request's own callback, so a slow one never overwrites a newer.
*/ */
function useRegionList(active: boolean, offline: boolean, t: Translate) { function useRegionList(active: boolean, offline: boolean, t: Translate) {
const [regions, setRegions] = useState<PaddleRegion[]>([]); const [regions, setRegions] = useState<PaddleRegion[]>([]);
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [asked, setAsked] = useState(0); const [asked, setAsked] = useState(0);
@@ -41,8 +38,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
paddleRegionRepo.list().then( paddleRegionRepo.list().then(
(list) => { (list) => {
if (cancelled) return; if (cancelled) return;
setRegions(list.regions); setRegions(list);
setRequests(list.requests);
setError(null); setError(null);
setLoaded(true); setLoaded(true);
}, },
@@ -63,7 +59,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [active, offline, reading]); }, [active, offline, reading]);
const reload = useCallback(() => setAsked((n) => n + 1), []); const reload = useCallback(() => setAsked((n) => n + 1), []);
return { regions, requests, loading: !loaded && !offline, error, reload }; return { regions, loading: !loaded && !offline, error, reload };
} }
/** Geofabrik's list searched as the admin types, the last search winning. */ /** Geofabrik's list searched as the admin types, the last search winning. */
@@ -103,8 +99,7 @@ function useCatalogSearch(offline: boolean, t: Translate) {
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search * PAD-05A in the admin area: the regions TREK reads paddle data for, a search
* in Geofabrik's list to add one, reading a region again and removing it * in Geofabrik's list to add one, reading a region again and removing it
* after a confirmation. Shown only while the Tours addon is on; the status of * after a confirmation. Shown only while the Tours addon is on; the status of
* a read follows without reloading the page. PAD-11C: how TREK loads regions * a read follows without reloading the page.
* for paddlers by itself, and the regions they asked for, to load or decline.
*/ */
export function usePaddleRegionsAdmin() { export function usePaddleRegionsAdmin() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -112,7 +107,6 @@ export function usePaddleRegionsAdmin() {
const { offline } = useNetworkMode(); const { offline } = useNetworkMode();
const toursOn = useAddonStore((state) => state.isEnabled('tours')); const toursOn = useAddonStore((state) => state.isEnabled('tours'));
const list = useRegionList(toursOn, offline, t); const list = useRegionList(toursOn, offline, t);
const autoLoad = usePaddleAutoLoad(toursOn, offline);
const search = useCatalogSearch(offline, t); const search = useCatalogSearch(offline, t);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [removing, setRemoving] = useState<PaddleRegion | null>(null); const [removing, setRemoving] = useState<PaddleRegion | null>(null);
@@ -152,12 +146,6 @@ export function usePaddleRegionsAdmin() {
[run, setQuery, t] [run, setQuery, t]
); );
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]); const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
/** A paddler's request let through: the region is added like any other. */
const approve = useCallback(
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
[run, t]
);
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
const confirmRemove = useCallback(() => { const confirmRemove = useCallback(() => {
const region = removing; const region = removing;
setRemoving(null); setRemoving(null);
@@ -169,8 +157,6 @@ export function usePaddleRegionsAdmin() {
toursOn, toursOn,
offline, offline,
regions: list.regions, regions: list.regions,
requests: list.requests,
autoLoad,
loading: list.loading, loading: list.loading,
error: list.error, error: list.error,
retry: reload, retry: reload,
@@ -179,8 +165,6 @@ export function usePaddleRegionsAdmin() {
busyId, busyId,
add, add,
refresh, refresh,
approve,
decline,
removing, removing,
askRemove: setRemoving, askRemove: setRemoving,
cancelRemove: () => setRemoving(null), cancelRemove: () => setRemoving(null),
@@ -24,7 +24,7 @@ export const CATEGORIES = [
{ id: 'members', keys: ['member_manage'] }, { id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] }, { id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] }, { id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] }, { id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
]; ];
/** /**
@@ -1,13 +1,12 @@
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react' import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell' import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
import { Tooltip } from '../../shared/Tooltip'
import ToggleSwitch from '../../Settings/ToggleSwitch' import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext' import { useTranslation } from '../../../i18n/TranslationContext'
import { useConnectForm } from './useConnectForm' import { useConnectForm } from './useConnectForm'
import { Badge } from './DocSyncBits' import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons' import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync' import type { DocSyncProvider, useDocSync } from './useDocSync'
/** /**
* Connecting a store, as a dialog rather than a panel that unfolds in place. * Connecting a store, as a dialog rather than a panel that unfolds in place.
@@ -33,7 +32,7 @@ export default function DocSyncConnectModal({
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id] const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const form = useConnectForm(provider, existing, sync) const form = useConnectForm(provider, existing, sync)
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null) const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const test = async () => setVerdict(await form.probe()) const test = async () => setVerdict(await form.probe())
const labelId = useId() const labelId = useId()
@@ -135,7 +134,7 @@ function Verdict({
busy, busy,
failure, failure,
}: { }: {
verdict: DocSyncVerdict | null verdict: { connected: boolean; account?: string; error?: string } | null
busy: boolean busy: boolean
/** A call that failed outside the connection test (saving, most of all). */ /** A call that failed outside the connection test (saving, most of all). */
failure: string | null failure: string | null
@@ -173,16 +172,10 @@ function Verdict({
) )
} }
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted' const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
// The footer shares its row with the buttons, and a refusal cut to "The
// credentials were re..." with no way to read the rest left nothing to go on
// (#2666). Two lines, and the whole of it with the store's reason on hover.
return ( return (
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top"> <span className="flex min-w-0 items-center gap-2 text-caption text-danger">
<span className="flex min-w-0 items-center gap-2 text-caption text-danger"> {blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />} <span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span> </span>
</span>
</Tooltip>
) )
} }
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync' import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
/** /**
* The credential form's rules, for both shells. * The credential form's rules, for both shells.
@@ -39,7 +39,7 @@ export interface ConnectForm {
setInsecureTls: (on: boolean) => void setInsecureTls: (on: boolean) => void
/** Every required field is either filled in or already stored. */ /** Every required field is either filled in or already stored. */
complete: boolean complete: boolean
probe: () => Promise<DocSyncVerdict> probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
save: () => Promise<boolean> save: () => Promise<boolean>
} }
@@ -12,14 +12,6 @@ import { useServerPing } from '../../../sync/useServerPing'
* reason for the same problem. * reason for the same problem.
*/ */
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
export interface DocSyncVerdict {
connected: boolean
account?: string
error?: string
detail?: string
}
export interface DocSyncField { export interface DocSyncField {
field_key: string field_key: string
label: string label: string
@@ -272,9 +264,11 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => { const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test') setBusy('test')
try { try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) { } catch (e: unknown) {
return { connected: false, error: readError(e) } satisfies DocSyncVerdict return { connected: false, error: readError(e) }
} finally { } finally {
setBusy(null) setBusy(null)
} }
@@ -98,19 +98,6 @@ describe('JourneySettingsDialog', () => {
await screen.findByRole('button', { name: /create share link/i }) await screen.findByRole('button', { name: /create share link/i })
}) })
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney({
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
}))
expect(screen.getByText('Through trips')).toBeInTheDocument()
expect(screen.getByText('lena')).toBeInTheDocument()
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => { it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const { onSaved } = mountDialog() const { onSaved } = mountDialog()
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts' import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection' import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings' import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore' import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog' import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch' import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,8 +277,6 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div> </div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton> <AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection> </DialogSection>
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
</div> </div>
</div> </div>
@@ -326,32 +324,3 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</> </>
) )
} }
/**
* Who a trip's linked journal lets in (#2683), under its own heading below the
* contributors: shown here, but managed in that trip's settings, so read only.
*/
function TripAccessSection({ access, roleLabel }: {
access: JourneyTripAccess[] | undefined
roleLabel: (role: JourneyContributor['role']) => string
}) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<DialogSection label={t('journey.contributors.viaTrips')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
<div className={ROWS}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
</div>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
</div>
))}
</div>
</DialogSection>
)
}
+3 -2
View File
@@ -9,6 +9,7 @@ import { resolvePluginIcon } from '../shared/PluginIcon'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import { visibleManagedNavItems } from '../../managed' import { visibleManagedNavItems } from '../../managed'
import { isNavItemActive, useCreateAction } from './useCreateAction' import { isNavItemActive, useCreateAction } from './useCreateAction'
import { useIsDark } from '../../hooks/useIsDark'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = { const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' }, vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
@@ -22,8 +23,8 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
export default function BottomNav() { export default function BottomNav() {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const darkMode = useSettingsStore(s => s.settings.dark_mode) // Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const dark = useIsDark()
const addons = useAddonStore(s => s.addons) const addons = useAddonStore(s => s.addons)
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
// Page plugins are reachable from the mobile tab bar too, mirroring the desktop // Page plugins are reachable from the mobile tab bar too, mirroring the desktop
@@ -7,13 +7,14 @@ import { useInAppNotificationStore } from '../../store/inAppNotificationStore.ts
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx' import InAppNotificationItem from '../Notifications/InAppNotificationItem.tsx'
import { useIsDark } from '../../hooks/useIsDark'
export default function InAppNotificationBell(): React.ReactElement { export default function InAppNotificationBell(): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const { settings } = useSettingsStore() const { settings } = useSettingsStore()
const darkMode = settings.dark_mode // Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const dark = useIsDark()
const isAuthenticated = useAuthStore(s => s.isAuthenticated) const isAuthenticated = useAuthStore(s => s.isAuthenticated)
const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore() const { notifications, unreadCount, isLoading, fetchNotifications, fetchUnreadCount, markAllRead, deleteAll } = useInAppNotificationStore()
+18 -14
View File
@@ -169,24 +169,23 @@ describe('Navbar', () => {
expect(adminLink).toHaveAttribute('href', '/admin'); expect(adminLink).toHaveAttribute('href', '/admin');
}); });
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => { it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />); render(<Navbar onShare={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i }); const shareBtn = screen.getByRole('button', { name: /share/i });
const settings = screen.getByRole('button', { name: 'Trip settings' }); expect(shareBtn).toBeInTheDocument();
expect(back.parentElement).toBe(settings.closest('div'));
}); });
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => { it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSettings = vi.fn(); const onShare = vi.fn();
render(<Navbar onSettings={onSettings} />); render(<Navbar onShare={onShare} />);
await user.click(screen.getByRole('button', { name: 'Trip settings' })); const shareBtn = screen.getByRole('button', { name: /share/i });
expect(onSettings).toHaveBeenCalled(); await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
}); });
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => { it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
render(<Navbar />); render(<Navbar />);
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
}); });
@@ -454,8 +453,8 @@ describe('Navbar styling and menu details', () => {
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument(); expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
}); });
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => { it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />); render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
const back = screen.getByRole('button', { name: /back/i }); const back = screen.getByRole('button', { name: /back/i });
fireEvent.mouseEnter(back); fireEvent.mouseEnter(back);
@@ -463,6 +462,11 @@ describe('Navbar styling and menu details', () => {
fireEvent.mouseLeave(back); fireEvent.mouseLeave(back);
expect(back.style.background).toBe('transparent'); expect(back.style.background).toBe('transparent');
const share = screen.getByRole('button', { name: /share/i });
fireEvent.mouseEnter(share);
expect(share.style.background).toBe('var(--bg-hover)');
fireEvent.mouseLeave(share);
expect(share.style.background).toBe('var(--bg-card)');
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement; const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
fireEvent.mouseEnter(theme); fireEvent.mouseEnter(theme);
+18 -22
View File
@@ -6,13 +6,14 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { usePluginStore } from '../../store/pluginStore' import { usePluginStore } from '../../store/pluginStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
import HelpButton from '../Help/HelpButton' import HelpButton from '../Help/HelpButton'
import { resolvePluginIcon } from '../shared/PluginIcon' import { resolvePluginIcon } from '../shared/PluginIcon'
import { visibleManagedNavItems } from '../../managed' import { visibleManagedNavItems } from '../../managed'
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow' import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
import { useIsDark } from '../../hooks/useIsDark'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark } const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -21,8 +22,7 @@ interface NavbarProps {
tripId?: number | string tripId?: number | string
onBack?: () => void onBack?: () => void
showBack?: boolean showBack?: boolean
/** The trip's Settings: who is on it, its links and its journal. */ onShare?: () => void
onSettings?: () => void
} }
interface Addon { interface Addon {
@@ -33,7 +33,7 @@ interface Addon {
enabled: boolean enabled: boolean
} }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement { export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout, isPrerelease, appVersion } = useAuthStore() const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore() const { addons: allAddons, loadAddons } = useAddonStore()
@@ -42,8 +42,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
const location = useLocation() const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false) const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [scrolled, setScrolled] = useState<boolean>(false) const [scrolled, setScrolled] = useState<boolean>(false)
const darkMode = settings.dark_mode // Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const dark = useIsDark()
useEffect(() => { useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8) const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
@@ -122,7 +122,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
paddingTop: 'env(safe-area-inset-top, 0px)', paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)', height: 'var(--nav-h)',
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)', transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]"> }} className="trek-navbar hidden md:flex items-center px-4 gap-4 fixed top-0 inset-x-0 z-[200]">
{/* Left side. flex-1 basis-0, matching the action cluster on the right, so {/* Left side. flex-1 basis-0, matching the action cluster on the right, so
the tab pill between them sits in the middle of the bar rather than the tab pill between them sits in the middle of the bar rather than
being centred on top of both (#1983). Both columns keep at least their being centred on top of both (#1983). Both columns keep at least their
@@ -174,21 +174,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span> <span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
</span> </span>
))} ))}
{/* The trip's Settings sit with the trip, beside its pill: they are about this
trip, not about the app the right side of the bar belongs to. */}
{onSettings && (
// The same pill as the trip's own beside it, so the two read as one pair.
<button
type="button"
onClick={onSettings}
aria-label={t('trip.settings.title')}
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
<span className="text-caption font-semibold">{t('nav.settings')}</span>
</button>
)}
</div> </div>
{/* Centred liquid-glass tab menu (design handoff). {/* Centred liquid-glass tab menu (design handoff).
@@ -261,6 +246,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
itself on top of them. */} itself on top of them. */}
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end"> <div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
{/* Share button */}
{onShare && (
<button type="button" onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */} {/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
{user && <HelpButton />} {user && <HelpButton />}
+11 -4
View File
@@ -8,7 +8,6 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
import RoadtripViaMarkers from './RoadtripViaMarkers' import RoadtripViaMarkers from './RoadtripViaMarkers'
import PlannerWaypointMarker from './PlannerWaypointMarker' import PlannerWaypointMarker from './PlannerWaypointMarker'
import HazardLayers from './HazardLayers' import HazardLayers from './HazardLayers'
import { RouteHitBand } from './RouteHitBand'
import { formatViaDwell } from './routeViaDwell' import { formatViaDwell } from './routeViaDwell'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors' import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker' import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
@@ -1159,7 +1158,8 @@ export const MapView = memo(function MapView({
</MarkerClusterGroup> </MarkerClusterGroup>
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
When the route can be reshaped an invisible band wider than both takes the click. */} The casing carries the click when the route can be reshaped: it is the wider of
the two, so it is the one a pointer actually lands on. */}
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [ {route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
// Walked, so dashed and without the casing: the drive is the solid line (#2532). // Walked, so dashed and without the casing: the drive is the solid line (#2532).
<Polyline <Polyline
@@ -1173,7 +1173,15 @@ export const MapView = memo(function MapView({
key={`${i}-casing`} key={`${i}-casing`}
positions={seg} positions={seg}
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }} pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false} interactive={!!onRouteClick}
eventHandlers={onRouteClick ? {
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu
// underneath the new via.
e.originalEvent.stopPropagation()
onRouteClick(e.latlng.lat, e.latlng.lng)
},
} : undefined}
/>, />,
<Polyline <Polyline
key={`${i}-core`} key={`${i}-core`}
@@ -1181,7 +1189,6 @@ export const MapView = memo(function MapView({
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }} pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
interactive={false} interactive={false}
/>, />,
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
])} ])}
{/* The last bit to a place the road does not reach. {/* The last bit to a place the road does not reach.
@@ -2,25 +2,18 @@ import L from 'leaflet';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Marker, Tooltip, useMap } from 'react-leaflet'; import { Marker, Tooltip, useMap } from 'react-leaflet';
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel'; import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The pin's inline style for a size and selection; the renderer owns the colour. */ /** The pin's inline style for a size and selection; the renderer owns the colour. */
export type PinCss = (size: number, selected: boolean) => string; export type PinCss = (size: number, selected: boolean) => string;
function plannerWaypointIcon( function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
pinCss: PinCss, const size = selected ? 30 : 26;
point: PlannerPinPoint,
index: number,
selected: boolean,
draggable: boolean
): L.DivIcon {
const { size, text } = plannerPinShape(point, index, selected);
return L.divIcon({ return L.divIcon({
className: 'tour-planner-waypoint-marker', className: 'tour-planner-waypoint-marker',
iconSize: [size, size], iconSize: [size, size],
iconAnchor: [size / 2, size / 2], iconAnchor: [size / 2, size / 2],
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`, html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
}); });
} }
@@ -32,7 +25,7 @@ export default function PlannerWaypointMarker({
onMove, onMove,
pinCss, pinCss,
}: { }: {
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint; point: { id: string; lat: number; lng: number; name?: string; detail?: string };
index: number; index: number;
selected: boolean; selected: boolean;
onClick?: (id: string) => void; onClick?: (id: string) => void;
@@ -46,11 +39,7 @@ export default function PlannerWaypointMarker({
handlersRef.current = { onClick, onMove }; handlersRef.current = { onClick, onMove };
}, [onClick, onMove]); }, [onClick, onMove]);
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]); const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
const { label, shaping } = point; const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
const icon = useMemo(
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
[index, label, onMove, pinCss, selected, shaping]
);
const eventHandlers = useMemo( const eventHandlers = useMemo(
() => ({ () => ({
click: (event: { originalEvent: MouseEvent }) => { click: (event: { originalEvent: MouseEvent }) => {
@@ -1,35 +0,0 @@
import { Polyline } from 'react-leaflet';
/**
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
* than the drawn line, that takes the click which reshapes the route, as the
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
*/
export function RouteHitBand({
positions,
onRouteClick,
}: {
positions: [number, number][];
onRouteClick: (lat: number, lng: number) => void;
}) {
return (
<Polyline
positions={positions}
pathOptions={{
color: 'transparent',
weight: 20,
opacity: 0,
lineCap: 'round',
lineJoin: 'round',
className: 'cursor-copy',
}}
eventHandlers={{
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
e.originalEvent.stopPropagation();
onRouteClick(e.latlng.lat, e.latlng.lng);
},
}}
/>
);
}
+1 -9
View File
@@ -110,15 +110,7 @@ export interface MapViewProps {
onHighlightAlternative?: (index: number | null) => void; onHighlightAlternative?: (index: number | null) => void;
/** Generic numbered control points for list-first route editors. */ /** Generic numbered control points for list-first route editors. */
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */ /** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
plannerWaypoints?: Array<{ plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
id: string;
lat: number;
lng: number;
name?: string;
detail?: string;
label?: string;
shaping?: boolean;
}>;
selectedPlannerWaypointId?: string | null; selectedPlannerWaypointId?: string | null;
onPlannerWaypointClick?: (id: string) => void; onPlannerWaypointClick?: (id: string) => void;
/** Drops a dragged control point; returns false when the move was refused. */ /** Drops a dragged control point; returns false when the move was refused. */
@@ -1,12 +0,0 @@
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
import { describe, expect, it } from 'vitest';
import { plannerPinShape } from './plannerPinShape';
describe('plannerPinShape', () => {
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
});
});
@@ -1,16 +0,0 @@
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
export interface PlannerPinPoint {
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
label?: string;
shaping?: boolean;
}
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
export function plannerPinShape(
point: PlannerPinPoint,
index: number,
selected: boolean
): { size: number; text: string } {
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
}
@@ -1,85 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
vi.mock('./RouteCalculator', async (importActual) => {
const actual = await importActual<typeof import('./RouteCalculator')>();
return { ...actual, calculateRouteWithLegs: vi.fn() };
});
const WAYPOINTS = [
{ lat: 47.5, lng: 19.04 },
{ lat: 47.49, lng: 19.05 },
];
const ROUTED = {
coordinates: [
[47.5, 19.04],
[47.49, 19.05],
],
legs: [],
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(calculateRouteWithLegs).mockReset();
});
afterEach(() => vi.useRealTimers());
describe('calculateRouteWithRetries', () => {
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
});
it('waits as long as the host asks when that is longer', async () => {
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
.mockResolvedValueOnce(ROUTED);
const answer = calculateRouteWithRetries(WAYPOINTS, {});
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
await expect(answer).resolves.toBe(ROUTED);
});
it('gives up after the last delay and throws the refusal', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
const answer = calculateRouteWithRetries(WAYPOINTS, {});
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
await settled;
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
});
it('throws a refusal the host meant at once', async () => {
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
});
it('stops waiting when the day changes', async () => {
const controller = new AbortController();
vi.mocked(calculateRouteWithLegs)
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
await vi.advanceTimersByTimeAsync(10);
controller.abort();
await settled;
});
});
-48
View File
@@ -1,48 +0,0 @@
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
export type { RouteProfileKey } from './RouteCalculator';
/** How often a rate-limited route is asked for again, and how long after. */
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
/** A pause that ends early, without throwing, when the signal aborts. */
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise((resolve) => {
if (signal?.aborted || ms <= 0) {
resolve();
return;
}
const timer = setTimeout(done, ms);
function done(): void {
clearTimeout(timer);
signal?.removeEventListener('abort', done);
resolve();
}
signal?.addEventListener('abort', done, { once: true });
});
/**
* `calculateRouteWithLegs`, asked again while the host says it is busy.
*
* The public OSRM hosts allow about one request a second and answer 429 above that. A
* day with a change of mode, or a quick switch between days, asks more often than that,
* and the day route used to give up on the first refusal and keep its straight lines
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
* as long as the host asks when it says; a refusal the host meant is thrown at once,
* because the same request earns the same answer. An abort ends the wait, and the next
* attempt then throws the AbortError the caller already handles.
*/
export async function calculateRouteWithRetries(
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
): ReturnType<typeof calculateRouteWithLegs> {
for (let attempt = 0; ; attempt++) {
try {
return await calculateRouteWithLegs(waypoints, opts);
} catch (err) {
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
}
}
}
+32 -12
View File
@@ -1,5 +1,4 @@
import type { RouteProfileKey } from './RouteCalculator' import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan' import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias' import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
import { projectDayItinerary } from './dayTourProjection' import { projectDayItinerary } from './dayTourProjection'
@@ -186,6 +185,20 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
const REQUEST_SPACING_MS = 1100 const REQUEST_SPACING_MS = 1100
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */ /** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
const CACHE_HIT_MS = 60 const CACHE_HIT_MS = 60
/** How often a rate-limited leg is tried again, and how long after. */
const RETRY_DELAYS_MS = [1500, 4000]
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
new Promise(resolve => {
if (signal?.aborted || ms <= 0) { resolve(); return }
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal?.removeEventListener('abort', done)
resolve()
}
signal?.addEventListener('abort', done, { once: true })
})
/** /**
* Ask the router for every leg of every day, one at a time. * Ask the router for every leg of every day, one at a time.
@@ -220,16 +233,23 @@ export async function routeTripLegs(
runs.forEach((chunks, r) => { runs.forEach((chunks, r) => {
chunks.forEach((chunk, c) => { chunks.forEach((chunk, c) => {
tasks.push(async () => { tasks.push(async () => {
if (signal?.aborted) return for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
try { if (signal?.aborted) return
const answer = await calculateRouteWithRetries( try {
chunk.waypoints, const answer = await calculateRouteWithLegs(
{ signal, profile: chunk.mode, tripId, dayId: day.id }, chunk.waypoints,
) { signal, profile: chunk.mode, tripId, dayId: day.id },
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) } )
onAnswer?.(routed) routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
} catch { onAnswer?.(routed)
// Refused for good, or aborted: the leg keeps its straight line. return
} catch (err) {
if (signal?.aborted) return
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
// When the host says how long to wait, waiting less is just a second refusal.
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
}
} }
}) })
}) })
@@ -2,7 +2,6 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel'; import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
import type { MapViewProps } from './mapViewContract'; import type { MapViewProps } from './mapViewContract';
import { plannerPinShape } from './plannerPinShape';
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'; import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */ /** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
@@ -72,10 +71,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
for (const [index, point] of plannerWaypoints.entries()) { for (const [index, point] of plannerWaypoints.entries()) {
const selected = point.id === selectedPlannerWaypointId; const selected = point.id === selectedPlannerWaypointId;
const { size, text } = plannerPinShape(point, index, selected); const size = selected ? 30 : 26;
const el = document.createElement('button'); const el = document.createElement('button');
el.type = 'button'; el.type = 'button';
el.textContent = text; el.textContent = String(index + 1);
const name = point.name ?? `Waypoint ${index + 1}`; const name = point.name ?? `Waypoint ${index + 1}`;
el.setAttribute('aria-label', name); el.setAttribute('aria-label', name);
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`; el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
@@ -4,6 +4,7 @@ import { useTranslation } from '../../i18n'
import type { InAppNotification } from '../../store/inAppNotificationStore' import type { InAppNotification } from '../../store/inAppNotificationStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { compactTime, useNotificationItemActions } from './useNotificationItemActions' import { compactTime, useNotificationItemActions } from './useNotificationItemActions'
import { useIsDark } from '../../hooks/useIsDark'
function relativeTime(dateStr: string, locale: string): string { function relativeTime(dateStr: string, locale: string): string {
return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now') return compactTime(dateStr, locale === 'ar' ? 'الآن' : 'just now')
@@ -17,8 +18,8 @@ interface NotificationItemProps {
export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement { export default function InAppNotificationItem({ notification, onClose }: NotificationItemProps): React.ReactElement {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const { settings } = useSettingsStore() const { settings } = useSettingsStore()
const darkMode = settings.dark_mode // Off the .dark class: a special theme is dark whatever the stored mode says.
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const dark = useIsDark()
const { responding, handleRespond, handleNavigate, markRead, deleteNotification } = const { responding, handleRespond, handleNavigate, markRead, deleteNotification } =
useNotificationItemActions(notification, onClose) useNotificationItemActions(notification, onClose)
@@ -271,9 +271,7 @@ describe('PackingListPanel', () => {
]; ];
const { container } = render(<PackingListPanel tripId={1} items={items} />); const { container } = render(<PackingListPanel tripId={1} items={items} />);
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
// Only the category grip stays: the order of the categories is not the A to Z of their items. expect(container.querySelector('[draggable="true"]')).toBeNull();
const draggable = [...container.querySelectorAll('[draggable="true"]')];
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
}); });
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => { it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { import {
Trash2, Plus, ChevronDown, ChevronRight, Trash2, Plus, ChevronDown, ChevronRight,
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
} from 'lucide-react' } from 'lucide-react'
import type { PackingItem, PackingBag } from '../../types' import type { PackingItem, PackingBag } from '../../types'
import { katColor } from './packingListPanel.helpers' import { katColor } from './packingListPanel.helpers'
@@ -38,16 +38,6 @@ interface KategorieGruppeProps {
// Off while the list is shown A-Z: a drop there would move an item in an order // Off while the list is shown A-Z: a drop there would move an item in an order
// the reader cannot see. // the reader cannot see.
reorderable?: boolean reorderable?: boolean
// Moving the whole category among the others, wired by the list, which sees every
// card. `onDrop` gets the category that was dragged onto this one.
categoryDrag?: {
isDragging: boolean
isOver: boolean
onStart: () => void
onOver: () => void
onEnd: () => void
onDrop: (dragged: string) => void
}
// Three-tier sharing (#858) — threaded down to each item's share control. // Three-tier sharing (#858) — threaded down to each item's share control.
currentUserId?: number currentUserId?: number
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
@@ -56,7 +46,7 @@ interface KategorieGruppeProps {
onLeave?: (id: number, userId: number) => void onLeave?: (id: number, userId: number) => void
} }
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) { export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true) const [offen, setOffen] = useState(true)
const [dragId, setDragId] = useState<number | null>(null) const [dragId, setDragId] = useState<number | null>(null)
const [overId, setOverId] = useState<number | null>(null) const [overId, setOverId] = useState<number | null>(null)
@@ -130,9 +120,8 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
return ( return (
// Shaped after the phone's category card: a tinted head band carrying the // Shaped after the phone's category card: a tinted head band carrying the
// name as written, the count, and the fold arrow on the far right. // name as written, the count, and the fold arrow on the far right.
<CategoryDropFrame drag={categoryDrag}> <div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} /> <span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName && canEdit ? ( {editingName && canEdit ? (
@@ -355,65 +344,6 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
))} ))}
</div> </div>
)} )}
</CategoryDropFrame>
)
}
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
/** The data type a dragged category carries, its name as the value. */
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
/**
* The card, and while a category is dragged its drop target. Captured, because
* the item rows stop their own drops from bubbling.
*/
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
// Told apart by the drag's own data type, which is there from the first event on:
// a flag set in state on dragstart can still be unrendered when a quick drag
// arrives, and a dragged item must pass through to its rows untouched.
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
return (
<div
onDragOverCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
}) : undefined}
onDropCapture={drag ? (e => {
if (!isCategoryDrag(e)) return
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
}) : undefined}
style={{
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
opacity: drag?.isDragging ? 0.4 : 1,
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
transition: 'opacity 0.15s, box-shadow 0.15s',
}}
>
{children}
</div> </div>
) )
} }
/** The handle a category is dragged by, in the head band before its colour dot. */
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
if (!drag) return null
return (
<span
draggable
role="button"
aria-label={label}
title={label}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
e.dataTransfer.setData('text/plain', kategorie)
drag.onStart()
}}
onDragEnd={drag.onEnd}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
>
<GripVertical size={14} />
</span>
)
}
@@ -1,4 +1,3 @@
import { useState } from 'react'
import type { PackingState } from './usePackingListPanel' import type { PackingState } from './usePackingListPanel'
import { KategorieGruppe } from './PackingListPanelCategoryGroup' import { KategorieGruppe } from './PackingListPanelCategoryGroup'
import EmptyState from '../shared/EmptyState' import EmptyState from '../shared/EmptyState'
@@ -8,13 +7,8 @@ export function PackingList(S: PackingState) {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem, items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees, handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems, bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory, currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
} = S } = S
// Which category is being dragged and which card it is over. Held here, above
// the cards, because the drag starts in one card and ends in another.
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
const [overCategory, setOverCategory] = useState<string | null>(null)
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
return ( return (
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}> <div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
{items.length === 0 ? ( {items.length === 0 ? (
@@ -46,14 +40,6 @@ export function PackingList(S: PackingState) {
allItems={items} allItems={items}
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)} onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
reorderable={sort === 'manual'} reorderable={sort === 'manual'}
categoryDrag={canEdit ? {
isDragging: draggedCategory === kat,
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
onOver: () => setOverCategory(kat),
onEnd: endCategoryDrag,
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
} : undefined}
currentUserId={currentUserId} currentUserId={currentUserId}
onSetSharing={handleSetSharing} onSetSharing={handleSetSharing}
onClone={handleCloneItem} onClone={handleCloneItem}
@@ -6,7 +6,6 @@ import {
groupPackingItems, groupPackingItems,
isLastCustomItemInCategory, isLastCustomItemInCategory,
isPackingPlaceholder, isPackingPlaceholder,
itemOrderWithCategoryMoved,
packingCategoryOrder, packingCategoryOrder,
packingItemWeight, packingItemWeight,
packingProgress, packingProgress,
@@ -15,7 +14,7 @@ import {
} from './packingListModel'; } from './packingListModel';
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'; import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009 // FE-PACK-MODEL-001 to FE-PACK-MODEL-008
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 }); const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 }); const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
@@ -77,28 +76,4 @@ describe('packingListModel', () => {
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true); expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false); expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
}); });
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
const items = [day1, day2a, own, day3, day2b];
const common = packingViewItems(items, 'common');
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
expect(order).toEqual([14, 11, 12, 15, 13]);
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
});
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
const items = [shared, stove, loose];
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
});
}); });
@@ -25,34 +25,6 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
return seen; return seen;
} }
/**
* The whole list's item order with category `from` moved to where `to` stands.
*
* A category has no position of its own: it shows where its first item is. So a
* category is moved by moving its items, as one block, through the item reorder
* the list already has; nothing new is stored, and the order reaches every device
* the way a dragged item does. The view's items come first, grouped in the new
* order with each group in its own order; the other view's items follow in theirs,
* which leaves that view's categories as they were. Null when nothing would move.
*/
export function itemOrderWithCategoryMoved(
items: PackingItem[],
viewItems: PackingItem[],
from: string,
to: string,
defaultCategory: string
): number[] | null {
const order = packingCategoryOrder(viewItems, defaultCategory);
const fromIndex = order.indexOf(from);
const toIndex = order.indexOf(to);
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
order.splice(fromIndex, 1);
order.splice(toIndex, 0, from);
const inView = new Set(viewItems.map((i) => i.id));
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
}
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] { export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
if (status === 'open') return items.filter((i) => !i.checked); if (status === 'open') return items.filter((i) => !i.checked);
if (status === 'done') return items.filter((i) => !!i.checked); if (status === 'done') return items.filter((i) => !!i.checked);
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import type { PackingItem } from '../../types' import type { PackingItem } from '../../types'
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers' import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel' import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
import { usePackingBags } from './usePackingBags' import { usePackingBags } from './usePackingBags'
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees' import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
import { usePackingImport } from './usePackingImport' import { usePackingImport } from './usePackingImport'
@@ -106,13 +106,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems) const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
// A category dropped onto another takes its place; the slice reorders
// optimistically and rolls back with a toast of its own.
const moveCategory = (from: string, to: string) => {
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
}
const { const {
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName, addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory, addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
@@ -220,7 +213,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems, tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName, filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory, tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked, handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal, bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers, handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
) )
return ( return (
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}> <DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
<div ref={bodyRef}> <div ref={bodyRef}>
{asking ? ( {asking ? (
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter"> <section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
@@ -308,10 +308,9 @@ export default function AccountTab(): React.ReactElement {
/> />
<input <input
type="text" type="text"
inputMode="text" inputMode="numeric"
autoComplete="one-time-code"
value={mfaDisableCode} value={mfaDisableCode}
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))} onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder={t('settings.mfa.codePlaceholder')} placeholder={t('settings.mfa.codePlaceholder')}
aria-label={t('settings.mfa.codePlaceholder')} aria-label={t('settings.mfa.codePlaceholder')}
className={`${INPUT} font-geist tabular-nums`} className={`${INPUT} font-geist tabular-nums`}
@@ -6,7 +6,8 @@ import { Segmented } from '../shared/dialogParts'
import Section from './Section' import Section from './Section'
import ToggleSwitch from './ToggleSwitch' import ToggleSwitch from './ToggleSwitch'
import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit' import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'
import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS } from '../../theme/schemes' import { APPEARANCE_SCHEMES, CUSTOM_ACCENT_PRESETS, SPECIAL_THEMES } from '../../theme/schemes'
import SpecialThemeCard from '../../theme/special/SpecialThemeCard'
import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared' import { APPEARANCE_SCALE_MIN, APPEARANCE_SCALE_MAX } from '@trek/shared'
import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel' import { DESKTOP_GROUPS, MOBILE_GROUPS, isHex } from './appearanceModel'
import { useAppearanceEditor } from './useAppearanceEditor' import { useAppearanceEditor } from './useAppearanceEditor'
@@ -35,7 +36,7 @@ const DOT = 'h-4 w-4 flex-none rounded-full shadow-[inset_0_0_0_1px_var(--border
export default function AppearanceSettingsTab(): React.ReactElement { export default function AppearanceSettingsTab(): React.ReactElement {
const { t } = useTranslation() const { t } = useTranslation()
const tr = (key: string, fallback: string) => t(key) || fallback const tr = (key: string, fallback: string) => t(key) || fallback
const { cfg, darkMode, isDark, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } = const { cfg, darkMode, isDark, modeLocked, update, setMode, setWidget, resetAll, accentLight, accentDark, customRatio } =
useAppearanceEditor() useAppearanceEditor()
// The stored mode, read the way the old boolean values meant it. // The stored mode, read the way the old boolean values meant it.
@@ -50,7 +51,9 @@ export default function AppearanceSettingsTab(): React.ReactElement {
{/* Color mode */} {/* Color mode */}
<SettingRow <SettingRow
label={tr('settings.colorMode', 'Color mode')} label={tr('settings.colorMode', 'Color mode')}
hint={modeLocked ? t('settings.appearance.special.modeLocked') : undefined}
control={ control={
<div className={modeLocked ? 'pointer-events-none opacity-45' : undefined} aria-disabled={modeLocked || undefined}>
<Segmented <Segmented
label={tr('settings.colorMode', 'Color mode')} label={tr('settings.colorMode', 'Color mode')}
value={mode} value={mode}
@@ -70,9 +73,20 @@ export default function AppearanceSettingsTab(): React.ReactElement {
}, },
]} ]}
/> />
</div>
} }
/> />
{/* Special themes: a whole look, not just an accent */}
<SettingRow label={t('settings.appearance.special')} stacked>
<div className="grid gap-2 sm:grid-cols-2">
{SPECIAL_THEMES.map((s) => (
<SpecialThemeCard key={s.id} theme={s} active={cfg.schemeId === s.id}
onSelect={() => update({ schemeId: s.id })} />
))}
</div>
</SettingRow>
{/* Color scheme swatches */} {/* Color scheme swatches */}
<SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked> <SettingRow label={tr('settings.appearance.scheme', 'Color scheme')} stacked>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
@@ -290,6 +304,7 @@ function schemeFallback(id: string): string {
rose: 'Rose', rose: 'Rose',
amber: 'Amber', amber: 'Amber',
violet: 'Violet', violet: 'Violet',
halloween: 'Halloween',
} }
return map[id] || id return map[id] || id
} }
@@ -1,6 +1,7 @@
import { DEFAULT_APPEARANCE, normalizeAppearance, type AppearanceConfig } from '@trek/shared'; import { DEFAULT_APPEARANCE, isSpecialScheme, normalizeAppearance, type AppearanceConfig } from '@trek/shared';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useIsDark } from '../../hooks/useIsDark';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { applyAppearance } from '../../theme/applyAppearance'; import { applyAppearance } from '../../theme/applyAppearance';
@@ -42,10 +43,10 @@ export function useAppearanceEditor() {
[updateSetting] [updateSetting]
); );
const isDark = // Off the `.dark` class: a special theme forces dark whatever the stored mode says.
settings.dark_mode === true || const isDark = useIsDark();
settings.dark_mode === 'dark' || // While a special theme is on, the color mode has nothing to switch.
(settings.dark_mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches); const modeLocked = isSpecialScheme(cfg.schemeId);
// Live preview now (DOM), persist after a short debounce (API). // Live preview now (DOM), persist after a short debounce (API).
const update = (patch: Partial<AppearanceConfig>) => { const update = (patch: Partial<AppearanceConfig>) => {
@@ -83,6 +84,7 @@ export function useAppearanceEditor() {
cfg, cfg,
darkMode: settings.dark_mode, darkMode: settings.dark_mode,
isDark, isDark,
modeLocked,
update, update,
setMode, setMode,
setWidget, setWidget,
@@ -1,36 +0,0 @@
import type { PaddleLakeStyle } from '@trek/shared';
import { useTranslation } from '../../../i18n';
import { LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
/**
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
* shore is for wind, when a capsize near the bank is a short swim. The wind
* card suggests it on a windy day; here it is chosen.
*/
export function PaddleLakeStyleField({
value,
onChange,
}: {
value: PaddleLakeStyle;
onChange: (style: PaddleLakeStyle) => void;
}) {
const { t } = useTranslation();
return (
<div data-testid="tour-planner-lake-style" className="min-w-0">
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
<Segmented<PaddleLakeStyle>
value={value}
onChange={onChange}
label={t('tours.paddle.lakeStyle')}
fill
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
/>
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
{t(`tours.paddle.lakeStyle.${value}Hint`)}
</span>
</div>
);
}
@@ -33,8 +33,6 @@ const PAINT: PaddlePaint = {
access: '#4338ca', access: '#4338ca',
landing: '#0f766e', landing: '#0f766e',
portage: '#9a6233', portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff', disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)', shadow: 'rgba(15, 23, 42, 0.35)',
}; };
@@ -1,152 +0,0 @@
import type { PaddleRouteLeg } from '@trek/shared';
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units';
import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip';
import type { PaddleLegFailure } from '../planner/paddleRouting';
import { lineLengthM } from '../planner/tourFixedLegs';
/**
* PAD-11: what the waypoint list says about a leg the router planned or
* could not plan, and about a point it set itself.
*/
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
export function AutoPointPill() {
const { t } = useTranslation();
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
return (
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
<span
data-testid="tour-planner-auto-point"
aria-label={t('tours.paddle.auto')}
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
>
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
</span>
</Tooltip>
);
}
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
function notesOf(
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
t: (key: string, params?: Record<string, string | number>) => string
): Note[] {
return leg.segments.flatMap((segment, index): Note[] => {
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
if (segment.kind === 'rapids' && segment.grade != null) {
return [
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
];
}
if (segment.kind === 'pass')
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
return [];
});
}
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
if (!leg || leg.status !== 'ok') return null;
const notes = notesOf(leg, t);
if (notes.length === 0) return null;
return (
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
{notes.map((note) => (
<Tooltip
key={note.key}
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
>
<span
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
style={fs(11)}
>
{note.tone === 'gap' ? (
<AlertTriangle
size={11}
strokeWidth={2.2}
className="flex-none"
style={{ color: 'var(--map-paddle-gap)' }}
aria-hidden="true"
/>
) : (
<Waves
size={11}
strokeWidth={2.2}
className="flex-none"
style={{ color: 'var(--map-paddle-rapids)' }}
aria-hidden="true"
/>
)}
<span className="min-w-0 truncate">{note.label}</span>
<span
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
style={fs(10)}
>
{formatDistance(note.length / 1000, unit)}
</span>
</span>
</Tooltip>
))}
</li>
);
}
/**
* A leg the router could not plan: why, in plain words, and what to do about
* it. The map draws it straight and red meanwhile, and the tour waits to be
* saved until it is planned or drawn by hand on purpose. Where TREK could not
* fetch the water data of the leg's region (PAD-11C), `message` says why and
* `onLoadRetry` reads the region again.
*/
export function PaddleLegFailureRow({
code,
disabled,
onDrawStraight,
onRetry,
message,
onLoadRetry,
}: {
code: PaddleLegFailure;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
message?: string | null;
onLoadRetry?: () => void;
}) {
const { t } = useTranslation();
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
const action =
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
return (
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
</span>
<span className="flex flex-wrap gap-1">
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
{retry && (
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.retry')}
</button>
)}
</span>
</div>
</li>
);
}
@@ -1,73 +0,0 @@
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton';
import { placeOf } from './paddleObstaclePlace';
describe('placeOf', () => {
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
expect(
placeOf({
type: 'LineString',
coordinates: [
[12.9, 53.2],
[12.91, 53.21],
[12.92, 53.22],
],
})
).toEqual({ lat: 53.21, lng: 12.91 });
const middle = placeOf({
type: 'Polygon',
coordinates: [
[
[12, 53],
[12.02, 53],
[12.02, 53.02],
[12, 53.02],
[12, 53],
],
],
});
expect(middle?.lat).toBeCloseTo(53.01, 9);
expect(middle?.lng).toBeCloseTo(12.01, 9);
});
});
describe('PaddleObstaclePlaceButton', () => {
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
const onPlace = vi.fn();
render(
<PaddleObstaclePlaceButton
kind="slipway"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={onPlace}
/>
);
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
});
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
render(
<PaddleObstaclePlaceButton
kind="egress"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={vi.fn()}
/>
);
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
});
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
render(
<PaddleObstaclePlaceButton
kind="weir"
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
onPlace={vi.fn()}
/>
);
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
});
});
@@ -1,45 +0,0 @@
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
import { MapPinPlus } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
import { placeOf } from './paddleObstaclePlace';
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
put_in: 'put_in',
slipway: 'put_in',
egress: 'egress',
};
/**
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
* waypoint from its card on the map, so the point sits where the paddler
* really gets in or out. Set after a portage asked for at the last point, it
* is where the portage ends.
*/
export function PaddleObstaclePlaceButton({
kind,
geometry,
onPlace,
}: {
kind: PaddleObstacleKind;
geometry: PaddleGeometry;
onPlace: (lat: number, lng: number) => void;
}) {
const { t } = useTranslation();
const role = PLACEABLE[kind];
const spot = role ? placeOf(geometry) : null;
if (!role || !spot) return null;
return (
<button
type="button"
data-testid="paddle-obstacle-place"
onClick={() => onPlace(spot.lat, spot.lng)}
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
style={fs(12, 'body')}
>
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
</button>
);
}
@@ -1,78 +0,0 @@
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
import type { PaddleRegionLoadState } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PaddleRegionLoadRow } from './PaddleRegionLoadRow';
import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad';
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
describe('PaddleRegionLoadRow', () => {
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
const onDrawStraight = vi.fn();
render(
<PaddleRegionLoadRow
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
disabled={false}
onDrawStraight={onDrawStraight}
/>
);
const row = screen.getByTestId('tour-planner-region-load');
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
expect(row).toHaveTextContent('Downloading 45%');
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
expect(onDrawStraight).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
render(
<PaddleRegionLoadRow
state={{ state: 'requested', region: REF, reason: 'too_large' }}
disabled={false}
onDrawStraight={vi.fn()}
/>
);
const row = screen.getByTestId('tour-planner-region-load');
expect(row).toHaveTextContent('has been requested');
expect(row).toHaveTextContent('larger than TREK loads by itself');
});
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
const { unmount } = render(
<PaddleRegionLoadRow
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
disabled={false}
onDrawStraight={vi.fn()}
/>
);
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
unmount();
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
expect(screen.queryByRole('button')).toBeNull();
});
});
describe('region load states', () => {
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
const pending: PaddleRegionLoadState[] = [
{ state: 'ready', region: REF },
{ state: 'requested', region: REF, reason: 'ask' },
];
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
expect(isRegionLoadPending(undefined)).toBe(false);
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
'tours.paddle.load.declined{"region":"Oberpfalz"}'
);
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
'tours.paddle.load.noExtract'
);
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
'tours.paddle.load.catalogUnavailable'
);
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
expect(regionLoadFailure(undefined, t)).toBeNull();
});
});
@@ -1,99 +0,0 @@
import type { PaddleRegionLoadState } from '@trek/shared';
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { fs } from '../../shared/DialogShell';
type Translate = ReturnType<typeof useTranslation>['t'];
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
const region = state.region.name;
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
return t('tours.paddle.load.loading', { region });
}
/** The phase of a load in words, with its share where it is known. */
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
const share = state.progress.fraction;
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
}
/**
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
* the data is on its way, and the leg is planned on its own once it is there.
* Drawing it straight stays possible for whoever does not want to wait.
*/
export function PaddleRegionLoadRow({
state,
disabled,
onDrawStraight,
}: {
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
disabled: boolean;
onDrawStraight: () => void;
}) {
const { t, language } = useTranslation();
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
const busy = state.state !== 'requested';
return (
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
<div
role="status"
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
>
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
{busy ? (
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
) : (
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
)}
<span className="min-w-0">{headline(state, t)}</span>
</span>
{state.state === 'loading' && (
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
<span className="inline-flex min-w-0 items-center gap-1">
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
<span className="truncate">{phaseText(state, t, language)}</span>
</span>
</span>
<span
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
aria-hidden="true"
>
<span
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
/>
</span>
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
{t('tours.paddle.load.hint')}
</span>
</span>
)}
{state.state === 'requested' && state.reason !== 'ask' && (
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
</span>
)}
{state.state !== 'ready' && (
<span className="flex flex-wrap gap-1">
<button
type="button"
disabled={disabled}
onClick={onDrawStraight}
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
style={fs(10.5)}
>
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
{t('tours.paddle.drawStraight')}
</button>
</span>
)}
</div>
</li>
);
}
@@ -7,9 +7,8 @@ import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub'; import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore'; import { useSettingsStore } from '../../../store/settingsStore';
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf'; import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { portageLegBetween } from '../planner/tourRouteActions'; import { portageLegBetween } from '../planner/tourRouteActions';
import type { TourPlannerController } from '../planner/useTourPlanner'; import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard'; import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
/** What the card writes for a time it cannot give: an en dash. */ /** What the card writes for a time it cannot give: an en dash. */
@@ -1,53 +0,0 @@
// FE-PADDLE-WAYPOINT-PARTS-001 to 004: the pending portage at the last point and a portage's way on land (PAD-03).
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PendingPortageRow, PortageLegRow, type PortageWay } from './PaddleWaypointParts';
const way = (extra: Partial<PortageWay> = {}): PortageWay => ({
routed: false,
noWay: false,
searching: false,
onFollow: vi.fn(),
onStraighten: vi.fn(),
...extra,
});
function openPortage(props: Partial<Parameters<typeof PortageLegRow>[0]> = {}) {
render(<PortageLegRow lengthM={320} extraMinutes={null} disabled={false} onExtraChange={vi.fn()} {...props} />);
fireEvent.click(screen.getByRole('button', { expanded: false }));
}
describe('PendingPortageRow', () => {
it('FE-PADDLE-WAYPOINT-PARTS-001: says to set the point where the boat goes in again, and takes the ask back', () => {
const onRemove = vi.fn();
render(<PendingPortageRow disabled={false} onRemove={onRemove} />);
expect(screen.getByTestId('tour-planner-portage-pending')).toHaveTextContent('Portage from here');
fireEvent.click(screen.getByRole('button', { name: 'Do not carry after all' }));
expect(onRemove).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WAYPOINT-PARTS-002: offers no way back on a tour that cannot be edited', () => {
render(<PendingPortageRow disabled onRemove={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'Do not carry after all' })).toBeNull();
});
});
describe('PortageLegRow way', () => {
it('FE-PADDLE-WAYPOINT-PARTS-003: switches between following the path and a straight line', () => {
const straight = way();
openPortage({ way: straight });
fireEvent.click(screen.getByRole('button', { name: 'Follow the path' }));
expect(straight.onFollow).toHaveBeenCalledTimes(1);
const routed = way({ routed: true });
openPortage({ way: routed });
fireEvent.click(screen.getAllByRole('button', { name: 'Straight line' })[1]);
expect(routed.onStraighten).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-WAYPOINT-PARTS-004: says why a carry stayed straight, and that the router is asked', () => {
openPortage({ way: way({ noWay: true }) });
expect(screen.getByTestId('tour-planner-portage-way')).toHaveTextContent('No path without a long detour');
openPortage({ way: way({ searching: true }) });
expect(screen.getAllByTestId('tour-planner-portage-way')[1]).toHaveTextContent('Looking for a path');
});
});
@@ -1,10 +1,9 @@
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared'; import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
import { ChevronDown, Loader2, X } from 'lucide-react'; import { ChevronDown, X } from 'lucide-react';
import { useCallback, useState, type ReactNode } from 'react'; import { useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore'; import { useSettingsStore } from '../../../store/settingsStore';
import { formatDistance } from '../../../utils/units'; import { formatDistance } from '../../../utils/units';
import { Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import { PaddleGlyph } from './PaddleGlyph'; import { PaddleGlyph } from './PaddleGlyph';
import { PORTAGE_CSS_COLOR } from './paddlePaint'; import { PORTAGE_CSS_COLOR } from './paddlePaint';
@@ -200,99 +199,6 @@ function StopCard({
); );
} }
/**
* A portage asked for at the last point, before the point it leads to is set:
* dashed in the portage brown, it says where to click next, and can be taken
* back. Once the next point is set it becomes a portage like any other.
*/
export function PendingPortageRow({ disabled, onRemove }: { disabled: boolean; onRemove: () => void }) {
const { t } = useTranslation();
return (
<li data-testid="tour-planner-portage-pending" className="flex min-w-0 py-0.5 ps-[34px]">
<div
className="flex w-full min-w-0 items-start gap-1.5 rounded-[14px] border border-dashed bg-surface-card py-1 pe-1 ps-1"
style={{ borderColor: PORTAGE_CSS_COLOR }}
>
<span
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-surface-card"
style={{ background: PORTAGE_CSS_COLOR }}
>
<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />
</span>
<span className="flex min-w-0 flex-1 flex-col py-[2px]">
<span className="truncate font-semibold text-content" style={fs(11.5, 'body')}>
{t('tours.paddle.portagePending')}
</span>
<span className="leading-snug text-content-muted" style={fs(11, 'body')}>
{t('tours.paddle.portagePendingHint')}
</span>
</span>
{!disabled && (
<button
type="button"
onClick={onRemove}
aria-label={t('tours.paddle.portagePendingOff')}
title={t('tours.paddle.portagePendingOff')}
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
>
<X size={12} strokeWidth={2.4} />
</button>
)}
</div>
</li>
);
}
export interface PortageWay {
/** The portage follows the ways on land (its line has a shape of its own). */
routed: boolean;
/** The router was asked and found no way without a big detour. */
noWay: boolean;
/** The router is being asked right now. */
searching: boolean;
onFollow: () => void;
onStraighten: () => void;
}
/** Whether a portage follows the ways on land or goes straight, and why it stayed straight. */
function PortageWayField({ way, disabled }: { way: PortageWay; disabled: boolean }) {
const { t } = useTranslation();
const value = way.routed ? 'follow' : 'straight';
const hint = way.searching
? t('tours.paddle.portageWay.searching')
: !way.routed && way.noWay
? t('tours.paddle.portageWay.noWay')
: null;
return (
<div className="flex min-w-0 flex-col gap-1" data-testid="tour-planner-portage-way">
<span className="block truncate text-content" style={fs(12, 'body')}>
{t('tours.paddle.portageWay')}
</span>
<fieldset disabled={disabled || way.searching} className="m-0 min-w-0 border-0 p-0">
<Segmented<'follow' | 'straight'>
value={value}
fill
label={t('tours.paddle.portageWay')}
onChange={(next) => (next === 'follow' ? way.onFollow() : way.onStraighten())}
options={[
{
value: 'follow',
label: t('tours.paddle.portageWay.follow'),
icon: way.searching ? <Loader2 size={12} className="animate-spin" aria-hidden="true" /> : undefined,
},
{ value: 'straight', label: t('tours.paddle.portageWay.straight') },
]}
/>
</fieldset>
{hint && (
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
{hint}
</span>
)}
</div>
);
}
/** /**
* A portage under the point it starts at, in the earth brown the map draws it * A portage under the point it starts at, in the earth brown the map draws it
* in: its length on land and the time for getting out, unloading and back in * in: its length on land and the time for getting out, unloading and back in
@@ -305,7 +211,6 @@ export function PortageLegRow({
onExtraChange, onExtraChange,
position, position,
onRemove, onRemove,
way,
}: { }: {
lengthM: number; lengthM: number;
extraMinutes: number | null; extraMinutes: number | null;
@@ -315,8 +220,6 @@ export function PortageLegRow({
position?: string; position?: string;
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */ /** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
onRemove?: () => void; onRemove?: () => void;
/** On a planned tour: whether the portage follows the ways on land or goes straight, and the switch between them. */
way?: PortageWay;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const unit = useSettingsStore((state) => state.settings.distance_unit); const unit = useSettingsStore((state) => state.settings.distance_unit);
@@ -339,7 +242,6 @@ export function PortageLegRow({
{t('tours.paddle.portageInfo')} {t('tours.paddle.portageInfo')}
</p> </p>
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />} {onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
{way && <PortageWayField way={way} disabled={disabled} />}
<StopField <StopField
label={t('tours.paddle.portageExtra')} label={t('tours.paddle.portageExtra')}
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined} hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
@@ -1,4 +1,4 @@
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-018: the wind card of a paddle tour (PAD-08) and the section that feeds it. // FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in. // FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -340,30 +340,6 @@ describe('PaddleWindSection', () => {
}); });
}); });
describe('PaddleWindCard on the lakes (PAD-11)', () => {
it('FE-PADDLE-WIND-017: on a windy day offers to keep near the shore, and says so once the shore is chosen', () => {
const onChoose = vi.fn();
const windy = windOf(ready(forecastOf({ hours: [hourOf(12, 22, 34, 270)] })));
const { rerender } = render(<PaddleWindCard wind={windy} lakes={{ value: 'direct', onChoose }} />);
const advice = screen.getByTestId('paddle-wind-shore-advice');
expect(advice).toHaveTextContent('Up to 22 km/h, gusts 34 km/h on this day.');
fireEvent.click(within(advice).getByRole('button', { name: 'Plan near the shore' }));
expect(onChoose).toHaveBeenCalledWith('shore');
rerender(<PaddleWindCard wind={windy} lakes={{ value: 'shore', onChoose }} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
expect(screen.getByText('The lakes are planned near the shore.')).toBeInTheDocument();
});
it('FE-PADDLE-WIND-018: says nothing of the shore on a calm day, nor where no lake is crossed', () => {
const calm = windOf(ready(forecastOf({ hours: [hourOf(12, 8, 14, 270)] })));
const { rerender } = render(<PaddleWindCard wind={calm} lakes={{ value: 'direct', onChoose: vi.fn() }} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ hours: [hourOf(12, 30, 50, 270)] })))} />);
expect(screen.queryByTestId('paddle-wind-shore-advice')).not.toBeInTheDocument();
});
});
describe('FigureBadge', () => { describe('FigureBadge', () => {
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => { it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />); const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
@@ -1,4 +1,4 @@
import { compassPointOf, type PaddleLakeStyle, type WindForecastResponse, type WindHour } from '@trek/shared'; import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react'; import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
@@ -8,7 +8,6 @@ import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { RailCard, TourNotice } from '../tourParts'; import { RailCard, TourNotice } from '../tourParts';
import { FigureBadge } from './FigureBadge'; import { FigureBadge } from './FigureBadge';
import { windForShore } from './lakeStyleAdvice';
import { useTourWind, type TourWind } from './useTourWind'; import { useTourWind, type TourWind } from './useTourWind';
type Translate = (key: string, params?: Record<string, unknown>) => string; type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -210,58 +209,15 @@ function WindHints({ t }: { t: Translate }) {
); );
} }
/** The lake style of the tour being edited, for the wind card to suggest the shore on a windy day. */
export interface LakeStyleChoice {
value: PaddleLakeStyle;
onChoose: (style: PaddleLakeStyle) => void;
}
/**
* PAD-11: on a windy day, the offer to keep near the shores on the lakes,
* with the wind that prompts it; once taken, it says the lakes are planned so.
*/
function ShoreAdvice({
wind,
lakes,
t,
}: {
wind: { meanKmh: number; gustsKmh: number };
lakes: LakeStyleChoice;
t: Translate;
}) {
if (lakes.value === 'shore') {
return (
<TourNotice role="status" icon={<Wind size={13} strokeWidth={2} />}>
{t('tours.paddle.wind.shoreTaken')}
</TourNotice>
);
}
return (
<TourNotice tone="warning" icon={<Wind size={13} strokeWidth={2} />}>
<span data-testid="paddle-wind-shore-advice" className="flex flex-wrap items-center gap-x-2 gap-y-1">
{t('tours.paddle.wind.shoreAdvice', { mean: wind.meanKmh, gusts: wind.gustsKmh })}
<button
type="button"
onClick={() => lakes.onChoose('shore')}
className="font-semibold underline-offset-2 hover:underline"
>
{t('tours.paddle.wind.shoreAdopt')}
</button>
</span>
</TourNotice>
);
}
/** /**
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The * PAD-08: the wind on the day a paddle tour is planned for, at its start. The
* mean, the gusts and the direction are figures of their own, with the source * mean, the gusts and the direction are figures of their own, with the source
* and when it was asked; no value is turned into a verdict, and missing data * and when it was asked; no value is turned into a verdict, and missing data
* is never shown as calm. * is never shown as calm.
*/ */
export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeStyleChoice }) { export function PaddleWindCard({ wind }: { wind: TourWind }) {
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null; const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
const shoreWind = ready && lakes ? windForShore(ready) : null;
return ( return (
<RailCard <RailCard
icon={<Wind size={14} strokeWidth={2} />} icon={<Wind size={14} strokeWidth={2} />}
@@ -293,7 +249,6 @@ export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeSt
) : ( ) : (
<Unavailable wind={wind} t={t} locale={locale} /> <Unavailable wind={wind} t={t} locale={locale} />
)} )}
{shoreWind && lakes && <ShoreAdvice wind={shoreWind} lakes={lakes} t={t} />}
<WindHints t={t} /> <WindHints t={t} />
</div> </div>
</RailCard> </RailCard>
@@ -304,13 +259,10 @@ export function PaddleWindCard({ wind, lakes }: { wind: TourWind; lakes?: LakeSt
export function PaddleWindSection({ export function PaddleWindSection({
placeId, placeId,
start, start,
lakes,
}: { }: {
placeId: number | null; placeId: number | null;
start: { lat: number; lng: number } | null; start: { lat: number; lng: number } | null;
/** Given while the tour is edited and its route crosses a lake: the card may suggest the shore. */
lakes?: LakeStyleChoice;
}) { }) {
const wind = useTourWind(placeId, start); const wind = useTourWind(placeId, start);
return <PaddleWindCard wind={wind} lakes={lakes} />; return <PaddleWindCard wind={wind} />;
} }
@@ -1,51 +0,0 @@
// FE-PADDLE-LAKE-ADVICE-001 to FE-PADDLE-LAKE-ADVICE-002: when the wind makes the shore worth suggesting (PAD-11 with PAD-08).
import type { WindForecastResponse, WindHour } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { crossesLake, windForShore } from './lakeStyleAdvice';
function forecast(hours: Array<Partial<WindHour> & { hour: number }>): WindForecastResponse {
return {
status: 'forecast',
date: '2026-06-01',
lat: 53.4,
lng: 12.7,
timezone: 'Europe/Berlin',
source: 'Open-Meteo',
fetched_at: '2026-05-30T08:00:00Z',
horizon_days: 16,
day: null,
hours: hours.map((hour) => ({ speed_kmh: null, gusts_kmh: null, direction_deg: null, ...hour })),
} as WindForecastResponse;
}
describe('lakeStyleAdvice', () => {
it('FE-PADDLE-LAKE-ADVICE-001: a fresh breeze or strong gusts in the paddling hours suggest the shore; a stormy night does not', () => {
expect(windForShore(forecast([{ hour: 10, speed_kmh: 12, gusts_kmh: 22 }]))).toBeNull();
expect(windForShore(forecast([{ hour: 14, speed_kmh: 21.4, gusts_kmh: 26 }]))).toEqual({
meanKmh: 21,
gustsKmh: 26,
});
expect(windForShore(forecast([{ hour: 14, speed_kmh: 15, gusts_kmh: 31 }]))).toEqual({ meanKmh: 15, gustsKmh: 31 });
expect(windForShore(forecast([{ hour: 3, speed_kmh: 40, gusts_kmh: 70 }]))).toBeNull();
// An hour without a value is no calm hour, but it is no wind either.
expect(windForShore(forecast([{ hour: 12 }]))).toBeNull();
});
it('FE-PADDLE-LAKE-ADVICE-002: only a route that crosses a lake makes the lake style matter', () => {
const lake = { kind: 'lake' as const, from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null };
const leg = (kind: 'lake' | 'water') => ({
status: 'ok' as const,
geometry: [
[53.4, 12.7],
[53.5, 12.7],
] as [number, number][],
segments: [{ ...lake, kind }],
distance_m: 1000,
snapped_m: { from: 0, to: 0 },
});
expect(crossesLake(null)).toBe(false);
expect(crossesLake({ legs: [leg('water'), null] })).toBe(false);
expect(crossesLake({ legs: [leg('water'), leg('lake')] })).toBe(true);
expect(crossesLake({ legs: [{ status: 'failed', code: 'offline', at: null }] })).toBe(false);
});
});
@@ -1,32 +0,0 @@
import type { WindForecastResponse } from '@trek/shared';
import type { PaddleTourRoute } from '../planner/paddleRouting';
/** The hours a day tour is out on the water, as the wind strip shows them. */
const FIRST_HOUR = 6;
const LAST_HOUR = 21;
/**
* Wind from which open water gets rough for a small boat: a moderate breeze
* (Beaufort 4 from 20 km/h) or gusts of 30 km/h, where a crossing turns into
* work and a capsize far from the bank into a long swim.
*/
const MEAN_KMH = 20;
const GUSTS_KMH = 30;
/** Whether the route as planned crosses a lake, so its lake style changes anything. */
export function crossesLake(route: Pick<PaddleTourRoute, 'legs'> | null): boolean {
return !!route?.legs.some((leg) => leg?.status === 'ok' && leg.segments.some((segment) => segment.kind === 'lake'));
}
/**
* PAD-11 with PAD-08: the strongest wind of the paddling hours when it is
* enough to keep near the shores on the lakes, else null. Lakes are crossed
* straight over as a rule and along the shore only in wind; this says when.
*/
export function windForShore(forecast: WindForecastResponse): { meanKmh: number; gustsKmh: number } | null {
const hours = forecast.hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
const meanKmh = Math.max(0, ...hours.map((hour) => hour.speed_kmh ?? 0));
const gustsKmh = Math.max(0, ...hours.map((hour) => hour.gusts_kmh ?? 0));
if (meanKmh < MEAN_KMH && gustsKmh < GUSTS_KMH) return null;
return { meanKmh: Math.round(meanKmh), gustsKmh: Math.round(gustsKmh) };
}
@@ -34,8 +34,6 @@ const PAINT: PaddlePaint = {
access: '#4338ca', access: '#4338ca',
landing: '#0f766e', landing: '#0f766e',
portage: '#9a6233', portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff', disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)', shadow: 'rgba(15, 23, 42, 0.35)',
}; };
@@ -1,23 +0,0 @@
import type { PaddleGeometry } from '@trek/shared';
/**
* The spot a waypoint goes to: the place itself, the middle of a way (a
* slipway drawn as a ramp), or the middle of an area's outline.
*/
export function placeOf(geometry: PaddleGeometry): { lat: number; lng: number } | null {
const line =
geometry.type === 'Point'
? [geometry.coordinates]
: geometry.type === 'LineString'
? geometry.coordinates
: geometry.coordinates[0];
if (!line?.length) return null;
if (geometry.type === 'Polygon') {
const ring = line.slice(0, -1);
const lng = ring.reduce((sum, [x]) => sum + x, 0) / ring.length;
const lat = ring.reduce((sum, [, y]) => sum + y, 0) / ring.length;
return { lat, lng };
}
const [lng, lat] = line[Math.floor((line.length - 1) / 2)] ?? [];
return lat === undefined || lng === undefined ? null : { lat, lng };
}
@@ -12,8 +12,6 @@ const PADDLE_PAINT_TOKENS = {
access: '--map-paddle-access', access: '--map-paddle-access',
landing: '--map-paddle-landing', landing: '--map-paddle-landing',
portage: '--map-portage', portage: '--map-portage',
rapids: '--map-paddle-rapids',
gap: '--map-paddle-gap',
disc: '--map-paddle-disc', disc: '--map-paddle-disc',
shadow: '--map-paddle-shadow', shadow: '--map-paddle-shadow',
} as const; } as const;
@@ -1,35 +0,0 @@
import type { PaddleRegionLoadState } from '@trek/shared';
import type { useTranslation } from '../../../i18n';
type Translate = ReturnType<typeof useTranslation>['t'];
/** The states in which the data is on its way: the leg waits rather than fails. */
export function isRegionLoadPending(
state: PaddleRegionLoadState | undefined
): state is Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }> {
return state?.state === 'loading' || state?.state === 'ready' || state?.state === 'requested';
}
/**
* Why a leg stays unplanned once the data cannot come: declined, failed, no
* water near the place, nothing to load. Null where the leg's own reason says
* it best (loading is off, or nothing was asked yet).
*/
export function regionLoadFailure(state: PaddleRegionLoadState | undefined, t: Translate): string | null {
if (!state) return null;
const region = state.region?.name ?? '';
switch (state.state) {
case 'declined':
return t('tours.paddle.load.declined', { region });
case 'failed':
return t('tours.paddle.load.failed', { region });
case 'no_water':
return t('tours.paddle.load.noWater', { region });
case 'unavailable':
if (state.reason === 'no_extract') return t('tours.paddle.load.noExtract');
if (state.reason === 'catalog_unavailable') return t('tours.paddle.load.catalogUnavailable');
return null;
default:
return null;
}
}
@@ -1,54 +0,0 @@
// FE-PORTAGE-ROUTE-001 to 004: a portage laid on the ways on land, never on a detour (PAD-03).
import { beforeEach, describe, expect, it, vi } from 'vitest';
const valhallaRun = vi.fn();
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun: (...args: unknown[]) => valhallaRun(...args) }));
const { routePortageLine } = await import('./portageRoute');
const FROM = { lat: 53.2232, lng: 12.9668 };
const TO = { lat: 53.2229, lng: 12.9701 };
function answer(coordinates: [number, number][]) {
return { legs: [], snapped: [], total: { coordinates, distance: 0, duration: 0 } };
}
describe('routePortageLine', () => {
beforeEach(() => valhallaRun.mockReset());
it('FE-PORTAGE-ROUTE-001: asks the walking router on easy ground and starts and ends at the points themselves', async () => {
valhallaRun.mockResolvedValue(
answer([
[53.2233, 12.9672],
[53.2226, 12.9686],
[53.223, 12.9698],
])
);
const line = await routePortageLine(FROM, TO);
expect(valhallaRun).toHaveBeenCalledWith([FROM, TO], 'walking', [], undefined, { max_hiking_difficulty: 1 });
expect(line?.[0]).toEqual([FROM.lat, FROM.lng]);
expect(line?.[line.length - 1]).toEqual([TO.lat, TO.lng]);
expect(line).toHaveLength(5);
});
it('FE-PORTAGE-ROUTE-002: keeps the carry straight when the way is a long detour', async () => {
valhallaRun.mockResolvedValue(
answer([
[53.2233, 12.9672],
[53.24, 12.968],
[53.223, 12.9698],
])
);
expect(await routePortageLine(FROM, TO)).toBeNull();
});
it('FE-PORTAGE-ROUTE-003: keeps the carry straight when no router answers', async () => {
valhallaRun.mockResolvedValue(null);
expect(await routePortageLine(FROM, TO)).toBeNull();
});
it('FE-PORTAGE-ROUTE-004: takes no line of a single point', async () => {
valhallaRun.mockResolvedValue(answer([[53.2233, 12.9672]]));
expect(await routePortageLine(FROM, TO)).toBeNull();
});
});
@@ -1,36 +0,0 @@
import { haversineKm } from '../../../utils/geo';
import { valhallaRun } from '../../Map/valhallaRoute';
import { lineLengthM } from '../planner/tourFixedLegs';
type Point = { lat: number; lng: number };
/**
* How much longer than the straight line a carry along the ways may be before
* it counts as a detour: a way round a fenced lock is fine, a walk round the
* whole lake is not, and then the straight line says more.
*/
const DETOUR = { factor: 2.5, extraM: 300 } as const;
/** Paths a paddler carries a boat on: plain hiking ground, never a scramble. */
const CARRY_DIFFICULTY = 1;
/**
* PAD-03: the way a portage takes on land, from where the boat leaves the
* water to where it goes in again, on the ways a walker would take (the
* instance's Valhalla, the router the hikes use). The line starts and ends at
* the two points themselves, so the steps from the bank onto the path are
* part of it. Null when no router answers, or when the way is a detour.
*/
export async function routePortageLine(
from: Point,
to: Point,
signal?: AbortSignal
): Promise<[number, number][] | null> {
// The router answers null, never throws, when it cannot be reached; the carry then stays straight.
const run = await valhallaRun([from, to], 'walking', [], signal, { max_hiking_difficulty: CARRY_DIFFICULTY });
if (!run || run.total.coordinates.length < 2) return null;
const line: [number, number][] = [[from.lat, from.lng], ...run.total.coordinates, [to.lat, to.lng]];
const straightM = haversineKm(from, to) * 1000;
if (lineLengthM(line) > Math.max(straightM * DETOUR.factor, straightM + DETOUR.extraM)) return null;
return line;
}
@@ -37,8 +37,7 @@ import {
selectPaddleObstacle, selectPaddleObstacle,
usePaddleObstacleStore, usePaddleObstacleStore,
} from '../../../store/paddleObstacleStore'; } from '../../../store/paddleObstacleStore';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints'; import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { usePaddleChecklist } from './usePaddleChecklist'; import { usePaddleChecklist } from './usePaddleChecklist';
type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>; type CheckRead = Awaited<ReturnType<typeof tourRepo.obstacleCheck>>;
@@ -34,8 +34,6 @@ const PAINT: PaddlePaint = {
access: '#4338ca', access: '#4338ca',
landing: '#0f766e', landing: '#0f766e',
portage: '#9a6233', portage: '#9a6233',
rapids: '#d97706',
gap: '#dc2626',
disc: '#ffffff', disc: '#ffffff',
shadow: 'rgba(15, 23, 42, 0.35)', shadow: 'rgba(15, 23, 42, 0.35)',
}; };
@@ -1,86 +0,0 @@
// FE-PADDLE-REGION-LOAD-001 to 005: the planner fetches the water data of a place no region covers (PAD-11C).
import { act, renderHook, waitFor } from '@testing-library/react';
import type { PaddleRegionLoadState } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const load = vi.fn();
vi.mock('../../../repo/paddleRegionLoadRepo', () => ({
paddleRegionLoadRepo: { load: (...args: unknown[]) => load(...args) },
}));
const { usePaddleRegionLoad } = await import('./usePaddleRegionLoad');
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
const AT = { lat: 49.0123456, lng: 12.0987654 };
const failures = [
{ index: 0, code: 'no_region' as const, at: AT },
{ index: 1, code: 'no_region' as const, at: AT },
{ index: 2, code: 'no_water_path' as const, at: null },
];
const loading = (fraction: number): PaddleRegionLoadState => ({
state: 'loading',
region: REF,
progress: { phase: 'download', fraction },
ahead: 0,
});
describe('usePaddleRegionLoad', () => {
beforeEach(() => load.mockReset());
afterEach(() => vi.useRealTimers());
it('FE-PADDLE-REGION-LOAD-001: asks once per place, and shows its state on every leg ending there', async () => {
load.mockResolvedValue([loading(0.4)]);
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
await waitFor(() => expect(result.current.byLeg.get(0)).toEqual(loading(0.4)));
expect(load).toHaveBeenCalledWith([{ lat: 49.01235, lng: 12.09877 }], false, expect.any(AbortSignal));
expect(result.current.byLeg.get(1)).toEqual(loading(0.4));
expect(result.current.byLeg.has(2)).toBe(false);
});
it('FE-PADDLE-REGION-LOAD-002: follows a load and plans again once, when the data is there', async () => {
vi.useFakeTimers();
load.mockResolvedValueOnce([loading(0.2)]).mockResolvedValue([{ state: 'ready', region: REF }]);
const onReady = vi.fn();
const { rerender } = renderHook(() => usePaddleRegionLoad(failures, onReady));
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(onReady).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});
expect(load).toHaveBeenCalledTimes(2);
expect(onReady).toHaveBeenCalledTimes(1);
rerender();
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(onReady).toHaveBeenCalledTimes(1);
});
it('FE-PADDLE-REGION-LOAD-003: asks for nothing without a leg that lacks the data', async () => {
renderHook(() => usePaddleRegionLoad([{ index: 0, code: 'not_on_water', at: AT }], vi.fn()));
renderHook(() => usePaddleRegionLoad(null, vi.fn()));
await Promise.resolve();
expect(load).not.toHaveBeenCalled();
});
it('FE-PADDLE-REGION-LOAD-004: reads a failed region again on request', async () => {
load.mockResolvedValue([{ state: 'failed', region: REF }]);
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
await waitFor(() => expect(result.current.byLeg.get(0)?.state).toBe('failed'));
act(() => result.current.retry());
await waitFor(() => expect(load).toHaveBeenLastCalledWith(expect.any(Array), true, expect.any(AbortSignal)));
});
it('FE-PADDLE-REGION-LOAD-005: keeps asking after a failed look, more slowly', async () => {
vi.useFakeTimers();
load.mockRejectedValueOnce(new Error('offline')).mockResolvedValue([{ state: 'declined', region: REF }]);
const { result } = renderHook(() => usePaddleRegionLoad(failures, vi.fn()));
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(load).toHaveBeenCalledTimes(2);
expect(result.current.byLeg.get(0)?.state).toBe('declined');
});
});
@@ -1,108 +0,0 @@
import { MAX_PADDLE_LOAD_POINTS, type PaddleRegionLoadState, type PaddleRoutePoint } from '@trek/shared';
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { paddleRegionLoadRepo } from '../../../repo/paddleRegionLoadRepo';
import type { PaddleTourRoute } from '../planner/paddleRouting';
/** How soon the planner asks again: often while a region loads, now and then while it waits for an admin. */
const POLL_MS = { loading: 2_000, requested: 30_000, error: 10_000 } as const;
type Failure = PaddleTourRoute['failures'][number];
/** A place as the key it is asked and remembered by; five decimals are about a metre. */
function keyOf(point: PaddleRoutePoint): string {
return `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`;
}
function placeOf(key: string): PaddleRoutePoint {
const [lat, lng] = key.split(',').map(Number);
return { lat, lng };
}
/** The places the planner asks about: where its legs ran out of water data, each once. */
function placeKeysOf(failures: readonly Failure[] | null | undefined): string {
const keys = new Set<string>();
for (const failure of failures ?? []) {
if (failure.code === 'no_region' && failure.at) keys.add(keyOf(failure.at));
}
return [...keys].slice(0, MAX_PADDLE_LOAD_POINTS).join('|');
}
function nextPoll(states: readonly PaddleRegionLoadState[]): number | null {
if (states.some((state) => state.state === 'loading')) return POLL_MS.loading;
if (states.some((state) => state.state === 'requested')) return POLL_MS.requested;
return null;
}
/**
* PAD-11C: where a paddle leg failed for want of water data, the planner
* asks the server for it right away; the server loads the region around the
* place or asks an admin for it, as the instance is set up. While it loads
* the leg shows how far it is, and once it is there the route is planned
* again on its own. `onReady` plans again; it is called once per place.
*/
export function usePaddleRegionLoad(failures: readonly Failure[] | null | undefined, onReady: () => void) {
const placesKey = placeKeysOf(failures);
const places = useMemo(() => (placesKey ? placesKey.split('|').map(placeOf) : []), [placesKey]);
const [states, setStates] = useState<ReadonlyMap<string, PaddleRegionLoadState>>(new Map());
const [retryToken, setRetryToken] = useState(0);
const readied = useRef(new Set<string>());
const retrying = useRef(false);
/** The answers taken in; the route is planned again where a place's data has just arrived. */
const take = useEffectEvent((answers: readonly PaddleRegionLoadState[]) => {
setStates(new Map(places.flatMap((place, index) => (answers[index] ? [[keyOf(place), answers[index]]] : []))));
const fresh = places.filter((place, index) => {
const key = keyOf(place);
if (answers[index]?.state !== 'ready' || readied.current.has(key)) return false;
readied.current.add(key);
return true;
});
if (fresh.length > 0) onReady();
});
useEffect(() => {
if (places.length === 0) return undefined;
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const ask = async () => {
const retry = retrying.current;
retrying.current = false;
let answers: PaddleRegionLoadState[] | null;
try {
answers = await paddleRegionLoadRepo.load(places, retry, abort.signal);
} catch {
// The failure row keeps saying why the leg is unplanned; a later look may get through.
if (!abort.signal.aborted) timer = setTimeout(() => void ask(), POLL_MS.error);
return;
}
if (abort.signal.aborted || !answers) return;
take(answers);
const wait = nextPoll(answers);
if (wait !== null) timer = setTimeout(() => void ask(), wait);
};
void ask();
return () => {
abort.abort();
clearTimeout(timer);
};
}, [places, retryToken]);
/** Reads a region again whose last read failed. */
const retry = useCallback(() => {
retrying.current = true;
setRetryToken((token) => token + 1);
}, []);
/** The state of each failed leg's place, by the leg's index. */
const byLeg = useMemo(() => {
const result = new Map<number, PaddleRegionLoadState>();
for (const failure of failures ?? []) {
if (failure.code !== 'no_region' || !failure.at) continue;
const state = states.get(keyOf(failure.at));
if (state) result.set(failure.index, state);
}
return result;
}, [failures, states]);
return { byLeg, retry };
}
@@ -1,90 +0,0 @@
// FE-PORTAGE-ROUTING-001 to 005: portages set in the planner follow the ways on land (PAD-03).
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import { portageLegBetween } from '../planner/tourRouteActions';
const routePortageLine = vi.fn();
vi.mock('./portageRoute', () => ({ routePortageLine: (...args: unknown[]) => routePortageLine(...args) }));
const { usePortageRouting } = await import('./usePortageRouting');
const A: TourPlannerWaypoint = { id: 'a', lat: 53.2232, lng: 12.9668, role: 'start' };
const B: TourPlannerWaypoint = { id: 'b', lat: 53.2229, lng: 12.9701, role: 'end' };
const WAY: [number, number][] = [
[A.lat, A.lng],
[53.2226, 12.9686],
[B.lat, B.lng],
];
function plannerWith(waypoints: TourPlannerWaypoint[], hasUnsavedChanges: boolean) {
const planner = {
waypoints,
isPaddle: true,
hasUnsavedChanges,
amendWaypoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
planner.waypoints = change(planner.waypoints);
}),
editPaddlePoints: vi.fn((change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => {
planner.waypoints = change(planner.waypoints);
}),
};
return planner;
}
const carried = (): TourPlannerWaypoint[] => [{ ...A, fixedLeg: portageLegBetween(A, B) }, B];
describe('usePortageRouting', () => {
beforeEach(() => routePortageLine.mockReset());
it('FE-PORTAGE-ROUTING-001: lays a portage the paddler just set on the way the router found', async () => {
routePortageLine.mockResolvedValue(WAY);
const planner = plannerWith(carried(), true);
renderHook(() => usePortageRouting(planner, true));
await waitFor(() => expect(planner.amendWaypoints).toHaveBeenCalled());
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
});
it('FE-PORTAGE-ROUTING-002: leaves a tour as saved alone, and asks nobody while it cannot be edited', async () => {
const saved = plannerWith(carried(), false);
renderHook(() => usePortageRouting(saved, true));
const locked = plannerWith(carried(), true);
renderHook(() => usePortageRouting(locked, false));
await Promise.resolve();
expect(routePortageLine).not.toHaveBeenCalled();
});
it('FE-PORTAGE-ROUTING-003: keeps a carry straight when the router found no way, and says so', async () => {
routePortageLine.mockResolvedValue(null);
const planner = plannerWith(carried(), true);
const { result } = renderHook(() => usePortageRouting(planner, true));
await waitFor(() => expect(routePortageLine).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.noWay(['a'])).toBe(true));
expect(planner.waypoints[0].fixedLeg?.geometry).toHaveLength(2);
});
it('FE-PORTAGE-ROUTING-004: lays a saved straight portage on the way when asked to', async () => {
routePortageLine.mockResolvedValue(WAY);
const planner = plannerWith(carried(), false);
const { result } = renderHook(() => usePortageRouting(planner, true));
let found = false;
await act(async () => {
found = await result.current.follow(['a']);
});
expect(found).toBe(true);
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual(WAY);
});
it('FE-PORTAGE-ROUTING-005: draws a portage straight on request and keeps it so', async () => {
const planner = plannerWith([{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry: WAY } }, B], true);
const { result, rerender } = renderHook(() => usePortageRouting(planner, true));
act(() => result.current.straighten(['a']));
expect(planner.waypoints[0].fixedLeg?.geometry).toEqual([
[A.lat, A.lng],
[B.lat, B.lng],
]);
rerender();
await Promise.resolve();
expect(routePortageLine).not.toHaveBeenCalled();
});
});
@@ -1,194 +0,0 @@
import type { TourFixedLeg } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TourPlannerController } from '../planner/useTourPlanner';
import type { PortageWay } from './PaddleWaypointParts';
import { routePortageLine } from './portageRoute';
type Planner = Pick<
TourPlannerController,
'waypoints' | 'isPaddle' | 'hasUnsavedChanges' | 'amendWaypoints' | 'editPaddlePoints'
>;
type Point = TourPlannerController['waypoints'][number];
type Line = [number, number][];
/** A portage leg by its two ends, to six decimals (about ten centimetres): the same carry asks once. */
function keyOf(from: Point, to: Point): string {
return `${from.lat.toFixed(6)},${from.lng.toFixed(6)}>${to.lat.toFixed(6)},${to.lng.toFixed(6)}`;
}
function isStraightPortage(leg: TourFixedLeg | null | undefined): boolean {
return leg?.kind === 'portage' && leg.geometry.length === 2;
}
/** The portages still drawn straight, with the point after each. */
function straightPortages(points: readonly Point[]) {
return points.flatMap((point, index) => {
const next = points[index + 1];
// A portage the water router found is its own: it already follows the mapped way.
return next && !point.auto && isStraightPortage(point.fixedLeg)
? [{ id: point.id, from: point, to: next, key: keyOf(point, next) }]
: [];
});
}
/**
* PAD-03: a portage follows the ways on land instead of cutting straight
* across them. A portage the paddler sets, or whose ends they move, is asked
* of the router the hikes use and takes its line, unless that line is a
* detour; then it stays straight. A tour opened as saved keeps its straight
* portages until the paddler asks for the way (`follow`), and one they set
* straight on purpose (`straighten`) stays so.
*/
export function usePortageRouting(planner: Planner, editable: boolean) {
const { waypoints, isPaddle, hasUnsavedChanges, amendWaypoints, editPaddlePoints } = planner;
/** Carries kept straight: as the tour was saved, or as the paddler set them. */
const kept = useRef(new Set<string>());
/** What the router answered per carry; null when it found no way worth taking. */
const lines = useRef(new Map<string, Line | null>());
const layIn = useCallback(() => {
amendWaypoints((current) => {
let changed = false;
const next = current.map((point, index) => {
const after = current[index + 1];
const leg = point.fixedLeg;
if (!after || point.auto || !leg || leg.kind !== 'portage' || leg.geometry.length !== 2) return point;
const key = keyOf(point, after);
const line = kept.current.has(key) ? null : lines.current.get(key);
if (!line) return point;
changed = true;
return { ...point, fixedLeg: { ...leg, geometry: line } };
});
return changed ? next : current;
});
}, [amendWaypoints]);
useEffect(() => {
if (!isPaddle || !editable) return undefined;
const legs = straightPortages(waypoints);
if (!hasUnsavedChanges) {
for (const leg of legs) kept.current.add(leg.key);
return undefined;
}
const wanted = legs.filter((leg) => !kept.current.has(leg.key));
if (wanted.some((leg) => lines.current.get(leg.key))) layIn();
const missing = wanted.filter((leg) => !lines.current.has(leg.key));
if (missing.length === 0) return undefined;
const abort = new AbortController();
// One after the other: the public router allows about one request a second.
void missing
.reduce(async (previous, leg) => {
await previous;
if (abort.signal.aborted) return;
const line = await routePortageLine(leg.from, leg.to, abort.signal);
if (!abort.signal.aborted) lines.current.set(leg.key, line);
}, Promise.resolve())
.then(() => {
if (!abort.signal.aborted) layIn();
});
return () => abort.abort();
}, [editable, hasUnsavedChanges, isPaddle, layIn, waypoints]);
/** The carries starting at these points, each with the point after it. */
const legsOf = useCallback(
(ids: readonly string[]) =>
waypoints.flatMap((point, index) => {
const next = waypoints[index + 1];
return ids.includes(point.id) && next ? [{ point, next, key: keyOf(point, next) }] : [];
}),
[waypoints]
);
/**
* A portage laid on the ways, after it was straight (as saved, or set so),
* leg by leg: true once one follows them, false when there is no way worth
* taking and it stays straight.
*/
const follow = useCallback(
async (ids: readonly string[]) => {
const legs = legsOf(ids);
const found = await legs.reduce(async (previous, { point, next, key }) => {
const any = await previous;
kept.current.delete(key);
const line = await routePortageLine(point, next);
lines.current.set(key, line);
return any || !!line;
}, Promise.resolve(false));
if (found) layIn();
return found;
},
[layIn, legsOf]
);
/** A portage drawn straight, and kept so. */
const straighten = useCallback(
(ids: readonly string[]) => {
for (const { key } of legsOf(ids)) kept.current.add(key);
editPaddlePoints((current) =>
current.map((point, index) => {
const next = current[index + 1];
const leg = point.fixedLeg;
if (!ids.includes(point.id) || !next || leg?.kind !== 'portage') return point;
return {
...point,
fixedLeg: {
...leg,
geometry: [
[point.lat, point.lng],
[next.lat, next.lng],
],
},
};
})
);
},
[editPaddlePoints, legsOf]
);
/** Whether the router found no way worth taking for every leg of a portage. */
const noWay = useCallback(
(ids: readonly string[]) => {
const legs = legsOf(ids);
return legs.length > 0 && legs.every(({ key }) => lines.current.get(key) === null);
},
[legsOf]
);
return { follow, straighten, noWay };
}
type PortageRouting = ReturnType<typeof usePortageRouting>;
/** The waypoints whose legs make up the portage starting at `index`: one leg, or several in a row. */
function portageRunIds(points: readonly Point[], index: number): string[] {
const ids: string[] = [];
for (let at = index; at < points.length - 1 && points[at]?.fixedLeg?.kind === 'portage'; at += 1) {
ids.push(points[at].id);
}
return ids;
}
/**
* PAD-03: the way of the portage starting at a waypoint, for its card:
* whether it follows the ways on land, why it stayed straight, and the switch
* between the two. Undefined where no portage starts.
*/
export function usePortageWay(
points: readonly Point[],
routing: PortageRouting,
index: number
): PortageWay | undefined {
const [searching, setSearching] = useState(false);
const ids = portageRunIds(points, index);
if (ids.length === 0) return undefined;
return {
routed: ids.every((id) => (points.find((point) => point.id === id)?.fixedLeg?.geometry.length ?? 0) > 2),
noWay: routing.noWay(ids),
searching,
onFollow: () => {
setSearching(true);
void routing.follow(ids).finally(() => setSearching(false));
},
onStraighten: () => routing.straighten(ids),
};
}
@@ -926,24 +926,6 @@ describe('the paddle checklist in the rails (PAD-06)', () => {
expect(safetyRow()).toBeInTheDocument() expect(safetyRow()).toBeInTheDocument()
}) })
it('PCR-006: legs the water router could not plan are counted in the head, without the retry the rows already offer', () => {
const failed = paddleEditor({
status: 'routing-failed',
error: 'paddle-route',
paddleRoute: { coordinates: [], parts: [], distanceMeters: 0, legs: [], legLengths: [], failures: [
{ index: 0, code: 'not_on_water', at: null },
{ index: 1, code: 'busy', at: null },
] },
})
const { rerender } = render(<TourPlannerRail planner={failed} />)
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('2 legs not planned')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
rerender(<TourPlannerRail planner={planner({ mode: { type: 'new-draft' }, status: 'routing-failed', error: 'routing' })} />)
expect(within(screen.getByTestId('tour-planner-status')).getByLabelText('Route calculation failed. The last good route is still shown.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
})
it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => { it('PCR-005: a recorded kayak track scrolls its checklist before the wind and keeps the safety row in a footer; a recorded hike neither', () => {
const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />) const { rerender } = render(<TourPlannerRail planner={recorded(kayak)} paddleChecklist={checklist} />)
@@ -19,7 +19,6 @@ import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts' import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection } from '../tourParts'
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts' import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields' import { TourDetailsSection, TourNameField } from './TourEditorFields'
import { crossesLake } from '../paddle/lakeStyleAdvice'
import { PaddleCheckCard } from '../paddle/PaddleCheckCard' import { PaddleCheckCard } from '../paddle/PaddleCheckCard'
import { PaddleSafetyRow } from '../paddle/PaddleSafety' import { PaddleSafetyRow } from '../paddle/PaddleSafety'
import { PaddleTimeCard } from '../paddle/PaddleTimeCard' import { PaddleTimeCard } from '../paddle/PaddleTimeCard'
@@ -122,18 +121,6 @@ function StatusDot({ label, tone, children }: { label: string; tone: 'neutral' |
) )
} }
/** Whether the head offers to try again; a paddle leg that failed has its own actions on its row (PAD-11). */
function retryable(planner: TourPlannerController): boolean {
if (planner.error === 'paddle-route') return false
return planner.status === 'routing-failed' || planner.status === 'elevation-failed'
}
/** The status in words; a paddle tour with legs the router could not plan says how many (PAD-11). */
function statusText(planner: TourPlannerController, key: string, t: Translate): string {
if (planner.error === 'paddle-route') return t('tours.paddle.status.unrouted', { count: planner.paddleRoute?.failures.length ?? 1 })
return t(key)
}
/** /**
* What the editor is doing right now, as icon badges beside the head's title: * What the editor is doing right now, as icon badges beside the head's title:
* a restored draft, routing or saving, and a failure with its retry. Nothing * a restored draft, routing or saving, and a failure with its retry. Nothing
@@ -145,10 +132,10 @@ function RailStatusBadges({ planner }: { planner: TourPlannerController }) {
const restored = planner.draftRestored && planner.hasUnsavedChanges const restored = planner.draftRestored && planner.hasUnsavedChanges
const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0) const onFerrata = useTourFerrataCrossingStore(state => state.crossings.length > 0)
const failed = planner.error === 'save' const failed = planner.error === 'save'
const canRetry = retryable(planner) const canRetry = planner.status === 'routing-failed' || planner.status === 'elevation-failed'
if (!restored && !statusKey && !failed && !onFerrata) return null if (!restored && !statusKey && !failed && !onFerrata) return null
const warning = planner.error != null || canRetry const warning = planner.error != null || canRetry
const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? statusText(planner, statusKey, t) : null const statusLabel = failed ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null
const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation' const busy = planner.isSaving || planner.status === 'routing' || planner.status === 'enriching-elevation'
return ( return (
<span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1"> <span role="status" data-testid="tour-planner-status" className="ms-1.5 flex flex-none items-center gap-1">
@@ -339,11 +326,7 @@ function PaddleEditorCards({ planner, locked, checklist }: {
<> <>
{checklist && <PaddleCheckCard checklist={checklist} />} {checklist && <PaddleCheckCard checklist={checklist} />}
<PaddleTimeCard planner={planner} locked={locked} /> <PaddleTimeCard planner={planner} locked={locked} />
<PaddleWindSection <PaddleWindSection placeId={planner.editingPlaceId} start={planner.waypoints[0] ?? null} />
placeId={planner.editingPlaceId}
start={planner.waypoints[0] ?? null}
lakes={!locked && crossesLake(planner.paddleRoute) ? { value: planner.paddle.lakeStyle, onChoose: planner.paddle.setLakeStyle } : undefined}
/>
</> </>
) )
} }
@@ -75,33 +75,3 @@ describe('TourTypeFields (PAD-01)', () => {
expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument(); expect(screen.getByText(/enter a speed|speed of your own/i)).toBeInTheDocument();
}); });
}); });
describe('TourTypeFields lake style (PAD-11)', () => {
it('asks how to cross lakes on lake water or a route over a lake, straight over by default, and hands a change on', () => {
const river = planner({}, { waterContext: 'river' });
const { rerender } = render(<TourTypeFields planner={river} locked={false} />);
expect(screen.queryByTestId('tour-planner-lake-style')).not.toBeInTheDocument();
const lake = planner({}, { waterContext: 'lake' });
rerender(<TourTypeFields planner={lake} locked={false} />);
expect(screen.getByRole('button', { name: 'Straight over' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('The shortest line across open water.')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Near the shore' }));
expect(lake.paddle.setLakeStyle).toHaveBeenCalledWith('shore');
const lakeLeg = {
status: 'ok',
geometry: [
[53.4, 12.7],
[53.5, 12.7],
],
segments: [{ kind: 'lake', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: null }],
distance_m: 11000,
snapped_m: { from: 0, to: 0 },
};
const crossing = planner({ paddleRoute: { legs: [lakeLeg] } }, { waterContext: 'river', lakeStyle: 'shore' });
rerender(<TourTypeFields planner={crossing} locked={false} />);
expect(screen.getByRole('button', { name: 'Near the shore' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('Stays near the bank, for windy days: longer, but land is never far.')).toBeInTheDocument();
});
});
@@ -10,8 +10,6 @@ import { useTranslation } from '../../../i18n';
import { SoftPill } from '../../Planner/planParts'; import { SoftPill } from '../../Planner/planParts';
import { INPUT, LABEL, Segmented } from '../../shared/dialogParts'; import { INPUT, LABEL, Segmented } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import { crossesLake } from '../paddle/lakeStyleAdvice';
import { PaddleLakeStyleField } from '../paddle/PaddleLakeStyleField';
import { TourNotice } from '../tourParts'; import { TourNotice } from '../tourParts';
import type { TourPlannerController } from './useTourPlanner'; import type { TourPlannerController } from './useTourPlanner';
@@ -128,9 +126,6 @@ export function TourTypeFields({ planner, locked }: TourTypeFieldsProps) {
options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))} options={WATERS.map((water) => ({ value: water, label: t(`tours.paddle.water.${water}`) }))}
/> />
</div> </div>
{(paddle.waterContext === 'lake' || crossesLake(planner.paddleRoute)) && (
<PaddleLakeStyleField value={paddle.lakeStyle} onChange={paddle.setLakeStyle} />
)}
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5"> <div className="mb-[5px] flex min-h-5 min-w-0 items-center gap-1.5">
<label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed"> <label className={`${LABEL} !mb-0 min-w-0 truncate leading-5`} htmlFor="tour-planner-paddle-speed">
@@ -1,174 +0,0 @@
// FE-TOUR-WAYPOINT-LIST-001 to FE-TOUR-WAYPOINT-LIST-006: shaping points and the water router's answers in the list (P2A-03, P2A-05, PAD-11).
import userEvent from '@testing-library/user-event';
import type { PaddleRouteLeg } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, within } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
import { useSettingsStore } from '../../../store/settingsStore';
import type { PaddleTourRoute } from './paddleRouting';
import TourWaypointList from './TourWaypointList';
import type { TourPlannerController } from './useTourPlanner';
/** The item at `i`, or a failure that names the index instead of a later undefined. */
function at<T>(list: readonly T[], i: number): T {
const item = list[i];
if (item === undefined) throw new Error(`nothing at ${i}`);
return item;
}
vi.mock('./useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
const START = { id: 'a', lat: 49.1, lng: 12.1, role: 'start' as const };
const SHAPE = { id: 's', lat: 49.08, lng: 12.11, role: 'via' as const, shaping: true };
const STOP = { id: 'b', lat: 49.05, lng: 12.12, role: 'via' as const };
const END = { id: 'c', lat: 49.0, lng: 12.1, role: 'end' as const };
const segment = (kind: 'water' | 'rapids' | 'pass' | 'gap', from: number, to: number, grade: number | null = null) => ({
kind,
from,
to,
name: null,
source: null,
osm_ref: null,
grade,
});
const WHITEWATER: PaddleRouteLeg = {
status: 'ok',
geometry: [
[49.05, 12.12],
[49.04, 12.12],
[49.03, 12.115],
[49.0, 12.1],
],
segments: [segment('water', 0, 1), segment('rapids', 1, 2, 2), segment('gap', 2, 3)],
distance_m: 5800,
snapped_m: { from: 0, to: 0 },
};
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
return {
...hikePlannerDefaults(),
waypoints: [START, SHAPE, STOP, END],
selectedWaypointId: null,
setSelectedWaypointId: vi.fn(),
reorderWaypoint: vi.fn(),
moveWaypoint: vi.fn(),
removeWaypoint: vi.fn(),
...overrides,
} as unknown as TourPlannerController;
}
function paddleRoute(legs: PaddleTourRoute['legs'], failures: PaddleTourRoute['failures'] = []): PaddleTourRoute {
return { coordinates: [], parts: [], distanceMeters: 0, legs, legLengths: legs.map(() => 0), failures };
}
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
});
describe('TourWaypointList', () => {
it('FE-TOUR-WAYPOINT-LIST-001: numbers the stops only; a shaping point is a dot and does not count', () => {
render(<TourWaypointList planner={planner()} canEdit isSaving={false} />);
const rows = screen.getAllByTestId('tour-planner-waypoint-row');
expect(rows).toHaveLength(4);
expect(within(at(rows, 1)).getByLabelText('Shaping point')).toHaveAttribute('data-waypoint-shaping', 'true');
expect(
[...document.querySelectorAll('[data-waypoint-number]')].map((el) => el.getAttribute('data-waypoint-number'))
).toEqual(['1', '2', '3']);
expect(screen.getByText('Click the route on the map to add a shaping point there.')).toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-002: the menu plans a routed leg again, draws it by hand, and turns a stop into a shaping point', async () => {
const controller = planner({ selectedWaypointId: 'b' });
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const row = at(screen.getAllByTestId('tour-planner-waypoint-row'), 2);
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Plan the next leg again' }));
expect(controller.recomputeLeg).toHaveBeenCalledWith('b');
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Make it a shaping point' }));
expect(controller.setWaypointShaping).toHaveBeenCalledWith('b', true);
await userEvent.click(within(row).getByRole('button', { name: 'More actions' }));
await userEvent.click(screen.getByRole('button', { name: 'Draw the next leg by hand' }));
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('b');
});
it('FE-TOUR-WAYPOINT-LIST-003: a shaping point becomes a stop again; the first point offers no shaping', async () => {
const controller = planner({ selectedWaypointId: 's' });
const { rerender } = render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
await userEvent.click(
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 1)).getByRole('button', { name: 'More actions' })
);
await userEvent.click(screen.getByRole('button', { name: 'Make it a stop' }));
expect(controller.setWaypointShaping).toHaveBeenCalledWith('s', false);
rerender(<TourWaypointList planner={planner({ selectedWaypointId: 'a' })} canEdit isSaving={false} />);
await userEvent.click(
within(at(screen.getAllByTestId('tour-planner-waypoint-row'), 0)).getByRole('button', { name: 'More actions' })
);
expect(screen.queryByRole('button', { name: 'Make it a shaping point' })).not.toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-004: a paddle leg shows its whitewater and its data gap with their lengths', () => {
const route = paddleRoute([null, null, WHITEWATER]);
render(
<TourWaypointList
planner={planner({ ...{ isPaddle: true, tourType: 'kayak' }, paddleRoute: route, routeCurrent: true })}
canEdit
isSaving={false}
/>
);
const notes = screen.getByTestId('tour-planner-leg-notes');
expect(within(notes).getByText('Whitewater 2')).toBeInTheDocument();
expect(within(notes).getByText('Gap in the map data')).toBeInTheDocument();
});
it('FE-TOUR-WAYPOINT-LIST-005: a leg the router could not plan says why and offers the way out', async () => {
const route = paddleRoute(
[{ status: 'failed', code: 'busy', at: null }, null, null],
[{ index: 0, code: 'busy', at: null }]
);
const controller = planner({ isPaddle: true, tourType: 'kayak', paddleRoute: route, routeCurrent: true });
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
const failure = screen.getByTestId('tour-planner-leg-failure');
expect(within(failure).getByRole('alert')).toHaveTextContent('The route planner is busy. Try again in a moment.');
await userEvent.click(within(failure).getByRole('button', { name: 'Try again' }));
expect(controller.recomputeLeg).toHaveBeenCalledWith('a');
await userEvent.click(within(failure).getByRole('button', { name: 'Draw straight' }));
expect(controller.toggleWaypointFreeLeg).toHaveBeenCalledWith('a');
});
it('FE-TOUR-WAYPOINT-LIST-006: a point off the water offers no retry; legs drawn by hand can go back to the water in one step', async () => {
const drawn = {
kind: 'free' as const,
geometry: [
[49.1, 12.1],
[49.08, 12.11],
] as [number, number][],
};
const route = paddleRoute(
[null, { status: 'failed', code: 'not_on_water', at: null }, null],
[{ index: 1, code: 'not_on_water', at: null }]
);
const auto = { from: { id: 'a', lat: 49.1, lng: 12.1 }, to: { id: 'c', lat: 49.0, lng: 12.1 } };
const controller = planner({
isPaddle: true,
tourType: 'kayak',
paddleRoute: route,
routeCurrent: true,
waypoints: [{ ...START, fixedLeg: drawn }, SHAPE, { ...STOP, auto }, END],
});
render(<TourWaypointList planner={controller} canEdit isSaving={false} />);
expect(
within(screen.getByTestId('tour-planner-leg-failure')).queryByRole('button', { name: 'Try again' })
).toBeNull();
expect(screen.getByTestId('tour-planner-auto-point')).toHaveAttribute('aria-label', 'Found');
await userEvent.click(screen.getByTestId('tour-planner-route-on-water'));
expect(controller.routeDrawnLegsOnWater).toHaveBeenCalled();
});
});
@@ -1,24 +1,16 @@
import { import { paddleLayoutOfPoints, type PaddleLayout, type PaddleRole, type TourFixedLeg } from '@trek/shared';
paddleLayoutOfPoints,
type PaddleLayout,
type PaddleRegionLoadState,
type PaddleRole,
type TourFixedLeg,
} from '@trek/shared';
import { import {
ArrowDown, ArrowDown,
ArrowDownUp, ArrowDownUp,
ArrowUp, ArrowUp,
GripVertical, GripVertical,
Info,
MapPin, MapPin,
MapPinPlus, MapPinPlus,
MoreHorizontal, MoreHorizontal,
Navigation,
RefreshCw,
Repeat, Repeat,
Spline, Spline,
Trash2, Trash2,
Waves,
} from 'lucide-react'; } from 'lucide-react';
import { Fragment, useState, type MouseEvent, type ReactNode } from 'react'; import { Fragment, useState, type MouseEvent, type ReactNode } from 'react';
import { useTranslation } from '../../../i18n'; import { useTranslation } from '../../../i18n';
@@ -36,16 +28,10 @@ import { ContextMenu, useContextMenu } from '../../shared/ContextMenu';
import { fs } from '../../shared/DialogShell'; import { fs } from '../../shared/DialogShell';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph'; import { LockIcon, PortageIcon } from '../paddle/PaddleGlyph';
import { AutoPointPill, PaddleLegFailureRow, PaddleLegNotes } from '../paddle/PaddleLegParts'; import { LockRow, PaddleRolePill, PortageLegRow } from '../paddle/PaddleWaypointParts';
import { isRegionLoadPending, regionLoadFailure } from '../paddle/paddleRegionLoad';
import { PaddleRegionLoadRow } from '../paddle/PaddleRegionLoadRow';
import { LockRow, PaddleRolePill, PendingPortageRow, PortageLegRow } from '../paddle/PaddleWaypointParts';
import { usePaddleRegionLoad } from '../paddle/usePaddleRegionLoad';
import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions'; import { usePaddleWaypointActions } from '../paddle/usePaddleWaypointActions';
import { usePortageRouting, usePortageWay } from '../paddle/usePortageRouting'; import { RailCard, TourNotice } from '../tourParts';
import { RailCard } from '../tourParts';
import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf'; import { paddlePlanPoints, paddleRolesById } from './paddlePlanOf';
import type { PaddleLegFailure } from './paddleRouting';
import { lineLengthM } from './tourFixedLegs'; import { lineLengthM } from './tourFixedLegs';
import type { TourPlannerController } from './useTourPlanner'; import type { TourPlannerController } from './useTourPlanner';
import { useWaypointPlaceNames } from './useWaypointPlaceNames'; import { useWaypointPlaceNames } from './useWaypointPlaceNames';
@@ -221,35 +207,18 @@ function FerrataLegRow({ crossing }: { crossing: TourFerrataCrossing }) {
* by the tooltip. * by the tooltip.
*/ */
function WaypointNumber({ function WaypointNumber({
stop, index,
selected, selected,
ferrata, ferrata,
loopEnd = false, loopEnd = false,
shaping = false,
}: { }: {
/** The stop's number; shaping points do not count, as on the map. */ index: number;
stop: number;
selected: boolean; selected: boolean;
ferrata: TourWaypointFerrata | null; ferrata: TourWaypointFerrata | null;
/** The way back to the start of a loop: shown as the return, not as one more stop. */ /** The way back to the start of a loop: shown as the return, not as one more stop. */
loopEnd?: boolean; loopEnd?: boolean;
/** P2A-03: a shaping point, a small dot as on the map. */
shaping?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (shaping) {
return (
<span
aria-label={t('tours.planner.shapingPoint')}
data-waypoint-shaping="true"
className="grid h-7 w-7 flex-none place-items-center"
>
<span
className={`h-3 w-3 rounded-full ring-2 ring-surface-card ${selected ? 'bg-accent' : 'bg-content-faint'}`}
/>
</span>
);
}
if (loopEnd) { if (loopEnd) {
return ( return (
<span <span
@@ -261,16 +230,16 @@ function WaypointNumber({
</span> </span>
); );
} }
const label = t('tours.planner.waypointLabel', { n: stop }); const label = t('tours.planner.waypointLabel', { n: index + 1 });
const number = ( const number = (
<span <span
aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label} aria-label={ferrata ? `${label}, ${t('tours.ferrata.waypointAtEnd', { name: ferrata.name })}` : label}
data-waypoint-number={stop} data-waypoint-number={index + 1}
data-ferrata-end={ferrata ? 'true' : undefined} data-ferrata-end={ferrata ? 'true' : undefined}
className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`} className={`relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-semibold tabular-nums ${selected ? 'bg-accent text-accent-text' : 'bg-edge text-content'} ${ferrata ? 'ring-2 ring-inset ring-[color:var(--map-ferrata)]' : ''}`}
style={fs(11.5)} style={fs(11.5)}
> >
{stop} {index + 1}
{ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />} {ferrata && <FerrataGlyph size={22} className="absolute -bottom-2.5 -start-2.5" />}
</span> </span>
); );
@@ -309,18 +278,9 @@ function WaypointRowLabel({
> >
{name ?? coordinates} {name ?? coordinates}
</span> </span>
{point.shaping ? ( {paddleRole !== undefined
<Tooltip label={t('tours.planner.shapingHint')}> ? paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
<span className="inline-flex"> : point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>}
<SoftPill>{t('tours.planner.shapingPoint')}</SoftPill>
</span>
</Tooltip>
) : paddleRole !== undefined ? (
paddleRole && !loopEnd && <PaddleRolePill role={paddleRole} />
) : (
point.role !== 'via' && !loopEnd && <SoftPill>{t(ROLE_KEY[point.role])}</SoftPill>
)}
{point.auto && <AutoPointPill />}
</span> </span>
{name && !loopEnd && ( {name && !loopEnd && (
<span <span
@@ -342,82 +302,6 @@ function WaypointRowLabel({
* and remove beside it. Two controls keep the place name readable in a narrow * and remove beside it. Two controls keep the place name readable in a narrow
* rail however many tools a tour type adds. * rail however many tools a tour type adds.
*/ */
type MenuItems = Parameters<ReturnType<typeof useContextMenu>['open']>[1];
type Translate = ReturnType<typeof useTranslation>['t'];
type WaypointPoint = TourPlannerController['waypoints'][number];
/**
* The tools for the leg after a point: drawn by hand or routed (on a paddle
* tour: on the water), planned again (P2A-05), and the point a stop or a
* shaping point (P2A-03; never the first or the last).
*/
function routeMenuItems(
planner: TourPlannerController,
point: WaypointPoint,
index: number,
lastIndex: number,
t: Translate
): MenuItems {
const hasLeg = index < lastIndex;
const free = point.fixedLeg?.kind === 'free';
const routed = hasLeg && !point.fixedLeg;
const items: MenuItems = [];
if (free || routed) {
const routeAgain = planner.isPaddle ? t('tours.paddle.routeOnWater') : t('tours.planner.routeLeg');
items.push(
{ divider: true },
{
label: free ? routeAgain : t('tours.planner.freeLeg'),
icon: free && planner.isPaddle ? Waves : Spline,
onClick: () => planner.toggleWaypointFreeLeg(point.id),
}
);
}
if (routed)
items.push({
label: t('tours.planner.recomputeLeg'),
icon: RefreshCw,
onClick: () => planner.recomputeLeg(point.id),
});
if (index > 0 && index < lastIndex) {
items.push({
label: t(point.shaping ? 'tours.planner.makeStop' : 'tours.planner.makeShaping'),
icon: Navigation,
onClick: () => planner.setWaypointShaping(point.id, !point.shaping),
});
}
return items;
}
/** A paddle tour's tools at a point: carry the boat from here, and a lock here. */
function paddleMenuItems(
paddle: ReturnType<typeof usePaddleWaypointActions>,
point: WaypointPoint,
hasLeg: boolean,
t: Translate
): MenuItems {
const portage = point.fixedLeg?.kind === 'portage';
const items: MenuItems = [{ divider: true }];
// At the last point too: the portage starts here and reaches the point set next.
items.push({
label: t(
!hasLeg && point.carryNext
? 'tours.paddle.portagePendingOff'
: portage
? 'tours.paddle.portageOff'
: 'tours.paddle.portageOn'
),
icon: PortageIcon,
onClick: () => paddle.togglePortage(point.id),
});
items.push({
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
icon: LockIcon,
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
});
return items;
}
function WaypointRowActions({ function WaypointRowActions({
planner, planner,
point, point,
@@ -427,7 +311,7 @@ function WaypointRowActions({
isSaving, isSaving,
}: { }: {
planner: TourPlannerController; planner: TourPlannerController;
point: WaypointPoint; point: TourPlannerController['waypoints'][number];
index: number; index: number;
lastIndex: number; lastIndex: number;
selected: boolean; selected: boolean;
@@ -436,7 +320,10 @@ function WaypointRowActions({
const { t } = useTranslation(); const { t } = useTranslation();
const menu = useContextMenu(); const menu = useContextMenu();
const paddle = usePaddleWaypointActions(planner); const paddle = usePaddleWaypointActions(planner);
const items: MenuItems = [ const free = point.fixedLeg?.kind === 'free';
const portage = point.fixedLeg?.kind === 'portage';
const hasLeg = index < lastIndex;
const items = [
{ {
label: t('tours.planner.moveUp'), label: t('tours.planner.moveUp'),
icon: ArrowUp, icon: ArrowUp,
@@ -449,8 +336,35 @@ function WaypointRowActions({
disabled: index === lastIndex, disabled: index === lastIndex,
onClick: () => planner.moveWaypoint(point.id, 1), onClick: () => planner.moveWaypoint(point.id, 1),
}, },
...routeMenuItems(planner, point, index, lastIndex, t), ...(!planner.isPaddle && hasLeg && point.fixedLeg?.kind !== 'ferrata'
...(planner.isPaddle ? paddleMenuItems(paddle, point, index < lastIndex, t) : []), ? [
{ divider: true },
{
label: t(free ? 'tours.planner.routeLeg' : 'tours.planner.freeLeg'),
icon: Spline,
onClick: () => planner.toggleWaypointFreeLeg(point.id),
},
]
: []),
...(planner.isPaddle
? [
{ divider: true },
...(hasLeg
? [
{
label: t(portage ? 'tours.paddle.portageOff' : 'tours.paddle.portageOn'),
icon: PortageIcon,
onClick: () => paddle.togglePortage(point.id),
},
]
: []),
{
label: t(point.lock ? 'tours.paddle.lockRemove' : 'tours.paddle.lockAdd'),
icon: LockIcon,
onClick: () => paddle.setLock(point.id, point.lock ? null : { wait_minutes: null, transit_minutes: null }),
},
]
: []),
]; ];
return ( return (
<span <span
@@ -488,29 +402,24 @@ function PaddleRowExtras({
index, index,
layout, layout,
editable, editable,
routing,
}: { }: {
planner: TourPlannerController; planner: TourPlannerController;
point: TourPlannerController['waypoints'][number]; point: TourPlannerController['waypoints'][number];
index: number; index: number;
layout: PaddleLayout; layout: PaddleLayout;
editable: boolean; editable: boolean;
routing: ReturnType<typeof usePortageRouting>;
}) { }) {
const paddle = usePaddleWaypointActions(planner); const paddle = usePaddleWaypointActions(planner);
const way = usePortageWay(planner.waypoints, routing, index);
const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index); const portage = layout.stretches.find((stretch) => stretch.kind === 'portage' && stretch.startIndex === index);
const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined; const lock = point.lock ? layout.locks.find((stop) => stop.index === index) : undefined;
return ( return (
<> <>
{point.carryNext && <PendingPortageRow disabled={!editable} onRemove={() => paddle.togglePortage(point.id)} />}
{portage && ( {portage && (
<PortageLegRow <PortageLegRow
lengthM={portage.toM - portage.fromM} lengthM={portage.toM - portage.fromM}
extraMinutes={portage.extraMinutes} extraMinutes={portage.extraMinutes}
disabled={!editable} disabled={!editable}
onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)} onExtraChange={(minutes) => paddle.setPortageExtra(point.id, minutes)}
way={point.auto ? undefined : way}
/> />
)} )}
{point.lock && ( {point.lock && (
@@ -554,154 +463,6 @@ function AddPointRow({ disabled }: { disabled: boolean }) {
* reorderable by drag or by the arrow controls on each row. The coordinates * reorderable by drag or by the arrow controls on each row. The coordinates
* stand in for the name while it loads, offline, or when the lookup has none. * stand in for the name while it loads, offline, or when the lookup has none.
*/ */
/** P2A-03: how a shaping point is set, said once under the list while there is a route to click. */
function ShapeOnLineHint({ planner, canEdit }: { planner: TourPlannerController; canEdit: boolean }) {
const { t } = useTranslation();
if (!canEdit || planner.waypoints.length < 2) return null;
return (
<p className="m-0 mt-1.5 px-1.5 text-content-faint" style={fs(11, 'body')}>
{t('tours.planner.shapeOnLine')}
</p>
);
}
/** Stops are numbered on their own, as on the map: a shaping point (P2A-03) takes no number. */
function stopNumbersOf(points: ReadonlyArray<WaypointPoint>): Map<string, number> {
const numbers = new Map<string, number>();
for (const point of points) if (!point.shaping) numbers.set(point.id, numbers.size + 1);
return numbers;
}
/** The route tools in the card head: reverse, plan the drawn legs on the water (PAD-11), back to the start. */
function WaypointListTools({ planner, isSaving }: { planner: TourPlannerController; isSaving: boolean }) {
const { t } = useTranslation();
const hasDrawnLegs = planner.isPaddle && planner.waypoints.some((point) => point.fixedLeg?.kind === 'free');
return (
<span className="flex flex-none items-center gap-0.5">
<RouteToolButton
testId="tour-planner-reverse"
label={t('tours.planner.reverse')}
disabled={isSaving || planner.waypoints.length < 2}
onAct={planner.reverse}
>
<ArrowDownUp size={13} strokeWidth={2.2} />
</RouteToolButton>
{hasDrawnLegs && (
<RouteToolButton
testId="tour-planner-route-on-water"
label={t('tours.paddle.routeAllOnWater')}
disabled={isSaving}
onAct={planner.routeDrawnLegsOnWater}
>
<Waves size={13} strokeWidth={2.2} />
</RouteToolButton>
)}
<RouteToolButton
testId="tour-planner-return-to-start"
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
onAct={planner.returnToStart}
>
<Repeat size={13} strokeWidth={2.2} />
</RouteToolButton>
</span>
);
}
/**
* A leg the router could not plan. Where it lacked the water data of the
* region (PAD-11C), the row follows the data instead: loading or asked for,
* the leg waits calmly; declined, failed or not to be had, it says why.
*/
function LegFailure({
code,
load,
disabled,
onDrawStraight,
onRetry,
onLoadRetry,
}: {
code: PaddleLegFailure;
load: PaddleRegionLoadState | undefined;
disabled: boolean;
onDrawStraight: () => void;
onRetry: () => void;
onLoadRetry: () => void;
}) {
const { t } = useTranslation();
if (isRegionLoadPending(load)) {
return <PaddleRegionLoadRow state={load} disabled={disabled} onDrawStraight={onDrawStraight} />;
}
return (
<PaddleLegFailureRow
code={code}
disabled={disabled}
onDrawStraight={onDrawStraight}
onRetry={onRetry}
message={regionLoadFailure(load, t)}
onLoadRetry={load?.state === 'failed' ? onLoadRetry : undefined}
/>
);
}
/**
* The rows under a waypoint for the leg that starts there: the via ferrata it
* crosses, a line drawn by hand, and on a paddle tour what the router found on
* the way (PAD-11) or why it could not plan it, plus the portage and lock rows.
*/
function WaypointLegRows({
planner,
point,
index,
crossings = [],
routedLegs,
paddleLayout,
editable,
portageRouting,
regionLoad,
}: {
planner: TourPlannerController;
point: WaypointPoint;
index: number;
crossings?: TourFerrataCrossing[];
routedLegs: TourPlannerController['paddleRoute'] | null;
paddleLayout: ReturnType<typeof paddleLayoutOfPoints> | null;
editable: boolean;
portageRouting: ReturnType<typeof usePortageRouting>;
regionLoad: ReturnType<typeof usePaddleRegionLoad>;
}) {
const failure = routedLegs?.failures.find((candidate) => candidate.index === index);
return (
<>
{crossings.map((crossing) => (
<FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
))}
{point.fixedLeg?.kind === 'free' && <FreeLegRow leg={point.fixedLeg} />}
{routedLegs && <PaddleLegNotes leg={routedLegs.legs[index]} />}
{failure && (
<LegFailure
code={failure.code}
load={regionLoad.byLeg.get(index)}
disabled={!editable}
onDrawStraight={() => planner.toggleWaypointFreeLeg(point.id)}
onRetry={() => planner.recomputeLeg(point.id)}
onLoadRetry={regionLoad.retry}
/>
)}
{paddleLayout && (
<PaddleRowExtras
planner={planner}
point={point}
index={index}
layout={paddleLayout}
editable={editable}
routing={portageRouting}
/>
)}
</>
);
}
export default function TourWaypointList({ export default function TourWaypointList({
planner, planner,
canEdit, canEdit,
@@ -728,22 +489,34 @@ export default function TourWaypointList({
// A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler. // A hike keeps its start and end badges (undefined); a paddle tour names each point for the paddler.
const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined); const paddleRoleOf = (id: string) => (paddleRoles ? (paddleRoles.get(id) ?? null) : undefined);
const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null; const paddleLayout = planner.isPaddle ? paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints)) : null;
// PAD-03: portages the paddler sets follow the ways on land.
const portageRouting = usePortageRouting(planner, canEdit && !isSaving);
// The router's answer per leg, while it still belongs to these points (PAD-11).
const routedLegs = planner.isPaddle && planner.routeCurrent ? planner.paddleRoute : null;
// PAD-11C: where a leg lacks the water data of its region, TREK fetches it and plans the leg again.
const regionLoad = usePaddleRegionLoad(routedLegs?.failures, planner.retry);
const stopNumbers = stopNumbersOf(planner.waypoints);
const endDrag = () => { const endDrag = () => {
setDraggedWaypointId(null); setDraggedWaypointId(null);
setDropTarget(null); setDropTarget(null);
}; };
// One card like a day in the plan: the head counts the points, the rows sit below. // One card like a day in the plan: the head counts the points, the rows sit below.
const tools = canEdit && planner.waypoints.length > 0 && <WaypointListTools planner={planner} isSaving={isSaving} />; const tools = canEdit && planner.waypoints.length > 0 && (
// The way back of a loop is no stop of its own, and a shaping point no stop at all, so the count leaves them out. <span className="flex flex-none items-center gap-0.5">
const stops = stopNumbers.size - (planner.isLoop ? 1 : 0); <RouteToolButton
testId="tour-planner-reverse"
label={t('tours.planner.reverse')}
disabled={isSaving || planner.waypoints.length < 2}
onAct={planner.reverse}
>
<ArrowDownUp size={13} strokeWidth={2.2} />
</RouteToolButton>
<RouteToolButton
testId="tour-planner-return-to-start"
label={t(planner.isLoop ? 'tours.planner.alreadyLoop' : 'tours.planner.returnToStart')}
disabled={isSaving || planner.waypoints.length < 2 || planner.isLoop}
onAct={planner.returnToStart}
>
<Repeat size={13} strokeWidth={2.2} />
</RouteToolButton>
</span>
);
// The way back of a loop is no stop of its own, so the count leaves it out.
const stops = planner.waypoints.length - (planner.isLoop ? 1 : 0);
const card = (children: ReactNode) => ( const card = (children: ReactNode) => (
<RailCard <RailCard
icon={<MapPin size={14} strokeWidth={2} />} icon={<MapPin size={14} strokeWidth={2} />}
@@ -835,11 +608,10 @@ export default function TourWaypointList({
<GripVertical size={14} strokeWidth={1.8} /> <GripVertical size={14} strokeWidth={1.8} />
</span> </span>
<WaypointNumber <WaypointNumber
stop={stopNumbers.get(point.id) ?? index + 1} index={index}
selected={selected} selected={selected}
ferrata={ferrataEnds[point.id] ?? null} ferrata={ferrataEnds[point.id] ?? null}
loopEnd={loopEnd} loopEnd={loopEnd}
shaping={!!point.shaping}
/> />
<button <button
type="button" type="button"
@@ -866,23 +638,30 @@ export default function TourWaypointList({
)} )}
</div> </div>
</li> </li>
<WaypointLegRows {(legCrossings.get(point.id) ?? []).map((crossing) => (
planner={planner} <FerrataLegRow key={`${crossing.id}-${crossing.fromM}`} crossing={crossing} />
point={point} ))}
index={index} {/* A paddle tour is drawn by hand throughout; the notice says it once instead of on every leg. */}
crossings={legCrossings.get(point.id)} {point.fixedLeg?.kind === 'free' && !planner.isPaddle && <FreeLegRow leg={point.fixedLeg} />}
routedLegs={routedLegs} {paddleLayout && (
paddleLayout={paddleLayout} <PaddleRowExtras
editable={canEdit && !isSaving} planner={planner}
portageRouting={portageRouting} point={point}
regionLoad={regionLoad} index={index}
/> layout={paddleLayout}
editable={canEdit && !isSaving}
/>
)}
</Fragment> </Fragment>
); );
})} })}
</ol> </ol>
{canEdit && <AddPointRow disabled={isSaving} />} {canEdit && <AddPointRow disabled={isSaving} />}
<ShapeOnLineHint planner={planner} canEdit={canEdit} /> {planner.isPaddle && (
<div className="mt-2">
<TourNotice icon={<Info size={13} strokeWidth={2} />}>{t('tours.paddle.drawnByHand')}</TourNotice>
</div>
)}
</> </>
); );
} }
@@ -6,7 +6,7 @@ import {
type PaddleRole, type PaddleRole,
type PaddleTimeEstimate, type PaddleTimeEstimate,
} from '@trek/shared'; } from '@trek/shared';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints'; import type { TourPlannerWaypoint } from './useTourPlanner';
/** The planner's control points as the shared paddle plan reads them. */ /** The planner's control points as the shared paddle plan reads them. */
export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] { export function paddlePlanPoints(points: readonly TourPlannerWaypoint[]): PaddlePlanPoint[] {
@@ -26,17 +26,15 @@ export function paddleRolesById(points: readonly TourPlannerWaypoint[]): Map<str
/** /**
* PAD-07: the estimate of a paddle tour as planned, without its breaks (the * PAD-07: the estimate of a paddle tour as planned, without its breaks (the
* planner adds those as it does for a hike), with the routed length of each * planner adds those as it does for a hike). A speed the paddler typed that
* leg where the router planned it (PAD-11). A speed the paddler typed that
* does not hold leaves the water time unknown rather than wrong. * does not hold leaves the water time unknown rather than wrong.
*/ */
export function paddleEstimateOf( export function paddleEstimateOf(
points: readonly TourPlannerWaypoint[], points: readonly TourPlannerWaypoint[],
paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null }, paddle: { effectiveSpeedKmh: number | null; portageSpeedKmh: number | null },
speedInvalid: boolean, speedInvalid: boolean
legLengths?: ReadonlyArray<number | null>
): PaddleTimeEstimate { ): PaddleTimeEstimate {
return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points), legLengths), { return estimatePaddleTime(paddleLayoutOfPoints(paddlePlanPoints(points)), {
paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh, paddleSpeedKmh: speedInvalid ? null : paddle.effectiveSpeedKmh,
portageSpeedKmh: paddle.portageSpeedKmh, portageSpeedKmh: paddle.portageSpeedKmh,
breakMinutes: null, breakMinutes: null,
@@ -1,214 +0,0 @@
// FE-PADDLE-ROUTING-001 to FE-PADDLE-ROUTING-010: a paddle tour's route put together from the water router's legs (PAD-11).
import type { PaddleRouteLeg, PaddleRouteSegment } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { paddleRouteRepo } from '../../../repo/paddleRouteRepo';
import {
adoptChangedPoints,
materializePaddleLegs,
planPaddleStep,
pruneAutoPoints,
routePaddleTour,
type PaddlePoint,
} from './paddleRouting';
/** The item at `i`, or a failure that names the index instead of a later undefined. */
function at<T>(list: readonly T[], i: number): T {
const item = list[i];
if (item === undefined) throw new Error(`nothing at ${i}`);
return item;
}
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: vi.fn(), forget: vi.fn() } }));
const KAYAK = { boat: 'kayak' as const, lakeStyle: 'direct' as const };
const A: PaddlePoint = { id: 'a', lat: 49.1, lng: 12.1 };
const B: PaddlePoint = { id: 'b', lat: 49.05, lng: 12.12 };
const C: PaddlePoint = { id: 'c', lat: 49.0, lng: 12.1 };
function segment(kind: PaddleRouteSegment['kind'], from: number, to: number, extra: Partial<PaddleRouteSegment> = {}) {
return { kind, from, to, name: null, source: null, osm_ref: null, grade: null, ...extra };
}
/** A routed leg through five points: water, a portage round a weir, water, a lock, water. */
const WEIR_AND_LOCK: PaddleRouteLeg = {
status: 'ok',
geometry: [
[49.1, 12.1],
[49.09, 12.11],
[49.08, 12.11],
[49.07, 12.115],
[49.06, 12.118],
[49.05, 12.12],
],
segments: [
segment('water', 0, 1),
segment('portage', 1, 2, { source: 'tagged' }),
segment('water', 2, 3),
segment('lock', 3, 4, { name: 'Schleuse' }),
segment('water', 4, 5),
],
distance_m: 6100,
snapped_m: { from: 0, to: 0 },
};
const straight = (from: PaddlePoint, to: PaddlePoint, distance = 1000): PaddleRouteLeg => ({
status: 'ok',
geometry: [
[from.lat, from.lng],
[to.lat, to.lng],
],
segments: [segment('water', 0, 1)],
distance_m: distance,
snapped_m: { from: 0, to: 0 },
});
let ids = 0;
const newId = () => `auto-${(ids += 1)}`;
beforeEach(() => {
ids = 0;
vi.mocked(paddleRouteRepo.plan).mockReset();
});
describe('routePaddleTour', () => {
it('FE-PADDLE-ROUTING-001: asks only for the legs without a line, and keeps a drawn or carried leg as it is', async () => {
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([straight(B, C, 5500)]);
const drawn = {
kind: 'free' as const,
geometry: [
[A.lat, A.lng],
[B.lat, B.lng],
] as [number, number][],
};
const route = await routePaddleTour([{ ...A, fixedLeg: drawn }, B, C], 7, KAYAK);
expect(vi.mocked(paddleRouteRepo.plan).mock.calls[0]?.[1]).toEqual([{ from: B, to: C }]);
expect(route.legs[0]).toBeNull();
expect(route.legs[1]?.status).toBe('ok');
expect(route.parts.map((part) => [part.free, !!part.land])).toEqual([
[true, false],
[false, false],
]);
expect(route.legLengths[1]).toBe(5500);
expect(route.distanceMeters).toBeCloseTo(at(route.legLengths, 0) + 5500);
expect(route.coordinates[0]).toEqual([A.lat, A.lng]);
expect(route.coordinates[route.coordinates.length - 1]).toEqual([C.lat, C.lng]);
});
it('FE-PADDLE-ROUTING-002: draws a portage as land, whitewater and a boat pass amber, a gap red, and merges the rest', async () => {
const marked: PaddleRouteLeg = {
...WEIR_AND_LOCK,
segments: [
segment('water', 0, 1),
segment('portage', 1, 2),
segment('rapids', 2, 3, { grade: 2 }),
segment('pass', 3, 4),
segment('gap', 4, 5),
],
};
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([marked]);
const route = await routePaddleTour([A, B], 7, KAYAK);
expect(route.parts.map((part) => part.land ?? part.mark ?? 'water')).toEqual(['water', true, 'rapids', 'gap']);
// Whitewater and the boat pass next to it are one amber line.
expect(route.parts[2]?.coordinates).toHaveLength(3);
});
it('FE-PADDLE-ROUTING-003: a leg the router could not plan stays a straight unrouted stretch, counted as a failure', async () => {
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([
straight(A, B),
{ status: 'failed', code: 'not_on_water', at: { lat: C.lat, lng: C.lng } },
]);
const route = await routePaddleTour([A, B, C], 7, KAYAK);
expect(route.failures).toEqual([{ index: 1, code: 'not_on_water', at: { lat: C.lat, lng: C.lng } }]);
expect(route.parts[route.parts.length - 1]).toMatchObject({
mark: 'unrouted',
coordinates: [
[B.lat, B.lng],
[C.lat, C.lng],
],
});
// The straight line counts in the length, so the time does not shrink while a leg waits.
expect(route.legLengths[1]).toBeGreaterThan(5000);
});
it('FE-PADDLE-ROUTING-004: a leg the answer leaves out is unrouted, never silently straight water', async () => {
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([]);
const route = await routePaddleTour([A, B], 7, KAYAK);
expect(route.failures).toEqual([{ index: 0, code: 'no_water_path', at: null }]);
});
it('FE-PADDLE-ROUTING-005: a tour with every leg drawn asks nothing', async () => {
const carried = {
kind: 'portage' as const,
geometry: [
[A.lat, A.lng],
[B.lat, B.lng],
] as [number, number][],
extra_minutes: null,
};
const route = await routePaddleTour([{ ...A, fixedLeg: carried }, B], 7, KAYAK);
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
expect(route.parts[0]?.land).toBe(true);
});
});
describe('automatic points', () => {
it("FE-PADDLE-ROUTING-006: sets a portage pair and a lock where the router found them, on the paddler's own leg", () => {
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
expect(points.map((point) => point.id)).toEqual(['a', 'auto-1', 'auto-2', 'auto-3', 'b']);
expect(points[1]).toMatchObject({ lat: 49.09, lng: 12.11, fixedLeg: { kind: 'portage', extra_minutes: null } });
expect(points[2]).toMatchObject({ lat: 49.08, lng: 12.11 });
expect(points[2]?.fixedLeg).toBeUndefined();
expect(points[3]).toMatchObject({ lock: { wait_minutes: null, transit_minutes: null } });
expect(points[1]?.auto).toEqual({
from: { id: 'a', lat: A.lat, lng: A.lng },
to: { id: 'b', lat: B.lat, lng: B.lng },
});
});
it('FE-PADDLE-ROUTING-007: planning the same leg again keeps the ids, and a leg at an automatic point adds nothing', () => {
const once = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
const legs = once.slice(0, -1).map(() => WEIR_AND_LOCK);
const twice = materializePaddleLegs(once, { legs }, newId);
expect(twice).toEqual(once);
});
it('FE-PADDLE-ROUTING-008: drops the automatic points of a leg whose end moved or went, and keeps them otherwise', () => {
const points = materializePaddleLegs([A, B, C], { legs: [WEIR_AND_LOCK, straight(B, C)] }, newId);
expect(pruneAutoPoints(points)).toEqual(points);
const moved = points.map((point) => (point.id === 'b' ? { ...point, lat: point.lat + 0.01 } : point));
expect(pruneAutoPoints(moved).map((point) => point.id)).toEqual(['a', 'b', 'c']);
const shaped = [at(points, 0), { id: 's', lat: 49.095, lng: 12.105, shaping: true }, ...points.slice(1)];
expect(pruneAutoPoints(shaped).map((point) => point.id)).toEqual(['a', 's', 'b', 'c']);
});
it('FE-PADDLE-ROUTING-009: a point the paddler moves or edits is theirs; an untouched one stays automatic', () => {
const points = materializePaddleLegs([A, B], { legs: [WEIR_AND_LOCK] }, newId);
const edited = points.map((point) =>
point.id === 'auto-3' ? { ...point, lock: { wait_minutes: 20, transit_minutes: null } } : point
);
const adopted = adoptChangedPoints(points, edited);
expect(adopted.find((point) => point.id === 'auto-3')?.auto).toBeUndefined();
expect(adopted.find((point) => point.id === 'auto-1')?.auto).toBeDefined();
});
});
describe('planPaddleStep', () => {
it('FE-PADDLE-ROUTING-010: prunes first, then sets what the router found, then answers the route', async () => {
const signal = new AbortController().signal;
vi.mocked(paddleRouteRepo.plan).mockResolvedValue([WEIR_AND_LOCK]);
const first = await planPaddleStep([A, B], 7, KAYAK, signal, newId);
expect('points' in first && first.points).toHaveLength(5);
const found = 'points' in first ? first.points : [];
vi.mocked(paddleRouteRepo.plan).mockResolvedValue(found.slice(0, -1).map(() => straight(A, B, 1500)));
const second = await planPaddleStep(found, 7, KAYAK, signal, newId);
expect('route' in second && second.route.legLengths).toHaveLength(4);
const stale = found.map((point) => (point.id === 'a' ? { ...point, lng: point.lng + 0.01 } : point));
vi.mocked(paddleRouteRepo.plan).mockClear();
const third = await planPaddleStep(stale, 7, KAYAK, signal, newId);
expect('points' in third && third.points.map((point) => point.id)).toEqual(['a', 'b']);
expect(paddleRouteRepo.plan).not.toHaveBeenCalled();
});
});
@@ -1,262 +0,0 @@
import type { PaddleLock, PaddleRouteFailureCode, PaddleRouteLeg, TourFixedLeg } from '@trek/shared';
import { paddleRouteRepo, type PaddleLegAnswer, type PaddleRouteProfile } from '../../../repo/paddleRouteRepo';
import { haversineKm } from '../../../utils/geo';
import { lineLengthM } from './tourFixedLegs';
import type { TourRoutePart } from './tourRouting';
/**
* PAD-11: a paddle tour's route in the planner. A leg without a line of its
* own is planned on the water by the server's router, as a hike's legs are by
* Valhalla; a leg drawn by hand or carried as a portage keeps its line. Where
* the router carries round a weir or goes through a lock between two of the
* paddler's own points, the planner sets points of its own there (`auto`), so
* the portage and the lock count like any planned by hand: in the time, in the
* checklist, on the map. They are derived again whenever the points they came
* from change, and become the paddler's own once touched.
*/
/** Where an automatic point came from: the two points of the paddler's leg it was found on, as they were. */
export interface AutoBase {
from: { id: string; lat: number; lng: number };
to: { id: string; lat: number; lng: number };
}
export interface PaddlePoint {
id: string;
lat: number;
lng: number;
fixedLeg?: TourFixedLeg | null;
lock?: PaddleLock | null;
shaping?: boolean;
/** Set by the planner where the router found a portage or a lock; gone once the paddler touches the point. */
auto?: AutoBase;
}
export type PaddleLegFailure = PaddleRouteFailureCode | 'offline';
export interface PaddleTourRoute {
coordinates: [number, number][];
parts: TourRoutePart[];
distanceMeters: number;
/** Per leg (index of its first point): the router's answer, or null for a leg with a line of its own. */
legs: Array<PaddleLegAnswer | null>;
/** The routed length of every leg, metres, for the time estimate. */
legLengths: number[];
failures: Array<{ index: number; code: PaddleLegFailure; at: { lat: number; lng: number } | null }>;
}
/** What a leg holds when its answer is somehow missing: unrouted, never silently straight. */
const NOT_ASKED: PaddleLegAnswer = { status: 'failed', code: 'no_water_path', at: null };
/** Two points this close are one spot, in degrees (about ten centimetres). */
const SAME_SPOT = 1e-6;
function samePlace(a: { lat: number; lng: number }, b: { lat: number; lng: number }): boolean {
return Math.abs(a.lat - b.lat) < SAME_SPOT && Math.abs(a.lng - b.lng) < SAME_SPOT;
}
type PartKind = 'water' | 'rapids' | 'gap' | 'unrouted' | 'land' | 'manual';
function partOf(kind: PartKind, coordinates: [number, number][]): TourRoutePart {
if (kind === 'land') return { coordinates, free: false, land: true };
if (kind === 'manual') return { coordinates, free: true };
return { coordinates, free: false, ...(kind === 'water' ? {} : { mark: kind }) };
}
/** The parts of one routed leg: water as one line, carried and marked stretches as their own. */
function routedParts(leg: Extract<PaddleRouteLeg, { status: 'ok' }>): TourRoutePart[] {
return leg.segments.map((segment) => {
const line = leg.geometry.slice(segment.from, segment.to + 1) as [number, number][];
if (segment.kind === 'portage') return partOf('land', line);
if (segment.kind === 'rapids' || segment.kind === 'pass') return partOf('rapids', line);
if (segment.kind === 'gap') return partOf('gap', line);
return partOf('water', line);
});
}
/** Neighbouring parts drawn alike as one line, as the hike's route does. */
function mergeParts(parts: TourRoutePart[]): TourRoutePart[] {
const merged: TourRoutePart[] = [];
for (const part of parts) {
const previous = merged[merged.length - 1];
if (previous && previous.free === part.free && !!previous.land === !!part.land && previous.mark === part.mark) {
previous.coordinates = [...previous.coordinates, ...part.coordinates.slice(1)];
} else {
merged.push({ ...part, coordinates: [...part.coordinates] });
}
}
return merged;
}
/** Plans the legs that need it and puts the whole line together. */
export async function routePaddleTour(
points: readonly PaddlePoint[],
tripId: number | string,
profile: PaddleRouteProfile,
signal?: AbortSignal
): Promise<PaddleTourRoute> {
const routed = points.slice(0, -1).flatMap((point, index) => (point.fixedLeg ? [] : [index]));
const answers = routed.length
? await paddleRouteRepo.plan(
tripId,
routed.map((index) => ({ from: points[index], to: points[index + 1] })),
profile,
signal
)
: [];
const byLeg = new Map(routed.map((index, at) => [index, answers[at]]));
const parts: TourRoutePart[] = [];
const legs: PaddleTourRoute['legs'] = [];
const legLengths: number[] = [];
const failures: PaddleTourRoute['failures'] = [];
for (let index = 0; index < points.length - 1; index += 1) {
const point = points[index];
const next = points[index + 1];
const leg = point.fixedLeg;
if (leg) {
const line = leg.geometry.map(([lat, lng]): [number, number] => [lat, lng]);
parts.push(partOf(leg.kind === 'portage' ? 'land' : 'manual', line));
legs.push(null);
legLengths.push(lineLengthM(line));
continue;
}
const answer = byLeg.get(index) ?? NOT_ASKED;
legs.push(answer);
if (answer.status === 'ok') {
parts.push(...routedParts(answer));
legLengths.push(answer.distance_m);
continue;
}
// A leg the router could not plan stays visible as what it is: a straight stretch nobody checked.
failures.push({ index, code: answer.code, at: answer.at });
parts.push(
partOf('unrouted', [
[point.lat, point.lng],
[next.lat, next.lng],
])
);
legLengths.push(haversineKm(point, next) * 1000);
}
const lines = mergeParts(parts);
const coordinates = lines.flatMap((part, index) => (index === 0 ? part.coordinates : part.coordinates.slice(1)));
return {
coordinates,
parts: lines,
distanceMeters: legLengths.reduce((sum, length) => sum + length, 0),
legs,
legLengths,
failures,
};
}
function baseOf(points: readonly PaddlePoint[], index: number): AutoBase {
const from = points[index];
const to = points[index + 1];
return { from: { id: from.id, lat: from.lat, lng: from.lng }, to: { id: to.id, lat: to.lat, lng: to.lng } };
}
/**
* The automatic points that no longer stand on the leg they were found on
* (an end of it moved, or went), dropped; the next plan finds them again
* where they belong.
*/
export function pruneAutoPoints<T extends PaddlePoint>(points: readonly T[]): T[] {
const own = points.filter((point) => !point.auto);
return points.filter((point) => {
const base = point.auto;
if (!base) return true;
const from = own.find((candidate) => candidate.id === base.from.id);
const to = own.find((candidate) => candidate.id === base.to.id);
if (!from || !to || !samePlace(from, base.from) || !samePlace(to, base.to)) return false;
return own.indexOf(to) === own.indexOf(from) + 1;
});
}
/**
* Sets the portages and locks the router found on the paddler's own legs as
* points. An existing automatic point at the same spot keeps its id, so
* planning the same leg again changes nothing.
*/
export function materializePaddleLegs<T extends PaddlePoint>(
points: readonly T[],
route: Pick<PaddleTourRoute, 'legs'>,
newId: () => string
): T[] {
const existing = points.filter((point) => point.auto);
const idAt = (lat: number, lng: number) => existing.find((point) => samePlace(point, { lat, lng }))?.id ?? newId();
const result: T[] = [];
points.forEach((point, index) => {
result.push(point);
const next = points[index + 1];
const answer = route.legs[index];
// Only a leg between two of the paddler's own points: a leg at an automatic point is part of one already set.
if (!next || point.auto || next.auto || !answer || answer.status !== 'ok') return;
const base = baseOf(points, index);
const last = answer.geometry.length - 1;
for (const segment of answer.segments) {
if (segment.kind === 'portage' && segment.from > 0 && segment.to < last) {
const line = answer.geometry.slice(segment.from, segment.to + 1) as [number, number][];
const [outLat, outLng] = line[0];
const [inLat, inLng] = line[line.length - 1];
result.push(
{
id: idAt(outLat, outLng),
lat: outLat,
lng: outLng,
auto: base,
fixedLeg: { kind: 'portage', geometry: line, extra_minutes: null },
} as unknown as T,
{ id: idAt(inLat, inLng), lat: inLat, lng: inLng, auto: base } as unknown as T
);
}
if (segment.kind === 'lock' && segment.from > 0 && segment.to < last) {
const [lat, lng] = answer.geometry[Math.round((segment.from + segment.to) / 2)];
result.push({
id: idAt(lat, lng),
lat,
lng,
auto: base,
lock: { wait_minutes: null, transit_minutes: null },
} as unknown as T);
}
}
});
return result;
}
/** A point the paddler changed is theirs from then on: it loses its automatic mark. */
export function adoptChangedPoints<T extends PaddlePoint>(before: readonly T[], after: readonly T[]): T[] {
const previous = new Map(before.map((point) => [point.id, point]));
return after.map((point) => {
if (!point.auto) return point;
const was = previous.get(point.id);
const same =
!!was &&
samePlace(was, point) &&
JSON.stringify(was.lock ?? null) === JSON.stringify(point.lock ?? null) &&
JSON.stringify(was.fixedLeg ?? null) === JSON.stringify(point.fixedLeg ?? null);
if (same) return point;
const { auto: _auto, ...own } = point;
return own as T;
});
}
/**
* One run of the paddle router over the points: either points to set first
* (automatic ones dropped from a leg that changed, or found on a planned
* leg), after which the planner runs again, or the finished route.
*/
export async function planPaddleStep<T extends PaddlePoint>(
points: readonly T[],
tripId: number | string,
profile: PaddleRouteProfile,
signal: AbortSignal,
newId: () => string
): Promise<{ points: T[] } | { route: PaddleTourRoute }> {
const pruned = pruneAutoPoints(points);
if (pruned.length !== points.length) return { points: pruned };
const route = await routePaddleTour(points, tripId, profile, signal);
// Points are only ever added: the same count means nothing new was found.
const materialized = materializePaddleLegs(points, route, newId);
return materialized.length !== points.length ? { points: materialized } : { route };
}
@@ -1,34 +0,0 @@
// FE-ROUTE-LEG-AT-001 to FE-ROUTE-LEG-AT-003: which leg a click on the drawn route lands in (P2A-03).
import { describe, expect, it } from 'vitest';
import { legStartAt } from './routeLegAt';
const A = { id: 'a', lat: 49.0, lng: 12.0 };
const B = { id: 'b', lat: 49.0, lng: 12.1 };
const C = { id: 'c', lat: 49.1, lng: 12.1 };
// The route bends between the points, as a river does.
const LINE: [number, number][] = [
[49.0, 12.0],
[49.01, 12.05],
[49.0, 12.1],
[49.05, 12.11],
[49.1, 12.1],
];
describe('legStartAt', () => {
it('FE-ROUTE-LEG-AT-001: a spot on the line belongs to the leg whose two points enclose it', () => {
expect(legStartAt([A, B, C], LINE, 49.009, 12.05)).toBe('a');
expect(legStartAt([A, B, C], LINE, 49.05, 12.11)).toBe('b');
});
it('FE-ROUTE-LEG-AT-002: a loop keeps its order, so a spot near the start on the way back is the last leg', () => {
const back = { id: 'back', lat: A.lat, lng: A.lng };
const loop: [number, number][] = [...LINE, [49.05, 12.02], [49.0, 12.0]];
expect(legStartAt([A, B, C, back], loop, 49.05, 12.02)).toBe('c');
});
it('FE-ROUTE-LEG-AT-003: without a line or a leg there is nothing to shape', () => {
expect(legStartAt([A, B], null, 49, 12.05)).toBeNull();
expect(legStartAt([A], LINE, 49, 12.05)).toBeNull();
expect(legStartAt([A, B], [[49, 12]], 49, 12.05)).toBeNull();
});
});
@@ -1,55 +0,0 @@
/**
* P2A-03: which leg of a tour a spot on its drawn route belongs to. Every
* waypoint is placed along the line (searching on from where the one before
* lay, so a loop or a route that passes a point twice keeps its order), then
* the spot; the leg is the one whose two points enclose it.
*/
/** Where a spot falls along a line, as the distance from its start in the line's own degrees (flat earth is plenty for ordering). */
function alongLine(
line: readonly [number, number][],
lat: number,
lng: number,
fromIndex: number
): { at: number; index: number } {
const cos = Math.cos((lat * Math.PI) / 180);
let travelled = 0;
let best = { at: 0, index: fromIndex, distance: Infinity };
for (let i = 0; i < line.length - 1; i += 1) {
const [aLat, aLng] = line[i];
const [bLat, bLng] = line[i + 1];
const dx = (bLng - aLng) * cos;
const dy = bLat - aLat;
const length = Math.hypot(dx, dy);
if (i >= fromIndex) {
const t =
length === 0 ? 0 : Math.max(0, Math.min(1, ((lng - aLng) * cos * dx + (lat - aLat) * dy) / (length * length)));
const distance = Math.hypot(aLng * cos + dx * t - lng * cos, aLat + dy * t - lat);
if (distance < best.distance) best = { at: travelled + length * t, index: i, distance };
}
travelled += length;
}
return { at: best.at, index: best.index };
}
/** The id of the waypoint that starts the leg holding the spot, or null when the route has no such leg. */
export function legStartAt(
points: ReadonlyArray<{ id: string; lat: number; lng: number }>,
line: readonly [number, number][] | null,
lat: number,
lng: number
): string | null {
if (!line || line.length < 2 || points.length < 2) return null;
const positions: number[] = [];
let from = 0;
for (const point of points) {
const placed = alongLine(line, point.lat, point.lng, from);
positions.push(placed.at);
from = placed.index;
}
const spot = alongLine(line, lat, lng, 0).at;
for (let i = 0; i < points.length - 1; i += 1) {
if (spot >= positions[i] && spot <= positions[i + 1]) return points[i].id;
}
return null;
}
@@ -5,8 +5,8 @@ import {
type PlannableTourType, type PlannableTourType,
type TourMaxHikingDifficulty, type TourMaxHikingDifficulty,
} from '@trek/shared'; } from '@trek/shared';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import type { PaddleFieldValues } from './usePaddleFields'; import type { PaddleFieldValues } from './usePaddleFields';
import type { TourPlannerWaypoint } from './useTourPlanner';
/** /**
* The tour planner's draft as it is kept in local storage between visits, and * The tour planner's draft as it is kept in local storage between visits, and
@@ -15,7 +15,7 @@ import type { PaddleFieldValues } from './usePaddleFields';
* half read. * half read.
*/ */
export interface DraftPayload { export interface DraftPayload {
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
name: string; name: string;
description?: string; description?: string;
website?: string; website?: string;
@@ -30,7 +30,7 @@ export interface DraftPayload {
paddle?: PaddleFieldValues; paddle?: PaddleFieldValues;
} }
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9]; const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8];
function optionalString(value: unknown): boolean { function optionalString(value: unknown): boolean {
return value === undefined || typeof value === 'string'; return value === undefined || typeof value === 'string';
@@ -1,83 +0,0 @@
// FE-TOUR-WAYPOINTS-001 to FE-TOUR-WAYPOINTS-005: the editor's waypoint model, shaping points and drawn legs (P2A-03, PAD-11).
import { describe, expect, it } from 'vitest';
import {
normalizePlannerWaypoints,
sameWaypointSnapshot,
tourWaypointKey,
withDrawnLegsRouted,
withShaping,
withShapingPoint,
type TourPlannerWaypoint,
} from './tourPlannerWaypoints';
const A: TourPlannerWaypoint = { id: 'a', lat: 49, lng: 12, role: 'start' };
const B: TourPlannerWaypoint = { id: 'b', lat: 49.01, lng: 12.01, role: 'via' };
const C: TourPlannerWaypoint = { id: 'c', lat: 49.02, lng: 12.02, role: 'end' };
describe('tourPlannerWaypoints', () => {
it('FE-TOUR-WAYPOINTS-001: a shaping point goes into the leg after the point named, and nowhere past the end', () => {
const shaped = withShapingPoint([A, B, C], 'b', { id: 's', lat: 49.015, lng: 12.015 });
expect(shaped.map((point) => point.id)).toEqual(['a', 'b', 's', 'c']);
expect(shaped[2]).toEqual({ id: 's', lat: 49.015, lng: 12.015, role: 'via', shaping: true });
const points = [A, B, C];
expect(withShapingPoint(points, 'c', { id: 's', lat: 0, lng: 0 })).toBe(points);
expect(withShapingPoint(points, 'missing', { id: 's', lat: 0, lng: 0 })).toBe(points);
});
it('FE-TOUR-WAYPOINTS-002: a stop becomes a shaping point and back; the ends always stay stops', () => {
const shaped = withShaping([A, B, C], 'b', true);
expect(shaped[1]?.shaping).toBe(true);
expect(withShaping(shaped, 'b', false)[1]).not.toHaveProperty('shaping');
expect(withShaping([A, B, C], 'a', true)[0]).toBe(A);
expect(withShaping([A, B, C], 'c', true)[2]).toBe(C);
});
it('FE-TOUR-WAYPOINTS-003: every drawn leg goes back to the router; a portage stays', () => {
const free = {
kind: 'free' as const,
geometry: [
[49, 12],
[49.01, 12.01],
] as [number, number][],
};
const carried = {
kind: 'portage' as const,
geometry: [
[49.01, 12.01],
[49.02, 12.02],
] as [number, number][],
extra_minutes: 5,
};
const routed = withDrawnLegsRouted([{ ...A, fixedLeg: free }, { ...B, fixedLeg: carried }, C]);
expect(routed[0]).not.toHaveProperty('fixedLeg');
expect(routed[1]?.fixedLeg).toEqual(carried);
});
it('FE-TOUR-WAYPOINTS-004: the key changes when a point shapes instead of stops, or a line gains a bend', () => {
const key = tourWaypointKey([A, B, C]);
expect(tourWaypointKey(withShaping([A, B, C], 'b', true))).not.toBe(key);
const two = {
kind: 'free' as const,
geometry: [
[49, 12],
[49.01, 12.01],
] as [number, number][],
};
const three = {
kind: 'free' as const,
geometry: [
[49, 12],
[49.005, 12.0],
[49.01, 12.01],
] as [number, number][],
};
expect(tourWaypointKey([{ ...A, fixedLeg: two }, B])).not.toBe(tourWaypointKey([{ ...A, fixedLeg: three }, B]));
});
it('FE-TOUR-WAYPOINTS-005: a point the router set is part of the snapshot, and roles follow the order', () => {
const auto = { from: { id: 'a', lat: 49, lng: 12 }, to: { id: 'c', lat: 49.02, lng: 12.02 } };
expect(sameWaypointSnapshot([A, B, C], [A, { ...B, auto }, C])).toBe(false);
expect(sameWaypointSnapshot([A, B, C], [A, B, C])).toBe(true);
expect(normalizePlannerWaypoints([C, A, B]).map((point) => point.role)).toEqual(['start', 'via', 'end']);
});
});
@@ -1,119 +0,0 @@
import type { PaddleLock, TourFixedLeg, TourWaypoint, TourWaypointRole } from '@trek/shared';
import type { AutoBase } from './paddleRouting';
import { keepJoinedFixedLegs } from './tourFixedLegs';
import { alignFreeLegs } from './tourRouteActions';
/** A control point of a planned tour, as the editor holds it. */
export interface TourPlannerWaypoint {
id: string;
lat: number;
lng: number;
role: TourWaypointRole;
/** The leg to the next waypoint as a fixed line (a via ferrata on the tour), not routed. */
fixedLeg?: TourFixedLeg | null;
/** PAD-07: a lock at this point of a paddle tour, with the times expected there. */
lock?: PaddleLock | null;
/** P2A-03: a shaping point, there only to steer the route; no stop. */
shaping?: boolean;
/** PAD-11: set where the router found a portage or a lock; gone once the paddler touches the point. */
auto?: AutoBase;
/** PAD-03: at the last point, the boat is carried on to the next point once it is set; never saved. */
carryNext?: boolean;
}
/** A saved tour's waypoints as the editor holds them, each under a fresh id. */
export function plannerWaypointsOf(saved: readonly TourWaypoint[], newId: () => string): TourPlannerWaypoint[] {
return saved.map((point) => ({
id: newId(),
lat: point.lat,
lng: point.lng,
role: point.role,
...(point.fixed_leg ? { fixedLeg: point.fixed_leg } : {}),
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
}));
}
/**
* The editor's waypoints as they are saved, in their order: what the server
* keeps and nothing of the editor's own (ids, automatic marks, a portage
* waiting for its next point).
*/
export function savedWaypointsOf(points: readonly TourPlannerWaypoint[]): TourWaypoint[] {
return points.map((point, sequence) => ({
lat: point.lat,
lng: point.lng,
role: point.role,
sequence,
...(point.fixedLeg ? { fixed_leg: point.fixedLeg } : {}),
...(point.lock ? { lock: point.lock } : {}),
...(point.shaping ? { shaping: true } : {}),
}));
}
/** Roles from the order; free legs follow their points, and a paddle tour keeps its portages and locks. */
export function normalizePlannerWaypoints(points: TourPlannerWaypoint[], paddle = false): TourPlannerWaypoint[] {
return keepJoinedFixedLegs(alignFreeLegs(points, paddle)).map((point, index) => ({
...point,
role: index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'via',
}));
}
function fixedLegKey(leg: TourFixedLeg | null | undefined): string {
if (!leg) return '';
return leg.kind === 'ferrata' ? `:${leg.ref}` : `:${leg.kind}:${leg.geometry.length}`;
}
/** What the route depends on: every point, where it is, the fixed line after it and whether it only shapes. */
export function tourWaypointKey(points: TourPlannerWaypoint[]): string {
return points
.map(
(point) =>
`${point.id}:${point.lat.toFixed(7)}:${point.lng.toFixed(7)}${fixedLegKey(point.fixedLeg)}${point.shaping ? ':shape' : ''}`
)
.join('|');
}
/** The line, and what a paddler planned along it that draws no line: a portage's time, a lock, a point the router set. */
export function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]): boolean {
const extras = (points: TourPlannerWaypoint[]) =>
JSON.stringify(
points.map((point) => [
point.fixedLeg?.kind === 'portage' ? (point.fixedLeg.extra_minutes ?? null) : null,
point.lock ?? null,
!!point.auto,
!!point.carryNext,
])
);
return tourWaypointKey(a) === tourWaypointKey(b) && extras(a) === extras(b);
}
/** P2A-03: the points with a shaping point set into the leg after `afterId`; unchanged when there is no such leg. */
export function withShapingPoint(
points: TourPlannerWaypoint[],
afterId: string,
shape: Pick<TourPlannerWaypoint, 'id' | 'lat' | 'lng'>
): TourPlannerWaypoint[] {
const index = points.findIndex((point) => point.id === afterId);
if (index < 0 || index >= points.length - 1) return points;
return [...points.slice(0, index + 1), { ...shape, role: 'via', shaping: true }, ...points.slice(index + 1)];
}
/** P2A-03: a stop made a shaping point, or a shaping point a stop. The ends of a tour are always stops. */
export function withShaping(points: TourPlannerWaypoint[], id: string, shaping: boolean): TourPlannerWaypoint[] {
return points.map((point, index) => {
if (point.id !== id || index === 0 || index === points.length - 1) return point;
if (shaping) return { ...point, shaping: true };
const { shaping: _dropped, ...stop } = point;
return stop;
});
}
/** PAD-11: every leg drawn by hand handed back to the router. */
export function withDrawnLegsRouted(points: TourPlannerWaypoint[]): TourPlannerWaypoint[] {
return points.map((point) => {
if (point.fixedLeg?.kind !== 'free') return point;
const { fixedLeg: _drawn, ...routed } = point;
return routed;
});
}
@@ -108,25 +108,10 @@ describe('free legs (P2A-04)', () => {
expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]); expect(aligned.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
}); });
it('routes a paddle tour on the water: a via ferrata goes back to the router, a leg drawn by hand follows its points', () => { it('draws every leg by hand for a paddle tour', () => {
const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true); const aligned = alignFreeLegs([A, { ...B, fixedLeg: FERRATA }, C], true);
expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual([undefined, undefined, undefined]); expect(aligned.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
const drawn = alignFreeLegs([{ ...A, fixedLeg: freeLegBetween(A, C) }, B, C], true); expect(aligned[1].fixedLeg).toEqual(freeLegBetween(B, C));
expect(drawn[0].fixedLeg).toEqual(freeLegBetween(A, B));
});
it('keeps the shape of a drawn paddle leg while its ends still meet their points, and redraws it once one moves', () => {
const shaped = {
kind: 'free' as const,
geometry: [
[A.lat, A.lng],
[A.lat + 0.001, A.lng + 0.002],
[B.lat, B.lng],
] as [number, number][],
};
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, B], true)[0].fixedLeg).toEqual(shaped);
const moved = { ...B, lat: B.lat + 0.01 };
expect(alignFreeLegs([{ ...A, fixedLeg: shaped }, moved, C], true)[0].fixedLeg).toEqual(freeLegBetween(A, moved));
}); });
}); });
@@ -137,31 +122,7 @@ describe('portages and locks (PAD-03, PAD-07)', () => {
const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a'); const carried = togglePortageLeg(alignFreeLegs([A, B, C], true), 'a');
expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B)); expect(carried[0].fixedLeg).toEqual(portageLegBetween(A, B));
expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B)); expect(togglePortageLeg(carried, 'a')[0].fixedLeg).toEqual(freeLegBetween(A, B));
}); expect(togglePortageLeg(carried, 'c')).toEqual(carried);
it('asks for a portage at the last point and draws it to the point set next', () => {
const asked = togglePortageLeg(alignFreeLegs([A, B], true), 'b');
expect(asked[1]).toMatchObject({ carryNext: true });
// Redrawn with no point after it, the ask waits.
expect(alignFreeLegs(asked, true)[1]).toMatchObject({ carryNext: true });
const placed = alignFreeLegs([...asked, C], true);
expect(placed[1].fixedLeg).toEqual(portageLegBetween(B, C));
expect(placed[1]).not.toHaveProperty('carryNext');
// A second press takes the ask back, and a hike forgets it.
expect(togglePortageLeg(asked, 'b')[1]).not.toHaveProperty('carryNext');
expect(alignFreeLegs([...asked, C])[1]).not.toHaveProperty('carryNext');
});
it('keeps a portage that follows a way while its ends stay, and draws it straight once one moves', () => {
const geometry: [number, number][] = [
[A.lat, A.lng],
[47.004, 11.006],
[B.lat, B.lng],
];
const points: RoutePoint[] = [{ ...A, fixedLeg: { ...portageLegBetween(A, B), geometry } }, B, C];
expect(alignFreeLegs(points, true)[0].fixedLeg).toEqual(points[0].fixedLeg);
const moved = alignFreeLegs([points[0], { ...B, lat: 47.012 }, C], true);
expect(moved[0].fixedLeg).toEqual(portageLegBetween(A, { lat: 47.012, lng: B.lng }));
}); });
it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => { it('keeps a portage and its time when a paddle tour is redrawn, and moves the time to the leg that leads it', () => {
@@ -14,12 +14,6 @@ export interface RoutePoint {
lng: number; lng: number;
fixedLeg?: TourFixedLeg | null; fixedLeg?: TourFixedLeg | null;
lock?: PaddleLock | null; lock?: PaddleLock | null;
/**
* PAD-03: the boat is carried on from this point, the last of the tour so
* far; the portage is drawn to the next point once it is set. The planner's
* own, never saved.
*/
carryNext?: boolean;
} }
/** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */ /** Closer than this, in degrees, two waypoints stand on the same spot (about a centimetre). */
@@ -120,46 +114,25 @@ function extrasOnFirstLeg<T extends RoutePoint>(points: T[]): T[] {
return points; return points;
} }
/** A line still joins its points when its ends lie this close to them, in degrees (about two metres). */
const JOINS_DEG = 2e-5;
function stillJoins(leg: TourFixedLeg, from: { lat: number; lng: number }, to: { lat: number; lng: number }): boolean {
const first = leg.geometry[0];
const last = leg.geometry[leg.geometry.length - 1];
const near = (end: readonly unknown[] | undefined, point: { lat: number; lng: number }) =>
!!end && Math.abs(Number(end[0]) - point.lat) < JOINS_DEG && Math.abs(Number(end[1]) - point.lng) < JOINS_DEG;
return near(first, from) && near(last, to);
}
/** /**
* Every hand-drawn leg redrawn straight to the waypoint that now follows it, * Every hand-drawn leg, and every portage, redrawn straight to the waypoint
* so moving a point drags it along instead of tearing it off; a leg on the * that now follows it, so moving a point drags them along instead of tearing
* last point leads nowhere and goes. On a hike a portage and a lock are a * them off. A leg on the last point leads nowhere and goes, and so does a lock
* paddler's and go. On a paddle tour (`paddle`) a via ferrata is a hiker's * nobody paddles to on a tour that is no paddle tour. With `allFree` every
* and goes back to the router; a portage and a lock stay, a portage is * leg that is not a portage becomes hand-drawn, the way a paddle tour is planned.
* dragged along like a drawn leg, and a portage or drawn leg whose line has
* a shape of its own (a portage way the router followed) keeps it while its
* ends still meet their points. A leg without a line stays routed. A portage
* asked for at the last point of a paddle tour becomes one once a point follows.
*/ */
export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle = false): T[] { export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], allFree = false): T[] {
const aligned = points.map((current, index) => { const aligned = points.map((point, index) => {
const next = points[index + 1]; const next = points[index + 1];
const { carryNext, ...own } = current; const leg = point.fixedLeg;
if (carryNext && paddle) { const kept = allFree ? point : dropPaddleOnly(point);
if (!next) return current; const free = allFree || leg?.kind === 'free';
return { ...own, fixedLeg: portageLegBetween(current, next) } as T; if (!free && !(allFree && isPortage(leg))) return kept;
}
const point = (carryNext ? own : current) as T;
const kept = paddle ? dropHikeOnly(point) : dropPaddleOnly(point);
const leg = kept.fixedLeg;
const follows = leg?.kind === 'free' || (paddle && isPortage(leg));
if (!follows || !leg) return kept;
if (!next) { if (!next) {
if (!kept.fixedLeg) return kept;
const { fixedLeg: _dropped, ...rest } = kept; const { fixedLeg: _dropped, ...rest } = kept;
return rest as T; return rest as T;
} }
if (paddle && leg.geometry.length > 2 && stillJoins(leg, point, next)) return kept;
return { return {
...kept, ...kept,
fixedLeg: isPortage(leg) fixedLeg: isPortage(leg)
@@ -167,14 +140,7 @@ export function alignFreeLegs<T extends RoutePoint>(points: readonly T[], paddle
: freeLegBetween(point, next), : freeLegBetween(point, next),
}; };
}); });
return paddle ? extrasOnFirstLeg(aligned) : aligned; return allFree ? extrasOnFirstLeg(aligned) : aligned;
}
/** A boat climbs no via ferrata: its leg goes back to the router, on the water. */
function dropHikeOnly<T extends RoutePoint>(point: T): T {
if (point.fixedLeg?.kind !== 'ferrata') return point;
const { fixedLeg: _ferrata, ...routed } = point;
return routed as T;
} }
/** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */ /** A hike carries no boat: a portage turns back into a routed leg and a lock goes. */
@@ -185,21 +151,13 @@ function dropPaddleOnly<T extends RoutePoint>(point: T): T {
return plain as T; return plain as T;
} }
/** /** PAD-03: the leg after this point carried as a portage, or paddled again. */
* PAD-03: the leg after this point carried as a portage, or paddled again. At
* the last point there is no leg yet: the portage waits for the next point.
*/
export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] { export function togglePortageLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
const index = points.findIndex((point) => point.id === id); const index = points.findIndex((point) => point.id === id);
const point = points[index]; const point = points[index];
const next = points[index + 1]; const next = points[index + 1];
if (!point) return [...points]; if (!point || !next) return [...points];
const copy = [...points]; const copy = [...points];
if (!next) {
const { carryNext, ...own } = point;
copy[index] = (carryNext ? own : { ...point, carryNext: true }) as T;
return copy;
}
copy[index] = { copy[index] = {
...point, ...point,
fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next), fixedLeg: isPortage(point.fixedLeg) ? freeLegBetween(point, next) : portageLegBetween(point, next),
@@ -231,15 +189,14 @@ export function setWaypointLock<T extends RoutePoint>(points: readonly T[], id:
} }
/** /**
* The leg after this waypoint drawn by hand, or routed again (on a paddle * The leg after this waypoint drawn by hand, or routed again. A via ferrata
* tour: on the water). A via ferrata and a portage keep their line; the last * keeps its line; the last point has no leg to change.
* point has no leg to change.
*/ */
export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] { export function toggleFreeLeg<T extends RoutePoint>(points: readonly T[], id: string): T[] {
const index = points.findIndex((point) => point.id === id); const index = points.findIndex((point) => point.id === id);
const point = points[index]; const point = points[index];
const next = points[index + 1]; const next = points[index + 1];
if (!point || !next || point.fixedLeg?.kind === 'ferrata' || isPortage(point.fixedLeg)) return [...points]; if (!point || !next || point.fixedLeg?.kind === 'ferrata') return [...points];
const copy = [...points]; const copy = [...points];
if (point.fixedLeg?.kind === 'free') { if (point.fixedLeg?.kind === 'free') {
const { fixedLeg: _dropped, ...rest } = point; const { fixedLeg: _dropped, ...rest } = point;
@@ -1,47 +0,0 @@
// FE-TOUR-ROUTE-STYLE-001 to FE-TOUR-ROUTE-STYLE-003: how the planner map draws the parts of a tour (PAD-03, PAD-11).
import { describe, expect, it } from 'vitest';
import { tourRouteStyle } from './tourRouteStyle';
import type { TourRoutePart } from './tourRouting';
const PAINT = { portage: 'brown', rapids: 'amber', gap: 'red', casing: 'white' };
const line: [number, number][] = [
[49, 12],
[49.01, 12.01],
];
const part = (extra: Partial<TourRoutePart> = {}): TourRoutePart => ({ coordinates: line, free: false, ...extra });
describe('tourRouteStyle', () => {
it('FE-TOUR-ROUTE-STYLE-001: a routed tour is one plain line, and no parts draw nothing', () => {
expect(tourRouteStyle([part(), part()], true, PAINT)).toEqual({ walking: null, colors: null });
expect(tourRouteStyle(null, true, PAINT)).toEqual({ walking: null, colors: null });
});
it('FE-TOUR-ROUTE-STYLE-002: a paddle tour dots and colours what no router planned and what the paddler should see', () => {
const style = tourRouteStyle(
[
part(),
part({ land: true }),
part({ mark: 'rapids' }),
part({ mark: 'gap' }),
part({ mark: 'unrouted' }),
part({ free: true }),
],
true,
PAINT
);
expect(style.walking).toEqual([false, true, false, true, true, true]);
expect(style.colors).toEqual([
undefined,
{ line: 'brown', casing: 'white' },
{ line: 'amber', casing: 'white' },
{ line: 'red', casing: 'white' },
{ line: 'red', casing: 'white' },
undefined,
]);
});
it('FE-TOUR-ROUTE-STYLE-003: a hike dots its hand-drawn stretches and keeps its own colour', () => {
const style = tourRouteStyle([part(), part({ free: true })], false, PAINT);
expect(style).toEqual({ walking: [false, true], colors: null });
});
});
@@ -1,47 +1,36 @@
import type { TourRoutePart } from './tourRouting'; import type { TourRoutePart } from './tourRouting';
/** /**
* PAD-03, PAD-11: the paddle paints of the planner map, resolved from the * PAD-03: a portage on the map, in an earth brown over a rim in the map's own
* paddle tokens (`usePaddlePaint`): a portage in earth brown, whitewater and * white, dotted the way the map draws a walk: the boat is carried there, not
* boat passes in amber, a gap in the data or a leg no route was found for in * paddled. The colours come resolved from the paddle tokens (`usePaddlePaint`).
* red, each over a rim in the map's own white.
*/ */
export interface RoutePaint { export interface PortagePaint {
portage: string; line: string;
rapids: string;
gap: string;
casing: string; casing: string;
} }
export interface TourRouteStyle { export interface TourRouteStyle {
/** Per line: dotted (a stretch nobody routed: drawn by hand, carried, or a gap) or solid. */ /** Per line: dotted (a hand-drawn stretch of a hike, a portage) or solid. */
walking: boolean[] | null; walking: boolean[] | null;
/** Per line: a paddle paint, or the route's own colour where undefined. */ /** Per line: the portage brown, or the route's own colour where undefined. */
colors: ({ line: string; casing: string } | undefined)[] | null; colors: ({ line: string; casing: string } | undefined)[] | null;
} }
function paddleColor(part: TourRoutePart, paint: RoutePaint): { line: string; casing: string } | undefined {
if (part.land) return { line: paint.portage, casing: paint.casing };
if (part.mark === 'rapids') return { line: paint.rapids, casing: paint.casing };
if (part.mark === 'gap' || part.mark === 'unrouted') return { line: paint.gap, casing: paint.casing };
return undefined;
}
/** /**
* How the planner map draws the parts of a tour being edited. What no router * How the planner map draws the parts of a tour being edited. A hike draws
* planned is dotted, so a routed path and a hand-drawn one never look alike: * what no router planned dotted, so a routed path and a hand-drawn one never
* a hike's hand-drawn stretches, a paddle tour's portages, gaps, unrouted legs * look alike. A paddle tour is drawn by hand throughout, so its water is the
* and stretches drawn by hand. A paddle tour also colours what the paddler * plain line and only its portages stand out, dotted and brown.
* should look at.
*/ */
export function tourRouteStyle( export function tourRouteStyle(
parts: readonly TourRoutePart[] | null, parts: readonly TourRoutePart[] | null,
isPaddle: boolean, isPaddle: boolean,
paint: RoutePaint portage: PortagePaint
): TourRouteStyle { ): TourRouteStyle {
if (!parts?.length) return { walking: null, colors: null }; if (!parts?.length) return { walking: null, colors: null };
const walking = parts.map((part) => part.free || !!part.land || part.mark === 'gap' || part.mark === 'unrouted'); const walking = parts.map((part) => (isPaddle ? !!part.land : part.free));
const colors = parts.map((part) => (isPaddle ? paddleColor(part, paint) : undefined)); const colors = parts.map((part) => (part.land ? { line: portage.line, casing: portage.casing } : undefined));
return { return {
walking: walking.some(Boolean) ? walking : null, walking: walking.some(Boolean) ? walking : null,
colors: colors.some(Boolean) ? colors : null, colors: colors.some(Boolean) ? colors : null,
@@ -9,12 +9,6 @@ export interface TourRoutePart {
free: boolean free: boolean
/** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */ /** PAD-03: carried over land, a portage of a paddle tour; absent on water and on a hike. */
land?: boolean land?: boolean
/**
* PAD-11: on a routed paddle tour, a stretch the paddler should look at:
* whitewater or a boat pass, a gap in the data the router bridged, or a leg
* it could not plan (drawn straight, and nothing checked it).
*/
mark?: 'rapids' | 'gap' | 'unrouted'
} }
export interface TourRouteResult { export interface TourRouteResult {
@@ -1,7 +1,6 @@
import { import {
recommendedPaddleSpeedKmh, recommendedPaddleSpeedKmh,
type PaddleBoatType, type PaddleBoatType,
type PaddleLakeStyle,
type PaddleWaterContext, type PaddleWaterContext,
type TourListItem, type TourListItem,
} from '@trek/shared'; } from '@trek/shared';
@@ -14,8 +13,6 @@ export interface PaddleFieldValues {
speedKmh: number | null; speedKmh: number | null;
/** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */ /** PAD-07: the carrying speed the paddler chose, km/h; null while the starting value applies. Absent in an older draft. */
portageSpeedKmh?: number | null; portageSpeedKmh?: number | null;
/** PAD-11: how lakes are routed; null (or absent, in an older draft) is straight over. */
lakeStyle?: PaddleLakeStyle | null;
} }
export const NO_PADDLE_FIELDS: PaddleFieldValues = { export const NO_PADDLE_FIELDS: PaddleFieldValues = {
@@ -23,22 +20,17 @@ export const NO_PADDLE_FIELDS: PaddleFieldValues = {
waterContext: null, waterContext: null,
speedKmh: null, speedKmh: null,
portageSpeedKmh: null, portageSpeedKmh: null,
lakeStyle: null,
}; };
/** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */ /** The paddle fields of a saved tour, or none for a hike or a list cached before they existed. */
export function paddleFieldsOf( export function paddleFieldsOf(
tour: Pick< tour: Pick<TourListItem, 'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh'>
TourListItem,
'boat_type' | 'water_context' | 'paddle_speed_kmh' | 'portage_speed_kmh' | 'paddle_lake_style'
>
): PaddleFieldValues { ): PaddleFieldValues {
return { return {
boatType: tour.boat_type ?? null, boatType: tour.boat_type ?? null,
waterContext: tour.water_context ?? null, waterContext: tour.water_context ?? null,
speedKmh: tour.paddle_speed_kmh ?? null, speedKmh: tour.paddle_speed_kmh ?? null,
portageSpeedKmh: tour.portage_speed_kmh ?? null, portageSpeedKmh: tour.portage_speed_kmh ?? null,
lakeStyle: tour.paddle_lake_style ?? null,
}; };
} }
@@ -59,8 +51,7 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
merged.boatType === values.boatType && merged.boatType === values.boatType &&
merged.waterContext === values.waterContext && merged.waterContext === values.waterContext &&
Object.is(merged.speedKmh, values.speedKmh) && Object.is(merged.speedKmh, values.speedKmh) &&
Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null) && Object.is(merged.portageSpeedKmh ?? null, values.portageSpeedKmh ?? null);
(merged.lakeStyle ?? null) === (values.lakeStyle ?? null);
if (!same && onEdit()) setValues(merged); if (!same && onEdit()) setValues(merged);
}, },
[onEdit, values] [onEdit, values]
@@ -70,7 +61,6 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
return { return {
...values, ...values,
portageSpeedKmh: values.portageSpeedKmh ?? null, portageSpeedKmh: values.portageSpeedKmh ?? null,
lakeStyle: values.lakeStyle ?? 'direct',
recommendedSpeedKmh: recommended, recommendedSpeedKmh: recommended,
/** The speed the time is worked out with: the chosen one, else the boat's suggestion. */ /** The speed the time is worked out with: the chosen one, else the boat's suggestion. */
effectiveSpeedKmh: values.speedKmh ?? recommended, effectiveSpeedKmh: values.speedKmh ?? recommended,
@@ -79,8 +69,6 @@ export function usePaddleFields(initial: PaddleFieldValues, onEdit: () => boolea
setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]), setSpeedKmh: useCallback((speedKmh: number | null) => edit({ speedKmh }), [edit]),
/** PAD-07: the carrying speed, or back to the starting value with null. */ /** PAD-07: the carrying speed, or back to the starting value with null. */
setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]), setPortageSpeedKmh: useCallback((portageSpeedKmh: number | null) => edit({ portageSpeedKmh }), [edit]),
/** PAD-11: straight over the lakes, or along their shores. */
setLakeStyle: useCallback((lakeStyle: PaddleLakeStyle) => edit({ lakeStyle }), [edit]),
/** Back to the boat's suggestion, by choice. */ /** Back to the boat's suggestion, by choice. */
adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]), adoptRecommendedSpeed: useCallback(() => edit({ speedKmh: null }), [edit]),
/** Loaded from a tour or a draft, or cleared; not an edit. */ /** Loaded from a tour or a draft, or cleared; not an edit. */
@@ -1,71 +0,0 @@
import { useCallback, type RefObject } from 'react';
import { randomId } from '../../../utils/randomId';
import { withDrawnLegsRouted, withShaping, withShapingPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
type Commit = (change: (current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]) => void;
/**
* P2A-03, P2A-05 and PAD-11: the editor's tools for the line between the
* stops. A shaping point steers the route without being a stop, a routed leg
* can be planned afresh, and a paddle tour's legs drawn by hand go back to the
* router in one step. Each change is one step of the history, through the
* planner's own commit; nothing here plans a route itself.
*/
export function useRouteShaping({
commit,
waypointsRef,
mayEdit,
isPaddle,
retry,
forgetLeg,
}: {
commit: Commit;
waypointsRef: RefObject<TourPlannerWaypoint[]>;
/** Whether the route may change now: the permission held and no save under way. */
mayEdit: () => boolean;
isPaddle: () => boolean;
/** Plans the route again as it stands. */
retry: () => void;
/** Drops the answer kept for a paddle leg, so the router is asked again. */
forgetLeg: (from: TourPlannerWaypoint, to: TourPlannerWaypoint) => void;
}) {
/** A shaping point into the leg after `afterId`; its id, or null when there is no such leg. */
const insertShapingPoint = useCallback(
(afterId: string, lat: number, lng: number) => {
if (!mayEdit() || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const points = waypointsRef.current;
const shape = { id: randomId(), lat, lng };
if (withShapingPoint(points, afterId, shape) === points) return null;
commit((current) => withShapingPoint(current, afterId, shape));
return shape.id;
},
[commit, mayEdit, waypointsRef]
);
const setWaypointShaping = useCallback(
(id: string, shaping: boolean) => {
if (mayEdit()) commit((current) => withShaping(current, id, shaping));
},
[commit, mayEdit]
);
const routeDrawnLegsOnWater = useCallback(() => {
if (mayEdit() && isPaddle()) commit(withDrawnLegsRouted);
}, [commit, isPaddle, mayEdit]);
/** The routed leg after this waypoint planned afresh, past any answer kept from before. */
const recomputeLeg = useCallback(
(id: string) => {
const points = waypointsRef.current;
const index = points.findIndex((point) => point.id === id);
const point = points[index];
const next = points[index + 1];
if (!point || !next || point.fixedLeg) return;
if (isPaddle()) forgetLeg(point, next);
retry();
},
[forgetLeg, isPaddle, retry, waypointsRef]
);
return { insertShapingPoint, setWaypointShaping, routeDrawnLegsOnWater, recomputeLeg };
}
@@ -1,26 +0,0 @@
import { useCallback } from 'react';
import { legStartAt } from './routeLegAt';
import type { TourPlannerController } from './useTourPlanner';
/**
* P2A-03: a click on the route being edited sets a shaping point where it
* landed, in the leg that runs there, and selects it, ready to be dragged
* where the route should go. Undefined while the points cannot be moved, or
* while a click on the line picks a spot for something else.
*/
export function useShapingClick(
planner: Pick<TourPlannerController, 'waypoints' | 'route' | 'insertShapingPoint' | 'setSelectedWaypointId'>,
{ editable, picking }: { editable: boolean; picking: boolean }
): ((lat: number, lng: number) => void) | undefined {
const { waypoints, route, insertShapingPoint, setSelectedWaypointId } = planner;
const shapeAt = useCallback(
(lat: number, lng: number) => {
const after = legStartAt(waypoints, route, lat, lng);
if (!after) return;
const id = insertShapingPoint(after, lat, lng);
if (id) setSelectedWaypointId(id);
},
[insertShapingPoint, route, setSelectedWaypointId, waypoints]
);
return editable && !picking ? shapeAt : undefined;
}
@@ -1,20 +1,16 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, paddlePlan, paddleForget } = const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour } = vi.hoisted(() => ({
vi.hoisted(() => ({ routeWalkingTour: vi.fn(),
routeWalkingTour: vi.fn(), enrichTourElevations: vi.fn(),
enrichTourElevations: vi.fn(), createTour: vi.fn(),
createTour: vi.fn(), detailTour: vi.fn(),
detailTour: vi.fn(), updateTour: vi.fn(),
updateTour: vi.fn(), }));
paddlePlan: vi.fn(),
paddleForget: vi.fn(),
}));
vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations })); vi.mock('./tourRouting', () => ({ routeWalkingTour, enrichTourElevations }));
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } })); vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
vi.mock('../../../repo/paddleRouteRepo', () => ({ paddleRouteRepo: { plan: paddlePlan, forget: paddleForget } }));
vi.mock('../useTourPermissions', () => ({ vi.mock('../useTourPermissions', () => ({
useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({ useTourPermissions: ({ canEdit = true, canAssign = true }: { canEdit?: boolean; canAssign?: boolean }) => ({
canEdit, canEdit,
@@ -24,12 +20,6 @@ vi.mock('../useTourPermissions', () => ({
import { useTourPlanner } from './useTourPlanner'; import { useTourPlanner } from './useTourPlanner';
/** The value, or a failure saying it is missing instead of a later undefined. */
function must<T>(value: T | null | undefined): T {
if (value === null || value === undefined) throw new Error('expected a value');
return value;
}
const routed = { const routed = {
coordinates: [ coordinates: [
[48, 11], [48, 11],
@@ -74,20 +64,6 @@ function plannerWith(points: Array<[number, number]>) {
return hook; return hook;
} }
type LegAsked = { from: { lat: number; lng: number }; to: { lat: number; lng: number } };
/** The water router's answer for every leg asked: a straight stretch on the water, 1.2 km long. */
const onWater = (legs: LegAsked[]) =>
legs.map(({ from, to }) => ({
status: 'ok' as const,
geometry: [
[from.lat, from.lng],
[to.lat, to.lng],
],
segments: [{ kind: 'water' as const, from: 0, to: 1 }],
distance_m: 1200,
snapped_m: { from: 0, to: 0 },
}));
const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]); const coordinatesOf = (points: Array<{ lat: number; lng: number }>) => points.map((point) => [point.lat, point.lng]);
describe('useTourPlanner route tools', () => { describe('useTourPlanner route tools', () => {
@@ -95,8 +71,6 @@ describe('useTourPlanner route tools', () => {
vi.useFakeTimers(); vi.useFakeTimers();
localStorage.clear(); localStorage.clear();
routeWalkingTour.mockReset().mockResolvedValue(routed); routeWalkingTour.mockReset().mockResolvedValue(routed);
paddlePlan.mockReset().mockImplementation(async (_trip: number, legs: LegAsked[]) => onWater(legs));
paddleForget.mockReset();
enrichTourElevations.mockReset().mockResolvedValue(enriched); enrichTourElevations.mockReset().mockResolvedValue(enriched);
createTour.mockReset(); createTour.mockReset();
detailTour.mockReset(); detailTour.mockReset();
@@ -247,7 +221,7 @@ describe('useTourPlanner route tools', () => {
}); });
describe('paddle tours (PAD-01)', () => { describe('paddle tours (PAD-01)', () => {
it('plans every leg on the water once paddle is chosen, and one undo takes the type back', async () => { it('draws every leg by hand once paddle is chosen, and one undo takes the type and the legs back', () => {
const { result } = plannerWith([ const { result } = plannerWith([
[48, 11], [48, 11],
[48.01, 11.02], [48.01, 11.02],
@@ -255,22 +229,9 @@ describe('useTourPlanner route tools', () => {
]); ]);
act(() => result.current.setTourType('kayak')); act(() => result.current.setTourType('kayak'));
expect(result.current.isPaddle).toBe(true); expect(result.current.isPaddle).toBe(true);
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]); expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
routeWalkingTour.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
expect(routeWalkingTour).not.toHaveBeenCalled();
expect(paddlePlan).toHaveBeenLastCalledWith(
31,
expect.any(Array),
{ boat: 'kayak', lakeStyle: 'direct' },
expect.any(AbortSignal)
);
expect(result.current.status).toBe('ready');
expect(result.current.paddleRoute?.legLengths).toEqual([1200, 1200]);
act(() => result.current.addWaypoint(48.03, 11.01)); act(() => result.current.addWaypoint(48.03, 11.01));
expect(result.current.waypoints[2].fixedLeg).toBeUndefined(); expect(result.current.waypoints[2].fixedLeg?.kind).toBe('free');
act(() => result.current.undo()); act(() => result.current.undo());
act(() => result.current.undo()); act(() => result.current.undo());
expect(result.current.tourType).toBe('hike'); expect(result.current.tourType).toBe('hike');
@@ -279,6 +240,7 @@ describe('useTourPlanner route tools', () => {
it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => { it('holds Save back until a boat and the water are chosen, then saves them without a hiking limit', async () => {
createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] }); createTour.mockResolvedValue({ tour: { ...savedTour, tour_type: 'kayak' }, waypoints: [] });
routeWalkingTour.mockResolvedValue({ ...routed, parts: [{ ...routed.parts[0], free: true }] });
const { result } = plannerWith([ const { result } = plannerWith([
[48, 11], [48, 11],
[48.01, 11.02], [48.01, 11.02],
@@ -302,14 +264,11 @@ describe('useTourPlanner route tools', () => {
boat_type: 'canoe', boat_type: 'canoe',
water_context: 'lake', water_context: 'lake',
paddle_speed_kmh: null, paddle_speed_kmh: null,
paddle_lake_style: 'direct',
max_hiking_difficulty: null, max_hiking_difficulty: null,
}); });
// A paddle tour carries no heights to look up.
expect(enrichTourElevations).not.toHaveBeenCalled();
}); });
it('times the water by the routed length at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => { it('times the water at the boat speed, keeps a chosen speed when the boat changes, and takes the suggestion only on purpose', async () => {
const { result } = plannerWith([ const { result } = plannerWith([
[48, 11], [48, 11],
[48.01, 11.02], [48.01, 11.02],
@@ -319,13 +278,13 @@ describe('useTourPlanner route tools', () => {
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(450); await vi.advanceTimersByTimeAsync(450);
}); });
// 1.2 km on the water the router found, at 4 km/h. const distanceKm = result.current.routeAnalysis?.distanceKm ?? 0;
expect(result.current.walkingDurationMinutes).toBe(18); expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 4) * 60));
act(() => result.current.paddle.setSpeedKmh(6)); act(() => result.current.paddle.setSpeedKmh(6));
act(() => result.current.paddle.setBoatType('other')); act(() => result.current.paddle.setBoatType('other'));
expect(result.current.paddle.speedKmh).toBe(6); expect(result.current.paddle.speedKmh).toBe(6);
expect(result.current.walkingDurationMinutes).toBe(12); expect(result.current.walkingDurationMinutes).toBe(Math.round((distanceKm / 6) * 60));
act(() => result.current.paddle.adoptRecommendedSpeed()); act(() => result.current.paddle.adoptRecommendedSpeed());
expect(result.current.paddle.recommendedSpeedKmh).toBeNull(); expect(result.current.paddle.recommendedSpeedKmh).toBeNull();
@@ -384,137 +343,4 @@ describe('useTourPlanner route tools', () => {
expect(old.result.current.name).toBe('Old'); expect(old.result.current.name).toBe('Old');
}); });
}); });
describe('shaping and water routing (P2A-03, P2A-05, PAD-11)', () => {
const settle = () =>
act(async () => {
await vi.advanceTimersByTimeAsync(450);
});
it('sets a shaping point into a leg, makes it a stop and back, and never past the end', async () => {
const { result } = plannerWith([
[48, 11],
[48.02, 11.02],
]);
const [start, end] = result.current.waypoints;
let id: string | null = null;
act(() => {
id = result.current.insertShapingPoint(must(start).id, 48.01, 11.005);
});
expect(result.current.waypoints.map((point) => !!point.shaping)).toEqual([false, true, false]);
expect(result.current.waypoints[1]?.id).toBe(id);
act(() => {
expect(result.current.insertShapingPoint(must(end).id, 48.03, 11.03)).toBeNull();
expect(result.current.insertShapingPoint(must(start).id, Number.NaN, 11)).toBeNull();
});
act(() => result.current.setWaypointShaping(must(id), false));
expect(result.current.waypoints[1]).not.toHaveProperty('shaping');
act(() => result.current.setWaypointShaping(must(start).id, true));
expect(result.current.waypoints[0]).not.toHaveProperty('shaping');
// One undo takes back one change.
act(() => result.current.undo());
expect(result.current.waypoints[1]?.shaping).toBe(true);
await settle();
expect(routeWalkingTour).toHaveBeenLastCalledWith(
expect.arrayContaining([expect.objectContaining({ shaping: true })]),
expect.any(AbortSignal),
2
);
});
it('plans a leg afresh: a paddle leg is forgotten and asked again, a fixed leg is left alone', async () => {
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
]);
act(() => result.current.setTourType('kayak'));
await settle();
expect(paddlePlan).toHaveBeenCalledTimes(1);
const [start, end] = result.current.waypoints;
act(() => result.current.recomputeLeg(must(start).id));
expect(paddleForget).toHaveBeenCalledWith(31, start, end, { boat: 'kayak', lakeStyle: 'direct' });
await settle();
expect(paddlePlan).toHaveBeenCalledTimes(2);
act(() => result.current.recomputeLeg(must(end).id));
expect(paddleForget).toHaveBeenCalledTimes(1);
});
it('sends every leg drawn by hand back to the water router in one step', async () => {
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
[48.02, 11],
]);
act(() => result.current.setTourType('kayak'));
for (const point of result.current.waypoints.slice(0, 2))
act(() => result.current.toggleWaypointFreeLeg(point.id));
expect(result.current.waypoints.map((point) => point.fixedLeg?.kind)).toEqual(['free', 'free', undefined]);
act(() => result.current.routeDrawnLegsOnWater());
expect(result.current.waypoints.map((point) => point.fixedLeg)).toEqual([undefined, undefined, undefined]);
act(() => result.current.undo());
expect(result.current.waypoints[0]?.fixedLeg?.kind).toBe('free');
});
it('keeps a tour with an unplanned leg from being saved, and says it per leg', async () => {
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
legs.map(() => ({ status: 'failed', code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } }))
);
const { result } = plannerWith([
[48, 11],
[48.01, 11.02],
]);
act(() => result.current.setName('Off the water'));
act(() => result.current.setTourType('kayak'));
act(() => result.current.paddle.setBoatType('kayak'));
act(() => result.current.paddle.setWaterContext('river'));
await settle();
expect(result.current.status).toBe('routing-failed');
expect(result.current.error).toBe('paddle-route');
expect(result.current.paddleRoute?.failures).toEqual([
{ index: 0, code: 'not_on_water', at: { lat: 48.01, lng: 11.02 } },
]);
expect(result.current.routeCurrent).toBe(true);
expect(result.current.canSave).toBe(false);
});
it('sets the portage and the lock the router found as points of their own, and they go when their leg changes', async () => {
paddlePlan.mockImplementation(async (_trip: number, legs: LegAsked[]) =>
legs.map(({ from, to }) => {
const mid = (a: number, b: number, t: number) => a + (b - a) * t;
const at = (t: number): [number, number] => [mid(from.lat, to.lat, t), mid(from.lng, to.lng, t)];
const geometry = [at(0), at(0.3), at(0.4), at(1)];
const segments = [
{ kind: 'water', from: 0, to: 1 },
{ kind: 'portage', from: 1, to: 2, source: 'tagged' },
{ kind: 'water', from: 2, to: 3 },
].map((segment) => ({ name: null, source: null, osm_ref: null, grade: null, ...segment }));
return { status: 'ok', geometry, segments, distance_m: 3000, snapped_m: { from: 0, to: 0 } };
})
);
const { result } = plannerWith([
[48, 11],
[48.1, 11],
]);
act(() => result.current.setTourType('kayak'));
await settle();
await settle();
expect(result.current.waypoints).toHaveLength(4);
expect(result.current.waypoints[1]).toMatchObject({ fixedLeg: { kind: 'portage' } });
expect(result.current.waypoints.filter((point) => point.auto)).toHaveLength(2);
// Found by the router, not done by the paddler: no step of the history of its own.
act(() => result.current.undo());
expect(result.current.tourType).toBe('hike');
act(() => result.current.redo());
await settle();
await settle();
const end = result.current.waypoints[result.current.waypoints.length - 1];
act(() => {
void result.current.setWaypointPosition(end.id, 48.2, 11);
});
await settle();
expect(result.current.waypoints.filter((point) => point.auto).every((point) => point.auto?.to.lat === 48.2)).toBe(
true
);
});
});
}); });

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