mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 301a5bbb5a | |||
| d3da6ca08d | |||
| 4448455a46 | |||
| 189ea3be61 | |||
| 32dfc75756 | |||
| 717915d540 | |||
| f70a4eedf5 | |||
| 8c93dc8d9c | |||
| 6bd19e2294 | |||
| fe78831bd9 | |||
| a2b6378245 | |||
| 1321b28f2a | |||
| 137d948a24 | |||
| c45a0552ef | |||
| 961e2e4745 | |||
| 6d64ab1006 | |||
| 18695fbb1a | |||
| 255c141a92 | |||
| e803d28bf1 | |||
| 5d65bbd03b | |||
| 33255b168f | |||
| 187c4e2fc8 | |||
| dfd02940b5 | |||
| da0f79e285 | |||
| e877d35e0b | |||
| f3a39d9452 | |||
| b374fdc989 | |||
| b8eb2d909e | |||
| e429b49ef6 | |||
| 73237ffedb | |||
| adcaca3c5f | |||
| c3229afcba | |||
| 12e7bdf6e8 | |||
| 9382fa6dc6 | |||
| 3332495b7a | |||
| 75d8351fec | |||
| ce07c4dc72 | |||
| c5c88691bc | |||
| 827e682684 | |||
| 11a8d1b4d1 | |||
| 692373c8b8 | |||
| f6fd2722d0 | |||
| 9cd227fd81 | |||
| e4b49e1148 | |||
| a87ce65899 | |||
| 06e6e25b25 | |||
| d322363e53 | |||
| a20b682087 | |||
| ad86713fdc | |||
| 66525c66b7 | |||
| 7a484a09d4 | |||
| d72352911e | |||
| 134ab64bce | |||
| e29aa99269 | |||
| 7907963e03 | |||
| 45b9f07238 | |||
| d1ec6fb145 | |||
| 961d223c7c | |||
| faabc533c6 | |||
| bb39745a23 | |||
| c9e1e8ad86 | |||
| 9301053c56 | |||
| cda6e4bfd1 | |||
| dcc1c18fa9 | |||
| 7e9297d867 | |||
| a19d67e5a2 | |||
| cd64a2f542 | |||
| 1bc82f97fd | |||
| b76a298bb3 | |||
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 | |||
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 |
@@ -257,6 +257,15 @@ jobs:
|
||||
# migration that changes a column regenerates it in the same change.
|
||||
run: cd server && npm run check:db-types
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Errors fail, and the warnings per rule may not pass
|
||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||
@@ -264,6 +273,7 @@ jobs:
|
||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd server && npm run lint:warnings
|
||||
|
||||
- name: Formatting check
|
||||
@@ -395,6 +405,15 @@ jobs:
|
||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||
run: cd client && npm run lint:strict
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||
# whose warning count rises above scripts/eslint-baseline.json.
|
||||
@@ -402,6 +421,7 @@ jobs:
|
||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd client && npm run lint:warnings
|
||||
|
||||
- name: Page pattern check
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 875,
|
||||
"@typescript-eslint/no-misused-promises": 874,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
@@ -12,11 +12,11 @@
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
"@typescript-eslint/no-unused-vars": 130,
|
||||
"@typescript-eslint/no-unused-vars": 129,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
|
||||
@@ -571,6 +571,12 @@
|
||||
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
|
||||
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Public transit for one leg, between two city walks from the first one's end, and a journey late for the next walk",
|
||||
"hooks": ["src/components/Planner/useTransitLegPlanner.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||
*/
|
||||
import { ESLint } from 'eslint';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -137,6 +138,90 @@ export async function lintClient(root) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||
|
||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||
function gitIn(root, args) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 1 << 28,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The messages of one rule in one area of a single file's lint result. */
|
||||
function ofRule(results, rule, area) {
|
||||
const result = results[0];
|
||||
if (!result) return [];
|
||||
const pool =
|
||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||
return pool.filter((m) => ruleOf(m) === rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||
* The count alone names no file, and the files holding the most are old ones;
|
||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||
* without it), and the report then keeps to the files holding the most.
|
||||
*/
|
||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||
if (changed === null) return null;
|
||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||
const found = await Promise.all(
|
||||
keys
|
||||
.filter((key) => differs.has(key))
|
||||
.map(async (key) => {
|
||||
const path = join(root, key);
|
||||
// A typed rule can trip over a text that is not the file on disk (the
|
||||
// base version); then the file's own warnings are told, marked so.
|
||||
const lint = async (text) => {
|
||||
try {
|
||||
return { messages: ofRule(await lintText(text, path), rule, area) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const now = await lint(readText(path));
|
||||
if (!now) return [];
|
||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||
const was = before === null ? { messages: [] } : await lint(before);
|
||||
if (was && now.messages.length <= was.messages.length) return [];
|
||||
const note = was ? '' : ' (the base version could not be linted)';
|
||||
return now.messages.map((m) => `${key}:${m.line ?? 0} ${m.message}${note}`);
|
||||
})
|
||||
);
|
||||
return found.flat();
|
||||
}
|
||||
|
||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||
async function reportNew(grown, { root, files, lintText, error }) {
|
||||
if (!grown.length || !lintText) return;
|
||||
const found = await Promise.all(
|
||||
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
||||
);
|
||||
grown.forEach(({ area, rule }, i) => {
|
||||
const lines = found[i];
|
||||
if (!lines?.length) return;
|
||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||
for (const line of lines) error(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
||||
function lintTextClient(root) {
|
||||
const eslint = new ESLint({ cwd: root });
|
||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
@@ -148,6 +233,7 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint = lintClient,
|
||||
lintText = lint === lintClient ? lintTextClient : null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -160,13 +246,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -182,7 +268,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -191,5 +278,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2764,
|
||||
"src/components/Budget/CostsPanel.tsx": 1816,
|
||||
"src/components/Map/MapView.tsx": 1409,
|
||||
"src/components/Map/MapView.tsx": 1401,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2299,
|
||||
"src/components/Map/MapViewGL.tsx": 2296,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5378,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2971,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2969,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2864,
|
||||
"src/components/Planner/PlaceFormModal.tsx": 1250,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1304,
|
||||
@@ -24,7 +24,7 @@
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"src/pages/TripPlannerPage.tsx": 1141,
|
||||
"src/pages/atlas/useAtlas.ts": 1287,
|
||||
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
|
||||
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3309,
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 22,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { paddleRegionLoadResponseSchema, type PaddleRegionLoadRequest, type PaddleRegionLoadState } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-11C: the water data around places no paddle region covers yet, loaded or asked for by the server. */
|
||||
export const paddleRegionLoadApi = {
|
||||
load: (request: PaddleRegionLoadRequest, signal?: AbortSignal): Promise<PaddleRegionLoadState[]> =>
|
||||
apiClient
|
||||
.post('/paddle/regions/load', request, { signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionLoadResponseSchema.parse(r.data).points),
|
||||
};
|
||||
@@ -2,17 +2,19 @@ import {
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionSchema,
|
||||
paddleRegionSettingsSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegionRequest,
|
||||
type PaddleRegionSettings,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** A first look into Geofabrik's list can take the server a while (it fetches the list itself, within 20 s). */
|
||||
const GEOFABRIK = { timeout: 30_000 };
|
||||
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for. */
|
||||
/** PAD-05A, the admin side: the regions TREK reads paddle data for, and those paddlers asked for (PAD-11C). */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data)),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
@@ -29,4 +31,11 @@ export const paddleRegionsApi = {
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
decline: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/decline', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
settings: () => apiClient.get('/admin/paddle-regions/settings').then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
saveSettings: (settings: PaddleRegionSettings) =>
|
||||
apiClient.put('/admin/paddle-regions/settings', settings).then((r) => paddleRegionSettingsSchema.parse(r.data)),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { paddleRouteResponseSchema, type PaddleRouteInput, type PaddleRouteResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-11: legs of a paddle tour planned on the water by the server's router. */
|
||||
export const paddleRouteApi = {
|
||||
plan: (tripId: number | string, request: PaddleRouteInput, signal?: AbortSignal): Promise<PaddleRouteResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/paddle/route`, request, { signal })
|
||||
.then((r) => paddleRouteResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AdminFeatureToggleResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** An admin's switch for a map the Tours planner offers on its operator's terms. */
|
||||
function tourBaseMapAdminApi(path: string) {
|
||||
return {
|
||||
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get(path).then((r) => r.data),
|
||||
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
|
||||
apiClient.put(path, { enabled }).then((r) => r.data),
|
||||
};
|
||||
}
|
||||
|
||||
/** MAP-01: OpenHikingMap for a hike. */
|
||||
export const hikingMapAdminApi = tourBaseMapAdminApi('/admin/tours-hiking-map');
|
||||
/** BIK-06: CyclOSM for a bike tour. */
|
||||
export const cycleMapAdminApi = tourBaseMapAdminApi('/admin/tours-cycle-map');
|
||||
@@ -0,0 +1,33 @@
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* The file name a download's Content-Disposition gives: the UTF-8 `filename*`
|
||||
* first, since it keeps the tour's own script, the plain `filename` after.
|
||||
*/
|
||||
export function attachmentFilename(header: unknown): string | null {
|
||||
if (typeof header !== 'string') return null;
|
||||
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded.trim());
|
||||
} catch {
|
||||
// A malformed escape falls through to the plain name.
|
||||
}
|
||||
}
|
||||
return /filename="([^"]+)"/i.exec(header)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** PR1-06A: one saved tour as the GPX file the server writes. */
|
||||
export const tourGpxExportApi = {
|
||||
download: async (
|
||||
tripId: number | string,
|
||||
placeId: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ blob: Blob; filename: string | null }> => {
|
||||
const response = await apiClient.get<Blob>(`/trips/${tripId}/tours/${placeId}/export.gpx`, {
|
||||
responseType: 'blob',
|
||||
signal,
|
||||
});
|
||||
return { blob: response.data, filename: attachmentFilename(response.headers['content-disposition']) };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type {
|
||||
TourCreateResponse,
|
||||
TourLibraryDetailResponse,
|
||||
TourLibraryQuery,
|
||||
TourLibraryResponse,
|
||||
TourTakeOverRequest,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** LIB-02: the tours of the user's other trips, one's preview, and taking one over into a trip. */
|
||||
export const tourLibraryApi = {
|
||||
list: (query: TourLibraryQuery, signal?: AbortSignal): Promise<TourLibraryResponse> =>
|
||||
apiClient.get('/tours/library', { params: query, signal }).then((r) => r.data),
|
||||
detail: (placeId: number, signal?: AbortSignal): Promise<TourLibraryDetailResponse> =>
|
||||
apiClient.get(`/tours/library/${placeId}`, { signal }).then((r) => r.data),
|
||||
takeOver: (tripId: number | string, body: TourTakeOverRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/take-over`, body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TourPoiSearchRequest, TourPoiSearchResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* PLN-02: places along a tour. A search runs several stretches against the map
|
||||
* search on the server, each allowed its own timeout there, so it may take
|
||||
* longer than the shared default.
|
||||
*/
|
||||
const POI_SEARCH_TIMEOUT_MS = 60_000;
|
||||
|
||||
export const tourPoiSearchApi = {
|
||||
search: (tripId: number | string, body: TourPoiSearchRequest, signal?: AbortSignal): Promise<TourPoiSearchResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/poi-search`, body, { signal, timeout: POI_SEARCH_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* BIK-05: the trail grades of the ways a mountain bike tour runs on. The
|
||||
* server asks Overpass in parts, one after another, so a long tour may take
|
||||
* longer than the shared default.
|
||||
*/
|
||||
const TRAIL_GRADE_TIMEOUT_MS = 60_000;
|
||||
|
||||
export const tourTrailGradeApi = {
|
||||
grades: (
|
||||
tripId: number | string,
|
||||
body: TourTrailGradeRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<TourTrailGradeResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/trail-grades`, body, { signal, timeout: TRAIL_GRADE_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** Where one station of a walk is marked for one day; a write queued offline replays to the same path. */
|
||||
export function tourVisitPath(tripId: number | string, assignmentId: number, stationKey: string): string {
|
||||
return `/trips/${tripId}/tour-visits/${assignmentId}/${encodeURIComponent(stationKey)}`;
|
||||
}
|
||||
|
||||
/** CW-03: the stations of a trip's city walks visited or skipped on their days. */
|
||||
export const tourVisitsApi = {
|
||||
list: (tripId: number | string): Promise<TourVisitListResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/tour-visits`).then((r) => r.data),
|
||||
update: (
|
||||
tripId: number | string,
|
||||
assignmentId: number,
|
||||
stationKey: string,
|
||||
body: TourVisitUpdateRequest
|
||||
): Promise<TourVisitListResponse> =>
|
||||
apiClient.put(tourVisitPath(tripId, assignmentId, stationKey), body).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { WalkImportPreviewRequest, WalkImportPreviewResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* CW-07: a Google Maps walking route read into its stops. The server may
|
||||
* follow a short link a few hops within its own deadline, so the call waits
|
||||
* a little longer than that before it gives up.
|
||||
*/
|
||||
const WALK_IMPORT_TIMEOUT_MS = 20_000;
|
||||
|
||||
export const tourWalkImportApi = {
|
||||
preview: (
|
||||
tripId: number | string,
|
||||
body: WalkImportPreviewRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<WalkImportPreviewResponse> =>
|
||||
apiClient
|
||||
.post(`/trips/${tripId}/tours/import/google-maps/preview`, body, { signal, timeout: WALK_IMPORT_TIMEOUT_MS })
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
TripJournalCandidatesResponse,
|
||||
TripJournalLinkMemberRequest,
|
||||
TripJournalLinkResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
||||
* and who it is open to. In its own file rather than in the `client.ts` god
|
||||
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
||||
*/
|
||||
export const tripJournalApi = {
|
||||
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
||||
link: (
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
||||
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
/** The journals this person can open; the dialog offers the ones they own. */
|
||||
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
||||
apiClient.get('/journeys').then((r) => r.data),
|
||||
};
|
||||
@@ -19,6 +19,8 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// Tours facet invalidation — hooks/useTripWebSocket forwards tours:changed to
|
||||
// useTourPlaceIds so the active trip refreshes its Tours/Places read model.
|
||||
'tours:changed',
|
||||
// CW-03: store/tourVisitStore (watchTourVisits) keeps the visits store current.
|
||||
'tours:visits',
|
||||
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
@@ -93,6 +95,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// files it moved arrive as file:* events.
|
||||
'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)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -77,6 +77,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
|
||||
if (loading) {
|
||||
@@ -155,13 +156,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
}
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
<>
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(titleKey)}
|
||||
description={t(subtitleKey)}
|
||||
enabled={enabled}
|
||||
onToggle={() => void toggle()}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
|
||||
@@ -2,11 +2,13 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import type { Addon } from './addonModel';
|
||||
import { useAddonManager } from './useAddonManager';
|
||||
|
||||
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-006
|
||||
// FE-HOOK-ADDONMGR-001 to FE-HOOK-ADDONMGR-008
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
@@ -35,6 +37,9 @@ beforeEach(() => {
|
||||
useAddonStore.setState({ loadAddons });
|
||||
vi.spyOn(adminApi, 'addons').mockResolvedValue({ addons: ADDONS });
|
||||
vi.spyOn(adminApi, 'updateAddon').mockResolvedValue({});
|
||||
vi.spyOn(wandererAdminApi, 'get').mockResolvedValue({ enabled: false });
|
||||
vi.spyOn(hikingMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
vi.spyOn(cycleMapAdminApi, 'get').mockResolvedValue({ enabled: true });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -46,6 +51,13 @@ async function loaded() {
|
||||
|
||||
const enabledOf = (addons: Addon[], id: string) => addons.find((a) => a.id === id)?.enabled;
|
||||
|
||||
/** One of the map switches under Tours, by its key. */
|
||||
function mapSwitch(manager: ReturnType<typeof useAddonManager>, key: 'hiking' | 'cycle') {
|
||||
const found = manager.mapSwitches.find((entry) => entry.key === key);
|
||||
if (!found) throw new Error(`no ${key} map switch`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('useAddonManager', () => {
|
||||
it('FE-HOOK-ADDONMGR-001: loads once and sorts addons into trip, global, integration and the two shelves', async () => {
|
||||
const { result, rerender } = await loaded();
|
||||
@@ -115,4 +127,33 @@ describe('useAddonManager', () => {
|
||||
await act(() => result.current.documentProviderOptions[0].toggle());
|
||||
expect(adminApi.updateAddon).toHaveBeenLastCalledWith('gdrive', { enabled: false });
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-007: the hiking map switch under Tours loads, flips, saves and refreshes the store (MAP-01)', async () => {
|
||||
const update = vi.spyOn(hikingMapAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(mapSwitch(result.current, 'hiking').enabled).toBe(true));
|
||||
await act(() => mapSwitch(result.current, 'hiking').toggle());
|
||||
expect(update).toHaveBeenCalledWith(false);
|
||||
expect(mapSwitch(result.current, 'hiking').enabled).toBe(false);
|
||||
expect(loadAddons).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('admin.addons.toast.updated');
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-009: the cycling map switch under Tours loads, flips and saves on its own (BIK-06)', async () => {
|
||||
const update = vi.spyOn(cycleMapAdminApi, 'update').mockResolvedValue({ enabled: false });
|
||||
const { result } = await loaded();
|
||||
await waitFor(() => expect(mapSwitch(result.current, 'cycle').enabled).toBe(true));
|
||||
await act(() => mapSwitch(result.current, 'cycle').toggle());
|
||||
expect(update).toHaveBeenCalledWith(false);
|
||||
expect(mapSwitch(result.current, 'cycle').enabled).toBe(false);
|
||||
expect(mapSwitch(result.current, 'hiking').enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ADDONMGR-008: a failed save puts the switch under Tours back and says so', async () => {
|
||||
vi.spyOn(wandererAdminApi, 'update').mockRejectedValue(new Error('500'));
|
||||
const { result } = await loaded();
|
||||
await act(() => result.current.toggleToursWanderer());
|
||||
expect(result.current.toursWanderer).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith('admin.addons.toast.error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Bike, Footprints } from 'lucide-react';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { cycleMapAdminApi, hikingMapAdminApi } from '../../../api/tourBaseMaps';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
@@ -20,6 +22,8 @@ export function useAddonManager() {
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||
|
||||
const loadAddons = async () => {
|
||||
try {
|
||||
@@ -36,6 +40,14 @@ export function useAddonManager() {
|
||||
.get()
|
||||
.then((flag) => setToursWanderer(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
hikingMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursHikingMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
cycleMapAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursCycleMap(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOnMount();
|
||||
@@ -63,20 +75,44 @@ export function useAddonManager() {
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
/** wanderer under Tours: flipped at once, put back when the save fails. */
|
||||
const toggleToursWanderer = async () => {
|
||||
const next = !toursWanderer;
|
||||
setToursWanderer(next);
|
||||
/** A switch under Tours (wanderer, OpenHikingMap, CyclOSM): flipped at once, put back when the save fails. */
|
||||
const flipToursSwitch = async (
|
||||
current: boolean,
|
||||
set: (enabled: boolean) => void,
|
||||
save: (enabled: boolean) => Promise<unknown>
|
||||
) => {
|
||||
const next = !current;
|
||||
set(next);
|
||||
try {
|
||||
await wandererAdminApi.update(next);
|
||||
await save(next);
|
||||
} catch {
|
||||
setToursWanderer(!next);
|
||||
set(!next);
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
return;
|
||||
}
|
||||
void refreshGlobalAddons();
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
const toggleToursWanderer = () => flipToursSwitch(toursWanderer, setToursWanderer, wandererAdminApi.update);
|
||||
/** The maps the Tours planner offers on their operators' terms, one switch each (MAP-01, BIK-06). */
|
||||
const mapSwitches = [
|
||||
{
|
||||
key: 'hiking',
|
||||
Icon: Footprints,
|
||||
titleKey: 'admin.toursHikingMap.title',
|
||||
subtitleKey: 'admin.toursHikingMap.subtitle',
|
||||
enabled: toursHikingMap,
|
||||
toggle: () => flipToursSwitch(toursHikingMap, setToursHikingMap, hikingMapAdminApi.update),
|
||||
},
|
||||
{
|
||||
key: 'cycle',
|
||||
Icon: Bike,
|
||||
titleKey: 'admin.toursCycleMap.title',
|
||||
subtitleKey: 'admin.toursCycleMap.subtitle',
|
||||
enabled: toursCycleMap,
|
||||
toggle: () => flipToursSwitch(toursCycleMap, setToursCycleMap, cycleMapAdminApi.update),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const asOption = (provider: Addon): ProviderOption => ({
|
||||
key: provider.id,
|
||||
@@ -106,5 +142,6 @@ export function useAddonManager() {
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PaddleRegionAutoMode, PaddleRegionPending } from '@trek/shared';
|
||||
import { Ban, CloudDownload, Hand, Loader2, Power, Sparkles } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { ChoiceChips, SettingRows, SETTINGS_BUTTON, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
const MODE_ICONS = { auto: CloudDownload, ask: Hand, off: Power } as const;
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
const SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** A size in megabytes as the reader's language writes it, from 1000 on in gigabytes. */
|
||||
function useMegabytes() {
|
||||
const { language } = useTranslation();
|
||||
return (mb: number) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: 'unit',
|
||||
unit: mb >= 1000 ? 'gigabyte' : 'megabyte',
|
||||
maximumFractionDigits: mb >= 1000 ? 1 : 0,
|
||||
}).format(mb >= 1000 ? mb / 1000 : mb);
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* PAD-11C: how TREK gets the water data of a place a paddler plans in. The
|
||||
* mode, and in the automatic one the largest region it loads without asking.
|
||||
*/
|
||||
export function AutoLoadSettings({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const { settings, saving, save } = admin.autoLoad;
|
||||
if (!settings) return null;
|
||||
const sizes = SIZES.includes(settings.max_mb as (typeof SIZES)[number])
|
||||
? SIZES
|
||||
: [...SIZES, settings.max_mb].sort((a, b) => a - b);
|
||||
return (
|
||||
<div data-testid="paddle-auto-load" className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.title')}</span>
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<Segmented<PaddleRegionAutoMode>
|
||||
value={settings.mode}
|
||||
onChange={(mode) => void save({ mode })}
|
||||
label={t('admin.paddleRegions.auto.title')}
|
||||
fill
|
||||
options={MODES.map((mode) => {
|
||||
const Icon = MODE_ICONS[mode];
|
||||
return {
|
||||
value: mode,
|
||||
label: t(`admin.paddleRegions.auto.mode.${mode}`),
|
||||
icon: <Icon size={13} strokeWidth={2} aria-hidden="true" />,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</fieldset>
|
||||
<SettingsHint className="mt-1.5">{t(`admin.paddleRegions.auto.modeHint.${settings.mode}`)}</SettingsHint>
|
||||
</div>
|
||||
{settings.mode === 'auto' && (
|
||||
<fieldset disabled={saving} className="m-0 min-w-0 border-0 p-0">
|
||||
<span className={LABEL}>{t('admin.paddleRegions.auto.maxSize')}</span>
|
||||
<ChoiceChips<string>
|
||||
value={String(settings.max_mb)}
|
||||
label={t('admin.paddleRegions.auto.maxSize')}
|
||||
onChange={(value) => void save({ max_mb: Number(value) })}
|
||||
options={sizes.map((mb) => ({ value: String(mb), label: megabytes(mb) }))}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.paddleRegions.auto.maxSizeHint')}</SettingsHint>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestRow({ request, admin }: { request: PaddleRegionPending; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const megabytes = useMegabytes();
|
||||
const busy = admin.busyId === request.id;
|
||||
const date = new Date(request.requested_at).toLocaleDateString(language, { dateStyle: 'medium' });
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-request"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 basis-48 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{request.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{request.declined ? (
|
||||
<StatusPill tone="danger" icon={<Ban size={10} strokeWidth={2.4} />}>
|
||||
{t('admin.paddleRegions.requests.declined')}
|
||||
</StatusPill>
|
||||
) : (
|
||||
<StatusPill tone="warning">{t(`admin.paddleRegions.requests.reason.${request.reason}`)}</StatusPill>
|
||||
)}
|
||||
{request.bytes !== null && <StatusPill>{megabytes(Math.max(1, Math.round(request.bytes / MB)))}</StatusPill>}
|
||||
{request.requested_by && (
|
||||
<StatusPill>{t('admin.paddleRegions.requests.by', { user: request.requested_by })}</StatusPill>
|
||||
)}
|
||||
<StatusPill>{date}</StatusPill>
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
{!request.declined && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.decline(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{t('admin.paddleRegions.requests.decline')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.approve(request.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{busy ? <Loader2 size={13} className="animate-spin" /> : <CloudDownload size={13} strokeWidth={2.2} />}
|
||||
{t('admin.paddleRegions.requests.approve')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The regions paddlers asked for that wait for an admin, the open ones first. */
|
||||
export function RequestList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.requests.length === 0) return null;
|
||||
const ordered = [...admin.requests].sort((a, b) => Number(a.declined) - Number(b.declined));
|
||||
const open = admin.requests.filter((request) => !request.declined).length;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2" data-testid="paddle-region-requests">
|
||||
<span className={`${LABEL} mb-0 flex items-center gap-1.5`}>
|
||||
{t('admin.paddleRegions.requests.title')}
|
||||
{open > 0 && <StatusPill tone="warning">{open}</StatusPill>}
|
||||
</span>
|
||||
<SettingRows>
|
||||
{ordered.map((request) => (
|
||||
<RequestRow key={request.id} request={request} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A region TREK loaded by itself for a paddler, told apart from those an admin added. */
|
||||
export function AutoOriginPill() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.origin.autoHint')}>
|
||||
<span className="inline-flex" data-testid="paddle-region-auto">
|
||||
<StatusPill icon={<Sparkles size={10} strokeWidth={2.2} />}>{t('admin.paddleRegions.origin.auto')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-027: the paddle regions card in the admin area, drawn from a
|
||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
@@ -25,11 +25,13 @@ function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
origin: 'admin',
|
||||
source_timestamp: '2026-10-03T12:00:00Z',
|
||||
imported_at: '2026-10-04T08:00:00Z',
|
||||
feature_count: 1234,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -56,6 +58,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
||||
toursOn: true,
|
||||
offline: false,
|
||||
regions: [],
|
||||
requests: [],
|
||||
autoLoad: { settings: null, saving: false, save: vi.fn(async () => {}) },
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
@@ -63,6 +67,8 @@ function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['searc
|
||||
busyId: null,
|
||||
add: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
approve: vi.fn(async () => {}),
|
||||
decline: vi.fn(async () => {}),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
@@ -295,6 +301,91 @@ describe('PaddleRegionsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleRegionsPanel water network (PAD-11)', () => {
|
||||
it('FE-ADMIN-PADDLEREG-022: a region with its network shows the water it holds, the rest in the tooltip', async () => {
|
||||
const network = {
|
||||
water_km: 1364.5,
|
||||
lakes: 266,
|
||||
portages: 14,
|
||||
inferred_portages: 232,
|
||||
gaps: 63,
|
||||
locks: 17,
|
||||
built_at: '2026-10-17T15:30:20Z',
|
||||
bytes: 1496816,
|
||||
};
|
||||
show({ regions: [region({ network })] });
|
||||
const pill = within(rowOf('Oberpfalz')).getByTestId('paddle-region-network');
|
||||
expect(pill).toHaveTextContent('1,365 km of water');
|
||||
fireEvent.mouseEnter(pill);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'266 lakes, 14 mapped portages, 232 assumed carries, 17 locks, 63 bridged gaps'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-023: a region read before routing existed asks to be read again; one never read or being read says nothing', () => {
|
||||
show({
|
||||
regions: [
|
||||
region({ id: 'old', name: 'Old read', network: null }),
|
||||
region({ id: 'new', name: 'Never read', imported_at: null, status: 'queued', network: null }),
|
||||
],
|
||||
});
|
||||
expect(within(rowOf('Old read')).getByText('No water network')).toBeInTheDocument();
|
||||
expect(within(rowOf('Never read')).queryByText('No water network')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-024: offers automatic, on request and off, with the size limit only for automatic', () => {
|
||||
const save = vi.fn(async () => {});
|
||||
show({ autoLoad: { settings: { mode: 'auto', max_mb: 500 }, saving: false, save } });
|
||||
const auto = screen.getByTestId('paddle-auto-load');
|
||||
expect(within(auto).getByRole('button', { name: /Automatic/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(auto).getByText('Largest region loaded automatically')).toBeInTheDocument();
|
||||
fireEvent.click(within(auto).getByRole('button', { name: /On request/ }));
|
||||
expect(save).toHaveBeenCalledWith({ mode: 'ask' });
|
||||
fireEvent.click(within(auto).getByRole('button', { name: '1 GB' }));
|
||||
expect(save).toHaveBeenCalledWith({ max_mb: 1000 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-025: hides the size limit unless loading is automatic, and shows nothing before the setting is read', () => {
|
||||
show({ autoLoad: { settings: { mode: 'off', max_mb: 500 }, saving: false, save: vi.fn() } });
|
||||
expect(screen.queryByText('Largest region loaded automatically')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/TREK loads nothing by itself/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-026: lists the requests, the open ones first, to load or decline', () => {
|
||||
const approve = vi.fn(async () => {});
|
||||
const decline = vi.fn(async () => {});
|
||||
const base = {
|
||||
bytes: 734_003_200,
|
||||
requested_by: 'elmo',
|
||||
requested_at: '2026-10-18T09:00:00Z',
|
||||
};
|
||||
show({
|
||||
approve,
|
||||
decline,
|
||||
requests: [
|
||||
{ ...base, id: 'norway', name: 'Norway', reason: 'too_large', declined: true },
|
||||
{ ...base, id: 'brandenburg', name: 'Brandenburg', reason: 'limit', declined: false },
|
||||
],
|
||||
});
|
||||
const rows = screen.getAllByTestId('paddle-region-request');
|
||||
expect(rows[0]).toHaveTextContent('Brandenburg');
|
||||
expect(rows[0]).toHaveTextContent('Daily limit');
|
||||
expect(rows[0]).toHaveTextContent('by elmo');
|
||||
expect(rows[1]).toHaveTextContent('Declined');
|
||||
fireEvent.click(within(rows[0]).getByRole('button', { name: 'Decline' }));
|
||||
expect(decline).toHaveBeenCalledWith('brandenburg');
|
||||
fireEvent.click(within(rows[1]).getByRole('button', { name: /Load/ }));
|
||||
expect(approve).toHaveBeenCalledWith('norway');
|
||||
expect(within(rows[1]).queryByRole('button', { name: 'Decline' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-027: marks a region TREK loaded by itself', () => {
|
||||
show({ regions: [region({ id: 'mv', name: 'Mecklenburg', origin: 'auto' }), READY] });
|
||||
expect(within(rowOf('Mecklenburg')).getByTestId('paddle-region-auto')).toHaveTextContent('Automatic');
|
||||
expect(within(rowOf('Oberpfalz')).queryByTestId('paddle-region-auto')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddleRegionsModel', () => {
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-001: a region waiting for its read or being read counts as reading', () => {
|
||||
expect(isReading({ status: 'queued' })).toBe(true);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { AutoLoadSettings, AutoOriginPill, RequestList } from './PaddleAutoLoadParts';
|
||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
@@ -22,8 +23,10 @@ type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
/**
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle obstacles for,
|
||||
* picked from Geofabrik's list. Both the desktop and the phone admin show
|
||||
* this one card under the addons, while the Tours addon is on.
|
||||
* picked from Geofabrik's list, and (PAD-11C) how TREK loads the region of a
|
||||
* place a paddler plans in by itself, with the regions that wait for an admin.
|
||||
* Both the desktop and the phone admin show this one card under the addons,
|
||||
* while the Tours addon is on.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
@@ -67,6 +70,8 @@ function Panel() {
|
||||
</div>
|
||||
)}
|
||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||
<AutoLoadSettings admin={admin} />
|
||||
<RequestList admin={admin} />
|
||||
<CatalogSearch admin={admin} />
|
||||
<RegionList admin={admin} />
|
||||
</fieldset>
|
||||
@@ -216,6 +221,41 @@ function RegionStatus({ region }: { region: PaddleRegion }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: the water network the router follows in a region, as one figure
|
||||
* with the rest in its tooltip. A region read before routing existed says it
|
||||
* needs reading again; a network is never claimed where none was built.
|
||||
*/
|
||||
function RegionNetwork({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const { network } = region;
|
||||
if (!network) {
|
||||
if (!region.imported_at || region.status === 'importing') return null;
|
||||
return (
|
||||
<Tooltip label={t('admin.paddleRegions.network.missingHint')}>
|
||||
<span className="inline-flex">
|
||||
<StatusPill tone="warning">{t('admin.paddleRegions.network.missing')}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const km = new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(network.water_km);
|
||||
const details = [
|
||||
t('admin.paddleRegions.network.lakes', { count: network.lakes }),
|
||||
t('admin.paddleRegions.network.portages', { count: network.portages }),
|
||||
t('admin.paddleRegions.network.inferred', { count: network.inferred_portages }),
|
||||
t('admin.paddleRegions.network.locks', { count: network.locks }),
|
||||
t('admin.paddleRegions.network.gaps', { count: network.gaps }),
|
||||
].join(', ');
|
||||
return (
|
||||
<Tooltip label={details}>
|
||||
<span className="inline-flex" data-testid="paddle-region-network">
|
||||
<StatusPill>{t('admin.paddleRegions.network.km', { km })}</StatusPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
@@ -239,10 +279,12 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<RegionStatus region={region} />
|
||||
{region.origin === 'auto' && <AutoOriginPill />}
|
||||
{region.imported_at && (
|
||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||
)}
|
||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||
<RegionNetwork region={region} />
|
||||
</span>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// FE-HOOK-PADDLE-AUTOLOAD-001 to 003: how TREK loads regions by itself, read and saved in the admin area (PAD-11C).
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
|
||||
const repo = vi.hoisted(() => ({ settings: vi.fn(), saveSettings: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', () => ({ paddleRegionRepo: repo }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const { usePaddleAutoLoad } = await import('./usePaddleAutoLoad');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||
|
||||
describe('usePaddleAutoLoad', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
repo.settings.mockResolvedValue({ mode: 'auto', max_mb: 500 });
|
||||
repo.saveSettings.mockImplementation(async (settings: unknown) => settings);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-001: reads the setting once the card shows, and not offline', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 }));
|
||||
renderHook(() => usePaddleAutoLoad(true, true), { wrapper });
|
||||
expect(repo.settings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-002: shows a change at once and keeps what the server saved', async () => {
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ mode: 'ask' });
|
||||
});
|
||||
expect(repo.saveSettings).toHaveBeenCalledWith({ mode: 'ask', max_mb: 500 });
|
||||
expect(result.current.settings).toEqual({ mode: 'ask', max_mb: 500 });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLE-AUTOLOAD-003: takes a refused change back and says so', async () => {
|
||||
repo.saveSettings.mockRejectedValueOnce(new Error('nope'));
|
||||
const { result } = renderHook(() => usePaddleAutoLoad(true, false), { wrapper });
|
||||
await waitFor(() => expect(result.current.settings).not.toBeNull());
|
||||
await act(async () => {
|
||||
await result.current.save({ max_mb: 2000 });
|
||||
});
|
||||
expect(result.current.settings).toEqual({ mode: 'auto', max_mb: 500 });
|
||||
expect(toast.error).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PaddleRegionSettings } from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/**
|
||||
* PAD-11C in the admin area: how TREK loads the water data of a place a
|
||||
* paddler plans in, read once the card shows and saved on every change. A
|
||||
* change shows at once and goes back, with a toast, when the server refuses
|
||||
* it. While one is saved the controls rest, so two saves never cross.
|
||||
*/
|
||||
export function usePaddleAutoLoad(active: boolean, offline: boolean) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState<PaddleRegionSettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pending = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.settings().then(
|
||||
(loaded) => {
|
||||
if (!cancelled) setSettings(loaded);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (!cancelled) toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, toast]);
|
||||
|
||||
const save = useCallback(
|
||||
async (change: Partial<PaddleRegionSettings>) => {
|
||||
if (!settings || pending.current) return;
|
||||
const before = settings;
|
||||
const next = { ...settings, ...change };
|
||||
pending.current = true;
|
||||
setSaving(true);
|
||||
setSettings(next);
|
||||
try {
|
||||
setSettings(await paddleRegionRepo.saveSettings(next));
|
||||
} catch (err) {
|
||||
setSettings(before);
|
||||
toast.error(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[settings, t, toast]
|
||||
);
|
||||
|
||||
return { settings, saving, save };
|
||||
}
|
||||
@@ -24,11 +24,15 @@ const repo = vi.hoisted(() => ({
|
||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
decline: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
settings: vi.fn(async () => ({ mode: 'auto' as const, max_mb: 500 })),
|
||||
saveSettings: vi.fn(async (settings: { mode: 'auto' | 'ask' | 'off'; max_mb: number }) => settings),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||
paddleRegionRepo: repo,
|
||||
// The list answers its regions and no requests unless a test says otherwise (PAD-11C).
|
||||
paddleRegionRepo: { ...repo, list: () => repo.list().then((regions) => ({ regions, requests: [] })) },
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
@@ -40,11 +44,13 @@ function region(id: string, status: PaddleRegion['status'], over: Partial<Paddle
|
||||
id,
|
||||
name: id,
|
||||
status,
|
||||
origin: 'admin',
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
error: null,
|
||||
progress: null,
|
||||
network: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry, PaddleRegionPending } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -7,6 +7,7 @@ import { useAddonStore } from '../../../store/addonStore';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { isReading } from './paddleRegionsModel';
|
||||
import { usePaddleAutoLoad } from './usePaddleAutoLoad';
|
||||
|
||||
/** How often the list is asked again while a region waits or is read. */
|
||||
export const PADDLE_REGIONS_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 3000;
|
||||
@@ -23,12 +24,14 @@ function errorText(t: Translate, err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The regions as the server holds them, asked again every few seconds while
|
||||
* any of them is read, and on `reload` after a change. Every answer is set
|
||||
* The regions as the server holds them, with those paddlers asked for, asked
|
||||
* again every few seconds while any of them is read, and on `reload` after a
|
||||
* change. Every answer is set
|
||||
* from the request's own callback, so a slow one never overwrites a newer.
|
||||
*/
|
||||
function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
const [regions, setRegions] = useState<PaddleRegion[]>([]);
|
||||
const [requests, setRequests] = useState<PaddleRegionPending[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [asked, setAsked] = useState(0);
|
||||
@@ -38,7 +41,8 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list);
|
||||
setRegions(list.regions);
|
||||
setRequests(list.requests);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
@@ -59,7 +63,7 @@ function useRegionList(active: boolean, offline: boolean, t: Translate) {
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, offline, reading]);
|
||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||
return { regions, loading: !loaded && !offline, error, reload };
|
||||
return { regions, requests, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
@@ -99,7 +103,8 @@ function useCatalogSearch(offline: boolean, t: Translate) {
|
||||
* PAD-05A in the admin area: the regions TREK reads paddle data for, a search
|
||||
* in Geofabrik's list to add one, reading a region again and removing it
|
||||
* after a confirmation. Shown only while the Tours addon is on; the status of
|
||||
* a read follows without reloading the page.
|
||||
* a read follows without reloading the page. PAD-11C: how TREK loads regions
|
||||
* for paddlers by itself, and the regions they asked for, to load or decline.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +112,7 @@ export function usePaddleRegionsAdmin() {
|
||||
const { offline } = useNetworkMode();
|
||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||
const list = useRegionList(toursOn, offline, t);
|
||||
const autoLoad = usePaddleAutoLoad(toursOn, offline);
|
||||
const search = useCatalogSearch(offline, t);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||
@@ -146,6 +152,12 @@ export function usePaddleRegionsAdmin() {
|
||||
[run, setQuery, t]
|
||||
);
|
||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||
/** A paddler's request let through: the region is added like any other. */
|
||||
const approve = useCallback(
|
||||
(id: string) => run(id, () => paddleRegionRepo.add(id), t('admin.paddleRegions.toast.added')),
|
||||
[run, t]
|
||||
);
|
||||
const decline = useCallback((id: string) => run(id, () => paddleRegionRepo.decline(id)), [run]);
|
||||
const confirmRemove = useCallback(() => {
|
||||
const region = removing;
|
||||
setRemoving(null);
|
||||
@@ -157,6 +169,8 @@ export function usePaddleRegionsAdmin() {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
requests: list.requests,
|
||||
autoLoad,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
@@ -165,6 +179,8 @@ export function usePaddleRegionsAdmin() {
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
approve,
|
||||
decline,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CATEGORIES = [
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
|
||||
await screen.findByRole('button', { name: /create share link/i })
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSaved } = mountDialog()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import JourneyShareSection from './JourneyShareSection'
|
||||
import { useJourneySettings } from './useJourneySettings'
|
||||
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
|
||||
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</div>
|
||||
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
||||
</DialogSection>
|
||||
|
||||
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
||||
* contributors: shown here, but managed in that trip's settings, so read only.
|
||||
*/
|
||||
function TripAccessSection({ access, roleLabel }: {
|
||||
access: JourneyTripAccess[] | undefined
|
||||
roleLabel: (role: JourneyContributor['role']) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<DialogSection label={t('journey.contributors.viaTrips')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
||||
<div className={ROWS}>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
||||
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
||||
</div>
|
||||
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,23 +169,24 @@ describe('Navbar', () => {
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||
render(<Navbar onShare={vi.fn()} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
expect(shareBtn).toBeInTheDocument();
|
||||
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
||||
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
||||
expect(back.parentElement).toBe(settings.closest('div'));
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onShare = vi.fn();
|
||||
render(<Navbar onShare={onShare} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareBtn);
|
||||
expect(onShare).toHaveBeenCalled();
|
||||
const onSettings = vi.fn();
|
||||
render(<Navbar onSettings={onSettings} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
||||
expect(onSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
|
||||
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
||||
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.mouseEnter(back);
|
||||
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
|
||||
fireEvent.mouseLeave(back);
|
||||
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;
|
||||
fireEvent.mouseEnter(theme);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
@@ -21,7 +21,8 @@ interface NavbarProps {
|
||||
tripId?: number | string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
/** The trip's Settings: who is on it, its links and its journal. */
|
||||
onSettings?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -32,7 +33,7 @@ interface Addon {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
itself on top of them. */}
|
||||
<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 */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-017
|
||||
// FE-COMP-MAPLAYER-001 to FE-COMP-MAPLAYER-020
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -59,6 +59,38 @@ describe('MapLayerSwitcher', () => {
|
||||
expect(onChange).toHaveBeenCalledWith('topo')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-018: offers the hiking map only where the tour type and the admin offer it (MAP-01)', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'satellite'] }} />)
|
||||
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={onChange} layers={{ bases: ['default', 'topo', 'hiking', 'satellite'] }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hiking' }))
|
||||
expect(onChange).toHaveBeenCalledWith('hiking')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-019: a hiking map that stopped answering says so on its segment, and only while it is shown', () => {
|
||||
const baseHealth = { base: 'hiking' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
|
||||
const layers = { bases: ['default', 'hiking'] as const, baseHealth }
|
||||
const { rerender } = render(<TourMapLayerSwitcher active="hiking" onChange={() => {}} layers={layers} />)
|
||||
const segment = screen.getByRole('button', { name: 'Hiking' })
|
||||
expect(segment).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(segment).toHaveTextContent('!')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('OpenHikingMap is not answering right now')
|
||||
|
||||
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} layers={layers} />)
|
||||
expect(screen.getByRole('button', { name: 'Hiking' })).not.toHaveTextContent('!')
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-020: CyclOSM sits in the switch as Cycling and says so when it stops answering (BIK-06)', () => {
|
||||
const baseHealth = { base: 'cycle' as const, down: true, onTileError: vi.fn(), onTileLoad: vi.fn() }
|
||||
render(<TourMapLayerSwitcher active="cycle" onChange={() => {}} layers={{ bases: ['default', 'cycle'], baseHealth }} />)
|
||||
expect(screen.getByRole('button', { name: 'Cycling' })).toHaveTextContent('!')
|
||||
expect(screen.getByRole('status')).toHaveTextContent('CyclOSM is not answering right now')
|
||||
expect(screen.queryByRole('button', { name: 'Hiking' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPLAYER-008: the shell is as wide as the size its neighbours are placed off', () => {
|
||||
render(<MapLayerSwitcher active="default" onToggle={() => {}} />)
|
||||
const button = screen.getByRole('button')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Anchor, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -9,9 +9,10 @@ import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
import type { BaseMapHealth, TourRasterBase } from './tourRasterBases'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | 'topo'
|
||||
export type TourBaseLayer = BaseLayer | TourRasterBase
|
||||
|
||||
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
|
||||
export interface TourLayerOffer {
|
||||
@@ -19,6 +20,8 @@ export interface TourLayerOffer {
|
||||
overlays?: readonly ToggleableRasterOverlay[]
|
||||
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
|
||||
paddle?: PaddleMapLayer
|
||||
/** MAP-01, BIK-06: whether the map on terms shown (OpenHikingMap, CyclOSM) answers; the maps report into it. */
|
||||
baseHealth?: BaseMapHealth
|
||||
}
|
||||
|
||||
const FROSTED_SHELL = {
|
||||
@@ -83,6 +86,8 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
const TOUR_LAYERS = [
|
||||
{ id: 'default', labelKey: 'map.baseLayer.default', Icon: MapIcon },
|
||||
{ id: 'topo', labelKey: null, Icon: Mountain },
|
||||
{ id: 'hiking', labelKey: 'map.baseLayer.hiking', Icon: Footprints },
|
||||
{ id: 'cycle', labelKey: 'map.baseLayer.cycle', Icon: Bike },
|
||||
{ id: 'satellite', labelKey: 'map.baseLayer.satellite', Icon: Satellite },
|
||||
] as const
|
||||
|
||||
@@ -199,6 +204,15 @@ const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; down
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it.
|
||||
*/
|
||||
/** The warning mark on a switch whose source stopped answering; the tooltip says what it means. */
|
||||
function DownBadge() {
|
||||
return (
|
||||
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
|
||||
!
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const look = OVERLAY_LOOK[overlay.id]
|
||||
@@ -221,11 +235,7 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay;
|
||||
>
|
||||
<look.Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && (
|
||||
<span aria-hidden className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warning-soft px-1 font-semibold leading-none text-warning" style={fs(10)}>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
@@ -265,14 +275,18 @@ export function TourMapLayerSwitcher({
|
||||
const { t } = useTranslation()
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
// A map on terms is a best-effort service: while it is shown and stops answering, its segment says so.
|
||||
const health = layers?.baseHealth
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
const down = !!health?.down && health.base === id && active === id
|
||||
const downLabel = id === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')
|
||||
return (
|
||||
<Tooltip key={id} label={label} placement="top" disabled={!compact}>
|
||||
<Tooltip key={id} label={down ? downLabel : label} placement="top" disabled={!compact && !down}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(id)}
|
||||
@@ -283,10 +297,16 @@ export function TourMapLayerSwitcher({
|
||||
>
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
{!compact && <span>{label}</span>}
|
||||
{down && <DownBadge />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{health?.down && active === health.base && (
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')}
|
||||
</span>
|
||||
)}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{placeTool && <PlaceToolToggle tool={placeTool} />}
|
||||
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('MapView', () => {
|
||||
const route = [[[39.9042, 116.4074], [39.9052, 116.4084]]] as [number, number][][]
|
||||
const plannerWaypoints = [{ id: 'start', lat: 39.9042, lng: 116.4074, role: 'start' as const }]
|
||||
const routeProfileFocus = { distanceMeters: 100, elevationMeters: 0, lat: 39.9047, lng: 116.4079, sampleIndex: 0 }
|
||||
const props = (viewBaseLayer: 'default' | 'topo' | 'satellite') => ({
|
||||
const props = (viewBaseLayer: 'default' | 'topo' | 'hiking' | 'satellite') => ({
|
||||
places: [beijing], center: [39.9042, 116.4074] as [number, number], zoom: 15,
|
||||
route, plannerWaypoints, routeProfileFocus, tileUrl: AMAP_ROAD, viewBaseLayer, onViewBaseLayerChange,
|
||||
})
|
||||
@@ -279,6 +279,11 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').every(polyline => polyline.getAttribute('data-points') === JSON.stringify(route[0]))).toBe(true)
|
||||
expect(screen.getByTestId('profile-focus-marker')).toHaveAttribute('data-center', JSON.stringify([routeProfileFocus.lat, routeProfileFocus.lng]))
|
||||
|
||||
// MAP-01: OpenHikingMap is drawn in WGS-84 like topo, from its own template.
|
||||
rerender(<MapView {...props('hiking')} />)
|
||||
expect(screen.getByTestId('map-container')).toHaveAttribute('data-crs', 'EPSG:3857')
|
||||
expect(screen.getByTestId('tile-layer')).toHaveAttribute('data-url', 'https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png')
|
||||
|
||||
rerender(<MapView {...props('satellite')} />)
|
||||
const satelliteMap = screen.getByTestId('map-container')
|
||||
expect(satelliteMap).not.toBe(topoMap)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { RouteHitBand } from './RouteHitBand'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -62,7 +63,8 @@ import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './p
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { SelectedTrackLine, TrackLines } from './TrackLines'
|
||||
import DawarichTrailLayer from './DawarichTrailLayer'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { tileHealthHandlers, tourRasterBaseOf } from './tourRasterBases'
|
||||
import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
@@ -838,7 +840,7 @@ export const MapView = memo(function MapView({
|
||||
const baseLayer = (viewBaseLayer ?? globalBaseLayer) as TourBaseLayer
|
||||
const hasViewBaseLayer = viewBaseLayer !== undefined && typeof onViewBaseLayerChange === 'function'
|
||||
const isSatellite = baseLayer === 'satellite'
|
||||
const isTopo = baseLayer === 'topo'
|
||||
const tourRaster = tourRasterBaseOf(baseLayer) // topo, OpenHikingMap or CyclOSM, the Tours planner's own
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
// the default is a vector style.
|
||||
@@ -850,7 +852,7 @@ export const MapView = memo(function MapView({
|
||||
* decided before the map is constructed, because Leaflet cannot change a map's
|
||||
* CRS afterwards — hence the whole map, not just the tile layer.
|
||||
*/
|
||||
const isGcjBasemap = !isTopo && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
|
||||
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
|
||||
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
@@ -1115,17 +1117,16 @@ export const MapView = memo(function MapView({
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
) : isTopo ? (
|
||||
) : tourRaster ? (
|
||||
<TileLayer
|
||||
key="topo"
|
||||
url={OPENTOPOMAP_TILE_URL}
|
||||
attribution={OPENTOPOMAP_TILE_ATTRIBUTION}
|
||||
maxNativeZoom={OPENTOPOMAP_TILE_MAXZOOM}
|
||||
key={baseLayer}
|
||||
url={tourRaster.url}
|
||||
attribution={tourRaster.attribution}
|
||||
maxNativeZoom={tourRaster.maxZoom}
|
||||
maxZoom={MAP_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
keepBuffer={8} updateWhenZooming={false} updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
eventHandlers={tileHealthHandlers(tourLayers?.baseHealth, baseLayer)}
|
||||
/>
|
||||
) : basemap.kind === 'vector' ? (
|
||||
<VectorBasemap style={basemap.style} />
|
||||
@@ -1158,8 +1159,7 @@ export const MapView = memo(function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||
<Polyline
|
||||
@@ -1173,15 +1173,7 @@ export const MapView = memo(function MapView({
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
@@ -1189,6 +1181,7 @@ export const MapView = memo(function MapView({
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
...(onRouteClick ? [<RouteHitBand key={`${i}-hit`} positions={seg} onRouteClick={onRouteClick} />] : []),
|
||||
])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { applySatellite } from './glRasterLayers'
|
||||
import { applyBaseLayer } from './glRasterLayers'
|
||||
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
@@ -612,7 +612,7 @@ export function MapViewGL({
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
|
||||
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers)
|
||||
// Beneath the planned route's casing, the GL twin of the Leaflet pane order.
|
||||
useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing', dawarichHiddenDates)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -2185,14 +2185,11 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const apply = () => {
|
||||
applySatellite(map, isSatellite)
|
||||
applySatellite(map, baseLayer === 'topo', true)
|
||||
}
|
||||
const apply = () => applyBaseLayer(map, baseLayer)
|
||||
apply()
|
||||
map.on('styledata', apply)
|
||||
return () => { map.off('styledata', apply) }
|
||||
}, [isSatellite, baseLayer, mapReady, glProvider])
|
||||
}, [baseLayer, mapReady, glProvider])
|
||||
|
||||
if (!isMapLibre && !mapboxToken) {
|
||||
return (
|
||||
|
||||
@@ -2,18 +2,25 @@ import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The pin's inline style for a size and selection; the renderer owns the colour. */
|
||||
export type PinCss = (size: number, selected: boolean) => string;
|
||||
|
||||
function plannerWaypointIcon(pinCss: PinCss, index: number, selected: boolean, draggable: boolean): L.DivIcon {
|
||||
const size = selected ? 30 : 26;
|
||||
function plannerWaypointIcon(
|
||||
pinCss: PinCss,
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean,
|
||||
draggable: boolean
|
||||
): L.DivIcon {
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
return L.divIcon({
|
||||
className: 'tour-planner-waypoint-marker',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${index + 1}</span>`,
|
||||
html: `<span style="${pinCss(size, selected)};cursor:${draggable ? 'grab' : 'pointer'};touch-action:${draggable ? 'none' : 'auto'};user-select:none">${text}</span>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +32,7 @@ export default function PlannerWaypointMarker({
|
||||
onMove,
|
||||
pinCss,
|
||||
}: {
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string };
|
||||
point: { id: string; lat: number; lng: number; name?: string; detail?: string } & PlannerPinPoint;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick?: (id: string) => void;
|
||||
@@ -39,7 +46,11 @@ export default function PlannerWaypointMarker({
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const icon = useMemo(() => plannerWaypointIcon(pinCss, index, selected, !!onMove), [index, onMove, pinCss, selected]);
|
||||
const { label, shaping } = point;
|
||||
const icon = useMemo(
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping }, index, selected, !!onMove),
|
||||
[index, label, onMove, pinCss, selected, shaping]
|
||||
);
|
||||
const eventHandlers = useMemo(
|
||||
() => ({
|
||||
click: (event: { originalEvent: MouseEvent }) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Polyline } from 'react-leaflet';
|
||||
|
||||
/**
|
||||
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
||||
* than the drawn line, that takes the click which reshapes the route, as the
|
||||
* GL renderer's hit layer does: an 8 px line is too thin to aim at.
|
||||
*/
|
||||
export function RouteHitBand({
|
||||
positions,
|
||||
onRouteClick,
|
||||
}: {
|
||||
positions: [number, number][];
|
||||
onRouteClick: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: 'transparent',
|
||||
weight: 20,
|
||||
opacity: 0,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
className: 'cursor-copy',
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
||||
e.originalEvent.stopPropagation();
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { applyBaseLayer, tourRasterSourceId } from './glRasterLayers';
|
||||
|
||||
/** Enough of a GL map for the base layers: sources, layers with their visibility, and the style's order. */
|
||||
function fakeMap(styleLayers: Array<{ id: string }> = [{ id: 'water' }, { id: 'trip-route' }]) {
|
||||
const sources = new Map<string, Record<string, unknown>>();
|
||||
const layers = new Map<string, string>();
|
||||
const map = {
|
||||
getSource: vi.fn((id: string) => sources.get(id)),
|
||||
addSource: vi.fn((id: string, source: Record<string, unknown>) => sources.set(id, source)),
|
||||
getLayer: vi.fn((id: string) => (layers.has(id) ? { id } : undefined)),
|
||||
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, 'visible')),
|
||||
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, value)),
|
||||
getStyle: vi.fn(() => ({ layers: styleLayers })),
|
||||
};
|
||||
return { map, sources, layers };
|
||||
}
|
||||
|
||||
describe('applyBaseLayer (MAP-01)', () => {
|
||||
it('lays OpenHikingMap with its credit under the first thing TREK draws', () => {
|
||||
const { map, sources } = fakeMap();
|
||||
applyBaseLayer(map, 'hiking');
|
||||
expect(tourRasterSourceId('hiking')).toBe('trip-hiking');
|
||||
expect(sources.get('trip-hiking')).toMatchObject({
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openmaps.fr/openhikingmap/{z}/{x}/{y}.png'],
|
||||
maxzoom: 18,
|
||||
});
|
||||
expect(String(sources.get('trip-hiking')?.attribution)).toContain('openmaps.fr/donate');
|
||||
expect(map.addLayer).toHaveBeenCalledWith(expect.objectContaining({ id: 'trip-hiking-raster' }), 'trip-route');
|
||||
// Nothing else is built while it is off.
|
||||
expect(sources.has('trip-topo')).toBe(false);
|
||||
expect(sources.has('trip-satellite')).toBe(false);
|
||||
});
|
||||
|
||||
it('switching away hides it, and back shows the same layer again without a second source', () => {
|
||||
const { map, layers } = fakeMap();
|
||||
applyBaseLayer(map, 'hiking');
|
||||
applyBaseLayer(map, 'topo');
|
||||
expect(layers.get('trip-hiking-raster')).toBe('none');
|
||||
expect(layers.get('trip-topo-raster')).toBe('visible');
|
||||
applyBaseLayer(map, 'hiking');
|
||||
expect(layers.get('trip-hiking-raster')).toBe('visible');
|
||||
expect(layers.get('trip-topo-raster')).toBe('none');
|
||||
expect(map.addSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the user's own map when the style refuses the layer", () => {
|
||||
const { map } = fakeMap();
|
||||
map.addLayer.mockImplementation(() => {
|
||||
throw new Error('style not loaded');
|
||||
});
|
||||
expect(() => applyBaseLayer(map, 'hiking')).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
SATELLITE_TILE_ATTRIBUTION,
|
||||
SATELLITE_TILE_MAXZOOM,
|
||||
SATELLITE_TILE_URL,
|
||||
} from '../../constants/mapDefaults';
|
||||
import { SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, SATELLITE_TILE_URL } from '../../constants/mapDefaults';
|
||||
import { TOUR_RASTER_BASE_IDS, TOUR_RASTER_BASES, type TourRasterBase } from './tourRasterBases';
|
||||
|
||||
/*
|
||||
* The raster layers a GL map draws between its basemap style and what TREK
|
||||
* adds on top: the satellite and topo base layers, and (in mapRasterOverlay.ts)
|
||||
* adds on top: the satellite, topo and hiking base layers, and (in mapRasterOverlay.ts)
|
||||
* the transparent overlays such as the seamarks.
|
||||
*/
|
||||
|
||||
@@ -50,9 +44,10 @@ export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
||||
* yet. The catch covers that case, and `styledata` brings the pass back once it is in.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
const layerId = topo ? 'trip-topo-raster' : SATELLITE_LAYER_ID;
|
||||
const sourceId = topo ? 'trip-topo' : SATELLITE_SOURCE_ID;
|
||||
export function applySatellite(map: any, on: boolean, raster?: TourRasterBase): void {
|
||||
const layerId = raster ? `trip-${raster}-raster` : SATELLITE_LAYER_ID;
|
||||
const sourceId = raster ? tourRasterSourceId(raster) : SATELLITE_SOURCE_ID;
|
||||
const source = raster ? TOUR_RASTER_BASES[raster] : null;
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
||||
@@ -65,12 +60,10 @@ export function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
if (!map.getSource(sourceId)) {
|
||||
map.addSource(sourceId, {
|
||||
type: 'raster',
|
||||
tiles: topo
|
||||
? ['a', 'b', 'c'].map((subdomain) => OPENTOPOMAP_TILE_URL.replace('{s}', subdomain))
|
||||
: [SATELLITE_TILE_URL],
|
||||
tiles: source ? source.tiles : [SATELLITE_TILE_URL],
|
||||
tileSize: 256,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
maxzoom: source ? source.maxZoom : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: source ? source.attribution : SATELLITE_TILE_ATTRIBUTION,
|
||||
});
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
@@ -90,3 +83,14 @@ export function applySatellite(map: any, on: boolean, topo = false): void {
|
||||
/* a style that refuses the layer keeps the plain basemap */
|
||||
}
|
||||
}
|
||||
|
||||
/** The GL source a Tours raster base map is drawn from, which its tile events name. */
|
||||
export function tourRasterSourceId(raster: TourRasterBase): string {
|
||||
return `trip-${raster}`;
|
||||
}
|
||||
|
||||
/** The base layer on a GL map: the imagery, a Tours raster map, or neither over the user's own style. */
|
||||
export function applyBaseLayer(map: Parameters<typeof applySatellite>[0], baseLayer: string): void {
|
||||
applySatellite(map, baseLayer === 'satellite');
|
||||
for (const raster of TOUR_RASTER_BASE_IDS) applySatellite(map, baseLayer === raster, raster);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay';
|
||||
import {
|
||||
useRasterOverlaysGL,
|
||||
useSourceTileHealthGL,
|
||||
type MapRasterOverlay,
|
||||
type RasterOverlayGlMap,
|
||||
} from './mapRasterOverlay';
|
||||
|
||||
type Handler = (event: Record<string, unknown>) => void;
|
||||
|
||||
@@ -103,3 +108,27 @@ describe('useRasterOverlaysGL', () => {
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSourceTileHealthGL (MAP-01)', () => {
|
||||
it("reports one source's failed and arrived tiles into its health, nothing else's", () => {
|
||||
const { gl, fire } = fakeMap();
|
||||
const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
|
||||
renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health));
|
||||
fire('error', { sourceId: 'trip-hiking', error: new Error('503') });
|
||||
fire('error', { sourceId: 'trip-topo', error: new Error('503') });
|
||||
fire('sourcedata', { sourceId: 'trip-hiking', tile: {} });
|
||||
fire('sourcedata', { sourceId: 'trip-hiking' });
|
||||
expect(health.onTileError).toHaveBeenCalledTimes(1);
|
||||
expect(health.onTileLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('listens to nothing while the map is not shown, and lets go when it goes', () => {
|
||||
const { map, gl } = fakeMap();
|
||||
const health = { down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
|
||||
renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', undefined));
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
const { unmount } = renderHook(() => useSourceTileHealthGL(gl, 'trip-hiking', health));
|
||||
unmount();
|
||||
expect(map.off).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@ import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerG
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||
import { isPaddleHit, type PaddleGlMap, usePaddleLayerGL } from '../Tours/paddle/usePaddleLayerGL';
|
||||
import { OWN_LAYER_PREFIXES } from './glRasterLayers';
|
||||
import { OWN_LAYER_PREFIXES, tourRasterSourceId } from './glRasterLayers';
|
||||
import type { BaseMapHealth, TileHealth } from './tourRasterBases';
|
||||
|
||||
/**
|
||||
* A transparent raster layer laid over the basemap and under the route, such
|
||||
@@ -104,16 +105,47 @@ export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: re
|
||||
}, [map, overlays]);
|
||||
}
|
||||
|
||||
/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
|
||||
/**
|
||||
* Reports the tiles of one GL source into its health while `health` is given:
|
||||
* a failure carries the source it came from, an arrival a tile. The base layer
|
||||
* itself is drawn by applyBaseLayer.
|
||||
*/
|
||||
export function useSourceTileHealthGL(
|
||||
map: RasterOverlayGlMap | null,
|
||||
sourceId: string,
|
||||
health: TileHealth | undefined
|
||||
) {
|
||||
const { onTileError, onTileLoad } = health ?? {};
|
||||
useEffect(() => {
|
||||
if (!map || !onTileError || !onTileLoad) return;
|
||||
const failed = (event: { error?: Error; sourceId?: string }) => {
|
||||
if (event.sourceId === sourceId) onTileError();
|
||||
};
|
||||
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
|
||||
if (event.sourceId === sourceId && event.tile) onTileLoad();
|
||||
};
|
||||
map.on('error', failed);
|
||||
map.on('sourcedata', loaded);
|
||||
return () => {
|
||||
map.off('error', failed);
|
||||
map.off('sourcedata', loaded);
|
||||
};
|
||||
}, [map, sourceId, onTileError, onTileLoad]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers,
|
||||
* the paddle obstacles, and whether the map on terms (OpenHikingMap, CyclOSM) answers while it is the base map.
|
||||
*/
|
||||
export function useTourMapLayersGL(
|
||||
map: RasterOverlayGlMap | null,
|
||||
ferrataLayer: FerrataMapLayer | undefined,
|
||||
overlays: readonly MapRasterOverlay[] | undefined,
|
||||
paddle?: PaddleMapLayer
|
||||
layers: { overlays?: readonly MapRasterOverlay[]; paddle?: PaddleMapLayer; baseHealth?: BaseMapHealth } | undefined
|
||||
) {
|
||||
useFerrataMapLayerGL(map, ferrataLayer);
|
||||
useRasterOverlaysGL(map, overlays);
|
||||
usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle);
|
||||
useRasterOverlaysGL(map, layers?.overlays);
|
||||
usePaddleLayerGL(map as unknown as PaddleGlMap | null, layers?.paddle);
|
||||
useSourceTileHealthGL(map, tourRasterSourceId(layers?.baseHealth?.base ?? 'hiking'), layers?.baseHealth);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -110,7 +110,15 @@ export interface MapViewProps {
|
||||
onHighlightAlternative?: (index: number | null) => void;
|
||||
/** Generic numbered control points for list-first route editors. */
|
||||
/** `name` and `detail` (its role) show on hover; without a name the pin is named by its number. */
|
||||
plannerWaypoints?: Array<{ id: string; lat: number; lng: number; name?: string; detail?: string }>;
|
||||
plannerWaypoints?: Array<{
|
||||
id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
name?: string;
|
||||
detail?: string;
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}>;
|
||||
selectedPlannerWaypointId?: string | null;
|
||||
onPlannerWaypointClick?: (id: string) => void;
|
||||
/** Drops a dragged control point; returns false when the move was refused. */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// FE-PLANNER-PIN-001: a stop is a numbered pin, a shaping point a small dot without a number (P2A-03).
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
|
||||
describe('plannerPinShape', () => {
|
||||
it('FE-PLANNER-PIN-001: numbers a stop by its label or its place, and leaves a shaping point blank and small', () => {
|
||||
expect(plannerPinShape({}, 2, false)).toEqual({ size: 26, text: '3' });
|
||||
expect(plannerPinShape({ label: '2' }, 4, true)).toEqual({ size: 30, text: '2' });
|
||||
expect(plannerPinShape({ shaping: true, label: '9' }, 1, false)).toEqual({ size: 14, text: '' });
|
||||
expect(plannerPinShape({ shaping: true }, 1, true)).toEqual({ size: 18, text: '' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** What a planner pin shows: its stop number, or (P2A-03) nothing on a shaping point, which only steers the route. */
|
||||
export interface PlannerPinPoint {
|
||||
/** The stop number to print; shaping points do not count, so stops keep their numbers. */
|
||||
label?: string;
|
||||
shaping?: boolean;
|
||||
}
|
||||
|
||||
/** A stop is a numbered pin; a shaping point a small dot of the same colour, without a number. */
|
||||
export function plannerPinShape(
|
||||
point: PlannerPinPoint,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): { size: number; text: string } {
|
||||
if (point.shaping) return { size: selected ? 18 : 14, text: '' };
|
||||
return { size: selected ? 30 : 26, text: point.label ?? String(index + 1) };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, Binoculars, Mountain, Warehouse, Droplet, Toilet, Sandwich, Anchor, Sailboat, Wrench, Store, type LucideIcon } from 'lucide-react'
|
||||
import type { TourPoiCategory } from '@trek/shared'
|
||||
import { parsePluginPoiCategoryKey, type PluginPoiDetail } from '@trek/shared'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
@@ -40,9 +41,41 @@ export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
||||
)
|
||||
|
||||
const CORE_COLOR = new Map(POI_CATEGORIES.map(c => [c.key, c.color] as const))
|
||||
const coreColor = (key: string) => CORE_COLOR.get(key) ?? POI_DEFAULT_COLOR
|
||||
|
||||
/**
|
||||
* What a hike, a ride or a paddle tour stops for (PLN-02), searched along a tour in the
|
||||
* planner and drawn on its map. Each takes the colour of the explore category
|
||||
* it is closest to, so the map keeps one palette.
|
||||
*/
|
||||
export const TOUR_POI_LOOKS: Record<TourPoiCategory, PoiCategory> = {
|
||||
viewpoint: { key: 'viewpoint', labelKey: 'tours.poi.cat.viewpoint', Icon: Binoculars, color: coreColor('sights') },
|
||||
peak: { key: 'peak', labelKey: 'tours.poi.cat.peak', Icon: Mountain, color: coreColor('nature') },
|
||||
shelter: { key: 'shelter', labelKey: 'tours.poi.cat.shelter', Icon: Warehouse, color: HOTEL_COLOR },
|
||||
drinking_water: { key: 'drinking_water', labelKey: 'tours.poi.cat.drinking_water', Icon: Droplet, color: coreColor('museum') },
|
||||
food: { key: 'food', labelKey: 'tours.poi.cat.food', Icon: Utensils, color: coreColor('restaurant') },
|
||||
toilets: { key: 'toilets', labelKey: 'tours.poi.cat.toilets', Icon: Toilet, color: coreColor('bar') },
|
||||
picnic: { key: 'picnic', labelKey: 'tours.poi.cat.picnic', Icon: Sandwich, color: coreColor('nature') },
|
||||
campsite: { key: 'campsite', labelKey: 'tours.poi.cat.campsite', Icon: Tent, color: coreColor('activity') },
|
||||
sights: { key: 'sights', labelKey: 'tours.poi.cat.sights', Icon: Camera, color: coreColor('sights') },
|
||||
slipway: { key: 'slipway', labelKey: 'tours.poi.cat.slipway', Icon: Anchor, color: coreColor('museum') },
|
||||
boat_rental: { key: 'boat_rental', labelKey: 'tours.poi.cat.boat_rental', Icon: Sailboat, color: coreColor('cafe') },
|
||||
bike_repair: { key: 'bike_repair', labelKey: 'tours.poi.cat.bike_repair', Icon: Wrench, color: coreColor('activity') },
|
||||
// CW-05: a city walk's own, each in the colour of the explore category it comes from.
|
||||
museum: { key: 'museum', labelKey: 'tours.poi.cat.museum', Icon: Landmark, color: coreColor('museum') },
|
||||
park: { key: 'park', labelKey: 'tours.poi.cat.park', Icon: Trees, color: coreColor('nature') },
|
||||
cafe: { key: 'cafe', labelKey: 'tours.poi.cat.cafe', Icon: Coffee, color: coreColor('cafe') },
|
||||
restaurant: { key: 'restaurant', labelKey: 'tours.poi.cat.restaurant', Icon: Utensils, color: coreColor('restaurant') },
|
||||
market: { key: 'market', labelKey: 'tours.poi.cat.market', Icon: Store, color: coreColor('shopping') },
|
||||
}
|
||||
|
||||
// A Map rather than an object, so a category named after an Object.prototype member
|
||||
// (`constructor`, `toString`) finds nothing rather than a function.
|
||||
const CATEGORY_BY_KEY = new Map([...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const))
|
||||
// (`constructor`, `toString`) finds nothing rather than a function. A Tours category
|
||||
// that shares a key with an explore one (`sights`, `campsite`) keeps the explore look.
|
||||
const CATEGORY_BY_KEY = new Map(
|
||||
[...Object.values(TOUR_POI_LOOKS), ...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c] as const),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
export interface Poi {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isOnTermsBase, tileHealthHandlers, TOUR_RASTER_BASES, tourRasterBaseOf } from './tourRasterBases';
|
||||
|
||||
// FE-MAP-TOURBASES-001 to FE-MAP-TOURBASES-003
|
||||
|
||||
describe('tourRasterBases (MAP-01, BIK-06)', () => {
|
||||
it('FE-MAP-TOURBASES-001: knows topo, OpenHikingMap and CyclOSM, CyclOSM on its three shards', () => {
|
||||
expect(tourRasterBaseOf('topo')).toBe(TOUR_RASTER_BASES.topo);
|
||||
expect(tourRasterBaseOf('cycle')?.tiles).toEqual([
|
||||
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||
'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||
'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
||||
]);
|
||||
expect(tourRasterBaseOf('cycle')?.maxZoom).toBe(20);
|
||||
expect(tourRasterBaseOf('satellite')).toBeNull();
|
||||
// Inherited names are no base map.
|
||||
expect(tourRasterBaseOf('toString')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-TOURBASES-002: the maps on terms carry their credit; topo is no map on terms', () => {
|
||||
expect(isOnTermsBase('hiking')).toBe(true);
|
||||
expect(isOnTermsBase('cycle')).toBe(true);
|
||||
expect(isOnTermsBase('topo')).toBe(false);
|
||||
expect(isOnTermsBase('default')).toBe(false);
|
||||
expect(TOUR_RASTER_BASES.cycle.onTerms?.credit.map((part) => part.href)).toEqual([
|
||||
'https://www.cyclosm.org',
|
||||
'https://www.openstreetmap.org/copyright',
|
||||
]);
|
||||
expect(TOUR_RASTER_BASES.hiking.onTerms?.credit).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-MAP-TOURBASES-003: a Leaflet layer reports into the health of the map watched, and only of that one', () => {
|
||||
const health = { base: 'cycle' as const, down: false, onTileError: vi.fn(), onTileLoad: vi.fn() };
|
||||
expect(tileHealthHandlers(health, 'cycle')).toEqual({ tileerror: health.onTileError, tileload: health.onTileLoad });
|
||||
expect(tileHealthHandlers(health, 'hiking')).toBeUndefined();
|
||||
expect(tileHealthHandlers(undefined, 'cycle')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
CYCLOSM_TILE_ATTRIBUTION,
|
||||
CYCLOSM_TILE_MAXZOOM,
|
||||
CYCLOSM_TILE_URL,
|
||||
OPENHIKINGMAP_TILE_ATTRIBUTION,
|
||||
OPENHIKINGMAP_TILE_MAXZOOM,
|
||||
OPENHIKINGMAP_TILE_URL,
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
} from '../../constants/mapDefaults';
|
||||
|
||||
/** The raster base maps only the Tours planner offers, drawn the same way by Leaflet, GL and the 3D map. */
|
||||
export type TourRasterBase = 'topo' | 'hiking' | 'cycle';
|
||||
|
||||
export interface TourRasterBaseSource {
|
||||
/** The Leaflet template; `{s}` is a shard Leaflet fills in. */
|
||||
url: string;
|
||||
/** The same tiles spelled out for a GL source, which knows no shards. */
|
||||
tiles: string[];
|
||||
attribution: string;
|
||||
maxZoom: number;
|
||||
/**
|
||||
* A map its operator offers on terms (MAP-01, BIK-06): loaded only as viewed, never
|
||||
* prefetched or kept offline, its credit readable without a click, and watched for whether it
|
||||
* answers. `credit` is what the 3D map shows above its controls.
|
||||
*/
|
||||
onTerms?: { credit: ReadonlyArray<{ label: string; href: string }> };
|
||||
}
|
||||
|
||||
export const TOUR_RASTER_BASES: Record<TourRasterBase, TourRasterBaseSource> = {
|
||||
topo: {
|
||||
url: OPENTOPOMAP_TILE_URL,
|
||||
tiles: ['a', 'b', 'c'].map((shard) => OPENTOPOMAP_TILE_URL.replace('{s}', shard)),
|
||||
attribution: OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
maxZoom: OPENTOPOMAP_TILE_MAXZOOM,
|
||||
},
|
||||
hiking: {
|
||||
url: OPENHIKINGMAP_TILE_URL,
|
||||
tiles: [OPENHIKINGMAP_TILE_URL],
|
||||
attribution: OPENHIKINGMAP_TILE_ATTRIBUTION,
|
||||
maxZoom: OPENHIKINGMAP_TILE_MAXZOOM,
|
||||
onTerms: {
|
||||
credit: [
|
||||
{ label: '\u00a9 OpenHikingMap', href: 'https://wiki.openstreetmap.org/wiki/OpenHikingMap' },
|
||||
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
|
||||
{ label: '\u2764\ufe0f Donation', href: 'https://openmaps.fr/donate' },
|
||||
],
|
||||
},
|
||||
},
|
||||
cycle: {
|
||||
url: CYCLOSM_TILE_URL,
|
||||
tiles: ['a', 'b', 'c'].map((shard) => CYCLOSM_TILE_URL.replace('{s}', shard)),
|
||||
attribution: CYCLOSM_TILE_ATTRIBUTION,
|
||||
maxZoom: CYCLOSM_TILE_MAXZOOM,
|
||||
onTerms: {
|
||||
credit: [
|
||||
{ label: '\u00a9 CyclOSM', href: 'https://www.cyclosm.org' },
|
||||
{ label: '\u00a9 OpenStreetMap', href: 'https://www.openstreetmap.org/copyright' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TOUR_RASTER_BASE_IDS = Object.keys(TOUR_RASTER_BASES) as TourRasterBase[];
|
||||
|
||||
/** The raster source of a base layer, or null for the user's own map and the imagery. */
|
||||
export function tourRasterBaseOf(layer: string): TourRasterBaseSource | null {
|
||||
return Object.prototype.hasOwnProperty.call(TOUR_RASTER_BASES, layer)
|
||||
? TOUR_RASTER_BASES[layer as TourRasterBase]
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether the layer is a map its operator offers on terms (OpenHikingMap, CyclOSM). */
|
||||
export function isOnTermsBase(layer: string): layer is TourRasterBase {
|
||||
return !!tourRasterBaseOf(layer)?.onTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a best-effort tile source still answers: a run of failed tiles marks
|
||||
* it down, one tile arriving clears that. The maps report into it, the switch
|
||||
* shows it.
|
||||
*/
|
||||
export interface TileHealth {
|
||||
down: boolean;
|
||||
onTileError: () => void;
|
||||
onTileLoad: () => void;
|
||||
}
|
||||
|
||||
/** The health of the map on terms that is the base map now, with which one it is. */
|
||||
export interface BaseMapHealth extends TileHealth {
|
||||
base: TourRasterBase;
|
||||
}
|
||||
|
||||
/** A Leaflet tile layer's events that report into the health of `layer`, while it is the map watched. */
|
||||
export function tileHealthHandlers(health: BaseMapHealth | undefined, layer: string) {
|
||||
return health?.base === layer ? { tileerror: health.onTileError, tileload: health.onTileLoad } : undefined;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { plannerPinShape } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
|
||||
/** The slice of the GL map the waypoint pins need: its box and screen-to-ground lookup. */
|
||||
@@ -71,10 +72,10 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
|
||||
for (const [index, point] of plannerWaypoints.entries()) {
|
||||
const selected = point.id === selectedPlannerWaypointId;
|
||||
const size = selected ? 30 : 26;
|
||||
const { size, text } = plannerPinShape(point, index, selected);
|
||||
const el = document.createElement('button');
|
||||
el.type = 'button';
|
||||
el.textContent = String(index + 1);
|
||||
el.textContent = text;
|
||||
const name = point.name ?? `Waypoint ${index + 1}`;
|
||||
el.setAttribute('aria-label', name);
|
||||
el.style.cssText = `${pinCss(size, selected)};cursor:pointer;padding:0`;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids } from './valhallaRoute'
|
||||
import { valhallaBase, valhallaAvailable, valhallaTurn, resetValhallaTurns, valhallaRouteAvoiding, valhallaRun, valhallaAlternates, alternatesFrom, runFrom, legAvoids, valhallaTraceAttributes } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
@@ -531,3 +531,55 @@ describe('valhallaAlternates', () => {
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaTraceAttributes (BIK-01)', () => {
|
||||
const TRACE = 'https://valhalla1.openstreetmap.de/trace_attributes'
|
||||
|
||||
it('VALHALLA-TRACE-001: asks for the ways under the line, matched as a mountain bike rides, and hands the answer on', async () => {
|
||||
const bodies: Record<string, unknown>[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json({ shape: SHAPE, edges: [] })
|
||||
}))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toEqual({ shape: SHAPE, edges: [] })
|
||||
expect(bodies[0]).toMatchObject({
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
shape_match: 'map_snap',
|
||||
filters: { action: 'include' },
|
||||
})
|
||||
expect(bodies[0].shape).toEqual(SHAPE_POINTS.map(([lat, lon]) => ({ lat, lon })))
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-002: a long line is thinned evenly to what one request carries, its ends kept', async () => {
|
||||
let shape: unknown[] = []
|
||||
server.use(http.post(TRACE, async ({ request }) => {
|
||||
shape = ((await request.json()) as { shape: unknown[] }).shape
|
||||
return HttpResponse.json({})
|
||||
}))
|
||||
const line = Array.from({ length: 9001 }, (_, i): [number, number] => [47 + i / 1e5, 11])
|
||||
await valhallaTraceAttributes(line)
|
||||
expect(shape.length).toBeLessThanOrEqual(4001)
|
||||
expect(shape[0]).toEqual({ lat: 47, lon: 11 })
|
||||
expect(shape[shape.length - 1]).toEqual({ lat: line[9000][0], lon: 11 })
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-003: null for a refusal, a network failure, a line of one point or a router that is not Valhalla', async () => {
|
||||
server.use(http.post(TRACE, () => new HttpResponse(null, { status: 400 })))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
server.use(http.post(TRACE, () => HttpResponse.error()))
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS.slice(0, 1))).resolves.toBeNull()
|
||||
setSettings({ routing_base_url: 'https://osrm.example' })
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-TRACE-004: a caller gone before its turn asks nothing', async () => {
|
||||
let asked = false
|
||||
server.use(http.post(TRACE, () => { asked = true; return HttpResponse.json({}) }))
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(valhallaTraceAttributes(SHAPE_POINTS, controller.signal)).resolves.toBeNull()
|
||||
expect(asked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +55,9 @@ const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/** What a caller tunes in the costing model: a weighting, a switch, or a bicycle type by name. */
|
||||
export type CostingOptions = Record<string, number | string | boolean>
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
@@ -191,7 +194,7 @@ export async function valhallaRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
@@ -315,14 +318,14 @@ function routeBody(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Record<string, unknown> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = { ...costingOptions }
|
||||
const options: CostingOptions = { ...costingOptions }
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
return {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
@@ -359,6 +362,51 @@ async function postRoute(base: string, body: Record<string, unknown>, signal?: A
|
||||
}
|
||||
}
|
||||
|
||||
/** The most points a description of a line asks about; a longer line is thinned evenly, its ends kept. */
|
||||
const TRACE_MAX_POINTS = 4000
|
||||
/** How long a description may take before it counts as failed, so a router that hangs does not leave its card waiting. */
|
||||
const TRACE_TIMEOUT_MS = 30_000
|
||||
|
||||
/**
|
||||
* BIK-01: how the router describes the ways under a line (`trace_attributes`):
|
||||
* the surface, the use and road class, the cycle lane and the OpenStreetMap way
|
||||
* of every edge, with the line as matched. The edges are matched as a mountain
|
||||
* bike would ride them, the costing that refuses the fewest ways, so a road
|
||||
* bike's route and a gravel one are described alike. Null for every failure:
|
||||
* the description is an addition beside a route that already exists.
|
||||
*/
|
||||
export async function valhallaTraceAttributes(line: readonly [number, number][], signal?: AbortSignal): Promise<unknown> {
|
||||
const base = valhallaBase()
|
||||
if (!base || line.length < 2) return null
|
||||
const step = Math.max(1, Math.ceil(line.length / TRACE_MAX_POINTS))
|
||||
const shape = line.filter((_, index) => index % step === 0 || index === line.length - 1).map(([lat, lon]) => ({ lat, lon }))
|
||||
const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(TRACE_TIMEOUT_MS)]) : AbortSignal.timeout(TRACE_TIMEOUT_MS)
|
||||
try {
|
||||
await valhallaTurn(base, signal)
|
||||
if (signal?.aborted) return null
|
||||
const response = await fetch(`${base}/trace_attributes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify({
|
||||
shape,
|
||||
costing: 'bicycle',
|
||||
costing_options: { bicycle: { bicycle_type: 'Mountain' } },
|
||||
shape_match: 'map_snap',
|
||||
filters: {
|
||||
attributes: ['shape', 'edge.surface', 'edge.use', 'edge.road_class', 'edge.cycle_lane', 'edge.length', 'edge.way_id',
|
||||
'edge.begin_shape_index', 'edge.end_shape_index'],
|
||||
action: 'include',
|
||||
},
|
||||
}),
|
||||
signal: bounded,
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return await response.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
@@ -366,7 +414,7 @@ async function requestRun(
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
costingOptions: Readonly<Record<string, number>> = {},
|
||||
costingOptions: Readonly<CostingOptions> = {},
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const data = await postRoute(base, routeBody(waypoints, profile, avoid, costingOptions), signal)
|
||||
return data === null ? null : runFrom(data)
|
||||
|
||||
@@ -8,7 +8,8 @@ import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { WalkProgressPill } from '../Tours/walk/WalkStations';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
type DayRowTourTimes = TourPlannedTimes & { tour?: TourListItem };
|
||||
@@ -19,6 +20,8 @@ interface DayPlanPlaceSublineProps {
|
||||
tourTimes: DayRowTourTimes | undefined;
|
||||
isTourPlace: boolean;
|
||||
timeFormat: string;
|
||||
/** CW-03: the row's day assignment, which a city walk's progress belongs to. */
|
||||
assignmentId?: number;
|
||||
}
|
||||
|
||||
function hasPlannedTimes(tourTimes: DayRowTourTimes | undefined): boolean {
|
||||
@@ -57,9 +60,9 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-planned-duration"
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
aria-label={`${t('tours.planner.plannedTotalDuration')}: ${plannedTotalText(tourTimes, t)}${manual ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}
|
||||
>
|
||||
{t('tours.planner.plannedShort')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}
|
||||
{t('tours.planner.plannedShort')}: {plannedTotalText(tourTimes, t, true)}
|
||||
{manual ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
|
||||
</span>
|
||||
</TimePill>
|
||||
@@ -90,7 +93,14 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
|
||||
|
||||
// Second line of a place row in the day plan: time, tour facts or planned
|
||||
// tour durations, then the clamped description.
|
||||
export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFormat }: DayPlanPlaceSublineProps) {
|
||||
export function DayPlanPlaceSubline({
|
||||
place,
|
||||
cat,
|
||||
tourTimes,
|
||||
isTourPlace,
|
||||
timeFormat,
|
||||
assignmentId,
|
||||
}: DayPlanPlaceSublineProps) {
|
||||
const { locale } = useTranslation();
|
||||
const dayRowTour = tourTimes?.tour;
|
||||
const description = place.description || place.address || cat?.name;
|
||||
@@ -106,6 +116,7 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{dayRowTour && <WalkProgressPill tour={dayRowTour} assignmentId={assignmentId} />}
|
||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
// RS-01 / RC-01: how a Tour stop reads in the day plan while the Tours addon is on,
|
||||
// and that it falls back to the plain Place row when it is off. The mocks are the
|
||||
// ones DayPlanSidebar.test.tsx renders the sidebar with.
|
||||
// CW-08: public transit between two city walks from the day plan's connector.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAssignment, buildDay, buildPlace, buildTrip, buildUser } from '../../../tests/helpers/factories';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildReservation,
|
||||
buildTrip,
|
||||
buildUser,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Reservation } from '../../types';
|
||||
import { tourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import DayPlanSidebar from './DayPlanSidebar';
|
||||
// ── Hoisted mock state (accessible in vi.mock factories) ────────────────────
|
||||
const mockDayNotesState = vi.hoisted(() => ({
|
||||
@@ -56,12 +68,14 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
generateGoogleMapsUrl: vi.fn().mockReturnValue('https://maps.google.com/...'),
|
||||
generateCoMapsUrl: vi.fn().mockReturnValue('https://comaps.at/...'),
|
||||
optimizeRoute: vi.fn().mockImplementation((places) => places),
|
||||
// One leg per waypoint gap; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints) =>
|
||||
// One leg per waypoint gap, carrying its two ends as the real one does; the connector between two stops reads distanceText.
|
||||
calculateRouteWithLegs: vi.fn().mockImplementation((waypoints: { lat: number; lng: number }[] = []) =>
|
||||
Promise.resolve({
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
legs: Array.from({ length: Math.max(0, (waypoints?.length ?? 0) - 1) }, () => ({
|
||||
legs: waypoints.slice(1).map((to, index) => ({
|
||||
from: [waypoints[index]?.lat, waypoints[index]?.lng],
|
||||
to: [to.lat, to.lng],
|
||||
distanceText: '2 km',
|
||||
durationText: '10 min',
|
||||
drivingText: '10 min',
|
||||
@@ -71,6 +85,13 @@ vi.mock('../Map/RouteCalculator', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// CW-08: a walk's stops are read for the names of a transit leg's two ends.
|
||||
const mockTourDetail = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: mockTourDetail } };
|
||||
});
|
||||
|
||||
// PlaceAvatar needs IntersectionObserver
|
||||
class MockIO {
|
||||
observe = vi.fn();
|
||||
@@ -160,7 +181,14 @@ describe('DayPlanSidebar', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 101, day_id: 10, order_index: 0, place });
|
||||
const times = { walkingMinutes: 60, breakMinutes: 35, plannedTotalMinutes: 95, manuallyOverridden: false };
|
||||
const times = {
|
||||
walkingMinutes: 60,
|
||||
breakMinutes: 35,
|
||||
plannedTotalMinutes: 95,
|
||||
manuallyOverridden: false,
|
||||
walk: null,
|
||||
atLeast: false,
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
@@ -189,6 +217,22 @@ describe('DayPlanSidebar', () => {
|
||||
);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Manual override');
|
||||
|
||||
// CW-02: a city walk whose stay is not entered yet plans at least its known total.
|
||||
view.rerender(
|
||||
<DayPlanSidebar
|
||||
{...{
|
||||
...props,
|
||||
plannedTourDurations: new Map([
|
||||
[place.id, { ...times, breakMinutes: null, plannedTotalMinutes: 135, atLeast: true }],
|
||||
]),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const walkTotal = screen.getByTestId('trip-plan-tour-planned-duration');
|
||||
expect(walkTotal).toHaveTextContent('Planned: ≥ 2 h 15 min');
|
||||
expect(walkTotal).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(view.container.querySelector('[data-testid="trip-plan-tour-description"]')).toBeNull();
|
||||
@@ -305,3 +349,214 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// CW-08: the old town walk ends at the bridge, the harbour walk starts at the fish market.
|
||||
const TOURS_ON = [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }];
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
tour_place_id: 51,
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
tour_place_id: 52,
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const cafe = buildPlace({ id: 53, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
function walkTour(place: { id: number; name: string }, overrides: Partial<TourListItem> = {}): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
distance: 3,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: 90,
|
||||
break_additional_minutes: null,
|
||||
planned_duration_minutes: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: true,
|
||||
caution: false,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
...overrides,
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
}),
|
||||
buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
}),
|
||||
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
|
||||
],
|
||||
};
|
||||
const tours = [walkTour(oldTown), walkTour(harbour)];
|
||||
return makeDefaultProps({
|
||||
days: [day],
|
||||
places: [oldTown, harbour, cafe],
|
||||
assignments,
|
||||
selectedDayId: 10,
|
||||
routeShown: true,
|
||||
tourPlaceIds: new Set([51, 52]),
|
||||
plannedTourDurations: new Map(tours.map((tour) => [tour.place_id, { ...tourPlannedTimes(tour), tour }])),
|
||||
reservations: extra.reservations ?? [],
|
||||
onPlanTransitLeg: extra.onPlanTransitLeg,
|
||||
});
|
||||
}
|
||||
|
||||
/** A journey saved from the old town walk's end to the harbour walk's start, arriving at `arrives`. */
|
||||
function savedJourney(arrives: string): Reservation {
|
||||
return {
|
||||
...buildReservation({
|
||||
id: 300,
|
||||
type: 'transit',
|
||||
title: 'Old bridge → Fish market',
|
||||
reservation_time: '2025-06-01T12:20',
|
||||
reservation_end_time: `2025-06-01T${arrives}`,
|
||||
day_id: 10,
|
||||
}),
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Old bridge',
|
||||
lat: 52.51,
|
||||
lng: 13.42,
|
||||
local_date: '2025-06-01',
|
||||
local_time: '12:20',
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'Fish market',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
local_date: '2025-06-01',
|
||||
local_time: arrives,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
transit: {
|
||||
provider: 'transitous',
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
legs: [{ mode: 'TRAM', line: 'M1', duration: 1500 }],
|
||||
},
|
||||
},
|
||||
} as unknown as Reservation;
|
||||
}
|
||||
|
||||
function contextMenu() {
|
||||
return within(document.querySelector('[style*="z-index: 999999"]') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DayPlanSidebar, public transit between two city walks (CW-08)', () => {
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: TOURS_ON });
|
||||
mockTourDetail.mockImplementation((_tripId: number, placeId: number) =>
|
||||
Promise.resolve({
|
||||
tour: walkTour(placeId === 51 ? oldTown : harbour),
|
||||
waypoints:
|
||||
placeId === 51
|
||||
? [
|
||||
{ lat: 52.52, lng: 13.4, role: 'start', sequence: 0, key: 'a1', name: 'Town hall', stay_minutes: 30 },
|
||||
{ lat: 52.51, lng: 13.42, role: 'end', sequence: 1, key: 'a2', name: 'Old bridge', stay_minutes: 15 },
|
||||
]
|
||||
: [
|
||||
{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0, key: 'b1', name: 'Fish market' },
|
||||
{ lat: 52.49, lng: 13.46, role: 'end', sequence: 1, key: 'b2', name: 'Lighthouse' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('CW-08: the connector between two walks opens the search from the last stop to the first, when the first walk ends', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg })} />);
|
||||
|
||||
await user.click(await screen.findByLabelText('Change travel mode'));
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
// The walk's end at 10:00 plus 90 min walking and 45 min of stays; nothing is saved.
|
||||
await waitFor(() =>
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(mockTourDetail).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('CW-08: a leg from a walk to a place keeps the search as for any leg, and reads no stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, withCafe: true })} />);
|
||||
|
||||
const connectors = await screen.findAllByLabelText('Change travel mode');
|
||||
expect(connectors).toHaveLength(2);
|
||||
await user.click(connectors[1]);
|
||||
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }));
|
||||
|
||||
expect(onPlanTransitLeg).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: '', lat: 52.49, lng: 13.46 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: null,
|
||||
});
|
||||
expect(mockTourDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('CW-08: a journey saved at the walks’ ends adds no walk of nothing on either side of it', async () => {
|
||||
render(<DayPlanSidebar {...walkDayProps({ withCafe: true, reservations: [savedJourney('12:50')] })} />);
|
||||
|
||||
// The only leg left is the one from the harbour walk to the cafe; the journey covers the rest.
|
||||
await screen.findByLabelText('Change travel mode');
|
||||
expect(screen.getAllByLabelText('Change travel mode')).toHaveLength(1);
|
||||
expect(screen.queryByTestId('transit-walk-late')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('CW-08: a saved journey that now arrives after the next walk starts says so, and changes nothing', () => {
|
||||
const onPlanTransitLeg = vi.fn();
|
||||
render(<DayPlanSidebar {...walkDayProps({ onPlanTransitLeg, reservations: [savedJourney('13:10')] })} />);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(onPlanTransitLeg).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,9 @@ import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallba
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Eraser, Route as RouteIcon, RouteOff, Bookmark, StickyNote, TramFront, Zap, MapPin, Globe } from 'lucide-react'
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
||||
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
||||
@@ -161,8 +162,9 @@ export interface DayPlanSidebarProps {
|
||||
* Opens the public-transit search pre-filled for a single leg (#1281 follow-up):
|
||||
* the leg's origin and destination endpoints plus the origin's departure time,
|
||||
* so "public transport" becomes an option on any connector, not just the day header.
|
||||
* Between two city walks it carries what the search says of them (CW-08).
|
||||
*/
|
||||
onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void
|
||||
onPlanTransitLeg?: (leg: PlannedTransitLeg) => void
|
||||
/** Opens the journey view for a saved transit entry (#1065). */
|
||||
onOpenTransit?: (reservation: Reservation) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
@@ -789,6 +791,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]
|
||||
const to = run[i + 1]
|
||||
// A booking whose station is the stop itself leaves a leg that goes nowhere, as on the phone (CW-08).
|
||||
if ((!from.isPlace || !to.isPlace) && from.lat === to.lat && from.lng === to.lng) continue
|
||||
const mode = resolveLegMode(from, to, dfMode)
|
||||
tasks.push(async () => {
|
||||
const seg = await legBetween(from, to, dayId, mode)
|
||||
@@ -1405,26 +1409,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
})
|
||||
}
|
||||
|
||||
// Coordinates back to names for the transit search, over every located place,
|
||||
// hotel and booking endpoint on the trip (see transitLeg.ts).
|
||||
const transitNameIndex = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations],
|
||||
)
|
||||
|
||||
// The extra connector-menu entry (#1281 follow-up): search public transit for this
|
||||
// leg instead of drawing a road route. Only when a handler is wired (day has dates).
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment) => {
|
||||
if (!onPlanTransitLeg) return undefined
|
||||
const leg = buildTransitLeg(seg, dayId, transitNameIndex, assignments, reservations)
|
||||
if (!leg) return undefined
|
||||
return () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time })
|
||||
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
|
||||
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
|
||||
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
|
||||
onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour })
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
|
||||
const leg = transitLegs.legFor(dayId, seg)
|
||||
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
|
||||
}
|
||||
|
||||
// Open the mode menu at the clicked connector: every route profile, the optional
|
||||
// "public transport" entry, plus a "use day default" entry that clears the override.
|
||||
const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => {
|
||||
ctxMenu.open(e, legModeMenu(mode => setLegMode(assignmentId, dayId, mode), transitLegPlanner(dayId, seg)))
|
||||
ctxMenu.open(e, legModeMenu(m => setLegMode(assignmentId, dayId, m), transitLegPlanner(dayId, seg, assignmentId)))
|
||||
}
|
||||
|
||||
// Set the mode of the leg ENTERING this stop (a booking arrival or the morning
|
||||
@@ -2033,7 +2030,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} />
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} />
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
@@ -2359,6 +2356,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<WalkLateLine late={transitLegs.lateBefore(day, res, merged.slice(idx + 1))} />
|
||||
</div>
|
||||
{transitMeta && (() => {
|
||||
const expanded = expandedTransitIds.has(res.id)
|
||||
|
||||
@@ -159,7 +159,7 @@ export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay,
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" align="top" blocked={!!asking} header={header} footer={footer}>
|
||||
<DialogShell open={isOpen} onClose={close} labelledBy={dialogTitleId} width="narrow" blocked={!!asking} header={header} footer={footer}>
|
||||
<div ref={bodyRef}>
|
||||
{asking ? (
|
||||
<section aria-labelledby={titleId} aria-describedby={bodyId} className="trek-page-enter">
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { isCarrierTransport } from '../../utils/dayMerge'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { WalkLateLine, WalkTransitNote } from '../Tours/walk/WalkTransitNote'
|
||||
import { lateForWalk, type WalkLate, type WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -314,7 +316,7 @@ function LineBadge({ leg }: { leg: TransitLeg }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, t }: {
|
||||
function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, adding, late, t }: {
|
||||
it: TransitItinerary
|
||||
tzFrom: string
|
||||
tzTo: string
|
||||
@@ -323,6 +325,8 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
onToggle: () => void
|
||||
onAdd: () => void
|
||||
adding: boolean
|
||||
/** CW-08: the next city walk this connection arrives too late for. */
|
||||
late: WalkLate | null
|
||||
t: (k: string, p?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const transitLegs = it.legs.filter(l => l.mode !== 'WALK')
|
||||
@@ -352,6 +356,7 @@ function ItineraryCard({ it, tzFrom, tzTo, is12h, expanded, onToggle, onAdd, add
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<WalkLateLine late={late} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
@@ -460,9 +465,11 @@ interface TransitSearchPanelProps {
|
||||
initialTo?: PickedPlace | null
|
||||
/** Pre-seed the departure time ('HH:mm') — used when planning a specific leg. */
|
||||
initialTime?: string | null
|
||||
/** CW-08: the two city walks the leg runs between; said only while the search is for their day. */
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null, walk = null }: TransitSearchPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -470,7 +477,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const [from, setFrom] = useState<PickedPlace | null>(initialFrom)
|
||||
const [to, setTo] = useState<PickedPlace | null>(initialTo)
|
||||
const [time, setTime] = useState(initialTime || '09:00')
|
||||
const walkHere = walk?.dayId === day.id ? walk : null
|
||||
// Between two walks the time is when the first one ends, never one made up: unknown, it is chosen.
|
||||
const [time, setTime] = useState(initialTime || (walkHere ? '' : '09:00'))
|
||||
const [arriveBy, setArriveBy] = useState(false)
|
||||
const [activeModes, setActiveModes] = useState<Set<string>>(() => new Set(MODE_GROUPS.map(m => m.key)))
|
||||
const [pref, setPref] = useState<'best' | 'transfers' | 'walking'>('best')
|
||||
@@ -629,6 +638,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
const tzFrom = from ? tzAt(from.lat, from.lng) : 'UTC'
|
||||
const tzTo = to ? tzAt(to.lat, to.lng) : tzFrom
|
||||
const canSearch = !!from && !!to && !!time && !loading
|
||||
|
||||
const segBtn = (active: boolean): React.CSSProperties => ({
|
||||
padding: isMobile ? '6px 5px' : '6px 11px', fontSize: isMobile ? 'calc(11px * var(--fs-scale-body, 1))' : 'calc(12px * var(--fs-scale-body, 1))', borderRadius: 7, fontWeight: 500,
|
||||
@@ -639,6 +649,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, fontFamily: 'var(--font-system)' }}>
|
||||
<WalkTransitNote walk={walkHere} />
|
||||
{/* from / to — stacked tight on mobile, swap button on desktop only */}
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 8, alignItems: isMobile ? 'stretch' : 'flex-end' }}>
|
||||
<StopPicker label={t('transit.from')} value={from} onPick={setFrom} quickPicks={quickPicks} near={near} placeholder={t('transit.searchStop')} />
|
||||
@@ -707,9 +718,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={search}
|
||||
disabled={!from || !to || loading}
|
||||
disabled={!canSearch}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: (!from || !to || loading) ? 'default' : 'pointer', fontFamily: 'inherit', opacity: (!from || !to || loading) ? 0.55 : 1, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, border: 'none', borderRadius: 10, padding: '9px 18px', fontWeight: 600, fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: canSearch ? 'pointer' : 'default', fontFamily: 'inherit', opacity: canSearch ? 1 : 0.55, flexShrink: 0, width: isMobile ? '100%' : undefined }}
|
||||
>
|
||||
<Search size={14} strokeWidth={2.2} />
|
||||
{loading ? t('transit.searching') : t('transit.search')}
|
||||
@@ -743,6 +754,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
onToggle={() => setExpandedIdx(expandedIdx === idx ? null : idx)}
|
||||
onAdd={() => addItinerary(it, idx)}
|
||||
adding={addingIdx === idx}
|
||||
late={lateForWalk(walkHere, day.date ?? null, { date: dateYMDInTz(it.endTime, tzTo), time: timeHHmmInTz(it.endTime, tzTo) })}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// CW-08: the transit search between two city walks, opened from the day plan's
|
||||
// connector with the first walk's end and the next one's start. The mocks are the
|
||||
// ones TransitSearchPanel.test.tsx renders the panel with.
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { buildDay, buildPlace, buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit';
|
||||
import TransitSearchPanel from './TransitSearchPanel';
|
||||
|
||||
const { transitApiMock } = vi.hoisted(() => ({ transitApiMock: { geocode: vi.fn(), plan: vi.fn() } }));
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return { ...actual, transitApi: transitApiMock };
|
||||
});
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ error: vi.fn(), success: vi.fn() }) }));
|
||||
|
||||
const day = buildDay({ id: 10, trip_id: 1, date: '2025-06-01', title: 'Berlin Day' });
|
||||
const OLD_BRIDGE = { name: 'Old bridge', lat: 52.51, lng: 13.42 };
|
||||
const FISH_MARKET = { name: 'Fish market', lat: 52.5, lng: 13.45 };
|
||||
const WALK: WalkTransitContext = {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
};
|
||||
|
||||
// Berlin, summer time (UTC+2): a tram that departs 12:20 and arrives at `arrives` (UTC).
|
||||
function itinerary(arrives: string) {
|
||||
const at = (time: string) => ({ lat: 52.505, lng: 13.43, time, scheduledTime: null, track: null });
|
||||
return {
|
||||
startTime: '2025-06-01T10:20:00Z',
|
||||
endTime: `2025-06-01T${arrives}:00Z`,
|
||||
duration: 1800,
|
||||
transfers: 0,
|
||||
walkSeconds: 240,
|
||||
legs: [
|
||||
{
|
||||
mode: 'TRAM',
|
||||
from: { ...at('2025-06-01T10:20:00Z'), name: 'START' },
|
||||
to: { ...at(`2025-06-01T${arrives}:00Z`), name: 'END' },
|
||||
duration: 1800,
|
||||
distance: null,
|
||||
headsign: 'Harbour',
|
||||
line: 'M1',
|
||||
lineColor: null,
|
||||
lineTextColor: null,
|
||||
agency: 'BVG',
|
||||
intermediateStops: 4,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(walk: WalkTransitContext | null, initialTime: string | null = walk?.ends ?? null) {
|
||||
const onAdd = vi.fn().mockResolvedValue({});
|
||||
render(
|
||||
<TransitSearchPanel
|
||||
day={day}
|
||||
days={[day]}
|
||||
places={[buildPlace({ id: 1, name: 'Old town', lat: 52.52, lng: 13.4 })]}
|
||||
onAdd={onAdd}
|
||||
initialFrom={OLD_BRIDGE}
|
||||
initialTo={FISH_MARKET}
|
||||
initialTime={initialTime}
|
||||
walk={walk}
|
||||
/>
|
||||
);
|
||||
return { onAdd };
|
||||
}
|
||||
|
||||
const timeField = () => screen.getByPlaceholderText('00:00') as HTMLInputElement;
|
||||
const searchButton = () => screen.getByRole('button', { name: /^Search$/ });
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as never);
|
||||
});
|
||||
|
||||
describe('TransitSearchPanel between two city walks (CW-08)', () => {
|
||||
it('says when the first walk ends and the next starts, and departs from the last stop then', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel(WALK);
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
const [from, to] = screen.getAllByPlaceholderText('Search stop or station…') as HTMLInputElement[];
|
||||
expect(from?.value).toBe('Old bridge');
|
||||
expect(to?.value).toBe('Fish market');
|
||||
expect(timeField().value).toBe('12:15');
|
||||
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
// 12:15 in Berlin (UTC+2), from the walk's end and to the next one's start.
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: '52.51,13.42',
|
||||
to: '52.5,13.45',
|
||||
time: '2025-06-01T10:15:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a connection that arrives after the next walk starts, and only that one', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('11:10'), itinerary('10:50')] });
|
||||
renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await screen.findByText(/12:20\D+13:10/);
|
||||
const late = screen.getAllByTestId('transit-walk-late');
|
||||
expect(late).toHaveLength(1);
|
||||
expect(late[0]).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(screen.getByText(/12:20\D+13:10/).closest('button')).toContainElement(late[0] ?? null);
|
||||
});
|
||||
|
||||
it('says an end that leaves a time out is the earliest', () => {
|
||||
renderPanel({ ...WALK, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town ends at 12:15 at the earliest; not all of its times are known yet.'
|
||||
);
|
||||
});
|
||||
|
||||
it('says a walk that ends after midnight does so, and makes up no time on this day', () => {
|
||||
renderPanel({ ...WALK, ends: null, endsNextDay: true, endsAtLeast: true });
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'Old town only ends after midnight, so choose a departure time.'
|
||||
);
|
||||
expect(timeField().value).toBe('');
|
||||
});
|
||||
|
||||
it('makes up no time without an end, and waits for one to be chosen', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] });
|
||||
renderPanel({ ...WALK, ends: null, nextStarts: null });
|
||||
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent(
|
||||
'When Old town ends is not known yet, so choose a departure time.'
|
||||
);
|
||||
expect(screen.queryByTestId('transit-walk-next')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('');
|
||||
expect(searchButton()).toBeDisabled();
|
||||
|
||||
await user.type(timeField(), '1230');
|
||||
await user.tab();
|
||||
expect(searchButton()).toBeEnabled();
|
||||
await user.click(searchButton());
|
||||
await waitFor(() => expect(transitApiMock.plan).toHaveBeenCalled());
|
||||
expect(transitApiMock.plan.mock.calls[0]?.[0]).toMatchObject({ time: '2025-06-01T10:30:00.000Z' });
|
||||
});
|
||||
|
||||
it('on another day than the walks, speaks of them no more and departs at the usual time', () => {
|
||||
renderPanel({ ...WALK, dayId: 11 }, null);
|
||||
expect(screen.queryByTestId('transit-walk-end')).not.toBeInTheDocument();
|
||||
expect(timeField().value).toBe('09:00');
|
||||
});
|
||||
|
||||
it('adds a connection only once it is opened and taken, between the two stops', async () => {
|
||||
const user = userEvent.setup();
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [itinerary('10:50')] });
|
||||
const { onAdd } = renderPanel(WALK);
|
||||
|
||||
await user.click(searchButton());
|
||||
await user.click(await screen.findByText(/12:20\D+12:50/));
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
await user.click(await screen.findByRole('button', { name: 'Add to day' }));
|
||||
|
||||
await waitFor(() => expect(onAdd).toHaveBeenCalledTimes(1));
|
||||
const payload = onAdd.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({ type: 'transit', title: 'Old bridge → Fish market', day_id: 10 });
|
||||
expect(payload.endpoints.map((point: { name: string }) => point.name)).toEqual(['Old bridge', 'Fish market']);
|
||||
});
|
||||
});
|
||||
@@ -519,6 +519,15 @@ describe('TransportModal', () => {
|
||||
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
|
||||
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
|
||||
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
|
||||
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
|
||||
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
|
||||
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
||||
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01' }),
|
||||
|
||||
@@ -19,7 +19,8 @@ import { TravelerPicker } from './TravelerPicker'
|
||||
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { BookingReviewDraft } from './parsedItemToDraft'
|
||||
import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel'
|
||||
import TransitSearchPanel from './TransitSearchPanel'
|
||||
import type { TransitPrefill } from './transitLeg'
|
||||
import { BookingDialogHeader, StatusPill } from './bookings/BookingDialogShell'
|
||||
import { DialogShell, DialogSection, DialogFooter, FooterSpacer, DialogButton, DeleteButton } from '../shared/DialogShell'
|
||||
import { INPUT, READONLY_BOX, GRID_2, GRID_3, PANEL, SEARCH_ON_PANEL, EditorField, Segmented, PillSelect, AddRowButton } from '../shared/dialogParts'
|
||||
@@ -70,7 +71,7 @@ interface TransportModalProps {
|
||||
/** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */
|
||||
tripHasDates?: boolean
|
||||
/** Pre-seed the transit search — used by "change route" and by per-leg planning. */
|
||||
transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null; time?: string | null } | null
|
||||
transitPrefill?: TransitPrefill | null
|
||||
/** Trip members + guests, for the traveler picker (#1517). */
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
@@ -304,6 +305,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
initialFrom={transitPrefill?.from ?? null}
|
||||
initialTo={transitPrefill?.to ?? null}
|
||||
initialTime={transitPrefill?.time ?? null}
|
||||
walk={transitPrefill?.walk ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import type { MouseEvent, ReactNode, Ref } from 'react'
|
||||
import { Clock, MoreHorizontal, type LucideIcon } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { ContextMenu, useContextMenu } from '../shared/ContextMenu'
|
||||
@@ -15,8 +15,11 @@ import { fs } from '../shared/DialogShell'
|
||||
export const PANEL_BAR = 'flex min-h-[52px] flex-none items-center gap-1.5 border-b border-edge-faint px-3 py-2'
|
||||
|
||||
/** A 32px icon control on a panel bar. Raised like an active filter tab while `active`. */
|
||||
export function BarButton({ label, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom' }: {
|
||||
export function BarButton({ label, tooltip, onClick, active = false, disabled = false, children, className = '', ariaPressed, ariaExpanded, ariaHaspopup, placement = 'bottom', testId, ref }: {
|
||||
label: string
|
||||
/** A longer explanation for the tooltip; the label still names the button. */
|
||||
tooltip?: string
|
||||
testId?: string
|
||||
onClick: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
active?: boolean
|
||||
disabled?: boolean
|
||||
@@ -26,10 +29,12 @@ export function BarButton({ label, onClick, active = false, disabled = false, ch
|
||||
ariaExpanded?: boolean
|
||||
ariaHaspopup?: 'dialog' | 'menu'
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
/** The button itself, for a list or a menu that opens beside it. */
|
||||
ref?: Ref<HTMLButtonElement>
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label} placement={placement}>
|
||||
<button type="button" onClick={onClick} disabled={disabled} aria-label={label}
|
||||
<Tooltip label={tooltip ?? label} placement={placement}>
|
||||
<button ref={ref} type="button" onClick={onClick} disabled={disabled} aria-label={label} data-testid={testId}
|
||||
aria-pressed={ariaPressed} aria-expanded={ariaExpanded} aria-haspopup={ariaHaspopup}
|
||||
className={`grid h-8 w-8 flex-none place-items-center rounded-[10px] transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted enabled:hover:bg-surface-card enabled:hover:text-content'} ${className}`}>
|
||||
{children}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { buildTransitLeg, buildTransitNameIndex } from './transitLeg'
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-006
|
||||
// FE-PLANNER-TRANSITLEG-001 to FE-PLANNER-TRANSITLEG-007
|
||||
|
||||
const visit = (id: number, dayId: number, name: string, lat: number, lng: number, placeTime?: string) =>
|
||||
({ id, day_id: dayId, place: { id, name, lat, lng, place_time: placeTime ?? null } }) as unknown as Assignment
|
||||
@@ -69,4 +69,11 @@ describe('buildTransitLeg', () => {
|
||||
expect(buildTransitLeg(undefined, 1, names, assignments, [])).toBeNull()
|
||||
expect(buildTransitLeg({ from: [1, 1] } as unknown as RouteSegment, 1, names, assignments, [])).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITLEG-007: a leg leaving a loop tour does not depart when the tour starts (CW-08)', () => {
|
||||
// A tour's place sits at its start; a loop ends there too, but its time is when it starts.
|
||||
const loop = { ...visit(31, 3, 'Old town loop', 52.52, 13.4, '10:00'), tour_place_id: 31 } as Assignment
|
||||
const tours = { '3': [loop, visit(32, 3, 'Harbour', 52.5, 13.45)] }
|
||||
expect(buildTransitLeg(seg([52.52, 13.4], [52.5, 13.45]), 3, buildTransitNameIndex(tours, [], []), tours, [])?.time).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Accommodation, Assignment, Reservation, RouteSegment } from '../../types'
|
||||
import type { WalkTransitContext } from '../Tours/walk/walkTransit'
|
||||
import type { PickedPlace } from './TransitSearchPanel'
|
||||
|
||||
/** What the transit search is opened with for one leg of a day. */
|
||||
@@ -8,6 +9,20 @@ export interface TransitLeg {
|
||||
time: string | null
|
||||
}
|
||||
|
||||
/** A leg as a view hands it on to open the search: its day and, between two city walks, what it says of them (CW-08). */
|
||||
export interface PlannedTransitLeg extends TransitLeg {
|
||||
dayId: number
|
||||
walk?: WalkTransitContext
|
||||
}
|
||||
|
||||
/** What the search is seeded with: a leg, or the ends of a journey whose route is changed. */
|
||||
export interface TransitPrefill {
|
||||
from?: PickedPlace | null
|
||||
to?: PickedPlace | null
|
||||
time?: string | null
|
||||
walk?: WalkTransitContext | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A connector's endpoints carry only coordinates (RouteSegment.from/to, both
|
||||
* [lat, lng]); resolve them back to the NAMES the transit search shows by indexing
|
||||
@@ -48,7 +63,9 @@ function originDepartureTime(
|
||||
assignments: Record<string, Assignment[]>,
|
||||
reservations: Reservation[],
|
||||
): string | null {
|
||||
for (const a of assignments[String(dayId)] ?? []) {
|
||||
// A tour's place is where it starts and its time when it starts: a leg leaving a loop is no departure then.
|
||||
const stops = (assignments[String(dayId)] ?? []).filter(a => a.tour_place_id == null || a.tour_place_id !== a.place?.id)
|
||||
for (const a of stops) {
|
||||
if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null
|
||||
}
|
||||
for (const r of reservations) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildAssignment, buildPlace, buildReservation } from '../../../tests/helpers/factories';
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import type { RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { useTransitLegPlanner, type TransitLegPlannerDeps } from './useTransitLegPlanner';
|
||||
|
||||
// CW-08: the transit leg behind the desktop day plan and the phone timeline.
|
||||
|
||||
vi.mock('../../repo/tourRepo', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../repo/tourRepo')>();
|
||||
return { ...actual, tourRepo: { ...actual.tourRepo, detail: vi.fn() } };
|
||||
});
|
||||
|
||||
const oldTown = buildPlace({
|
||||
id: 51,
|
||||
name: 'Old town',
|
||||
lat: 52.52,
|
||||
lng: 13.4,
|
||||
route_geometry: '[[52.52,13.4],[52.515,13.41],[52.51,13.42]]',
|
||||
});
|
||||
const harbour = buildPlace({
|
||||
id: 52,
|
||||
name: 'Harbour',
|
||||
lat: 52.5,
|
||||
lng: 13.45,
|
||||
route_geometry: '[[52.5,13.45],[52.49,13.46]]',
|
||||
});
|
||||
const museum = buildPlace({ id: 53, name: 'Museum', lat: 52.47, lng: 13.48, place_time: '9:05' });
|
||||
const cafe = buildPlace({ id: 54, name: 'Corner cafe', lat: 52.48, lng: 13.47 });
|
||||
|
||||
const walkA = buildAssignment({
|
||||
id: 201,
|
||||
day_id: 10,
|
||||
order_index: 0,
|
||||
tour_place_id: 51,
|
||||
place: { ...oldTown, place_time: '10:00' },
|
||||
});
|
||||
const walkB = buildAssignment({
|
||||
id: 202,
|
||||
day_id: 10,
|
||||
order_index: 1,
|
||||
tour_place_id: 52,
|
||||
place: { ...harbour, place_time: '13:00' },
|
||||
});
|
||||
const assignments = {
|
||||
'10': [walkB, walkA],
|
||||
'11': [
|
||||
buildAssignment({ id: 301, day_id: 11, order_index: 0, place: museum }),
|
||||
buildAssignment({ id: 302, day_id: 11, order_index: 1, place: cafe }),
|
||||
],
|
||||
};
|
||||
|
||||
function walk(place: { id: number; name: string }): TourListItem {
|
||||
return {
|
||||
place_id: place.id,
|
||||
name: place.name,
|
||||
tour_type: 'walk',
|
||||
duration: 90,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
station_count: 2,
|
||||
station_stay_minutes: 45,
|
||||
station_stays_unknown: 0,
|
||||
} as TourListItem;
|
||||
}
|
||||
const tours = new Map([walk(oldTown), walk(harbour)].map((tour) => [tour.place_id, tour]));
|
||||
|
||||
const BETWEEN_WALKS = { from: [52.51, 13.42], to: [52.5, 13.45] } as unknown as RouteSegment;
|
||||
const MUSEUM_TO_CAFE = { from: [52.47, 13.48], to: [52.48, 13.47] } as unknown as RouteSegment;
|
||||
|
||||
function setup(overrides: Partial<TransitLegPlannerDeps> = {}) {
|
||||
const onPlan = vi.fn();
|
||||
const deps: TransitLegPlannerDeps = {
|
||||
tripId: 1,
|
||||
assignments,
|
||||
accommodations: [],
|
||||
reservations: [],
|
||||
places: [oldTown, harbour, museum, cafe],
|
||||
toursEnabled: true,
|
||||
tourOf: (placeId) => tours.get(placeId),
|
||||
onPlan,
|
||||
...overrides,
|
||||
};
|
||||
const { result, unmount } = renderHook(() => useTransitLegPlanner(deps), { wrapper: TranslationProvider });
|
||||
return { onPlan, result, unmount };
|
||||
}
|
||||
|
||||
const detail = (placeId: number, names: [string, string]) => ({
|
||||
tour: tours.get(placeId),
|
||||
waypoints: names.map((name, sequence) => ({
|
||||
lat: 0,
|
||||
lng: 0,
|
||||
role: 'via',
|
||||
sequence,
|
||||
key: `${placeId}-${sequence}`,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
/** Walk reads that answer only once let go, and what each was asked with. */
|
||||
function heldReads() {
|
||||
let release: () => void = () => undefined;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
gate.then(() => detail(placeId, ['First', 'Last']) as never)
|
||||
);
|
||||
const signals = () => vi.mocked(tourRepo.detail).mock.calls.map((call) => call[2]?.aborted);
|
||||
// A turn of the event loop lets every read and what follows it settle.
|
||||
const settle = async () => {
|
||||
release();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
};
|
||||
return { signals, settle };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(tourRepo.detail).mockReset();
|
||||
});
|
||||
|
||||
describe('useTransitLegPlanner', () => {
|
||||
it('offers no leg without a way to open the search, and the leg as for any connector with one', () => {
|
||||
expect(setup({ onPlan: undefined }).result.current.legFor(11, MUSEUM_TO_CAFE)).toBeNull();
|
||||
expect(setup().result.current.legFor(11, MUSEUM_TO_CAFE)).toEqual({
|
||||
from: { name: 'Museum', lat: 52.47, lng: 13.48 },
|
||||
to: { name: 'Corner cafe', lat: 52.48, lng: 13.47 },
|
||||
time: '09:05',
|
||||
});
|
||||
});
|
||||
|
||||
it('opens the search for an ordinary leg at once, with nothing said of walks', () => {
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(11, leg, 301);
|
||||
expect(onPlan.mock.calls[0]?.[0]).toStrictEqual({ dayId: 11, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('between two walks, opens it from the last stop to the first once both are read, when the first one ends', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
Promise.resolve(
|
||||
detail(placeId, placeId === 51 ? ['Town hall', 'Old bridge'] : ['Fish market', 'Lighthouse']) as never
|
||||
)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(onPlan).toHaveBeenCalledWith({
|
||||
dayId: 10,
|
||||
from: { name: 'Old bridge', lat: 52.51, lng: 13.42 },
|
||||
to: { name: 'Fish market', lat: 52.5, lng: 13.45 },
|
||||
time: '12:15',
|
||||
walk: {
|
||||
dayId: 10,
|
||||
fromWalk: 'Old town',
|
||||
toWalk: 'Harbour',
|
||||
ends: '12:15',
|
||||
endsNextDay: false,
|
||||
endsAtLeast: false,
|
||||
nextStarts: '13:00',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(vi.mocked(tourRepo.detail).mock.calls.map((call) => call[1])).toEqual([51, 52]);
|
||||
});
|
||||
|
||||
it('still opens it where a walk cannot be read, naming that end after its walk', async () => {
|
||||
vi.mocked(tourRepo.detail).mockImplementation((_tripId, placeId) =>
|
||||
placeId === 51
|
||||
? Promise.reject(new Error('Tour not available offline'))
|
||||
: // Route ends without keys stand in for stops this device does not hold.
|
||||
Promise.resolve({
|
||||
tour: tours.get(52),
|
||||
waypoints: [{ lat: 52.5, lng: 13.45, role: 'start', sequence: 0 }],
|
||||
} as never)
|
||||
);
|
||||
const { onPlan, result } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
|
||||
await waitFor(() => expect(onPlan).toHaveBeenCalledTimes(1));
|
||||
expect(onPlan.mock.calls[0]?.[0]).toMatchObject({
|
||||
from: { name: 'End of Old town' },
|
||||
to: { name: 'Start of Harbour' },
|
||||
time: '12:15',
|
||||
});
|
||||
});
|
||||
|
||||
it('with tours off, treats the leg between two walks as any other', () => {
|
||||
const { onPlan, result } = setup({ toursEnabled: false });
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 10, ...leg });
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens nothing without a way to open the search', () => {
|
||||
const { result } = setup({ onPlan: undefined });
|
||||
const leg = { from: { name: '', lat: 52.51, lng: 13.42 }, to: { name: '', lat: 52.5, lng: 13.45 }, time: null };
|
||||
expect(() => result.current.plan(10, leg, walkA.id)).not.toThrow();
|
||||
expect(tourRepo.detail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens only the leg asked for last, and drops the walks still being read for an earlier one', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result } = setup();
|
||||
const between = result.current.legFor(10, BETWEEN_WALKS);
|
||||
const ordinary = result.current.legFor(11, MUSEUM_TO_CAFE);
|
||||
if (!between || !ordinary) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(10, between, walkA.id);
|
||||
result.current.plan(11, ordinary, 301);
|
||||
expect(reads.signals()).toEqual([true, true, true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).toHaveBeenCalledTimes(1);
|
||||
expect(onPlan).toHaveBeenCalledWith({ dayId: 11, ...ordinary });
|
||||
});
|
||||
|
||||
it('opens nothing once the view is gone before the walks are read', async () => {
|
||||
const reads = heldReads();
|
||||
const { onPlan, result, unmount } = setup();
|
||||
const leg = result.current.legFor(10, BETWEEN_WALKS);
|
||||
if (!leg) throw new Error('no leg');
|
||||
|
||||
result.current.plan(10, leg, walkA.id);
|
||||
expect(reads.signals()).toEqual([false, false]);
|
||||
unmount();
|
||||
expect(reads.signals()).toEqual([true, true]);
|
||||
await reads.settle();
|
||||
|
||||
expect(onPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says when a saved journey now arrives after the walk it leads to starts, while tours are on', () => {
|
||||
const journey = buildReservation({ id: 300, type: 'transit', reservation_end_time: '2025-06-01T13:10' });
|
||||
const following = [{ type: 'place', sortKey: 1, data: walkB }] as MergedItem[];
|
||||
const day = { date: '2025-06-01' };
|
||||
expect(setup().result.current.lateBefore(day, journey, following)).toEqual({ walk: 'Harbour', time: '13:00' });
|
||||
expect(setup({ toursEnabled: false }).result.current.lateBefore(day, journey, following)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { Accommodation, AssignmentsMap, Day, Place, Reservation, RouteSegment } from '../../types';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { projectDayItinerary } from '../Map/dayTourProjection';
|
||||
import { readWalkStations } from '../Tours/walk/walkStationsRead';
|
||||
import { journeyLate, walkPairAt, walkTransitLeg, type TourOf, type WalkLate } from '../Tours/walk/walkTransit';
|
||||
import { buildTransitLeg, buildTransitNameIndex, type PlannedTransitLeg, type TransitLeg } from './transitLeg';
|
||||
|
||||
export interface TransitLegPlannerDeps {
|
||||
tripId: number | string;
|
||||
assignments: AssignmentsMap;
|
||||
accommodations: Accommodation[];
|
||||
reservations: Reservation[];
|
||||
places: Place[];
|
||||
toursEnabled: boolean;
|
||||
tourOf: TourOf;
|
||||
/** Opens the search for a leg; without it no leg offers one, as on a trip without dates. */
|
||||
onPlan: ((leg: PlannedTransitLeg) => void) | undefined;
|
||||
}
|
||||
|
||||
/** A walk's stops, or null where this device cannot read them. */
|
||||
async function stopsOrNull(tripId: number | string, placeId: number, signal: AbortSignal) {
|
||||
try {
|
||||
return await readWalkStations(tripId, placeId, signal);
|
||||
} catch {
|
||||
// They only name the leg's two ends: without them it opens named after the walks.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public transit for one leg of a day (#2398), behind the desktop day plan and
|
||||
* the phone timeline alike: the leg's two ends and the time its origin is
|
||||
* planned for. Between two city walks (CW-08) it runs from the first walk's
|
||||
* last stop to the next one's first, departing when the first one ends, and a
|
||||
* saved journey that now arrives after the next walk starts says so. Nothing
|
||||
* is saved here: the search opens, and only the connection picked there is
|
||||
* added to the day. Only the last leg asked for opens it, and none after the
|
||||
* view is gone.
|
||||
*/
|
||||
export function useTransitLegPlanner({
|
||||
tripId,
|
||||
assignments,
|
||||
accommodations,
|
||||
reservations,
|
||||
places,
|
||||
toursEnabled,
|
||||
tourOf,
|
||||
onPlan,
|
||||
}: TransitLegPlannerDeps) {
|
||||
const { t } = useTranslation();
|
||||
// Coordinates back to names, over every located place, hotel and booking endpoint of the trip.
|
||||
const names = useMemo(
|
||||
() => buildTransitNameIndex(assignments, accommodations, reservations),
|
||||
[assignments, accommodations, reservations]
|
||||
);
|
||||
|
||||
const legFor = useCallback(
|
||||
(dayId: number, seg?: RouteSegment): TransitLeg | null =>
|
||||
onPlan ? buildTransitLeg(seg, dayId, names, assignments, reservations) : null,
|
||||
[onPlan, names, assignments, reservations]
|
||||
);
|
||||
|
||||
// The walks' stops being read for the leg asked for last; a later leg, or leaving the view, drops them.
|
||||
const pending = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => pending.current?.abort(), []);
|
||||
|
||||
/** Opens the search for a leg, told by the entry it leaves where the connector carries one. */
|
||||
const plan = useCallback(
|
||||
(dayId: number, leg: TransitLeg, originId?: number) => {
|
||||
if (!onPlan) return;
|
||||
pending.current?.abort();
|
||||
const day = [...(assignments[String(dayId)] ?? [])].sort((a, b) => a.order_index - b.order_index);
|
||||
const pair = toursEnabled ? walkPairAt(projectDayItinerary(day, true, places), leg, tourOf, originId) : null;
|
||||
if (!pair) {
|
||||
onPlan({ dayId, ...leg });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
pending.current = controller;
|
||||
// The stops name the ends; read from this device's copy when offline, so the search waits only for them.
|
||||
void Promise.all([
|
||||
stopsOrNull(tripId, pair.first.item.placeId, controller.signal),
|
||||
stopsOrNull(tripId, pair.next.item.placeId, controller.signal),
|
||||
]).then(([first, next]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
onPlan({ dayId, ...walkTransitLeg({ leg, dayId, pair, stops: { first, next }, places, t }) });
|
||||
});
|
||||
},
|
||||
[onPlan, assignments, toursEnabled, places, tourOf, tripId, t]
|
||||
);
|
||||
|
||||
/** A saved journey on a day that arrives after the walk it leads to starts; null otherwise. */
|
||||
const lateBefore = useCallback(
|
||||
(day: Pick<Day, 'date'>, reservation: Reservation, following: readonly MergedItem[]): WalkLate | null =>
|
||||
toursEnabled ? journeyLate({ reservation, following, date: day.date ?? null, tourOf }) : null,
|
||||
[toursEnabled, tourOf]
|
||||
);
|
||||
|
||||
return { legFor, plan, lateBefore };
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Bike, Footprints, Mountain, Route } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { useTourVisitStore } from '../../store/tourVisitStore';
|
||||
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
|
||||
@@ -117,6 +118,78 @@ describe('PAD-09 the check bench on a tour row', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CW-02 a city walk's time on its day row", () => {
|
||||
const walk: TourListItem = {
|
||||
...tour,
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
break_additional_minutes: 15,
|
||||
max_hiking_difficulty: null,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 120,
|
||||
station_stays_unknown: 0,
|
||||
};
|
||||
|
||||
it('shows the walking time and the stays as the planned total, its breaks left out', () => {
|
||||
render(<TourDayRowPills tour={walk} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('2 h 45 min');
|
||||
expect(time).toHaveAccessibleName('Walking: 45 min, Stays: 2 h, Planned total duration: 2 h 45 min');
|
||||
});
|
||||
|
||||
it('says the total is at least what is known while a stay is not entered', () => {
|
||||
render(<TourDayRowPills tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />);
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(time).toHaveAccessibleName(
|
||||
'Walking: 45 min, Stays: at least 1 h 30 min, Planned total duration: at least 2 h 15 min'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the same total among the facts of a phone row, and no breaks', () => {
|
||||
render(<TourDayRowFacts tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} mobile />);
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking: 45 min');
|
||||
expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CW-03 a city walk's progress among the facts of a day row", () => {
|
||||
const walk: TourListItem = { ...tour, tour_type: 'walk', max_hiking_difficulty: null, station_count: 5 };
|
||||
|
||||
beforeEach(() =>
|
||||
useTourVisitStore.setState({
|
||||
tripId: '4',
|
||||
byAssignment: { 9: { 'stop-01': 'visited', 'stop-02': 'skipped' } },
|
||||
})
|
||||
);
|
||||
|
||||
it.each([false, true])('shows the stops visited on that day, a tap naming them whole (mobile=%s)', (mobile) => {
|
||||
const openRow = vi.fn();
|
||||
render(
|
||||
<div onClick={openRow}>
|
||||
<TourDayRowFacts tour={walk} assignmentId={9} mobile={mobile} />
|
||||
</div>
|
||||
);
|
||||
const progress = screen.getByTestId('plan-walk-progress');
|
||||
expect(progress).toHaveTextContent('1/5');
|
||||
expect(progress).toHaveAccessibleName('1 of 5 visited');
|
||||
fireEvent.click(progress);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('1 of 5 visited');
|
||||
expect(openRow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows none without a day, or before a stop of that day is marked', () => {
|
||||
render(
|
||||
<>
|
||||
<TourDayRowFacts tour={walk} mobile />
|
||||
<TourDayRowFacts tour={walk} assignmentId={10} mobile />
|
||||
</>
|
||||
);
|
||||
expect(screen.queryByTestId('plan-walk-progress')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the compact facts of a tour list row', () => {
|
||||
const testIds = () =>
|
||||
[...document.querySelectorAll('[data-testid^="plan-tour"], [data-testid="trip-plan-tour-time"]')].map((el) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { ArrowUp, CheckCheck, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -10,7 +10,9 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import { paddleCheckFact } from './paddle/paddleCheckModel';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
import { formatPlannedTourDuration, plannedTotalText, tourPlannedTimes } from './tourPresentation';
|
||||
import { useWalkProgress } from './walk/walkProgress';
|
||||
import { walkStaysText } from './walk/walkTimeFields';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -163,27 +165,23 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const times = tourPlannedTimes(tour);
|
||||
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
|
||||
const walking = times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes);
|
||||
const total = plannedTotalText(times, t);
|
||||
const timeParts = [
|
||||
times.walkingMinutes != null
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
walking ? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${walking}` : null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
: null,
|
||||
times.plannedTotalMinutes != null
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
// CW-02: a city walk's stays, which its total adds to the walking time.
|
||||
times.walk ? `${t('tours.walk.stays')}: ${walkStaysText(times.walk, t)}` : null,
|
||||
total
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${total}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
const check = paddleCheckOf(tour, t);
|
||||
const time = shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
key="time"
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
const shown = plannedTotalText(times, t, true) ?? walking;
|
||||
const time = shown != null && (
|
||||
<TourDayRowPill key="time" icon={Clock} value={shown} label={timeParts.join(', ')} testId="trip-plan-tour-time" />
|
||||
);
|
||||
const distance = tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
@@ -209,9 +207,19 @@ export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem;
|
||||
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
|
||||
}
|
||||
|
||||
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
|
||||
export default function TourDayRowFacts({
|
||||
tour,
|
||||
assignmentId,
|
||||
mobile = false,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
/** The row's day assignment, for a city walk's progress on that day (CW-03). */
|
||||
assignmentId?: number;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const progress = useWalkProgress(tour, assignmentId);
|
||||
const times = tourPlannedTimes(tour);
|
||||
const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
|
||||
const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
|
||||
@@ -222,7 +230,7 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
|
||||
if (times.walkingMinutes != null)
|
||||
add(
|
||||
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
|
||||
tourDayRowIcon(tour.tour_type === 'kayak' || tour.tour_type === 'bike' ? tour.tour_type : 'walk'),
|
||||
formatPlannedTourDuration(times.walkingMinutes),
|
||||
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
|
||||
'trip-plan-tour-walking-time'
|
||||
@@ -234,10 +242,11 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
t('tours.planner.breaksAdditional'),
|
||||
'trip-plan-tour-breaks'
|
||||
);
|
||||
if (times.plannedTotalMinutes != null)
|
||||
const total = plannedTotalText(times, t, true);
|
||||
if (total)
|
||||
add(
|
||||
Clock,
|
||||
formatPlannedTourDuration(times.plannedTotalMinutes),
|
||||
total,
|
||||
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
|
||||
'trip-plan-tour-planned-duration'
|
||||
);
|
||||
@@ -248,6 +257,15 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
{facts.map((fact) => (
|
||||
<Fact key={fact.testId} {...fact} label={`${fact.label}: ${fact.value}`} mobile={mobile} />
|
||||
))}
|
||||
{progress && (
|
||||
<Fact
|
||||
icon={CheckCheck}
|
||||
value={`${progress.visited}/${progress.total}`}
|
||||
label={t('tours.walk.progress', progress)}
|
||||
testId="plan-walk-progress"
|
||||
mobile={mobile}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import { paddleBench } from '../../../tests/helpers/paddleCheck'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import TourDetailDialog from './TourDetailDialog'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { tourRepo } from '../../repo/tourRepo'
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
||||
@@ -60,6 +61,11 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
||||
})
|
||||
|
||||
// A repository answer one test stubs never reaches the tests after it.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('TourDetailDialog', () => {
|
||||
it('keeps desktop detail non-modal and returns focus to its Tour row on Escape', async () => {
|
||||
const opener = document.createElement('button')
|
||||
@@ -142,6 +148,67 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Planned (Manual override)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-02: shows a city walk its walking time, its stays and their total, and no breaks left from another type', async () => {
|
||||
const walk = {
|
||||
...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 45, break_additional_minutes: 15,
|
||||
station_count: 3, station_stay_minutes: 120, station_stays_unknown: 0,
|
||||
}
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [] })
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={walk} />)
|
||||
|
||||
// 45 min of walking, a museum of 90 min and a cafe of 30 min, as the day row says it too.
|
||||
expect(screen.getByLabelText('Walking: 45 min, Stays: 2 h, Planned: 2 h 45 min')).toHaveTextContent('2 h 45 min')
|
||||
expect(screen.getByText('Stays')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Breaks')).not.toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1 }} />)
|
||||
expect(screen.getByLabelText('Walking: 45 min, Stays: at least 1 h 30 min, Planned: at least 2 h 15 min'))
|
||||
.toHaveTextContent('≥ 2 h 15 min')
|
||||
// On a phone's three narrow columns the longer values wrap rather than being cut.
|
||||
const stays = screen.getByText('Stays').closest('dt')?.nextElementSibling
|
||||
expect(stays).toHaveTextContent('at least 1 h 30 min')
|
||||
expect(stays).toHaveClass('break-words')
|
||||
expect(stays).not.toHaveClass('truncate')
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps}
|
||||
tour={{ ...walk, station_stay_minutes: 90, station_stays_unknown: 1, planned_duration_minutes: 240 }} />)
|
||||
expect(screen.getByLabelText(
|
||||
'Walking: 45 min, Stays: at least 1 h 30 min, Planned (Manual override): 4 h, Computed: at least 2 h 15 min'
|
||||
)).toHaveTextContent('4 h')
|
||||
expect(screen.getByText('Computed')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('walk-stations')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CW-04: a walk opened from a day it is on keeps that day for its times; opened otherwise, a day can be picked', async () => {
|
||||
const walk = { ...tour, tour_type: 'walk' as const, max_hiking_difficulty: null, duration: 20, station_count: 1 }
|
||||
vi.spyOn(tourRepo, 'detail').mockResolvedValue({ tour: walk, waypoints: [
|
||||
{ lat: 48, lng: 11, role: 'start', sequence: 0, key: 'stop-01', name: 'Museum', stay_minutes: 30, opening_hours: 'Mo-Fr 10:00-18:00' },
|
||||
] })
|
||||
// 2026-10-19 is a Monday: the museum opens at 10:00.
|
||||
const days = [buildDay({ id: 7, date: '2026-10-19' }), buildDay({ id: 8, date: '2026-10-20' })]
|
||||
const assignments = {
|
||||
'7': [buildAssignment({ id: 9, day_id: 7, place_id: 42, assignment_time: '09:00', place })],
|
||||
'8': [buildAssignment({ id: 10, day_id: 8, place_id: 42, assignment_time: '11:00', place })],
|
||||
}
|
||||
const view = render(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} selectedDayId={7} />)
|
||||
|
||||
expect(screen.getByTestId('walk-day')).toHaveTextContent('Day 1, start 09:00')
|
||||
expect(screen.queryByTestId('walk-day-picker')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Arrival about 09:00, stay until 09:30')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('walk-station-conflict'))
|
||||
.toHaveTextContent('Museum is closed at 09:00, the stay would last until 09:30; open 10:00 to 18:00')
|
||||
expect(screen.getByText('Mark visited')).toBeInTheDocument()
|
||||
|
||||
view.rerender(<TourDetailDialog {...defaultProps} tour={walk} days={days} assignments={assignments} />)
|
||||
expect(screen.getByTestId('walk-day-picker')).toHaveTextContent('Day 1, start 09:00')
|
||||
expect(screen.queryByText('Mark visited')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('walk-day-picker'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Day 2, .*start 11:00$/ }))
|
||||
expect(await screen.findByText('Arrival about 11:00, stay until 11:30')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('walk-station-conflict')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Open 10:00 to 18:00/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PAD-01: a paddle tour without heights shows distance, paddling and total side by side in one row', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', has_waypoints: true, duration: 50 }} />)
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import TrackColorPicker from '../shared/TrackColorPicker';
|
||||
import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { TourGpxExportButton } from './TourGpxExportButton';
|
||||
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
|
||||
import { PaddleWindSection } from './paddle/PaddleWindCard';
|
||||
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
|
||||
@@ -57,12 +58,16 @@ import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField }
|
||||
import {
|
||||
formatPlannedTourDuration,
|
||||
hikeSourceBadgeLabel,
|
||||
plannedTotalText,
|
||||
tourPlannedTimes,
|
||||
tourSource,
|
||||
tourWebsitePresentation,
|
||||
type TourPlannedTimes,
|
||||
} from './tourPresentation';
|
||||
import { useTourDayAssignment } from './useTourDayAssignment';
|
||||
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
|
||||
import { WalkStationsCard } from './walk/WalkStations';
|
||||
import { walkTimeFields } from './walk/walkTimeFields';
|
||||
|
||||
interface TourDetailDialogProps extends TourPermissionProps {
|
||||
tour: TourListItem;
|
||||
@@ -165,19 +170,28 @@ function placeStart(place: Place): { lat: number; lng: number } | null {
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The parts of the time a tour takes, as fields: walking, breaks, the planned total. */
|
||||
/**
|
||||
* The parts of the time a tour takes, as fields: walking, breaks, the planned
|
||||
* total; a city walk its walking time, its stays and their total, in the
|
||||
* planner's wording (CW-02).
|
||||
*/
|
||||
function tourTimeFields(
|
||||
times: ReturnType<typeof tourPlannedTimes>,
|
||||
times: TourPlannedTimes,
|
||||
t: Translate,
|
||||
tour: Pick<TourListItem, 'tour_type'>
|
||||
): TourMetricField[] {
|
||||
const walking = { label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t) };
|
||||
if (times.walk) {
|
||||
return walkTimeFields({
|
||||
walking,
|
||||
walkingMinutes: times.walkingMinutes,
|
||||
stays: times.walk,
|
||||
manualMinutes: times.manuallyOverridden ? times.plannedTotalMinutes : null,
|
||||
t,
|
||||
});
|
||||
}
|
||||
return [
|
||||
times.walkingMinutes != null
|
||||
? {
|
||||
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
|
||||
value: formatPlannedTourDuration(times.walkingMinutes),
|
||||
}
|
||||
: null,
|
||||
times.walkingMinutes != null ? { ...walking, value: formatPlannedTourDuration(times.walkingMinutes) } : null,
|
||||
times.breakMinutes
|
||||
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
|
||||
: null,
|
||||
@@ -200,19 +214,23 @@ function TourHeadPills({
|
||||
tour,
|
||||
trackColor,
|
||||
timeFields,
|
||||
shownMinutes,
|
||||
times,
|
||||
metrics,
|
||||
}: {
|
||||
tour: TourListItem;
|
||||
trackColor: string;
|
||||
timeFields: TourMetricField[];
|
||||
shownMinutes: number | null;
|
||||
/** The planned total where there is one, as the day row shows it, otherwise the walking time. */
|
||||
times: TourPlannedTimes;
|
||||
/** Measured from the line as it is now, so the head says what the fields below say. */
|
||||
metrics: { distanceKm: number | null; gain: number | null; loss: number | null };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const distanceUnit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const timeTooltip = timeFields.map((field) => `${field.label}: ${field.value}`).join(', ');
|
||||
const shownTime =
|
||||
plannedTotalText(times, t, true) ??
|
||||
(times.walkingMinutes == null ? null : formatPlannedTourDuration(times.walkingMinutes));
|
||||
const source = tourSource(tour);
|
||||
// The import this tour was planned from, named while it is still in the trip.
|
||||
const derivedFromName = useTripStore((state) =>
|
||||
@@ -256,11 +274,11 @@ function TourHeadPills({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shownMinutes != null && (
|
||||
{shownTime != null && (
|
||||
<Tooltip label={timeTooltip}>
|
||||
<span aria-label={timeTooltip} className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Clock size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
{formatPlannedTourDuration(shownMinutes)}
|
||||
{shownTime}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -368,6 +386,79 @@ function useTourNameEditor(place: Place, onUpdatePlace?: TourDetailDialogProps['
|
||||
return { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName };
|
||||
}
|
||||
|
||||
/** The tour's name in the head; `onUpdatePlace` is given only to someone who may rename it, in place. */
|
||||
function TourNameHeading({
|
||||
place,
|
||||
onUpdatePlace,
|
||||
}: {
|
||||
place: Place;
|
||||
onUpdatePlace?: TourDetailDialogProps['onUpdatePlace'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
|
||||
place,
|
||||
onUpdatePlace
|
||||
);
|
||||
if (onUpdatePlace && editingName) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void saveName();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setNameDraft(place.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveName()}
|
||||
disabled={savingName}
|
||||
aria-label={t('common.save')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2
|
||||
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
{onUpdatePlace && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(place.name);
|
||||
setEditingName(true);
|
||||
}}
|
||||
aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourDetailDialog({
|
||||
tour,
|
||||
place,
|
||||
@@ -404,10 +495,6 @@ export default function TourDetailDialog({
|
||||
const [colorOpen, setColorOpen] = useState(false);
|
||||
const [navigationOpen, setNavigationOpen] = useState(false);
|
||||
const navigationAnchorRef = useRef<HTMLSpanElement>(null);
|
||||
const { editingName, setEditingName, nameDraft, setNameDraft, savingName, saveName } = useTourNameEditor(
|
||||
place,
|
||||
canEdit ? onUpdatePlace : undefined
|
||||
);
|
||||
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
|
||||
|
||||
const heightsHidden = heightsUnknown(tour);
|
||||
@@ -452,66 +539,12 @@ export default function TourDetailDialog({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{canEdit && editingName ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') void saveName();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setNameDraft(place.name);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
aria-label={t('places.formName')}
|
||||
className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
<Tooltip label={t('common.save')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveName()}
|
||||
disabled={savingName}
|
||||
aria-label={t('common.save')}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-accent text-accent-text disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<h2
|
||||
className="m-0 min-w-0 break-words font-bold leading-snug tracking-[-0.01em] text-content"
|
||||
style={fs(17, 'subtitle')}
|
||||
>
|
||||
{place.name}
|
||||
</h2>
|
||||
{canEdit && onUpdatePlace && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNameDraft(place.name);
|
||||
setEditingName(true);
|
||||
}}
|
||||
aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<TourNameHeading place={place} onUpdatePlace={canEdit ? onUpdatePlace : undefined} />
|
||||
<TourHeadPills
|
||||
tour={tour}
|
||||
trackColor={trackColor}
|
||||
timeFields={timeFields}
|
||||
shownMinutes={times.plannedTotalMinutes ?? times.walkingMinutes}
|
||||
times={times}
|
||||
metrics={{
|
||||
distanceKm: analysis?.distanceKm ?? tour.distance,
|
||||
gain: analysis ? analysis.gain : tour.elevation_gain,
|
||||
@@ -588,6 +621,10 @@ export default function TourDetailDialog({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* A tour written here offline has no server copy to export until it syncs. */}
|
||||
{analysis && place.id > 0 && (
|
||||
<TourGpxExportButton target={{ tripId: place.trip_id, placeId: place.id, name: place.name }} />
|
||||
)}
|
||||
{canEdit && onSaveAsNew && <SaveAsNewButton onSave={() => onSaveAsNew(tour)} />}
|
||||
<FooterSpacer />
|
||||
{canEdit && onDelete && (
|
||||
@@ -647,6 +684,17 @@ export default function TourDetailDialog({
|
||||
placeId={place.id}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'walk' && (
|
||||
<WalkStationsCard
|
||||
tour={tour}
|
||||
tripId={place.trip_id}
|
||||
assignment={assignment}
|
||||
canMark={canAssign}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
route={analysis?.routeCoordinates ?? null}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
|
||||
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Download, Loader2, Share } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { BarButton } from '../Planner/planParts';
|
||||
import { DialogButton } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { useTourGpxExport, type TourGpxExportTarget } from './useTourGpxExport';
|
||||
|
||||
/**
|
||||
* PR1-06A, IO-01 in a footer: the saved tour as a GPX file, its own action
|
||||
* beside the navigation, which only leads to the start. "GPX" says the format,
|
||||
* the tooltip what the file holds. Where the platform takes GPX files, a share
|
||||
* button beside it hands the same file to the system's share sheet.
|
||||
*/
|
||||
export function TourGpxExportButton({ target }: { target: TourGpxExportTarget }) {
|
||||
const { t } = useTranslation();
|
||||
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={t('tours.gpxExport.hint')}>
|
||||
<span className="inline-flex">
|
||||
<DialogButton
|
||||
onClick={() => void exportGpx(target)}
|
||||
disabled={exporting}
|
||||
aria-label={t('tours.gpxExport.action')}
|
||||
data-testid="tour-gpx-export"
|
||||
icon={exporting ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} strokeWidth={2} />}
|
||||
>
|
||||
<span className="max-sm:hidden">GPX</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{canShare && (
|
||||
<Tooltip label={t('tours.gpxExport.share')}>
|
||||
<span className="inline-flex">
|
||||
<DialogButton
|
||||
onClick={() => void shareGpx(target)}
|
||||
disabled={exporting}
|
||||
aria-label={t('tours.gpxExport.share')}
|
||||
data-testid="tour-gpx-share"
|
||||
icon={<Share size={14} strokeWidth={2} />}
|
||||
>
|
||||
<span className="max-sm:hidden">{t('tours.gpxExport.shareShort')}</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same export in the planner's head. While the tour has unsaved changes
|
||||
* the file would not hold them, so the buttons wait for the save and say so.
|
||||
*/
|
||||
export function TourGpxExportBarButton({ target, unsaved }: { target: TourGpxExportTarget; unsaved: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { exportGpx, shareGpx, canShare, exporting } = useTourGpxExport();
|
||||
return (
|
||||
<>
|
||||
<BarButton
|
||||
label={t(unsaved ? 'tours.gpxExport.saveFirst' : 'tours.gpxExport.action')}
|
||||
onClick={() => void exportGpx(target)}
|
||||
disabled={unsaved || exporting}
|
||||
>
|
||||
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} strokeWidth={2} />}
|
||||
</BarButton>
|
||||
{canShare && (
|
||||
<BarButton
|
||||
label={t(unsaved ? 'tours.gpxExport.shareSaveFirst' : 'tours.gpxExport.share')}
|
||||
onClick={() => void shareGpx(target)}
|
||||
disabled={unsaved || exporting}
|
||||
>
|
||||
<Share size={15} strokeWidth={2} />
|
||||
</BarButton>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
|
||||
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
|
||||
import {
|
||||
BIKE_PROFILES,
|
||||
type BikeProfile,
|
||||
type PaddleBoatType,
|
||||
type PaddleWaterContext,
|
||||
type PlannableTourType,
|
||||
type TourGpxImportFields,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, FileDown, Info } from 'lucide-react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../hooks/useTileUrl';
|
||||
@@ -20,6 +27,7 @@ import {
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TourTypeSelect } from './planner/TourTypeSelect';
|
||||
import { TourNotice } from './tourParts';
|
||||
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
@@ -76,10 +84,12 @@ export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpx
|
||||
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const typeLabelId = useId();
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const [type, setType] = useState<PlannableTourType>('hike');
|
||||
const [boat, setBoat] = useState<PaddleBoatType | null>(null);
|
||||
const [water, setWater] = useState<PaddleWaterContext | null>(null);
|
||||
const [bikeProfile, setBikeProfile] = useState<BikeProfile>('touring');
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
@@ -96,7 +106,9 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
|
||||
const fields: TourGpxImportFields =
|
||||
type === 'kayak' && boat && water
|
||||
? { tour_type: 'kayak', boat_type: boat, water_context: water }
|
||||
: { tour_type: 'hike' };
|
||||
: type === 'bike'
|
||||
? { tour_type: 'bike', bike_profile: bikeProfile }
|
||||
: { tour_type: 'hike' };
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
return (
|
||||
@@ -168,18 +180,21 @@ function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files:
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSection label={t('tours.import.previewAs')}>
|
||||
<DialogSection label={<span id={typeLabelId}>{t('tours.import.previewAs')}</span>}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Segmented<PlannableTourType>
|
||||
value={type}
|
||||
onChange={setType}
|
||||
label={t('tours.import.previewAs')}
|
||||
fill
|
||||
options={[
|
||||
{ value: 'hike', label: t('tourTypes.hike'), icon: <Mountain size={13} strokeWidth={2} /> },
|
||||
{ value: 'kayak', label: t('tourTypes.kayak'), icon: <Waves size={13} strokeWidth={2} /> },
|
||||
]}
|
||||
/>
|
||||
<TourTypeSelect value={type} onChange={setType} labelId={typeLabelId} />
|
||||
{type === 'bike' && (
|
||||
<div data-testid="gpx-preview-bike" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.bike.profile')}</span>
|
||||
<Segmented<BikeProfile>
|
||||
value={bikeProfile}
|
||||
onChange={setBikeProfile}
|
||||
label={t('tours.bike.profile')}
|
||||
fill
|
||||
options={BIKE_PROFILES.map((value) => ({ value, label: t(`tours.bike.profile.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === 'kayak' && (
|
||||
<div data-testid="gpx-preview-paddle" className="grid grid-cols-1 gap-3 sm:grid-cols-[3fr_2fr]">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -177,3 +177,26 @@ export default function TourListFilterMenu({ filters }: { filters: TourListFilte
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a list says when its filters leave nothing: so, and a way to clear them. */
|
||||
export function TourFilterEmpty({ filters }: { filters: TourListFilters }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('tours.filter.empty')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { TourListItem, TourTypeKey } from '@trek/shared';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CalendarClock,
|
||||
ChartColumnBig,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
FileDown,
|
||||
Mountain,
|
||||
Route,
|
||||
Ruler,
|
||||
Waves,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, type ComponentType } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { useRailFold } from './railFold';
|
||||
import { TourDayRowPill } from './TourDayRowFacts';
|
||||
import { InfoDot, RailCard } from './tourParts';
|
||||
import { formatPlannedTourDuration } from './tourPresentation';
|
||||
import { tourScopeSummary, type ScopeSum, type TourTypeScope } from './tourScope';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
const TYPE_ICON: Partial<Record<TourTypeKey, ComponentType<{ size?: number; strokeWidth?: number }>>> = {
|
||||
hike: Mountain,
|
||||
kayak: Waves,
|
||||
};
|
||||
|
||||
/** A count as a white badge of the card: the number, then what it counts. */
|
||||
function CountBadge({ value, label, tone = 'neutral' }: { value: number; label: string; tone?: 'neutral' | 'faint' }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex flex-none items-center gap-1 rounded-full bg-surface-card px-2 py-0.5 shadow-sm ring-1 ring-inset ring-edge-faint ${tone === 'faint' ? 'text-content-muted' : 'text-content-secondary'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
<span className="font-geist font-semibold tabular-nums text-content">{value}</span>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One measure of a type as a badge: its icon and sum, its name (and how complete it is) in the tooltip. */
|
||||
interface ScopeBadge {
|
||||
key: string;
|
||||
icon: LucideIcon;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sum as a badge: nothing when no tour has the value, else the sum, marked
|
||||
* as at least that much when some tours lack it, the tooltip saying for how many.
|
||||
*/
|
||||
function sumBadge(
|
||||
{ key, icon, name }: Pick<ScopeBadge, 'key' | 'icon'> & { name: string },
|
||||
sum: ScopeSum,
|
||||
format: (value: number) => string,
|
||||
t: Translate
|
||||
): ScopeBadge | null {
|
||||
if (sum.value === null) return null;
|
||||
const partial = sum.known < sum.of;
|
||||
const value = partial || sum.atLeast ? `≥ ${format(sum.value)}` : format(sum.value);
|
||||
if (partial)
|
||||
return {
|
||||
key,
|
||||
icon,
|
||||
value,
|
||||
label: `${name}: ${value} (${t('tours.scope.partialBody', { known: sum.known, of: sum.of })})`,
|
||||
};
|
||||
// CW-02: known for every tour, but a city walk's stay is not entered yet.
|
||||
const said = sum.atLeast ? t('tours.walk.totalAtLeast', { time: format(sum.value) }) : value;
|
||||
return { key, icon, value, label: `${name}: ${said}` };
|
||||
}
|
||||
|
||||
function typeBadges(scope: TourTypeScope, unit: Parameters<typeof formatDistance>[1], t: Translate): ScopeBadge[] {
|
||||
const distance = (km: number) => formatDistance(km, unit);
|
||||
const height = (m: number) => formatElevation(m, unit);
|
||||
return [
|
||||
sumBadge({ key: 'distance', icon: Ruler, name: t('tours.detail.distance') }, scope.distanceKm, distance, t),
|
||||
sumBadge({ key: 'ascent', icon: ArrowUp, name: t('tours.detail.ascent') }, scope.ascentM, height, t),
|
||||
sumBadge({ key: 'descent', icon: ArrowDown, name: t('tours.detail.descent') }, scope.descentM, height, t),
|
||||
sumBadge(
|
||||
{ key: 'routed', icon: Clock, name: t('tours.scope.routed') },
|
||||
scope.routedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
),
|
||||
sumBadge(
|
||||
{ key: 'imported', icon: FileDown, name: t('tours.scope.imported') },
|
||||
scope.importedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
),
|
||||
sumBadge(
|
||||
// A city walk plans its stays, not breaks (CW-02).
|
||||
{
|
||||
key: 'planned',
|
||||
icon: CalendarClock,
|
||||
name: t(scope.type === 'walk' ? 'tours.scope.plannedWithStays' : 'tours.scope.planned'),
|
||||
},
|
||||
scope.plannedMinutes,
|
||||
formatPlannedTourDuration,
|
||||
t
|
||||
),
|
||||
].filter((badge): badge is ScopeBadge => badge !== null);
|
||||
}
|
||||
|
||||
/** One tour type: its name and how many tours, then its sums as a line of badges. */
|
||||
function TypeScope({ scope }: { scope: TourTypeScope }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const Icon = TYPE_ICON[scope.type] ?? Route;
|
||||
const badges = typeBadges(scope, unit, t);
|
||||
return (
|
||||
<section
|
||||
aria-label={t(`tourTypes.${scope.type}`)}
|
||||
className="flex flex-col gap-1.5"
|
||||
data-testid={`tour-scope-${scope.type}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="grid h-4 w-4 flex-none place-items-center text-content-muted">
|
||||
<Icon size={13} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12, 'body')}>
|
||||
{t(`tourTypes.${scope.type}`)}
|
||||
</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{scope.count}
|
||||
</span>
|
||||
</div>
|
||||
{badges.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 ps-[22px]">
|
||||
{badges.map((badge) => (
|
||||
<TourDayRowPill
|
||||
key={badge.key}
|
||||
icon={badge.icon}
|
||||
value={badge.value}
|
||||
label={badge.label}
|
||||
testId={`tour-scope-${scope.type}-${badge.key}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-0 ps-[22px] text-content-faint" style={fs(11.5, 'body')}>
|
||||
{t('tours.scope.noMeasures')}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* STA-01: what the trip's tours add up to, above their list, by type: how many,
|
||||
* how many are on a day, distance, ascent and descent, and the three kinds of
|
||||
* time apart (the router's, an import's, the planned one with breaks), never
|
||||
* added up across types. It reads the list as filtered, so a type chosen in the
|
||||
* filter narrows both. A value no tour has is left out rather than shown as
|
||||
* zero; a sum some tours lack is marked as at least that much. It starts folded
|
||||
* to its counts, and opening it is remembered in this browser.
|
||||
*/
|
||||
export function TourScopeSummary({ tours }: { tours: readonly TourListItem[] }) {
|
||||
const { t } = useTranslation();
|
||||
// Folded until opened: the list is what the rail is for, the totals a look on demand.
|
||||
const { folded, toggle } = useRailFold('trip-totals', true);
|
||||
// On a day as the day lists say, not the list's flag, so a tour put on a day counts at once (as its row's badge does).
|
||||
const assignments = useTripStore((state) => state.assignments);
|
||||
const scope = useMemo(() => {
|
||||
const onDays = new Set(Object.values(assignments ?? {}).flatMap((list) => list.map((item) => item.place_id)));
|
||||
return tourScopeSummary(tours, (tour) => onDays.has(tour.place_id));
|
||||
}, [assignments, tours]);
|
||||
if (scope.count === 0) return null;
|
||||
const toggleLabel = folded ? t('common.expand') : t('common.collapse');
|
||||
return (
|
||||
<RailCard
|
||||
icon={<ChartColumnBig size={14} strokeWidth={2} />}
|
||||
title={t('tours.scope.title')}
|
||||
aside={
|
||||
<span className="flex flex-none items-center gap-0.5">
|
||||
<InfoDot label={t('tours.scope.title')} description={t('tours.scope.info')} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={!folded}
|
||||
aria-label={toggleLabel}
|
||||
data-testid="tour-scope-toggle"
|
||||
className="grid h-6 w-6 place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<ChevronDown size={14} strokeWidth={2.2} className={`transition-transform ${folded ? '' : 'rotate-180'}`} />
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5" data-testid="tour-scope-counts">
|
||||
<CountBadge value={scope.count} label={t('tours.mode.tours')} />
|
||||
<CountBadge value={scope.onDays} label={t('places.planned')} tone="faint" />
|
||||
<CountBadge value={scope.count - scope.onDays} label={t('places.unplanned')} tone="faint" />
|
||||
</div>
|
||||
{!folded && (
|
||||
<div className="mt-3 flex flex-col gap-3 border-t border-edge-faint pt-3">
|
||||
{scope.types.map((typeScope) => (
|
||||
<TypeScope key={typeScope.type} scope={typeScope} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export interface TourWorkspaceRailParts {
|
||||
trackPaddlePlan?: TrackPaddlePlan;
|
||||
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
|
||||
paddleChecklist?: PaddleChecklist;
|
||||
/** The trip's tours, never offered as a stop of another tour (PR1-10). */
|
||||
tourPlaceIds?: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
|
||||
@@ -36,6 +38,7 @@ export default function TourWorkspaceRail({
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
paddleChecklist,
|
||||
tourPlaceIds,
|
||||
} = workspace;
|
||||
return wanderer.open ? (
|
||||
<WandererBrowserRail browser={wanderer} />
|
||||
@@ -48,6 +51,7 @@ export default function TourWorkspaceRail({
|
||||
recordedTrack={recordedTrack}
|
||||
trackPaddlePlan={trackPaddlePlan}
|
||||
paddleChecklist={paddleChecklist}
|
||||
tourPlaceIds={tourPlaceIds}
|
||||
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -349,7 +349,8 @@ describe('ToursSidebar', () => {
|
||||
// A file without heights says so instead of showing zero.
|
||||
expect(screen.getByTestId('gpx-preview-track')).toHaveTextContent('No heights in the file')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Paddle tour' }))
|
||||
fireEvent.click(screen.getByTestId('tour-type-select'))
|
||||
fireEvent.click(screen.getByTestId('tour-type-option-kayak'))
|
||||
const confirm = screen.getByTestId('gpx-preview-import')
|
||||
expect(confirm).toBeDisabled()
|
||||
expect(screen.getByText('Choose the boat and the water to import as a paddle tour.')).toBeInTheDocument()
|
||||
|
||||
@@ -9,7 +9,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import TourListFilterMenu from './TourListFilterMenu'
|
||||
import TourListFilterMenu, { TourFilterEmpty } from './TourListFilterMenu'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
|
||||
import { useTourGpxImport } from './useTourGpxImport'
|
||||
@@ -97,14 +97,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
|
||||
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
|
||||
{!loading && filters.emptyByFilter ? (
|
||||
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
<TourFilterEmpty filters={filters} />
|
||||
) : !loading && filters.filtered.length === 0 ? (
|
||||
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
|
||||
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { TourTrailGrade } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { BikeWaysCard } from './BikeWaysCard';
|
||||
import type { RouteWays } from './routeWays';
|
||||
import type { RouteWaysState } from './useRouteWays';
|
||||
|
||||
const hook = vi.hoisted(() => ({ state: null as unknown, retry: vi.fn(), input: null as unknown }));
|
||||
vi.mock('./useRouteWays', () => ({
|
||||
useRouteWays: (input: unknown) => {
|
||||
hook.input = input;
|
||||
return { ...(hook.state as object), retry: hook.retry };
|
||||
},
|
||||
}));
|
||||
|
||||
// FE-TOUR-BIKECARD-001 to FE-TOUR-BIKECARD-008
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
[47.001, 11.0],
|
||||
[47.002, 11.0],
|
||||
[47.003, 11.0],
|
||||
];
|
||||
const WAYS: RouteWays = {
|
||||
line: LINE,
|
||||
edges: [
|
||||
{ from: 0, to: 1, lengthM: 600, surface: 'paved', way: 'cyclePath', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 300, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'ground', way: 'path', wayId: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}) {
|
||||
return {
|
||||
tripId: 4,
|
||||
mode: { type: 'new-draft' },
|
||||
isBike: true,
|
||||
bikeProfile: 'touring',
|
||||
route: LINE,
|
||||
routeCurrent: true,
|
||||
...overrides,
|
||||
} as unknown as TourPlannerController;
|
||||
}
|
||||
|
||||
const grade = (id: number, mtb: string | null, uphill: string | null = null): TourTrailGrade => ({
|
||||
id,
|
||||
mtb_scale: mtb,
|
||||
mtb_scale_uphill: uphill,
|
||||
mtb_scale_imba: null,
|
||||
});
|
||||
|
||||
function show(state: Partial<RouteWaysState>, overrides: Partial<TourPlannerController> = {}) {
|
||||
hook.state = { status: 'ready', ways: WAYS, grades: null, ...state };
|
||||
return render(<BikeWaysCard planner={planner(overrides)} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useTourRouteHighlightStore.getState().clear();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('BikeWaysCard (BIK-01 to BIK-05)', () => {
|
||||
it('FE-TOUR-BIKECARD-001: the surfaces and kinds of way of the route, by share, with their lengths', () => {
|
||||
show({});
|
||||
const card = screen.getByTestId('bike-ways-card');
|
||||
expect(within(screen.getByTestId('bike-ways-row-surface-paved')).getByText('60 %')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-surface-gravel')).toHaveTextContent('300 m');
|
||||
expect(screen.getByTestId('bike-ways-row-way-cyclePath')).toBeInTheDocument();
|
||||
expect(screen.getByText('40 % unpaved')).toBeInTheDocument();
|
||||
expect(card).not.toHaveTextContent('Trail grade');
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-002: a row points at its stretches on the map, and lets go when pressed again', () => {
|
||||
show({});
|
||||
const row = screen.getByTestId('bike-ways-row-surface-gravel');
|
||||
fireEvent.click(row);
|
||||
expect(row).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toEqual({
|
||||
id: 'surface-gravel',
|
||||
lines: [LINE.slice(1, 3)],
|
||||
paint: 'gravel',
|
||||
});
|
||||
fireEvent.click(row);
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-003: a road bike tour names what is unpaved and shows it on the map', () => {
|
||||
show({}, { bikeProfile: 'road' } as Partial<TourPlannerController>);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show on the map' }));
|
||||
expect(useTourRouteHighlightStore.getState().highlight?.id).toBe('unpaved');
|
||||
expect(useTourRouteHighlightStore.getState().highlight?.lines).toEqual([LINE.slice(1)]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-004: a mountain bike tour shows its trail grades, uphill apart, the unknown said', () => {
|
||||
show({
|
||||
grades: {
|
||||
status: 'ready',
|
||||
byWay: new Map([
|
||||
[1, grade(1, '0')],
|
||||
[2, grade(2, '2', '3')],
|
||||
]),
|
||||
unasked: new Set([3]),
|
||||
},
|
||||
});
|
||||
expect(screen.getByTestId('bike-ways-row-trail-s0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-trail-s2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-trail-unasked')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bike-ways-row-uphill-u3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-005: grades nobody could be asked for are one notice with a retry, not a row for the whole tour', () => {
|
||||
show({ grades: { status: 'ready', byWay: new Map(), unasked: new Set([1, 2, 3]) } });
|
||||
expect(screen.queryByTestId('bike-ways-row-trail-unasked')).toBeNull();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
|
||||
expect(hook.retry).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-006: says when it is still asking, offline, or failed with a way to ask again', () => {
|
||||
const { unmount } = show({ status: 'loading', ways: null });
|
||||
expect(screen.getByTestId('bike-ways-card')).toHaveTextContent(/./);
|
||||
unmount();
|
||||
const offline = show({ status: 'offline', ways: null });
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
|
||||
offline.unmount();
|
||||
show({ status: 'failed', ways: null });
|
||||
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||
expect(hook.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-007: grades on their way or offline say so in their own section', () => {
|
||||
const { unmount } = show({ grades: { status: 'loading', byWay: new Map(), unasked: new Set() } });
|
||||
expect(screen.queryByTestId('bike-ways-row-trail-s0')).toBeNull();
|
||||
unmount();
|
||||
show({ grades: { status: 'offline', byWay: new Map(), unasked: new Set() } });
|
||||
expect(screen.queryByRole('button', { name: /try again/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-BIKECARD-008: only for a bike tour being edited, and only the route as planned now', () => {
|
||||
const { unmount } = show({ status: 'idle', ways: null });
|
||||
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
|
||||
unmount();
|
||||
const hike = show({}, { isBike: false });
|
||||
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
|
||||
hike.unmount();
|
||||
show({}, { routeCurrent: false, mode: { type: 'view-gpx' } } as Partial<TourPlannerController>);
|
||||
expect(screen.queryByTestId('bike-ways-card')).toBeNull();
|
||||
expect(hook.input).toMatchObject({ tripId: 4, active: false, route: null, profile: 'touring' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { AlertTriangle, Info, Layers, Loader2, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { CardAction, RailCard, TourNotice } from '../tourParts';
|
||||
import { bikePaintCss, surfacePaintKey, trailPaintKey, type BikePaintKey } from './bikePaint';
|
||||
import {
|
||||
sharesBy,
|
||||
stretchesOf,
|
||||
trailClassOf,
|
||||
UNPAVED,
|
||||
type ClassShare,
|
||||
type RouteWayEdge,
|
||||
type RouteWays,
|
||||
type SurfaceClass,
|
||||
type TrailClass,
|
||||
type WayClass,
|
||||
} from './routeWays';
|
||||
import { useRouteWays, type TrailGrades } from './useRouteWays';
|
||||
|
||||
const percent = (share: number) => `${Math.round(share * 100)} %`;
|
||||
|
||||
interface ShareRowProps<C extends string> {
|
||||
id: string;
|
||||
row: ClassShare<C>;
|
||||
label: string;
|
||||
paint: BikePaintKey;
|
||||
/** A dot in the class's colour; a kind of way has none. */
|
||||
dot: boolean;
|
||||
ways: RouteWays;
|
||||
inClass: (edge: RouteWayEdge) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One class of the route: its share and length, and a switch that points at
|
||||
* its stretches on the map. Pressed again, or another row pressed, it lets go.
|
||||
*/
|
||||
function ShareRow<C extends string>({ id, row, label, paint, dot, ways, inClass }: ShareRowProps<C>) {
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const shown = useTourRouteHighlightStore((state) => state.highlight?.id === id);
|
||||
const toggle = () =>
|
||||
useTourRouteHighlightStore.getState().show(shown ? null : { id, lines: stretchesOf(ways, inClass), paint });
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={shown}
|
||||
data-testid={`bike-ways-row-${id}`}
|
||||
onClick={toggle}
|
||||
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-start hover:bg-surface-tertiary ${shown ? 'bg-surface-tertiary' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-2.5 w-2.5 flex-none rounded-full ${dot ? '' : 'border border-edge'}`}
|
||||
style={dot ? { background: bikePaintCss(paint) } : undefined}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={fs(12, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex-none font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{formatDistance(row.lengthM / 1000, unit)}
|
||||
</span>
|
||||
<span className="w-10 flex-none text-end font-geist tabular-nums text-content-secondary" style={fs(11)}>
|
||||
{percent(row.share)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The shares as one bar, in the classes' colours, so the mix reads at a glance. */
|
||||
function ShareBar<C extends string>({ rows, paintOf }: { rows: ClassShare<C>[]; paintOf: (key: C) => BikePaintKey }) {
|
||||
return (
|
||||
<span aria-hidden="true" className="flex h-2 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
{rows.map((row) => (
|
||||
<span
|
||||
key={row.key}
|
||||
className="h-full"
|
||||
style={{ width: `${row.share * 100}%`, background: bikePaintCss(paintOf(row.key)) }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="font-geist font-bold uppercase tracking-[.08em] text-content-faint" style={fs(9.5, 'caption')}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SurfaceSection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<SurfaceClass>(ways, (edge) => edge.surface), [ways]);
|
||||
return (
|
||||
<Section label={t('tours.bike.ways.surface')}>
|
||||
<ShareBar rows={rows} paintOf={surfacePaintKey} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`surface-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.ways.surface.${row.key}`)}
|
||||
paint={surfacePaintKey(row.key)}
|
||||
dot
|
||||
ways={ways}
|
||||
inClass={(edge) => edge.surface === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function WaySection({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => sharesBy<WayClass>(ways, (edge) => edge.way), [ways]);
|
||||
return (
|
||||
<Section label={t('tours.bike.ways.way')}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`way-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.ways.way.${row.key}`)}
|
||||
paint="way"
|
||||
dot={false}
|
||||
ways={ways}
|
||||
inClass={(edge) => edge.way === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** BIK-05: the trail grades along a mountain bike tour, downhill and uphill apart, the unknown said. */
|
||||
function TrailSection({ ways, grades, onRetry }: { ways: RouteWays; grades: TrailGrades; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const classOf = useMemo(
|
||||
() =>
|
||||
(edge: RouteWayEdge): TrailClass =>
|
||||
edge.wayId !== null && grades.unasked.has(edge.wayId)
|
||||
? 'unasked'
|
||||
: trailClassOf(edge.wayId === null ? undefined : grades.byWay.get(edge.wayId)),
|
||||
[grades]
|
||||
);
|
||||
const uphillOf = useMemo(
|
||||
() =>
|
||||
(edge: RouteWayEdge): string => {
|
||||
const step = edge.wayId === null ? null : grades.byWay.get(edge.wayId)?.mtb_scale_uphill;
|
||||
return step ? `u${step[0]}` : 'none';
|
||||
},
|
||||
[grades]
|
||||
);
|
||||
const rows = useMemo(() => sharesBy<TrailClass>(ways, classOf), [ways, classOf]);
|
||||
const uphill = useMemo(() => sharesBy<string>(ways, uphillOf).filter((row) => row.key !== 'none'), [ways, uphillOf]);
|
||||
// Nothing could be asked about: said once with a way to ask again, not as a row covering the whole tour.
|
||||
const noneAnswered =
|
||||
grades.status === 'ready' && grades.byWay.size === 0 && rows.every((row) => row.key === 'unasked');
|
||||
const unread = noneAnswered ? 'failed' : grades.status;
|
||||
if (unread !== 'ready')
|
||||
return (
|
||||
<Section label={t('tours.bike.trail.title')}>
|
||||
<GradesStatus status={unread} onRetry={onRetry} />
|
||||
</Section>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Section label={t('tours.bike.trail.title')}>
|
||||
<ShareBar rows={rows} paintOf={trailPaintKey} />
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{rows.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`trail-${row.key}`}
|
||||
row={row}
|
||||
label={t(`tours.bike.trail.${row.key}`)}
|
||||
paint={trailPaintKey(row.key)}
|
||||
dot
|
||||
ways={ways}
|
||||
inClass={(edge) => classOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
{uphill.length > 0 && (
|
||||
<Section label={t('tours.bike.trail.uphillTitle')}>
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{uphill.map((row) => (
|
||||
<ShareRow
|
||||
key={row.key}
|
||||
id={`uphill-${row.key}`}
|
||||
row={row}
|
||||
label={t('tours.bike.trail.uphillStep', { step: row.key.slice(1) })}
|
||||
paint="way"
|
||||
dot={false}
|
||||
ways={ways}
|
||||
inClass={(edge) => uphillOf(edge) === row.key}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
<span className="text-content-faint" style={fs(11, 'body')}>
|
||||
{t('tours.bike.trail.note')}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GradesStatus({ status, onRetry }: { status: Exclude<TrailGrades['status'], 'ready'>; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (status === 'loading')
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
{t('tours.bike.trail.loading')}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<TourNotice
|
||||
tone="warning"
|
||||
icon={status === 'offline' ? <WifiOff size={13} strokeWidth={2} /> : <AlertTriangle size={13} strokeWidth={2} />}
|
||||
>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{t(status === 'offline' ? 'tours.bike.ways.offline' : 'tours.bike.trail.failed')}</span>
|
||||
{status === 'failed' && (
|
||||
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={onRetry}>
|
||||
{t('common.errorRetry')}
|
||||
</CardAction>
|
||||
)}
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/** BIK-02: a road bike tour names what of it is known to be unpaved, and points at it on the map. */
|
||||
function UnpavedNotice({ ways }: { ways: RouteWays }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const unpaved = ways.edges.filter((edge) => UNPAVED.has(edge.surface)).reduce((sum, edge) => sum + edge.lengthM, 0);
|
||||
if (unpaved === 0) return null;
|
||||
const show = () =>
|
||||
useTourRouteHighlightStore
|
||||
.getState()
|
||||
.show({ id: 'unpaved', lines: stretchesOf(ways, (edge) => UNPAVED.has(edge.surface)), paint: 'gravel' });
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{t('tours.bike.ways.unpavedNotice', { length: formatDistance(unpaved / 1000, unit) })}</span>
|
||||
<CardAction icon={<Layers size={12} strokeWidth={2} />} onClick={show}>
|
||||
{t('tours.bike.ways.showOnMap')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: what the bike tour being planned runs on, its surfaces and
|
||||
* its kinds of way, and on a mountain bike tour the trail grades. Read off the
|
||||
* router's description of the route as OpenStreetMap maps it; where the data
|
||||
* says nothing the card says unknown. Nothing here changes the tour.
|
||||
*/
|
||||
export function BikeWaysCard({ planner }: { planner: TourPlannerController }) {
|
||||
const { t } = useTranslation();
|
||||
const editing = planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved';
|
||||
const route = planner.routeCurrent ? planner.route : null;
|
||||
const state = useRouteWays({
|
||||
tripId: planner.tripId,
|
||||
active: planner.isBike && editing,
|
||||
route,
|
||||
profile: planner.bikeProfile,
|
||||
});
|
||||
const { ways } = state;
|
||||
const unpavedShare = useMemo(
|
||||
() => (ways ? sharesBy(ways, (edge) => (UNPAVED.has(edge.surface) ? 'unpaved' : 'other')) : []),
|
||||
[ways]
|
||||
);
|
||||
if (!planner.isBike || !editing || state.status === 'idle') return null;
|
||||
const unpaved = unpavedShare.find((row) => row.key === 'unpaved');
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Layers size={14} strokeWidth={2} />}
|
||||
title={t('tours.bike.ways.title')}
|
||||
foldId="bike-ways"
|
||||
aside={
|
||||
unpaved ? (
|
||||
<SoftPill tone="warning">{t('tours.bike.ways.unpavedBadge', { share: percent(unpaved.share) })}</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div data-testid="bike-ways-card" className="flex min-w-0 flex-col gap-3">
|
||||
{state.status === 'loading' && (
|
||||
<span className="inline-flex items-center gap-1.5 text-content-muted" style={fs(11.5, 'body')}>
|
||||
<Loader2 size={12} strokeWidth={2} className="animate-spin" />
|
||||
{t('tours.bike.ways.loading')}
|
||||
</span>
|
||||
)}
|
||||
{state.status === 'offline' && (
|
||||
<TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.bike.ways.offline')}</TourNotice>
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>{t('tours.bike.ways.failed')}</span>
|
||||
<CardAction icon={<RefreshCw size={12} strokeWidth={2} />} onClick={state.retry}>
|
||||
{t('common.errorRetry')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</TourNotice>
|
||||
)}
|
||||
{ways && (
|
||||
<>
|
||||
{planner.bikeProfile === 'road' && <UnpavedNotice ways={ways} />}
|
||||
<SurfaceSection ways={ways} />
|
||||
<WaySection ways={ways} />
|
||||
{state.grades && <TrailSection ways={ways} grades={state.grades} onRetry={state.retry} />}
|
||||
<span className="inline-flex gap-1.5 text-content-faint" style={fs(11, 'body')}>
|
||||
<Info size={12} strokeWidth={2} className="mt-0.5 flex-none" />
|
||||
{t('tours.bike.ways.source')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMapPaint } from '../../Map/mapPaint';
|
||||
import type { SurfaceClass, TrailClass } from './routeWays';
|
||||
|
||||
/**
|
||||
* The colours of a bike tour's surfaces and trail grades (styles/bike.css),
|
||||
* by name. The cards take the CSS variable, which follows the theme on its
|
||||
* own; the map reads them resolved (`useBikePaint`). A kind of way has no
|
||||
* colour of its own: it is pointed at in the accent.
|
||||
*/
|
||||
const BIKE_PAINT_TOKENS = {
|
||||
paved: '--bike-surface-paved',
|
||||
rough: '--bike-surface-rough',
|
||||
compacted: '--bike-surface-compacted',
|
||||
gravel: '--bike-surface-gravel',
|
||||
ground: '--bike-surface-ground',
|
||||
unknown: '--bike-surface-unknown',
|
||||
s0: '--bike-trail-s0',
|
||||
s1: '--bike-trail-s1',
|
||||
s2: '--bike-trail-s2',
|
||||
s3: '--bike-trail-s3',
|
||||
s4: '--bike-trail-s4',
|
||||
way: '--accent',
|
||||
casing: '--bike-highlight-casing',
|
||||
} as const;
|
||||
|
||||
export type BikePaintKey = keyof typeof BIKE_PAINT_TOKENS;
|
||||
|
||||
/** The paint of a trail class; a way without a grade, or not asked about, in the grey of the unknown. */
|
||||
export function trailPaintKey(key: TrailClass): BikePaintKey {
|
||||
return key === 'none' || key === 'unasked' ? 'unknown' : key;
|
||||
}
|
||||
|
||||
/** The paint of a surface class. */
|
||||
export function surfacePaintKey(key: SurfaceClass): BikePaintKey {
|
||||
return key;
|
||||
}
|
||||
|
||||
/** A colour as a CSS variable, for markup on the page. */
|
||||
export function bikePaintCss(key: BikePaintKey): string {
|
||||
return `var(${BIKE_PAINT_TOKENS[key]})`;
|
||||
}
|
||||
|
||||
/** The colours resolved for map paint, again whenever the theme changes. */
|
||||
export function useBikePaint(): Record<BikePaintKey, string> {
|
||||
return useMapPaint(BIKE_PAINT_TOKENS);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RouteHighlight } from '../../../store/tourRouteHighlightStore';
|
||||
import type { BikePaintKey } from './bikePaint';
|
||||
|
||||
type Line = [number, number][];
|
||||
type LineColor = { line: string; casing: string } | undefined;
|
||||
|
||||
/**
|
||||
* The planner map's lines with a highlight laid over them: the route's own
|
||||
* lines first as they were, the highlighted stretches after, solid, in their
|
||||
* colour over a rim of the map's own white, so they read over the route.
|
||||
* Without a highlight the lines stay exactly as they came.
|
||||
*/
|
||||
export function withRouteHighlight(
|
||||
highlight: RouteHighlight | null,
|
||||
route: Line[] | null,
|
||||
walking: boolean[] | null,
|
||||
colors: LineColor[] | null,
|
||||
paint: Record<BikePaintKey, string>
|
||||
): { route: Line[] | null; walking: boolean[] | null; colors: LineColor[] | null } {
|
||||
if (!highlight?.lines.length || !route?.length) return { route, walking, colors };
|
||||
const color = { line: paint[highlight.paint as BikePaintKey] ?? paint.way, casing: paint.casing };
|
||||
return {
|
||||
route: [...route, ...highlight.lines],
|
||||
walking: walking ? [...walking, ...highlight.lines.map(() => false)] : null,
|
||||
colors: [...(colors ?? route.map(() => undefined)), ...highlight.lines.map(() => color)],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { encodePolyline } from '../../../../tests/helpers/polyline';
|
||||
import { bikePaintCss, surfacePaintKey, trailPaintKey } from './bikePaint';
|
||||
import { withRouteHighlight } from './routeHighlight';
|
||||
import { routeWaysFrom, sharesBy, stretchesOf, trailClassOf, wayIdsOf, type RouteWays } from './routeWays';
|
||||
|
||||
// FE-TOUR-WAYS-001 to FE-TOUR-WAYS-010
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
[47.001, 11.0],
|
||||
[47.002, 11.0],
|
||||
[47.003, 11.0],
|
||||
[47.004, 11.0],
|
||||
];
|
||||
|
||||
function answer(edges: Record<string, unknown>[], line = LINE) {
|
||||
return { shape: encodePolyline(line, 6), edges };
|
||||
}
|
||||
|
||||
const edge = (from: number, to: number, extra: Record<string, unknown> = {}) => ({
|
||||
begin_shape_index: from,
|
||||
end_shape_index: to,
|
||||
length: 0.1,
|
||||
surface: 'paved_smooth',
|
||||
use: 'road',
|
||||
road_class: 'residential',
|
||||
way_id: 100 + from,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('routeWaysFrom (BIK-01 to BIK-04)', () => {
|
||||
it('FE-TOUR-WAYS-001: reads each edge into its place on the line, its surface, its kind of way and its way', () => {
|
||||
const ways = routeWaysFrom(
|
||||
answer([
|
||||
edge(0, 1, { surface: 'paved', use: 'cycleway' }),
|
||||
edge(1, 2, { surface: 'gravel', use: 'track', length: 0.25 }),
|
||||
edge(2, 3, { surface: 'dirt', use: 'path' }),
|
||||
edge(3, 4, { surface: 'compacted', use: 'road', road_class: 'primary', cycle_lane: 'dedicated', way_id: 0 }),
|
||||
])
|
||||
);
|
||||
expect(ways?.line).toHaveLength(5);
|
||||
expect(ways?.edges.map(({ surface, way, wayId, lengthM }) => [surface, way, wayId, lengthM])).toEqual([
|
||||
['paved', 'cyclePath', 100, 100],
|
||||
['gravel', 'track', 101, 250],
|
||||
['ground', 'path', 102, 100],
|
||||
['compacted', 'cycleLane', null, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-002: names quiet roads, roads, ferries and what it does not know', () => {
|
||||
const ways = routeWaysFrom(
|
||||
answer([
|
||||
edge(0, 1, { use: 'living_street' }),
|
||||
edge(1, 2, { use: 'road', road_class: 'secondary', surface: 'paved_rough' }),
|
||||
edge(2, 3, { use: 'ferry', surface: undefined }),
|
||||
edge(3, 4, { use: 'something_new', surface: 'wood' }),
|
||||
])
|
||||
);
|
||||
expect(ways?.edges.map(({ way, surface }) => [way, surface])).toEqual([
|
||||
['quiet', 'paved'],
|
||||
['road', 'rough'],
|
||||
['ferry', 'unknown'],
|
||||
['other', 'unknown'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-003: drops an edge without its place on the line, and is null for no answer of that kind', () => {
|
||||
const ways = routeWaysFrom(
|
||||
answer([edge(0, 1), { ...edge(1, 2), end_shape_index: 9 }, { ...edge(2, 1) }, { ...edge(2, 3), length: -1 }])
|
||||
);
|
||||
expect(ways?.edges).toHaveLength(1);
|
||||
expect(routeWaysFrom(null)).toBeNull();
|
||||
expect(routeWaysFrom({ shape: 1, edges: [] })).toBeNull();
|
||||
expect(routeWaysFrom(answer([]))).toBeNull();
|
||||
expect(routeWaysFrom(answer([edge(0, 0)], [[47, 11]]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const WAYS: RouteWays = {
|
||||
line: LINE,
|
||||
edges: [
|
||||
{ from: 0, to: 1, lengthM: 300, surface: 'paved', way: 'road', wayId: 1 },
|
||||
{ from: 1, to: 2, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 2, to: 3, lengthM: 100, surface: 'gravel', way: 'track', wayId: 2 },
|
||||
{ from: 3, to: 4, lengthM: 0, surface: 'ground', way: 'path', wayId: null },
|
||||
],
|
||||
};
|
||||
|
||||
describe('what the line runs on (BIK-01 to BIK-05)', () => {
|
||||
it('FE-TOUR-WAYS-004: shares by class, the largest first, a class with no length left out', () => {
|
||||
expect(sharesBy(WAYS, (e) => e.surface)).toEqual([
|
||||
{ key: 'paved', lengthM: 300, share: 0.6 },
|
||||
{ key: 'gravel', lengthM: 200, share: 0.4 },
|
||||
]);
|
||||
expect(sharesBy({ line: LINE, edges: [] }, (e) => e.surface)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-005: the stretches of a class, neighbours joined into one line each', () => {
|
||||
expect(stretchesOf(WAYS, (e) => e.surface === 'gravel')).toEqual([LINE.slice(1, 4)]);
|
||||
expect(stretchesOf(WAYS, (e) => e.surface !== 'gravel')).toEqual([LINE.slice(0, 2), LINE.slice(3, 5)]);
|
||||
expect(stretchesOf(WAYS, () => false)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-006: the ways to grade, each once, in riding order, at most as many as asked', () => {
|
||||
expect(wayIdsOf(WAYS, 10)).toEqual([1, 2]);
|
||||
expect(wayIdsOf(WAYS, 1)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-007: a trail grade by its step, S4 and up together, none where there is none', () => {
|
||||
expect(trailClassOf({ mtb_scale: '0' })).toBe('s0');
|
||||
expect(trailClassOf({ mtb_scale: '2+' })).toBe('s2');
|
||||
expect(trailClassOf({ mtb_scale: '5' })).toBe('s4');
|
||||
expect(trailClassOf({ mtb_scale: null })).toBe('none');
|
||||
expect(trailClassOf({ mtb_scale: 'x' })).toBe('none');
|
||||
expect(trailClassOf(undefined)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the paints and the highlight (BIK-01 to BIK-05)', () => {
|
||||
it('FE-TOUR-WAYS-008: a class names its token; an ungraded way is grey', () => {
|
||||
expect(surfacePaintKey('gravel')).toBe('gravel');
|
||||
expect(trailPaintKey('s3')).toBe('s3');
|
||||
expect(trailPaintKey('none')).toBe('unknown');
|
||||
expect(trailPaintKey('unasked')).toBe('unknown');
|
||||
expect(bikePaintCss('s1')).toBe('var(--bike-trail-s1)');
|
||||
});
|
||||
|
||||
const paint = { gravel: '#a0522d', way: '#111', casing: '#fff' } as Parameters<typeof withRouteHighlight>[4];
|
||||
|
||||
it('FE-TOUR-WAYS-009: lays the highlighted stretches over the route in their paint, solid', () => {
|
||||
const route = [LINE.slice(0, 3), LINE.slice(2)];
|
||||
const drawn = withRouteHighlight(
|
||||
{ id: 'surface:gravel', lines: [LINE.slice(1, 3)], paint: 'gravel' },
|
||||
route,
|
||||
[false, true],
|
||||
null,
|
||||
paint
|
||||
);
|
||||
expect(drawn.route).toEqual([...route, LINE.slice(1, 3)]);
|
||||
expect(drawn.walking).toEqual([false, true, false]);
|
||||
expect(drawn.colors).toEqual([undefined, undefined, { line: '#a0522d', casing: '#fff' }]);
|
||||
const way = withRouteHighlight({ id: 'way:track', lines: [LINE], paint: 'track' }, route, null, null, paint);
|
||||
expect(way.colors?.[2]).toEqual({ line: '#111', casing: '#fff' });
|
||||
expect(way.walking).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-010: without a highlight, or a route to lay it on, the lines stay as they came', () => {
|
||||
const route = [LINE];
|
||||
const colors = [{ line: '#00f', casing: '#fff' }];
|
||||
expect(withRouteHighlight(null, route, [false], colors, paint)).toEqual({ route, walking: [false], colors });
|
||||
expect(withRouteHighlight({ id: 'x', lines: [], paint: 'gravel' }, route, null, null, paint).route).toBe(route);
|
||||
expect(withRouteHighlight({ id: 'x', lines: [LINE], paint: 'gravel' }, null, null, null, paint).route).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { TourTrailGrade } from '@trek/shared';
|
||||
import { decodePolyline } from '../../Map/transitGeometry';
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: what a bike tour runs on, read from the router's own
|
||||
* description of the ways along its line (Valhalla's trace attributes): the
|
||||
* surface, the kind of way, and the OpenStreetMap way behind each stretch.
|
||||
* Pure, so the classes and their shares are tested apart from the request.
|
||||
*/
|
||||
|
||||
/** Surfaces from smooth to rough; `unknown` where the data names none. */
|
||||
export type SurfaceClass = 'paved' | 'rough' | 'compacted' | 'gravel' | 'ground' | 'unknown';
|
||||
|
||||
/** Kinds of way, the ones a rider plans by. */
|
||||
export type WayClass = 'cyclePath' | 'cycleLane' | 'quiet' | 'road' | 'track' | 'path' | 'ferry' | 'other';
|
||||
|
||||
/**
|
||||
* mtb:scale steps as a rider reads them: S0 to S3 apart, S4 and up together
|
||||
* (rare, and all of them expert ground); `none` where OpenStreetMap gives the
|
||||
* way no grade, `unasked` where it could not be asked this time.
|
||||
*/
|
||||
export type TrailClass = 's0' | 's1' | 's2' | 's3' | 's4' | 'none' | 'unasked';
|
||||
|
||||
/** Surfaces a road bike's rider calls unpaved (BIK-02): what is no paving of any kind. */
|
||||
export const UNPAVED: ReadonlySet<SurfaceClass> = new Set(['compacted', 'gravel', 'ground']);
|
||||
|
||||
export interface RouteWayEdge {
|
||||
/** Where on the line the stretch starts and ends, as indices into `RouteWays.line`. */
|
||||
from: number;
|
||||
to: number;
|
||||
lengthM: number;
|
||||
surface: SurfaceClass;
|
||||
way: WayClass;
|
||||
/** The OpenStreetMap way, where the router names it. */
|
||||
wayId: number | null;
|
||||
}
|
||||
|
||||
export interface RouteWays {
|
||||
/** The line as the router matched it, which the stretches index into. */
|
||||
line: [number, number][];
|
||||
edges: RouteWayEdge[];
|
||||
}
|
||||
|
||||
const SURFACE_OF: Record<string, SurfaceClass> = {
|
||||
paved_smooth: 'paved',
|
||||
paved: 'paved',
|
||||
paved_rough: 'rough',
|
||||
compacted: 'compacted',
|
||||
gravel: 'gravel',
|
||||
dirt: 'ground',
|
||||
path: 'ground',
|
||||
};
|
||||
|
||||
const PATH_USES = new Set([
|
||||
'path',
|
||||
'footway',
|
||||
'pedestrian',
|
||||
'bridleway',
|
||||
'mountain_bike',
|
||||
'steps',
|
||||
'sidewalk',
|
||||
'pedestrian_crossing',
|
||||
'elevator',
|
||||
]);
|
||||
const QUIET_ROAD_CLASSES = new Set(['residential', 'service_other', 'unclassified']);
|
||||
|
||||
function wayOf(use: string | undefined, roadClass: string | undefined, cycleLane: string | undefined): WayClass {
|
||||
if (use === 'cycleway' || use === 'cycle_street') return 'cyclePath';
|
||||
if (use === 'ferry' || use === 'rail_ferry') return 'ferry';
|
||||
if (use === 'track') return 'track';
|
||||
if (use && PATH_USES.has(use)) return 'path';
|
||||
if (
|
||||
use === 'living_street' ||
|
||||
use === 'service_road' ||
|
||||
use === 'driveway' ||
|
||||
use === 'alley' ||
|
||||
use === 'parking_aisle'
|
||||
)
|
||||
return 'quiet';
|
||||
if (use === 'road') {
|
||||
if (cycleLane === 'dedicated' || cycleLane === 'separated') return 'cycleLane';
|
||||
return roadClass && QUIET_ROAD_CLASSES.has(roadClass) ? 'quiet' : 'road';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
interface TraceEdge {
|
||||
surface?: unknown;
|
||||
use?: unknown;
|
||||
road_class?: unknown;
|
||||
cycle_lane?: unknown;
|
||||
length?: unknown;
|
||||
way_id?: unknown;
|
||||
begin_shape_index?: unknown;
|
||||
end_shape_index?: unknown;
|
||||
}
|
||||
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
||||
const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null);
|
||||
|
||||
/**
|
||||
* The router's answer read into stretches, or null when it is no answer of
|
||||
* that kind. Edge lengths come in kilometres; an edge without its place on the
|
||||
* line is dropped rather than placed by guess.
|
||||
*/
|
||||
export function routeWaysFrom(answer: unknown): RouteWays | null {
|
||||
const data = answer as { shape?: unknown; edges?: unknown } | null;
|
||||
if (!data || typeof data.shape !== 'string' || !Array.isArray(data.edges)) return null;
|
||||
const line = decodePolyline(data.shape, 6);
|
||||
if (line.length < 2) return null;
|
||||
const edges: RouteWayEdge[] = [];
|
||||
for (const raw of data.edges as TraceEdge[]) {
|
||||
const from = count(raw.begin_shape_index);
|
||||
const to = count(raw.end_shape_index);
|
||||
const km = count(raw.length);
|
||||
if (from === null || to === null || km === null || to < from || to >= line.length) continue;
|
||||
const surface = SURFACE_OF[text(raw.surface) ?? ''] ?? 'unknown';
|
||||
const wayId = count(raw.way_id);
|
||||
edges.push({
|
||||
from,
|
||||
to,
|
||||
lengthM: km * 1000,
|
||||
surface,
|
||||
way: wayOf(text(raw.use), text(raw.road_class), text(raw.cycle_lane)),
|
||||
wayId: wayId && wayId > 0 ? wayId : null,
|
||||
});
|
||||
}
|
||||
return edges.length ? { line, edges } : null;
|
||||
}
|
||||
|
||||
/** A trail grade's class: the step of mtb:scale, S4 and up together. */
|
||||
export function trailClassOf(grade: Pick<TourTrailGrade, 'mtb_scale'> | undefined): TrailClass {
|
||||
const step = grade?.mtb_scale ? Number(grade.mtb_scale[0]) : null;
|
||||
if (step === null || Number.isNaN(step)) return 'none';
|
||||
return step >= 4 ? 's4' : (`s${step}` as TrailClass);
|
||||
}
|
||||
|
||||
export interface ClassShare<C extends string> {
|
||||
key: C;
|
||||
lengthM: number;
|
||||
/** Of the whole line, 0 to 1. */
|
||||
share: number;
|
||||
}
|
||||
|
||||
/** How much of the line falls in each class, the largest first; classes it has none of are left out. */
|
||||
export function sharesBy<C extends string>(ways: RouteWays, classOf: (edge: RouteWayEdge) => C): ClassShare<C>[] {
|
||||
const total = ways.edges.reduce((sum, edge) => sum + edge.lengthM, 0);
|
||||
const sums = new Map<C, number>();
|
||||
for (const edge of ways.edges) sums.set(classOf(edge), (sums.get(classOf(edge)) ?? 0) + edge.lengthM);
|
||||
return [...sums]
|
||||
.filter(([, lengthM]) => lengthM > 0)
|
||||
.map(([key, lengthM]) => ({ key, lengthM, share: total > 0 ? lengthM / total : 0 }))
|
||||
.sort((a, b) => b.lengthM - a.lengthM);
|
||||
}
|
||||
|
||||
/** The stretches of the line in one class, neighbours joined, each as its own line for the map. */
|
||||
export function stretchesOf(ways: RouteWays, inClass: (edge: RouteWayEdge) => boolean): [number, number][][] {
|
||||
const stretches: [number, number][][] = [];
|
||||
let open: { from: number; to: number } | null = null;
|
||||
const close = () => {
|
||||
if (open && open.to > open.from) stretches.push(ways.line.slice(open.from, open.to + 1));
|
||||
open = null;
|
||||
};
|
||||
for (const edge of ways.edges) {
|
||||
if (!inClass(edge)) {
|
||||
close();
|
||||
continue;
|
||||
}
|
||||
if (open && edge.from <= open.to) open.to = Math.max(open.to, edge.to);
|
||||
else {
|
||||
close();
|
||||
open = { from: edge.from, to: edge.to };
|
||||
}
|
||||
}
|
||||
close();
|
||||
return stretches;
|
||||
}
|
||||
|
||||
/** The ways along the line OpenStreetMap could grade, each once, in the order they are ridden. */
|
||||
export function wayIdsOf(ways: RouteWays, limit: number): number[] {
|
||||
const ids: number[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const edge of ways.edges) {
|
||||
if (edge.wayId === null || seen.has(edge.wayId)) continue;
|
||||
seen.add(edge.wayId);
|
||||
ids.push(edge.wayId);
|
||||
if (ids.length === limit) break;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { useHighlightedRoute } from './useHighlightedRoute';
|
||||
|
||||
const LINE: [number, number][] = [
|
||||
[47, 11],
|
||||
[47.01, 11],
|
||||
];
|
||||
|
||||
describe('useHighlightedRoute (BIK-01 to BIK-05)', () => {
|
||||
it('lays the highlight over the route while shown, and leaves the route alone otherwise', () => {
|
||||
act(() => useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [LINE], paint: 'gravel' }));
|
||||
const shown = renderHook(() => useHighlightedRoute(true, [LINE], null, null));
|
||||
expect(shown.result.current.route).toHaveLength(2);
|
||||
expect(shown.result.current.colors?.[1]).toMatchObject({ line: expect.any(String), casing: expect.any(String) });
|
||||
const hidden = renderHook(() => useHighlightedRoute(false, [LINE], null, null));
|
||||
expect(hidden.result.current.route).toEqual([LINE]);
|
||||
act(() => useTourRouteHighlightStore.getState().clear());
|
||||
expect(shown.result.current.route).toEqual([LINE]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { useBikePaint } from './bikePaint';
|
||||
import { withRouteHighlight } from './routeHighlight';
|
||||
|
||||
type Lines = Parameters<typeof withRouteHighlight>[1];
|
||||
type Walking = Parameters<typeof withRouteHighlight>[2];
|
||||
type Colors = Parameters<typeof withRouteHighlight>[3];
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: the planner map's route with the stretches a bike tour's
|
||||
* card points at laid over it, while `shown` (the route being edited is on
|
||||
* the map); otherwise the lines as they came.
|
||||
*/
|
||||
export function useHighlightedRoute(shown: boolean, route: Lines, walking: Walking, colors: Colors) {
|
||||
const highlight = useTourRouteHighlightStore((state) => state.highlight);
|
||||
const paint = useBikePaint();
|
||||
return withRouteHighlight(shown ? highlight : null, route, walking, colors, paint);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { encodePolyline } from '../../../../tests/helpers/polyline';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { useRouteWays } from './useRouteWays';
|
||||
|
||||
const trace = vi.hoisted(() => ({ valhallaTraceAttributes: vi.fn() }));
|
||||
const gradesRepo = vi.hoisted(() => ({ grades: vi.fn() }));
|
||||
const network = vi.hoisted(() => ({ offline: false }));
|
||||
vi.mock('../../Map/valhallaRoute', () => trace);
|
||||
vi.mock('../../../repo/tourTrailGradeRepo', () => ({ tourTrailGradeRepo: gradesRepo }));
|
||||
vi.mock('../../../hooks/useNetworkMode', () => ({ useNetworkMode: () => ({ offline: network.offline }) }));
|
||||
|
||||
// FE-TOUR-WAYS-HOOK-001 to FE-TOUR-WAYS-HOOK-008
|
||||
|
||||
const ROUTE: [number, number][] = [
|
||||
[47.0, 11.0],
|
||||
[47.002, 11.0],
|
||||
[47.004, 11.0],
|
||||
];
|
||||
const ANSWER = {
|
||||
shape: encodePolyline(ROUTE, 6),
|
||||
edges: [
|
||||
{ begin_shape_index: 0, end_shape_index: 1, length: 0.2, surface: 'gravel', use: 'track', way_id: 11 },
|
||||
{ begin_shape_index: 1, end_shape_index: 2, length: 0.2, surface: 'dirt', use: 'path', way_id: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
type Input = Parameters<typeof useRouteWays>[0];
|
||||
const input = (overrides: Partial<Input> = {}): Input => ({
|
||||
tripId: 3,
|
||||
active: true,
|
||||
route: ROUTE,
|
||||
profile: 'touring',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
network.offline = false;
|
||||
trace.valhallaTraceAttributes.mockReset().mockResolvedValue(ANSWER);
|
||||
gradesRepo.grades.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('useRouteWays (BIK-01 to BIK-05)', () => {
|
||||
it('FE-TOUR-WAYS-HOOK-001: asks nothing while inactive or without a route of two points', () => {
|
||||
for (const overrides of [{ active: false }, { route: null }, { route: ROUTE.slice(0, 1) }]) {
|
||||
const { result } = renderHook(() => useRouteWays(input(overrides)));
|
||||
expect(result.current.status).toBe('idle');
|
||||
}
|
||||
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-002: waits for the route to settle, then reads its ways, without grades for a touring bike', async () => {
|
||||
const { result } = renderHook(() => useRouteWays(input()));
|
||||
expect(result.current.status).toBe('loading');
|
||||
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
|
||||
await settle();
|
||||
expect(trace.valhallaTraceAttributes).toHaveBeenCalledWith(ROUTE, expect.any(AbortSignal));
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.ways?.edges.map((edge) => edge.surface)).toEqual(['gravel', 'ground']);
|
||||
expect(result.current.grades).toBeNull();
|
||||
expect(gradesRepo.grades).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-003: a mountain bike tour asks the grades of its ways, and keeps what could not be asked apart', async () => {
|
||||
gradesRepo.grades.mockResolvedValue({
|
||||
ways: [{ id: 11, mtb_scale: '2', mtb_scale_uphill: null, mtb_scale_imba: null }],
|
||||
unanswered: [12],
|
||||
});
|
||||
const { result } = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
|
||||
await settle();
|
||||
expect(gradesRepo.grades).toHaveBeenCalledWith(3, { way_ids: [11, 12] }, expect.any(AbortSignal));
|
||||
expect(result.current.grades?.status).toBe('ready');
|
||||
expect(result.current.grades?.byWay.get(11)?.mtb_scale).toBe('2');
|
||||
expect([...(result.current.grades?.unasked ?? [])]).toEqual([12]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-004: grades that cannot be had say why: offline, or failed', async () => {
|
||||
gradesRepo.grades.mockRejectedValueOnce(new OnlineOnlyError());
|
||||
const offline = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
|
||||
await settle();
|
||||
expect(offline.result.current.status).toBe('ready');
|
||||
expect(offline.result.current.grades?.status).toBe('offline');
|
||||
offline.unmount();
|
||||
|
||||
gradesRepo.grades.mockRejectedValueOnce(new Error('overpass'));
|
||||
const failed = renderHook(() => useRouteWays(input({ profile: 'mtb' })));
|
||||
await settle();
|
||||
expect(failed.result.current.grades?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-005: a route the router cannot describe fails, and retry asks again', async () => {
|
||||
trace.valhallaTraceAttributes.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useRouteWays(input()));
|
||||
await settle();
|
||||
expect(result.current.status).toBe('failed');
|
||||
act(() => result.current.retry());
|
||||
expect(result.current.status).toBe('loading');
|
||||
await settle();
|
||||
expect(trace.valhallaTraceAttributes).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-006: an error on the way fails rather than leaving the card waiting', async () => {
|
||||
trace.valhallaTraceAttributes.mockRejectedValueOnce(new Error('boom'));
|
||||
const { result } = renderHook(() => useRouteWays(input()));
|
||||
await settle();
|
||||
expect(result.current.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-007: offline it says so and asks nothing', async () => {
|
||||
network.offline = true;
|
||||
const { result } = renderHook(() => useRouteWays(input()));
|
||||
await settle();
|
||||
expect(result.current.status).toBe('offline');
|
||||
expect(trace.valhallaTraceAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-WAYS-HOOK-008: a new route drops the highlight and the old answer, and leaving takes the highlight down', async () => {
|
||||
const { result, rerender, unmount } = renderHook((props: Input) => useRouteWays(props), { initialProps: input() });
|
||||
await settle();
|
||||
expect(result.current.status).toBe('ready');
|
||||
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
|
||||
rerender(input({ route: [...ROUTE, [47.006, 11.0]] }));
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
|
||||
expect(result.current.status).toBe('loading');
|
||||
useTourRouteHighlightStore.getState().show({ id: 'surface-gravel', lines: [ROUTE], paint: 'gravel' });
|
||||
unmount();
|
||||
expect(useTourRouteHighlightStore.getState().highlight).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { TOUR_TRAIL_GRADE_MAX_WAYS, type BikeProfile, type TourTrailGrade } from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourTrailGradeRepo } from '../../../repo/tourTrailGradeRepo';
|
||||
import { useTourRouteHighlightStore } from '../../../store/tourRouteHighlightStore';
|
||||
import { valhallaTraceAttributes } from '../../Map/valhallaRoute';
|
||||
import { routeWaysFrom, wayIdsOf, type RouteWays } from './routeWays';
|
||||
|
||||
/** How long a route has to stay before its ways are asked about, so a route being shaped asks once. */
|
||||
const SETTLE_MS = 600;
|
||||
|
||||
type RouteWaysStatus = 'idle' | 'loading' | 'ready' | 'failed' | 'offline';
|
||||
|
||||
export interface TrailGrades {
|
||||
status: 'loading' | 'ready' | 'failed' | 'offline';
|
||||
byWay: ReadonlyMap<number, TourTrailGrade>;
|
||||
/** Ways the data could not be asked about: their grade is unknown, not absent. */
|
||||
unasked: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
export interface RouteWaysState {
|
||||
status: RouteWaysStatus;
|
||||
ways: RouteWays | null;
|
||||
/** BIK-05, a mountain bike tour's only: the trail grades of its ways. */
|
||||
grades: TrailGrades | null;
|
||||
}
|
||||
|
||||
interface Answer extends RouteWaysState {
|
||||
attempt: number;
|
||||
/** The route and the question it answers; another one makes it stale. */
|
||||
route: [number, number][];
|
||||
withGrades: boolean;
|
||||
}
|
||||
|
||||
const IDLE: RouteWaysState = { status: 'idle', ways: null, grades: null };
|
||||
const NO_GRADES = { byWay: new Map<number, TourTrailGrade>(), unasked: new Set<number>() };
|
||||
|
||||
/** The ways of a route, and for a mountain bike tour their grades; the grades' outcome as it came. */
|
||||
async function askRouteWays(
|
||||
tripId: number | string,
|
||||
route: [number, number][],
|
||||
withGrades: boolean,
|
||||
signal: AbortSignal,
|
||||
onWays: (ways: RouteWays) => void
|
||||
): Promise<RouteWaysState> {
|
||||
const ways = routeWaysFrom(await valhallaTraceAttributes(route, signal));
|
||||
if (!ways) return { status: 'failed', ways: null, grades: null };
|
||||
if (!withGrades) return { status: 'ready', ways, grades: null };
|
||||
onWays(ways);
|
||||
const ids = wayIdsOf(ways, TOUR_TRAIL_GRADE_MAX_WAYS);
|
||||
if (ids.length === 0) return { status: 'ready', ways, grades: { status: 'ready', ...NO_GRADES } };
|
||||
try {
|
||||
const answer = await tourTrailGradeRepo.grades(tripId, { way_ids: ids }, signal);
|
||||
const byWay = new Map(answer.ways.map((grade) => [grade.id, grade]));
|
||||
return { status: 'ready', ways, grades: { status: 'ready', byWay, unasked: new Set(answer.unanswered) } };
|
||||
} catch (failure) {
|
||||
const status = failure instanceof OnlineOnlyError ? 'offline' : 'failed';
|
||||
return { status: 'ready', ways, grades: { status, ...NO_GRADES } };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-01 to BIK-05: what the bike tour being planned runs on, asked of the
|
||||
* router once its route has settled, and for a mountain bike tour the trail
|
||||
* grades of its ways. A new route makes the answer on its way stale and takes
|
||||
* the map's highlight down; nothing of it changes the tour.
|
||||
*/
|
||||
export function useRouteWays(input: {
|
||||
tripId: number | string;
|
||||
active: boolean;
|
||||
route: [number, number][] | null;
|
||||
profile: BikeProfile;
|
||||
}): RouteWaysState & { retry: () => void } {
|
||||
const { tripId, active, route, profile } = input;
|
||||
const { offline } = useNetworkMode();
|
||||
const [answer, setAnswer] = useState<Answer | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
const withGrades = profile === 'mtb';
|
||||
const asking = active && !offline && !!route && route.length >= 2;
|
||||
|
||||
useEffect(() => {
|
||||
useTourRouteHighlightStore.getState().clear();
|
||||
if (!asking || !route) return;
|
||||
const controller = new AbortController();
|
||||
const settle = (state: RouteWaysState) => {
|
||||
if (!controller.signal.aborted) setAnswer({ ...state, route, withGrades, attempt });
|
||||
};
|
||||
const timer = window.setTimeout(() => {
|
||||
askRouteWays(tripId, route, withGrades, controller.signal, (ways) =>
|
||||
settle({ status: 'ready', ways, grades: { status: 'loading', ...NO_GRADES } })
|
||||
)
|
||||
.then(settle)
|
||||
// Whatever went wrong, the card says it failed and offers to ask again rather than wait for ever.
|
||||
.catch(() => settle({ status: 'failed', ways: null, grades: null }));
|
||||
}, SETTLE_MS);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [asking, attempt, route, tripId, withGrades]);
|
||||
|
||||
// The highlight belongs to the route it was drawn on.
|
||||
useEffect(() => () => useTourRouteHighlightStore.getState().clear(), []);
|
||||
|
||||
if (!active || !route || route.length < 2) return { ...IDLE, retry };
|
||||
if (offline) return { status: 'offline', ways: null, grades: null, retry };
|
||||
const current = answer?.route === route && answer.withGrades === withGrades && answer.attempt === attempt;
|
||||
return current ? { ...answer, retry } : { status: 'loading', ways: null, grades: null, retry };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TourMaxHikingDifficulty } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { freeLegBetween } from '../planner/tourRouteActions';
|
||||
import { routeWalkingTour } from '../planner/tourRouting';
|
||||
import { isTourRoute } from '../planner/tourRouteOutcome';
|
||||
import { routeBikeTour, routeWalkingTour } from '../planner/tourRouting';
|
||||
import { paddleFieldsOf } from '../planner/usePaddleFields';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
|
||||
@@ -41,6 +42,8 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
const difficulty = (tour?.max_hiking_difficulty ?? DEFAULT_DIFFICULTY) as TourMaxHikingDifficulty;
|
||||
// A paddle import is planned as a paddle tour is: every leg drawn by hand, no router asked.
|
||||
const paddle = tour?.tour_type === 'kayak';
|
||||
// BIK-01: a bike import is planned for its profile.
|
||||
const bikeProfile = tour?.tour_type === 'bike' ? (tour.bike_profile ?? 'touring') : null;
|
||||
|
||||
useEffect(() => () => abort.current?.abort(), []);
|
||||
|
||||
@@ -63,9 +66,11 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
|
||||
}))
|
||||
: points;
|
||||
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
|
||||
const routed = bikeProfile
|
||||
? await routeBikeTour(legs, bikeProfile, controller.signal)
|
||||
: await routeWalkingTour(legs, controller.signal, difficulty);
|
||||
if (controller.signal.aborted) return;
|
||||
if (!routed) {
|
||||
if (!isTourRoute(routed)) {
|
||||
setState({ ...base, status: 'failed', error: 'routing' });
|
||||
return;
|
||||
}
|
||||
@@ -76,7 +81,7 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
line: routed.coordinates,
|
||||
comparison: compareRoutes(trackPoints, suggestion),
|
||||
});
|
||||
}, [difficulty, paddle, tour, track]);
|
||||
}, [bikeProfile, difficulty, paddle, tour, track]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abort.current?.abort();
|
||||
@@ -99,12 +104,14 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
points: current.points,
|
||||
sourcePlaceId: tour.place_id,
|
||||
inPlace: how === 'replace',
|
||||
revision: tour.revision,
|
||||
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
|
||||
...(bikeProfile ? { tourType: 'bike' as const, bikeProfile } : {}),
|
||||
});
|
||||
if (started) setState(IDLE);
|
||||
return started;
|
||||
},
|
||||
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
|
||||
[bikeProfile, current.points, current.status, difficulty, paddle, startDerivedTour, tour]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared';
|
||||
import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { OFM_POSITRON } from '../../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { MapViewAuto } from '../../Map/MapViewAuto';
|
||||
import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../../shared/DialogShell';
|
||||
import { TourDayRowPill } from '../TourDayRowFacts';
|
||||
import { RailFacts, TourNotice, type TourMetricField } from '../tourParts';
|
||||
import { plannedTotalText, tourPlannedTimes } from '../tourPresentation';
|
||||
import { type TourLibrary } from './useTourLibrary';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
type Unit = Parameters<typeof formatDistance>[1];
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */
|
||||
function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) {
|
||||
const times = tourPlannedTimes(tour);
|
||||
const total = plannedTotalText(times, t);
|
||||
const badge = plannedTotalText(times, t, true);
|
||||
return [
|
||||
total != null &&
|
||||
badge != null && {
|
||||
key: 'time',
|
||||
icon: Clock,
|
||||
value: badge,
|
||||
label: `${t('tours.planner.plannedTotalDuration')}: ${total}`,
|
||||
},
|
||||
tour.distance != null && {
|
||||
key: 'distance',
|
||||
icon: Ruler,
|
||||
value: formatDistance(tour.distance, unit),
|
||||
label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`,
|
||||
},
|
||||
tour.elevation_gain != null &&
|
||||
tour.tour_type !== 'kayak' && {
|
||||
key: 'ascent',
|
||||
icon: ArrowUp,
|
||||
value: formatElevation(tour.elevation_gain, unit),
|
||||
label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`,
|
||||
},
|
||||
].filter((badge) => !!badge);
|
||||
}
|
||||
|
||||
/** One tour of the list: its name, the trip it is in, its measures. */
|
||||
function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChoose}
|
||||
aria-pressed={selected}
|
||||
data-testid={`tour-library-row-${tour.place_id}`}
|
||||
className={`flex w-full min-w-0 flex-col gap-1 rounded-[10px] px-2.5 py-2 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{tour.name}
|
||||
</span>
|
||||
<span className="flex-none text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-content-muted" style={fs(11, 'body')}>
|
||||
{tour.trip.title}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1.5">
|
||||
{measureBadges(tour, unit, t).map((badge) => (
|
||||
<TourDayRowPill
|
||||
key={badge.key}
|
||||
icon={badge.icon}
|
||||
value={badge.value}
|
||||
label={badge.label}
|
||||
testId={`tour-library-${badge.key}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** A line of the list's state: loading, offline, failed with a retry, empty, or cut short. */
|
||||
function ListState({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const { listStatus, list } = library;
|
||||
if (listStatus === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.library.offline')}</TourNotice>;
|
||||
if (listStatus === 'error')
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{t('tours.library.loadFailed')}
|
||||
<button type="button" onClick={library.retry} className="font-semibold underline underline-offset-2">
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
if (listStatus === 'loading' && !list)
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 py-4 text-content-faint" style={fs(12, 'body')}>
|
||||
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
if (list && list.tours.length === 0)
|
||||
return (
|
||||
<p className="m-0 py-4 text-center text-content-muted" style={fs(12.5, 'body')}>
|
||||
{list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')}
|
||||
</p>
|
||||
);
|
||||
if (list?.truncated) return <TourNotice>{t('tours.library.truncated')}</TourNotice>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The search box and the two filters over the list. */
|
||||
function ListFilters({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const searchId = useId();
|
||||
const trips = library.list?.trips ?? [];
|
||||
const tripOptions = [
|
||||
{ value: ALL, label: t('tours.library.allTrips') },
|
||||
...trips.map((trip) => ({ value: String(trip.id), label: trip.title, hint: String(trip.tour_count) })),
|
||||
];
|
||||
const typeOptions = [
|
||||
{ value: ALL, label: t('tours.library.allTypes') },
|
||||
...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })),
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 rounded-[10px] border border-edge bg-surface-input px-2.5 py-2">
|
||||
<Search size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
<label htmlFor={searchId} className="sr-only">
|
||||
{t('tours.library.search')}
|
||||
</label>
|
||||
<input
|
||||
id={searchId}
|
||||
autoFocus
|
||||
value={library.filters.query}
|
||||
onChange={(event) => library.filter({ query: event.target.value })}
|
||||
placeholder={t('tours.library.search')}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
|
||||
style={fs(12.5, 'body')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.library.trip')}
|
||||
value={library.filters.tripFilter === null ? ALL : String(library.filters.tripFilter)}
|
||||
options={tripOptions}
|
||||
onChange={(value) => library.filter({ tripFilter: value === ALL ? null : Number(value) })}
|
||||
/>
|
||||
<PillSelect
|
||||
quiet
|
||||
label={t('tours.filter.type')}
|
||||
value={library.filters.typeFilter ?? ALL}
|
||||
options={typeOptions}
|
||||
onChange={(value) => library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The facts of the chosen tour as the preview shows them, each only where the tour has it. */
|
||||
function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] {
|
||||
const total = plannedTotalText(tourPlannedTimes(tour), t);
|
||||
const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null);
|
||||
return [
|
||||
fact(true, t('tours.library.fromTrip'), () => tour.trip.title),
|
||||
fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)),
|
||||
fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)),
|
||||
fact(total != null, t('tours.planner.plannedTotalDuration'), () => total ?? ''),
|
||||
fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () =>
|
||||
formatElevation(tour.elevation_gain ?? 0, unit)
|
||||
),
|
||||
fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () =>
|
||||
formatElevation(tour.elevation_loss ?? 0, unit)
|
||||
),
|
||||
fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)),
|
||||
].filter((field): field is TourMetricField => field !== null);
|
||||
}
|
||||
|
||||
/** The chosen tour: on a map, its facts, and the name the copy gets. */
|
||||
function Preview({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const nameId = useId();
|
||||
const line = library.preview?.line;
|
||||
const route = useMemo(
|
||||
// The client reads the contract's tuples loosely; the points are spelled out as pairs here.
|
||||
() =>
|
||||
line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [],
|
||||
[line]
|
||||
);
|
||||
const { selected } = library;
|
||||
if (!selected)
|
||||
return (
|
||||
<div className="grid h-full min-h-[240px] place-items-center rounded-[14px] border border-dashed border-edge px-6 text-center">
|
||||
<p className="m-0 max-w-[260px] text-content-muted" style={fs(12.5, 'body')}>
|
||||
{t('tours.library.chooseHint')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3" data-testid="tour-library-preview">
|
||||
<div className="relative h-[240px] flex-none overflow-hidden rounded-[14px] border border-edge-faint bg-surface-secondary">
|
||||
{route.length > 0 && (
|
||||
<MapViewAuto
|
||||
places={[]}
|
||||
route={route}
|
||||
tileUrl={tileUrl}
|
||||
focusPoints={route[0]}
|
||||
focusKey={String(selected.place_id)}
|
||||
followSelection={false}
|
||||
/>
|
||||
)}
|
||||
{library.previewStatus === 'loading' && (
|
||||
<span className="absolute inset-0 grid place-items-center text-content-faint">
|
||||
<Loader2 size={18} strokeWidth={2.2} className="animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
{(library.previewStatus === 'error' || library.previewStatus === 'offline') && (
|
||||
<span
|
||||
className="absolute inset-0 grid place-items-center px-6 text-center text-content-muted"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<RailFacts facts={previewFacts(selected, library.preview?.waypoint_count ?? 0, unit, t)} />
|
||||
<EditorField label={t('tours.library.nameHere')} htmlFor={nameId} hint={t('tours.library.independent')}>
|
||||
<input
|
||||
id={nameId}
|
||||
className={INPUT}
|
||||
value={library.name}
|
||||
maxLength={255}
|
||||
onChange={(event) => library.setName(event.target.value)}
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: the dialog to take a tour of another trip over into this one. The
|
||||
* list on the left, the chosen tour on the right; the copy is made only by the
|
||||
* button in its foot.
|
||||
*/
|
||||
/** LIB-02: the dialog that takes a tour of another trip into this one, opened from "Add tour". */
|
||||
export function TourLibraryDialog({ library }: { library: TourLibrary }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
if (!library.open) return null;
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={library.close}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
align="top"
|
||||
blocked={library.taking}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<Library size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={library.close}
|
||||
eyebrow={t('tours.library.eyebrow')}
|
||||
title={t('tours.library.title')}
|
||||
sub={t('tours.library.sub')}
|
||||
subWraps
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={library.close} disabled={library.taking}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
data-testid="tour-library-confirm"
|
||||
icon={
|
||||
library.taking ? (
|
||||
<Loader2 size={14} strokeWidth={2} className="animate-spin" />
|
||||
) : (
|
||||
<CopyPlus size={14} strokeWidth={2} />
|
||||
)
|
||||
}
|
||||
disabled={!library.canConfirm}
|
||||
onClick={() => void library.confirm()}
|
||||
>
|
||||
{t('tours.library.confirm')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div className="grid min-h-0 grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
|
||||
<section aria-label={t('tours.library.title')} className="flex min-w-0 flex-col gap-3">
|
||||
<ListFilters library={library} />
|
||||
<ListState library={library} />
|
||||
{library.list && library.list.tours.length > 0 && (
|
||||
<ul
|
||||
className={`m-0 flex max-h-[420px] list-none flex-col gap-0.5 overflow-y-auto py-0 pe-2 ps-0 transition-opacity [scrollbar-gutter:stable] ${library.listStatus === 'loading' ? 'opacity-60' : ''}`}
|
||||
data-testid="tour-library-list"
|
||||
>
|
||||
{library.list.tours.map((tour) => (
|
||||
<LibraryRow
|
||||
key={tour.place_id}
|
||||
tour={tour}
|
||||
selected={library.selected?.place_id === tour.place_id}
|
||||
onChoose={() => library.choose(tour)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<Preview library={library} />
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourCreateResponse, TourLibraryItem, TourLibraryResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
|
||||
import { TourLibraryDialog } from './TourLibraryDialog';
|
||||
import { useTourLibrary } from './useTourLibrary';
|
||||
|
||||
// FE-TOUR-LIBRARY-001 to FE-TOUR-LIBRARY-009
|
||||
|
||||
vi.mock('../../Map/MapViewAuto', () => ({
|
||||
MapViewAuto: ({ route }: { route: Array<Array<[number, number]>> }) => (
|
||||
<div data-testid="library-map" data-points={route[0]?.length ?? 0} />
|
||||
),
|
||||
}));
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
function item(over: Partial<TourLibraryItem> & Pick<TourLibraryItem, 'place_id' | 'name'>): TourLibraryItem {
|
||||
return {
|
||||
description: null,
|
||||
tour_type: 'hike',
|
||||
distance: 8.2,
|
||||
elevation_gain: 600,
|
||||
elevation_loss: 590,
|
||||
duration: 180,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: null,
|
||||
max_hiking_difficulty: 2,
|
||||
has_waypoints: true,
|
||||
origin: 'trek',
|
||||
trip: { id: 2, title: 'Alps' },
|
||||
...over,
|
||||
} as TourLibraryItem;
|
||||
}
|
||||
|
||||
const RIDGE = item({ place_id: 11, name: 'Ridge' });
|
||||
const LAKE = item({
|
||||
place_id: 12,
|
||||
name: 'Lake loop',
|
||||
tour_type: 'kayak',
|
||||
distance: null,
|
||||
elevation_gain: 40,
|
||||
trip: { id: 3, title: 'Lakes' },
|
||||
});
|
||||
const LIST: TourLibraryResponse = {
|
||||
tours: [RIDGE, LAKE],
|
||||
trips: [
|
||||
{ id: 3, title: 'Lakes', tour_count: 1 },
|
||||
{ id: 2, title: 'Alps', tour_count: 1 },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
let list: ReturnType<typeof vi.spyOn>;
|
||||
let detail: ReturnType<typeof vi.spyOn>;
|
||||
let takeOver: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
toast.success.mockReset();
|
||||
toast.error.mockReset();
|
||||
list = vi.spyOn(tourLibraryRepo, 'list').mockResolvedValue(LIST);
|
||||
detail = vi.spyOn(tourLibraryRepo, 'detail').mockResolvedValue({
|
||||
tour: RIDGE,
|
||||
line: [
|
||||
[47, 11],
|
||||
[47.1, 11.1],
|
||||
],
|
||||
waypoint_count: 4,
|
||||
});
|
||||
takeOver = vi.spyOn(tourLibraryRepo, 'takeOver').mockResolvedValue({
|
||||
tour: { place_id: 99, name: 'My ridge' },
|
||||
waypoints: [],
|
||||
} as unknown as TourCreateResponse);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('useTourLibrary', () => {
|
||||
it('FE-TOUR-LIBRARY-001: asks nothing until opened, then lists the other trips’ tours, the asking trip left out', async () => {
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
|
||||
expect(list).not.toHaveBeenCalled();
|
||||
act(() => result.current.show());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
|
||||
expect(list).toHaveBeenCalledWith({ exclude_trip_id: 5 }, expect.any(AbortSignal));
|
||||
expect(result.current.list?.tours).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-002: narrows by name after a pause, and by trip and type at once', async () => {
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
|
||||
act(() => result.current.show());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
|
||||
act(() => result.current.filter({ query: ' rid ', tripFilter: 2, typeFilter: 'hike' }));
|
||||
expect(result.current.listStatus).toBe('loading');
|
||||
await waitFor(() =>
|
||||
expect(list).toHaveBeenLastCalledWith(
|
||||
{ q: 'rid', trip_id: 2, type: 'hike', exclude_trip_id: 5 },
|
||||
expect.any(AbortSignal)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-003: says when it is offline or failed, and asks again on retry', async () => {
|
||||
list.mockRejectedValueOnce(new OnlineOnlyError()).mockRejectedValueOnce(new Error('500'));
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver: vi.fn() }));
|
||||
act(() => result.current.show());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('offline'));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('error'));
|
||||
act(() => result.current.retry());
|
||||
await waitFor(() => expect(result.current.listStatus).toBe('ready'));
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-004: previews the tour chosen and takes it over under the name given, then hands it in', async () => {
|
||||
const onTakenOver = vi.fn();
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.choose(RIDGE));
|
||||
expect(result.current.name).toBe('Ridge');
|
||||
await waitFor(() => expect(result.current.previewStatus).toBe('ready'));
|
||||
expect(detail).toHaveBeenCalledWith(11, expect.any(AbortSignal));
|
||||
act(() => result.current.setName(' My ridge '));
|
||||
await act(() => result.current.confirm());
|
||||
expect(takeOver).toHaveBeenCalledWith(5, { source_trip_id: 2, source_place_id: 11, name: 'My ridge' });
|
||||
expect(onTakenOver).toHaveBeenCalledWith(expect.objectContaining({ place_id: 99 }));
|
||||
expect(result.current.open).toBe(false);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-005: nothing is taken over without a name, and a failed take-over keeps the dialog', async () => {
|
||||
takeOver.mockRejectedValueOnce(new Error('500'));
|
||||
const onTakenOver = vi.fn();
|
||||
const { result } = renderHook(() => useTourLibrary({ tripId: 5, onTakenOver }));
|
||||
act(() => result.current.show());
|
||||
act(() => result.current.choose(RIDGE));
|
||||
act(() => result.current.setName(' '));
|
||||
expect(result.current.canConfirm).toBe(false);
|
||||
await act(() => result.current.confirm());
|
||||
expect(takeOver).not.toHaveBeenCalled();
|
||||
act(() => result.current.setName('Ridge'));
|
||||
await act(() => result.current.confirm());
|
||||
expect(onTakenOver).not.toHaveBeenCalled();
|
||||
expect(result.current.open).toBe(true);
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourLibraryDialog', () => {
|
||||
function Harness({ onTakenOver = vi.fn() }: { onTakenOver?: () => void }) {
|
||||
const library = useTourLibrary({ tripId: 5, onTakenOver });
|
||||
// The way in is the rail's Add tour menu (TourAddMenu); here a plain button stands in for it.
|
||||
return (
|
||||
<>
|
||||
<button type="button" data-testid="tour-rail-library" onClick={library.show}>
|
||||
open
|
||||
</button>
|
||||
<TourLibraryDialog library={library} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('FE-TOUR-LIBRARY-006: lists the tours with their trip and measures, a missing one left out', async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
const ridge = await screen.findByTestId('tour-library-row-11');
|
||||
expect(ridge).toHaveTextContent('Ridge');
|
||||
expect(ridge).toHaveTextContent('Alps');
|
||||
expect(within(ridge).getByTestId('tour-library-distance')).toHaveTextContent('8.2 km');
|
||||
const lake = screen.getByTestId('tour-library-row-12');
|
||||
// No distance known, and a paddle tour's climb says nothing: neither is shown.
|
||||
expect(within(lake).queryByTestId('tour-library-distance')).toBeNull();
|
||||
expect(within(lake).queryByTestId('tour-library-ascent')).toBeNull();
|
||||
expect(screen.getByTestId('tour-library-confirm')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-007: choosing a tour shows it on a map with its facts and the name field; the button takes it over', async () => {
|
||||
const onTakenOver = vi.fn();
|
||||
render(<Harness onTakenOver={onTakenOver} />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
fireEvent.click(await screen.findByTestId('tour-library-row-11'));
|
||||
const preview = screen.getByTestId('tour-library-preview');
|
||||
expect(await within(preview).findByTestId('library-map')).toHaveAttribute('data-points', '2');
|
||||
expect(preview).toHaveTextContent('Alps');
|
||||
expect(preview).toHaveTextContent('4');
|
||||
expect(within(preview).getByDisplayValue('Ridge')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('tour-library-confirm'));
|
||||
await waitFor(() => expect(onTakenOver).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('tour-library-preview')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-009: plans a city walk with its stays, at least that much while a stay is not entered (CW-02)', async () => {
|
||||
const walk = item({
|
||||
place_id: 13,
|
||||
name: 'Old town',
|
||||
tour_type: 'walk',
|
||||
duration: 45,
|
||||
break_additional_minutes: 15,
|
||||
station_count: 3,
|
||||
station_stay_minutes: 90,
|
||||
station_stays_unknown: 1,
|
||||
});
|
||||
list.mockResolvedValue({ ...LIST, tours: [walk] });
|
||||
detail.mockResolvedValue({ tour: walk, line: [], waypoint_count: 3 });
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
const row = await screen.findByTestId('tour-library-row-13');
|
||||
const time = within(row).getByTestId('tour-library-time');
|
||||
expect(time).toHaveTextContent('≥ 2 h 15 min');
|
||||
expect(time).toHaveAccessibleName('Planned total duration: at least 2 h 15 min');
|
||||
fireEvent.click(row);
|
||||
expect(
|
||||
await within(screen.getByTestId('tour-library-preview')).findByText('at least 2 h 15 min')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-LIBRARY-008: says when the other trips have no tours', async () => {
|
||||
list.mockResolvedValue({ tours: [], trips: [], truncated: false });
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByTestId('tour-rail-library'));
|
||||
expect(await screen.findByText('Your other trips have no tours yet.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
TourLibraryDetailResponse,
|
||||
TourLibraryItem,
|
||||
TourLibraryResponse,
|
||||
TourListItem,
|
||||
TourTypeKey,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourLibraryRepo } from '../../../repo/tourLibraryRepo';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
|
||||
/** How long typing rests before the list is asked again. */
|
||||
const SEARCH_PAUSE_MS = 250;
|
||||
|
||||
type LoadStatus = 'loading' | 'ready' | 'error' | 'offline';
|
||||
|
||||
/** An answer with the request it belongs to, so a newer request reads as loading, never as an older answer. */
|
||||
interface Answer<T> {
|
||||
key: string;
|
||||
status: Exclude<LoadStatus, 'loading'>;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
const failure = (err: unknown): 'error' | 'offline' => (err instanceof OnlineOnlyError ? 'offline' : 'error');
|
||||
|
||||
interface ListFilters {
|
||||
query: string;
|
||||
tripFilter: number | null;
|
||||
typeFilter: TourTypeKey | null;
|
||||
}
|
||||
|
||||
/** The list while the dialog is open: asked at once on opening, then after a pause in typing; the asking trip left out. */
|
||||
function useLibraryList(
|
||||
open: boolean,
|
||||
tripId: number,
|
||||
{ query, tripFilter, typeFilter }: ListFilters,
|
||||
attempt: number
|
||||
) {
|
||||
const [answer, setAnswer] = useState<Answer<TourLibraryResponse> | null>(null);
|
||||
const askedRef = useRef(false);
|
||||
const q = query.trim();
|
||||
const key = JSON.stringify([q, tripFilter, typeFilter, attempt]);
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
askedRef.current = false;
|
||||
return undefined;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const ask = () =>
|
||||
tourLibraryRepo
|
||||
.list(
|
||||
{
|
||||
q: q || undefined,
|
||||
trip_id: tripFilter ?? undefined,
|
||||
type: typeFilter ?? undefined,
|
||||
exclude_trip_id: tripId,
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
.then((data) => setAnswer({ key, status: 'ready', data }))
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
|
||||
});
|
||||
const timer = window.setTimeout(() => void ask(), askedRef.current ? SEARCH_PAUSE_MS : 0);
|
||||
askedRef.current = true;
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, key, q, tripFilter, typeFilter, tripId]);
|
||||
return {
|
||||
status: (answer?.key === key ? answer.status : 'loading') as LoadStatus,
|
||||
/** The last list that came in, kept while a newer one loads so the rows do not flicker away. */
|
||||
data: answer?.data ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The preview of the tour chosen, read anew for each choice. */
|
||||
function useLibraryPreview(open: boolean, selected: TourLibraryItem | null, attempt: number) {
|
||||
const [answer, setAnswer] = useState<Answer<TourLibraryDetailResponse> | null>(null);
|
||||
const placeId = selected?.place_id ?? null;
|
||||
const key = `${placeId ?? ''}:${attempt}`;
|
||||
useEffect(() => {
|
||||
if (!open || placeId === null) return undefined;
|
||||
const controller = new AbortController();
|
||||
tourLibraryRepo
|
||||
.detail(placeId, controller.signal)
|
||||
.then((data) => setAnswer({ key, status: 'ready', data }))
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setAnswer({ key, status: failure(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [open, placeId, key]);
|
||||
const status: LoadStatus | null = placeId === null ? null : answer?.key === key ? answer.status : 'loading';
|
||||
return { status, data: status === 'ready' ? (answer?.data ?? null) : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: taking a tour of another trip over into this one. The dialog lists
|
||||
* the tours of the user's other trips, searched by name and filtered by trip
|
||||
* and type, previews the one chosen, and takes it over only on an explicit
|
||||
* confirm, under the name in the field. Nothing is written before that, and
|
||||
* closing writes nothing. The new tour then comes into the trip through
|
||||
* `onTakenOver`, which also opens it.
|
||||
*/
|
||||
export function useTourLibrary({
|
||||
tripId,
|
||||
onTakenOver,
|
||||
}: {
|
||||
tripId: number;
|
||||
onTakenOver: (tour: TourListItem) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>({ query: '', tripFilter: null, typeFilter: null });
|
||||
const [selected, setSelected] = useState<TourLibraryItem | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [taking, setTaking] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const list = useLibraryList(open, tripId, filters, attempt);
|
||||
const preview = useLibraryPreview(open, selected, attempt);
|
||||
|
||||
const reset = () => {
|
||||
setOpen(false);
|
||||
setSelected(null);
|
||||
setFilters({ query: '', tripFilter: null, typeFilter: null });
|
||||
};
|
||||
const close = () => {
|
||||
if (!taking) reset();
|
||||
};
|
||||
const choose = (tour: TourLibraryItem) => {
|
||||
setSelected(tour);
|
||||
setName(tour.name);
|
||||
};
|
||||
const filter = (change: Partial<ListFilters>) => setFilters((current) => ({ ...current, ...change }));
|
||||
|
||||
const confirm = async () => {
|
||||
const chosenName = name.trim();
|
||||
if (!selected || !chosenName || taking) return;
|
||||
setTaking(true);
|
||||
try {
|
||||
const result = await tourLibraryRepo.takeOver(tripId, {
|
||||
source_trip_id: selected.trip.id,
|
||||
source_place_id: selected.place_id,
|
||||
name: chosenName,
|
||||
});
|
||||
setTaking(false);
|
||||
reset();
|
||||
await onTakenOver(result.tour);
|
||||
toast.success(t('tours.library.taken', { name: result.tour.name }));
|
||||
} catch (err: unknown) {
|
||||
setTaking(false);
|
||||
toast.error(t(failure(err) === 'offline' ? 'tours.library.offline' : 'tours.library.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
open,
|
||||
show: useCallback(() => setOpen(true), []),
|
||||
close,
|
||||
filters,
|
||||
filter,
|
||||
listStatus: list.status,
|
||||
list: list.data,
|
||||
selected,
|
||||
choose,
|
||||
previewStatus: preview.status,
|
||||
preview: preview.data,
|
||||
name,
|
||||
setName,
|
||||
taking,
|
||||
canConfirm: !!selected && name.trim().length > 0 && !taking,
|
||||
confirm,
|
||||
retry: () => setAttempt((value) => value + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export type TourLibrary = ReturnType<typeof useTourLibrary>;
|
||||
@@ -367,21 +367,25 @@ export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
|
||||
const notice = noticeOf(checklist);
|
||||
const { summary, check } = checklist;
|
||||
return (
|
||||
<RailCard
|
||||
icon={<ListChecks size={14} strokeWidth={2} />}
|
||||
title={t('tours.paddle.check.title')}
|
||||
aside={summary ? <PaddleCheckPill check={summary} /> : null}
|
||||
>
|
||||
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
|
||||
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
|
||||
{notice === 'new' && <PaddleObstacleNotice />}
|
||||
{summary && <PaddleCheckDataLine check={summary} />}
|
||||
{check && <CheckList checklist={checklist} check={check} />}
|
||||
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.check.notClearance')}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<RailCard
|
||||
icon={<ListChecks size={14} strokeWidth={2} />}
|
||||
title={t('tours.paddle.check.title')}
|
||||
foldId="paddle-check"
|
||||
aside={summary ? <PaddleCheckPill check={summary} /> : null}
|
||||
>
|
||||
<div data-testid="paddle-check-card" className="flex flex-col gap-2.5">
|
||||
{notice && <CheckNoticeLine notice={notice} evaluatedAt={check?.evaluated_at ?? null} />}
|
||||
{notice === 'new' && <PaddleObstacleNotice />}
|
||||
{summary && <PaddleCheckDataLine check={summary} />}
|
||||
{check && <CheckList checklist={checklist} check={check} />}
|
||||
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.check.notClearance')}
|
||||
</p>
|
||||
</div>
|
||||
</RailCard>
|
||||
{/* Outside the card's body: an obstacle picked on the map opens its entry with the card folded too. */}
|
||||
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
|
||||
</RailCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PaddleLakeStyle } from '@trek/shared';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { LABEL, Segmented } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
const STYLES: readonly PaddleLakeStyle[] = ['direct', 'shore'];
|
||||
|
||||
/**
|
||||
* PAD-11: how the route crosses a lake. Straight over is the rule; along the
|
||||
* shore is for wind, when a capsize near the bank is a short swim. The wind
|
||||
* card suggests it on a windy day; here it is chosen.
|
||||
*/
|
||||
export function PaddleLakeStyleField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: PaddleLakeStyle;
|
||||
onChange: (style: PaddleLakeStyle) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div data-testid="tour-planner-lake-style" className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.lakeStyle')}</span>
|
||||
<Segmented<PaddleLakeStyle>
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={t('tours.paddle.lakeStyle')}
|
||||
fill
|
||||
options={STYLES.map((style) => ({ value: style, label: t(`tours.paddle.lakeStyle.${style}`) }))}
|
||||
/>
|
||||
<span className="mt-1 block text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tours.paddle.lakeStyle.${value}Hint`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ const PAINT: PaddlePaint = {
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
rapids: '#d97706',
|
||||
gap: '#dc2626',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
@@ -12,6 +12,35 @@ import {
|
||||
} from './paddleMapLayer';
|
||||
|
||||
const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const;
|
||||
|
||||
/**
|
||||
* The pane the paddle lines are drawn in: under the overlay pane the tour's
|
||||
* route is in, so a rule that covers a whole river never hides the line
|
||||
* planned along it. The markers stay in the marker pane above, where they are
|
||||
* picked.
|
||||
*/
|
||||
const LINE_PANE = 'trek-paddle-lines';
|
||||
|
||||
/** Makes the line pane on a map that has none yet; undefined where the renderer has no panes, which then stacks by order. */
|
||||
function ensureLinePane(map: L.Map): string | undefined {
|
||||
if (typeof map.getPane !== 'function' || typeof map.createPane !== 'function') return undefined;
|
||||
if (!map.getPane(LINE_PANE)) {
|
||||
const pane = map.createPane(LINE_PANE);
|
||||
pane.style.zIndex = '397';
|
||||
// The app's stylesheet levels every pane (`.leaflet-pane { z-index: 0 !important }`), so
|
||||
// the order in the map pane decides: right before the overlay pane, under the route.
|
||||
const overlay = map.getPane('overlayPane');
|
||||
if (overlay?.parentElement) overlay.parentElement.insertBefore(pane, overlay);
|
||||
}
|
||||
return LINE_PANE;
|
||||
}
|
||||
|
||||
/** The line pane, made once per map before the first line is drawn into it. */
|
||||
function useLinePane(): string | undefined {
|
||||
const map = useMap();
|
||||
const [pane] = useState(() => ensureLinePane(map));
|
||||
return pane;
|
||||
}
|
||||
const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
|
||||
|
||||
function iconOf(item: PaddleMapItem, selected: boolean): L.DivIcon {
|
||||
@@ -36,6 +65,7 @@ function useLabel() {
|
||||
/**
|
||||
* The paddle obstacles on the Leaflet map (PAD-05B): each line on a white
|
||||
* casing in its category's colour, dashed while it is only near the route,
|
||||
* and under the tour's route so it never hides it,
|
||||
* and a round marker with its glyph; the quieter, the less it bears on the
|
||||
* route, and the objects only in the area wait for a closer zoom. A click
|
||||
* picks the object and goes no further, so it never also sets a waypoint
|
||||
@@ -44,6 +74,7 @@ function useLabel() {
|
||||
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
|
||||
const label = useLabel();
|
||||
const map = useMap();
|
||||
const linePane = useLinePane();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
|
||||
const selectedRef = layer?.selectedRef ?? null;
|
||||
@@ -75,6 +106,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
|
||||
<Polyline
|
||||
key={`${item.feature.ref}-casing`}
|
||||
positions={item.line}
|
||||
pane={linePane}
|
||||
pathOptions={{
|
||||
color: item.disc,
|
||||
weight: LINE_WEIGHT[item.relation] + 3,
|
||||
@@ -89,6 +121,7 @@ export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
|
||||
<Polyline
|
||||
key={`${item.feature.ref}-line`}
|
||||
positions={item.line}
|
||||
pane={linePane}
|
||||
bubblingMouseEvents={false}
|
||||
pathOptions={{
|
||||
color: item.color,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { PaddleRouteLeg } from '@trek/shared';
|
||||
import { AlertTriangle, RotateCcw, Sparkles, Spline, Waves } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import type { PaddleLegAnswer } from '../../../repo/paddleRouteRepo';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import type { PaddleLegFailure } from '../planner/paddleRouting';
|
||||
import { lineLengthM } from '../planner/tourFixedLegs';
|
||||
|
||||
/**
|
||||
* PAD-11: what the waypoint list says about a leg the router planned or
|
||||
* could not plan, and about a point it set itself.
|
||||
*/
|
||||
|
||||
/** A point the route planner set at a portage or a lock it found; touched by the paddler, it becomes theirs. */
|
||||
export function AutoPointPill() {
|
||||
const { t } = useTranslation();
|
||||
// Only an icon: the row keeps its room for the place name, the tooltip says the rest.
|
||||
return (
|
||||
<Tooltip label={`${t('tours.paddle.auto')}: ${t('tours.paddle.autoHint')}`}>
|
||||
<span
|
||||
data-testid="tour-planner-auto-point"
|
||||
aria-label={t('tours.paddle.auto')}
|
||||
className="grid h-[18px] w-[18px] flex-none place-items-center rounded-full bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<Sparkles size={10} strokeWidth={2.2} aria-hidden="true" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type Note = { key: string; label: string; tone: 'rapids' | 'gap'; length: number };
|
||||
|
||||
/** The stretches of a routed leg worth a look: whitewater, a boat pass, a gap in the data. */
|
||||
function notesOf(
|
||||
leg: Extract<PaddleRouteLeg, { status: 'ok' }>,
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
): Note[] {
|
||||
return leg.segments.flatMap((segment, index): Note[] => {
|
||||
const length = lineLengthM(leg.geometry.slice(segment.from, segment.to + 1) as [number, number][]);
|
||||
if (segment.kind === 'rapids' && segment.grade != null) {
|
||||
return [
|
||||
{ key: `r${index}`, label: t('tours.paddle.note.rapids', { grade: segment.grade }), tone: 'rapids', length },
|
||||
];
|
||||
}
|
||||
if (segment.kind === 'pass')
|
||||
return [{ key: `p${index}`, label: segment.name ?? t('tours.paddle.note.pass'), tone: 'rapids', length }];
|
||||
if (segment.kind === 'gap') return [{ key: `g${index}`, label: t('tours.paddle.note.gap'), tone: 'gap', length }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Chips under a routed leg for what the paddler should look at on it; nothing when the leg is plain water. */
|
||||
export function PaddleLegNotes({ leg }: { leg: PaddleLegAnswer | null | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
if (!leg || leg.status !== 'ok') return null;
|
||||
const notes = notesOf(leg, t);
|
||||
if (notes.length === 0) return null;
|
||||
return (
|
||||
<li data-testid="tour-planner-leg-notes" className="flex min-w-0 flex-wrap gap-1 py-0.5 ps-[34px]">
|
||||
{notes.map((note) => (
|
||||
<Tooltip
|
||||
key={note.key}
|
||||
label={t(note.tone === 'gap' ? 'tours.paddle.note.gapHint' : 'tours.paddle.note.rapidsHint')}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-[22px] min-w-0 items-center gap-1 rounded-full bg-surface-card pe-1 ps-1.5 font-semibold text-content shadow-sm ring-1 ring-inset ${note.tone === 'gap' ? 'ring-[color:var(--map-paddle-gap)]' : 'ring-[color:var(--map-paddle-rapids)]'}`}
|
||||
style={fs(11)}
|
||||
>
|
||||
{note.tone === 'gap' ? (
|
||||
<AlertTriangle
|
||||
size={11}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: 'var(--map-paddle-gap)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Waves
|
||||
size={11}
|
||||
strokeWidth={2.2}
|
||||
className="flex-none"
|
||||
style={{ color: 'var(--map-paddle-rapids)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{note.label}</span>
|
||||
<span
|
||||
className="flex-none rounded-full bg-surface-secondary px-1.5 font-geist font-normal tabular-nums leading-[16px] text-content-secondary"
|
||||
style={fs(10)}
|
||||
>
|
||||
{formatDistance(note.length / 1000, unit)}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg the router could not plan: why, in plain words, and what to do about
|
||||
* it. The map draws it straight and red meanwhile, and the tour waits to be
|
||||
* saved until it is planned or drawn by hand on purpose. Where TREK could not
|
||||
* fetch the water data of the leg's region (PAD-11C), `message` says why and
|
||||
* `onLoadRetry` reads the region again.
|
||||
*/
|
||||
export function PaddleLegFailureRow({
|
||||
code,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
onRetry,
|
||||
message,
|
||||
onLoadRetry,
|
||||
}: {
|
||||
code: PaddleLegFailure;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
onRetry: () => void;
|
||||
message?: string | null;
|
||||
onLoadRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const retry = onLoadRetry ?? (code !== 'not_on_water' && code !== 'no_region' ? onRetry : null);
|
||||
const action =
|
||||
'inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40';
|
||||
return (
|
||||
<li data-testid="tour-planner-leg-failure" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div role="alert" className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-danger-soft px-2 py-1.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 font-semibold text-danger" style={fs(11.5, 'body')}>
|
||||
<AlertTriangle size={12} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="min-w-0">{message ?? t(`tours.paddle.failed.${code}`)}</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<button type="button" disabled={disabled} onClick={onDrawStraight} className={action} style={fs(10.5)}>
|
||||
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.drawStraight')}
|
||||
</button>
|
||||
{retry && (
|
||||
<button type="button" disabled={disabled} onClick={retry} className={action} style={fs(10.5)}>
|
||||
<RotateCcw size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.retry')}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// FE-PADDLE-PLACE-001 to 004: a put-in, slipway or egress set as the next waypoint from its card (PAD-05B).
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PaddleObstaclePlaceButton } from './PaddleObstaclePlaceButton';
|
||||
import { placeOf } from './paddleObstaclePlace';
|
||||
|
||||
describe('placeOf', () => {
|
||||
it('FE-PADDLE-PLACE-001: takes a point as it is, the middle of a way and the middle of an outline', () => {
|
||||
expect(placeOf({ type: 'Point', coordinates: [12.9, 53.2] })).toEqual({ lat: 53.2, lng: 12.9 });
|
||||
expect(
|
||||
placeOf({
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[12.9, 53.2],
|
||||
[12.91, 53.21],
|
||||
[12.92, 53.22],
|
||||
],
|
||||
})
|
||||
).toEqual({ lat: 53.21, lng: 12.91 });
|
||||
const middle = placeOf({
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[12, 53],
|
||||
[12.02, 53],
|
||||
[12.02, 53.02],
|
||||
[12, 53.02],
|
||||
[12, 53],
|
||||
],
|
||||
],
|
||||
});
|
||||
expect(middle?.lat).toBeCloseTo(53.01, 9);
|
||||
expect(middle?.lng).toBeCloseTo(12.01, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleObstaclePlaceButton', () => {
|
||||
it('FE-PADDLE-PLACE-002: sets a slipway as a put-in at its spot', () => {
|
||||
const onPlace = vi.fn();
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="slipway"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={onPlace}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('paddle-obstacle-place'));
|
||||
expect(onPlace).toHaveBeenCalledWith(53.2, 12.9);
|
||||
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Put in here');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-PLACE-003: names an egress as the place to get out', () => {
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="egress"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('paddle-obstacle-place')).toHaveTextContent('Take out here');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-PLACE-004: offers nothing on an obstacle', () => {
|
||||
render(
|
||||
<PaddleObstaclePlaceButton
|
||||
kind="weir"
|
||||
geometry={{ type: 'Point', coordinates: [12.9, 53.2] }}
|
||||
onPlace={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId('paddle-obstacle-place')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PaddleGeometry, PaddleObstacleKind } from '@trek/shared';
|
||||
import { MapPinPlus } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { placeOf } from './paddleObstaclePlace';
|
||||
|
||||
/** Where a paddler gets in or out: these places can become a waypoint from their card. */
|
||||
const PLACEABLE: Partial<Record<PaddleObstacleKind, 'put_in' | 'egress'>> = {
|
||||
put_in: 'put_in',
|
||||
slipway: 'put_in',
|
||||
egress: 'egress',
|
||||
};
|
||||
|
||||
/**
|
||||
* PAD-05B: a put-in, a slipway or an egress taken over as the tour's next
|
||||
* waypoint from its card on the map, so the point sits where the paddler
|
||||
* really gets in or out. Set after a portage asked for at the last point, it
|
||||
* is where the portage ends.
|
||||
*/
|
||||
export function PaddleObstaclePlaceButton({
|
||||
kind,
|
||||
geometry,
|
||||
onPlace,
|
||||
}: {
|
||||
kind: PaddleObstacleKind;
|
||||
geometry: PaddleGeometry;
|
||||
onPlace: (lat: number, lng: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const role = PLACEABLE[kind];
|
||||
const spot = role ? placeOf(geometry) : null;
|
||||
if (!role || !spot) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="paddle-obstacle-place"
|
||||
onClick={() => onPlace(spot.lat, spot.lng)}
|
||||
className="mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity hover:opacity-90"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<MapPinPlus size={14} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t(role === 'put_in' ? 'tours.paddle.placeHere.putIn' : 'tours.paddle.placeHere.egress')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// FE-PADDLE-REGION-LOAD-ROW-001 to 004: what a leg waiting for its water data shows (PAD-11C).
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PaddleRegionLoadRow } from './PaddleRegionLoadRow';
|
||||
import { isRegionLoadPending, regionLoadFailure } from './paddleRegionLoad';
|
||||
|
||||
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
|
||||
const t = (key: string, params?: Record<string, unknown>) => `${key}${params ? JSON.stringify(params) : ''}`;
|
||||
|
||||
describe('PaddleRegionLoadRow', () => {
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-001: says the data loads, how far, and that the leg follows on its own', () => {
|
||||
const onDrawStraight = vi.fn();
|
||||
render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.45 }, ahead: 0 }}
|
||||
disabled={false}
|
||||
onDrawStraight={onDrawStraight}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId('tour-planner-region-load');
|
||||
expect(row).toHaveTextContent('Loading the water data for Oberpfalz');
|
||||
expect(row).toHaveTextContent('Downloading 45%');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Draw straight/ }));
|
||||
expect(onDrawStraight).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-002: says a region waits for an admin, and why', () => {
|
||||
render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'requested', region: REF, reason: 'too_large' }}
|
||||
disabled={false}
|
||||
onDrawStraight={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId('tour-planner-region-load');
|
||||
expect(row).toHaveTextContent('has been requested');
|
||||
expect(row).toHaveTextContent('larger than TREK loads by itself');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-003: shows the queue before the read starts, and no button once the data is there', () => {
|
||||
const { unmount } = render(
|
||||
<PaddleRegionLoadRow
|
||||
state={{ state: 'loading', region: REF, progress: null, ahead: 2 }}
|
||||
disabled={false}
|
||||
onDrawStraight={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('tour-planner-region-load')).toHaveTextContent('Waiting for other regions');
|
||||
unmount();
|
||||
render(<PaddleRegionLoadRow state={{ state: 'ready', region: REF }} disabled={false} onDrawStraight={vi.fn()} />);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('region load states', () => {
|
||||
it('FE-PADDLE-REGION-LOAD-ROW-004: waits on loading and requests, and names every way the data cannot come', () => {
|
||||
const pending: PaddleRegionLoadState[] = [
|
||||
{ state: 'ready', region: REF },
|
||||
{ state: 'requested', region: REF, reason: 'ask' },
|
||||
];
|
||||
for (const state of pending) expect(isRegionLoadPending(state)).toBe(true);
|
||||
expect(isRegionLoadPending(undefined)).toBe(false);
|
||||
expect(regionLoadFailure({ state: 'declined', region: REF }, t)).toBe(
|
||||
'tours.paddle.load.declined{"region":"Oberpfalz"}'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'failed', region: REF }, t)).toContain('tours.paddle.load.failed');
|
||||
expect(regionLoadFailure({ state: 'no_water', region: REF }, t)).toContain('tours.paddle.load.noWater');
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'no_extract' }, t)).toBe(
|
||||
'tours.paddle.load.noExtract'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: null, reason: 'catalog_unavailable' }, t)).toBe(
|
||||
'tours.paddle.load.catalogUnavailable'
|
||||
);
|
||||
expect(regionLoadFailure({ state: 'unavailable', region: REF, reason: 'off' }, t)).toBeNull();
|
||||
expect(regionLoadFailure(undefined, t)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { PaddleRegionLoadState } from '@trek/shared';
|
||||
import { CloudDownload, Hourglass, Loader2, Spline } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
function headline(state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>, t: Translate) {
|
||||
const region = state.region.name;
|
||||
if (state.state === 'ready') return t('tours.paddle.load.ready', { region });
|
||||
if (state.state === 'requested') return t('tours.paddle.load.requested', { region });
|
||||
return t('tours.paddle.load.loading', { region });
|
||||
}
|
||||
|
||||
/** The phase of a load in words, with its share where it is known. */
|
||||
function phaseText(state: Extract<PaddleRegionLoadState, { state: 'loading' }>, t: Translate, language: string) {
|
||||
if (!state.progress) return state.ahead > 0 ? t('tours.paddle.load.waiting') : t('tours.paddle.load.phase.download');
|
||||
const phase = t(`tours.paddle.load.phase.${state.progress.phase}`);
|
||||
const share = state.progress.fraction;
|
||||
return share === null ? phase : `${phase} ${new Intl.NumberFormat(language, { style: 'percent' }).format(share)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11C: a leg waiting for the water data of its region, which TREK loads
|
||||
* by itself or an admin has to let through. Calm, not red: nothing is wrong,
|
||||
* the data is on its way, and the leg is planned on its own once it is there.
|
||||
* Drawing it straight stays possible for whoever does not want to wait.
|
||||
*/
|
||||
export function PaddleRegionLoadRow({
|
||||
state,
|
||||
disabled,
|
||||
onDrawStraight,
|
||||
}: {
|
||||
state: Extract<PaddleRegionLoadState, { state: 'loading' | 'ready' | 'requested' }>;
|
||||
disabled: boolean;
|
||||
onDrawStraight: () => void;
|
||||
}) {
|
||||
const { t, language } = useTranslation();
|
||||
const fraction = state.state === 'loading' ? (state.progress?.fraction ?? null) : state.state === 'ready' ? 1 : null;
|
||||
const busy = state.state !== 'requested';
|
||||
return (
|
||||
<li data-testid="tour-planner-region-load" className="flex min-w-0 py-0.5 ps-[34px]">
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full min-w-0 flex-col gap-1.5 rounded-[10px] bg-surface-secondary px-2 py-1.5 ring-1 ring-inset ring-edge-faint"
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-1.5 font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{busy ? (
|
||||
<Loader2 size={12} strokeWidth={2.2} className="mt-[2px] flex-none animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Hourglass size={12} strokeWidth={2.2} className="mt-[2px] flex-none" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0">{headline(state, t)}</span>
|
||||
</span>
|
||||
{state.state === 'loading' && (
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center justify-between gap-2 text-content-muted" style={fs(10.5, 'body')}>
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<CloudDownload size={11} strokeWidth={2.2} className="flex-none" aria-hidden="true" />
|
||||
<span className="truncate">{phaseText(state, t, language)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="relative block h-[3px] w-full overflow-hidden rounded-full bg-surface-tertiary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`absolute inset-y-0 start-0 rounded-full bg-accent transition-[width] duration-500 ${fraction === null ? 'w-1/3 animate-pulse' : ''}`}
|
||||
style={fraction === null ? undefined : { width: `${Math.max(4, Math.round(fraction * 100))}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="leading-snug text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.load.hint')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{state.state === 'requested' && state.reason !== 'ask' && (
|
||||
<span className="leading-snug text-content-muted" style={fs(10.5, 'body')}>
|
||||
{t(state.reason === 'too_large' ? 'tours.paddle.load.reason.tooLarge' : 'tours.paddle.load.reason.limit')}
|
||||
</span>
|
||||
)}
|
||||
{state.state !== 'ready' && (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onDrawStraight}
|
||||
className="inline-flex h-[22px] flex-none items-center gap-1 rounded-full bg-surface-card px-2 font-semibold text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover disabled:opacity-40"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Spline size={11} strokeWidth={2.2} aria-hidden="true" />
|
||||
{t('tours.paddle.drawStraight')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user