mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 | |||
| e725d4f0a9 | |||
| 36f1b5b7d9 | |||
| acce063dbf | |||
| d229613e0e | |||
| 5622113348 | |||
| cd2ecf897b | |||
| f5af9d1259 | |||
| cecfdf2dcc | |||
| 23b28a1f3f | |||
| f7f6b83d33 | |||
| 097c1da0af | |||
| a74a2cf9b5 | |||
| dc1ff06573 | |||
| fc631d36b6 | |||
| 6463060f67 | |||
| 593f839592 | |||
| a9c5cf6d9a | |||
| f901982903 | |||
| 8f64431ff1 | |||
| c378722d63 | |||
| bfec874d04 | |||
| a36019b7e2 | |||
| bda24ec639 | |||
| 09caf4bc3f | |||
| 9272151c07 | |||
| 0105891355 | |||
| a7f5a114e6 | |||
| 2a4b71b3d9 | |||
| d46b6fb646 | |||
| 4a64fc9d21 | |||
| 5ee8d450a9 | |||
| de86e6383a | |||
| 14b917ee97 | |||
| 7fffd4300f | |||
| fa8272c522 | |||
| c40d00b830 | |||
| 86976aef10 | |||
| c246877f30 | |||
| 1e2ba0f842 | |||
| 64e413330e | |||
| 2e3fd4fc4c | |||
| 1c38d34699 | |||
| ccfb610aab | |||
| 03d6457bde | |||
| 89ad4ec56a | |||
| 2c3b20bb57 | |||
| d705bd9dd0 | |||
| 704621d0c2 | |||
| 9494521ac7 | |||
| 33b006014b |
@@ -8,7 +8,7 @@
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 560,
|
||||
"react-hooks/refs": 559,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1916,
|
||||
"src/api/client.ts": 1910,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"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/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2371,
|
||||
"src/components/Map/MapViewGL.tsx": 2299,
|
||||
"src/components/Map/RouteCalculator.ts": 1195,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
@@ -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": 1147,
|
||||
"src/pages/TripPlannerPage.tsx": 1142,
|
||||
"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,
|
||||
@@ -269,7 +269,7 @@
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Day, Place, Trip } from '../types'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourImportGpxResponse, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import type { MapsNearbyRequest, JourneyReorderEntryPhotosRequest, TransitProvider, GoogleQuotaUpdateRequest, NotificationDefaultsUpdateRequest, TourListItem, TourCreateRequest, TourCreateResponse, TourDetailResponse } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import { postProviderPhotosInBatches } from './providerPhotoBatches'
|
||||
import {
|
||||
@@ -525,11 +525,6 @@ export const toursApi = {
|
||||
apiClient.post(`/trips/${tripId}/tours`, body).then(r => r.data),
|
||||
update: (tripId: number | string, placeId: number | string, body: TourCreateRequest): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}`, body).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File): Promise<TourImportGpxResponse> => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
return postMultipart(`/trips/${tripId}/tours/import/gpx`, fd)
|
||||
},
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { paddleObstaclesResponseSchema, type PaddleObstaclesResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-05A: the paddle objects of a map area and how far TREK's data covers it. */
|
||||
export const paddleObstaclesApi = {
|
||||
inBox: (
|
||||
box: { south: number; west: number; north: number; east: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<PaddleObstaclesResponse> =>
|
||||
apiClient
|
||||
.get('/paddle/obstacles', {
|
||||
params: { bbox: [box.south, box.west, box.north, box.east].map((v) => v.toFixed(5)).join(',') },
|
||||
signal,
|
||||
})
|
||||
.then((r) => paddleObstaclesResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
paddleRegionCatalogResponseSchema,
|
||||
paddleRegionListResponseSchema,
|
||||
paddleRegionSchema,
|
||||
successResponseSchema,
|
||||
type PaddleRegionRequest,
|
||||
} 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. */
|
||||
export const paddleRegionsApi = {
|
||||
list: () => apiClient.get('/admin/paddle-regions').then((r) => paddleRegionListResponseSchema.parse(r.data).regions),
|
||||
catalog: (q: string, signal?: AbortSignal) =>
|
||||
apiClient
|
||||
.get('/admin/paddle-regions/catalog', { params: { q }, signal, ...GEOFABRIK })
|
||||
.then((r) => paddleRegionCatalogResponseSchema.parse(r.data).regions),
|
||||
add: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions', { id } satisfies PaddleRegionRequest, GEOFABRIK)
|
||||
.then((r) => paddleRegionSchema.parse(r.data)),
|
||||
refresh: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/refresh', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => paddleRegionSchema.parse(r.data)),
|
||||
remove: (id: string) =>
|
||||
apiClient
|
||||
.post('/admin/paddle-regions/remove', { id } satisfies PaddleRegionRequest)
|
||||
.then((r) => successResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TourGpxImportFields, TourGpxPreviewResponse, TourImportGpxResponse } from '@trek/shared';
|
||||
import { postMultipart } from './client';
|
||||
|
||||
/** A GPX file and the fields beside it, as the Tours import routes read a multipart body. */
|
||||
function gpxForm(file: File, fields?: TourGpxImportFields): FormData {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) fd.append(key, value);
|
||||
return fd;
|
||||
}
|
||||
|
||||
/** The Tours GPX import: a file read for its preview first (PAD-02), then imported as the type chosen. */
|
||||
export const tourGpxApi = {
|
||||
/** What the file holds, read by the import's own parser; nothing is stored. */
|
||||
preview: (tripId: number | string, file: File): Promise<TourGpxPreviewResponse> =>
|
||||
postMultipart(`/trips/${tripId}/tours/import/gpx/preview`, gpxForm(file)),
|
||||
import: (tripId: number | string, file: File, fields?: TourGpxImportFields): Promise<TourImportGpxResponse> =>
|
||||
postMultipart(`/trips/${tripId}/tours/import/gpx`, gpxForm(file, fields)),
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { tourDetailResponseSchema, type TourDetailResponse, type TourObstacleReviewsRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-06: what the paddlers decided about the obstacles of a paddle tour; answers the detail with its checklist. */
|
||||
export const tourObstacleReviewsApi = {
|
||||
save: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourObstacleReviewsRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient
|
||||
.put(`/trips/${tripId}/tours/${placeId}/obstacle-reviews`, body)
|
||||
.then((r) => tourDetailResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TourDetailResponse, TourPaddlePlanRequest } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-03/PAD-07: the portages, locks and speeds laid onto a recorded paddle track. */
|
||||
export const tourPaddlePlanApi = {
|
||||
update: (
|
||||
tripId: number | string,
|
||||
placeId: number | string,
|
||||
body: TourPaddlePlanRequest
|
||||
): Promise<TourDetailResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/tours/${placeId}/paddle-plan`, body).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),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { windForecastResponseSchema, type WindForecastResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** PAD-08: the wind forecast for one place and one day. */
|
||||
export const windApi = {
|
||||
forecast: (lat: number, lng: number, date: string, signal?: AbortSignal): Promise<WindForecastResponse> =>
|
||||
apiClient
|
||||
.get('/weather/wind', { params: { lat, lng, date }, signal })
|
||||
.then((r) => windForecastResponseSchema.parse(r.data)),
|
||||
};
|
||||
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// 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',
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// FE-ADMIN-PADDLEREG-001 to FE-ADMIN-PADDLEREG-021: the paddle regions card in the admin area, drawn from a
|
||||
// controlled usePaddleRegionsAdmin (the hook has its own test).
|
||||
// FE-ADMIN-PADDLEREG-MODEL-001 to FE-ADMIN-PADDLEREG-MODEL-003: the helpers in paddleRegionsModel.
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import PaddleRegionsPanel from './PaddleRegionsPanel';
|
||||
import type { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
type Admin = ReturnType<typeof usePaddleRegionsAdmin>;
|
||||
|
||||
const hook = vi.hoisted(() => ({ usePaddleRegionsAdmin: vi.fn<() => Admin>() }));
|
||||
vi.mock('./usePaddleRegionsAdmin', () => ({ usePaddleRegionsAdmin: hook.usePaddleRegionsAdmin }));
|
||||
|
||||
const HINT = 'Weirs, locks and paddling rules from OpenStreetMap, read per region and again every month.';
|
||||
const ATTRIBUTION =
|
||||
'Data © OpenStreetMap contributors (ODbL), regional extracts by Geofabrik. Read again on the 3rd of every month.';
|
||||
const EMPTY = 'No region yet. Paddle tours then show that no obstacle data is known.';
|
||||
const CATALOG_DOWN = 'Geofabrik cannot be reached right now. Try again later.';
|
||||
|
||||
/** A region read once, its data current to the 3rd of October (midday, so every time zone keeps the day). */
|
||||
function region(overrides: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
return {
|
||||
id: 'oberpfalz',
|
||||
name: 'Oberpfalz',
|
||||
status: 'ready',
|
||||
source_timestamp: '2026-10-03T12:00:00Z',
|
||||
imported_at: '2026-10-04T08:00:00Z',
|
||||
feature_count: 1234,
|
||||
error: null,
|
||||
progress: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A region before its first read: no data and no date yet. */
|
||||
const unread = (overrides: Partial<PaddleRegion>): PaddleRegion =>
|
||||
region({ source_timestamp: null, imported_at: null, feature_count: 0, ...overrides });
|
||||
|
||||
const READY = region();
|
||||
const WAITING = unread({ id: 'donau', name: 'Donau', status: 'queued' });
|
||||
const FAILED_AGAIN = region({ id: 'saale', name: 'Saale', status: 'failed', error: 'Download broke off.' });
|
||||
const FAILED_FIRST = unread({ id: 'mosel', name: 'Mosel', status: 'failed', error: 'Not a PBF file.' });
|
||||
|
||||
/** Geofabrik's answers for "Limburg": the plain names told apart by the regions they lie in. */
|
||||
const HITS: PaddleRegionCatalogEntry[] = [
|
||||
{ id: 'germany/hessen/limburg-weilburg', name: 'Limburg-Weilburg', path: ['Europe', 'Germany', 'Hessen'] },
|
||||
{ id: 'netherlands/limburg', name: 'Limburg', path: ['Europe', 'Netherlands'] },
|
||||
{ id: 'belgium/limburg', name: 'Limburg', path: ['Europe', 'Belgium'] },
|
||||
{ id: 'limburg', name: 'Limburg', path: [] },
|
||||
];
|
||||
|
||||
function adminState(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}): Admin {
|
||||
return {
|
||||
toursOn: true,
|
||||
offline: false,
|
||||
regions: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
added: new Set<string>(),
|
||||
busyId: null,
|
||||
add: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}),
|
||||
removing: null,
|
||||
askRemove: vi.fn(),
|
||||
cancelRemove: vi.fn(),
|
||||
confirmRemove: vi.fn(),
|
||||
...overrides,
|
||||
search: { query: '', setQuery: vi.fn(), hits: null, searching: false, error: null, ...search },
|
||||
};
|
||||
}
|
||||
|
||||
function show(overrides: Partial<Admin> = {}, search: Partial<Admin['search']> = {}) {
|
||||
const state = adminState(overrides, search);
|
||||
hook.usePaddleRegionsAdmin.mockReturnValue(state);
|
||||
return { state, ...render(<PaddleRegionsPanel />) };
|
||||
}
|
||||
|
||||
/** The card's title row, where the number of regions sits beside the title. */
|
||||
function titleRow(): HTMLElement {
|
||||
const row = screen.getByRole('heading', { name: 'Paddle water data' }).parentElement;
|
||||
if (!row) throw new Error('no title row');
|
||||
return row;
|
||||
}
|
||||
|
||||
function rowOf(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest<HTMLElement>('[data-testid="paddle-region-row"]');
|
||||
if (!row) throw new Error(`no row for ${name}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe('PaddleRegionsPanel', () => {
|
||||
it('FE-ADMIN-PADDLEREG-001: renders nothing while the Tours addon is off', () => {
|
||||
const { container } = show({ toursOn: false, regions: [READY] });
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-002: shows the card with its hint, the attribution and the empty state', () => {
|
||||
show();
|
||||
expect(screen.getByRole('heading', { name: 'Paddle water data' })).toBeInTheDocument();
|
||||
expect(screen.getByText(HINT)).toBeInTheDocument();
|
||||
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||
expect(screen.getByText(ATTRIBUTION)).toBeInTheDocument();
|
||||
// No count beside the title while there is no region.
|
||||
expect(titleRow()).toHaveTextContent(/^Paddle water data$/);
|
||||
const search = screen.getByRole('searchbox', { name: 'Add a region' });
|
||||
expect(search).toBeEnabled();
|
||||
expect(search).toHaveAttribute('placeholder', 'Search Geofabrik, for example Oberpfalz');
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-region-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-003: says the list is loading instead of calling it empty', () => {
|
||||
show({ loading: true });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
|
||||
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-004: offline it says why and locks the search and every row, without the list error', () => {
|
||||
show({ offline: true, error: 'Network Error', regions: [READY] });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Regions can only be managed with a connection.');
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Network Error')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('searchbox', { name: 'Add a region' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Read again Oberpfalz' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Remove region Oberpfalz' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-005: a failed list read shows its error with a retry that asks the hook again', () => {
|
||||
const { state } = show({ error: 'Server unreachable' });
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveTextContent('Server unreachable');
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Try again' }));
|
||||
expect(state.retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-006: the search shows the query and hands every change to the hook', () => {
|
||||
const { state } = show({}, { query: 'Lim' });
|
||||
const search = screen.getByRole('searchbox', { name: 'Add a region' });
|
||||
expect(search).toHaveValue('Lim');
|
||||
fireEvent.change(search, { target: { value: 'Limb' } });
|
||||
expect(state.search.setQuery).toHaveBeenCalledWith('Limb');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-007: while Geofabrik is searched it says so and shows no verdict yet', () => {
|
||||
show({}, { query: 'Limburg', searching: true });
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Loading...');
|
||||
expect(screen.queryByText('No region by that name')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-008: a failed search shows its error under the field, without the list retry', () => {
|
||||
show({}, { query: 'Limburg', error: CATALOG_DOWN });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(CATALOG_DOWN);
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-009: a search without hits says no region has that name', () => {
|
||||
show({}, { query: 'Atlantis', hits: [] });
|
||||
expect(screen.getByText('No region by that name')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-region-hit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-010: hits name the regions they lie in, outermost first, and mark the ones added', () => {
|
||||
show({ added: new Set(['germany/hessen/limburg-weilburg']) }, { query: 'Limburg', hits: HITS });
|
||||
const [weilburg, dutch, belgian, bare] = screen.getAllByTestId('paddle-region-hit');
|
||||
const path = within(weilburg).getAllByText(/^(Europe|Germany|Hessen)$/);
|
||||
expect(path.map((part) => part.textContent)).toEqual(['Europe', 'Germany', 'Hessen']);
|
||||
expect(within(weilburg).getByText('Added')).toBeInTheDocument();
|
||||
expect(within(weilburg).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument();
|
||||
expect(within(dutch).getByText('Netherlands')).toBeInTheDocument();
|
||||
expect(within(dutch).getByRole('button', { name: 'Add' })).toBeEnabled();
|
||||
expect(within(belgian).getByText('Belgium')).toBeInTheDocument();
|
||||
// A region at the top of Geofabrik's tree has nothing above it to name.
|
||||
expect(within(bare).getByText('Limburg').nextElementSibling).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-011: adding a hit hands its Geofabrik id to the hook', () => {
|
||||
const { state } = show({}, { query: 'Limburg', hits: HITS });
|
||||
const dutch = screen.getAllByTestId('paddle-region-hit')[1];
|
||||
fireEvent.click(within(dutch).getByRole('button', { name: 'Add' }));
|
||||
expect(state.add).toHaveBeenCalledWith('netherlands/limburg');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-012: while one region is added every add waits, and the one under way spins', () => {
|
||||
show({ busyId: 'netherlands/limburg' }, { query: 'Limburg', hits: HITS });
|
||||
const [, dutch, belgian] = screen.getAllByTestId('paddle-region-hit');
|
||||
const adding = within(dutch).getByRole('button', { name: 'Add' });
|
||||
const waiting = within(belgian).getByRole('button', { name: 'Add' });
|
||||
expect(adding).toBeDisabled();
|
||||
expect(waiting).toBeDisabled();
|
||||
expect(adding.querySelector('.animate-spin')).not.toBeNull();
|
||||
expect(waiting.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-013: a waiting region reads Waiting, has no data yet and cannot be read again', () => {
|
||||
show({ regions: [WAITING] });
|
||||
const row = rowOf('Donau');
|
||||
expect(within(row).getByText('Waiting')).toHaveClass('border-edge-faint');
|
||||
expect(within(row).queryByText(/objects?$/)).not.toBeInTheDocument();
|
||||
expect(within(row).queryByText(/^As of/)).not.toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Read again Donau' })).toBeDisabled();
|
||||
expect(within(row).getByRole('button', { name: 'Remove region Donau' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-014: a region being read shows its phase and share, the phase alone while the size is unknown', () => {
|
||||
show({
|
||||
regions: [
|
||||
unread({ id: 'elbe', name: 'Elbe', status: 'importing', progress: { phase: 'download', fraction: 0.416 } }),
|
||||
unread({ id: 'main', name: 'Main', status: 'importing', progress: { phase: 'read', fraction: 0 } }),
|
||||
unread({ id: 'isar', name: 'Isar', status: 'importing', progress: { phase: 'download', fraction: null } }),
|
||||
unread({ id: 'naab', name: 'Naab', status: 'importing' }),
|
||||
],
|
||||
});
|
||||
const elbe = rowOf('Elbe');
|
||||
expect(within(elbe).getByText('Downloading 42%')).toHaveClass('bg-accent');
|
||||
expect(within(elbe).getByRole('button', { name: 'Read again Elbe' })).toBeDisabled();
|
||||
expect(within(elbe).getByRole('button', { name: 'Remove region Elbe' })).toBeEnabled();
|
||||
// A read just begun says 0 %, not nothing.
|
||||
expect(within(rowOf('Main')).getByText('Reading 0%')).toBeInTheDocument();
|
||||
expect(within(rowOf('Isar')).getByText('Downloading')).toBeInTheDocument();
|
||||
expect(within(rowOf('Naab')).getByText('Reading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-015: a ready region shows its object count and the date its data is current to', () => {
|
||||
show({ regions: [READY, region({ id: 'regen', name: 'Regen', feature_count: 1, source_timestamp: null })] });
|
||||
const row = rowOf('Oberpfalz');
|
||||
expect(within(row).getByText('Ready')).toHaveClass('bg-success-soft');
|
||||
expect(within(row).getByText('1234 objects')).toBeInTheDocument();
|
||||
expect(within(row).getByText('As of Oct 3, 2026')).toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Read again Oberpfalz' })).toBeEnabled();
|
||||
// One object reads singular; without a source date there is no date to show.
|
||||
const regen = rowOf('Regen');
|
||||
expect(within(regen).getByText('1 object')).toBeInTheDocument();
|
||||
expect(within(regen).queryByText(/^As of/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-016: a failed read shows its error, and that the earlier data stays in use when there is some', () => {
|
||||
show({ regions: [FAILED_AGAIN, FAILED_FIRST] });
|
||||
const again = rowOf('Saale');
|
||||
expect(within(again).getByText('Failed')).toHaveClass('bg-danger-soft');
|
||||
const error = within(again).getByText('Download broke off. The data read before stays in use.');
|
||||
expect(error).toHaveAttribute('title', 'Download broke off.');
|
||||
const first = rowOf('Mosel');
|
||||
expect(within(first).getByText('Not a PBF file.')).toBeInTheDocument();
|
||||
expect(within(first).queryByText(/stays in use/)).not.toBeInTheDocument();
|
||||
expect(within(first).queryByText(/objects?$/)).not.toBeInTheDocument();
|
||||
expect(within(first).getByRole('button', { name: 'Read again Mosel' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-017: Read again and Remove hand the region to the hook', () => {
|
||||
const { state } = show({ regions: [READY] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Read again Oberpfalz' }));
|
||||
expect(state.refresh).toHaveBeenCalledWith('oberpfalz');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove region Oberpfalz' }));
|
||||
expect(state.askRemove).toHaveBeenCalledWith(READY);
|
||||
// The question opens only once the hook holds the region to remove.
|
||||
expect(screen.queryByRole('heading', { name: 'Remove region' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-018: while one region is busy its row dims and no row can be read again or removed', () => {
|
||||
show({ regions: [READY, FAILED_AGAIN], busyId: 'oberpfalz' });
|
||||
expect(rowOf('Oberpfalz')).toHaveClass('opacity-60');
|
||||
expect(rowOf('Saale')).not.toHaveClass('opacity-60');
|
||||
for (const name of ['Oberpfalz', 'Saale']) {
|
||||
expect(screen.getByRole('button', { name: `Read again ${name}` })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: `Remove region ${name}` })).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-019: the title carries the number of regions', () => {
|
||||
show({ regions: [READY, WAITING, FAILED_FIRST] });
|
||||
expect(within(titleRow()).getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('paddle-region-row')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-020: removing asks first, naming the region, and confirming hands it to the hook', () => {
|
||||
const { state } = show({ regions: [READY], removing: READY });
|
||||
const message = 'Oberpfalz and its obstacle data are removed. Paddle tours there then show no data.';
|
||||
expect(screen.getByRole('heading', { name: 'Remove region' })).toBeInTheDocument();
|
||||
expect(screen.getByText(message)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
expect(state.confirmRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-021: Cancel and Escape take the question back without removing', () => {
|
||||
const { state } = show({ regions: [READY], removing: READY });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(state.cancelRemove).toHaveBeenCalledTimes(2);
|
||||
expect(state.confirmRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
expect(isReading({ status: 'importing' })).toBe(true);
|
||||
expect(isReading({ status: 'ready' })).toBe(false);
|
||||
expect(isReading({ status: 'failed' })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-002: the share of a read is a whole percent, null while its size is unknown', () => {
|
||||
expect(progressPercent({ progress: null })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'download', fraction: 0 } })).toBe(0);
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 0.416 } })).toBe(42);
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PADDLEREG-MODEL-003: the badge tone follows the state of the read, never the water', () => {
|
||||
expect(regionStatusTone('queued')).toBe('neutral');
|
||||
expect(regionStatusTone('importing')).toBe('accent');
|
||||
expect(regionStatusTone('ready')).toBe('success');
|
||||
expect(regionStatusTone('failed')).toBe('danger');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { Check, ChevronRight, Loader2, Plus, RefreshCw, Search, Trash2, Waves, WifiOff } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import {
|
||||
SettingRows,
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_ICON_BUTTON,
|
||||
SettingsCard,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import { EditorField, INPUT } from '../../shared/dialogParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import ErrorBoundary from '../../shared/ErrorBoundary';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
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.
|
||||
*/
|
||||
export default function PaddleRegionsPanel() {
|
||||
return (
|
||||
<ErrorBoundary boundaryId="paddle-regions">
|
||||
<Panel />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel() {
|
||||
const { t } = useTranslation();
|
||||
const admin = usePaddleRegionsAdmin();
|
||||
if (!admin.toursOn) return null;
|
||||
return (
|
||||
<SettingsCard
|
||||
icon={Waves}
|
||||
title={t('admin.paddleRegions.title')}
|
||||
hint={t('admin.paddleRegions.hint')}
|
||||
badge={admin.regions.length > 0 ? <StatusPill>{admin.regions.length}</StatusPill> : undefined}
|
||||
>
|
||||
{admin.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="m-0 flex items-center gap-2 rounded-[12px] bg-warning-soft px-3.5 py-2.5 text-warning"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<WifiOff size={14} className="flex-none" />
|
||||
{t('admin.paddleRegions.offline')}
|
||||
</p>
|
||||
)}
|
||||
{admin.error && !admin.offline && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-wrap items-center gap-3 rounded-[12px] bg-danger-soft px-3.5 py-2.5 text-danger"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 basis-48">{admin.error}</span>
|
||||
<button type="button" onClick={admin.retry} className={SETTINGS_BUTTON} style={fs(12.5, 'body')}>
|
||||
{t('admin.paddleRegions.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<fieldset disabled={admin.offline} className="m-0 flex min-w-0 flex-col gap-4 border-0 p-0">
|
||||
<CatalogSearch admin={admin} />
|
||||
<RegionList admin={admin} />
|
||||
</fieldset>
|
||||
<SettingsHint>{t('admin.paddleRegions.attribution')}</SettingsHint>
|
||||
<ConfirmDialog
|
||||
isOpen={admin.removing !== null}
|
||||
onClose={admin.cancelRemove}
|
||||
onConfirm={admin.confirmRemove}
|
||||
title={t('admin.paddleRegions.remove')}
|
||||
message={t('admin.paddleRegions.removeMessage', { name: admin.removing?.name ?? '' })}
|
||||
confirmLabel={t('common.remove')}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** A Geofabrik region with the regions it lies in, outermost first, so two of one name tell apart. */
|
||||
function RegionPath({ hit }: { hit: PaddleRegionCatalogEntry }) {
|
||||
if (hit.path.length === 0) return null;
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-0.5 text-content-faint" style={fs(11.5)}>
|
||||
{hit.path.map((part, index) => (
|
||||
<span key={`${part}-${index}`} className="inline-flex items-center gap-0.5">
|
||||
{index > 0 && <ChevronRight size={11} className="flex-none rtl:rotate-180" aria-hidden="true" />}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const { search } = admin;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<EditorField label={t('admin.paddleRegions.search')} htmlFor={id}>
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id={id}
|
||||
type="search"
|
||||
className={`${INPUT} ps-9`}
|
||||
value={search.query}
|
||||
onChange={(event) => search.setQuery(event.target.value)}
|
||||
placeholder={t('admin.paddleRegions.searchPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</EditorField>
|
||||
{search.searching && (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={14} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{search.error && (
|
||||
<p role="alert" className="m-0 text-danger" style={fs(12.5, 'body')}>
|
||||
{search.error}
|
||||
</p>
|
||||
)}
|
||||
{search.hits && search.hits.length === 0 && <SettingsHint>{t('admin.paddleRegions.noHits')}</SettingsHint>}
|
||||
{search.hits && search.hits.length > 0 && (
|
||||
<SettingRows>
|
||||
{search.hits.map((hit) => (
|
||||
<div key={hit.id} className="flex items-center gap-3 py-2 pe-2 ps-3.5" data-testid="paddle-region-hit">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{hit.name}
|
||||
</span>
|
||||
<RegionPath hit={hit} />
|
||||
</div>
|
||||
{admin.added.has(hit.id) ? (
|
||||
<StatusPill icon={<Check size={11} strokeWidth={2.4} />}>{t('admin.paddleRegions.added')}</StatusPill>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => void admin.add(hit.id)}
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{admin.busyId === hit.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
)}
|
||||
{t('common.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SettingRows>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionList({ admin }: { admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
if (admin.loading) {
|
||||
return (
|
||||
<p role="status" className="m-0 flex items-center gap-2 text-content-muted" style={fs(12.5, 'body')}>
|
||||
<Loader2 size={14} className="flex-none animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (admin.regions.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[12px] border border-dashed border-edge px-4 py-5 text-center">
|
||||
<SettingsHint>{t('admin.paddleRegions.empty')}</SettingsHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingRows>
|
||||
{admin.regions.map((region) => (
|
||||
<RegionRow key={region.id} region={region} admin={admin} />
|
||||
))}
|
||||
</SettingRows>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionStatus({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const percent = progressPercent(region);
|
||||
const label =
|
||||
region.status === 'importing' && region.progress
|
||||
? t(`admin.paddleRegions.phase.${region.progress.phase}`)
|
||||
: t(`admin.paddleRegions.status.${region.status}`);
|
||||
const share =
|
||||
percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`;
|
||||
return (
|
||||
<StatusPill
|
||||
tone={regionStatusTone(region.status)}
|
||||
icon={region.status === 'importing' ? <Loader2 size={11} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{label}
|
||||
{share}
|
||||
</StatusPill>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
const date = region.source_timestamp
|
||||
? new Date(region.source_timestamp).toLocaleDateString(language, { dateStyle: 'medium' })
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 py-2.5 pe-2 ps-3 ${busy ? 'opacity-60' : ''}`}
|
||||
data-testid="paddle-region-row"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid h-8 w-8 flex-none place-items-center rounded-[10px] bg-surface-tertiary text-content-secondary"
|
||||
>
|
||||
<Waves size={15} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-medium text-content" style={fs(13, 'body')}>
|
||||
{region.name}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<RegionStatus region={region} />
|
||||
{region.imported_at && (
|
||||
<StatusPill>{t('admin.paddleRegions.objects', { count: region.feature_count })}</StatusPill>
|
||||
)}
|
||||
{region.imported_at && date && <StatusPill>{t('admin.paddleRegions.asOf', { date })}</StatusPill>}
|
||||
</span>
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="m-0 line-clamp-2 break-words text-danger" style={fs(11.5)} title={region.error}>
|
||||
{region.error}
|
||||
{region.imported_at ? ` ${t('admin.paddleRegions.keptData')}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip label={t('admin.paddleRegions.refresh')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.refresh')} ${region.name}`}
|
||||
disabled={admin.busyId !== null || region.status === 'queued' || region.status === 'importing'}
|
||||
onClick={() => void admin.refresh(region.id)}
|
||||
className={SETTINGS_ICON_BUTTON}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('admin.paddleRegions.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('admin.paddleRegions.remove')} ${region.name}`}
|
||||
disabled={admin.busyId !== null}
|
||||
onClick={() => admin.askRemove(region)}
|
||||
className={`${SETTINGS_ICON_BUTTON} hover:!text-danger`}
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { PaddleRegion } from '@trek/shared';
|
||||
|
||||
/** A region waiting for its read or being read: the list asks again until none is. */
|
||||
export function isReading(region: Pick<PaddleRegion, 'status'>): boolean {
|
||||
return region.status === 'queued' || region.status === 'importing';
|
||||
}
|
||||
|
||||
/** The share of a read under way as a whole percent, or null while its size is unknown. */
|
||||
export function progressPercent(region: Pick<PaddleRegion, 'progress'>): number | null {
|
||||
const fraction = region.progress?.fraction;
|
||||
return fraction === null || fraction === undefined ? null : Math.round(fraction * 100);
|
||||
}
|
||||
|
||||
/** The status badge's tone: what state the read is in, never whether the water is safe. */
|
||||
export function regionStatusTone(status: PaddleRegion['status']): 'neutral' | 'accent' | 'success' | 'danger' {
|
||||
if (status === 'importing') return 'accent';
|
||||
if (status === 'ready') return 'success';
|
||||
if (status === 'failed') return 'danger';
|
||||
return 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* FE-HOOK-PADDLEREGIONS-001 to FE-HOOK-PADDLEREGIONS-014: the paddle regions in
|
||||
* the admin area (PAD-05A), the one hook the desktop and the phone admin render
|
||||
* through PaddleRegionsPanel. The repo is mocked; its error codes and its
|
||||
* offline error stay the real ones, and the strings are the English ones.
|
||||
* FE-PADDLEREGIONSMODEL-001: the model's helpers for the list and its badges.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { PaddleRegionsOfflineError } from '../../../repo/paddleRegionRepo';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { setForcedOffline } from '../../../sync/networkMode';
|
||||
import { isReading, progressPercent, regionStatusTone } from './paddleRegionsModel';
|
||||
import { CATALOG_SEARCH_DEBOUNCE_MS, PADDLE_REGIONS_POLL_MS, usePaddleRegionsAdmin } from './usePaddleRegionsAdmin';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
list: vi.fn<() => Promise<PaddleRegion[]>>(),
|
||||
catalog: vi.fn<(q: string, signal?: AbortSignal) => Promise<PaddleRegionCatalogEntry[]>>(),
|
||||
add: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
refresh: vi.fn<(id: string) => Promise<PaddleRegion>>(),
|
||||
remove: vi.fn<(id: string) => Promise<{ success: true }>>(),
|
||||
}));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('../../../repo/paddleRegionRepo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../repo/paddleRegionRepo')>()),
|
||||
paddleRegionRepo: repo,
|
||||
}));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const OFFLINE = 'Regions can only be managed with a connection.';
|
||||
|
||||
/** A region as the server lists it. */
|
||||
function region(id: string, status: PaddleRegion['status'], over: Partial<PaddleRegion> = {}): PaddleRegion {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
status,
|
||||
source_timestamp: null,
|
||||
imported_at: null,
|
||||
feature_count: 0,
|
||||
error: null,
|
||||
progress: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A region in Geofabrik's list. */
|
||||
function hit(id: string, name: string, ...path: string[]): PaddleRegionCatalogEntry {
|
||||
return { id, name, path };
|
||||
}
|
||||
|
||||
const READY = region('oberbayern', 'ready', { source_timestamp: '2026-10-01T20:21:00Z', feature_count: 412 });
|
||||
const FAILED = region('niederbayern', 'failed', { error: 'Download failed' });
|
||||
const QUEUED = region('oberpfalz', 'queued');
|
||||
const IMPORTING = region('oberpfalz', 'importing', { progress: { phase: 'read', fraction: 0.4 } });
|
||||
const DONE = region('oberpfalz', 'ready', { source_timestamp: '2026-10-15T20:21:00Z', feature_count: 87 });
|
||||
const HIT = hit('oberpfalz', 'Oberpfalz', 'Europe', 'Germany', 'Bayern');
|
||||
const OBERBAYERN_HIT = hit('oberbayern', 'Oberbayern', 'Europe', 'Germany', 'Bayern');
|
||||
|
||||
/** An axios error as the server answers it: its message and, for some, a code. */
|
||||
function apiError(error: string, code?: string) {
|
||||
return { response: { data: { error, code } } };
|
||||
}
|
||||
|
||||
/** A promise the test settles itself, to look at the hook while a call is out. */
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** Lets the clock run on and every answer due by then arrive. */
|
||||
async function settle(ms = 0) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children);
|
||||
|
||||
async function mount() {
|
||||
const hook = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
|
||||
await settle();
|
||||
return hook;
|
||||
}
|
||||
|
||||
function seedTours() {
|
||||
seedStore(useAddonStore, { addons: [{ id: 'tours', enabled: true }] });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetAllStores();
|
||||
seedTours();
|
||||
toast.success.mockReset();
|
||||
toast.error.mockReset();
|
||||
repo.list.mockReset().mockResolvedValue([READY]);
|
||||
repo.catalog.mockReset().mockResolvedValue([HIT]);
|
||||
repo.add.mockReset().mockResolvedValue(QUEUED);
|
||||
repo.refresh.mockReset().mockResolvedValue(READY);
|
||||
repo.remove.mockReset().mockResolvedValue({ success: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => setForcedOffline(false));
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('usePaddleRegionsAdmin', () => {
|
||||
it('FE-HOOK-PADDLEREGIONS-001: asks nothing while the Tours addon is off, and loads once it is switched on', async () => {
|
||||
seedStore(useAddonStore, { addons: [] });
|
||||
const { result } = await mount();
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 2);
|
||||
expect(result.current.toursOn).toBe(false);
|
||||
expect(repo.list).not.toHaveBeenCalled();
|
||||
|
||||
act(() => seedTours());
|
||||
await settle();
|
||||
expect(result.current.toursOn).toBe(true);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.regions).toEqual([READY]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-002: loads the list, knows which regions are added and asks no more while none is read', async () => {
|
||||
repo.list.mockResolvedValue([READY, FAILED]);
|
||||
const { result } = renderHook(() => usePaddleRegionsAdmin(), { wrapper });
|
||||
expect(result.current).toMatchObject({ toursOn: true, offline: false, loading: true, regions: [], error: null });
|
||||
|
||||
await settle();
|
||||
expect(result.current).toMatchObject({ loading: false, regions: [READY, FAILED], error: null, busyId: null });
|
||||
expect([...result.current.added]).toEqual(['oberbayern', 'niederbayern']);
|
||||
expect(result.current.removing).toBeNull();
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 3);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-003: asks again on every poll while a region waits or is read, and stops once all are ready', async () => {
|
||||
repo.list
|
||||
.mockResolvedValueOnce([READY, QUEUED])
|
||||
.mockResolvedValueOnce([READY, IMPORTING])
|
||||
.mockResolvedValue([READY, DONE]);
|
||||
const { result } = await mount();
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
|
||||
await settle(PADDLE_REGIONS_POLL_MS - 1);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
await settle(1);
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.regions).toEqual([READY, IMPORTING]);
|
||||
|
||||
await settle(PADDLE_REGIONS_POLL_MS);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
expect(result.current.regions).toEqual([READY, DONE]);
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 5);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-004: a failed poll shows the error and keeps the regions; the next answer clears it', async () => {
|
||||
repo.list
|
||||
.mockResolvedValueOnce([QUEUED])
|
||||
.mockRejectedValueOnce(apiError('Database is locked'))
|
||||
.mockResolvedValue([DONE]);
|
||||
const { result } = await mount();
|
||||
await settle(PADDLE_REGIONS_POLL_MS);
|
||||
expect(result.current).toMatchObject({ error: 'Database is locked', regions: [QUEUED], loading: false });
|
||||
|
||||
await settle(PADDLE_REGIONS_POLL_MS);
|
||||
expect(result.current).toMatchObject({ error: null, regions: [DONE] });
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 3);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-005: names why the list failed, offline or unknown, and a retry clears it', async () => {
|
||||
repo.list
|
||||
.mockRejectedValueOnce(new PaddleRegionsOfflineError())
|
||||
.mockRejectedValueOnce('socket hang up')
|
||||
.mockResolvedValue([READY]);
|
||||
const { result } = await mount();
|
||||
expect(result.current).toMatchObject({ error: OFFLINE, loading: false, regions: [] });
|
||||
|
||||
await act(() => result.current.retry());
|
||||
expect(result.current.error).toBe('Error');
|
||||
|
||||
await act(() => result.current.retry());
|
||||
expect(result.current).toMatchObject({ error: null, regions: [READY] });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-006: searches Geofabrik only from two characters and once the typing rests', async () => {
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery(' o '));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
|
||||
expect(repo.catalog).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toMatchObject({ query: ' o ', hits: null, searching: false, error: null });
|
||||
|
||||
act(() => result.current.search.setQuery(' ob'));
|
||||
expect(result.current.search.searching).toBe(true);
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
|
||||
act(() => result.current.search.setQuery(' obe'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS - 1);
|
||||
expect(repo.catalog).not.toHaveBeenCalled();
|
||||
|
||||
await settle(1);
|
||||
expect(repo.catalog).toHaveBeenCalledTimes(1);
|
||||
expect(repo.catalog).toHaveBeenCalledWith('obe', expect.any(AbortSignal));
|
||||
expect(result.current.search).toMatchObject({ query: ' obe', hits: [HIT], searching: false, error: null });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-007: the answer for the words typed last wins, an older one arriving late is dropped', async () => {
|
||||
const first = deferred<PaddleRegionCatalogEntry[]>();
|
||||
const second = deferred<PaddleRegionCatalogEntry[]>();
|
||||
repo.catalog.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('ober'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
act(() => result.current.search.setQuery('oberpf'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(repo.catalog.mock.calls.map(([q]) => q)).toEqual(['ober', 'oberpf']);
|
||||
expect(repo.catalog.mock.calls[0][1]?.aborted).toBe(true);
|
||||
|
||||
first.resolve([OBERBAYERN_HIT, HIT]);
|
||||
await settle();
|
||||
expect(result.current.search).toMatchObject({ hits: null, searching: true });
|
||||
|
||||
second.resolve([HIT]);
|
||||
await settle();
|
||||
expect(result.current.search).toMatchObject({ query: 'oberpf', hits: [HIT], searching: false });
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-008: says when Geofabrik cannot be reached, until the words are too short to search', async () => {
|
||||
repo.catalog.mockRejectedValueOnce(apiError('Geofabrik did not answer', 'catalog_unavailable'));
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('oberpfalz'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(result.current.search).toMatchObject({
|
||||
hits: null,
|
||||
searching: false,
|
||||
error: 'Geofabrik cannot be reached right now. Try again later.',
|
||||
});
|
||||
|
||||
act(() => result.current.search.setQuery('o'));
|
||||
expect(result.current.search.error).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-009: adding a region clears the search, says so and lists it', async () => {
|
||||
repo.list.mockResolvedValueOnce([READY]).mockResolvedValue([READY, QUEUED]);
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('oberpfalz'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(result.current.added.has('oberpfalz')).toBe(false);
|
||||
|
||||
await act(() => result.current.add('oberpfalz'));
|
||||
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
|
||||
expect(toast.success).toHaveBeenCalledWith('Region added. TREK reads it in the background.');
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toMatchObject({ query: '', hits: null, searching: false });
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.regions).toEqual([READY, QUEUED]);
|
||||
expect(result.current.added.has('oberpfalz')).toBe(true);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-010: a refused add shows the server message and keeps the words typed', async () => {
|
||||
repo.add.mockRejectedValueOnce(apiError('Geofabrik has no region oberpfalz-nord', 'unknown_region'));
|
||||
const { result } = await mount();
|
||||
act(() => result.current.search.setQuery('oberpfalz-nord'));
|
||||
await act(() => result.current.add('oberpfalz-nord'));
|
||||
expect(toast.error).toHaveBeenCalledWith('Geofabrik has no region oberpfalz-nord');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(result.current.search.query).toBe('oberpfalz-nord');
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.busyId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-011: reading a region again asks quietly and reloads the list; a failure says why', async () => {
|
||||
const { result } = await mount();
|
||||
await act(() => result.current.refresh('oberbayern'));
|
||||
expect(repo.refresh).toHaveBeenCalledWith('oberbayern');
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
|
||||
repo.refresh.mockRejectedValueOnce(new PaddleRegionsOfflineError());
|
||||
await act(() => result.current.refresh('oberbayern'));
|
||||
expect(toast.error).toHaveBeenCalledWith(OFFLINE);
|
||||
expect(repo.list).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-012: removes a region only once the removal is confirmed', async () => {
|
||||
const { result } = await mount();
|
||||
act(() => result.current.askRemove(READY));
|
||||
expect(result.current.removing).toEqual(READY);
|
||||
act(() => result.current.cancelRemove());
|
||||
expect(result.current.removing).toBeNull();
|
||||
act(() => result.current.confirmRemove());
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
|
||||
repo.list.mockResolvedValue([]);
|
||||
act(() => result.current.askRemove(READY));
|
||||
act(() => result.current.confirmRemove());
|
||||
expect(repo.remove).toHaveBeenCalledWith('oberbayern');
|
||||
expect(result.current).toMatchObject({ removing: null, busyId: 'oberbayern' });
|
||||
|
||||
await settle();
|
||||
expect(toast.success).toHaveBeenCalledWith('Region removed');
|
||||
expect(result.current).toMatchObject({ busyId: null, regions: [] });
|
||||
expect(result.current.added.size).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-013: while one action runs, a second one is ignored', async () => {
|
||||
const reading = deferred<PaddleRegion>();
|
||||
repo.refresh.mockReturnValueOnce(reading.promise);
|
||||
const { result } = await mount();
|
||||
act(() => {
|
||||
void result.current.refresh('oberbayern');
|
||||
});
|
||||
expect(result.current.busyId).toBe('oberbayern');
|
||||
|
||||
await act(() => result.current.add('oberpfalz'));
|
||||
act(() => result.current.askRemove(READY));
|
||||
act(() => result.current.confirmRemove());
|
||||
expect(repo.add).not.toHaveBeenCalled();
|
||||
expect(repo.remove).not.toHaveBeenCalled();
|
||||
expect(repo.refresh).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.busyId).toBe('oberbayern');
|
||||
|
||||
reading.resolve(READY);
|
||||
await settle();
|
||||
expect(result.current.busyId).toBeNull();
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(() => result.current.add('oberpfalz'));
|
||||
expect(repo.add).toHaveBeenCalledWith('oberpfalz');
|
||||
});
|
||||
|
||||
it('FE-HOOK-PADDLEREGIONS-014: offline it asks nothing and shows no spinner, and picks up again with the connection', async () => {
|
||||
repo.list.mockResolvedValue([QUEUED]);
|
||||
setForcedOffline(true);
|
||||
const { result } = await mount();
|
||||
expect(result.current).toMatchObject({ offline: true, loading: false, regions: [], error: null });
|
||||
act(() => result.current.search.setQuery('oberpfalz'));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS * 3);
|
||||
expect(repo.list).not.toHaveBeenCalled();
|
||||
expect(repo.catalog).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toMatchObject({ hits: null, searching: false, error: null });
|
||||
|
||||
act(() => setForcedOffline(false));
|
||||
await settle(CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
expect(result.current.offline).toBe(false);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
expect(repo.catalog).toHaveBeenCalledWith('oberpfalz', expect.any(AbortSignal));
|
||||
expect(result.current.search.hits).toEqual([HIT]);
|
||||
|
||||
act(() => setForcedOffline(true));
|
||||
await settle(PADDLE_REGIONS_POLL_MS * 4);
|
||||
expect(repo.list).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.search.hits).toBeNull();
|
||||
|
||||
act(() => setForcedOffline(false));
|
||||
await settle();
|
||||
expect(repo.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paddleRegionsModel', () => {
|
||||
it('FE-PADDLEREGIONSMODEL-001: waits on queued and importing reads, tones the badge by status and rounds the progress', () => {
|
||||
const statuses: PaddleRegion['status'][] = ['queued', 'importing', 'ready', 'failed'];
|
||||
expect(statuses.map((status) => isReading({ status }))).toEqual([true, true, false, false]);
|
||||
expect(statuses.map((status) => regionStatusTone(status))).toEqual(['neutral', 'accent', 'success', 'danger']);
|
||||
expect(progressPercent({ progress: null })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'download', fraction: null } })).toBeNull();
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 0.456 } })).toBe(46);
|
||||
expect(progressPercent({ progress: { phase: 'read', fraction: 1 } })).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNetworkMode } from '../../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { paddleRegionErrorCode, paddleRegionRepo } from '../../../repo/paddleRegionRepo';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { getApiErrorMessage } from '../../../types';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { isReading } from './paddleRegionsModel';
|
||||
|
||||
/** 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;
|
||||
/** How long typing rests before Geofabrik's list is searched. */
|
||||
export const CATALOG_SEARCH_DEBOUNCE_MS = import.meta.env.MODE === 'test' ? 10 : 350;
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
function errorText(t: Translate, err: unknown): string {
|
||||
const code = paddleRegionErrorCode(err);
|
||||
if (code === 'offline') return t('admin.paddleRegions.offline');
|
||||
if (code === 'catalog_unavailable') return t('admin.paddleRegions.catalogUnavailable');
|
||||
return getApiErrorMessage(err, t('common.error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [asked, setAsked] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!active || offline) return;
|
||||
let cancelled = false;
|
||||
paddleRegionRepo.list().then(
|
||||
(list) => {
|
||||
if (cancelled) return;
|
||||
setRegions(list);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
},
|
||||
(err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(errorText(t, err));
|
||||
setLoaded(true);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [active, offline, t, asked]);
|
||||
const reading = regions.some(isReading);
|
||||
useEffect(() => {
|
||||
if (!active || offline || !reading) return;
|
||||
const timer = window.setInterval(() => setAsked((n) => n + 1), PADDLE_REGIONS_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, offline, reading]);
|
||||
const reload = useCallback(() => setAsked((n) => n + 1), []);
|
||||
return { regions, loading: !loaded && !offline, error, reload };
|
||||
}
|
||||
|
||||
/** Geofabrik's list searched as the admin types, the last search winning. */
|
||||
function useCatalogSearch(offline: boolean, t: Translate) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [result, setResult] = useState<{
|
||||
needle: string;
|
||||
hits: PaddleRegionCatalogEntry[] | null;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const needle = query.trim();
|
||||
const wanted = !offline && needle.length >= 2;
|
||||
useEffect(() => {
|
||||
if (!wanted) return;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
paddleRegionRepo
|
||||
.catalog(needle, controller.signal)
|
||||
.then((hits) => {
|
||||
if (!controller.signal.aborted) setResult({ needle, hits, error: null });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!controller.signal.aborted) setResult({ needle, hits: null, error: errorText(t, err) });
|
||||
});
|
||||
}, CATALOG_SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [needle, wanted, t]);
|
||||
// An answer counts only for the words it was asked for.
|
||||
const current = wanted && result?.needle === needle ? result : null;
|
||||
return { query, setQuery, hits: current?.hits ?? null, searching: wanted && !current, error: current?.error ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function usePaddleRegionsAdmin() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const { offline } = useNetworkMode();
|
||||
const toursOn = useAddonStore((state) => state.isEnabled('tours'));
|
||||
const list = useRegionList(toursOn, offline, t);
|
||||
const search = useCatalogSearch(offline, t);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState<PaddleRegion | null>(null);
|
||||
const pending = useRef(false);
|
||||
const { reload } = list;
|
||||
|
||||
const run = useCallback(
|
||||
async (id: string, action: () => Promise<unknown>, done?: string) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusyId(id);
|
||||
try {
|
||||
await action();
|
||||
if (done) toast.success(done);
|
||||
} catch (err) {
|
||||
toast.error(errorText(t, err));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusyId(null);
|
||||
reload();
|
||||
}
|
||||
},
|
||||
[reload, t, toast]
|
||||
);
|
||||
|
||||
const { setQuery } = search;
|
||||
const add = useCallback(
|
||||
(id: string) =>
|
||||
run(
|
||||
id,
|
||||
async () => {
|
||||
await paddleRegionRepo.add(id);
|
||||
setQuery('');
|
||||
},
|
||||
t('admin.paddleRegions.toast.added')
|
||||
),
|
||||
[run, setQuery, t]
|
||||
);
|
||||
const refresh = useCallback((id: string) => run(id, () => paddleRegionRepo.refresh(id)), [run]);
|
||||
const confirmRemove = useCallback(() => {
|
||||
const region = removing;
|
||||
setRemoving(null);
|
||||
if (region) void run(region.id, () => paddleRegionRepo.remove(region.id), t('admin.paddleRegions.toast.removed'));
|
||||
}, [removing, run, t]);
|
||||
const added = useMemo(() => new Set(list.regions.map((region) => region.id)), [list.regions]);
|
||||
|
||||
return {
|
||||
toursOn,
|
||||
offline,
|
||||
regions: list.regions,
|
||||
loading: list.loading,
|
||||
error: list.error,
|
||||
retry: reload,
|
||||
search,
|
||||
added,
|
||||
busyId,
|
||||
add,
|
||||
refresh,
|
||||
removing,
|
||||
askRemove: setRemoving,
|
||||
cancelRemove: () => setRemoving(null),
|
||||
confirmRemove,
|
||||
};
|
||||
}
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,20 +5,12 @@ import type { Ferrata } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance } from '../../utils/units';
|
||||
import { MAP_CONTROL_SHADOW } from '../Map/mapControlShadow';
|
||||
import { MAP_CARD_GLASS } from '../Map/mapControlShadow';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { ferrataOsmUrl } from './ferrataModel';
|
||||
|
||||
/** The map controls' frosted glass, so the card reads as one of them. */
|
||||
const MAP_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
};
|
||||
|
||||
/**
|
||||
* One via ferrata, picked on the map: its name, the route it belongs to, its
|
||||
* grade range and total length as badges, and the way back to the source.
|
||||
@@ -57,7 +49,7 @@ export default function FerrataDetailCard({
|
||||
return (
|
||||
<article
|
||||
className={`rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||
style={{ ...MAP_GLASS, width }}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="ferrata-detail-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useMapCardDismiss } from '../Map/useMapCardDismiss';
|
||||
import FerrataDetailCard from './FerrataDetailCard';
|
||||
import type { FerrataGroup } from './ferrataGroups';
|
||||
import type { FerrataSelection } from './useFerrataMapLayer';
|
||||
@@ -39,26 +40,7 @@ export default function FerrataDetailOverlay({
|
||||
onTourIds?: ReadonlySet<string>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const open = !!selection;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: PointerEvent) => {
|
||||
const card = ref.current;
|
||||
if (card && event.target instanceof Node && card.contains(event.target)) return;
|
||||
onOutsidePress?.();
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, onOutsidePress]);
|
||||
useMapCardDismiss(ref, !!selection, onClose, onOutsidePress);
|
||||
|
||||
if (!selection) return null;
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readMapPaint, sameMapPaint } from '../Map/mapPaint';
|
||||
|
||||
/**
|
||||
* The colours the via ferrata lines are drawn in.
|
||||
*
|
||||
@@ -10,9 +12,9 @@
|
||||
*
|
||||
* Map paint cannot take a `var(--token)`: Leaflet writes the colour into an
|
||||
* SVG `stroke` attribute and GL paint wants a resolved colour string. So the
|
||||
* `--map-ferrata*` tokens are read off the document when the layer draws (the
|
||||
* precedent is the hazard overlay in useHazardLayerGL), which keeps light and
|
||||
* dark mode apart without a colour literal in the layer.
|
||||
* `--map-ferrata*` tokens are read off the document when the layer draws
|
||||
* (`mapPaint.ts`; the precedent is the hazard overlay in useHazardLayerGL),
|
||||
* which keeps light and dark mode apart without a colour literal in the layer.
|
||||
*/
|
||||
export interface FerrataPaint {
|
||||
/** The dashed line itself. */
|
||||
@@ -23,27 +25,16 @@ export interface FerrataPaint {
|
||||
highlight: string;
|
||||
}
|
||||
|
||||
const TOKENS = {
|
||||
export const FERRATA_TOKENS = {
|
||||
line: '--map-ferrata',
|
||||
casing: '--map-ferrata-casing',
|
||||
highlight: '--map-ferrata-lit',
|
||||
} as const;
|
||||
|
||||
/** What a token resolves to on the root, or `currentColor` where nothing defines it (a test DOM). */
|
||||
function token(name: string, style: CSSStyleDeclaration | null): string {
|
||||
const value = style?.getPropertyValue(name).trim();
|
||||
return value || 'currentColor';
|
||||
}
|
||||
|
||||
export function ferrataPaint(): FerrataPaint {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
|
||||
return {
|
||||
line: token(TOKENS.line, style),
|
||||
casing: token(TOKENS.casing, style),
|
||||
highlight: token(TOKENS.highlight, style),
|
||||
};
|
||||
return readMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
export function sameFerrataPaint(a: FerrataPaint, b: FerrataPaint): boolean {
|
||||
return a.line === b.line && a.casing === b.casing && a.highlight === b.highlight;
|
||||
return sameMapPaint(a, b);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMapPaint } from '../Map/mapPaint';
|
||||
import { FERRATA_TOKENS, type FerrataPaint } from './ferrataPaint';
|
||||
|
||||
import { type FerrataPaint, ferrataPaint, sameFerrataPaint } from './ferrataPaint';
|
||||
|
||||
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
|
||||
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
|
||||
|
||||
/**
|
||||
* The line colours, resolved again whenever the light/dark class, the colour
|
||||
* scheme or a custom accent on `<html>` changes. Map paint holds resolved
|
||||
* strings rather than `var(--token)`, so without this a theme toggle would leave
|
||||
* the lines in the old scheme's colours. Same observer approach as `useIsDark`,
|
||||
* which only watches the class. The reference only changes when a colour does,
|
||||
* so a memo keyed on it does not churn.
|
||||
*/
|
||||
/** The line colours, resolved again whenever the theme on `<html>` changes (see `useMapPaint`). */
|
||||
export function useFerrataPaint(): FerrataPaint {
|
||||
const [paint, setPaint] = useState(ferrataPaint);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = ferrataPaint();
|
||||
setPaint((current) => (sameFerrataPaint(current, next) ? current : next));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return paint;
|
||||
return useMapPaint(FERRATA_TOKENS);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import type { DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
||||
@@ -32,7 +33,7 @@ export default function DocSyncConnectModal({
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
||||
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
@@ -134,7 +135,7 @@ function Verdict({
|
||||
busy,
|
||||
failure,
|
||||
}: {
|
||||
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||
verdict: DocSyncVerdict | null
|
||||
busy: boolean
|
||||
/** A call that failed outside the connection test (saving, most of all). */
|
||||
failure: string | null
|
||||
@@ -172,10 +173,16 @@ function Verdict({
|
||||
)
|
||||
}
|
||||
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
||||
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
||||
// The footer shares its row with the buttons, and a refusal cut to "The
|
||||
// credentials were re..." with no way to read the rest left nothing to go on
|
||||
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
|
||||
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
|
||||
</span>
|
||||
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
||||
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* The credential form's rules, for both shells.
|
||||
@@ -39,7 +39,7 @@ export interface ConnectForm {
|
||||
setInsecureTls: (on: boolean) => void
|
||||
/** Every required field is either filled in or already stored. */
|
||||
complete: boolean
|
||||
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
|
||||
probe: () => Promise<DocSyncVerdict>
|
||||
save: () => Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ import { useServerPing } from '../../../sync/useServerPing'
|
||||
* reason for the same problem.
|
||||
*/
|
||||
|
||||
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
|
||||
export interface DocSyncVerdict {
|
||||
connected: boolean
|
||||
account?: string
|
||||
error?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface DocSyncField {
|
||||
field_key: string
|
||||
label: string
|
||||
@@ -264,11 +272,9 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
||||
setBusy('test')
|
||||
try {
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
|
||||
connected: boolean; account?: string; error?: string; detail?: string
|
||||
}
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
|
||||
} catch (e: unknown) {
|
||||
return { connected: false, error: readError(e) }
|
||||
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
@@ -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,5 @@
|
||||
import { Loader2, Map as MapIcon, MapPinPlus, Mountain, Rotate3d, Satellite } from 'lucide-react'
|
||||
import { Anchor, 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'
|
||||
import { FerrataLineIcon } from '../Ferratas/FerrataGlyph'
|
||||
@@ -7,10 +8,19 @@ import type { FerrataStatus } from '../Ferratas/useTripFerratas'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { ToggleableRasterOverlay } from './mapRasterOverlay'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
export type TourBaseLayer = BaseLayer | 'topo'
|
||||
|
||||
/** What a tour's type offers on the planner map (P2B-06): its base layers, and the overlays beside the ferratas. */
|
||||
export interface TourLayerOffer {
|
||||
bases?: readonly TourBaseLayer[]
|
||||
overlays?: readonly ToggleableRasterOverlay[]
|
||||
/** PAD-05B: the paddle obstacles around a paddle tour, drawn by every renderer. */
|
||||
paddle?: PaddleMapLayer
|
||||
}
|
||||
|
||||
const FROSTED_SHELL = {
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto' as const,
|
||||
background: 'var(--sidebar-bg)',
|
||||
@@ -178,6 +188,51 @@ function PlaceToolToggle({ tool }: { tool: MapPlaceTool }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How each overlay the planner can offer is named and drawn on its switch. */
|
||||
const OVERLAY_LOOK: Record<string, { Icon: typeof Anchor; labelKey: string; downKey: string }> = {
|
||||
seamarks: { Icon: Anchor, labelKey: 'tours.map.seamarks', downKey: 'tours.map.seamarksDown' },
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for a transparent overlay (the seamarks), the way the via
|
||||
* ferrata switch looks: on in the accent, and in the warning tone with a badge
|
||||
* once its source stopped answering, which the tooltip names. The map and the
|
||||
* planning go on without it.
|
||||
*/
|
||||
function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; compact: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const look = OVERLAY_LOOK[overlay.id]
|
||||
if (!look) return null
|
||||
const label = t(look.labelKey)
|
||||
const down = overlay.status === 'down'
|
||||
const on = overlay.status !== 'off'
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={down ? t(look.downKey) : label} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={overlay.onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={on}
|
||||
data-testid={`map-overlay-${overlay.id}`}
|
||||
data-status={overlay.status}
|
||||
className={on ? 'bg-accent text-accent-text' : 'text-content-muted'}
|
||||
style={segmentStyle(on)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span role="status" aria-live="polite" className="sr-only">{down ? t(look.downKey) : ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-view Tours control. It never reads or writes the global map setting.
|
||||
* `compact` shows the base layers and the via ferrata switch by their icons
|
||||
@@ -191,10 +246,13 @@ export function TourMapLayerSwitcher({
|
||||
terrainActive = false,
|
||||
ferrataLayer,
|
||||
placeTool,
|
||||
layers,
|
||||
compact = false,
|
||||
}: {
|
||||
active: TourBaseLayer
|
||||
onChange: (layer: TourBaseLayer) => void
|
||||
/** The base layers and overlays this tour's type offers; all three bases and no overlay when left out. */
|
||||
layers?: TourLayerOffer
|
||||
/** Offers the 3D terrain switch beside the base layers. */
|
||||
onToggleTerrain?: () => void
|
||||
terrainActive?: boolean
|
||||
@@ -205,10 +263,12 @@ export function TourMapLayerSwitcher({
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const bases = layers?.bases
|
||||
const overlays = layers?.overlays
|
||||
|
||||
return (
|
||||
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
|
||||
{TOUR_LAYERS.map(({ id, labelKey, Icon }) => {
|
||||
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => {
|
||||
const selected = active === id
|
||||
const label = labelKey ? t(labelKey) : 'Topo'
|
||||
return (
|
||||
@@ -227,9 +287,10 @@ export function TourMapLayerSwitcher({
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{(ferrataLayer || onToggleTerrain || placeTool) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
|
||||
{(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} />}
|
||||
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
|
||||
{onToggleTerrain && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,6 @@ import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupL
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
@@ -68,6 +67,7 @@ import { crsForBasemap } from './gcj02Crs'
|
||||
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
|
||||
import VectorBasemap from './VectorBasemap'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { TourMapLayersLeaflet } from './RasterOverlayLeaflet'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { computeMapViewport, TILE_SIZE_RASTER, type ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -831,7 +831,7 @@ export const MapView = memo(function MapView({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
}: MapViewProps) {
|
||||
const globalBaseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
|
||||
const updateSetting = useSettingsStore(s => s.updateSetting)
|
||||
@@ -1291,7 +1291,7 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
<TourMapLayersLeaflet ferrataLayer={ferrataLayer} overlays={tourLayers?.overlays} paddle={tourLayers?.paddle} />
|
||||
|
||||
{/* The route as it was actually recorded (#2279). Drawn in the casing
|
||||
pane's sibling order so it sits under the planned route rather than
|
||||
@@ -1339,7 +1339,7 @@ export const MapView = memo(function MapView({
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,9 @@ 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 { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
|
||||
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
|
||||
import { OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { MapLockPill } from './MapLockPill'
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
import { placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
|
||||
@@ -32,7 +33,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import { groupCoincidentPlaces } from './coincidentPlaces'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { type FerrataGlMap, isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL'
|
||||
import type { FerrataGlMap } from '../Ferratas/useFerrataLayerGL'
|
||||
import { formatViaDwell } from './routeViaDwell'
|
||||
import { useDawarichTrailGL } from './useDawarichTrailGL'
|
||||
import { bindDayBoundaryDrag } from './dayBoundaryDrag'
|
||||
@@ -44,7 +45,7 @@ import { poiPinParts } from './poiMarker'
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
|
||||
@@ -65,25 +66,6 @@ const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
|
||||
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
|
||||
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
|
||||
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite'
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster'
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
|
||||
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
|
||||
// route source so they sit under the current route, which also puts them before the first
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-']
|
||||
|
||||
type PlaceWithCoords = Place & { lat: number; lng: number }
|
||||
|
||||
@@ -201,59 +183,6 @@ function addPlaceClusterLayers(map: any): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Visibility comes first: a layer that is already
|
||||
* there only needs flipping, so the usual pass is one call and a tap answers at once.
|
||||
*
|
||||
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
|
||||
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
|
||||
* the usual state, and the only retry here is `styledata`, which fires on style edits
|
||||
* and not when those finish. Behind that gate a stored choice met a busy map on load,
|
||||
* and a tap on the road trip stage met the sources the same render had just set again,
|
||||
* so the imagery was never drawn. The gate was also the wrong question: this only runs
|
||||
* after `load`, and addSource and addLayer refuse only a style document that is not in
|
||||
* 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
|
||||
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
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none')
|
||||
return
|
||||
}
|
||||
if (!on) return // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
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],
|
||||
tileSize: 256,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
})
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? []
|
||||
const firstOwn = layers.find((l: { id: string }) =>
|
||||
OWN_LAYER_PREFIXES.some(prefix => l.id.startsWith(prefix)))
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
}, firstOwn?.id)
|
||||
} catch { /* a style that refuses the layer keeps the plain basemap */ }
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
@@ -624,7 +553,7 @@ export function MapViewGL({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
@@ -683,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)
|
||||
useFerrataMapLayerGL(mapReady ? readyMap : null, ferrataLayer)
|
||||
useTourMapLayersGL(mapReady ? readyMap : null, ferrataLayer, tourLayers?.overlays, tourLayers?.paddle)
|
||||
// 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
|
||||
@@ -1500,7 +1429,7 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
if (isFerrataHit(map, e.point)) return // a picked via ferrata opens its card instead
|
||||
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -2311,7 +2240,7 @@ export function MapViewGL({
|
||||
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
|
||||
{hasViewBaseLayer
|
||||
? <TourMapLayerSwitcher active={baseLayer as TourBaseLayer} onChange={onViewBaseLayerChange}
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} />
|
||||
onToggleTerrain={onToggleTerrain3d} ferrataLayer={ferrataLayer} placeTool={placeTool} layers={tourLayers} />
|
||||
: <MapLayerSwitcher active={globalBaseLayer as BaseLayer} onToggle={toggleBaseLayer} />}
|
||||
</div>
|
||||
{/* Hover tooltip — cursor-following name/category/address card, identical to
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { TileLayer } from 'react-leaflet';
|
||||
import { FerrataMapLayerLeaflet } from '../Ferratas/FerrataLayerLeaflet';
|
||||
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import { PaddleLayerLeaflet } from '../Tours/paddle/PaddleLayerLeaflet';
|
||||
import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer';
|
||||
import type { MapRasterOverlay } from './mapRasterOverlay';
|
||||
|
||||
/** Over the basemap's tiles (z-index 1) and every basemap raster, under the route's own panes. */
|
||||
const OVERLAY_Z_INDEX = 300;
|
||||
|
||||
/** The overlays on the Leaflet map: a tile layer each while it is on, nothing while it is off. */
|
||||
function RasterOverlaysLeaflet({ overlays }: { overlays?: readonly MapRasterOverlay[] }) {
|
||||
return (
|
||||
<>
|
||||
{overlays
|
||||
?.filter((overlay) => overlay.active)
|
||||
.map((overlay) => (
|
||||
<TileLayer
|
||||
key={overlay.id}
|
||||
url={overlay.url}
|
||||
attribution={overlay.attribution}
|
||||
minZoom={overlay.minZoom}
|
||||
maxNativeZoom={overlay.maxZoom}
|
||||
maxZoom={22}
|
||||
zIndex={OVERLAY_Z_INDEX}
|
||||
eventHandlers={{ tileerror: overlay.onTileError, tileload: overlay.onTileLoad }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tour planner's layers on the Leaflet map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
|
||||
export function TourMapLayersLeaflet({
|
||||
ferrataLayer,
|
||||
overlays,
|
||||
paddle,
|
||||
}: {
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
overlays?: readonly MapRasterOverlay[];
|
||||
paddle?: PaddleMapLayer;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FerrataMapLayerLeaflet layer={ferrataLayer} />
|
||||
<RasterOverlaysLeaflet overlays={overlays} />
|
||||
<PaddleLayerLeaflet layer={paddle} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
OPENTOPOMAP_TILE_ATTRIBUTION,
|
||||
OPENTOPOMAP_TILE_MAXZOOM,
|
||||
OPENTOPOMAP_TILE_URL,
|
||||
SATELLITE_TILE_ATTRIBUTION,
|
||||
SATELLITE_TILE_MAXZOOM,
|
||||
SATELLITE_TILE_URL,
|
||||
} from '../../constants/mapDefaults';
|
||||
|
||||
/*
|
||||
* 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)
|
||||
* the transparent overlays such as the seamarks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The satellite base layer, the GL twin of the Leaflet one.
|
||||
*
|
||||
* Leaflet swaps its whole tile layer for the imagery; a GL map cannot, because the
|
||||
* basemap is a style with dozens of layers in it. So the imagery goes on as a raster
|
||||
* layer of its own, drawn over the style and under everything TREK adds — the route,
|
||||
* the pins and the tracks stay on top and stay legible, which is the same order the
|
||||
* Leaflet map ends up with.
|
||||
*
|
||||
* Same tokenless ESRI source as Leaflet, so the two look alike and neither needs a key.
|
||||
*/
|
||||
const SATELLITE_SOURCE_ID = 'trip-satellite';
|
||||
const SATELLITE_LAYER_ID = 'trip-satellite-raster';
|
||||
/** Everything TREK draws is prefixed; the imagery is inserted before the first of them. */
|
||||
// Everything TREK draws on a GL map, so the satellite raster can be slipped underneath all
|
||||
// of it. `route-alt-` earns its place here: the roads offered for a leg are added before the
|
||||
// route source so they sit under the current route, which also puts them before the first
|
||||
// `trip-` layer, and anchoring the imagery there painted right over them.
|
||||
export const OWN_LAYER_PREFIXES = ['trip-', 'trek-', 'route-alt-'];
|
||||
|
||||
/**
|
||||
* Puts the imagery on the map, or takes it off again.
|
||||
*
|
||||
* Built on demand rather than once at load: a style change drops every source the map
|
||||
* had, and a hot reload does the same. Visibility comes first: a layer that is already
|
||||
* there only needs flipping, so the usual pass is one call and a tap answers at once.
|
||||
*
|
||||
* Deliberately not gated on `isStyleLoaded()`. That reports false for as long as any
|
||||
* source has tiles or a setData in flight (see useDawarichTrailGL), which on a phone is
|
||||
* the usual state, and the only retry here is `styledata`, which fires on style edits
|
||||
* and not when those finish. Behind that gate a stored choice met a busy map on load,
|
||||
* and a tap on the road trip stage met the sources the same render had just set again,
|
||||
* so the imagery was never drawn. The gate was also the wrong question: this only runs
|
||||
* after `load`, and addSource and addLayer refuse only a style document that is not in
|
||||
* 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;
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', on ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!on) return; // Nothing to build while it is switched off.
|
||||
// The layer is what is missing, not necessarily the source: a pass that got the
|
||||
// source in and then failed on the layer would otherwise leave a source that stops
|
||||
// every later pass from ever building the layer.
|
||||
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],
|
||||
tileSize: 256,
|
||||
maxzoom: topo ? OPENTOPOMAP_TILE_MAXZOOM : SATELLITE_TILE_MAXZOOM,
|
||||
attribution: topo ? OPENTOPOMAP_TILE_ATTRIBUTION : SATELLITE_TILE_ATTRIBUTION,
|
||||
});
|
||||
}
|
||||
// Under the first thing TREK draws, over everything the basemap style draws.
|
||||
// Without the anchor the imagery lands on top and buries the route.
|
||||
const layers = map.getStyle?.()?.layers ?? [];
|
||||
const firstOwn = layers.find((l: { id: string }) => OWN_LAYER_PREFIXES.some((prefix) => l.id.startsWith(prefix)));
|
||||
map.addLayer(
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: sourceId,
|
||||
paint: { 'raster-opacity': 1 },
|
||||
},
|
||||
firstOwn?.id
|
||||
);
|
||||
} catch {
|
||||
/* a style that refuses the layer keeps the plain basemap */
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,11 @@
|
||||
* exactly that reason — the others were the ones that needed to match it.
|
||||
*/
|
||||
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
|
||||
|
||||
/** The frosted glass of the map's floating cards (a ferrata's, a paddle obstacle's), so they read as its controls. */
|
||||
export const MAP_CARD_GLASS = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Colours for map paint, read off the theme. Map paint cannot take a
|
||||
* `var(--token)`: Leaflet writes the colour into an SVG attribute and GL
|
||||
* paint wants a resolved colour string. So the map layers name the tokens
|
||||
* they draw with, and the colours are read off the document when they draw,
|
||||
* which keeps light and dark mode apart without a colour literal in a layer.
|
||||
*/
|
||||
|
||||
/** The attributes `applyAppearance()` writes on `<html>` that change what the tokens resolve to. */
|
||||
const THEME_ATTRIBUTES = ['class', 'style', 'data-scheme'];
|
||||
|
||||
/** What each token resolves to on the root, `currentColor` where nothing defines it (a test DOM). */
|
||||
export function readMapPaint<K extends string>(tokens: Readonly<Record<K, string>>): Record<K, string> {
|
||||
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
|
||||
const keys = Object.keys(tokens) as K[];
|
||||
return Object.fromEntries(
|
||||
keys.map((key) => [key, style?.getPropertyValue(tokens[key]).trim() || 'currentColor'])
|
||||
) as Record<K, string>;
|
||||
}
|
||||
|
||||
export function sameMapPaint<K extends string>(
|
||||
a: Readonly<Record<K, string>>,
|
||||
b: Readonly<Record<K, string>>
|
||||
): boolean {
|
||||
return (Object.keys(a) as K[]).every((key) => a[key] === b[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours, read again whenever the light or dark class, the colour scheme
|
||||
* or a custom accent on `<html>` changes; without this a theme toggle would
|
||||
* leave a layer in the old scheme's colours. The answer only changes identity
|
||||
* when a colour does, so a memo keyed on it does not churn. `tokens` is a
|
||||
* constant of the caller's module.
|
||||
*/
|
||||
export function useMapPaint<K extends string>(tokens: Readonly<Record<K, string>>): Record<K, string> {
|
||||
const [paint, setPaint] = useState(() => readMapPaint(tokens));
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = readMapPaint(tokens);
|
||||
setPaint((current) => (sameMapPaint(current, next) ? current : next));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: THEME_ATTRIBUTES });
|
||||
return () => observer.disconnect();
|
||||
}, [tokens]);
|
||||
return paint;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useRasterOverlaysGL, type MapRasterOverlay, type RasterOverlayGlMap } from './mapRasterOverlay';
|
||||
|
||||
type Handler = (event: Record<string, unknown>) => void;
|
||||
|
||||
/** Enough of a GL map to watch what the overlay hook does with it. */
|
||||
function fakeMap(styleLayers: Array<{ id: string }> = []) {
|
||||
const layers = new Map<string, { visibility: string }>();
|
||||
const sources = new Set<string>();
|
||||
const handlers = new Map<string, Set<Handler>>();
|
||||
const map = {
|
||||
getLayer: vi.fn((id: string) => layers.get(id)),
|
||||
getSource: vi.fn((id: string) => (sources.has(id) ? {} : undefined)),
|
||||
addSource: vi.fn((id: string) => sources.add(id)),
|
||||
addLayer: vi.fn((layer: { id: string }, _before?: string) => layers.set(layer.id, { visibility: 'visible' })),
|
||||
setLayoutProperty: vi.fn((id: string, _key: string, value: string) => layers.set(id, { visibility: value })),
|
||||
getStyle: vi.fn(() => ({ layers: styleLayers })),
|
||||
on: vi.fn((type: string, handler: Handler) => {
|
||||
if (!handlers.has(type)) handlers.set(type, new Set());
|
||||
handlers.get(type)?.add(handler);
|
||||
}),
|
||||
off: vi.fn((type: string, handler: Handler) => handlers.get(type)?.delete(handler)),
|
||||
};
|
||||
const fire = (type: string, event: Record<string, unknown>) =>
|
||||
handlers.get(type)?.forEach((handler) => handler(event));
|
||||
return { map, layers, fire, gl: map as unknown as RasterOverlayGlMap };
|
||||
}
|
||||
|
||||
function overlay(active: boolean): MapRasterOverlay {
|
||||
return {
|
||||
id: 'seamarks',
|
||||
url: 'https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png',
|
||||
attribution: 'OpenSeaMap',
|
||||
minZoom: 6,
|
||||
maxZoom: 18,
|
||||
active,
|
||||
onTileError: vi.fn(),
|
||||
onTileLoad: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRasterOverlaysGL', () => {
|
||||
it('adds a switched-on overlay under the first own layer, so the route stays on top', () => {
|
||||
const { map, gl } = fakeMap([{ id: 'background' }, { id: 'trek-route-line' }]);
|
||||
renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
|
||||
expect(map.addSource).toHaveBeenCalledWith(
|
||||
'trek-overlay-seamarks',
|
||||
expect.objectContaining({ type: 'raster', minzoom: 6 })
|
||||
);
|
||||
expect(map.addLayer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'trek-overlay-seamarks' }),
|
||||
'trek-route-line'
|
||||
);
|
||||
});
|
||||
|
||||
it('adds nothing for an overlay that is off, and hides one already there when it is switched off', () => {
|
||||
const { map, gl, layers } = fakeMap();
|
||||
const { rerender } = renderHook(({ list }) => useRasterOverlaysGL(gl, list), {
|
||||
initialProps: { list: [overlay(false)] },
|
||||
});
|
||||
expect(map.addLayer).not.toHaveBeenCalled();
|
||||
rerender({ list: [overlay(true)] });
|
||||
expect(layers.get('trek-overlay-seamarks')?.visibility).toBe('visible');
|
||||
rerender({ list: [overlay(false)] });
|
||||
expect(layers.get('trek-overlay-seamarks')?.visibility).toBe('none');
|
||||
});
|
||||
|
||||
it('puts the overlay back after a basemap change rebuilt the style', () => {
|
||||
const { map, gl, layers, fire } = fakeMap();
|
||||
renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
|
||||
layers.clear();
|
||||
fire('styledata', {});
|
||||
expect(map.addLayer).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("reports its own tiles' failures and arrivals, and leaves other sources' events alone", () => {
|
||||
const { gl, fire } = fakeMap();
|
||||
const seamarks = overlay(true);
|
||||
renderHook(() => useRasterOverlaysGL(gl, [seamarks]));
|
||||
fire('error', { sourceId: 'trek-overlay-seamarks', error: new Error('503') });
|
||||
fire('error', { sourceId: 'basemap', error: new Error('503') });
|
||||
fire('sourcedata', { sourceId: 'trek-overlay-seamarks', tile: {} });
|
||||
fire('sourcedata', { sourceId: 'trek-overlay-seamarks' });
|
||||
expect(seamarks.onTileError).toHaveBeenCalledTimes(1);
|
||||
expect(seamarks.onTileLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the plain map when the style refuses the layer, and lets go of its listeners on unmount', () => {
|
||||
const { map, gl } = fakeMap();
|
||||
map.addLayer.mockImplementation(() => {
|
||||
throw new Error('style not loaded');
|
||||
});
|
||||
const { unmount } = renderHook(() => useRasterOverlaysGL(gl, [overlay(true)]));
|
||||
unmount();
|
||||
expect(map.off).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does nothing without a map or without overlays', () => {
|
||||
const { map, gl } = fakeMap();
|
||||
renderHook(() => useRasterOverlaysGL(null, [overlay(true)]));
|
||||
renderHook(() => useRasterOverlaysGL(gl, []));
|
||||
expect(map.on).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Map } from 'mapbox-gl';
|
||||
import { useEffect } from 'react';
|
||||
import { isFerrataHit, useFerrataMapLayerGL } from '../Ferratas/useFerrataLayerGL';
|
||||
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';
|
||||
|
||||
/**
|
||||
* A transparent raster layer laid over the basemap and under the route, such
|
||||
* as OpenSeaMap's seamarks over a paddle tour. Only the tiles in view are
|
||||
* fetched while it is on: nothing is prefetched or stored for offline use, and
|
||||
* the browser's own cache follows the answer's headers.
|
||||
*/
|
||||
export interface MapRasterOverlay {
|
||||
id: string;
|
||||
url: string;
|
||||
attribution: string;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
active: boolean;
|
||||
/** A tile failed to load; the layer's owner decides when that means the source is down. */
|
||||
onTileError: () => void;
|
||||
/** A tile arrived; the source answers again. */
|
||||
onTileLoad: () => void;
|
||||
}
|
||||
|
||||
type RasterOverlayStatus = 'off' | 'on' | 'down';
|
||||
|
||||
/** An overlay with its switch: whether it is on, and whether its source stopped answering. */
|
||||
export interface ToggleableRasterOverlay extends MapRasterOverlay {
|
||||
status: RasterOverlayStatus;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** One map type for both engines, as the ferrata layer does: MapLibre answers every call made here. */
|
||||
export type RasterOverlayGlMap = Map;
|
||||
|
||||
/**
|
||||
* The layer id. It carries TREK's own prefix, so a basemap raster added later
|
||||
* (satellite, topo) is put under it, and it goes in under the first other own
|
||||
* layer, so the route and its markers stay on top.
|
||||
*/
|
||||
function overlayLayerId(id: string): string {
|
||||
return `trek-overlay-${id}`;
|
||||
}
|
||||
|
||||
function applyOverlay(map: RasterOverlayGlMap, overlay: MapRasterOverlay): void {
|
||||
const layerId = overlayLayerId(overlay.id);
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', overlay.active ? 'visible' : 'none');
|
||||
return;
|
||||
}
|
||||
if (!overlay.active) return;
|
||||
if (!map.getSource(layerId)) {
|
||||
map.addSource(layerId, {
|
||||
type: 'raster',
|
||||
tiles: [overlay.url],
|
||||
tileSize: 256,
|
||||
minzoom: overlay.minZoom,
|
||||
maxzoom: overlay.maxZoom,
|
||||
attribution: overlay.attribution,
|
||||
});
|
||||
}
|
||||
const firstOwn = (map.getStyle()?.layers ?? []).find(
|
||||
(layer) =>
|
||||
OWN_LAYER_PREFIXES.some((prefix) => layer.id.startsWith(prefix)) && !layer.id.startsWith('trek-overlay-')
|
||||
);
|
||||
map.addLayer({ id: layerId, type: 'raster', source: layerId, paint: { 'raster-opacity': 1 } }, firstOwn?.id);
|
||||
} catch {
|
||||
// A style that refuses the layer keeps the plain map; the switch reports nothing loaded.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlays on a GL map, kept through a basemap change (which rebuilds the
|
||||
* style and drops them) and reporting their tiles' arrivals and failures.
|
||||
*/
|
||||
export function useRasterOverlaysGL(map: RasterOverlayGlMap | null, overlays: readonly MapRasterOverlay[] | undefined) {
|
||||
useEffect(() => {
|
||||
if (!map || !overlays?.length) return;
|
||||
const apply = () => overlays.forEach((overlay) => applyOverlay(map, overlay));
|
||||
const sourceOf = (event: { sourceId?: string }) =>
|
||||
overlays.find((overlay) => overlayLayerId(overlay.id) === event.sourceId);
|
||||
// A tile's failure carries the source it came from; other errors carry none and are not ours.
|
||||
const failed = (event: { error?: Error; sourceId?: string }) => {
|
||||
const overlay = sourceOf(event);
|
||||
if (overlay?.active) overlay.onTileError();
|
||||
};
|
||||
const loaded = (event: { sourceId?: string; tile?: unknown }) => {
|
||||
const overlay = sourceOf(event);
|
||||
if (overlay?.active && event.tile) overlay.onTileLoad();
|
||||
};
|
||||
apply();
|
||||
map.on('styledata', apply);
|
||||
map.on('error', failed);
|
||||
map.on('sourcedata', loaded);
|
||||
return () => {
|
||||
map.off('styledata', apply);
|
||||
map.off('error', failed);
|
||||
map.off('sourcedata', loaded);
|
||||
};
|
||||
}, [map, overlays]);
|
||||
}
|
||||
|
||||
/** The tour planner's layers on a GL map: the via ferratas, the raster overlays its type offers and the paddle obstacles. */
|
||||
export function useTourMapLayersGL(
|
||||
map: RasterOverlayGlMap | null,
|
||||
ferrataLayer: FerrataMapLayer | undefined,
|
||||
overlays: readonly MapRasterOverlay[] | undefined,
|
||||
paddle?: PaddleMapLayer
|
||||
) {
|
||||
useFerrataMapLayerGL(map, ferrataLayer);
|
||||
useRasterOverlaysGL(map, overlays);
|
||||
usePaddleLayerGL(map as unknown as PaddleGlMap | null, paddle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click at `point` landed on an object of the tour layers, a via
|
||||
* ferrata or a paddle obstacle. The maps ask this before their own click
|
||||
* handler, so picking one opens its card instead of also setting a waypoint.
|
||||
*/
|
||||
export function isTourLayerHit<Point>(map: Parameters<typeof isFerrataHit<Point>>[0], point: Point): boolean {
|
||||
return isFerrataHit(map, point) || isPaddleHit(map, point);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays';
|
||||
import type { DayBoundaryControls } from './dayBoundaryDrag';
|
||||
import type { CompassMap } from './MapCompassPill';
|
||||
import type { MapPlaceTool, TourBaseLayer } from './MapLayerSwitcher';
|
||||
import type { MapPlaceTool, TourBaseLayer, TourLayerOffer } from './MapLayerSwitcher';
|
||||
import type { Poi } from './poiCategories';
|
||||
|
||||
/*
|
||||
@@ -129,6 +129,11 @@ export interface MapViewProps {
|
||||
* Ferratas modules.
|
||||
*/
|
||||
ferrataLayer?: FerrataMapLayer;
|
||||
/**
|
||||
* What the tour's type offers in the tour layer pill (P2B-06): its base layers,
|
||||
* and transparent overlays drawn over the basemap and under the route.
|
||||
*/
|
||||
tourLayers?: TourLayerOffer;
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 19.04 },
|
||||
{ lat: 47.49, lng: 19.05 },
|
||||
];
|
||||
const ROUTED = {
|
||||
coordinates: [
|
||||
[47.5, 19.04],
|
||||
[47.49, 19.05],
|
||||
],
|
||||
legs: [],
|
||||
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('calculateRouteWithRetries', () => {
|
||||
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits as long as the host asks when that is longer', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
});
|
||||
|
||||
it('gives up after the last delay and throws the refusal', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
await settled;
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
|
||||
});
|
||||
|
||||
it('throws a refusal the host meant at once', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
|
||||
|
||||
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting when the day changes', async () => {
|
||||
const controller = new AbortController();
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
|
||||
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
controller.abort();
|
||||
|
||||
await settled;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
|
||||
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
|
||||
export type { RouteProfileKey } from './RouteCalculator';
|
||||
|
||||
/** How often a rate-limited route is asked for again, and how long after. */
|
||||
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
|
||||
|
||||
/** A pause that ends early, without throwing, when the signal aborts. */
|
||||
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal?.aborted || ms <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(done, ms);
|
||||
function done(): void {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `calculateRouteWithLegs`, asked again while the host says it is busy.
|
||||
*
|
||||
* The public OSRM hosts allow about one request a second and answer 429 above that. A
|
||||
* day with a change of mode, or a quick switch between days, asks more often than that,
|
||||
* and the day route used to give up on the first refusal and keep its straight lines
|
||||
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
|
||||
* as long as the host asks when it says; a refusal the host meant is thrown at once,
|
||||
* because the same request earns the same answer. An abort ends the wait, and the next
|
||||
* attempt then throws the AbortError the caller already handles.
|
||||
*/
|
||||
export async function calculateRouteWithRetries(
|
||||
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
|
||||
): ReturnType<typeof calculateRouteWithLegs> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await calculateRouteWithLegs(waypoints, opts);
|
||||
} catch (err) {
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
|
||||
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
|
||||
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import type { RouteProfileKey } from './RouteCalculator'
|
||||
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
@@ -185,20 +186,6 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a rate-limited leg is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
@@ -233,23 +220,16 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||
}
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithRetries(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
} catch {
|
||||
// Refused for good, or aborted: the leg keeps its straight line.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* Closes a card floating over the map on a press anywhere outside it and on
|
||||
* Escape, while it is open. The press outside is handed to `onOutsidePress`
|
||||
* as well, so the map under it can leave the click that follows alone (see
|
||||
* `useOutsidePressGuard`). A press on another object closes this card first,
|
||||
* and the click then opens that object's.
|
||||
*/
|
||||
export function useMapCardDismiss(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
onOutsidePress?: () => void
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: PointerEvent) => {
|
||||
const card = ref.current;
|
||||
if (card && event.target instanceof Node && card.contains(event.target)) return;
|
||||
onOutsidePress?.();
|
||||
onClose();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', onDown, true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown, true);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, onClose, onOutsidePress, ref]);
|
||||
}
|
||||
@@ -271,7 +271,9 @@ describe('PackingListPanel', () => {
|
||||
];
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(container.querySelector('[draggable="true"]')).toBeNull();
|
||||
// Only the category grip stays: the order of the categories is not the A to Z of their items.
|
||||
const draggable = [...container.querySelectorAll('[draggable="true"]')];
|
||||
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
@@ -38,6 +38,16 @@ interface KategorieGruppeProps {
|
||||
// Off while the list is shown A-Z: a drop there would move an item in an order
|
||||
// the reader cannot see.
|
||||
reorderable?: boolean
|
||||
// Moving the whole category among the others, wired by the list, which sees every
|
||||
// card. `onDrop` gets the category that was dragged onto this one.
|
||||
categoryDrag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: () => void
|
||||
onOver: () => void
|
||||
onEnd: () => void
|
||||
onDrop: (dragged: string) => void
|
||||
}
|
||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
@@ -46,7 +56,7 @@ interface KategorieGruppeProps {
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
@@ -120,8 +130,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
return (
|
||||
// Shaped after the phone's category card: a tinted head band carrying the
|
||||
// name as written, the count, and the fold arrow on the far right.
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<CategoryDropFrame drag={categoryDrag}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName && canEdit ? (
|
||||
@@ -344,6 +355,65 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CategoryDropFrame>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
|
||||
|
||||
/** The data type a dragged category carries, its name as the value. */
|
||||
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
|
||||
|
||||
/**
|
||||
* The card, and while a category is dragged its drop target. Captured, because
|
||||
* the item rows stop their own drops from bubbling.
|
||||
*/
|
||||
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
|
||||
// Told apart by the drag's own data type, which is there from the first event on:
|
||||
// a flag set in state on dragstart can still be unrendered when a quick drag
|
||||
// arrives, and a dragged item must pass through to its rows untouched.
|
||||
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
|
||||
return (
|
||||
<div
|
||||
onDragOverCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
|
||||
}) : undefined}
|
||||
onDropCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
|
||||
}) : undefined}
|
||||
style={{
|
||||
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
|
||||
transition: 'opacity 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The handle a category is dragged by, in the head band before its colour dot. */
|
||||
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
|
||||
if (!drag) return null
|
||||
return (
|
||||
<span
|
||||
draggable
|
||||
role="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
|
||||
e.dataTransfer.setData('text/plain', kategorie)
|
||||
drag.onStart()
|
||||
}}
|
||||
onDragEnd={drag.onEnd}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -7,8 +8,13 @@ export function PackingList(S: PackingState) {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
|
||||
} = S
|
||||
// Which category is being dragged and which card it is over. Held here, above
|
||||
// the cards, because the drag starts in one card and ends in another.
|
||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
||||
const [overCategory, setOverCategory] = useState<string | null>(null)
|
||||
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
@@ -40,6 +46,14 @@ export function PackingList(S: PackingState) {
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
reorderable={sort === 'manual'}
|
||||
categoryDrag={canEdit ? {
|
||||
isDragging: draggedCategory === kat,
|
||||
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
|
||||
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
|
||||
onOver: () => setOverCategory(kat),
|
||||
onEnd: endCategoryDrag,
|
||||
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
|
||||
} : undefined}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={handleSetSharing}
|
||||
onClone={handleCloneItem}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
itemOrderWithCategoryMoved,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
|
||||
|
||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||
@@ -76,4 +77,28 @@ describe('packingListModel', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||
});
|
||||
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
|
||||
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
|
||||
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
|
||||
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
|
||||
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
|
||||
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
|
||||
const items = [day1, day2a, own, day3, day2b];
|
||||
const common = packingViewItems(items, 'common');
|
||||
|
||||
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
|
||||
expect(order).toEqual([14, 11, 12, 15, 13]);
|
||||
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
|
||||
|
||||
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
|
||||
const items = [shared, stove, loose];
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,34 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole list's item order with category `from` moved to where `to` stands.
|
||||
*
|
||||
* A category has no position of its own: it shows where its first item is. So a
|
||||
* category is moved by moving its items, as one block, through the item reorder
|
||||
* the list already has; nothing new is stored, and the order reaches every device
|
||||
* the way a dragged item does. The view's items come first, grouped in the new
|
||||
* order with each group in its own order; the other view's items follow in theirs,
|
||||
* which leaves that view's categories as they were. Null when nothing would move.
|
||||
*/
|
||||
export function itemOrderWithCategoryMoved(
|
||||
items: PackingItem[],
|
||||
viewItems: PackingItem[],
|
||||
from: string,
|
||||
to: string,
|
||||
defaultCategory: string
|
||||
): number[] | null {
|
||||
const order = packingCategoryOrder(viewItems, defaultCategory);
|
||||
const fromIndex = order.indexOf(from);
|
||||
const toIndex = order.indexOf(to);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, from);
|
||||
const inView = new Set(viewItems.map((i) => i.id));
|
||||
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
|
||||
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter((i) => !i.checked);
|
||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { usePackingBags } from './usePackingBags'
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||
import { usePackingImport } from './usePackingImport'
|
||||
@@ -106,6 +106,13 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
// A category dropped onto another takes its place; the slice reorders
|
||||
// optimistically and rolls back with a toast of its own.
|
||||
const moveCategory = (from: string, to: string) => {
|
||||
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
|
||||
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
|
||||
}
|
||||
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||
@@ -213,7 +220,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Category, Place } from '../../types';
|
||||
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 { TimePill } from './planParts';
|
||||
|
||||
@@ -35,9 +36,10 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
<TimePill>
|
||||
<span
|
||||
data-testid="trip-plan-tour-walking-time"
|
||||
aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
aria-label={`${movingTimeLabel(tourTimes.tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}
|
||||
>
|
||||
{t('tours.planner.walkingShort')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
{movingTimeLabel(tourTimes.tour, t('tours.planner.walkingShort'), t)}:{' '}
|
||||
{formatPlannedTourDuration(tourTimes.walkingMinutes)}
|
||||
</span>
|
||||
</TimePill>
|
||||
)}
|
||||
|
||||
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
aria-label={t('settings.mfa.codePlaceholder')}
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
|
||||
@@ -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 { paddleBench } from '../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import TourDayRowFacts, { TourDayRowIcon } from './TourDayRowFacts';
|
||||
import TourDayRowFacts, { TourDayRowIcon, TourDayRowPills } from './TourDayRowFacts';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
|
||||
const tour: TourListItem = {
|
||||
@@ -74,3 +75,72 @@ describe('RC-01 Tour day-row facts', () => {
|
||||
expect(openRow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAD-09 the check bench on a tour row', () => {
|
||||
const kayak: TourListItem = { ...tour, tour_type: 'kayak', paddle_check: paddleBench() };
|
||||
|
||||
it('PCD-001: a kayak tour with a bench shows one checklist pill, its tooltip saying it is no clearance', () => {
|
||||
render(<TourDayRowPills tour={kayak} />);
|
||||
const pill = screen.getByTestId('plan-tour-paddle-check');
|
||||
expect(pill).toHaveTextContent('1 open');
|
||||
expect(pill.getAttribute('aria-label')).toContain('A checklist is no safety or legal clearance.');
|
||||
});
|
||||
|
||||
it('PCD-002: an area without obstacle data shows no pill at all, never a count', () => {
|
||||
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'none', open: 3 }) }} />);
|
||||
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PCD-002b: data that could not be read still shows, without a count', () => {
|
||||
render(<TourDayRowPills tour={{ ...kayak, paddle_check: paddleBench({ data: 'unavailable', open: 3 }) }} />);
|
||||
expect(screen.getByTestId('plan-tour-paddle-check')).not.toHaveTextContent('3');
|
||||
});
|
||||
|
||||
it.each([false, true])('PCD-003: the day plan facts name the bench as one more fact (mobile=%s)', (mobile) => {
|
||||
render(<TourDayRowFacts tour={kayak} mobile={mobile} />);
|
||||
expect(screen.getByTestId('plan-tour-paddle-check')).toHaveAccessibleName('Checklist: 1 open');
|
||||
});
|
||||
|
||||
const without: Array<[string, TourListItem]> = [
|
||||
['a hike with a bench', { ...tour, paddle_check: paddleBench() }],
|
||||
['a kayak tour without a bench', { ...tour, tour_type: 'kayak' }],
|
||||
['a kayak tour whose bench is null', { ...tour, tour_type: 'kayak', paddle_check: null }],
|
||||
];
|
||||
it.each(without)('PCD-004: %s shows no checklist pill and no checklist fact', (_case, item) => {
|
||||
render(
|
||||
<>
|
||||
<TourDayRowPills tour={item} />
|
||||
<TourDayRowFacts tour={item} />
|
||||
</>
|
||||
);
|
||||
expect(screen.queryByTestId('plan-tour-paddle-check')).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) =>
|
||||
el.getAttribute('data-testid')
|
||||
);
|
||||
const timed = { ...tour, distance: 4.2, elevation_gain: 478, planned_duration_minutes: 50 };
|
||||
|
||||
it('PCD-005: a hike keeps time, distance and climbing, in that order', () => {
|
||||
render(<TourDayRowPills tour={timed} compact />);
|
||||
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-distance', 'plan-tour-ascent']);
|
||||
});
|
||||
|
||||
it('PCD-006: a paddle tour puts its check bench second and leaves out the climbing', () => {
|
||||
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} compact />);
|
||||
expect(testIds()).toEqual(['trip-plan-tour-time', 'plan-tour-paddle-check', 'plan-tour-distance']);
|
||||
});
|
||||
|
||||
it('PCD-007: the day plan still shows every fact', () => {
|
||||
render(<TourDayRowPills tour={{ ...timed, tour_type: 'kayak', paddle_check: paddleBench() }} />);
|
||||
expect(testIds()).toEqual([
|
||||
'trip-plan-tour-time',
|
||||
'plan-tour-distance',
|
||||
'plan-tour-ascent',
|
||||
'plan-tour-paddle-check',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { ArrowUp, Clock, Pause, Ruler, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -7,7 +7,9 @@ import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { WhiteBadge } from '../Planner/planParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import { paddleCheckFact } from './paddle/paddleCheckModel';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { movingTimeLabel } from './tourFacts';
|
||||
import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
|
||||
|
||||
export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
|
||||
@@ -117,7 +119,7 @@ export function TourDayRowAvatar({
|
||||
}
|
||||
|
||||
/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */
|
||||
function TourDayRowPill({
|
||||
export function TourDayRowPill({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
@@ -139,19 +141,32 @@ function TourDayRowPill({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A paddle tour's check bench as one fact (PAD-09), or null for a hike, a tour
|
||||
* listed without one, or an area without obstacle data: a row has no room for
|
||||
* a badge that says nothing, and the checklist itself still names the gap.
|
||||
*/
|
||||
function paddleCheckOf(tour: TourListItem, t: Parameters<typeof paddleCheckFact>[1]) {
|
||||
const check = tour.tour_type === 'kayak' ? tour.paddle_check : null;
|
||||
return check && check.data !== 'none' ? paddleCheckFact(check, t) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour's facts on its day-plan row, in the badges every other stop uses: the
|
||||
* time it takes (the planned total where there is one, otherwise the walking
|
||||
* time, the parts in the tooltip), how far and how much climbing.
|
||||
* time, the parts in the tooltip), how far, how much climbing and, for a paddle
|
||||
* tour, its check bench. `compact` (the tour list) keeps the three that matter
|
||||
* most, in that order: the time, then a paddle tour's check bench and its
|
||||
* distance, or a hike's distance and climbing.
|
||||
*/
|
||||
export function TourDayRowPills({ tour }: { tour: TourListItem }) {
|
||||
export function TourDayRowPills({ tour, compact = false }: { tour: TourListItem; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
const times = tourPlannedTimes(tour);
|
||||
const shownMinutes = times.plannedTotalMinutes ?? times.walkingMinutes;
|
||||
const timeParts = [
|
||||
times.walkingMinutes != null
|
||||
? `${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
@@ -160,34 +175,38 @@ export function TourDayRowPills({ tour }: { tour: TourListItem }) {
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
return (
|
||||
<>
|
||||
{shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
)}
|
||||
{tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
icon={Ruler}
|
||||
value={formatDistance(tour.distance, unit)}
|
||||
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
|
||||
testId="plan-tour-distance"
|
||||
/>
|
||||
)}
|
||||
{tour.elevation_gain != null && (
|
||||
<TourDayRowPill
|
||||
icon={ArrowUp}
|
||||
value={formatElevation(tour.elevation_gain, unit)}
|
||||
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
|
||||
testId="plan-tour-ascent"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
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 distance = tour.distance != null && (
|
||||
<TourDayRowPill
|
||||
key="distance"
|
||||
icon={Ruler}
|
||||
value={formatDistance(tour.distance, unit)}
|
||||
label={`${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`}
|
||||
testId="plan-tour-distance"
|
||||
/>
|
||||
);
|
||||
const ascent = tour.elevation_gain != null && (
|
||||
<TourDayRowPill
|
||||
key="ascent"
|
||||
icon={ArrowUp}
|
||||
value={formatElevation(tour.elevation_gain, unit)}
|
||||
label={`${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`}
|
||||
testId="plan-tour-ascent"
|
||||
/>
|
||||
);
|
||||
const bench = check && <TourDayRowPill key="check" {...check} testId="plan-tour-paddle-check" />;
|
||||
if (!compact) return <>{[time, distance, ascent, bench]}</>;
|
||||
// A paddle tour's climbing is the river's fall, nothing to plan by; its check bench is.
|
||||
return <>{tour.tour_type === 'kayak' ? [time, bench, distance] : [time, distance, ascent]}</>;
|
||||
}
|
||||
|
||||
export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
|
||||
@@ -203,9 +222,9 @@ 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(
|
||||
Footprints,
|
||||
tourDayRowIcon(tour.tour_type === 'kayak' ? 'kayak' : 'walk'),
|
||||
formatPlannedTourDuration(times.walkingMinutes),
|
||||
t('tours.planner.inspector.duration'),
|
||||
movingTimeLabel(tour, t('tours.planner.inspector.duration'), t),
|
||||
'trip-plan-tour-walking-time'
|
||||
);
|
||||
if (times.breakMinutes != null && times.breakMinutes > 0)
|
||||
@@ -222,6 +241,8 @@ export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourLi
|
||||
`${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
|
||||
'trip-plan-tour-planned-duration'
|
||||
);
|
||||
const check = paddleCheckOf(tour, t);
|
||||
if (check) add(check.icon, check.value, t('tours.paddle.check.title'), 'plan-tour-paddle-check');
|
||||
return (
|
||||
<span data-testid="plan-tour-facts" className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
{facts.map((fact) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/rende
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { 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'
|
||||
@@ -141,6 +142,16 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Planned (Manual override)')).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 }} />)
|
||||
|
||||
const row = screen.getByText('Distance').closest('dl')
|
||||
expect(row).toHaveClass('grid-cols-3')
|
||||
expect(row).toContainElement(screen.getByText('Paddling'))
|
||||
expect(row).toContainElement(screen.getByText('Planned'))
|
||||
expect(screen.queryByText('Ascent')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
|
||||
render(
|
||||
<TourDetailDialog
|
||||
@@ -214,4 +225,19 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getAllByText('T1').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText(/easy/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PCT-001: a paddle tour shows its checklist and the safety row before the wind, a hike neither', () => {
|
||||
const { unmount } = render(<TourDetailDialog {...defaultProps} tour={{ ...tour, tour_type: 'kayak', paddle_check: paddleBench() }} />)
|
||||
|
||||
expect(screen.getByText('Checklist')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('paddle-check-summary')).toBeInTheDocument()
|
||||
const safety = screen.getByRole('button', { name: /Paddling has risks/ })
|
||||
expect(safety.compareDocumentPosition(screen.getByTestId('paddle-wind-card'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
unmount()
|
||||
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, paddle_check: paddleBench() }} />)
|
||||
expect(screen.queryByText('Checklist')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Paddling has risks/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Minus,
|
||||
Mountain,
|
||||
Navigation,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -23,14 +22,14 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { translateApiError, useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../types';
|
||||
import type { Assignment, AssignmentsMap, Day, DistanceUnit, Place, TripFile } from '../../types';
|
||||
import { openFile } from '../../utils/fileDownload';
|
||||
import { filesForPlace } from '../../utils/placeFiles';
|
||||
import { analyzeRouteGeometry } from '../../utils/routeGeometry';
|
||||
@@ -48,8 +47,12 @@ import { Tooltip } from '../shared/Tooltip';
|
||||
import TrackColorPicker from '../shared/TrackColorPicker';
|
||||
import { markdownLinkComponents } from '../shared/markdownLink';
|
||||
import { TourDayRowAvatar } from './TourDayRowFacts';
|
||||
import { PaddleCheckSection } from './paddle/PaddleCheckSection';
|
||||
import { PaddleWindSection } from './paddle/PaddleWindCard';
|
||||
import { TourProvenanceBlock } from './provenance/TourProvenanceSection';
|
||||
import { tourDayRowIcon } from './tourDayRowIcon';
|
||||
import { tourDaysOf } from './tourDays';
|
||||
import { heightsUnknown, movingTimeLabel, withoutHeights } from './tourFacts';
|
||||
import { TourDayPills, TourFactFields, TourMetricFields, type TourMetricField } from './tourParts';
|
||||
import {
|
||||
formatPlannedTourDuration,
|
||||
@@ -113,13 +116,67 @@ function assignmentForSelectedDay(
|
||||
);
|
||||
}
|
||||
|
||||
/** The tour's measures, its times and its profile. Without heights the distance joins the times in one row. */
|
||||
function TourDetailMeasures({
|
||||
analysis,
|
||||
unit,
|
||||
timeFields,
|
||||
trackColor,
|
||||
placeId,
|
||||
}: {
|
||||
analysis: NonNullable<ReturnType<typeof analyzeRouteGeometry>>;
|
||||
unit: DistanceUnit;
|
||||
timeFields: TourMetricField[];
|
||||
trackColor: string;
|
||||
placeId: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-none flex-col gap-2">
|
||||
{analysis.minEle == null && analysis.gain == null ? (
|
||||
<TourMetricFields
|
||||
analysis={analysis}
|
||||
unit={unit}
|
||||
extra={timeFields}
|
||||
columns={timeFields.length === 1 ? 2 : timeFields.length === 2 ? 3 : undefined}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TourMetricFields analysis={analysis} unit={unit} columns={5} />
|
||||
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
|
||||
</>
|
||||
)}
|
||||
{analysis.distanceIndexedProfileSamples.length >= 2 && (
|
||||
<div className={`${BOX} p-2`}>
|
||||
<ElevationProfile
|
||||
samples={analysis.distanceIndexedProfileSamples}
|
||||
color={trackColor}
|
||||
gradientId={`tour-elevation-${placeId}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a tour starts, the place its wind is asked for (PAD-08). */
|
||||
function placeStart(place: Place): { lat: number; lng: number } | null {
|
||||
return place.lat != null && place.lng != null ? { lat: place.lat, lng: place.lng } : 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. */
|
||||
function tourTimeFields(times: ReturnType<typeof tourPlannedTimes>, t: Translate): TourMetricField[] {
|
||||
function tourTimeFields(
|
||||
times: ReturnType<typeof tourPlannedTimes>,
|
||||
t: Translate,
|
||||
tour: Pick<TourListItem, 'tour_type'>
|
||||
): TourMetricField[] {
|
||||
return [
|
||||
times.walkingMinutes != null
|
||||
? { label: t('tours.planner.walkingShort'), value: formatPlannedTourDuration(times.walkingMinutes) }
|
||||
? {
|
||||
label: movingTimeLabel(tour, t('tours.planner.walkingShort'), t),
|
||||
value: formatPlannedTourDuration(times.walkingMinutes),
|
||||
}
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? { label: t('tours.planner.breaksShort'), value: formatPlannedTourDuration(times.breakMinutes) }
|
||||
@@ -166,9 +223,25 @@ function TourHeadPills({
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />
|
||||
{createElement(tourDayRowIcon(tour.tour_type), {
|
||||
size: 11,
|
||||
strokeWidth: 2,
|
||||
className: 'flex-none',
|
||||
style: { color: trackColor },
|
||||
})}
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
{/* A paddle tour names its boat and its water, each a badge of its own. */}
|
||||
{tour.boat_type && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-boat">
|
||||
{t(`tours.paddle.boat.${tour.boat_type}`)}
|
||||
</span>
|
||||
)}
|
||||
{tour.water_context && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)} data-testid="tour-detail-water">
|
||||
{t(`tours.paddle.water.${tour.water_context}`)}
|
||||
</span>
|
||||
)}
|
||||
{source !== 'trek' && (
|
||||
<span className={HEAD_PILL} style={fs(10.5)}>
|
||||
<FileDown size={11} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
@@ -337,7 +410,11 @@ export default function TourDetailDialog({
|
||||
);
|
||||
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
|
||||
|
||||
const analysis = useMemo(() => analyzeRouteGeometry(place.route_geometry), [place.route_geometry]);
|
||||
const heightsHidden = heightsUnknown(tour);
|
||||
const analysis = useMemo(() => {
|
||||
const measured = analyzeRouteGeometry(place.route_geometry);
|
||||
return measured && heightsHidden ? withoutHeights(measured) : measured;
|
||||
}, [heightsHidden, place.route_geometry]);
|
||||
const trackColor = resolveTrackColor(place);
|
||||
const inheritedColor = inheritedTrackColor(place);
|
||||
const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id);
|
||||
@@ -350,7 +427,7 @@ export default function TourDetailDialog({
|
||||
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website);
|
||||
const times = tourPlannedTimes(tour);
|
||||
|
||||
const timeFields = tourTimeFields(times, t);
|
||||
const timeFields = tourTimeFields(times, t, tour);
|
||||
|
||||
const header = (
|
||||
<>
|
||||
@@ -562,20 +639,16 @@ export default function TourDetailDialog({
|
||||
</DialogSection>
|
||||
|
||||
{analysis && (
|
||||
<div className="flex flex-none flex-col gap-2">
|
||||
<TourMetricFields analysis={analysis} unit={distanceUnit} columns={5} />
|
||||
{timeFields.length > 0 && <TourFactFields fields={timeFields} columns={3} />}
|
||||
{analysis.distanceIndexedProfileSamples.length >= 2 && (
|
||||
<div className={`${BOX} p-2`}>
|
||||
<ElevationProfile
|
||||
samples={analysis.distanceIndexedProfileSamples}
|
||||
color={trackColor}
|
||||
gradientId={`tour-elevation-${place.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TourDetailMeasures
|
||||
analysis={analysis}
|
||||
unit={distanceUnit}
|
||||
timeFields={timeFields}
|
||||
trackColor={trackColor}
|
||||
placeId={place.id}
|
||||
/>
|
||||
)}
|
||||
{tour.tour_type === 'kayak' && <PaddleCheckSection tour={tour} />}
|
||||
{tour.tour_type === 'kayak' && <PaddleWindSection placeId={place.id} start={placeStart(place)} />}
|
||||
|
||||
{description && (
|
||||
<DialogSection className="flex-none" label={t('places.formDescription')}>
|
||||
|
||||
@@ -6,7 +6,15 @@ import TourGpxDropZone from './TourGpxDropZone';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
function gpxImport(overrides: Partial<TourGpxImport> = {}): TourGpxImport {
|
||||
return { importing: false, importFiles: vi.fn().mockResolvedValue(undefined), pick: vi.fn(), ...overrides };
|
||||
return {
|
||||
importing: false,
|
||||
importFiles: vi.fn().mockResolvedValue(undefined),
|
||||
pick: vi.fn(),
|
||||
preview: null,
|
||||
confirm: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Ban, FileDown, Loader2 } from 'lucide-react';
|
||||
import { useRef, useState, type DragEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
/** Only a drag of files from the desktop; a waypoint or a tour row being dragged is not one. */
|
||||
@@ -64,6 +65,7 @@ export default function TourGpxDropZone({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
{(over || gpxImport.importing) && (
|
||||
<div
|
||||
data-testid="tour-gpx-drop"
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { PaddleBoatType, PaddleWaterContext, PlannableTourType, TourGpxImportFields } from '@trek/shared';
|
||||
import { AlertTriangle, FileDown, Info, Mountain, Waves } from 'lucide-react';
|
||||
import { useId, useMemo, useState } 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 { SoftPill } from '../Planner/planParts';
|
||||
import { LABEL, Segmented } from '../shared/dialogParts';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
} from '../shared/DialogShell';
|
||||
import { TourNotice } from './tourParts';
|
||||
import type { GpxPreviewFile, TourGpxImport } from './useTourGpxImport';
|
||||
|
||||
const BOATS: readonly PaddleBoatType[] = ['kayak', 'canoe', 'sup', 'other'];
|
||||
const WATERS: readonly PaddleWaterContext[] = ['river', 'lake'];
|
||||
const BADGE =
|
||||
'inline-flex flex-none items-center rounded-full bg-surface-secondary px-2 font-geist tabular-nums leading-[20px] text-content-secondary';
|
||||
|
||||
/** One track of the file as a row: its name, and what it measures as badges; a track the trip holds says so. */
|
||||
function PreviewTrackRow({ name, track }: { name: string; track: GpxPreviewFile['tracks'][number] }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const heights = track.elevation_gain_m != null && track.elevation_loss_m != null;
|
||||
return (
|
||||
<li
|
||||
data-testid="gpx-preview-track"
|
||||
data-duplicate={track.duplicate || undefined}
|
||||
className={`flex min-w-0 flex-col gap-1.5 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5 ${track.duplicate ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{track.name || name}
|
||||
</span>
|
||||
{track.duplicate && <SoftPill tone="warning">{t('tours.import.previewDuplicate')}</SoftPill>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5" style={fs(11)}>
|
||||
<span className={BADGE}>{formatDistance(track.distance_km, unit)}</span>
|
||||
{heights ? (
|
||||
<>
|
||||
<span className={BADGE}>↑ {formatElevation(track.elevation_gain_m ?? 0, unit)}</span>
|
||||
<span className={BADGE}>↓ {formatElevation(track.elevation_loss_m ?? 0, unit)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={BADGE}>{t('tours.import.previewNoHeights')}</span>
|
||||
)}
|
||||
<span className={BADGE}>{t('tours.import.previewPoints', { count: track.point_count })}</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: a GPX file shown before anything is stored. The whole course on a
|
||||
* map, every track with its measures, the tracks the trip already holds named
|
||||
* as skipped, and the type to import them as; a paddle import names its boat
|
||||
* and water, as a planned paddle tour does. Closing it stores nothing.
|
||||
*/
|
||||
export default function TourGpxPreviewDialog({ gpxImport }: { gpxImport: TourGpxImport }) {
|
||||
const { preview } = gpxImport;
|
||||
if (!preview) return null;
|
||||
return <PreviewDialog gpxImport={gpxImport} files={preview} />;
|
||||
}
|
||||
|
||||
function PreviewDialog({ gpxImport, files }: { gpxImport: TourGpxImport; files: GpxPreviewFile[] }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = 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 tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
const lines = useMemo(
|
||||
() =>
|
||||
files.flatMap((file) =>
|
||||
file.tracks.map((track) => track.geometry.map(([lat, lng]): [number, number] => [lat, lng]))
|
||||
),
|
||||
[files]
|
||||
);
|
||||
const focusPoints = useMemo(() => lines.flat(), [lines]);
|
||||
const paddleIncomplete = type === 'kayak' && (boat === null || water === null);
|
||||
const canImport = fresh > 0 && !paddleIncomplete && !gpxImport.importing;
|
||||
const fields: TourGpxImportFields =
|
||||
type === 'kayak' && boat && water
|
||||
? { tour_type: 'kayak', boat_type: boat, water_context: water }
|
||||
: { tour_type: 'hike' };
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={gpxImport.cancel}
|
||||
labelledBy={labelId}
|
||||
width="editor"
|
||||
blocked={gpxImport.importing}
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<FileDown size={20} strokeWidth={2} className="text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={gpxImport.cancel}
|
||||
eyebrow={t('tours.import.previewTitle')}
|
||||
title={title}
|
||||
sub={t('tours.import.previewHint')}
|
||||
subWraps
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton onClick={gpxImport.cancel} disabled={gpxImport.importing}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
data-testid="gpx-preview-import"
|
||||
icon={<FileDown size={14} strokeWidth={2} />}
|
||||
disabled={!canImport}
|
||||
onClick={() => void gpxImport.confirm(fields)}
|
||||
>
|
||||
{t('tours.import.previewConfirm', { count: fresh })}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-testid="gpx-preview-map"
|
||||
className="relative h-[280px] flex-none overflow-hidden rounded-[14px] border border-edge-faint"
|
||||
>
|
||||
<MapViewAuto
|
||||
places={[]}
|
||||
route={lines}
|
||||
tileUrl={tileUrl}
|
||||
focusPoints={focusPoints}
|
||||
focusKey={files.map(({ file }) => file.name).join('|')}
|
||||
followSelection={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogSection label={t('tours.import.previewTracks', { count: tracks.length })}>
|
||||
<ul className="m-0 flex list-none flex-col gap-1.5 p-0">
|
||||
{tracks.map(({ file, track }, index) => (
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
{fresh === 0 && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.import.previewAllDuplicates')}
|
||||
</TourNotice>
|
||||
</div>
|
||||
)}
|
||||
</DialogSection>
|
||||
|
||||
<DialogSection label={t('tours.import.previewAs')}>
|
||||
<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} /> },
|
||||
]}
|
||||
/>
|
||||
{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">
|
||||
<span className={LABEL}>{t('tours.paddle.boat')}</span>
|
||||
<Segmented<PaddleBoatType>
|
||||
value={(boat ?? '') as PaddleBoatType}
|
||||
onChange={setBoat}
|
||||
label={t('tours.paddle.boat')}
|
||||
fill
|
||||
options={BOATS.map((value) => ({ value, label: t(`tours.paddle.boat.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<span className={LABEL}>{t('tours.paddle.water')}</span>
|
||||
<Segmented<PaddleWaterContext>
|
||||
value={(water ?? '') as PaddleWaterContext}
|
||||
onChange={setWater}
|
||||
label={t('tours.paddle.water')}
|
||||
fill
|
||||
options={WATERS.map((value) => ({ value, label: t(`tours.paddle.water.${value}`) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{paddleIncomplete && (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.chooseBoatImport')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
</DialogSection>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['map
|
||||
)
|
||||
}
|
||||
|
||||
/** The tile, the name and one line of badges with the description trailing after them. */
|
||||
/** The tile, the name, the description on a line of its own and one line of badges. */
|
||||
function TourRowBody({ tour, nameId, tourDays, dimmed }: {
|
||||
tour: TourListItem
|
||||
nameId: string
|
||||
@@ -60,24 +60,24 @@ function TourRowBody({ tour, nameId, tourDays, dimmed }: {
|
||||
<div className="min-w-0 flex-1">
|
||||
<span id={nameId} className="block min-w-0 truncate font-semibold leading-tight text-content"
|
||||
style={fs(13, 'body')}>{tour.name}</span>
|
||||
{/* One line like a plan row's: the badges keep their place, the description gives way,
|
||||
and what does not fit fades out instead of ending at a hard edge. */}
|
||||
{tour.description && (
|
||||
<span
|
||||
data-testid="tour-description-preview"
|
||||
className="mt-0.5 block min-w-0 truncate text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{tour.description}
|
||||
</span>
|
||||
)}
|
||||
{/* One line of whole badges, most important first: one that does not fit wraps onto a
|
||||
second line the fixed height hides, so a narrow sidebar shows two and never half of one. */}
|
||||
<div
|
||||
data-testid="tour-metrics"
|
||||
className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden"
|
||||
className="-mx-0.5 mt-0.5 flex h-6 min-w-0 flex-wrap content-start gap-x-1.5 gap-y-2 overflow-hidden px-0.5 pt-0.5"
|
||||
>
|
||||
<TourCautionBadge tour={tour} />
|
||||
<TourDayRowPills tour={tour} />
|
||||
<TourDayRowPills tour={tour} compact />
|
||||
<TourDaysBadge tourDays={tourDays} />
|
||||
{tour.description && (
|
||||
<span
|
||||
data-testid="tour-description-preview"
|
||||
className="min-w-0 flex-1 truncate text-content-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{tour.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,9 +108,9 @@ function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps,
|
||||
|
||||
/**
|
||||
* A tour in a list, laid out like a stop in the day plan: its tile with the
|
||||
* grade, the name, and one line of the plan's white badges (time, distance,
|
||||
* ascent, the days it is on, a caution) with the description trailing after
|
||||
* them. The day and delete actions sit on the right.
|
||||
* grade, the name, the description below it, and one line of the plan's
|
||||
* white badges (time, distance, ascent, the days it is on, a caution). The day
|
||||
* and delete actions sit on the right.
|
||||
*/
|
||||
export default function TourListRow({ tour,
|
||||
days = NO_DAYS,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import TourWorkspaceRail from './TourWorkspaceRail';
|
||||
import TourWorkspaceRail, { type TourWorkspaceRailParts } from './TourWorkspaceRail';
|
||||
import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
@@ -23,18 +25,13 @@ function wanderer(overrides: Partial<WandererBrowser>): WandererBrowser {
|
||||
return { open: false, enabled: true, openBrowser: vi.fn(), ...overrides } as unknown as WandererBrowser;
|
||||
}
|
||||
|
||||
function workspace(browser: WandererBrowser): TourWorkspaceRailParts {
|
||||
return { tourPlanner: planner, wandererBrowser: browser, tourConversion: conversion, recordedTrack };
|
||||
}
|
||||
|
||||
describe('TourWorkspaceRail', () => {
|
||||
it('RS-04: shows the wanderer trails in place of the editor while they are browsed', () => {
|
||||
render(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={wanderer({ open: true })}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
render(<TourWorkspaceRail workspace={workspace(wanderer({ open: true }))} canEdit canAssign />);
|
||||
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -42,16 +39,7 @@ describe('TourWorkspaceRail', () => {
|
||||
it('RS-04: offers the wanderer trails from the editor only while wanderer may be used', () => {
|
||||
const browser = wanderer({});
|
||||
seen.rail.length = 0;
|
||||
const view = render(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={browser}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign={false}
|
||||
/>
|
||||
);
|
||||
const view = render(<TourWorkspaceRail workspace={workspace(browser)} canEdit canAssign={false} />);
|
||||
expect(screen.getByText('tour editor')).toBeInTheDocument();
|
||||
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
|
||||
planner,
|
||||
@@ -62,16 +50,17 @@ describe('TourWorkspaceRail', () => {
|
||||
onOpenWanderer: browser.openBrowser,
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<TourWorkspaceRail
|
||||
planner={planner}
|
||||
wanderer={wanderer({ enabled: false })}
|
||||
conversion={conversion}
|
||||
recordedTrack={recordedTrack}
|
||||
canEdit
|
||||
canAssign
|
||||
/>
|
||||
);
|
||||
view.rerender(<TourWorkspaceRail workspace={workspace(wanderer({ enabled: false }))} canEdit canAssign />);
|
||||
expect(seen.rail[seen.rail.length - 1]?.onOpenWanderer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('PCW-001: hands the paddle plan and the checklist of the tour in view on to the editor rail', () => {
|
||||
const trackPaddlePlan = { pick: null } as unknown as TrackPaddlePlan;
|
||||
const paddleChecklist = { status: 'ready' } as unknown as PaddleChecklist;
|
||||
const parts = { ...workspace(wanderer({})), trackPaddlePlan, paddleChecklist };
|
||||
seen.rail.length = 0;
|
||||
render(<TourWorkspaceRail workspace={parts} canEdit canAssign />);
|
||||
expect(seen.rail[seen.rail.length - 1]?.trackPaddlePlan).toBe(trackPaddlePlan);
|
||||
expect(seen.rail[seen.rail.length - 1]?.paddleChecklist).toBe(paddleChecklist);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import type { GpxConversion } from './conversion/useGpxConversion';
|
||||
import type { PaddleChecklist } from './paddle/usePaddleChecklist';
|
||||
import type { TrackPaddlePlan } from './paddle/useTrackPaddlePlan';
|
||||
import { TourPlannerRail } from './planner/TourPlannerPanels';
|
||||
import type { TourPlannerController } from './planner/useTourPlanner';
|
||||
import type { RecordedTrack } from './useRecordedTrack';
|
||||
import WandererBrowserRail from './wanderer/WandererBrowserRail';
|
||||
import type { WandererBrowser } from './wanderer/useWandererBrowser';
|
||||
|
||||
/** What the tours workspace hands its left rail: the parts of the planner page's state, by their names there. */
|
||||
export interface TourWorkspaceRailParts {
|
||||
tourPlanner: TourPlannerController;
|
||||
wandererBrowser: WandererBrowser;
|
||||
tourConversion: GpxConversion;
|
||||
recordedTrack: RecordedTrack;
|
||||
/** The portages and locks of a recorded paddle track in view. */
|
||||
trackPaddlePlan?: TrackPaddlePlan;
|
||||
/** The obstacle checklist of the paddle tour open in the planner (PAD-06). */
|
||||
paddleChecklist?: PaddleChecklist;
|
||||
}
|
||||
|
||||
/** The tours workspace's left rail: the tour editor, or the wanderer trails while those are browsed. */
|
||||
export default function TourWorkspaceRail({
|
||||
planner,
|
||||
wanderer,
|
||||
conversion,
|
||||
recordedTrack,
|
||||
workspace,
|
||||
canEdit,
|
||||
canAssign,
|
||||
}: {
|
||||
planner: TourPlannerController;
|
||||
wanderer: WandererBrowser;
|
||||
conversion: GpxConversion;
|
||||
recordedTrack: RecordedTrack;
|
||||
workspace: TourWorkspaceRailParts;
|
||||
canEdit: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const {
|
||||
tourPlanner,
|
||||
wandererBrowser: wanderer,
|
||||
tourConversion,
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
paddleChecklist,
|
||||
} = workspace;
|
||||
return wanderer.open ? (
|
||||
<WandererBrowserRail browser={wanderer} />
|
||||
) : (
|
||||
<TourPlannerRail
|
||||
planner={planner}
|
||||
planner={tourPlanner}
|
||||
canEdit={canEdit}
|
||||
canAssign={canAssign}
|
||||
conversion={conversion}
|
||||
conversion={tourConversion}
|
||||
recordedTrack={recordedTrack}
|
||||
trackPaddlePlan={trackPaddlePlan}
|
||||
paddleChecklist={paddleChecklist}
|
||||
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,13 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { toursApi } from '../../api/client'
|
||||
import { tourGpxApi } from '../../api/tourGpx'
|
||||
import ToursSidebar from './ToursSidebar'
|
||||
import { pickFiles } from '../../../tests/helpers/filePicker'
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }))
|
||||
|
||||
vi.mock('../Map/MapViewAuto', () => ({ MapViewAuto: () => <div data-testid="gpx-preview-map-stub" /> }))
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }))
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
@@ -41,6 +42,19 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } })
|
||||
})
|
||||
|
||||
function previewTrack(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'Ridge',
|
||||
geometry: [[47, 11, 600], [47.01, 11.01, 650], [47.02, 11.02, 640]] as [number, number, number][],
|
||||
point_count: 3,
|
||||
distance_km: 2.4,
|
||||
elevation_gain_m: 50,
|
||||
elevation_loss_m: 10,
|
||||
duplicate: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToursSidebar', () => {
|
||||
it('uses the compact counted dropdown for All, Unplanned and Planned', () => {
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[tour, { ...tour, place_id: 43, name: 'Planned walk', planned: true }]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
@@ -61,15 +75,18 @@ describe('ToursSidebar', () => {
|
||||
expect(row).toHaveClass('bg-surface-selected')
|
||||
})
|
||||
|
||||
it('RS-01: trails the description after the badges on one line, as a plan row does', () => {
|
||||
it('RS-01: puts the description on its own line between the name and the badges', () => {
|
||||
const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[listed]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
const row = screen.getByRole('option', { name: /Selected ridge walk/i })
|
||||
const line = within(row).getByTestId('tour-metrics')
|
||||
expect(line).toHaveClass('flex-nowrap', 'overflow-hidden')
|
||||
expect(line).toHaveClass('flex-wrap', 'h-6', 'overflow-hidden')
|
||||
expect(line).toHaveTextContent('1 h 35 min')
|
||||
expect(within(line).getByTestId('tour-description-preview')).toHaveTextContent('A quiet path above the lake')
|
||||
expect(within(line).getByTestId('tour-description-preview')).toHaveClass('truncate')
|
||||
const description = within(row).getByTestId('tour-description-preview')
|
||||
expect(line).not.toContainElement(description)
|
||||
expect(description).toHaveTextContent('A quiet path above the lake')
|
||||
expect(description).toHaveClass('truncate')
|
||||
expect(description.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
// The information link belongs to the detail, not the list row.
|
||||
expect(row).not.toHaveTextContent('Komoot')
|
||||
})
|
||||
@@ -205,7 +222,8 @@ describe('ToursSidebar', () => {
|
||||
})
|
||||
|
||||
it('quarantines returned Tour ids before loading the imported Places', async () => {
|
||||
const importCall = vi.spyOn(toursApi, 'importGpx').mockResolvedValue({
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({
|
||||
tours: [tour], caution: false, skipped: 0,
|
||||
})
|
||||
const onToursChanged = vi.fn()
|
||||
@@ -224,6 +242,7 @@ describe('ToursSidebar', () => {
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
|
||||
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
// Only the places are read again, not the whole trip: that would rebuild the workspace.
|
||||
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
|
||||
@@ -231,11 +250,13 @@ describe('ToursSidebar', () => {
|
||||
expect(onToursChanged).toHaveBeenCalledWith([42])
|
||||
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(refreshPlaces.mock.invocationCallOrder[0])
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reports duplicate-only GPX success without refetching or invalidating unchanged data', async () => {
|
||||
vi.spyOn(toursApi, 'importGpx').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack()] })
|
||||
vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [], caution: false, skipped: 3 })
|
||||
const onToursChanged = vi.fn()
|
||||
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
|
||||
render(<ToursSidebar
|
||||
@@ -250,6 +271,7 @@ describe('ToursSidebar', () => {
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
|
||||
expect(onToursChanged).not.toHaveBeenCalled()
|
||||
@@ -257,9 +279,10 @@ describe('ToursSidebar', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('RC-04: names each file that fails and still imports the rest', async () => {
|
||||
vi.spyOn(toursApi, 'importGpx')
|
||||
vi.spyOn(tourGpxApi, 'preview')
|
||||
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
|
||||
.mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
|
||||
.mockResolvedValueOnce({ tracks: [previewTrack()] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValueOnce({ tours: [tour], caution: true, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
@@ -269,15 +292,78 @@ describe('ToursSidebar', () => {
|
||||
new File(['<gpx/>'], 'empty.gpx', { type: 'application/gpx+xml' }),
|
||||
new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' }),
|
||||
])
|
||||
// The file without a track is named at once and left out of the preview.
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported from GPX'))
|
||||
expect(toast.error).toHaveBeenCalledWith('No track or route found in empty.gpx')
|
||||
expect(importCall).toHaveBeenCalledOnce()
|
||||
expect(toast.info).toHaveBeenCalledWith('Imported with caution — elevation/metrics incomplete')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: shows the file before storing anything, and closing the preview stores nothing', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack(), previewTrack({ name: 'Back', duplicate: true })] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('GPX preview')
|
||||
expect(dialog).toHaveTextContent('ridge.gpx')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[1]).toHaveTextContent('Already in the trip')
|
||||
expect(screen.getAllByTestId('gpx-preview-track')[0]).toHaveTextContent('3 points')
|
||||
// Only the track the trip does not hold yet is imported.
|
||||
expect(screen.getByRole('button', { name: 'Import 1 tour' })).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
||||
expect(importCall).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: a file the trip holds completely cannot be imported again, and says why', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ duplicate: true })] })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })])
|
||||
expect(await screen.findByText('Every track is already in the trip, so there is nothing to import.')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('gpx-preview-import')).toBeDisabled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('PAD-02: a paddle import waits for its boat and water, then sends them along', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview').mockResolvedValue({ tracks: [previewTrack({ elevation_gain_m: null, elevation_loss_m: null })] })
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
const file = new File(['<gpx/>'], 'lake.gpx', { type: 'application/gpx+xml' })
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])
|
||||
await screen.findByRole('dialog')
|
||||
// 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' }))
|
||||
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()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'SUP' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Lake' }))
|
||||
expect(confirm).toBeEnabled()
|
||||
fireEvent.click(confirm)
|
||||
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' }))
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RC-04: refuses files that are not GPX before uploading anything', async () => {
|
||||
const importCall = vi.spyOn(toursApi, 'importGpx')
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import')
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import TourListFilterMenu from './TourListFilterMenu'
|
||||
import { useTourListFilters } from './useTourListFilters'
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog'
|
||||
import { useTourGpxImport } from './useTourGpxImport'
|
||||
import { type AssignTourToDay, useTourDayAssignment } from './useTourDayAssignment'
|
||||
import { useTourMapVisibility } from './useTourMapVisibility'
|
||||
@@ -130,6 +131,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
|
||||
return card(
|
||||
<div className="flex flex-col items-start gap-2.5">
|
||||
<p className="m-0 leading-snug text-content-secondary" style={fs(12, 'body')}>
|
||||
{t('tours.conversion.intro')}
|
||||
{t(conversion.paddle ? 'tours.conversion.introPaddle' : 'tours.conversion.intro')}
|
||||
</p>
|
||||
<CardAction onClick={() => void conversion.analyze()} icon={<Route size={12} strokeWidth={2.2} />}>
|
||||
{t('tours.conversion.analyze')}
|
||||
@@ -138,7 +138,13 @@ export default function TourConversionSection({ conversion }: { conversion: GpxC
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(faithful ? 'tours.conversion.close' : 'tours.conversion.different')}
|
||||
{t(
|
||||
faithful
|
||||
? conversion.paddle
|
||||
? 'tours.conversion.closePaddle'
|
||||
: 'tours.conversion.close'
|
||||
: 'tours.conversion.different'
|
||||
)}
|
||||
</StatusLine>
|
||||
<div className="flex flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
<Tooltip label={t('tours.conversion.replaceHint')}>
|
||||
|
||||
@@ -1,6 +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 { paddleFieldsOf } from '../planner/usePaddleFields';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { compareRoutes, deriveControlPoints, type RouteComparison, type TrackPoint } from './gpxConversion';
|
||||
|
||||
@@ -37,6 +39,8 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
const track = planner.readOnlyGpxAnalysis?.routeCoordinates ?? null;
|
||||
const current = tour !== null && state.placeId === tour.place_id ? state : IDLE;
|
||||
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';
|
||||
|
||||
useEffect(() => () => abort.current?.abort(), []);
|
||||
|
||||
@@ -53,7 +57,13 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
return;
|
||||
}
|
||||
setState({ ...base, status: 'analyzing' });
|
||||
const routed = await routeWalkingTour(points, controller.signal, difficulty);
|
||||
const legs = paddle
|
||||
? points.map((point, index) => ({
|
||||
...point,
|
||||
fixedLeg: index < points.length - 1 ? freeLegBetween(point, points[index + 1]) : null,
|
||||
}))
|
||||
: points;
|
||||
const routed = await routeWalkingTour(legs, controller.signal, difficulty);
|
||||
if (controller.signal.aborted) return;
|
||||
if (!routed) {
|
||||
setState({ ...base, status: 'failed', error: 'routing' });
|
||||
@@ -66,7 +76,7 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
line: routed.coordinates,
|
||||
comparison: compareRoutes(trackPoints, suggestion),
|
||||
});
|
||||
}, [difficulty, tour, track]);
|
||||
}, [difficulty, paddle, tour, track]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abort.current?.abort();
|
||||
@@ -89,11 +99,12 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
points: current.points,
|
||||
sourcePlaceId: tour.place_id,
|
||||
inPlace: how === 'replace',
|
||||
...(paddle ? { tourType: 'kayak' as const, paddle: paddleFieldsOf(tour) } : {}),
|
||||
});
|
||||
if (started) setState(IDLE);
|
||||
return started;
|
||||
},
|
||||
[current.points, current.status, difficulty, startDerivedTour, tour]
|
||||
[current.points, current.status, difficulty, paddle, startDerivedTour, tour]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
@@ -102,11 +113,13 @@ export function useGpxConversion(planner: TourPlannerController) {
|
||||
/** The import shown can be planned from: it has a track and the user may add tours. */
|
||||
available: tour !== null && (track?.length ?? 0) >= 2 && planner.canEdit,
|
||||
difficulty,
|
||||
/** The import is a paddle tour: its version is drawn with straight legs, not routed. */
|
||||
paddle,
|
||||
analyze,
|
||||
cancel,
|
||||
adopt,
|
||||
}),
|
||||
[adopt, analyze, cancel, current, difficulty, planner.canEdit, tour, track]
|
||||
[adopt, analyze, cancel, current, difficulty, paddle, planner.canEdit, tour, track]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* A figure as a badge holding its value as a white badge, the planner's
|
||||
* badge-in-badge look: the wind of the day, a weir's height, an access rule.
|
||||
* What it means exactly can sit in its tooltip.
|
||||
*/
|
||||
export function FigureBadge({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
info,
|
||||
tone = 'neutral',
|
||||
testId,
|
||||
}: {
|
||||
label?: string;
|
||||
value: string;
|
||||
icon?: ReactNode;
|
||||
info?: string;
|
||||
/** `warning` colours the value: a rule that forbids, a way recorded as private. */
|
||||
tone?: 'neutral' | 'warning';
|
||||
testId?: string;
|
||||
}) {
|
||||
const badge = (
|
||||
<span
|
||||
data-testid={testId}
|
||||
className="inline-flex h-[24px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary pe-[3px] ps-[3px] text-content-muted"
|
||||
style={fs(10.5, 'body')}
|
||||
>
|
||||
{label && <span className="ps-1">{label}</span>}
|
||||
<span
|
||||
className={`inline-flex h-[18px] min-w-0 flex-none items-center gap-1 rounded-full bg-surface-card px-1.5 font-geist font-semibold tabular-nums shadow-sm ${tone === 'warning' ? 'text-warning' : 'text-content'}`}
|
||||
style={fs(11)}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate">{value}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
return info ? <Tooltip label={info}>{badge}</Tooltip> : badge;
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
// FE-PADDLE-DECIDE-001 to FE-PADDLE-DECIDE-016: the decision panel of one checklist entry (PAD-06).
|
||||
// FE-PADDLE-DECIDE-017 to FE-PADDLE-DECIDE-020: the panel below a paddle object's map card, wired to the checklist.
|
||||
import type { ComponentProps } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ObstacleDecision, ObstacleDecisionRecord, TourObstacleCheck, TourObstacleEntry } from '@trek/shared';
|
||||
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { ChecklistObstaclePanel, ObstacleDecisionPanel } from './ObstacleDecisionPanel';
|
||||
import { formatCheckDate } from './paddleCheckModel';
|
||||
import type { PaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
/** What the panel says, as shared/src/i18n/en/tours.ts words it. */
|
||||
const SAYS = {
|
||||
bans: 'OpenStreetMap records a ban or private ground here. A decision does not lift it.',
|
||||
portageMissing: 'Choosing it plans no portage. Plan the carry on the map.',
|
||||
gone: 'Missing from the data is no proof that the obstacle is gone.',
|
||||
supLock: 'SUP: check a portage',
|
||||
supLockInfo:
|
||||
'For SUP, the German canoe federation and rescue organisations advise checking a permitted portage at locks rather than locking through. This is advice, not a ban everywhere, and local rules still apply.',
|
||||
};
|
||||
|
||||
/** The four choices in the order the panel offers them. */
|
||||
const CHOICES = ['Paddle through', 'Carry round', 'Go round', 'Not affected'];
|
||||
|
||||
const DECIDED_AT = '2026-09-20T10:00:00.000Z';
|
||||
/** The day a decision was made, with the spaces the DOM queries read. */
|
||||
const DECIDED_ON = formatCheckDate(DECIDED_AT, 'en').replace(/\s/g, ' ');
|
||||
|
||||
/** Paddling forbidden by the canoe tag. */
|
||||
const BANNED: TourObstacleEntry['access'] = {
|
||||
value: 'no',
|
||||
source: 'canoe',
|
||||
condition: null,
|
||||
forbidden: true,
|
||||
private: false,
|
||||
};
|
||||
|
||||
function record(decision: ObstacleDecision, note: string | null = null): ObstacleDecisionRecord {
|
||||
return { decision, note, decided_at: DECIDED_AT, decided_by: 1 };
|
||||
}
|
||||
|
||||
/** The weir on the route, decided about. */
|
||||
function decidedEntry(decision: ObstacleDecision = 'pass', note: string | null = null): TourObstacleEntry {
|
||||
return obstacleEntry({ status: 'decided', decision: record(decision, note) });
|
||||
}
|
||||
|
||||
type PanelProps = ComponentProps<typeof ObstacleDecisionPanel>;
|
||||
|
||||
const onDecide = vi.fn<PanelProps['onDecide']>();
|
||||
const onNote = vi.fn<PanelProps['onNote']>(() => Promise.resolve(true));
|
||||
const onClear = vi.fn<PanelProps['onClear']>();
|
||||
const onKeep = vi.fn<PanelProps['onKeep']>();
|
||||
/** Trip member 1 is Anna; anyone else has left the trip. */
|
||||
const memberName = (id: number | null) => (id === 1 ? 'Anna' : null);
|
||||
|
||||
/** The panel for an entry a paddler may decide about, nothing to move on to and no portage to plan. */
|
||||
function panel(entry: TourObstacleEntry, props: Partial<PanelProps> = {}) {
|
||||
return (
|
||||
<ObstacleDecisionPanel
|
||||
entry={entry}
|
||||
canDecide
|
||||
saving={false}
|
||||
memberName={memberName}
|
||||
onDecide={onDecide}
|
||||
onNote={onNote}
|
||||
onClear={onClear}
|
||||
onKeep={onKeep}
|
||||
onNext={null}
|
||||
onPlanPortage={null}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPanel = (entry: TourObstacleEntry, props: Partial<PanelProps> = {}) => render(panel(entry, props));
|
||||
|
||||
/** The notices the panel shows; it holds one at least. */
|
||||
const notices = () => screen.getByTestId('obstacle-notices');
|
||||
|
||||
/**
|
||||
* A checklist as usePaddleChecklist hands it to the panel: ready, open to
|
||||
* decisions, nothing saving, entries found by their refs, every action a spy.
|
||||
*/
|
||||
function checklistOf(
|
||||
check: TourObstacleCheck | null = obstacleCheck(),
|
||||
overrides: Partial<PaddleChecklist> = {}
|
||||
): PaddleChecklist {
|
||||
return {
|
||||
status: 'ready',
|
||||
tour: null,
|
||||
check,
|
||||
summary: null,
|
||||
stale: false,
|
||||
dirty: false,
|
||||
canDecide: true,
|
||||
canPlanPortage: true,
|
||||
savingKey: null,
|
||||
selectedKey: null,
|
||||
dialogKey: null,
|
||||
entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
|
||||
select: vi.fn<PaddleChecklist['select']>(),
|
||||
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
|
||||
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
|
||||
nextOf: () => null,
|
||||
next: vi.fn<PaddleChecklist['next']>(),
|
||||
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
|
||||
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
|
||||
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
|
||||
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
|
||||
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
|
||||
memberName,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
describe('ObstacleDecisionPanel', () => {
|
||||
it('FE-PADDLE-DECIDE-001: an open entry offers the four choices as one radio group named after it, none checked', () => {
|
||||
const { rerender } = renderPanel(obstacleEntry());
|
||||
|
||||
const group = screen.getByRole('radiogroup', { name: 'Wehr Regendorf' });
|
||||
const radios = within(group).getAllByRole('radio');
|
||||
expect(radios.map((radio) => radio.textContent)).toEqual(CHOICES);
|
||||
for (const radio of radios) {
|
||||
expect(radio).toHaveAttribute('aria-checked', 'false');
|
||||
expect(radio).toBeEnabled();
|
||||
}
|
||||
// Nothing decided yet: no note to add, nothing to remove, nothing to warn about.
|
||||
expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Remove decision' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
|
||||
|
||||
// An unnamed object names the group by its kind.
|
||||
rerender(panel(obstacleEntry({ name: null, kind: 'dam' })));
|
||||
expect(screen.getByRole('radiogroup', { name: 'Dam' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-002: a choice decides; the decided one is filled and checked, and choosing it again sends nothing', () => {
|
||||
const { rerender } = renderPanel(obstacleEntry());
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Carry round' }));
|
||||
expect(onDecide).toHaveBeenCalledTimes(1);
|
||||
expect(onDecide).toHaveBeenCalledWith('portage');
|
||||
|
||||
rerender(panel(decidedEntry('portage')));
|
||||
const chosen = screen.getByRole('radio', { name: 'Carry round' });
|
||||
expect(chosen).toHaveAttribute('aria-checked', 'true');
|
||||
expect(chosen).toHaveClass('bg-accent', 'text-accent-text');
|
||||
expect(screen.getByRole('radio', { name: 'Paddle through' })).toHaveAttribute('aria-checked', 'false');
|
||||
expect(screen.getByRole('radio', { name: 'Paddle through' })).not.toHaveClass('bg-accent');
|
||||
|
||||
fireEvent.click(chosen);
|
||||
expect(onDecide).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Not affected' }));
|
||||
expect(onDecide).toHaveBeenLastCalledWith('not_affected');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-003: in review the earlier choice keeps a dashed ring, nothing is checked, the reason shows', () => {
|
||||
renderPanel(obstacleEntry({ status: 'review', changed: ['route'], decision: record('pass') }));
|
||||
|
||||
// Named with its review state too, so it is told apart by more than its ring.
|
||||
const earlier = screen.getByRole('radio', { name: 'Paddle through, Check again' });
|
||||
expect(earlier).toHaveAttribute('aria-checked', 'false');
|
||||
expect(earlier).toHaveClass('border-dashed');
|
||||
expect(earlier).not.toHaveClass('bg-accent');
|
||||
for (const name of CHOICES.slice(1)) {
|
||||
expect(screen.getByRole('radio', { name })).not.toHaveClass('border-dashed');
|
||||
}
|
||||
expect(screen.getByTestId('obstacle-decision-panel')).toHaveTextContent('Route changed');
|
||||
|
||||
// The earlier choice can be made again, against the route as it is now.
|
||||
fireEvent.click(earlier);
|
||||
expect(onDecide).toHaveBeenCalledWith('pass');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-004: a ban or private ground in the data stays a notice after every decision', () => {
|
||||
const { rerender } = renderPanel(obstacleEntry({ access: BANNED }));
|
||||
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
|
||||
|
||||
rerender(panel(obstacleEntry({ access: BANNED, status: 'decided', decision: record('pass') })));
|
||||
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
|
||||
|
||||
const privateGround = { ...BANNED, value: 'private', forbidden: false, private: true };
|
||||
rerender(panel(obstacleEntry({ access: privateGround, status: 'decided', decision: record('not_affected') })));
|
||||
expect(within(notices()).getByText(SAYS.bans)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-005: a lock advised for SUP says so, with the explanation behind an info dot', () => {
|
||||
const lock = { kind: 'lock' as const, name: 'Schleuse Regensburg' };
|
||||
const { rerender } = renderPanel(obstacleEntry({ ...lock, advice: 'sup_lock_portage' }));
|
||||
|
||||
expect(within(notices()).getByText(SAYS.supLock)).toBeInTheDocument();
|
||||
expect(within(notices()).getByRole('button', { name: SAYS.supLock })).toHaveAccessibleDescription(SAYS.supLockInfo);
|
||||
|
||||
rerender(panel(obstacleEntry(lock)));
|
||||
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-006: carrying round with no portage planned yet says so and offers to plan it', () => {
|
||||
const onPlanPortage = vi.fn<() => void>();
|
||||
const todo = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||
const { rerender } = renderPanel(todo, { onPlanPortage });
|
||||
|
||||
expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument();
|
||||
fireEvent.click(within(notices()).getByRole('button', { name: 'Plan the portage' }));
|
||||
expect(onPlanPortage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Without a way to plan it the notice stays, without the action.
|
||||
rerender(panel(todo));
|
||||
expect(within(notices()).getByText(SAYS.portageMissing)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
|
||||
|
||||
// Going round while the route still passes is no missing portage.
|
||||
rerender(panel(obstacleEntry({ status: 'todo', decision: record('reroute') }), { onPlanPortage }));
|
||||
expect(screen.queryByTestId('obstacle-notices')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-007: never more than two notices; the ban and the missing portage come before the SUP advice', () => {
|
||||
renderPanel(
|
||||
obstacleEntry({
|
||||
kind: 'lock',
|
||||
advice: 'sup_lock_portage',
|
||||
access: BANNED,
|
||||
status: 'todo',
|
||||
decision: record('portage'),
|
||||
}),
|
||||
{ onPlanPortage: vi.fn<() => void>() }
|
||||
);
|
||||
|
||||
const shown = notices();
|
||||
expect(within(shown).getByText(SAYS.bans)).toBeInTheDocument();
|
||||
expect(within(shown).getByText(SAYS.portageMissing)).toBeInTheDocument();
|
||||
expect(within(shown).queryByText(SAYS.supLock)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-008: the check on site the data records reads as a month, and as unknown when there is none', () => {
|
||||
const { rerender } = renderPanel(obstacleEntry({ local_check: '2025-06' }));
|
||||
|
||||
const badge = screen.getByTestId('obstacle-on-site');
|
||||
expect(badge).toHaveTextContent('On site');
|
||||
expect(badge).toHaveTextContent(formatCheckDate('2025-06', 'en').replace(/\s/g, ' '));
|
||||
|
||||
rerender(panel(obstacleEntry()));
|
||||
expect(screen.getByTestId('obstacle-on-site')).toHaveTextContent('unknown');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-009: a decision takes a note of two rows and at most 500 characters, saved trimmed', async () => {
|
||||
renderPanel(decidedEntry());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
||||
const box = screen.getByRole('textbox', { name: 'Note' });
|
||||
expect(box).toHaveAttribute('rows', '2');
|
||||
expect(box).toHaveAttribute('maxlength', '500');
|
||||
expect(box).toHaveFocus();
|
||||
|
||||
fireEvent.change(box, { target: { value: ' Links an der Bootsrutsche ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onNote).toHaveBeenCalledTimes(1);
|
||||
expect(onNote).toHaveBeenCalledWith('Links an der Bootsrutsche');
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-021: a note whose save failed stays in the editor with its text', async () => {
|
||||
onNote.mockResolvedValueOnce(false);
|
||||
renderPanel(decidedEntry());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts umtragen' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(onNote).toHaveBeenCalledWith('Rechts umtragen'));
|
||||
await Promise.resolve();
|
||||
expect(screen.getByRole('textbox', { name: 'Note' })).toHaveValue('Rechts umtragen');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-010: a saved note reads clamped; Cancel and an unchanged save keep it, emptying it removes it', () => {
|
||||
renderPanel(decidedEntry('pass', 'Bootsrutsche am linken Ufer'));
|
||||
|
||||
expect(screen.getByText('Bootsrutsche am linken Ufer')).toHaveClass('line-clamp-3');
|
||||
expect(screen.queryByRole('button', { name: 'Note' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
const box = screen.getByRole('textbox', { name: 'Note' });
|
||||
expect(box).toHaveValue('Bootsrutsche am linken Ufer');
|
||||
fireEvent.change(box, { target: { value: 'Anders' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onNote).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Bootsrutsche am linken Ufer')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onNote).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: ' ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onNote).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-011: Escape in the note ends the edit and goes no further, so the map card around it stays open', () => {
|
||||
const parent = vi.fn<() => void>();
|
||||
const onDocument = vi.fn<(event: KeyboardEvent) => void>();
|
||||
document.addEventListener('keydown', onDocument);
|
||||
try {
|
||||
render(<div onKeyDown={parent}>{panel(decidedEntry())}</div>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
||||
const box = screen.getByRole('textbox', { name: 'Note' });
|
||||
// Any other key reaches the card and the page as always.
|
||||
fireEvent.keyDown(box, { key: 'a' });
|
||||
expect(parent).toHaveBeenCalledTimes(1);
|
||||
expect(onDocument).toHaveBeenCalledTimes(1);
|
||||
|
||||
const delivered = fireEvent.keyDown(box, { key: 'Escape' });
|
||||
expect(delivered).toBe(false);
|
||||
expect(parent).toHaveBeenCalledTimes(1);
|
||||
expect(onDocument).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(onNote).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
document.removeEventListener('keydown', onDocument);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-012: the foot says who decided when, removes the decision and moves on to the next entry', () => {
|
||||
const onNext = vi.fn<() => void>();
|
||||
const { rerender } = renderPanel(decidedEntry(), { onNext });
|
||||
|
||||
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Someone who left the trip leaves the date alone; without a next entry there is no way on.
|
||||
rerender(panel(obstacleEntry({ status: 'decided', decision: { ...record('pass'), decided_by: 7 } })));
|
||||
expect(screen.getByText(`Decided ${DECIDED_ON}`)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
|
||||
|
||||
// An open entry moves on all the same.
|
||||
rerender(panel(obstacleEntry(), { onNext }));
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
|
||||
expect(screen.queryByText(/^Decided/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-013: an object the data no longer holds offers keeping or removing its decision, no choices', () => {
|
||||
renderPanel(earlierEntry());
|
||||
|
||||
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(SAYS.gone)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep decision' }));
|
||||
expect(onKeep).toHaveBeenCalledTimes(1);
|
||||
// One way to remove it: the foot leaves it to the actions.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-014: an earlier decision the route no longer meets has no choices to make, yet can be removed', () => {
|
||||
renderPanel(earlierEntry({ status: 'off_route' }));
|
||||
|
||||
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(SAYS.gone)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Keep decision' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-015: a viewer reads the panel: the choices, the note and removing are off, moving on is not', () => {
|
||||
const onNext = vi.fn<() => void>();
|
||||
renderPanel(decidedEntry('pass', 'Bootsrutsche'), { canDecide: false, onNext });
|
||||
|
||||
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Go round' }));
|
||||
expect(onDecide).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-016: while its change saves the choices wait, and a viewer cannot keep or remove a vanished one', () => {
|
||||
const { rerender } = renderPanel(obstacleEntry(), { saving: true });
|
||||
|
||||
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
|
||||
expect(screen.getByTestId('obstacle-decision-panel')).toHaveAttribute('aria-busy', 'true');
|
||||
|
||||
rerender(panel(earlierEntry(), { canDecide: false }));
|
||||
expect(screen.getByRole('button', { name: 'Keep decision' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Remove decision' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChecklistObstaclePanel', () => {
|
||||
it('FE-PADDLE-DECIDE-017: shows nothing without a checklist or for an object it does not list, and stays while it reads again', () => {
|
||||
const { container, rerender } = render(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/2" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
rerender(<ChecklistObstaclePanel checklist={checklistOf(null, { status: 'loading' })} featureRef="node/1" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
// Reading again keeps the panel open, locked, so a note being written is not lost.
|
||||
rerender(
|
||||
<ChecklistObstaclePanel
|
||||
checklist={checklistOf(obstacleCheck(), { status: 'loading', canDecide: false })}
|
||||
featureRef="node/1"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Paddle through' })).toBeDisabled();
|
||||
|
||||
rerender(<ChecklistObstaclePanel checklist={checklistOf()} featureRef="node/1" />);
|
||||
expect(screen.getByTestId('obstacle-decision-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-018: decides, plans the portage, notes, removes and moves on through the checklist', async () => {
|
||||
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
|
||||
const checklist = checklistOf(obstacleCheck({ entries: [entry, following] }), {
|
||||
nextOf: (item) => (item.key === entry.key ? following : null),
|
||||
});
|
||||
render(<ChecklistObstaclePanel checklist={checklist} featureRef="node/1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Go round' }));
|
||||
expect(checklist.decide).toHaveBeenCalledWith(entry, 'reroute');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan the portage' }));
|
||||
expect(checklist.planPortage).toHaveBeenCalledWith(entry);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Note' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Rechts aussteigen' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(checklist.setNote).toHaveBeenCalledWith(entry, 'Rechts aussteigen');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove decision' }));
|
||||
expect(checklist.clear).toHaveBeenCalledWith(entry);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect(checklist.next).toHaveBeenCalledWith(entry);
|
||||
expect(screen.getByText(`Decided ${DECIDED_ON} by Anna`)).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-022: someone who may not edit the tour is not offered to plan the portage', () => {
|
||||
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||
render(
|
||||
<ChecklistObstaclePanel
|
||||
checklist={checklistOf(obstacleCheck({ entries: [entry] }), { canPlanPortage: false, canDecide: false })}
|
||||
featureRef="node/1"
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Plan the portage' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-023: moving on keeps the focus in the panel when the next entry has no Next of its own', () => {
|
||||
const entry = obstacleEntry({ status: 'todo', decision: record('portage') });
|
||||
const following = obstacleEntry({ key: 'node/2', refs: ['node/2'], name: 'Wehr Pielenhofen', along_m: 3400 });
|
||||
const check = obstacleCheck({ entries: [entry, following] });
|
||||
let featureRef = 'node/1';
|
||||
const checklist = checklistOf(check, {
|
||||
nextOf: (item) => (item.key === entry.key ? following : null),
|
||||
next: vi.fn<PaddleChecklist['next']>(() => {
|
||||
featureRef = 'node/2';
|
||||
}),
|
||||
});
|
||||
const { rerender } = render(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
|
||||
const next = screen.getByRole('button', { name: 'Next' });
|
||||
next.focus();
|
||||
fireEvent.click(next);
|
||||
rerender(<ChecklistObstaclePanel checklist={checklist} featureRef={featureRef} />);
|
||||
expect(screen.getByTestId('obstacle-decision-panel')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-019: a decision about an object the data no longer holds is kept through the checklist', () => {
|
||||
const gone = earlierEntry();
|
||||
const checklist = checklistOf(obstacleCheck({ entries: [obstacleEntry(), gone] }));
|
||||
render(<ChecklistObstaclePanel checklist={checklist} featureRef="node/9" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Keep decision' }));
|
||||
expect(checklist.keep).toHaveBeenCalledWith(gone);
|
||||
expect(screen.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-DECIDE-020: the entry whose change is saving waits, as every entry does when the checklist cannot decide', () => {
|
||||
const check = obstacleCheck({ entries: [obstacleEntry(), obstacleEntry({ key: 'node/2', refs: ['node/2'] })] });
|
||||
const { rerender } = render(
|
||||
<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/2' })} featureRef="node/1" />
|
||||
);
|
||||
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeEnabled();
|
||||
|
||||
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { savingKey: 'node/1' })} featureRef="node/1" />);
|
||||
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
|
||||
|
||||
rerender(<ChecklistObstaclePanel checklist={checklistOf(check, { canDecide: false })} featureRef="node/1" />);
|
||||
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
import {
|
||||
OBSTACLE_DECISIONS,
|
||||
type ObstacleDecision,
|
||||
type ObstacleDecisionRecord,
|
||||
type TourObstacleEntry,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
ArrowRight,
|
||||
Ban,
|
||||
Check,
|
||||
LifeBuoy,
|
||||
MoveRight,
|
||||
Pencil,
|
||||
SearchX,
|
||||
Split,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
Unlink,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { CardAction, InfoDot, TourNotice } from '../tourParts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { ObstacleStatusBadges } from './PaddleCheckBadges';
|
||||
import { PortageIcon } from './PaddleGlyph';
|
||||
import { formatCheckDate } from './paddleCheckModel';
|
||||
import type { PaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/** The longest note a decision keeps, as the checklist contract allows it. */
|
||||
const NOTE_MAX = 500;
|
||||
|
||||
/** The notices an entry can call for, in the order they are shown: a ban first, since no decision lifts it. */
|
||||
const NOTICE_ORDER = ['bans', 'portage', 'sup'] as const;
|
||||
type NoticeId = (typeof NOTICE_ORDER)[number];
|
||||
/** The most notices the panel shows at once. */
|
||||
const MAX_NOTICES = 2;
|
||||
|
||||
const CHOICE_ICON: Record<ObstacleDecision, ReactNode> = {
|
||||
pass: <MoveRight size={14} strokeWidth={2.2} className="rtl:rotate-180" />,
|
||||
portage: <PortageIcon size={14} />,
|
||||
reroute: <Split size={14} strokeWidth={2.2} />,
|
||||
not_affected: <Unlink size={14} strokeWidth={2.2} />,
|
||||
};
|
||||
|
||||
/** How a choice looks: decided, decided before the route or the data changed, or not chosen. */
|
||||
type ChoiceLook = 'chosen' | 'earlier' | 'idle';
|
||||
|
||||
const CHOICE_LOOK: Record<ChoiceLook, string> = {
|
||||
chosen: 'border-transparent bg-accent text-accent-text',
|
||||
earlier: 'border-dashed border-accent bg-surface-card text-content',
|
||||
idle: 'border-edge-faint bg-surface-card text-content-secondary enabled:hover:text-content',
|
||||
};
|
||||
|
||||
const CHOICE =
|
||||
'flex min-w-0 items-center gap-1.5 rounded-[10px] border px-2.5 py-1.5 text-start font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50';
|
||||
const QUIET =
|
||||
'rounded-sm font-semibold text-content-muted underline-offset-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:text-content enabled:hover:underline disabled:cursor-default disabled:opacity-40';
|
||||
const ICON_BUTTON =
|
||||
'grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:bg-surface-hover enabled:hover:text-content disabled:cursor-default disabled:opacity-40';
|
||||
const SAVE_BUTTON =
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-full bg-accent px-2.5 font-semibold text-accent-text shadow-sm transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent enabled:hover:opacity-90 disabled:cursor-default disabled:opacity-40';
|
||||
|
||||
/** The four choices as a radio group of two by two: the decided one filled, in review the earlier one dashed. */
|
||||
function DecisionChoices({
|
||||
entry,
|
||||
label,
|
||||
disabled,
|
||||
onDecide,
|
||||
}: {
|
||||
entry: TourObstacleEntry;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
onDecide: (decision: ObstacleDecision) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const decided = entry.decision?.decision ?? null;
|
||||
// In review the earlier choice no longer holds: it is shown, not checked, and can be made again.
|
||||
const reviewing = entry.status === 'review';
|
||||
const lookOf = (choice: ObstacleDecision): ChoiceLook => {
|
||||
if (choice !== decided) return 'idle';
|
||||
return reviewing ? 'earlier' : 'chosen';
|
||||
};
|
||||
return (
|
||||
<div role="radiogroup" aria-label={label} className="grid grid-cols-2 gap-1.5">
|
||||
{OBSTACLE_DECISIONS.map((choice) => {
|
||||
const look = lookOf(choice);
|
||||
return (
|
||||
<button
|
||||
key={choice}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={look === 'chosen'}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (look !== 'chosen') onDecide(choice);
|
||||
}}
|
||||
className={`${CHOICE} ${CHOICE_LOOK[look]}`}
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<span className="flex-none">{CHOICE_ICON[choice]}</span>
|
||||
<span className="min-w-0 truncate">{t(`tours.paddle.check.decision.${choice}`)}</span>
|
||||
{/* The earlier choice is told apart by more than its dashed ring. */}
|
||||
{look === 'earlier' && <span className="sr-only">{`, ${t('tours.paddle.check.status.review')}`}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Which notices an entry calls for, at most MAX_NOTICES of them. */
|
||||
function noticesOf(entry: TourObstacleEntry): NoticeId[] {
|
||||
const wanted: Record<NoticeId, boolean> = {
|
||||
bans: entry.access.forbidden || entry.access.private,
|
||||
portage: entry.status === 'todo' && entry.decision?.decision === 'portage',
|
||||
sup: entry.advice === 'sup_lock_portage',
|
||||
};
|
||||
return NOTICE_ORDER.filter((id) => wanted[id]).slice(0, MAX_NOTICES);
|
||||
}
|
||||
|
||||
/** What stays true whatever was chosen: a ban in the data, a portage not planned yet, the SUP advice at a lock. */
|
||||
function PanelNotices({ entry, onPlanPortage }: { entry: TourObstacleEntry; onPlanPortage: (() => void) | null }) {
|
||||
const { t } = useTranslation();
|
||||
const shown = noticesOf(entry);
|
||||
if (shown.length === 0) return null;
|
||||
const notices: Record<NoticeId, ReactNode> = {
|
||||
bans: (
|
||||
<TourNotice key="bans" icon={<Ban size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.check.bansInfo')}
|
||||
</TourNotice>
|
||||
),
|
||||
portage: (
|
||||
<TourNotice key="portage" icon={<PortageIcon size={13} />}>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<span>{t('tours.paddle.check.portageMissingInfo')}</span>
|
||||
{onPlanPortage && (
|
||||
<CardAction icon={<PortageIcon size={12} />} onClick={onPlanPortage}>
|
||||
{t('tours.paddle.check.planPortage')}
|
||||
</CardAction>
|
||||
)}
|
||||
</div>
|
||||
</TourNotice>
|
||||
),
|
||||
sup: (
|
||||
<TourNotice key="sup" icon={<LifeBuoy size={13} strokeWidth={2} />}>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="min-w-0">{t('tours.paddle.check.supLock')}</span>
|
||||
<span className="-my-1 flex-none">
|
||||
<InfoDot label={t('tours.paddle.check.supLock')} description={t('tours.paddle.check.supLockInfo')} />
|
||||
</span>
|
||||
</div>
|
||||
</TourNotice>
|
||||
),
|
||||
};
|
||||
return (
|
||||
<div data-testid="obstacle-notices" className="flex flex-col gap-2">
|
||||
{shown.map((id) => notices[id])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An entry the data no longer holds: why that proves nothing, and keeping or removing its decision. */
|
||||
function GoneActions({ disabled, onKeep, onClear }: { disabled: boolean; onKeep: () => void; onClear: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<TourNotice icon={<SearchX size={13} strokeWidth={2} />}>{t('tours.paddle.check.goneInfo')}</TourNotice>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<CardAction icon={<Check size={12} strokeWidth={2.4} />} onClick={onKeep} disabled={disabled}>
|
||||
{t('tours.paddle.check.keep')}
|
||||
</CardAction>
|
||||
<CardAction icon={<Trash2 size={12} strokeWidth={2.2} />} onClick={onClear} disabled={disabled}>
|
||||
{t('tours.paddle.check.clear')}
|
||||
</CardAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Writing the note in two rows; Escape drops the edit without closing the map card or the dialog around it. */
|
||||
function NoteEditor({
|
||||
draft,
|
||||
disabled,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
draft: string;
|
||||
disabled: boolean;
|
||||
onChange: (draft: string) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const box = useRef<HTMLTextAreaElement>(null);
|
||||
// The note opens for writing, so it takes the focus once, when it opens.
|
||||
useEffect(() => {
|
||||
box.current?.focus();
|
||||
}, []);
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
// The map card and the dialog close on Escape; inside the note it only ends the edit.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={NOTE_MAX}
|
||||
ref={box}
|
||||
value={draft}
|
||||
aria-label={t('tours.paddle.check.note')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
className="w-full resize-none rounded-[10px] border border-edge bg-surface-input px-2.5 py-1.5 text-content outline-none focus:border-accent"
|
||||
style={fs(12, 'body')}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<CardAction icon={<X size={12} strokeWidth={2.4} />} onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</CardAction>
|
||||
<button type="button" onClick={onSave} disabled={disabled} className={SAVE_BUTTON} style={fs(11.5, 'body')}>
|
||||
<Check size={12} strokeWidth={2.4} aria-hidden="true" />
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The decision's note: read clamped with a way to edit it, or a way to add one. */
|
||||
function NoteBlock({
|
||||
note,
|
||||
disabled,
|
||||
onNote,
|
||||
}: {
|
||||
note: string | null;
|
||||
disabled: boolean;
|
||||
/** Resolves to whether the note was saved; a failed save keeps the text being written. */
|
||||
onNote: (note: string | null) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The text being written, or null while the note is only read.
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const save = (text: string) => {
|
||||
const next = text.trim() || null;
|
||||
if (next === note) {
|
||||
setDraft(null);
|
||||
return;
|
||||
}
|
||||
void onNote(next).then((saved) => {
|
||||
if (saved) setDraft(null);
|
||||
});
|
||||
};
|
||||
if (draft !== null)
|
||||
return (
|
||||
<NoteEditor
|
||||
draft={draft}
|
||||
disabled={disabled}
|
||||
onChange={setDraft}
|
||||
onSave={() => save(draft)}
|
||||
onCancel={() => setDraft(null)}
|
||||
/>
|
||||
);
|
||||
if (!note)
|
||||
return (
|
||||
<div>
|
||||
<CardAction icon={<StickyNote size={12} strokeWidth={2.2} />} onClick={() => setDraft('')} disabled={disabled}>
|
||||
{t('tours.paddle.check.note')}
|
||||
</CardAction>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<p
|
||||
className="m-0 line-clamp-3 min-w-0 flex-1 break-words text-content-muted"
|
||||
style={fs(11.5, 'body')}
|
||||
title={note}
|
||||
>
|
||||
{note}
|
||||
</p>
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.edit')}
|
||||
onClick={() => setDraft(note)}
|
||||
disabled={disabled}
|
||||
className={ICON_BUTTON}
|
||||
>
|
||||
<Pencil size={12} strokeWidth={2.2} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Who decided and when: the trip member by name while they are on the trip, otherwise the date alone. */
|
||||
function decidedLine(
|
||||
decision: ObstacleDecisionRecord,
|
||||
memberName: (id: number | null) => string | null,
|
||||
t: Translate,
|
||||
locale: string
|
||||
): string {
|
||||
const date = formatCheckDate(decision.decided_at, locale);
|
||||
const name = memberName(decision.decided_by);
|
||||
return name ? t('tours.paddle.check.decidedBy', { date, name }) : t('tours.paddle.check.decidedAt', { date });
|
||||
}
|
||||
|
||||
/** The foot of the panel: who decided when, removing the decision, and the way on to the next open entry. */
|
||||
function MetaLine({
|
||||
decision,
|
||||
memberName,
|
||||
clearable,
|
||||
disabled,
|
||||
onClear,
|
||||
onNext,
|
||||
}: {
|
||||
decision: ObstacleDecisionRecord | null;
|
||||
memberName: (id: number | null) => string | null;
|
||||
clearable: boolean;
|
||||
disabled: boolean;
|
||||
onClear: () => void;
|
||||
onNext: (() => void) | null;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
if (!decision && !onNext) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{decision && <span className="min-w-0">{decidedLine(decision, memberName, t, locale)}</span>}
|
||||
{decision && clearable && (
|
||||
<button type="button" onClick={onClear} disabled={disabled} className={QUIET}>
|
||||
{t('tours.paddle.check.clear')}
|
||||
</button>
|
||||
)}
|
||||
{onNext && (
|
||||
<span className="ms-auto">
|
||||
<CardAction icon={<ArrowRight size={12} strokeWidth={2.4} className="rtl:rotate-180" />} onClick={onNext}>
|
||||
{t('tours.paddle.check.next')}
|
||||
</CardAction>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-06: what the paddler decides about one obstacle of the checklist, below
|
||||
* its map card or in its dialog. The four choices, the notices that stay
|
||||
* whatever was chosen (a ban in the data, a portage not planned yet, the SUP
|
||||
* advice at a lock), the check on site the data records, a note, and who
|
||||
* decided when. An object the data no longer holds offers keeping or removing
|
||||
* its decision instead of the choices. A decision is the paddler's choice,
|
||||
* never a safety or legal clearance.
|
||||
*/
|
||||
export function ObstacleDecisionPanel({
|
||||
entry,
|
||||
canDecide,
|
||||
saving,
|
||||
memberName,
|
||||
onDecide,
|
||||
onNote,
|
||||
onClear,
|
||||
onKeep,
|
||||
onNext,
|
||||
onPlanPortage,
|
||||
}: {
|
||||
entry: TourObstacleEntry;
|
||||
canDecide: boolean;
|
||||
/** This entry's change is on its way to the server. */
|
||||
saving: boolean;
|
||||
memberName: (id: number | null) => string | null;
|
||||
onDecide: (decision: ObstacleDecision) => void;
|
||||
onNote: (note: string | null) => Promise<boolean>;
|
||||
onClear: () => void;
|
||||
onKeep: () => void;
|
||||
/** Moves on to the next entry still asking for a look; null when there is none. */
|
||||
onNext: (() => void) | null;
|
||||
/** Starts planning the portage round the obstacle; null where that cannot be offered. */
|
||||
onPlanPortage: (() => void) | null;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
const locked = !canDecide || saving;
|
||||
const gone = entry.status === 'gone';
|
||||
const label = entry.name ?? t(`tours.paddle.obstacle.kind.${entry.kind}`);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const movedOn = useRef(false);
|
||||
// Moving on keeps the focus in the panel, also when the next entry has no Next button of its own.
|
||||
useLayoutEffect(() => {
|
||||
if (!movedOn.current) return;
|
||||
movedOn.current = false;
|
||||
if (!root.current?.contains(document.activeElement)) root.current?.focus({ preventScroll: true });
|
||||
});
|
||||
const next = onNext
|
||||
? () => {
|
||||
movedOn.current = true;
|
||||
onNext();
|
||||
}
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
tabIndex={-1}
|
||||
data-testid="obstacle-decision-panel"
|
||||
aria-busy={saving || undefined}
|
||||
className="flex flex-col gap-2.5 outline-none"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5 empty:hidden">
|
||||
<ObstacleStatusBadges entry={entry} />
|
||||
</div>
|
||||
{entry.relation !== null && <DecisionChoices entry={entry} label={label} disabled={locked} onDecide={onDecide} />}
|
||||
{gone && <GoneActions disabled={locked} onKeep={onKeep} onClear={onClear} />}
|
||||
<PanelNotices entry={entry} onPlanPortage={onPlanPortage} />
|
||||
<div className="flex">
|
||||
<FigureBadge
|
||||
testId="obstacle-on-site"
|
||||
label={t('tours.paddle.check.onSite')}
|
||||
value={entry.local_check ? formatCheckDate(entry.local_check, locale) : t('tours.paddle.check.unknown')}
|
||||
/>
|
||||
</div>
|
||||
{entry.decision && <NoteBlock key={entry.key} note={entry.decision.note} disabled={locked} onNote={onNote} />}
|
||||
<MetaLine
|
||||
decision={entry.decision}
|
||||
memberName={memberName}
|
||||
clearable={!gone}
|
||||
disabled={locked}
|
||||
onClear={onClear}
|
||||
onNext={next}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The decision panel of one checklist entry wired to the checklist; the map card and the entry dialog share it. */
|
||||
export function ChecklistEntryPanel({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
|
||||
return (
|
||||
<ObstacleDecisionPanel
|
||||
entry={entry}
|
||||
canDecide={checklist.canDecide}
|
||||
saving={checklist.savingKey === entry.key}
|
||||
memberName={checklist.memberName}
|
||||
onDecide={(decision) => void checklist.decide(entry, decision)}
|
||||
onNote={(note) => checklist.setNote(entry, note)}
|
||||
onClear={() => void checklist.clear(entry)}
|
||||
onKeep={() => void checklist.keep(entry)}
|
||||
onNext={checklist.nextOf(entry) ? () => checklist.next(entry) : null}
|
||||
onPlanPortage={checklist.canPlanPortage ? () => checklist.planPortage(entry) : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The checklist's part of the map card of a paddle object (PAD-06): the
|
||||
* decision panel of the entry the object belongs to, below its facts, or
|
||||
* nothing while there is no checklist or it does not list the object.
|
||||
*/
|
||||
export function ChecklistObstaclePanel({ checklist, featureRef }: { checklist: PaddleChecklist; featureRef: string }) {
|
||||
// While the checklist reads again it keeps showing, locked, so the panel open on an object stays open.
|
||||
const entry = checklist.check ? checklist.entryForRef(featureRef) : null;
|
||||
if (!entry) return null;
|
||||
return (
|
||||
<div className="mt-3 border-t border-edge-faint pt-3">
|
||||
<ChecklistEntryPanel checklist={checklist} entry={entry} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// FE-PADDLE-ENTRY-DIALOG-001 to FE-PADDLE-ENTRY-DIALOG-007: the dialog of a checklist entry the map holds no card for.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { TourObstacleCheck, TourObstacleEntry } from '@trek/shared';
|
||||
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { earlierEntry, obstacleCheck, obstacleEntry } from '../../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
|
||||
import type { PaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
const OSM = 'View in OpenStreetMap';
|
||||
|
||||
/**
|
||||
* A checklist as usePaddleChecklist hands it to the dialog: ready, open to
|
||||
* decisions, no dialog open unless told, every action a spy.
|
||||
*/
|
||||
function checklistOf(
|
||||
check: TourObstacleCheck | null = obstacleCheck(),
|
||||
overrides: Partial<PaddleChecklist> = {}
|
||||
): PaddleChecklist {
|
||||
return {
|
||||
status: 'ready',
|
||||
tour: null,
|
||||
check,
|
||||
summary: null,
|
||||
stale: false,
|
||||
dirty: false,
|
||||
canDecide: true,
|
||||
canPlanPortage: true,
|
||||
savingKey: null,
|
||||
selectedKey: null,
|
||||
dialogKey: null,
|
||||
entryForRef: (ref) => check?.entries.find((entry) => entry.refs.includes(ref)) ?? null,
|
||||
select: vi.fn<PaddleChecklist['select']>(),
|
||||
selectOwn: vi.fn<PaddleChecklist['selectOwn']>(),
|
||||
closeDialog: vi.fn<PaddleChecklist['closeDialog']>(),
|
||||
nextOf: () => null,
|
||||
next: vi.fn<PaddleChecklist['next']>(),
|
||||
planPortage: vi.fn<PaddleChecklist['planPortage']>(),
|
||||
decide: vi.fn<PaddleChecklist['decide']>(() => Promise.resolve(true)),
|
||||
setNote: vi.fn<PaddleChecklist['setNote']>(() => Promise.resolve(true)),
|
||||
clear: vi.fn<PaddleChecklist['clear']>(() => Promise.resolve(true)),
|
||||
keep: vi.fn<PaddleChecklist['keep']>(() => Promise.resolve(true)),
|
||||
memberName: (id) => (id === 1 ? 'Anna' : null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The dialog opened for an entry the way the checklist opens it: by its key. */
|
||||
function openFor(entry: TourObstacleEntry): PaddleChecklist {
|
||||
const checklist = checklistOf(obstacleCheck({ entries: [entry] }), { dialogKey: entry.key });
|
||||
render(<ObstacleEntryDialog checklist={checklist} />);
|
||||
return checklist;
|
||||
}
|
||||
|
||||
/** The head band of the dialog, apart from the panel in its body. */
|
||||
function headerOf(dialog: HTMLElement): HTMLElement {
|
||||
const header = dialog.querySelector('header');
|
||||
if (!header) throw new Error('the dialog has no head band');
|
||||
return header;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// No distance unit chosen: the dialog reads metric.
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
describe('ObstacleEntryDialog', () => {
|
||||
it('FE-PADDLE-ENTRY-DIALOG-001: stays closed without a dialog key, for a key the checklist lacks and without a checklist', () => {
|
||||
const { rerender } = render(<ObstacleEntryDialog checklist={checklistOf()} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<ObstacleEntryDialog checklist={checklistOf(obstacleCheck(), { dialogKey: 'node/404' })} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<ObstacleEntryDialog checklist={checklistOf(null, { dialogKey: 'node/1' })} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ENTRY-DIALOG-002: names the obstacle and its kind, how far along the route it lies, and links to OSM', () => {
|
||||
openFor(obstacleEntry());
|
||||
|
||||
const header = headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }));
|
||||
expect(header).toHaveTextContent('Weir');
|
||||
expect(header).toHaveTextContent('1.2 km');
|
||||
const link = within(header).getByRole('link', { name: OSM });
|
||||
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/node/1');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link.getAttribute('rel')).toContain('noreferrer');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ENTRY-DIALOG-003: its body is the decision panel, wired to the checklist', () => {
|
||||
const entry = obstacleEntry();
|
||||
const checklist = openFor(entry);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
|
||||
expect(within(dialog).getByTestId('obstacle-decision-panel')).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'Carry round' }));
|
||||
expect(checklist.decide).toHaveBeenCalledWith(entry, 'portage');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ENTRY-DIALOG-004: an object the data no longer holds gives no distance and offers keeping its decision', () => {
|
||||
const gone = earlierEntry();
|
||||
const checklist = openFor(gone);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Alte Sohlschwelle' });
|
||||
const header = headerOf(dialog);
|
||||
expect(header).not.toHaveTextContent('km');
|
||||
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.openstreetmap.org/node/9'
|
||||
);
|
||||
expect(within(dialog).queryByRole('radiogroup')).not.toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep decision' }));
|
||||
expect(checklist.keep).toHaveBeenCalledWith(gone);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ENTRY-DIALOG-005: an unnamed obstacle is titled by its kind and links its nearest object', () => {
|
||||
openFor(obstacleEntry({ key: 'lock:way/42', refs: ['way/42', 'way/43'], kind: 'lock', name: null, along_m: 800 }));
|
||||
|
||||
const header = headerOf(screen.getByRole('dialog', { name: 'Lock' }));
|
||||
expect(within(header).getByRole('heading', { name: 'Lock' })).toBeInTheDocument();
|
||||
expect(header).toHaveTextContent('800 m');
|
||||
expect(within(header).getByRole('link', { name: OSM })).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.openstreetmap.org/way/42'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ENTRY-DIALOG-006: the distance follows the chosen unit', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
|
||||
openFor(obstacleEntry());
|
||||
|
||||
expect(headerOf(screen.getByRole('dialog', { name: 'Wehr Regendorf' }))).toHaveTextContent('0.7 mi');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-ENTRY-DIALOG-007: the close button and Escape close it, while Escape in the note only ends the note', () => {
|
||||
const decided = obstacleEntry({
|
||||
status: 'decided',
|
||||
decision: { decision: 'pass', note: null, decided_at: '2026-09-20T10:00:00.000Z', decided_by: 1 },
|
||||
});
|
||||
const checklist = openFor(decided);
|
||||
const dialog = screen.getByRole('dialog', { name: 'Wehr Regendorf' });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Note' }));
|
||||
fireEvent.keyDown(within(dialog).getByRole('textbox', { name: 'Note' }), { key: 'Escape' });
|
||||
expect(within(dialog).queryByRole('textbox')).not.toBeInTheDocument();
|
||||
expect(checklist.closeDialog).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(checklist.closeDialog).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Close' }));
|
||||
expect(checklist.closeDialog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TourObstacleEntry } from '@trek/shared';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { DialogHeader, DialogShell, DialogTile, NEUTRAL_TINT, PILL } from '../../shared/DialogShell';
|
||||
import { ChecklistEntryPanel } from './ObstacleDecisionPanel';
|
||||
import { ChecklistGlyph } from './PaddleCheckBadges';
|
||||
import { osmUrlOf } from './paddleObstacleModel';
|
||||
import type { PaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
/** The pills of the head band: how far along the route the object lies and the object on OpenStreetMap. */
|
||||
function EntryPills({ entry }: { entry: TourObstacleEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const ref = entry.refs[0];
|
||||
return (
|
||||
<>
|
||||
{entry.along_m !== null && (
|
||||
<span className={`${PILL} tabular-nums`}>{formatDistance(entry.along_m / 1000, unit)}</span>
|
||||
)}
|
||||
{ref && (
|
||||
<a href={osmUrlOf(ref)} target="_blank" rel="noopener noreferrer" className={`${PILL} hover:underline`}>
|
||||
{t('ferratas.viewInOsm')}
|
||||
<ExternalLink size={12} strokeWidth={2.2} aria-hidden="true" />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The dialog for an entry the checklist holds: its head band, then the decision panel. */
|
||||
function EntryDialog({ checklist, entry }: { checklist: PaddleChecklist; entry: TourObstacleEntry }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const kind = t(`tours.paddle.obstacle.kind.${entry.kind}`);
|
||||
return (
|
||||
<DialogShell
|
||||
onClose={checklist.closeDialog}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<ChecklistGlyph kind={entry.kind} dashed={entry.relation === 'near'} />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={checklist.closeDialog}
|
||||
title={entry.name ?? kind}
|
||||
sub={entry.name ? kind : undefined}
|
||||
pills={<EntryPills entry={entry} />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChecklistEntryPanel checklist={checklist} entry={entry} />
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-06: one entry of the checklist in a narrow dialog, for an object the
|
||||
* map holds no card for (offline, or an earlier decision the data or the
|
||||
* route no longer carries): what it is, how far along the route it lies, its
|
||||
* object on OpenStreetMap and the same decision panel the map card carries.
|
||||
*/
|
||||
export function ObstacleEntryDialog({ checklist }: { checklist: PaddleChecklist }) {
|
||||
const { check, dialogKey } = checklist;
|
||||
const entry = dialogKey === null ? null : (check?.entries.find((item) => item.key === dialogKey) ?? null);
|
||||
if (!entry) return null;
|
||||
return <EntryDialog checklist={checklist} entry={entry} />;
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// PCB-001 to PCB-033: the badges of a paddle tour's checklist (PAD-06, PAD-09): the bench as one pill, how far its
|
||||
// data reaches and how current it is, the ring a row's symbol sits in, where an entry stands and its paddling rule.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ObstacleDecision, PaddleCheck, PaddleCheckData, TourObstacleEntry } from '@trek/shared';
|
||||
|
||||
import { earlierEntry, obstacleEntry, paddleBench } from '../../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import {
|
||||
ChecklistGlyph,
|
||||
ObstacleAccessBadge,
|
||||
ObstacleStatusBadges,
|
||||
PaddleCheckDataLine,
|
||||
PaddleCheckPill,
|
||||
} from './PaddleCheckBadges';
|
||||
import { formatCheckDate } from './paddleCheckModel';
|
||||
|
||||
const NOT_CLEARANCE = 'A checklist is no safety or legal clearance.';
|
||||
const BANS_INFO = 'OpenStreetMap records a ban or private ground here. A decision does not lift it.';
|
||||
const GONE_INFO = 'Missing from the data is no proof that the obstacle is gone.';
|
||||
/** A conditional rule as OpenStreetMap writes it: shown as written, never evaluated. */
|
||||
const CONDITION = 'no @ (Nov-Mar)';
|
||||
|
||||
/** The dates of a bench as the fixtures carry them, and as the badges word them in the suite's locale (en-US). */
|
||||
const DATA_AS_OF = '2026-10-01T00:00:00.000Z';
|
||||
const READ_AT = '2026-10-02T04:20:00.000Z';
|
||||
const AS_OF = formatCheckDate(DATA_AS_OF, 'en-US');
|
||||
const READ = formatCheckDate(READ_AT, 'en-US');
|
||||
|
||||
/** No paddling rule in the tags at all. */
|
||||
const NO_RULE: TourObstacleEntry['access'] = {
|
||||
value: null,
|
||||
source: null,
|
||||
condition: null,
|
||||
forbidden: false,
|
||||
private: false,
|
||||
};
|
||||
|
||||
function decisionOf(decision: ObstacleDecision): TourObstacleEntry['decision'] {
|
||||
return { decision, note: null, decided_at: '2026-10-10T10:00:00.000Z', decided_by: 1 };
|
||||
}
|
||||
|
||||
/** What an element's tooltip says once it is hovered; the hover ends before it returns. */
|
||||
async function tooltipOf(element: HTMLElement): Promise<string | null> {
|
||||
fireEvent.mouseEnter(element);
|
||||
const text = (await screen.findByRole('tooltip')).textContent;
|
||||
fireEvent.mouseLeave(element);
|
||||
return text;
|
||||
}
|
||||
|
||||
function pillOf(check: PaddleCheck): HTMLElement {
|
||||
render(<PaddleCheckPill check={check} />);
|
||||
return screen.getByTestId('plan-tour-paddle-check');
|
||||
}
|
||||
|
||||
interface Precedence {
|
||||
id: string;
|
||||
rule: string;
|
||||
bench: Partial<PaddleCheck>;
|
||||
value: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const PRECEDENCE: Precedence[] = [
|
||||
{
|
||||
id: 'PCB-002',
|
||||
rule: 'unavailable data says so over every count and every ban',
|
||||
bench: { data: 'unavailable', open: 2, review: 1, bans: 1 },
|
||||
value: 'Unavailable',
|
||||
icon: 'circle-dashed',
|
||||
},
|
||||
{
|
||||
id: 'PCB-003',
|
||||
rule: 'no data says so and never shows a count',
|
||||
bench: { data: 'none', open: 2, decided: 3 },
|
||||
value: 'No data',
|
||||
icon: 'circle-dashed',
|
||||
},
|
||||
{
|
||||
id: 'PCB-004',
|
||||
rule: 'entries open or to check again come before a partial coverage, counted together',
|
||||
bench: { data: 'partial', open: 1, review: 2 },
|
||||
value: '3 open',
|
||||
icon: 'list-checks',
|
||||
},
|
||||
{
|
||||
id: 'PCB-005',
|
||||
rule: 'partial data with nothing pending names the coverage',
|
||||
bench: { data: 'partial', open: 0, decided: 2 },
|
||||
value: 'Partial',
|
||||
icon: 'list-checks',
|
||||
},
|
||||
{
|
||||
id: 'PCB-006',
|
||||
rule: 'data cut short with nothing pending names it',
|
||||
bench: { data: 'limited', open: 0 },
|
||||
value: 'Cut short',
|
||||
icon: 'list-checks',
|
||||
},
|
||||
{
|
||||
id: 'PCB-007',
|
||||
rule: 'a route without known obstacles says none known, never clear',
|
||||
bench: { entries: 0, open: 0 },
|
||||
value: 'None known',
|
||||
icon: 'list-checks',
|
||||
},
|
||||
{
|
||||
id: 'PCB-008',
|
||||
rule: 'every entry decided counts the decisions',
|
||||
bench: { entries: 2, open: 0, decided: 2 },
|
||||
value: '2 decided',
|
||||
icon: 'list-checks',
|
||||
},
|
||||
];
|
||||
|
||||
describe('PaddleCheckPill', () => {
|
||||
it('PCB-001: an open entry on covered data reads as open and is named by its counts and the caveat', () => {
|
||||
const pill = pillOf(paddleBench());
|
||||
|
||||
expect(pill.textContent).toBe('1 open');
|
||||
expect(pill).toHaveAttribute('aria-label', `1 open\n${NOT_CLEARANCE}`);
|
||||
expect(pill.querySelector('svg.lucide-list-checks')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(PRECEDENCE)('$id: $rule', ({ bench, value, icon }) => {
|
||||
const pill = pillOf(paddleBench(bench));
|
||||
|
||||
expect(pill.textContent).toBe(value);
|
||||
expect(pill.querySelector(`svg.lucide-${icon}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PCB-009: the tooltip lists the data, every count, the ban and that a checklist is no clearance', async () => {
|
||||
const pill = pillOf(paddleBench({ data: 'partial', open: 2, review: 1, decided: 3, bans: 1 }));
|
||||
const label = [
|
||||
'Obstacle data: Partial',
|
||||
'2 open',
|
||||
'1 to check again',
|
||||
'3 decided',
|
||||
'1 with a ban',
|
||||
BANS_INFO,
|
||||
NOT_CLEARANCE,
|
||||
].join('\n');
|
||||
|
||||
expect(pill.textContent).toBe('3 open');
|
||||
expect(pill.querySelector('svg.lucide-ban')).not.toBeNull();
|
||||
expect(pill).toHaveAttribute('aria-label', label);
|
||||
expect(await tooltipOf(pill)).toBe(label);
|
||||
});
|
||||
});
|
||||
|
||||
interface Coverage {
|
||||
id: string;
|
||||
data: Exclude<PaddleCheckData, 'covered'>;
|
||||
word: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
const COVERAGE: Coverage[] = [
|
||||
{
|
||||
id: 'PCB-011',
|
||||
data: 'partial',
|
||||
word: 'Partial',
|
||||
info: `Obstacle data covers only part of the route (as of ${AS_OF}). Check the rest yourself.`,
|
||||
},
|
||||
{
|
||||
id: 'PCB-012',
|
||||
data: 'none',
|
||||
word: 'No data',
|
||||
info: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
|
||||
},
|
||||
{
|
||||
id: 'PCB-013',
|
||||
data: 'limited',
|
||||
word: 'Cut short',
|
||||
info: 'TREK shows only part of the objects here; the area holds more. Check the water yourself.',
|
||||
},
|
||||
{
|
||||
id: 'PCB-014',
|
||||
data: 'unavailable',
|
||||
word: 'Unavailable',
|
||||
info: 'The obstacle data could not be loaded. Check the water yourself.',
|
||||
},
|
||||
];
|
||||
|
||||
describe('PaddleCheckDataLine', () => {
|
||||
it('PCB-010: covered data shows no coverage badge, the date OSM is current to and when TREK fetched it', async () => {
|
||||
render(<PaddleCheckDataLine check={paddleBench({ data_as_of: DATA_AS_OF, read_at: READ_AT })} />);
|
||||
|
||||
expect(screen.queryByTestId('paddle-check-coverage')).not.toBeInTheDocument();
|
||||
const asOf = screen.getByTestId('paddle-check-as-of');
|
||||
expect(within(asOf).getByText('OSM as of')).toBeInTheDocument();
|
||||
expect(within(asOf).getByText(AS_OF)).toBeInTheDocument();
|
||||
const read = screen.getByTestId('paddle-check-read');
|
||||
expect(within(read).getByText('Fetched')).toBeInTheDocument();
|
||||
expect(within(read).getByText(READ)).toBeInTheDocument();
|
||||
// Both sit in the one wrapping line.
|
||||
expect(screen.getByTestId('paddle-check-data')).toContainElement(asOf);
|
||||
expect(screen.getByTestId('paddle-check-data')).toContainElement(read);
|
||||
|
||||
expect(await tooltipOf(asOf)).toBe(`OpenStreetMap data as of ${AS_OF}, not a check on site.`);
|
||||
});
|
||||
|
||||
it.each(COVERAGE)(
|
||||
'$id: $data data names its coverage and says to check the water yourself',
|
||||
async ({ data, word, info }) => {
|
||||
render(<PaddleCheckDataLine check={paddleBench({ data, data_as_of: DATA_AS_OF })} />);
|
||||
|
||||
const coverage = screen.getByTestId('paddle-check-coverage');
|
||||
expect(within(coverage).getByText('Obstacle data')).toBeInTheDocument();
|
||||
expect(within(coverage).getByText(word)).toBeInTheDocument();
|
||||
expect(await tooltipOf(coverage)).toBe(info);
|
||||
}
|
||||
);
|
||||
|
||||
it('PCB-015: an unknown date reads as unknown, and data never fetched shows no fetch date', async () => {
|
||||
render(<PaddleCheckDataLine check={paddleBench({ data: 'partial', data_as_of: null, read_at: null })} />);
|
||||
|
||||
const asOf = screen.getByTestId('paddle-check-as-of');
|
||||
expect(within(asOf).getByText('unknown')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-read')).not.toBeInTheDocument();
|
||||
expect(await tooltipOf(asOf)).toBe('OpenStreetMap data of an unknown date, not a check on site.');
|
||||
expect(await tooltipOf(screen.getByTestId('paddle-check-coverage'))).toBe(
|
||||
'Obstacle data covers only part of the route (as of an unknown date). Check the rest yourself.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChecklistGlyph', () => {
|
||||
it('PCB-016: an obstacle on the route sits in a solid 24 px ring of its kind colour', () => {
|
||||
render(<ChecklistGlyph kind="weir" />);
|
||||
|
||||
const ring = screen.getByTestId('paddle-check-glyph');
|
||||
expect(ring.style.width).toBe('24px');
|
||||
expect(ring.style.height).toBe('24px');
|
||||
expect(ring.style.color).toBe('var(--map-paddle-obstacle)');
|
||||
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
|
||||
expect(ring).toHaveClass('border-solid');
|
||||
expect(ring).not.toHaveClass('border-dashed');
|
||||
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir');
|
||||
});
|
||||
|
||||
it('PCB-017: an object only near the route gets a dashed ring at the size asked for; a lock gate shows the lock', () => {
|
||||
render(<ChecklistGlyph kind="lock_gate" dashed size={20} />);
|
||||
|
||||
const ring = screen.getByTestId('paddle-check-glyph');
|
||||
expect(ring).toHaveClass('border-dashed');
|
||||
expect(ring.style.width).toBe('20px');
|
||||
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
|
||||
});
|
||||
|
||||
it('PCB-018: an own portage, an own lock and an access rule take the colour and symbol of their kind', () => {
|
||||
const { rerender } = render(<ChecklistGlyph kind="portage" />);
|
||||
let ring = screen.getByTestId('paddle-check-glyph');
|
||||
expect(ring.style.borderColor).toBe('var(--map-portage)');
|
||||
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'portage');
|
||||
|
||||
rerender(<ChecklistGlyph kind="lock" />);
|
||||
ring = screen.getByTestId('paddle-check-glyph');
|
||||
expect(ring.style.borderColor).toBe('var(--map-paddle-obstacle)');
|
||||
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
|
||||
|
||||
rerender(<ChecklistGlyph kind="access" />);
|
||||
ring = screen.getByTestId('paddle-check-glyph');
|
||||
expect(ring.style.borderColor).toBe('var(--map-paddle-access)');
|
||||
expect(ring.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'access');
|
||||
});
|
||||
});
|
||||
|
||||
interface EarlierStatus {
|
||||
id: string;
|
||||
status: 'off_route' | 'unknown';
|
||||
decision: ObstacleDecision;
|
||||
chosen: string;
|
||||
word: string;
|
||||
}
|
||||
|
||||
const EARLIER: EarlierStatus[] = [
|
||||
{
|
||||
id: 'PCB-027',
|
||||
status: 'off_route',
|
||||
decision: 'not_affected',
|
||||
chosen: 'Not affected',
|
||||
word: 'No longer on the route',
|
||||
},
|
||||
{ id: 'PCB-028', status: 'unknown', decision: 'reroute', chosen: 'Go round', word: 'Not comparable' },
|
||||
];
|
||||
|
||||
describe('ObstacleStatusBadges', () => {
|
||||
it('PCB-019: an open entry reads as open and nothing more', () => {
|
||||
render(<ObstacleStatusBadges entry={obstacleEntry()} />);
|
||||
|
||||
const badges = screen.getAllByTestId('paddle-check-status');
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.textContent).toBe('Open');
|
||||
});
|
||||
|
||||
it('PCB-020: a decided entry shows the decision alone', () => {
|
||||
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: decisionOf('not_affected') })} />);
|
||||
|
||||
expect(screen.getByTestId('paddle-check-status').textContent).toBe('Not affected');
|
||||
});
|
||||
|
||||
it('PCB-021: an entry counted as decided that carries no decision shows no empty badge', () => {
|
||||
const { container } = render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'decided', decision: null })} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('PCB-022: a choice whose plan is missing names the choice and what is missing', () => {
|
||||
const { rerender } = render(
|
||||
<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('portage') })} />
|
||||
);
|
||||
let badge = screen.getByTestId('paddle-check-status');
|
||||
expect(within(badge).getByText('Carry round')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('No portage yet')).toBeInTheDocument();
|
||||
|
||||
rerender(<ObstacleStatusBadges entry={obstacleEntry({ status: 'todo', decision: decisionOf('reroute') })} />);
|
||||
badge = screen.getByTestId('paddle-check-status');
|
||||
expect(within(badge).getByText('Go round')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('Route still passes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PCB-023: an entry to check again gets one badge for each change, each with the turning arrow', () => {
|
||||
const entry = obstacleEntry({ status: 'review', changed: ['route', 'source'], decision: decisionOf('pass') });
|
||||
render(<ObstacleStatusBadges entry={entry} />);
|
||||
|
||||
const badges = screen.getAllByTestId('paddle-check-status');
|
||||
expect(badges.map((badge) => badge.textContent)).toEqual(['Check againRoute changed', 'Check againData changed']);
|
||||
for (const badge of badges) expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
|
||||
// The earlier choice is the panel's to show, not the badge's.
|
||||
expect(screen.queryByText('Paddle through')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PCB-024: one change gives one badge', () => {
|
||||
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: ['source'] })} />);
|
||||
|
||||
const badges = screen.getAllByTestId('paddle-check-status');
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0]?.textContent).toBe('Check againData changed');
|
||||
});
|
||||
|
||||
it('PCB-025: an entry to check again without a recorded change still says so, with the turning arrow', () => {
|
||||
render(<ObstacleStatusBadges entry={obstacleEntry({ status: 'review', changed: [] })} />);
|
||||
|
||||
const badge = screen.getByTestId('paddle-check-status');
|
||||
expect(badge.textContent).toBe('Check again');
|
||||
expect(badge.querySelector('svg.lucide-rotate-ccw')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('PCB-026: a decision about an object the data no longer holds says so, and kept it steps back', async () => {
|
||||
const { rerender } = render(<ObstacleStatusBadges entry={earlierEntry()} />);
|
||||
let badge = screen.getByTestId('paddle-check-status');
|
||||
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
|
||||
expect(badge.closest('.opacity-60')).toBeNull();
|
||||
// Missing from the data is no proof the obstacle is gone.
|
||||
expect(await tooltipOf(badge)).toBe(GONE_INFO);
|
||||
|
||||
rerender(<ObstacleStatusBadges entry={earlierEntry({ status: 'kept' })} />);
|
||||
badge = screen.getByTestId('paddle-check-status');
|
||||
expect(within(badge).getByText('Paddle through')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('No longer in the data')).toBeInTheDocument();
|
||||
expect(badge.closest('.opacity-60')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(EARLIER)(
|
||||
'$id: an earlier decision now $status names the decision and why it stands apart',
|
||||
({ status, decision, chosen, word }) => {
|
||||
render(<ObstacleStatusBadges entry={earlierEntry({ status, decision: decisionOf(decision) })} />);
|
||||
|
||||
const badge = screen.getByTestId('paddle-check-status');
|
||||
expect(within(badge).getByText(chosen)).toBeInTheDocument();
|
||||
expect(within(badge).getByText(word)).toBeInTheDocument();
|
||||
expect(badge.closest('.opacity-60')).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('ObstacleAccessBadge', () => {
|
||||
it('PCB-029: a rule that neither forbids, makes private nor carries a condition shows nothing', () => {
|
||||
const { container, rerender } = render(<ObstacleAccessBadge access={NO_RULE} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
rerender(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'yes', source: 'canoe' }} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('PCB-030: a ban for canoes names the canoe rule and colours its value as a warning', () => {
|
||||
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'no', source: 'canoe', forbidden: true }} />);
|
||||
|
||||
const badge = screen.getByTestId('paddle-check-access');
|
||||
expect(within(badge).getByText('Canoe')).not.toHaveClass('text-warning');
|
||||
expect(within(badge).getByText('no').parentElement).toHaveClass('text-warning');
|
||||
});
|
||||
|
||||
it('PCB-031: private ground under the general access rule is a warning too', () => {
|
||||
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'private', source: 'access', private: true }} />);
|
||||
|
||||
const badge = screen.getByTestId('paddle-check-access');
|
||||
expect(within(badge).getByText('Access')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('private').parentElement).toHaveClass('text-warning');
|
||||
});
|
||||
|
||||
it('PCB-032: a value TREK has no words for is shown as written, its condition in the tooltip', async () => {
|
||||
render(<ObstacleAccessBadge access={{ ...NO_RULE, value: 'permit', source: 'boat', condition: CONDITION }} />);
|
||||
|
||||
const badge = screen.getByTestId('paddle-check-access');
|
||||
expect(within(badge).getByText('Boat')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('permit').parentElement).toHaveClass('text-content');
|
||||
expect(within(badge).getByText('permit').parentElement).not.toHaveClass('text-warning');
|
||||
expect(await tooltipOf(badge)).toBe(`Condition: ${CONDITION}`);
|
||||
});
|
||||
|
||||
it('PCB-033: a condition without a value of its own is shown as written under its own label', () => {
|
||||
render(<ObstacleAccessBadge access={{ ...NO_RULE, condition: CONDITION }} />);
|
||||
|
||||
const badge = screen.getByTestId('paddle-check-access');
|
||||
expect(within(badge).getByText('Condition')).toBeInTheDocument();
|
||||
expect(within(badge).getByText(CONDITION)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
ObstacleDecision,
|
||||
ObstacleEntryStatus,
|
||||
PaddleCheck,
|
||||
PaddleCheckData,
|
||||
PaddleObstacleKind,
|
||||
TourObstacleEntry,
|
||||
} from '@trek/shared';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { TourDayRowPill } from '../TourDayRowFacts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { checkDataWord, formatCheckDate, paddleCheckFact } from './paddleCheckModel';
|
||||
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
|
||||
import { KNOWN_ACCESS_VALUES } from './paddleObstacleModel';
|
||||
import { paddleKindCss } from './paddlePaint';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
|
||||
|
||||
/** Why data that falls short of the route asks for a look of one's own, as the obstacle notice says it. */
|
||||
const COVERAGE_INFO: Record<Exclude<PaddleCheckData, 'covered'>, (t: Translate, date: string) => string> = {
|
||||
partial: (t, date) => t('tours.paddle.obstacles.partial', { date }),
|
||||
none: (t) => t('tours.paddle.obstacles.none'),
|
||||
limited: (t) => t('tours.paddle.obstacles.truncated'),
|
||||
unavailable: (t) => t('tours.paddle.obstacles.error'),
|
||||
};
|
||||
|
||||
/** How a status reads beside the decision it is about. */
|
||||
interface StatusLook {
|
||||
/** The status in words; null where the decision alone says it. */
|
||||
word: (t: Translate, decision: ObstacleDecision | null) => string | null;
|
||||
info?: (t: Translate) => string;
|
||||
/** A decision kept about an object the data no longer holds steps back. */
|
||||
dimmed?: boolean;
|
||||
}
|
||||
|
||||
const GONE: StatusLook = {
|
||||
word: (t) => t('tours.paddle.check.status.gone'),
|
||||
info: (t) => t('tours.paddle.check.goneInfo'),
|
||||
};
|
||||
|
||||
/** Every status but review, which gets a badge for each thing that changed. */
|
||||
const STATUS_LOOK: Record<Exclude<ObstacleEntryStatus, 'review'>, StatusLook> = {
|
||||
open: { word: (t) => t('tours.paddle.check.status.open') },
|
||||
decided: { word: () => null },
|
||||
// A choice whose plan is missing: no portage drawn yet, or the route still passes.
|
||||
todo: {
|
||||
word: (t, decision) =>
|
||||
t(
|
||||
decision === 'reroute' ? 'tours.paddle.check.status.reroutePending' : 'tours.paddle.check.status.portageMissing'
|
||||
),
|
||||
},
|
||||
gone: GONE,
|
||||
kept: { ...GONE, dimmed: true },
|
||||
off_route: { word: (t) => t('tours.paddle.check.status.offRoute') },
|
||||
unknown: { word: (t) => t('tours.paddle.check.status.unknown') },
|
||||
};
|
||||
|
||||
/**
|
||||
* The check bench of a paddle tour as one pill (PAD-09), in the badge the day
|
||||
* plan gives a tour's facts: the first rule that applies gives its value, and
|
||||
* its tooltip names the whole bench and that it is no clearance.
|
||||
*/
|
||||
export function PaddleCheckPill({ check }: { check: PaddleCheck }) {
|
||||
const { t } = useTranslation();
|
||||
const { icon, value, label } = paddleCheckFact(check, t);
|
||||
return <TourDayRowPill icon={icon} value={value} label={label} testId="plan-tour-paddle-check" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the obstacle data of a bench reaches and how current it is: a badge
|
||||
* for data that does not cover the route, the date OpenStreetMap is current
|
||||
* to, and when TREK fetched it, which is never a check on site.
|
||||
*/
|
||||
export function PaddleCheckDataLine({ check }: { check: PaddleCheck }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const word = checkDataWord(check.data, t);
|
||||
const asOf = check.data_as_of ? formatCheckDate(check.data_as_of, locale) : null;
|
||||
const coverage =
|
||||
check.data === 'covered' ? null : COVERAGE_INFO[check.data](t, asOf ?? t('tours.paddle.obstacles.dateUnknown'));
|
||||
return (
|
||||
<div data-testid="paddle-check-data" className="flex flex-wrap items-center gap-1">
|
||||
{word && coverage && (
|
||||
<FigureBadge
|
||||
testId="paddle-check-coverage"
|
||||
label={t('tours.paddle.check.dataLabel')}
|
||||
value={word}
|
||||
info={coverage}
|
||||
/>
|
||||
)}
|
||||
<FigureBadge
|
||||
testId="paddle-check-as-of"
|
||||
label={t('tours.paddle.check.sourceDate')}
|
||||
value={asOf ?? t('tours.paddle.check.unknown')}
|
||||
info={
|
||||
asOf ? t('tours.paddle.obstacleCard.source', { date: asOf }) : t('tours.paddle.obstacleCard.sourceUndated')
|
||||
}
|
||||
/>
|
||||
{check.read_at ? (
|
||||
<FigureBadge
|
||||
testId="paddle-check-read"
|
||||
label={t('tours.paddle.check.readDate')}
|
||||
value={formatCheckDate(check.read_at, locale)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The symbol of a checklist row: the kind's glyph in a ring of its colour,
|
||||
* the ring dashed for an object only near the route. The paddler's own
|
||||
* portages and locks take the portage and lock kinds.
|
||||
*/
|
||||
export function ChecklistGlyph({
|
||||
kind,
|
||||
dashed = false,
|
||||
size = 24,
|
||||
}: {
|
||||
kind: PaddleObstacleKind;
|
||||
/** An object only near the route: how it stands to it is uncertain. */
|
||||
dashed?: boolean;
|
||||
size?: number;
|
||||
}) {
|
||||
const color = paddleKindCss(kind);
|
||||
return (
|
||||
<span
|
||||
data-testid="paddle-check-glyph"
|
||||
aria-hidden="true"
|
||||
className={`grid flex-none place-items-center rounded-full border-2 bg-surface-card ${dashed ? 'border-dashed' : 'border-solid'}`}
|
||||
style={{ width: size, height: size, color, borderColor: color }}
|
||||
>
|
||||
<PaddleGlyph kind={OBSTACLE_GLYPH[kind]} size={Math.round(size * 0.55)} strokeWidth={2.3} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** An entry to check again: one badge for each thing that changed since the decision. */
|
||||
function ReviewBadges({ changed }: { changed: TourObstacleEntry['changed'] }) {
|
||||
const { t } = useTranslation();
|
||||
const review = t('tours.paddle.check.status.review');
|
||||
const icon = <RotateCcw size={10} strokeWidth={2.4} aria-hidden="true" className="flex-none" />;
|
||||
if (changed.length === 0) return <FigureBadge testId="paddle-check-status" value={review} icon={icon} />;
|
||||
return (
|
||||
<>
|
||||
{changed.map((reason) => (
|
||||
<FigureBadge
|
||||
key={reason}
|
||||
testId="paddle-check-status"
|
||||
label={review}
|
||||
value={t(`tours.paddle.check.changed.${reason}`)}
|
||||
icon={icon}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a checklist entry stands, as its row and its decision panel show it:
|
||||
* the decision with what still stands in its way, a badge for each change an
|
||||
* entry to check again has seen, and a decision kept about an object the data
|
||||
* no longer holds dimmed. The badges come without a box of their own, so they
|
||||
* flow on in the line they are put into.
|
||||
*/
|
||||
export function ObstacleStatusBadges({ entry }: { entry: TourObstacleEntry }) {
|
||||
const { t } = useTranslation();
|
||||
if (entry.status === 'review') return <ReviewBadges changed={entry.changed} />;
|
||||
const look = STATUS_LOOK[entry.status];
|
||||
const decision = entry.decision?.decision ?? null;
|
||||
const chosen = decision ? t(`tours.paddle.check.decision.${decision}`) : undefined;
|
||||
const word = look.word(t, decision);
|
||||
const value = word ?? chosen;
|
||||
if (!value) return null;
|
||||
const badge = (
|
||||
<FigureBadge testId="paddle-check-status" label={word ? chosen : undefined} value={value} info={look.info?.(t)} />
|
||||
);
|
||||
return look.dimmed ? <span className="inline-flex opacity-60">{badge}</span> : badge;
|
||||
}
|
||||
|
||||
/** A rule's value in the user's words where TREK knows it, as the tags write it where it does not. */
|
||||
function accessValueOf(value: string, t: Translate): string {
|
||||
return ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The paddling rule an obstacle's tags give, on its row only when it forbids,
|
||||
* makes the ground private or carries a condition. A rule that forbids or
|
||||
* makes it private takes the warning tone, as on the map card; a condition
|
||||
* without a value of its own is shown as written.
|
||||
*/
|
||||
export function ObstacleAccessBadge({ access }: { access: TourObstacleEntry['access'] }) {
|
||||
const { t } = useTranslation();
|
||||
if (!access.forbidden && !access.private && !access.condition) return null;
|
||||
const condition = t('tours.paddle.obstacleCard.condition');
|
||||
const { value } = access;
|
||||
return (
|
||||
<FigureBadge
|
||||
testId="paddle-check-access"
|
||||
label={value === null ? condition : t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
|
||||
value={value === null ? (access.condition ?? '') : accessValueOf(value, t)}
|
||||
info={access.condition ? `${condition}: ${access.condition}` : undefined}
|
||||
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// FE-PADDLE-CHECKCARD-001 to FE-PADDLE-CHECKCARD-017: the checklist card of a paddle tour (PAD-06) in the planner
|
||||
// rail, its state notices, its rows in route order and the fold of earlier decisions.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PaddleAccess, PaddleCheck, TourObstacleEntry } from '@trek/shared';
|
||||
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import {
|
||||
earlierEntry,
|
||||
obstacleCheck,
|
||||
obstacleEntry,
|
||||
ownPortage,
|
||||
paddleBench,
|
||||
} from '../../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { PaddleCheckCard } from './PaddleCheckCard';
|
||||
import { formatCheckDate } from './paddleCheckModel';
|
||||
import type { PaddleChecklist } from './usePaddleChecklist';
|
||||
|
||||
// The badges, the entry dialog and the data notice have suites of their own; here they show what the card hands them.
|
||||
vi.mock('./PaddleCheckBadges', () => ({
|
||||
ChecklistGlyph: ({ kind, dashed = false }: { kind: string; dashed?: boolean; size?: number }) => (
|
||||
<span data-testid="checklist-glyph" data-kind={kind} data-dashed={String(dashed)} />
|
||||
),
|
||||
ObstacleStatusBadges: ({ entry }: { entry: TourObstacleEntry }) => <span>status {entry.status}</span>,
|
||||
ObstacleAccessBadge: ({ access }: { access: PaddleAccess }) =>
|
||||
access.forbidden || access.private ? <span>access {access.value}</span> : null,
|
||||
PaddleCheckPill: ({ check }: { check: PaddleCheck }) => (
|
||||
<span data-testid="paddle-check-pill">{check.open} open</span>
|
||||
),
|
||||
PaddleCheckDataLine: ({ check }: { check: PaddleCheck }) => <span data-testid="paddle-check-data">{check.data}</span>,
|
||||
}));
|
||||
vi.mock('./ObstacleEntryDialog', () => ({
|
||||
ObstacleEntryDialog: ({ checklist }: { checklist: PaddleChecklist }) => (
|
||||
<div role="dialog" aria-label="Obstacle entry">
|
||||
{checklist.dialogKey}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('./PaddleObstacleNotice', () => ({ PaddleObstacleNotice: () => <p>What TREK knows about the water</p> }));
|
||||
|
||||
/** The line the data notice stub shows. */
|
||||
const OBSTACLE_NOTICE = 'What TREK knows about the water';
|
||||
|
||||
/** The words as shared/src/i18n/en/tours.ts has them. */
|
||||
const SAYS = {
|
||||
title: 'Checklist',
|
||||
saveFirst: 'Save the tour to check it.',
|
||||
loading: 'Loading the obstacles along the route …',
|
||||
offlineEmpty: 'The checklist loads once you are online.',
|
||||
error: 'The obstacle data could not be loaded. Check the water yourself.',
|
||||
unsaved: 'Unsaved changes. The list shows the saved route.',
|
||||
none: 'No obstacle data for this area. Check weirs, locks and portages here yourself.',
|
||||
dateUnknown: 'an unknown date',
|
||||
notClearance: 'A checklist is no safety or legal clearance.',
|
||||
earlier: 'Earlier decisions',
|
||||
own: 'Own',
|
||||
ownInfo: 'Your own plan, not a fact from a data source.',
|
||||
near: 'Near the route',
|
||||
supLock: 'SUP: check a portage',
|
||||
privateWay: 'Private way',
|
||||
carry: 'Carrying',
|
||||
bypassed: 'Carried round, the portage counts',
|
||||
};
|
||||
|
||||
const staleLine = (time: string): string => `As of ${time}. Updates once online.`;
|
||||
const coveredEmptyLine = (date: string): string =>
|
||||
`No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`;
|
||||
|
||||
/** A date as the card writes it, with the spaces the DOM queries read. */
|
||||
const dateOf = (value: string): string => formatCheckDate(value, 'en').replace(/\s/g, ' ');
|
||||
|
||||
/** The weir of the fixtures: on the route at 1.2 km, open. */
|
||||
const WEIR = obstacleEntry();
|
||||
/** A weir further on that OpenStreetMap closes to canoes. */
|
||||
const BANNED_WEIR = obstacleEntry({
|
||||
key: 'way/3',
|
||||
refs: ['way/3'],
|
||||
name: 'Wehr Kallmünz',
|
||||
along_m: 2500,
|
||||
access: { value: 'no', source: 'canoe', condition: null, forbidden: true, private: false },
|
||||
});
|
||||
/** An unnamed lock beside the route, decided already, where SUP paddlers are advised to carry round. */
|
||||
const NEAR_LOCK = obstacleEntry({
|
||||
key: 'lock:node/2',
|
||||
refs: ['node/2', 'node/3'],
|
||||
kind: 'lock',
|
||||
name: null,
|
||||
relation: 'near',
|
||||
along_m: 3400,
|
||||
distance_m: 40,
|
||||
advice: 'sup_lock_portage',
|
||||
status: 'decided',
|
||||
decision: { decision: 'portage', note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 1 },
|
||||
});
|
||||
/** The paddler's own portage round the first weir. */
|
||||
const OWN_PORTAGE = ownPortage({ from_m: 1100, to_m: 1250 });
|
||||
/** A lock the paddler marked and locks through on a SUP, so the advice to check a portage stands. */
|
||||
const ADVISED_LOCK = ownPortage({
|
||||
kind: 'lock',
|
||||
index: 4,
|
||||
from_m: 5000,
|
||||
to_m: 5000,
|
||||
covers: [],
|
||||
advice: 'sup_lock_portage',
|
||||
});
|
||||
/** A lock the paddler marked that a portage carries round; the server gives it no advice. */
|
||||
const BYPASSED_LOCK = ownPortage({ kind: 'lock', index: 5, from_m: 5600, to_m: 5600, covers: [], bypassed: true });
|
||||
/** All of it out of route order, as the server may list it. */
|
||||
const ROUTE = obstacleCheck({ entries: [NEAR_LOCK, WEIR, BANNED_WEIR], own: [ADVISED_LOCK, OWN_PORTAGE] });
|
||||
/** The weir, and an earlier decision about a sill the route no longer meets. */
|
||||
const GONE = earlierEntry({ status: 'off_route' });
|
||||
const GONE_NAME = 'Alte Sohlschwelle';
|
||||
const WITH_EARLIER = obstacleCheck({ entries: [WEIR, GONE] });
|
||||
/** The weir, and a decision about a sill the complete data no longer holds: it counts, so it stays in view. */
|
||||
const VANISHED = earlierEntry({ key: 'node/8', refs: ['node/8'], name: 'Verschwundenes Wehr' });
|
||||
|
||||
/** What usePaddleChecklist hands the card: a ready list matching the saved route, nothing selected, every action a spy. */
|
||||
function checklistOf(overrides: Partial<PaddleChecklist> = {}): PaddleChecklist {
|
||||
return {
|
||||
status: 'ready',
|
||||
tour: null,
|
||||
check: obstacleCheck(),
|
||||
summary: paddleBench(),
|
||||
stale: false,
|
||||
dirty: false,
|
||||
canDecide: true,
|
||||
canPlanPortage: true,
|
||||
savingKey: null,
|
||||
selectedKey: null,
|
||||
dialogKey: null,
|
||||
entryForRef: vi.fn(() => null),
|
||||
select: vi.fn(),
|
||||
selectOwn: vi.fn(),
|
||||
closeDialog: vi.fn(),
|
||||
decide: vi.fn(async () => true),
|
||||
setNote: vi.fn(async () => true),
|
||||
clear: vi.fn(async () => true),
|
||||
keep: vi.fn(async () => true),
|
||||
nextOf: vi.fn(() => null),
|
||||
next: vi.fn(),
|
||||
planPortage: vi.fn(),
|
||||
memberName: vi.fn(() => null),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const renderCard = (checklist: PaddleChecklist) => render(<PaddleCheckCard checklist={checklist} />);
|
||||
|
||||
/** The rows along the route, in the order the card lists them. */
|
||||
function rowButtons(): HTMLElement[] {
|
||||
return within(screen.getByTestId('paddle-check-rows')).getAllByRole('button');
|
||||
}
|
||||
|
||||
/** The row along the route whose name starts as given. */
|
||||
function rowNamed(name: RegExp): HTMLElement {
|
||||
return within(screen.getByTestId('paddle-check-rows')).getByRole('button', { name });
|
||||
}
|
||||
|
||||
/** The element a badge's tooltip hangs on. */
|
||||
function parentOf(element: HTMLElement): HTMLElement {
|
||||
const parent = element.parentElement;
|
||||
if (!parent) throw new Error('no parent around the badge');
|
||||
return parent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// No distance unit chosen: the card reads metric, in English.
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
describe('PaddleCheckCard', () => {
|
||||
it('FE-PADDLE-CHECKCARD-001: renders nothing while the checklist is off', () => {
|
||||
const { container } = renderCard(checklistOf({ status: 'off', check: null, summary: null }));
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-002: a new paddle tour asks to be saved first and says what is known about the water', () => {
|
||||
renderCard(checklistOf({ status: 'new', check: null, summary: null, dirty: true }));
|
||||
|
||||
const card = screen.getByRole('region', { name: SAYS.title });
|
||||
const saveFirst = within(card).getByText(SAYS.saveFirst);
|
||||
const notice = within(card).getByText(OBSTACLE_NOTICE);
|
||||
// The request to save comes first, the data notice below it; a new draft is unsaved by nature and says no more.
|
||||
expect(saveFirst.compareDocumentPosition(notice) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('paddle-check-pill')).not.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('paddle-check-data')).not.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('paddle-check-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-003: while the list loads it says so as a status with a spinner, the bench already in the head', () => {
|
||||
renderCard(checklistOf({ status: 'loading', check: null, dirty: true }));
|
||||
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveTextContent(SAYS.loading);
|
||||
expect(status.querySelector('svg.animate-spin')).not.toBeNull();
|
||||
// Loading speaks before unsaved changes.
|
||||
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(OBSTACLE_NOTICE)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
|
||||
expect(screen.getByTestId('paddle-check-data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-004: offline without a cached list it says the list loads once online', () => {
|
||||
renderCard(checklistOf({ status: 'offline-empty', check: null }));
|
||||
|
||||
expect(screen.getByText(SAYS.offlineEmpty)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-005: a list that could not be read says the obstacle data could not be loaded', () => {
|
||||
renderCard(checklistOf({ status: 'error', check: null }));
|
||||
|
||||
expect(screen.getByText(SAYS.error)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-006: unsaved route changes speak before the cache and dim the rows without hiding them', () => {
|
||||
renderCard(checklistOf({ check: ROUTE, dirty: true, stale: true }));
|
||||
|
||||
expect(screen.getByText(SAYS.unsaved)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Updates once online/)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
|
||||
expect(rowButtons()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-007: a list from the cache says when it was worked out and dims the rows', () => {
|
||||
renderCard(checklistOf({ check: ROUTE, stale: true }));
|
||||
|
||||
expect(screen.getByText(staleLine(dateOf(ROUTE.evaluated_at)))).toBeInTheDocument();
|
||||
expect(screen.queryByText(SAYS.unsaved)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-check-list')).toHaveClass('opacity-60');
|
||||
expect(rowButtons()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-008: lists obstacles and own marks in route order, each with its place and where it stands', () => {
|
||||
renderCard(checklistOf({ check: ROUTE }));
|
||||
|
||||
const card = screen.getByRole('region', { name: SAYS.title });
|
||||
expect(within(card).getByTestId('paddle-check-pill')).toHaveTextContent('1 open');
|
||||
expect(within(card).getByTestId('paddle-check-data')).toHaveTextContent('covered');
|
||||
// A ready list that matches the saved route has nothing to say above it and is not dimmed.
|
||||
expect(within(card).queryByRole('status')).not.toBeInTheDocument();
|
||||
expect(within(card).queryByText(SAYS.unsaved)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-check-list')).not.toHaveClass('opacity-60');
|
||||
|
||||
const rows = rowButtons();
|
||||
expect(rows.map((row) => row.textContent)).toEqual([
|
||||
expect.stringMatching(/^Portage1\.1 km/),
|
||||
expect.stringMatching(/^Wehr Regendorf1\.2 km/),
|
||||
expect.stringMatching(/^Wehr Kallmünz2\.5 km/),
|
||||
expect.stringMatching(/^Lock3\.4 km/),
|
||||
expect.stringMatching(/^Lock5 km/),
|
||||
]);
|
||||
const [, weir, banned, lock] = rows;
|
||||
expect(within(weir).getByText('status open')).toBeInTheDocument();
|
||||
expect(within(weir).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'false');
|
||||
expect(within(weir).queryByText(SAYS.near)).not.toBeInTheDocument();
|
||||
expect(within(weir).queryByText(/^access/)).not.toBeInTheDocument();
|
||||
expect(within(banned).getByText('access no')).toBeInTheDocument();
|
||||
// An unnamed lock goes by its kind: near the route on a dashed ring, its decision and the SUP advice.
|
||||
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
|
||||
expect(within(lock).getByTestId('checklist-glyph')).toHaveAttribute('data-dashed', 'true');
|
||||
expect(within(lock).getByText(SAYS.near)).toBeInTheDocument();
|
||||
expect(within(lock).getByText('status decided')).toBeInTheDocument();
|
||||
expect(within(lock).getByText(SAYS.supLock)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-009: an own portage reads as the own plan, how far it carries, what it carries round', async () => {
|
||||
renderCard(
|
||||
checklistOf({ check: obstacleCheck({ own: [ownPortage({ from_m: 1100, to_m: 1250, conflict: 'private' })] }) })
|
||||
);
|
||||
|
||||
const row = rowNamed(/^Portage/);
|
||||
expect(
|
||||
within(row)
|
||||
.getAllByTestId('checklist-glyph')
|
||||
.map((glyph) => glyph.dataset.kind)
|
||||
).toEqual(['portage', 'weir']);
|
||||
expect(within(row).getByText(SAYS.carry).nextElementSibling).toHaveTextContent('150 m');
|
||||
expect(within(row).getByRole('img', { name: 'Weir' })).toBeInTheDocument();
|
||||
expect(within(row).getByText(SAYS.privateWay)).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(parentOf(within(row).getByText(SAYS.own)));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(SAYS.ownInfo);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-010: a forbidden carrying way reads as its access rule, a lock carried round is dimmed', () => {
|
||||
const forbidden = ownPortage({ from_m: 2000, to_m: 2200, conflict: 'forbidden' });
|
||||
renderCard(checklistOf({ check: obstacleCheck({ entries: [], own: [BYPASSED_LOCK, ADVISED_LOCK, forbidden] }) }));
|
||||
|
||||
const [portage, advised, bypassed] = rowButtons();
|
||||
expect(portage).toHaveTextContent('access no');
|
||||
expect(portage).not.toHaveTextContent(SAYS.privateWay);
|
||||
// A covered obstacle the checklist does not hold gets no chip.
|
||||
expect(within(portage).queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(portage).not.toHaveClass('opacity-60');
|
||||
|
||||
// A lock passed on a SUP carries the advice; one a portage carries round is dimmed and says why.
|
||||
expect(within(advised).getByTestId('checklist-glyph')).toHaveAttribute('data-kind', 'lock');
|
||||
expect(advised).toHaveTextContent(SAYS.own);
|
||||
expect(advised).toHaveTextContent(SAYS.supLock);
|
||||
expect(advised).not.toHaveTextContent(SAYS.carry);
|
||||
expect(advised).not.toHaveClass('opacity-60');
|
||||
|
||||
expect(bypassed).toHaveClass('opacity-60');
|
||||
expect(bypassed).toHaveTextContent(SAYS.own);
|
||||
expect(bypassed).toHaveTextContent(SAYS.bypassed);
|
||||
expect(bypassed).not.toHaveTextContent(SAYS.supLock);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-011: a row is one button that selects its obstacle or its own mark', () => {
|
||||
const select = vi.fn<PaddleChecklist['select']>();
|
||||
const selectOwn = vi.fn<PaddleChecklist['selectOwn']>();
|
||||
renderCard(checklistOf({ check: ROUTE, select, selectOwn }));
|
||||
|
||||
for (const row of rowButtons()) expect(row).toHaveAttribute('type', 'button');
|
||||
// Nothing to press inside a row.
|
||||
expect(within(rowNamed(/^Wehr Kallmünz/)).queryByRole('button')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(rowNamed(/^Wehr Kallmünz/));
|
||||
expect(select).toHaveBeenCalledWith(BANNED_WEIR);
|
||||
fireEvent.click(rowNamed(/^Portage/));
|
||||
expect(selectOwn).toHaveBeenCalledWith(OWN_PORTAGE);
|
||||
expect(select).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-012: the selected entry is the current row, scrolled into view, and the mark follows the selection', () => {
|
||||
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
|
||||
const { rerender } = renderCard(checklistOf({ check: ROUTE, selectedKey: WEIR.key }));
|
||||
|
||||
const weir = rowNamed(/^Wehr Regendorf/);
|
||||
expect(weir).toHaveAttribute('aria-current', 'true');
|
||||
expect(weir).toHaveClass('ring-1', 'ring-inset', 'ring-accent', 'bg-surface-hover');
|
||||
expect(rowButtons().filter((row) => row.hasAttribute('aria-current'))).toEqual([weir]);
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
|
||||
expect(scrollIntoView.mock.contexts[0]).toBe(weir);
|
||||
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE, selectedKey: BANNED_WEIR.key })} />);
|
||||
const banned = rowNamed(/^Wehr Kallmünz/);
|
||||
expect(banned).toHaveAttribute('aria-current', 'true');
|
||||
expect(weir).not.toHaveAttribute('aria-current');
|
||||
expect(weir).not.toHaveClass('ring-accent');
|
||||
expect(scrollIntoView).toHaveBeenCalledTimes(2);
|
||||
expect(scrollIntoView.mock.contexts[1]).toBe(banned);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-013: earlier decisions wait folded under their count, apart from the route, and open on request', () => {
|
||||
const select = vi.fn<PaddleChecklist['select']>();
|
||||
renderCard(checklistOf({ check: WITH_EARLIER, select }));
|
||||
|
||||
const fold = screen.getByRole('button', { name: SAYS.earlier });
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(within(screen.getByTestId('paddle-check-earlier')).getByText('1')).toBeInTheDocument();
|
||||
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
|
||||
expect(rowButtons()).toHaveLength(1);
|
||||
|
||||
fireEvent.click(fold);
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'true');
|
||||
const earlier = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
|
||||
// No longer on the route, so no place along it.
|
||||
expect(earlier).not.toHaveTextContent('km');
|
||||
expect(earlier).toHaveTextContent('status off_route');
|
||||
fireEvent.click(earlier);
|
||||
expect(select).toHaveBeenCalledWith(GONE);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-014: a selected earlier decision opens the fold by itself and comes into view, until folded again', () => {
|
||||
const scrollIntoView = vi.mocked(Element.prototype.scrollIntoView);
|
||||
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
|
||||
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, selectedKey: GONE.key })} />);
|
||||
const fold = screen.getByRole('button', { name: SAYS.earlier });
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'true');
|
||||
const row = screen.getByRole('button', { name: /^Alte Sohlschwelle/ });
|
||||
expect(row).toHaveAttribute('aria-current', 'true');
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' });
|
||||
expect(scrollIntoView.mock.contexts).toContain(row);
|
||||
|
||||
fireEvent.click(fold);
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText(GONE_NAME)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-018: a decision about an object the data no longer holds stays among the rows, after the route', () => {
|
||||
renderCard(checklistOf({ check: obstacleCheck({ entries: [VANISHED, WEIR] }) }));
|
||||
const rows = rowButtons();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1]).toHaveTextContent('Verschwundenes Wehr');
|
||||
expect(screen.queryByRole('button', { name: SAYS.earlier })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-015: without rows it says nothing is known on covered data, and that there is none where none is', () => {
|
||||
const empty = obstacleCheck({ entries: [], own: [] });
|
||||
const bench = paddleBench({ entries: 0, open: 0 });
|
||||
const { rerender } = renderCard(checklistOf({ check: empty, summary: bench }));
|
||||
expect(screen.getByText(coveredEmptyLine(dateOf(bench.data_as_of ?? '')))).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-rows')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data_as_of: null } })} />);
|
||||
expect(screen.getByText(coveredEmptyLine(SAYS.dateUnknown))).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PaddleCheckCard
|
||||
checklist={checklistOf({ check: empty, summary: { ...bench, data: 'none', data_as_of: null } })}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(SAYS.none)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
|
||||
|
||||
// Partial data says so in its data line; the card adds no sentence of its own.
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: empty, summary: { ...bench, data: 'partial' } })} />);
|
||||
expect(screen.queryByText(SAYS.none)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/No known obstacles/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-016: mounts the entry dialog only while an entry the map cannot show is open in it', () => {
|
||||
const { rerender } = renderCard(checklistOf({ check: WITH_EARLIER }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER, dialogKey: GONE.key })} />);
|
||||
expect(screen.getByRole('dialog', { name: 'Obstacle entry' })).toHaveTextContent(GONE.key);
|
||||
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: WITH_EARLIER })} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CHECKCARD-017: closes on the line that a checklist is no clearance, before and after the tour is saved', () => {
|
||||
const { rerender } = renderCard(checklistOf({ status: 'new', check: null, summary: null }));
|
||||
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
|
||||
|
||||
rerender(<PaddleCheckCard checklist={checklistOf({ check: ROUTE })} />);
|
||||
expect(screen.getByTestId('paddle-check-card').lastElementChild).toHaveTextContent(SAYS.notClearance);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
import type {
|
||||
PaddleAccess,
|
||||
PaddleCheck,
|
||||
PaddleObstacleKind,
|
||||
TourObstacleCheck,
|
||||
TourObstacleEntry,
|
||||
TourOwnEntry,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, CloudOff, LifeBuoy, ListChecks, Loader2, PencilLine, Save, WifiOff } from 'lucide-react';
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { FoldButton, RailCard, TourNotice } from '../tourParts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { ObstacleEntryDialog } from './ObstacleEntryDialog';
|
||||
import {
|
||||
ChecklistGlyph,
|
||||
ObstacleAccessBadge,
|
||||
ObstacleStatusBadges,
|
||||
PaddleCheckDataLine,
|
||||
PaddleCheckPill,
|
||||
} from './PaddleCheckBadges';
|
||||
import { checklistRows, formatCheckDate, type ChecklistRow } from './paddleCheckModel';
|
||||
import { PaddleObstacleNotice } from './PaddleObstacleNotice';
|
||||
import type { PaddleChecklist, PaddleChecklistStatus } from './usePaddleChecklist';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
/** What the card says above the rows: the tour's own state, unsaved changes, or an answer from the cache. */
|
||||
type CheckNotice = Exclude<PaddleChecklistStatus, 'off' | 'ready'> | 'dirty' | 'stale';
|
||||
|
||||
const ICON = { size: 13, strokeWidth: 2 } as const;
|
||||
|
||||
/** Each state's sentence and icon; while the list loads it is a status with a spinner. */
|
||||
const NOTICES: Record<CheckNotice, { textKey: string; icon: ReactNode; role?: 'status' }> = {
|
||||
new: { textKey: 'tours.paddle.check.saveFirst', icon: <Save {...ICON} /> },
|
||||
loading: {
|
||||
textKey: 'tours.paddle.obstacles.loading',
|
||||
icon: <Loader2 {...ICON} className="animate-spin" />,
|
||||
role: 'status',
|
||||
},
|
||||
'offline-empty': { textKey: 'tours.paddle.check.offlineEmpty', icon: <WifiOff {...ICON} /> },
|
||||
error: { textKey: 'tours.paddle.obstacles.error', icon: <AlertTriangle {...ICON} /> },
|
||||
dirty: { textKey: 'tours.paddle.check.unsaved', icon: <PencilLine {...ICON} /> },
|
||||
stale: { textKey: 'tours.paddle.check.stale', icon: <CloudOff {...ICON} /> },
|
||||
};
|
||||
|
||||
/** A carrying way the data forbids (`access=no` or `portage:access=no`), as the access rule it runs into. */
|
||||
const FORBIDDEN_WAY: PaddleAccess = { value: 'no', source: 'access', condition: null, forbidden: true, private: false };
|
||||
|
||||
const ROW =
|
||||
'flex w-full items-start gap-2 rounded-[10px] px-1.5 py-1.5 text-start transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent';
|
||||
const ROW_SELECTED = 'bg-surface-hover ring-1 ring-inset ring-accent';
|
||||
|
||||
/** The one notice that applies: the tour's state first, then unsaved route changes, then a list from the cache. */
|
||||
function noticeOf({ status, dirty, stale }: Pick<PaddleChecklist, 'status' | 'dirty' | 'stale'>): CheckNotice | null {
|
||||
if (status === 'off') return null;
|
||||
if (status !== 'ready') return status;
|
||||
if (dirty) return 'dirty';
|
||||
return stale ? 'stale' : null;
|
||||
}
|
||||
|
||||
function kindLabel(kind: PaddleObstacleKind, t: Translate): string {
|
||||
return t(`tours.paddle.obstacle.kind.${kind}`);
|
||||
}
|
||||
|
||||
/** Metres along the route in the user's distance unit. */
|
||||
function useDistanceLabel(): (metres: number) => string {
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
return (metres) => formatDistance(metres / 1000, unit);
|
||||
}
|
||||
|
||||
function rowKey(row: ChecklistRow, index: number): string {
|
||||
return row.type === 'obstacle' ? row.entry.key : `own:${row.own.kind}:${row.own.from_m}:${index}`;
|
||||
}
|
||||
|
||||
/** The sentence the checklist's state calls for. */
|
||||
function CheckNoticeLine({ notice, evaluatedAt }: { notice: CheckNotice; evaluatedAt: string | null }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const { textKey, icon, role } = NOTICES[notice];
|
||||
const time = evaluatedAt ? formatCheckDate(evaluatedAt, locale) : '';
|
||||
return (
|
||||
<TourNotice role={role} icon={icon}>
|
||||
{t(textKey, { time })}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row of the checklist: one full width button with the glyph, the title and a line of badges that wraps between them. */
|
||||
function CheckRowButton({
|
||||
selected = false,
|
||||
dimmed = false,
|
||||
onClick,
|
||||
glyph,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
selected?: boolean;
|
||||
dimmed?: boolean;
|
||||
onClick: () => void;
|
||||
glyph: ReactNode;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
// An entry picked on the map comes into view in the rail.
|
||||
useEffect(() => {
|
||||
if (selected) ref.current?.scrollIntoView?.({ block: 'nearest' });
|
||||
}, [selected]);
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={`${ROW} ${selected ? ROW_SELECTED : 'hover:bg-surface-hover'} ${dimmed ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<span className="flex-none">{glyph}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-semibold text-content" style={fs(12.5, 'body')} title={title}>
|
||||
{title}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1">{children}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The SUP advice at a lock: check a portage rather than lock through. */
|
||||
function SupLockBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SoftPill icon={<LifeBuoy size={10} strokeWidth={2.2} className="flex-none" />}>
|
||||
{t('tours.paddle.check.supLock')}
|
||||
</SoftPill>
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks a row as the paddler's own plan, never a fact from a data source. */
|
||||
function OwnBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip label={t('tours.paddle.check.ownInfo')}>
|
||||
<span className="inline-flex flex-none">
|
||||
<SoftPill>{t('tours.paddle.check.own')}</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The obstacles an own portage carries round, each a small glyph its tooltip names. */
|
||||
function CoveredChips({ keys, entries }: { keys: readonly string[]; entries: readonly TourObstacleEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
const covered = keys
|
||||
.map((key) => entries.find((entry) => entry.key === key))
|
||||
.filter((entry): entry is TourObstacleEntry => entry !== undefined);
|
||||
return (
|
||||
<>
|
||||
{covered.map((entry) => {
|
||||
const label = kindLabel(entry.kind, t);
|
||||
return (
|
||||
<Tooltip key={entry.key} label={label}>
|
||||
<span role="img" aria-label={label} className="inline-flex flex-none">
|
||||
<ChecklistGlyph kind={entry.kind} size={18} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A carrying way the data records as private or forbidden, in the warning tone the obstacle card gives rules. */
|
||||
function ConflictBadge({ conflict }: { conflict: NonNullable<TourOwnEntry['conflict']> }) {
|
||||
const { t } = useTranslation();
|
||||
if (conflict === 'forbidden') return <ObstacleAccessBadge access={FORBIDDEN_WAY} />;
|
||||
return <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>;
|
||||
}
|
||||
|
||||
/** One obstacle: what it is, where on the route, and where its decision stands. */
|
||||
function PaddleCheckRow({
|
||||
entry,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
entry: TourObstacleEntry;
|
||||
selected: boolean;
|
||||
onSelect: (entry: TourObstacleEntry) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const distance = useDistanceLabel();
|
||||
const near = entry.relation === 'near';
|
||||
return (
|
||||
<CheckRowButton
|
||||
selected={selected}
|
||||
onClick={() => onSelect(entry)}
|
||||
glyph={<ChecklistGlyph kind={entry.kind} dashed={near} />}
|
||||
title={entry.name ?? kindLabel(entry.kind, t)}
|
||||
>
|
||||
{entry.along_m !== null && <SoftPill className="tabular-nums">{distance(entry.along_m)}</SoftPill>}
|
||||
{near && <SoftPill>{t('tours.paddle.obstacleCard.near')}</SoftPill>}
|
||||
<ObstacleStatusBadges entry={entry} />
|
||||
<ObstacleAccessBadge access={entry.access} />
|
||||
{entry.advice !== null && <SupLockBadge />}
|
||||
</CheckRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The paddler's own portage or lock among the obstacles. A portage says how
|
||||
* far it carries and what it carries round; a lock the portage carries round
|
||||
* is dimmed and says so.
|
||||
*/
|
||||
function OwnCheckRow({
|
||||
own,
|
||||
entries,
|
||||
onSelect,
|
||||
}: {
|
||||
own: TourOwnEntry;
|
||||
entries: readonly TourObstacleEntry[];
|
||||
onSelect: (own: TourOwnEntry) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const distance = useDistanceLabel();
|
||||
const portage = own.kind === 'portage';
|
||||
const bypassed = !portage && own.bypassed;
|
||||
return (
|
||||
<CheckRowButton
|
||||
dimmed={bypassed}
|
||||
onClick={() => onSelect(own)}
|
||||
glyph={<ChecklistGlyph kind={own.kind} />}
|
||||
title={kindLabel(own.kind, t)}
|
||||
>
|
||||
<SoftPill className="tabular-nums">{distance(own.from_m)}</SoftPill>
|
||||
<OwnBadge />
|
||||
{portage && <FigureBadge label={t('tours.paddle.time.carry')} value={distance(own.to_m - own.from_m)} />}
|
||||
{portage && <CoveredChips keys={own.covers} entries={entries} />}
|
||||
{own.conflict && <ConflictBadge conflict={own.conflict} />}
|
||||
{bypassed && <SoftPill>{t('tours.paddle.lockBypassed')}</SoftPill>}
|
||||
{own.advice !== null && <SupLockBadge />}
|
||||
</CheckRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row along the route: an obstacle picks its entry, an own mark its place on the map. */
|
||||
function RouteRow({
|
||||
row,
|
||||
checklist,
|
||||
entries,
|
||||
}: {
|
||||
row: ChecklistRow;
|
||||
checklist: PaddleChecklist;
|
||||
entries: readonly TourObstacleEntry[];
|
||||
}) {
|
||||
if (row.type === 'own') return <OwnCheckRow own={row.own} entries={entries} onSelect={checklist.selectOwn} />;
|
||||
return (
|
||||
<PaddleCheckRow entry={row.entry} selected={row.entry.key === checklist.selectedKey} onSelect={checklist.select} />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The decisions the route or the data no longer carries, folded away under
|
||||
* their count. A selection among them opens the fold, until the paddler folds
|
||||
* it again for that selection.
|
||||
*/
|
||||
function EarlierDecisions({
|
||||
entries,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
}: {
|
||||
entries: readonly TourObstacleEntry[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (entry: TourObstacleEntry) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const id = useId();
|
||||
const [choice, setChoice] = useState<{ open: boolean; key: string | null }>({ open: false, key: null });
|
||||
const holdsSelected = entries.some((entry) => entry.key === selectedKey);
|
||||
const expanded = (holdsSelected && choice.key !== selectedKey) || choice.open;
|
||||
const label = t('tours.paddle.check.earlier');
|
||||
return (
|
||||
<div data-testid="paddle-check-earlier" className="border-t border-edge-faint pt-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content-secondary" style={fs(11.5, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
<SoftPill className="tabular-nums">{entries.length}</SoftPill>
|
||||
<FoldButton
|
||||
expanded={expanded}
|
||||
controls={id}
|
||||
label={label}
|
||||
onToggle={() => setChoice({ open: !expanded, key: selectedKey })}
|
||||
/>
|
||||
</div>
|
||||
<ul id={id} hidden={!expanded} className="-mx-1.5 mt-1 list-none p-0">
|
||||
{expanded &&
|
||||
entries.map((entry) => (
|
||||
<li key={entry.key}>
|
||||
<PaddleCheckRow entry={entry} selected={entry.key === selectedKey} onSelect={onSelect} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A route without rows: nothing known where the data covers it, no data where there is none; else the data line speaks. */
|
||||
function EmptyLine({ summary }: { summary: PaddleCheck | null }) {
|
||||
const { t, locale } = useTranslation();
|
||||
let text: string | null = null;
|
||||
if (summary?.data === 'none') text = t('tours.paddle.obstacles.none');
|
||||
else if (summary?.data === 'covered') {
|
||||
const date = summary.data_as_of
|
||||
? formatCheckDate(summary.data_as_of, locale)
|
||||
: t('tours.paddle.obstacles.dateUnknown');
|
||||
text = t('tours.paddle.obstacles.coveredEmpty', { date });
|
||||
}
|
||||
if (!text) return null;
|
||||
return (
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rows along the route, then the earlier decisions; dimmed, never hidden, while they may not match the route. */
|
||||
function CheckList({ checklist, check }: { checklist: PaddleChecklist; check: TourObstacleCheck }) {
|
||||
const { rows, earlier } = useMemo(() => checklistRows(check), [check]);
|
||||
// Dimmed while it may not match the route, and while the list it came from reads again.
|
||||
const dimmed = checklist.dirty || checklist.stale || checklist.status === 'loading';
|
||||
return (
|
||||
<div data-testid="paddle-check-list" className={`flex flex-col gap-1.5 ${dimmed ? 'opacity-60' : ''}`}>
|
||||
{rows.length > 0 ? (
|
||||
<ul data-testid="paddle-check-rows" className="-mx-1.5 flex list-none flex-col gap-0.5 p-0">
|
||||
{rows.map((row, index) => (
|
||||
<li key={rowKey(row, index)}>
|
||||
<RouteRow row={row} checklist={checklist} entries={check.entries} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyLine summary={checklist.summary} />
|
||||
)}
|
||||
{earlier.length > 0 && (
|
||||
<EarlierDecisions entries={earlier} selectedKey={checklist.selectedKey} onSelect={checklist.select} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-06 in the planner rail: the obstacle checklist of the open paddle tour.
|
||||
* The obstacles and the paddler's own portages and locks stand in route
|
||||
* order, each a row that picks it on the map. One line says what state the
|
||||
* list is in; while the route has unsaved changes or the list comes from the
|
||||
* cache, the rows stay readable but dimmed. The card closes on the line that
|
||||
* a checklist is no safety or legal clearance, and opens an entry the map
|
||||
* cannot show in a dialog of its own.
|
||||
*/
|
||||
export function PaddleCheckCard({ checklist }: { checklist: PaddleChecklist }) {
|
||||
const { t } = useTranslation();
|
||||
if (checklist.status === 'off') return null;
|
||||
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>
|
||||
{checklist.dialogKey !== null && <ObstacleEntryDialog checklist={checklist} />}
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// PCS-001 to PCS-004: the checklist section in a paddle tour's detail (PAD-09): the bench and how current its data
|
||||
// is in one box, then the safety note, which stays when the tour has no bench yet.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
|
||||
import { paddleBench } from '../../../../tests/helpers/paddleCheck';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { PaddleCheckSection } from './PaddleCheckSection';
|
||||
|
||||
const SAFETY = 'Paddling has risks. Check water, rules and weather yourself.';
|
||||
|
||||
/** A planned paddle tour as the list carries it, without a bench. */
|
||||
const TOUR: TourListItem = {
|
||||
place_id: 21,
|
||||
name: 'Naab paddle',
|
||||
tour_type: 'kayak',
|
||||
distance: 12.4,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
boat_type: 'canoe',
|
||||
};
|
||||
|
||||
interface Benchless {
|
||||
id: string;
|
||||
what: string;
|
||||
tour: TourListItem;
|
||||
}
|
||||
|
||||
const BENCHLESS: Benchless[] = [
|
||||
{ id: 'PCS-002', what: 'no bench yet', tour: { ...TOUR, paddle_check: null } },
|
||||
{ id: 'PCS-003', what: 'a list cached before the bench existed', tour: TOUR },
|
||||
];
|
||||
|
||||
describe('PaddleCheckSection', () => {
|
||||
it('PCS-001: a tour with a bench shows its pill and its data in one box, the safety note below it', () => {
|
||||
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench({ data: 'partial', open: 2 }) }} />);
|
||||
|
||||
expect(screen.getByText('Checklist')).toBeInTheDocument();
|
||||
const summary = screen.getByTestId('paddle-check-summary');
|
||||
expect(within(summary).getByTestId('plan-tour-paddle-check').textContent).toBe('2 open');
|
||||
expect(within(summary).getByTestId('paddle-check-coverage')).toHaveTextContent('Partial');
|
||||
expect(within(summary).getByTestId('paddle-check-as-of')).toHaveTextContent('OSM as of');
|
||||
expect(within(summary).getByTestId('paddle-check-read')).toHaveTextContent('Fetched');
|
||||
|
||||
const row = screen.getByRole('button', { name: SAFETY });
|
||||
expect(summary).not.toContainElement(row);
|
||||
expect(summary.compareDocumentPosition(row) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(BENCHLESS)('$id: a tour with $what shows the safety note alone', ({ tour }) => {
|
||||
render(<PaddleCheckSection tour={tour} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: SAFETY })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Checklist')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-summary')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('plan-tour-paddle-check')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-check-data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PCS-004: the safety note of the section opens the full note', () => {
|
||||
render(<PaddleCheckSection tour={{ ...TOUR, paddle_check: paddleBench() }} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: SAFETY }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Safety on the water' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { BOX } from '../../Planner/bookings/bookingParts';
|
||||
import { DialogSection } from '../../shared/DialogShell';
|
||||
import { PaddleCheckDataLine, PaddleCheckPill } from './PaddleCheckBadges';
|
||||
import { PaddleSafetyRow } from './PaddleSafety';
|
||||
|
||||
/**
|
||||
* The checklist of a paddle tour in its detail (PAD-09): the check bench as
|
||||
* the day plan shows it and how current its data is, then the safety note.
|
||||
* A tour without a bench yet shows the safety note alone.
|
||||
*/
|
||||
export function PaddleCheckSection({ tour }: { tour: TourListItem }) {
|
||||
const { t } = useTranslation();
|
||||
const check = tour.paddle_check ?? null;
|
||||
if (!check) {
|
||||
return (
|
||||
<div className="flex-none">
|
||||
<PaddleSafetyRow />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DialogSection className="flex-none" label={t('tours.paddle.check.title')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div data-testid="paddle-check-summary" className={`${BOX} flex flex-col gap-2 px-3 py-2.5`}>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<PaddleCheckPill check={check} />
|
||||
</div>
|
||||
<PaddleCheckDataLine check={check} />
|
||||
</div>
|
||||
<PaddleSafetyRow />
|
||||
</div>
|
||||
</DialogSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { CSSProperties, SVGProps } from 'react';
|
||||
import { PADDLE_GLYPH_PATHS, type PaddleGlyphKind } from './paddleGlyphPaths';
|
||||
|
||||
/**
|
||||
* The paddler's own symbols, drawn on lucide's grid (24, stroke 2, round
|
||||
* caps) so they sit beside its icons without a seam: a lock as a chamber
|
||||
* between its two mitre gates, a portage as footsteps (lucide's own), a weir
|
||||
* as water falling over a ledge, a dam as a wall holding water back, rapids
|
||||
* as broken water, getting in and out as an arrow into or out of the water.
|
||||
* The waypoint list, the map and the checklist all use them.
|
||||
*/
|
||||
export function PaddleGlyph({
|
||||
kind,
|
||||
size = 16,
|
||||
strokeWidth = 2,
|
||||
...rest
|
||||
}: { kind: PaddleGlyphKind; size?: number; strokeWidth?: number } & Omit<SVGProps<SVGSVGElement>, 'children'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
data-paddle-glyph={kind}
|
||||
{...rest}
|
||||
>
|
||||
{PADDLE_GLYPH_PATHS[kind].map((d) => (
|
||||
<path key={d} d={d} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** The portage glyph as an icon component, for menus that take one. */
|
||||
export function PortageIcon(props: { size?: number; style?: CSSProperties }) {
|
||||
return <PaddleGlyph kind="portage" {...props} />;
|
||||
}
|
||||
|
||||
/** The lock glyph as an icon component, for menus that take one. */
|
||||
export function LockIcon(props: { size?: number; style?: CSSProperties }) {
|
||||
return <PaddleGlyph kind="lock" {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* FE-PADDLE-LEAFLET-001 to FE-PADDLE-LEAFLET-006: the paddle obstacles on the
|
||||
* Leaflet renderer.
|
||||
*
|
||||
* Leaflet itself is not under test: the mock reflects each polyline's,
|
||||
* marker's and tooltip's props back as data attributes, so the assertions read
|
||||
* what is drawn, in which order and with what options. Pinned: the objects only
|
||||
* in the area wait for PADDLE_AREA_MIN_ZOOM unless one is picked, every line
|
||||
* sits on a white casing that takes no clicks, a line only near the route is
|
||||
* dashed, the markers grow and rise by how they stand to the route, and a click
|
||||
* on a marker or a line picks the object without reaching the map underneath.
|
||||
*/
|
||||
import L from 'leaflet';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PaddleFeature, PaddleGeometry, PaddleObstacleKind, RelatedObstacle } from '@trek/shared';
|
||||
|
||||
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { PaddleLayerLeaflet } from './PaddleLayerLeaflet';
|
||||
import {
|
||||
PADDLE_AREA_MIN_ZOOM,
|
||||
paddleMapItems,
|
||||
paddleMarkerHtml,
|
||||
type PaddleMapItem,
|
||||
type PaddleMapLayer,
|
||||
} from './paddleMapLayer';
|
||||
import type { PaddlePaint } from './paddlePaint';
|
||||
|
||||
/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */
|
||||
const PAINT: PaddlePaint = {
|
||||
obstacle: '#334155',
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
|
||||
const zoom = { value: 12 };
|
||||
const zoomEnd: { handler: (() => void) | null } = { handler: null };
|
||||
|
||||
/** What Leaflet hands a click handler; stopPropagation marks its original event. */
|
||||
const leafletClick = { originalEvent: new MouseEvent('click') };
|
||||
type Handlers = Record<string, ((event: typeof leafletClick) => void) | undefined>;
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
Polyline: ({
|
||||
positions,
|
||||
pathOptions,
|
||||
interactive,
|
||||
bubblingMouseEvents,
|
||||
eventHandlers,
|
||||
}: {
|
||||
positions: [number, number][];
|
||||
pathOptions: { color: string; weight: number; opacity: number; dashArray?: string; lineCap?: string };
|
||||
interactive?: boolean;
|
||||
bubblingMouseEvents?: boolean;
|
||||
eventHandlers?: Handlers;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="polyline"
|
||||
data-positions={JSON.stringify(positions)}
|
||||
data-color={pathOptions.color}
|
||||
data-weight={pathOptions.weight}
|
||||
data-opacity={pathOptions.opacity}
|
||||
data-dash={pathOptions.dashArray ?? ''}
|
||||
data-cap={pathOptions.lineCap ?? ''}
|
||||
data-interactive={interactive === false ? 'false' : 'true'}
|
||||
data-bubbles={bubblingMouseEvents === false ? 'false' : 'true'}
|
||||
onClick={() => eventHandlers?.click?.(leafletClick)}
|
||||
/>
|
||||
),
|
||||
Marker: ({
|
||||
position,
|
||||
icon,
|
||||
zIndexOffset,
|
||||
eventHandlers,
|
||||
children,
|
||||
}: {
|
||||
position: [number, number];
|
||||
icon: L.DivIcon;
|
||||
zIndexOffset: number;
|
||||
eventHandlers: Handlers;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pin"
|
||||
data-position={JSON.stringify(position)}
|
||||
data-size={JSON.stringify(icon.options.iconSize)}
|
||||
data-anchor={JSON.stringify(icon.options.iconAnchor)}
|
||||
data-class={icon.options.className}
|
||||
data-html={typeof icon.options.html === 'string' ? icon.options.html : ''}
|
||||
data-z={zIndexOffset}
|
||||
onClick={() => eventHandlers.click?.(leafletClick)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Tooltip: ({
|
||||
children,
|
||||
direction,
|
||||
offset,
|
||||
opacity,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
direction: string;
|
||||
offset: [number, number];
|
||||
opacity: number;
|
||||
className: string;
|
||||
}) => (
|
||||
<span
|
||||
data-testid="tooltip"
|
||||
data-direction={direction}
|
||||
data-offset={JSON.stringify(offset)}
|
||||
data-opacity={opacity}
|
||||
data-class={className}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
useMap: () => ({ getZoom: () => zoom.value }),
|
||||
useMapEvents: (handlers: { zoomend?: () => void }) => {
|
||||
zoomEnd.handler = handlers.zoomend ?? null;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
const feature = (
|
||||
ref: string,
|
||||
kind: PaddleObstacleKind,
|
||||
geometry: PaddleGeometry,
|
||||
name: string | null = null
|
||||
): PaddleFeature => ({
|
||||
ref,
|
||||
kind,
|
||||
group_key: null,
|
||||
name,
|
||||
geometry,
|
||||
tags: {},
|
||||
tag_hash: ref,
|
||||
region_id: 'oberpfalz',
|
||||
});
|
||||
const lineString = (...coordinates: [number, number][]): PaddleGeometry => ({ type: 'LineString', coordinates });
|
||||
const polygon = (...ring: [number, number][]): PaddleGeometry => ({ type: 'Polygon', coordinates: [ring] });
|
||||
|
||||
/** In GeoJSON [lng, lat]: a weir the route crosses, a lock beside it, a put-in and an access area further off. */
|
||||
const WEIR = feature('way/1', 'weir', lineString([12.1, 49.1], [12.1005, 49.1005], [12.101, 49.101]), 'Pielmühle');
|
||||
const LOCK = feature('way/2', 'lock', lineString([12.11, 49.11], [12.111, 49.111]));
|
||||
const PUT_IN = feature('node/3', 'put_in', { type: 'Point', coordinates: [12.12, 49.12] }, 'Etterzhausen');
|
||||
const ACCESS = feature('way/4', 'access', polygon([12.13, 49.13], [12.131, 49.13], [12.131, 49.131], [12.13, 49.13]));
|
||||
const RELATED: RelatedObstacle[] = [
|
||||
{ feature: WEIR, relation: 'on_route', distanceM: 0, alongM: 1200 },
|
||||
{ feature: LOCK, relation: 'near', distanceM: 90, alongM: 2400 },
|
||||
];
|
||||
const ITEMS = paddleMapItems([WEIR, LOCK, PUT_IN, ACCESS], RELATED, PAINT);
|
||||
const ALL_LABELS = ['Weir: Pielmühle', 'Lock', 'Put-in: Etterzhausen', 'Access rule'];
|
||||
const ROUTE_LABELS = ['Weir: Pielmühle', 'Lock'];
|
||||
|
||||
const layerOf = (
|
||||
items: PaddleMapItem[],
|
||||
selectedRef: string | null = null,
|
||||
onSelect: (ref: string) => void = vi.fn()
|
||||
): PaddleMapLayer => ({ items, selectedRef, selectedKey: null, onSelect });
|
||||
|
||||
const lines = () => screen.queryAllByTestId('polyline');
|
||||
const pins = () => screen.queryAllByTestId('pin');
|
||||
const labels = () => screen.queryAllByTestId('tooltip').map((tip) => tip.textContent);
|
||||
const attr = (elements: HTMLElement[], name: string) => elements.map((element) => element.dataset[name]);
|
||||
|
||||
const zoomTo = (value: number) => {
|
||||
zoom.value = value;
|
||||
act(() => zoomEnd.handler?.());
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
zoom.value = PADDLE_AREA_MIN_ZOOM - 1;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('PaddleLayerLeaflet', () => {
|
||||
it('FE-PADDLE-LEAFLET-001: draws nothing without a layer, without items, or with only area objects while zoomed out', () => {
|
||||
const { container, rerender } = render(<PaddleLayerLeaflet />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
rerender(<PaddleLayerLeaflet layer={layerOf([])} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
rerender(<PaddleLayerLeaflet layer={layerOf(paddleMapItems([PUT_IN, ACCESS], [], PAINT))} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LEAFLET-002: area objects wait for PADDLE_AREA_MIN_ZOOM, join on a zoomend and leave again when zoomed out', () => {
|
||||
render(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
|
||||
expect(labels()).toEqual(ROUTE_LABELS);
|
||||
// A casing and a line each for the weir and the lock.
|
||||
expect(lines()).toHaveLength(4);
|
||||
|
||||
zoomTo(PADDLE_AREA_MIN_ZOOM);
|
||||
expect(labels()).toEqual(ALL_LABELS);
|
||||
// The put-in is a point and draws no line; the access area adds its outline.
|
||||
expect(lines()).toHaveLength(6);
|
||||
|
||||
zoomTo(PADDLE_AREA_MIN_ZOOM - 1);
|
||||
expect(labels()).toEqual(ROUTE_LABELS);
|
||||
expect(lines()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LEAFLET-003: a picked area object shows at any zoom, larger and on top, until it is no longer picked', () => {
|
||||
zoom.value = PADDLE_AREA_MIN_ZOOM - 3;
|
||||
const { rerender } = render(<PaddleLayerLeaflet layer={layerOf(ITEMS, 'way/4')} />);
|
||||
expect(labels()).toEqual([...ROUTE_LABELS, 'Access rule']);
|
||||
expect(lines()).toHaveLength(6);
|
||||
|
||||
const pin = screen.getByRole('button', { name: 'Access rule' });
|
||||
expect(pin.dataset.size).toBe('[36,36]');
|
||||
expect(pin.dataset.anchor).toBe('[18,18]');
|
||||
expect(pin.dataset.z).toBe('900');
|
||||
expect(pin.dataset.html).toBe(paddleMarkerHtml(ITEMS[3], true));
|
||||
|
||||
rerender(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
|
||||
expect(labels()).toEqual(ROUTE_LABELS);
|
||||
expect(lines()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LEAFLET-004: markers sit on the point or mid-line, grow and rise by their relation, and name kind and object', () => {
|
||||
zoom.value = PADDLE_AREA_MIN_ZOOM;
|
||||
const { rerender } = render(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
|
||||
|
||||
expect(attr(pins(), 'position')).toEqual([
|
||||
'[49.1005,12.1005]',
|
||||
'[49.111,12.111]',
|
||||
'[49.12,12.12]',
|
||||
'[49.131,12.131]',
|
||||
]);
|
||||
expect(attr(pins(), 'size')).toEqual(['[30,30]', '[26,26]', '[22,22]', '[22,22]']);
|
||||
expect(attr(pins(), 'anchor')).toEqual(['[15,15]', '[13,13]', '[11,11]', '[11,11]']);
|
||||
expect(attr(pins(), 'z')).toEqual(['600', '300', '300', '300']);
|
||||
expect(attr(pins(), 'class')).toEqual(['trek-paddle-pin', 'trek-paddle-pin', 'trek-paddle-pin', 'trek-paddle-pin']);
|
||||
expect(attr(pins(), 'html')).toEqual(ITEMS.map((item) => paddleMarkerHtml(item, false)));
|
||||
|
||||
// The kind, with the object's name when it has one.
|
||||
expect(labels()).toEqual(ALL_LABELS);
|
||||
const tip = screen.getAllByTestId('tooltip')[0];
|
||||
expect(tip.dataset.direction).toBe('top');
|
||||
expect(tip.dataset.offset).toBe('[0,-12]');
|
||||
expect(tip.dataset.opacity).toBe('1');
|
||||
expect(tip.dataset.class).toBe('map-tooltip');
|
||||
|
||||
// Picking the weir draws its marker larger and above the rest; the others keep theirs.
|
||||
rerender(<PaddleLayerLeaflet layer={layerOf(ITEMS, 'way/1')} />);
|
||||
expect(attr(pins(), 'size')).toEqual(['[36,36]', '[26,26]', '[22,22]', '[22,22]']);
|
||||
expect(attr(pins(), 'z')).toEqual(['900', '300', '300', '300']);
|
||||
expect(pins()[0].dataset.html).toBe(paddleMarkerHtml(ITEMS[0], true));
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LEAFLET-005: every line runs on a white casing that takes no clicks, dashed only while near the route', () => {
|
||||
zoom.value = PADDLE_AREA_MIN_ZOOM;
|
||||
render(<PaddleLayerLeaflet layer={layerOf(ITEMS)} />);
|
||||
// The casings of the weir, the lock and the access outline first, then their lines on top.
|
||||
const casings = lines().slice(0, 3);
|
||||
const drawn = lines().slice(3);
|
||||
expect(drawn).toHaveLength(3);
|
||||
|
||||
expect(attr(casings, 'color')).toEqual(['#ffffff', '#ffffff', '#ffffff']);
|
||||
expect(attr(casings, 'weight')).toEqual(['7', '6', '5']);
|
||||
expect(attr(casings, 'dash')).toEqual(['', '', '']);
|
||||
expect(attr(casings, 'interactive')).toEqual(['false', 'false', 'false']);
|
||||
|
||||
expect(attr(drawn, 'color')).toEqual([PAINT.obstacle, PAINT.obstacle, PAINT.access]);
|
||||
expect(attr(drawn, 'weight')).toEqual(['4', '3', '2']);
|
||||
expect(attr(drawn, 'dash')).toEqual(['', '6 5', '']);
|
||||
expect(attr(drawn, 'cap')).toEqual(['round', 'round', 'round']);
|
||||
expect(attr(drawn, 'interactive')).toEqual(['true', 'true', 'true']);
|
||||
expect(attr(drawn, 'bubbles')).toEqual(['false', 'false', 'false']);
|
||||
|
||||
// Casing and line fade together, by how the object stands to the route.
|
||||
expect(attr(casings, 'opacity')).toEqual(['1', '0.85', '0.5']);
|
||||
expect(attr(drawn, 'opacity')).toEqual(['1', '0.85', '0.5']);
|
||||
|
||||
// Both follow the same geometry, turned into [lat, lng].
|
||||
expect(attr(casings, 'positions')).toEqual(attr(drawn, 'positions'));
|
||||
expect(attr(drawn, 'positions')).toEqual([
|
||||
'[[49.1,12.1],[49.1005,12.1005],[49.101,12.101]]',
|
||||
'[[49.11,12.11],[49.111,12.111]]',
|
||||
'[[49.13,12.13],[49.13,12.131],[49.131,12.131],[49.13,12.13]]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-LEAFLET-006: a click on a marker or a line picks the object and goes no further, so no waypoint lands under it', () => {
|
||||
const stop = vi.spyOn(L.DomEvent, 'stopPropagation');
|
||||
const onSelect = vi.fn();
|
||||
render(<PaddleLayerLeaflet layer={layerOf(ITEMS, null, onSelect)} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Lock' }));
|
||||
expect(onSelect).toHaveBeenCalledWith('way/2');
|
||||
expect(stop).toHaveBeenCalledWith(leafletClick);
|
||||
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(onSelect.mock.invocationCallOrder[0]);
|
||||
|
||||
// Two casings, then the weir's line and the lock's.
|
||||
fireEvent.click(lines()[2]);
|
||||
expect(onSelect).toHaveBeenLastCalledWith('way/1');
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A casing carries no handler of its own.
|
||||
fireEvent.click(lines()[0]);
|
||||
expect(onSelect).toHaveBeenCalledTimes(2);
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import L from 'leaflet';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import {
|
||||
isPaddleItemSelected,
|
||||
markerSize,
|
||||
paddleItemsAtZoom,
|
||||
paddleMarkerHtml,
|
||||
type PaddleMapItem,
|
||||
type PaddleMapLayer,
|
||||
} from './paddleMapLayer';
|
||||
|
||||
const LINE_WEIGHT = { on_route: 4, near: 3, area: 2 } as const;
|
||||
const LINE_OPACITY = { on_route: 1, near: 0.85, area: 0.5 } as const;
|
||||
|
||||
function iconOf(item: PaddleMapItem, selected: boolean): L.DivIcon {
|
||||
const size = markerSize(item.relation, selected);
|
||||
return L.divIcon({
|
||||
html: paddleMarkerHtml(item, selected),
|
||||
className: 'trek-paddle-pin',
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
/** What a marker's tooltip says: the kind of object and its name, if it has one. */
|
||||
function useLabel() {
|
||||
const { t } = useTranslation();
|
||||
return (item: PaddleMapItem) => {
|
||||
const kind = t(`tours.paddle.obstacle.kind.${item.feature.kind}`);
|
||||
return item.feature.name ? `${kind}: ${item.feature.name}` : kind;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* under it.
|
||||
*/
|
||||
export function PaddleLayerLeaflet({ layer }: { layer?: PaddleMapLayer }) {
|
||||
const label = useLabel();
|
||||
const map = useMap();
|
||||
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||
useMapEvents({ zoomend: () => setZoom(map.getZoom()) });
|
||||
const selectedRef = layer?.selectedRef ?? null;
|
||||
const selectedKey = layer?.selectedKey ?? null;
|
||||
const all = layer?.items;
|
||||
const items = useMemo(() => (all ? paddleItemsAtZoom(all, zoom, selectedRef) : undefined), [all, zoom, selectedRef]);
|
||||
// Rebuilt only when the items or the selection change, not on every render of the map.
|
||||
const icons = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(items ?? []).map((item) => [
|
||||
item.feature.ref,
|
||||
iconOf(item, isPaddleItemSelected(item, selectedRef, selectedKey)),
|
||||
])
|
||||
),
|
||||
[items, selectedRef, selectedKey]
|
||||
);
|
||||
if (!layer || !items?.length) return null;
|
||||
const select = (ref: string) => ({
|
||||
click: (event: L.LeafletMouseEvent) => {
|
||||
L.DomEvent.stopPropagation(event);
|
||||
layer.onSelect(ref);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{items.map((item) =>
|
||||
item.line ? (
|
||||
<Polyline
|
||||
key={`${item.feature.ref}-casing`}
|
||||
positions={item.line}
|
||||
pathOptions={{
|
||||
color: item.disc,
|
||||
weight: LINE_WEIGHT[item.relation] + 3,
|
||||
opacity: LINE_OPACITY[item.relation],
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{items.map((item) =>
|
||||
item.line ? (
|
||||
<Polyline
|
||||
key={`${item.feature.ref}-line`}
|
||||
positions={item.line}
|
||||
bubblingMouseEvents={false}
|
||||
pathOptions={{
|
||||
color: item.color,
|
||||
weight: LINE_WEIGHT[item.relation],
|
||||
opacity: LINE_OPACITY[item.relation],
|
||||
dashArray: item.relation === 'near' ? '6 5' : undefined,
|
||||
lineCap: 'round',
|
||||
}}
|
||||
eventHandlers={select(item.feature.ref)}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<Marker
|
||||
key={`${item.feature.ref}-pin`}
|
||||
position={item.marker}
|
||||
icon={icons.get(item.feature.ref) as L.DivIcon}
|
||||
zIndexOffset={
|
||||
isPaddleItemSelected(item, selectedRef, selectedKey) ? 900 : item.relation === 'on_route' ? 600 : 300
|
||||
}
|
||||
eventHandlers={select(item.feature.ref)}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -12]} opacity={1} className="map-tooltip">
|
||||
{label(item)}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// FE-PADDLE-CARD-001 to FE-PADDLE-CARD-015: the card for one paddle object picked on the map or in the checklist.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PaddleFeature, RelatedObstacle } from '@trek/shared';
|
||||
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import PaddleObstacleCard from './PaddleObstacleCard';
|
||||
import { paddleObstacleDetail } from './paddleObstacleModel';
|
||||
|
||||
type Relation = Omit<RelatedObstacle, 'feature'>;
|
||||
|
||||
/** Noon UTC, so the date reads the same in every time zone the suite runs in. */
|
||||
const AS_OF = '2026-09-01T12:00:00Z';
|
||||
const ON_ROUTE: Relation = { relation: 'on_route', distanceM: 0, alongM: 1200 };
|
||||
const NEAR: Relation = { relation: 'near', distanceM: 80, alongM: 640 };
|
||||
|
||||
function weir(overrides: Partial<PaddleFeature> = {}): PaddleFeature {
|
||||
return {
|
||||
ref: 'way/29256405',
|
||||
kind: 'weir',
|
||||
group_key: null,
|
||||
name: 'Wehr Pielenhofen',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[11.9561, 49.0567],
|
||||
[11.9566, 49.0571],
|
||||
],
|
||||
},
|
||||
tags: { waterway: 'weir' },
|
||||
tag_hash: 'h-29256405',
|
||||
region_id: 'oberpfalz',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the card is handed for the picked object, worked out by the model the way the store does it. */
|
||||
function detailOf(feature: PaddleFeature, related: Relation | null, asOf: string | null = AS_OF) {
|
||||
const detail = paddleObstacleDetail({
|
||||
features: [feature],
|
||||
related: related ? [{ feature, ...related }] : [],
|
||||
regions: [{ id: 'oberpfalz', name: 'Oberpfalz', source_timestamp: asOf, imported_at: null }],
|
||||
selectedRef: feature.ref,
|
||||
});
|
||||
if (!detail) throw new Error(`${feature.ref} is not picked`);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** The weir on the route, carrying only the given tags. */
|
||||
function withTags(tags: Record<string, string>) {
|
||||
return detailOf(weir({ tags }), ON_ROUTE);
|
||||
}
|
||||
|
||||
/** The badge a label sits in, which its tooltip hangs on. */
|
||||
function badgeOf(label: string): HTMLElement {
|
||||
const badge = screen.getByText(label).parentElement;
|
||||
if (!badge) throw new Error(`no badge around ${label}`);
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** The white badge beside a label: it holds the value and carries the tone. */
|
||||
function valueBeside(label: string): Element {
|
||||
const value = screen.getByText(label).nextElementSibling;
|
||||
if (!value) throw new Error(`no value beside ${label}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('PaddleObstacleCard', () => {
|
||||
beforeEach(() => {
|
||||
// No distance unit chosen: the card reads metric.
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-001: names the object, says what it is and how far along the route it lies', async () => {
|
||||
const { container } = render(<PaddleObstacleCard detail={detailOf(weir(), ON_ROUTE)} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Wehr Pielenhofen' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Weir')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'weir');
|
||||
expect(valueBeside('On the route at').textContent).toBe('1.2 km');
|
||||
expect(screen.getByTestId('paddle-obstacle-relation')).not.toHaveTextContent('Link unclear');
|
||||
|
||||
fireEvent.mouseEnter(badgeOf('On the route at'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('How far along the route it lies, from the start');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-002: an unnamed object takes its kind as title and does not repeat it', () => {
|
||||
const gate = weir({ ref: 'node/77', kind: 'lock_gate', name: null, tags: { 'seamark:type': 'gate' } });
|
||||
const { container } = render(<PaddleObstacleCard detail={detailOf(gate, ON_ROUTE)} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Lock gate' })).toHaveAttribute('title', 'Lock gate');
|
||||
expect(screen.getAllByText('Lock gate')).toHaveLength(1);
|
||||
// A gate is drawn as part of its lock.
|
||||
expect(container.querySelector('[data-paddle-glyph]')).toHaveAttribute('data-paddle-glyph', 'lock');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-003: an object only near the route gives its distance and says the link is unclear', async () => {
|
||||
render(<PaddleObstacleCard detail={detailOf(weir(), NEAR)} />);
|
||||
|
||||
expect(valueBeside('Near the route').textContent).toBe('80 m');
|
||||
expect(screen.getByText('Link unclear')).toBeInTheDocument();
|
||||
expect(screen.queryByText('On the route at')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(badgeOf('Near the route'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'How far from the route it lies. It may be on another arm of the water.'
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-004: an object only in the area gives no distance at all', () => {
|
||||
render(<PaddleObstacleCard detail={detailOf(weir(), null)} />);
|
||||
|
||||
const relation = screen.getByTestId('paddle-obstacle-relation');
|
||||
expect(relation).toHaveTextContent('In the area');
|
||||
expect(relation).not.toHaveTextContent('On the route at');
|
||||
expect(relation).not.toHaveTextContent('Near the route');
|
||||
expect(relation).not.toHaveTextContent('Link unclear');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-005: reads canoe before boat and words the known values, marking a ban or private water', () => {
|
||||
const { rerender } = render(<PaddleObstacleCard detail={withTags({ canoe: 'no', boat: 'yes' })} />);
|
||||
expect(valueBeside('Canoe').textContent).toBe('no');
|
||||
expect(valueBeside('Canoe')).toHaveClass('text-warning');
|
||||
expect(screen.queryByText('Boat')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleObstacleCard detail={withTags({ boat: 'permissive' })} />);
|
||||
expect(valueBeside('Boat').textContent).toBe('tolerated');
|
||||
expect(valueBeside('Boat')).toHaveClass('text-content');
|
||||
expect(valueBeside('Boat')).not.toHaveClass('text-warning');
|
||||
|
||||
rerender(<PaddleObstacleCard detail={withTags({ access: 'private' })} />);
|
||||
expect(valueBeside('Access').textContent).toBe('private');
|
||||
expect(valueBeside('Access')).toHaveClass('text-warning');
|
||||
|
||||
// A value the card has no words for is shown as OpenStreetMap has it.
|
||||
rerender(<PaddleObstacleCard detail={withTags({ canoe: 'portage' })} />);
|
||||
expect(valueBeside('Canoe').textContent).toBe('portage');
|
||||
expect(valueBeside('Canoe')).not.toHaveClass('text-warning');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-006: no paddling rule reads as unknown, never as allowed', () => {
|
||||
const bare = withTags({});
|
||||
const { rerender } = render(<PaddleObstacleCard detail={bare} />);
|
||||
const access = screen.getByTestId('paddle-obstacle-access');
|
||||
expect(access).toHaveTextContent('No paddling rule known');
|
||||
expect(access).not.toHaveTextContent('yes');
|
||||
|
||||
// A rule that names its tag but carries no value says nothing either.
|
||||
const unsure = { ...bare.access, source: 'canoe' as const, value: null };
|
||||
rerender(<PaddleObstacleCard detail={{ ...bare, access: unsure }} />);
|
||||
expect(screen.getByText('No paddling rule known')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Canoe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-007: a conditional rule is shown as written, alone or beside its rule', async () => {
|
||||
const conditional = 'no @ (Nov 01-Mar 31)';
|
||||
const { rerender } = render(<PaddleObstacleCard detail={withTags({ 'canoe:conditional': conditional })} />);
|
||||
expect(valueBeside('Condition').textContent).toBe(conditional);
|
||||
expect(screen.queryByText('No paddling rule known')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(badgeOf('Condition'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(conditional);
|
||||
fireEvent.mouseLeave(badgeOf('Condition'));
|
||||
|
||||
rerender(<PaddleObstacleCard detail={withTags({ canoe: 'yes', 'canoe:conditional': conditional })} />);
|
||||
expect(valueBeside('Canoe').textContent).toBe('yes');
|
||||
expect(valueBeside('Condition').textContent).toBe(conditional);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-008: names the facts worth knowing, a bare length in the user unit', async () => {
|
||||
const lock = weir({
|
||||
ref: 'way/4711',
|
||||
kind: 'lock',
|
||||
name: 'Schleuse Regensburg',
|
||||
tags: {
|
||||
height: '3',
|
||||
width: '12',
|
||||
opening_hours: 'Mo-Su 06:00-22:00',
|
||||
seasonal: 'summer',
|
||||
fee: 'yes',
|
||||
operator: 'WSA Donau MDK',
|
||||
'seamark:type': 'lock_basin',
|
||||
},
|
||||
});
|
||||
const { rerender } = render(<PaddleObstacleCard detail={detailOf(lock, ON_ROUTE)} />);
|
||||
expect(valueBeside('Height').textContent).toBe('3 m');
|
||||
expect(valueBeside('Width').textContent).toBe('12 m');
|
||||
expect(valueBeside('Hours').textContent).toBe('Mo-Su 06:00-22:00');
|
||||
expect(valueBeside('Seasonal').textContent).toBe('summer');
|
||||
expect(valueBeside('Fee').textContent).toBe('yes');
|
||||
expect(valueBeside('Operator').textContent).toBe('WSA Donau MDK');
|
||||
// A tag the card has no label for stays behind.
|
||||
expect(screen.queryByText('lock_basin')).not.toBeInTheDocument();
|
||||
|
||||
// The tooltip keeps the tag as OpenStreetMap holds it.
|
||||
fireEvent.mouseEnter(badgeOf('Width'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(/^12$/);
|
||||
fireEvent.mouseLeave(badgeOf('Width'));
|
||||
|
||||
const rapids = weir({
|
||||
kind: 'rapids',
|
||||
name: null,
|
||||
tags: { 'whitewater:section_grade': 'III', height: '1.2 m' },
|
||||
});
|
||||
rerender(<PaddleObstacleCard detail={detailOf(rapids, NEAR)} />);
|
||||
expect(valueBeside('Grade').textContent).toBe('III');
|
||||
// A length with a unit of its own is not read as metres.
|
||||
expect(valueBeside('Height').textContent).toBe('1.2 m');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-009: a portage recorded as private warns that it is a private way', () => {
|
||||
const portage = weir({ ref: 'way/815', kind: 'portage', name: null, tags: { 'portage:access': 'private' } });
|
||||
const { rerender } = render(<PaddleObstacleCard detail={detailOf(portage, NEAR)} />);
|
||||
expect(screen.getByRole('heading', { name: 'Portage' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Private way')).toHaveClass('text-warning');
|
||||
|
||||
rerender(<PaddleObstacleCard detail={detailOf({ ...portage, tags: { highway: 'path' } }, NEAR)} />);
|
||||
expect(screen.queryByText('Private way')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-010: shows the description OpenStreetMap holds, or else its note, as written', () => {
|
||||
const description = 'Fish pass on the left bank, the boat chute is closed.';
|
||||
const { rerender } = render(<PaddleObstacleCard detail={withTags({ description })} />);
|
||||
expect(screen.getByText(description)).toHaveAttribute('title', description);
|
||||
|
||||
rerender(<PaddleObstacleCard detail={withTags({ description: ' ', note: 'Carry round on the right.' })} />);
|
||||
expect(screen.getByText('Carry round on the right.')).toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleObstacleCard detail={withTags({})} />);
|
||||
expect(screen.queryByText('Carry round on the right.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-011: links to the object on openstreetmap.org and says how current the data is', () => {
|
||||
const { rerender } = render(<PaddleObstacleCard detail={detailOf(weir(), ON_ROUTE)} />);
|
||||
const link = screen.getByRole('link', { name: 'View in OpenStreetMap' });
|
||||
expect(link).toHaveAttribute('href', 'https://www.openstreetmap.org/way/29256405');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
const dated = 'OpenStreetMap data as of Sep 1, 2026, not a check on site. Data © OpenStreetMap contributors';
|
||||
expect(screen.getByText(dated)).toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleObstacleCard detail={detailOf(weir(), ON_ROUTE, null)} />);
|
||||
const undated = 'OpenStreetMap data of an unknown date, not a check on site. Data © OpenStreetMap contributors';
|
||||
expect(screen.getByText(undated)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-012: offers a close button only when it can be closed', () => {
|
||||
const onClose = vi.fn();
|
||||
const detail = detailOf(weir(), ON_ROUTE);
|
||||
const { rerender } = render(<PaddleObstacleCard detail={detail} />);
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleObstacleCard detail={detail} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-013: takes the width it is given, and sizes itself to its content without one', () => {
|
||||
const detail = detailOf(weir(), ON_ROUTE);
|
||||
const { rerender } = render(<PaddleObstacleCard detail={detail} />);
|
||||
expect(screen.getByTestId('paddle-obstacle-card')).toHaveClass('w-max');
|
||||
|
||||
rerender(<PaddleObstacleCard detail={detail} width={320} />);
|
||||
const card = screen.getByTestId('paddle-obstacle-card');
|
||||
expect(card).toHaveStyle({ width: '320px' });
|
||||
expect(card).not.toHaveClass('w-max');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-014: puts what the checklist adds below the facts and above the source link', () => {
|
||||
render(
|
||||
<PaddleObstacleCard detail={withTags({ height: '3' })}>
|
||||
<button type="button">Portage round it</button>
|
||||
</PaddleObstacleCard>
|
||||
);
|
||||
const added = screen.getByRole('button', { name: 'Portage round it' });
|
||||
const facts = screen.getByTestId('paddle-obstacle-access');
|
||||
expect(facts.compareDocumentPosition(added)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(added.compareDocumentPosition(screen.getByRole('link'))).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-CARD-015: gives the distances in the unit the user reads', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
|
||||
const { rerender } = render(<PaddleObstacleCard detail={detailOf(weir(), { ...ON_ROUTE, alongM: 3218 })} />);
|
||||
expect(valueBeside('On the route at').textContent).toBe('2 mi');
|
||||
|
||||
rerender(<PaddleObstacleCard detail={detailOf(weir(), { ...NEAR, distanceM: 805 })} />);
|
||||
expect(valueBeside('Near the route').textContent).toBe('0.5 mi');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ExternalLink, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
|
||||
import { KNOWN_ACCESS_VALUES, metresOf, osmUrlOf, type PaddleObstacleDetail } from './paddleObstacleModel';
|
||||
import { paddleKindCss } from './paddlePaint';
|
||||
|
||||
const ACCESS_KEYS: readonly string[] = KNOWN_ACCESS_VALUES;
|
||||
|
||||
/** How the object stands to the route: on it at a distance along it, near it and uncertain, or only in the area. */
|
||||
function RelationBadges({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5" data-testid="paddle-obstacle-relation">
|
||||
{detail.relation === 'on_route' && (
|
||||
<FigureBadge
|
||||
label={t('tours.paddle.obstacleCard.onRoute')}
|
||||
value={formatDistance((detail.alongM ?? 0) / 1000, unit)}
|
||||
info={t('tours.paddle.obstacleCard.alongInfo')}
|
||||
/>
|
||||
)}
|
||||
{detail.relation === 'near' && (
|
||||
<>
|
||||
<FigureBadge
|
||||
label={t('tours.paddle.obstacleCard.near')}
|
||||
value={formatDistance((detail.distanceM ?? 0) / 1000, unit)}
|
||||
info={t('tours.paddle.obstacleCard.distanceInfo')}
|
||||
/>
|
||||
<SoftPill>{t('tours.paddle.obstacleCard.uncertain')}</SoftPill>
|
||||
</>
|
||||
)}
|
||||
{detail.relation === 'area' && <SoftPill>{t('tours.paddle.obstacleCard.area')}</SoftPill>}
|
||||
{detail.privateWay && <SoftPill tone="warning">{t('tours.paddle.obstacleCard.privateWay')}</SoftPill>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The paddling rule the tags give (canoe read before boat), its condition as written, and the facts worth knowing. */
|
||||
function AccessAndFacts({ detail, unit }: { detail: PaddleObstacleDetail; unit: DistanceUnit }) {
|
||||
const { t } = useTranslation();
|
||||
const { access } = detail;
|
||||
const accessValue = (value: string) =>
|
||||
ACCESS_KEYS.includes(value) ? t(`tours.paddle.obstacleCard.accessValue.${value}`) : value;
|
||||
const factValue = (key: string, value: string) => {
|
||||
const metres = key === 'height' || key === 'width' ? metresOf(value) : null;
|
||||
return metres === null ? value : formatDistance(metres / 1000, unit);
|
||||
};
|
||||
const ruled = access.source !== null && access.value !== null;
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5" data-testid="paddle-obstacle-access">
|
||||
{ruled && (
|
||||
<FigureBadge
|
||||
label={t(`tours.paddle.obstacleCard.access.${access.source ?? 'access'}`)}
|
||||
value={accessValue(access.value ?? '')}
|
||||
tone={access.forbidden || access.private ? 'warning' : 'neutral'}
|
||||
/>
|
||||
)}
|
||||
{!ruled && !access.condition && <SoftPill>{t('tours.paddle.obstacleCard.noAccessInfo')}</SoftPill>}
|
||||
{access.condition && (
|
||||
<FigureBadge
|
||||
label={t('tours.paddle.obstacleCard.condition')}
|
||||
value={access.condition}
|
||||
info={access.condition}
|
||||
/>
|
||||
)}
|
||||
{detail.facts.map((fact) => (
|
||||
<FigureBadge
|
||||
key={fact.key}
|
||||
label={t(`tours.paddle.obstacleCard.fact.${fact.key === 'whitewater:section_grade' ? 'grade' : fact.key}`)}
|
||||
value={factValue(fact.key, fact.value)}
|
||||
info={fact.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The way to the source and how current it is; a record in the data is no check on site. */
|
||||
function SourceLines({ detail }: { detail: PaddleObstacleDetail }) {
|
||||
const { t, language } = useTranslation();
|
||||
const date = detail.dataAsOf ? new Date(detail.dataAsOf).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={osmUrlOf(detail.feature.ref)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 font-medium text-content hover:underline"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('ferratas.viewInOsm')}
|
||||
<ExternalLink size={13} aria-hidden="true" />
|
||||
</a>
|
||||
<p className="mt-2 border-t border-edge-faint pt-2 text-content-faint" style={fs(9)}>
|
||||
{date ? t('tours.paddle.obstacleCard.source', { date }) : t('tours.paddle.obstacleCard.sourceUndated')}{' '}
|
||||
{t('ferratas.attribution')}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One paddle object picked on the map or in the checklist (PAD-05B), in the
|
||||
* look of the via ferrata card: what it is, how it stands to the route (an
|
||||
* object only near it is said to be uncertain, never on it), the paddling
|
||||
* rule its tags give with canoe read before boat, the facts worth knowing,
|
||||
* and where the data comes from and how current it is. A missing rule reads
|
||||
* as unknown, never as allowed; the card says it is data, not a check on site.
|
||||
* `children` takes what the checklist adds below the facts.
|
||||
*/
|
||||
export default function PaddleObstacleCard({
|
||||
detail,
|
||||
onClose,
|
||||
width,
|
||||
children,
|
||||
}: {
|
||||
detail: PaddleObstacleDetail;
|
||||
onClose?: () => void;
|
||||
width?: number;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((s) => s.settings.distance_unit) ?? 'metric';
|
||||
const { feature } = detail;
|
||||
const kind = t(`tours.paddle.obstacle.kind.${feature.kind}`);
|
||||
const color = paddleKindCss(feature.kind);
|
||||
return (
|
||||
<article
|
||||
className={`max-h-[70vh] overflow-y-auto overscroll-contain rounded-2xl p-3 text-content ${width ? '' : 'w-max min-w-[min(18rem,calc(100vw_-_2rem))] max-w-[22.5rem]'}`}
|
||||
style={{ ...MAP_CARD_GLASS, width }}
|
||||
data-testid="paddle-obstacle-card"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full bg-surface-card shadow-sm"
|
||||
style={{ color, boxShadow: `inset 0 0 0 2px ${color}` }}
|
||||
>
|
||||
<PaddleGlyph kind={OBSTACLE_GLYPH[feature.kind]} size={15} strokeWidth={2.3} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={feature.name ?? kind}>
|
||||
{feature.name ?? kind}
|
||||
</h3>
|
||||
{feature.name && <p className="truncate text-caption text-content-muted">{kind}</p>}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="flex-none rounded-lg p-1 text-content-muted hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<RelationBadges detail={detail} unit={unit} />
|
||||
<AccessAndFacts detail={detail} unit={unit} />
|
||||
{detail.note && (
|
||||
<p className="mt-2 line-clamp-3 break-words text-content-muted" style={fs(11.5, 'body')} title={detail.note}>
|
||||
{detail.note}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
<SourceLines detail={detail} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// FE-COMP-PADDLENOTICE-001 to FE-COMP-PADDLENOTICE-011: the one line on what TREK knows about the water.
|
||||
import type { PaddleCoverageRegion, RelatedObstacle } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { act, render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { clearPaddleObstacles, publishPaddleObstacles } from '../../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { PaddleObstacleNotice } from './PaddleObstacleNotice';
|
||||
|
||||
type Published = Parameters<typeof publishPaddleObstacles>[0];
|
||||
|
||||
const LOADING = 'Loading the obstacles along the route …';
|
||||
const OFFLINE = 'Without a connection TREK loads no obstacle data. Check the water yourself.';
|
||||
const FAILED = 'The obstacle data could not be loaded. Check the water yourself.';
|
||||
const NO_DATA = 'No obstacle data for this area. Check weirs, locks and portages here yourself.';
|
||||
const TRUNCATED = 'TREK shows only part of the objects here; the area holds more. Check the water yourself.';
|
||||
const UNKNOWN_DATE = 'an unknown date';
|
||||
|
||||
const partialLine = (date: string): string =>
|
||||
`Obstacle data covers only part of the route (as of ${date}). Check the rest yourself.`;
|
||||
const coveredLine = (count: number, date: string): string =>
|
||||
`${count} obstacles and access points along the route, from OpenStreetMap as of ${date}. The data can have gaps, so check the water yourself.`;
|
||||
const coveredOneLine = (date: string): string =>
|
||||
`1 obstacle or access point along the route, from OpenStreetMap as of ${date}. The data can have gaps, so check the water yourself.`;
|
||||
const coveredEmptyLine = (date: string): string =>
|
||||
`No known obstacles along the route (OpenStreetMap as of ${date}). Check the water yourself all the same.`;
|
||||
|
||||
/**
|
||||
* A region TREK holds data for. Its date is local noon, so it reads as the
|
||||
* same day in whatever time zone the suite runs.
|
||||
*/
|
||||
const region = (id: string, sourceTimestamp: string | null): PaddleCoverageRegion => ({
|
||||
id,
|
||||
name: `Region ${id}`,
|
||||
source_timestamp: sourceTimestamp,
|
||||
imported_at: '2026-10-01T08:00:00Z',
|
||||
});
|
||||
|
||||
/** A weir the route runs over, `alongM` metres from its start. */
|
||||
const weir = (ref: string, alongM: number): RelatedObstacle => ({
|
||||
feature: {
|
||||
ref,
|
||||
kind: 'weir',
|
||||
group_key: null,
|
||||
name: null,
|
||||
geometry: { type: 'Point', coordinates: [13.4, 52.5] },
|
||||
tags: { waterway: 'weir' },
|
||||
tag_hash: `hash-${ref}`,
|
||||
region_id: 'brandenburg',
|
||||
},
|
||||
relation: 'on_route',
|
||||
distanceM: 0,
|
||||
alongM,
|
||||
});
|
||||
|
||||
/** Publishes a ready answer for a route one region covers wholly, with nothing on it; `overrides` change that. */
|
||||
function publish(overrides: Partial<Published> = {}): void {
|
||||
publishPaddleObstacles({
|
||||
status: 'ready',
|
||||
features: [],
|
||||
related: [],
|
||||
routeLengthM: 8000,
|
||||
coverage: 'covered',
|
||||
regions: [region('brandenburg', '2026-06-15T12:00:00')],
|
||||
truncated: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
// The data date reads in the user's language.
|
||||
seedStore(useSettingsStore, { settings: { language: 'en' } });
|
||||
});
|
||||
|
||||
describe('PaddleObstacleNotice', () => {
|
||||
it('FE-COMP-PADDLENOTICE-001: before anything is read it says the obstacles are loading, as a status with a spinner', () => {
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(LOADING);
|
||||
expect(screen.getByRole('status').querySelector('svg.animate-spin')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-002: offline it says no data is loaded, even while the store still holds a covered answer', () => {
|
||||
publish({ status: 'offline', related: [weir('node/1', 500)] });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByText(OFFLINE)).toBeInTheDocument();
|
||||
// A warning, not a status line: there is nothing to rely on.
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-003: a failed read says the data could not be loaded', () => {
|
||||
publish({ status: 'error' });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByText(FAILED)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-004: an area without data says to check the water there instead of reading as open water', () => {
|
||||
publish({ coverage: 'none', regions: [], truncated: true });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByText(NO_DATA)).toBeInTheDocument();
|
||||
expect(screen.queryByText(TRUNCATED)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-005: a partly covered route names the oldest date among its regions', () => {
|
||||
publish({
|
||||
coverage: 'partial',
|
||||
// Out of order and one without a date: the route is only as current as its oldest dated part.
|
||||
regions: [region('spree', '2026-06-20T12:00:00'), region('havel', '2026-05-10T12:00:00'), region('oder', null)],
|
||||
truncated: true,
|
||||
});
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByText(partialLine('May 10, 2026'))).toBeInTheDocument();
|
||||
// That only part of the route is covered matters more than an answer cut at its limit.
|
||||
expect(screen.queryByText(TRUNCATED)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-006: without a dated region the date reads as unknown', () => {
|
||||
publish({ coverage: 'partial', regions: [region('spree', null), region('havel', '')] });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByText(partialLine(UNKNOWN_DATE))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-007: an answer cut at its limit says the area holds more instead of counting', () => {
|
||||
publish({ truncated: true, related: [weir('node/1', 500), weir('node/2', 2500)] });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByText(TRUNCATED)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/obstacles and access points/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-008: a covered route counts what lies on or near it, with the date, as a status', () => {
|
||||
const related: RelatedObstacle[] = [
|
||||
weir('node/1', 500),
|
||||
weir('way/2', 2500),
|
||||
{ ...weir('node/3', 6000), relation: 'near', distanceM: 90 },
|
||||
];
|
||||
// A fourth object is read in the area but lies off the route, so it is not counted.
|
||||
publish({ features: [...related.map((item) => item.feature), weir('node/4', 0).feature], related });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(coveredLine(3, 'Jun 15, 2026'));
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-009: one object on the route reads in the singular', () => {
|
||||
publish({ related: [weir('node/1', 500)] });
|
||||
render(<PaddleObstacleNotice />);
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(coveredOneLine('Jun 15, 2026'));
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-010: a covered route with nothing on it still says to check the water, dated or not', () => {
|
||||
publish();
|
||||
render(<PaddleObstacleNotice />);
|
||||
expect(screen.getByRole('status')).toHaveTextContent(coveredEmptyLine('Jun 15, 2026'));
|
||||
|
||||
act(() => {
|
||||
publish({ regions: [region('brandenburg', null)] });
|
||||
});
|
||||
expect(screen.getByRole('status')).toHaveTextContent(coveredEmptyLine(UNKNOWN_DATE));
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLENOTICE-011: follows the store from loading to the answer, and back when the tour closes', () => {
|
||||
publish({ status: 'loading' });
|
||||
render(<PaddleObstacleNotice />);
|
||||
expect(screen.getByRole('status')).toHaveTextContent(LOADING);
|
||||
|
||||
act(() => {
|
||||
publish({ related: [weir('node/1', 500)] });
|
||||
});
|
||||
expect(screen.getByRole('status')).toHaveTextContent(coveredOneLine('Jun 15, 2026'));
|
||||
|
||||
act(() => {
|
||||
clearPaddleObstacles();
|
||||
});
|
||||
expect(screen.getByRole('status')).toHaveTextContent(LOADING);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { compareCodeUnits } from '@trek/shared';
|
||||
import { AlertTriangle, Info, Loader2, WifiOff } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
|
||||
import { TourNotice } from '../tourParts';
|
||||
|
||||
/** The oldest data date among the regions, as a short local date; the route is only as current as its oldest part. */
|
||||
function dataDate(regions: readonly { source_timestamp?: string | null }[], language: string): string | null {
|
||||
const times = regions
|
||||
.map((region) => region.source_timestamp)
|
||||
.filter((time): time is string => !!time)
|
||||
.sort(compareCodeUnits);
|
||||
return times[0] ? new Date(times[0]).toLocaleDateString(language, { dateStyle: 'medium' }) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-05B: what TREK knows about the water along the route, in one line of
|
||||
* the checklist card until a new paddle tour is saved. It never reads as an
|
||||
* all-clear: covered or not, the paddler checks the water, where nothing is
|
||||
* known it says so instead of leaving an empty map to be read as open water,
|
||||
* and an answer cut at its limit says that more is there.
|
||||
*/
|
||||
export function PaddleObstacleNotice() {
|
||||
const { t, language } = useTranslation();
|
||||
const { status, coverage, related, regions, truncated } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
status: state.status,
|
||||
coverage: state.coverage,
|
||||
related: state.related,
|
||||
regions: state.regions,
|
||||
truncated: state.truncated,
|
||||
}))
|
||||
);
|
||||
const warn = <AlertTriangle size={13} strokeWidth={2} />;
|
||||
if (status === 'idle' || status === 'loading') {
|
||||
return (
|
||||
<TourNotice role="status" icon={<Loader2 size={13} strokeWidth={2} className="animate-spin" />}>
|
||||
{t('tours.paddle.obstacles.loading')}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
if (status === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.obstacles.offline')}</TourNotice>;
|
||||
if (status === 'error') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.error')}</TourNotice>;
|
||||
const date = dataDate(regions, language) ?? t('tours.paddle.obstacles.dateUnknown');
|
||||
if (coverage === 'none') return <TourNotice icon={warn}>{t('tours.paddle.obstacles.none')}</TourNotice>;
|
||||
if (coverage === 'partial')
|
||||
return <TourNotice icon={warn}>{t('tours.paddle.obstacles.partial', { date })}</TourNotice>;
|
||||
if (truncated) return <TourNotice icon={warn}>{t('tours.paddle.obstacles.truncated')}</TourNotice>;
|
||||
return (
|
||||
<TourNotice role="status" icon={<Info size={13} strokeWidth={2} />}>
|
||||
{related.length
|
||||
? t('tours.paddle.obstacles.covered', { count: related.length, date })
|
||||
: t('tours.paddle.obstacles.coveredEmpty', { date })}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// FE-COMP-PADDLEOVERLAY-001 to FE-COMP-PADDLEOVERLAY-008: the picked paddle object's card over the planner map,
|
||||
// opened and closed through the obstacle store, and the ways it closes.
|
||||
import type { ComponentProps } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PaddleCoverageRegion, PaddleFeature, RelatedObstacle } from '@trek/shared';
|
||||
|
||||
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../../tests/helpers/store';
|
||||
import {
|
||||
publishPaddleObstacles,
|
||||
selectPaddleObstacle,
|
||||
usePaddleObstacleStore,
|
||||
} from '../../../store/paddleObstacleStore';
|
||||
import PaddleObstacleOverlay from './PaddleObstacleOverlay';
|
||||
|
||||
const REGION: PaddleCoverageRegion = {
|
||||
id: 'hessen',
|
||||
name: 'Hessen',
|
||||
source_timestamp: '2026-06-01T12:00:00Z',
|
||||
imported_at: '2026-06-02T08:00:00Z',
|
||||
};
|
||||
|
||||
const WEIR: PaddleFeature = {
|
||||
ref: 'way/101',
|
||||
kind: 'weir',
|
||||
group_key: null,
|
||||
name: 'Hammer weir',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[8.1, 50.1],
|
||||
[8.101, 50.1],
|
||||
],
|
||||
},
|
||||
tags: { waterway: 'weir' },
|
||||
tag_hash: 'weir-hash',
|
||||
region_id: 'hessen',
|
||||
};
|
||||
|
||||
/** Unnamed and from a region the answer does not hold, so its card reads its kind and an unknown date. */
|
||||
const SLIPWAY: PaddleFeature = {
|
||||
ref: 'node/202',
|
||||
kind: 'slipway',
|
||||
group_key: null,
|
||||
name: null,
|
||||
geometry: { type: 'Point', coordinates: [8.2, 50.2] },
|
||||
tags: { leisure: 'slipway' },
|
||||
tag_hash: 'slipway-hash',
|
||||
region_id: 'elsewhere',
|
||||
};
|
||||
|
||||
const WEIR_ON_ROUTE: RelatedObstacle = { feature: WEIR, relation: 'on_route', distanceM: 4, alongM: 2500 };
|
||||
|
||||
function publish(features: PaddleFeature[], related: RelatedObstacle[] = []): void {
|
||||
publishPaddleObstacles({
|
||||
status: 'ready',
|
||||
features,
|
||||
related,
|
||||
routeLengthM: 12_000,
|
||||
coverage: 'covered',
|
||||
regions: [REGION],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
function renderOverlay(props: Partial<ComponentProps<typeof PaddleObstacleOverlay>> = {}): void {
|
||||
render(
|
||||
<div>
|
||||
<button type="button">map</button>
|
||||
<PaddleObstacleOverlay insetEnd={400} bottom={66} {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overlay = () => screen.queryByTestId('paddle-obstacle-overlay');
|
||||
const selectedRef = () => usePaddleObstacleStore.getState().selectedRef;
|
||||
const pressMap = () => fireEvent.pointerDown(screen.getByRole('button', { name: 'map' }));
|
||||
|
||||
describe('PaddleObstacleOverlay', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-001: shows nothing and leaves presses and keys alone while no known object is picked', () => {
|
||||
const onOutsidePress = vi.fn();
|
||||
publish([WEIR]);
|
||||
renderOverlay({ onOutsidePress });
|
||||
expect(overlay()).toBeNull();
|
||||
|
||||
// A pick the store holds no object for opens nothing either.
|
||||
act(() => selectPaddleObstacle('node/999'));
|
||||
expect(overlay()).toBeNull();
|
||||
|
||||
pressMap();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
expect(selectedRef()).toBe('node/999');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-002: anchors the card of the pick at the bottom of the inline end, clear of the panel', () => {
|
||||
publish([WEIR], [WEIR_ON_ROUTE]);
|
||||
selectPaddleObstacle(WEIR.ref);
|
||||
renderOverlay({ width: 320 });
|
||||
|
||||
const shown = screen.getByTestId('paddle-obstacle-overlay');
|
||||
expect(shown.style.insetInlineEnd).toBe('400px');
|
||||
// Logical only: no physical side, so a right-to-left page mirrors it.
|
||||
expect(shown.style.right).toBe('');
|
||||
expect(shown.style.left).toBe('');
|
||||
expect(shown.style.bottom).toBe('66px');
|
||||
expect(shown.style.maxWidth).toBe('calc(100% - 432px)');
|
||||
|
||||
const card = screen.getByTestId('paddle-obstacle-card');
|
||||
expect(shown).toContainElement(card);
|
||||
expect(card.style.width).toBe('320px');
|
||||
expect(screen.getByRole('heading', { name: 'Hammer weir' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Weir')).toBeInTheDocument();
|
||||
// The relation and the date of the data come out of the same store as the pick.
|
||||
expect(screen.getByText('On the route at')).toBeInTheDocument();
|
||||
expect(screen.getByText(/^OpenStreetMap data as of /)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-003: closes on a press outside it and hands that press on, never on a press inside it', () => {
|
||||
const onOutsidePress = vi.fn();
|
||||
publish([WEIR]);
|
||||
selectPaddleObstacle(WEIR.ref);
|
||||
renderOverlay({ onOutsidePress });
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('heading', { name: 'Hammer weir' }));
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
expect(selectedRef()).toBe(WEIR.ref);
|
||||
expect(overlay()).toBeInTheDocument();
|
||||
|
||||
pressMap();
|
||||
expect(onOutsidePress).toHaveBeenCalledTimes(1);
|
||||
expect(selectedRef()).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
|
||||
// Once closed it stops listening, so the next press on the map is the map's alone.
|
||||
pressMap();
|
||||
expect(onOutsidePress).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-004: closes on a press outside it without anyone to hand the press to', () => {
|
||||
publish([WEIR]);
|
||||
selectPaddleObstacle(WEIR.ref);
|
||||
renderOverlay();
|
||||
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(selectedRef()).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-005: closes on Escape, not on another key nor on an Escape something else already took', () => {
|
||||
const onOutsidePress = vi.fn();
|
||||
publish([WEIR]);
|
||||
selectPaddleObstacle(WEIR.ref);
|
||||
renderOverlay({ onOutsidePress });
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' });
|
||||
expect(overlay()).toBeInTheDocument();
|
||||
|
||||
// A dialog above the map that handles its own Escape keeps the card open.
|
||||
const takeEscape = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
document.addEventListener('keydown', takeEscape, true);
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
document.removeEventListener('keydown', takeEscape, true);
|
||||
expect(overlay()).toBeInTheDocument();
|
||||
expect(selectedRef()).toBe(WEIR.ref);
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(selectedRef()).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
// A key is not a press: the map has no click to leave alone.
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-006: closes from its own close button without reporting a press outside', () => {
|
||||
const onOutsidePress = vi.fn();
|
||||
publish([WEIR]);
|
||||
selectPaddleObstacle(WEIR.ref);
|
||||
renderOverlay({ onOutsidePress });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(selectedRef()).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-007: puts the checklist part for the picked object inside the card, where a press keeps it open', () => {
|
||||
const onOutsidePress = vi.fn();
|
||||
publish([WEIR], [WEIR_ON_ROUTE]);
|
||||
selectPaddleObstacle(WEIR.ref);
|
||||
renderOverlay({
|
||||
onOutsidePress,
|
||||
extra: (detail) => <button type="button">{`Decide ${detail.feature.ref} ${detail.relation}`}</button>,
|
||||
});
|
||||
|
||||
const decide = screen.getByRole('button', { name: 'Decide way/101 on_route' });
|
||||
expect(screen.getByTestId('paddle-obstacle-card')).toContainElement(decide);
|
||||
|
||||
fireEvent.pointerDown(decide);
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
expect(selectedRef()).toBe(WEIR.ref);
|
||||
expect(overlay()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLEOVERLAY-008: follows the pick made elsewhere and closes when the object leaves the store', () => {
|
||||
const onOutsidePress = vi.fn();
|
||||
publish([WEIR, SLIPWAY]);
|
||||
renderOverlay({ onOutsidePress });
|
||||
expect(overlay()).toBeNull();
|
||||
|
||||
act(() => selectPaddleObstacle(WEIR.ref));
|
||||
expect(screen.getByRole('heading', { name: 'Hammer weir' })).toBeInTheDocument();
|
||||
|
||||
// The checklist picks another object: the same card shows that one, named by its kind.
|
||||
act(() => selectPaddleObstacle(SLIPWAY.ref));
|
||||
expect(screen.getByRole('heading', { name: 'Slipway' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Hammer weir' })).toBeNull();
|
||||
expect(screen.getByText(/^OpenStreetMap data of an unknown date/)).toBeInTheDocument();
|
||||
|
||||
// A refresh without the object drops the pick and the card with it, and the card stops listening.
|
||||
act(() => publish([WEIR]));
|
||||
expect(selectedRef()).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
pressMap();
|
||||
expect(onOutsidePress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
|
||||
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
|
||||
import PaddleObstacleCard from './PaddleObstacleCard';
|
||||
import { paddleObstacleDetail, type PaddleObstacleDetail } from './paddleObstacleModel';
|
||||
|
||||
/**
|
||||
* The picked paddle object's card over the planner map (PAD-05B), where a
|
||||
* via ferrata's card sits and closing as that one does: on a press outside
|
||||
* it and on Escape, the press handed on so the map leaves its click alone.
|
||||
* The pick lives in the obstacle store, so the map and the checklist open
|
||||
* and close the same card. `extra` adds the checklist's part for the object.
|
||||
*/
|
||||
export default function PaddleObstacleOverlay({
|
||||
onOutsidePress,
|
||||
insetEnd,
|
||||
bottom,
|
||||
width,
|
||||
extra,
|
||||
}: {
|
||||
onOutsidePress?: () => void;
|
||||
insetEnd: number;
|
||||
bottom: number;
|
||||
width?: number;
|
||||
extra?: (detail: PaddleObstacleDetail) => ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { features, related, regions, selectedRef } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
features: state.features,
|
||||
related: state.related,
|
||||
regions: state.regions,
|
||||
selectedRef: state.selectedRef,
|
||||
}))
|
||||
);
|
||||
const detail = useMemo(
|
||||
() => paddleObstacleDetail({ features, related, regions, selectedRef }),
|
||||
[features, related, regions, selectedRef]
|
||||
);
|
||||
const close = useCallback(() => selectPaddleObstacle(null), []);
|
||||
useMapCardDismiss(ref, !!detail, close, onOutsidePress);
|
||||
|
||||
if (!detail) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute z-[20] flex justify-end transition-[bottom] duration-200 ease-out"
|
||||
style={{ insetInlineEnd: insetEnd, bottom, maxWidth: `calc(100% - ${insetEnd + 32}px)` }}
|
||||
data-testid="paddle-obstacle-overlay"
|
||||
>
|
||||
<PaddleObstacleCard detail={detail} onClose={close} width={width}>
|
||||
{extra?.(detail)}
|
||||
</PaddleObstacleCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// FE-COMP-PADDLE-STRIP-001 to FE-COMP-PADDLE-STRIP-011: the band of a paddle tour, water and portages to scale,
|
||||
// with its locks and the obstacles on or near the route.
|
||||
import type { PaddleFeature, PaddleLayout, PaddleObstacleKind, PaddleRelation, RelatedObstacle } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import {
|
||||
publishPaddleObstacles,
|
||||
selectPaddleObstacle,
|
||||
usePaddleObstacleStore,
|
||||
} from '../../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { PaddleRouteStrip } from './PaddleRouteStrip';
|
||||
import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
|
||||
/** Eight kilometres: water, a carried kilometre, water, a 40 m carry round the second lock, water. */
|
||||
const LAYOUT: PaddleLayout = {
|
||||
totalM: 8000,
|
||||
stretches: [
|
||||
{ kind: 'water', fromM: 0, toM: 4000, extraMinutes: null, startIndex: 0 },
|
||||
{ kind: 'portage', fromM: 4000, toM: 5000, extraMinutes: 10, startIndex: 2 },
|
||||
{ kind: 'water', fromM: 5000, toM: 6000, extraMinutes: null, startIndex: 3 },
|
||||
{ kind: 'portage', fromM: 6000, toM: 6040, extraMinutes: null, startIndex: 4 },
|
||||
{ kind: 'water', fromM: 6040, toM: 8000, extraMinutes: null, startIndex: 5 },
|
||||
],
|
||||
locks: [
|
||||
{ wait_minutes: 10, transit_minutes: 15, atM: 2000, bypassed: false, index: 1 },
|
||||
{ wait_minutes: null, transit_minutes: null, atM: 6000, bypassed: true, index: null },
|
||||
],
|
||||
};
|
||||
|
||||
function feature(ref: string, kind: PaddleObstacleKind, name: string | null = null): PaddleFeature {
|
||||
return {
|
||||
ref,
|
||||
kind,
|
||||
group_key: null,
|
||||
name,
|
||||
geometry: { type: 'Point', coordinates: [12.1, 49.1] },
|
||||
tags: {},
|
||||
tag_hash: ref,
|
||||
region_id: 'bayern',
|
||||
};
|
||||
}
|
||||
|
||||
function relate(entry: PaddleFeature, relation: PaddleRelation, alongM: number): RelatedObstacle {
|
||||
return { feature: entry, relation, distanceM: relation === 'on_route' ? 0 : 90, alongM };
|
||||
}
|
||||
|
||||
/** Measured along a route of 4 km, so each one sits on the 8 km band at twice its distance. */
|
||||
const RELATED: RelatedObstacle[] = [
|
||||
relate(feature('node/1', 'put_in', 'Bootsrampe'), 'on_route', 0),
|
||||
relate(feature('way/2', 'weir', 'Wehr Regensburg'), 'on_route', 1500),
|
||||
relate(feature('way/3', 'access', 'Naturschutzgebiet'), 'near', 1800),
|
||||
relate(feature('node/4', 'rapids'), 'near', 2500),
|
||||
relate(feature('way/5', 'dam', 'Staustufe'), 'on_route', 4400),
|
||||
];
|
||||
|
||||
function publish(related: RelatedObstacle[], routeLengthM = 4000): void {
|
||||
publishPaddleObstacles({
|
||||
status: 'ready',
|
||||
features: related.map((entry) => entry.feature),
|
||||
related,
|
||||
routeLengthM,
|
||||
coverage: 'covered',
|
||||
regions: [],
|
||||
truncated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const marks = () => screen.queryAllByTestId('paddle-route-strip-obstacle');
|
||||
const locks = () => screen.queryAllByTestId('paddle-route-strip-lock');
|
||||
const stretchesOf = () =>
|
||||
Array.from(screen.getByTestId('paddle-route-strip').querySelectorAll<HTMLElement>('[data-stretch]'));
|
||||
|
||||
/** The tooltip a hover opens on the element, closed again afterwards. */
|
||||
async function tooltipOf(element: HTMLElement): Promise<string | null> {
|
||||
fireEvent.mouseEnter(element);
|
||||
const tip = await screen.findByRole('tooltip');
|
||||
const text = tip.textContent;
|
||||
fireEvent.mouseLeave(element);
|
||||
return text;
|
||||
}
|
||||
|
||||
describe('PaddleRouteStrip', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-001: draws water and portages in order and to scale, a short carry at its least width', () => {
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
|
||||
const parts = stretchesOf();
|
||||
expect(parts.map((part) => part.dataset.stretch)).toEqual(['water', 'portage', 'water', 'portage', 'water']);
|
||||
// Half, an eighth and an eighth of the 8 km; the 40 m carry is drawn at the 1.5 floor instead of 0.5.
|
||||
expect(parts.slice(0, 4).map((part) => part.style.flexGrow)).toEqual(['50', '12.5', '12.5', '1.5']);
|
||||
expect(parts[0]).toHaveClass('bg-info');
|
||||
expect(parts[1]).not.toHaveClass('bg-info');
|
||||
expect(parts[1]?.getAttribute('style')).toContain(`background: ${PORTAGE_CSS_COLOR}`);
|
||||
// The scale runs from the put-in to the take-out.
|
||||
expect(screen.getByText('0 m')).toBeInTheDocument();
|
||||
expect(screen.getByText('8 km')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-002: names each stretch with its length on hover', async () => {
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
const parts = stretchesOf();
|
||||
|
||||
expect(await tooltipOf(parts[0])).toBe('On the water 4 km');
|
||||
expect(await tooltipOf(parts[1])).toBe('Portage 1 km');
|
||||
expect(await tooltipOf(parts[3])).toBe('Portage 40 m');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-003: marks each lock at its place, one a portage carries round faded', async () => {
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
const [open, carried] = locks();
|
||||
|
||||
expect(locks()).toHaveLength(2);
|
||||
expect(open.style.insetInlineStart).toBe('25%');
|
||||
expect(open).not.toHaveClass('opacity-45');
|
||||
expect(open.querySelector('[data-paddle-glyph="lock"]')).toBeInTheDocument();
|
||||
expect(carried.style.insetInlineStart).toBe('75%');
|
||||
expect(carried).toHaveClass('opacity-45');
|
||||
expect(await tooltipOf(open)).toBe('Lock 2 km');
|
||||
expect(await tooltipOf(carried)).toBe('Lock 6 km');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-004: places the obstacles to decide about by their share of the measured route', async () => {
|
||||
publish(RELATED);
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
|
||||
// The put-in helps and the access rule runs along a stretch: neither becomes a mark.
|
||||
expect(marks()).toHaveLength(3);
|
||||
const [weir, rapids, dam] = marks();
|
||||
expect(weir).toHaveAccessibleName('Weir: Wehr Regensburg');
|
||||
// 1.5 of the 4 km measured is 3 of the 8 km drawn.
|
||||
expect(weir.style.insetInlineStart).toBe('37.5%');
|
||||
expect(weir.querySelector('[data-paddle-glyph="weir"]')).toBeInTheDocument();
|
||||
expect(weir?.getAttribute('style')).toContain(`color: ${paddleKindCss('weir')}`);
|
||||
expect(rapids).toHaveAccessibleName('Rapids');
|
||||
expect(rapids.style.insetInlineStart).toBe('62.5%');
|
||||
// Measured past the end of the route, it still sits at the take-out.
|
||||
expect(dam).toHaveAccessibleName('Dam: Staustufe');
|
||||
expect(dam.style.insetInlineStart).toBe('100%');
|
||||
expect(await tooltipOf(weir)).toBe('Weir: Wehr Regensburg 3 km');
|
||||
expect(await tooltipOf(rapids)).toBe('Rapids 5 km');
|
||||
expect(await tooltipOf(dam)).toBe('Dam: Staustufe 8 km');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-005: rings an obstacle on the route solid and dims one only near it, dashed', () => {
|
||||
publish(RELATED);
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
const [weir, rapids] = marks();
|
||||
|
||||
expect(weir.style.border).toContain('solid');
|
||||
expect(weir).not.toHaveClass('opacity-60');
|
||||
expect(rapids.style.border).toContain('dashed');
|
||||
expect(rapids).toHaveClass('opacity-60');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-006: a click picks an obstacle for its card and a second click puts it back', () => {
|
||||
publish(RELATED);
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
const rapids = screen.getByRole('button', { name: 'Rapids' });
|
||||
expect(rapids).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(rapids);
|
||||
expect(usePaddleObstacleStore.getState().selectedRef).toBe('node/4');
|
||||
expect(rapids).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(rapids).toHaveClass('scale-125', 'z-30');
|
||||
// Picked, a near one shows in full: no dimming and a solid ring.
|
||||
expect(rapids).not.toHaveClass('opacity-60');
|
||||
expect(rapids.style.border).toContain('solid');
|
||||
expect(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(rapids);
|
||||
expect(usePaddleObstacleStore.getState().selectedRef).toBeNull();
|
||||
expect(rapids).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(rapids).toHaveClass('z-20', 'opacity-60');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-007: shows the obstacle picked elsewhere as picked, and a click moves the pick', () => {
|
||||
publish(RELATED);
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
|
||||
act(() => selectPaddleObstacle('way/5'));
|
||||
expect(screen.getByRole('button', { name: 'Dam: Staustufe' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' }));
|
||||
expect(usePaddleObstacleStore.getState().selectedRef).toBe('way/2');
|
||||
expect(screen.getByRole('button', { name: 'Dam: Staustufe' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Weir: Wehr Regensburg' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-008: places no obstacle while the route they were measured along has no length', () => {
|
||||
publish(RELATED, 0);
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
|
||||
expect(marks()).toHaveLength(0);
|
||||
expect(locks()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-009: compact, it draws the band alone, without locks, obstacles or the scale', () => {
|
||||
publish(RELATED);
|
||||
render(<PaddleRouteStrip layout={LAYOUT} compact />);
|
||||
|
||||
expect(stretchesOf()).toHaveLength(5);
|
||||
expect(locks()).toHaveLength(0);
|
||||
expect(marks()).toHaveLength(0);
|
||||
expect(screen.queryByText('0 m')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('8 km')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-010: draws nothing for a tour without length or without stretches', () => {
|
||||
publish(RELATED);
|
||||
const { rerender } = render(<PaddleRouteStrip layout={{ ...LAYOUT, totalM: 0 }} />);
|
||||
expect(screen.queryByTestId('paddle-route-strip')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleRouteStrip layout={{ ...LAYOUT, stretches: [] }} />);
|
||||
expect(screen.queryByTestId('paddle-route-strip')).not.toBeInTheDocument();
|
||||
expect(marks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-STRIP-011: reads its lengths in the unit the user chose, metric when none is set', async () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
|
||||
render(<PaddleRouteStrip layout={LAYOUT} />);
|
||||
|
||||
expect(screen.getByText('0 mi')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 mi')).toBeInTheDocument();
|
||||
expect(await tooltipOf(locks()[0])).toBe('Lock 1.2 mi');
|
||||
|
||||
act(() => seedStore(useSettingsStore, { settings: buildSettings() }));
|
||||
expect(screen.getByText('8 km')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { PaddleLayout } from '@trek/shared';
|
||||
import { useMemo } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { focusPaddleObstacle, selectPaddleObstacle, usePaddleObstacleStore } from '../../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { OBSTACLE_GLYPH } from './paddleGlyphPaths';
|
||||
import { routeStripObstacles } from './paddleObstacleModel';
|
||||
import { paddleKindCss, PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
|
||||
/** Water in the band and in the tiles that explain it: the info blue of the theme. */
|
||||
export const WATER_COLOR = 'var(--info)';
|
||||
|
||||
/** The narrowest a stretch is drawn, in percent, so a short portage still shows. */
|
||||
const MIN_SHARE = 1.5;
|
||||
|
||||
/**
|
||||
* A paddle tour from put-in to take-out as one band, to scale: water in the
|
||||
* planner's blue, portages in the earth brown the map draws them in, each lock
|
||||
* a mark at its place (struck through where a portage carries round it). The
|
||||
* obstacles the data knows on or near the route sit on it too, ringed in the
|
||||
* colour of their kind, dashed when only near; a click opens one's card. It
|
||||
* stands where a hike shows its elevation profile; a paddle tour has none.
|
||||
*/
|
||||
export function PaddleRouteStrip({ layout, compact = false }: { layout: PaddleLayout; compact?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const { related, routeLengthM, selectedRef } = usePaddleObstacleStore(
|
||||
useShallow((state) => ({
|
||||
related: state.related,
|
||||
routeLengthM: state.routeLengthM,
|
||||
selectedRef: state.selectedRef,
|
||||
}))
|
||||
);
|
||||
const total = layout.totalM;
|
||||
const obstacles = useMemo(
|
||||
() => (compact ? [] : routeStripObstacles(related, routeLengthM, total)),
|
||||
[compact, related, routeLengthM, total]
|
||||
);
|
||||
if (total <= 0 || layout.stretches.length === 0) return null;
|
||||
const share = (m: number) => Math.max(MIN_SHARE, (m / total) * 100);
|
||||
const km = (m: number) => formatDistance(m / 1000, unit);
|
||||
return (
|
||||
<div data-testid="paddle-route-strip" className="min-w-0">
|
||||
<div className={`relative ${compact ? '' : 'py-[3px]'}`}>
|
||||
{!compact &&
|
||||
layout.locks.map((lock) => (
|
||||
<Tooltip key={`${lock.atM}-${lock.index ?? ''}`} label={`${t('tours.paddle.lock')} ${km(lock.atM)}`}>
|
||||
<span
|
||||
data-testid="paddle-route-strip-lock"
|
||||
className={`absolute top-1/2 z-10 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card text-content-secondary shadow-sm ring-1 ring-edge rtl:translate-x-1/2 ${lock.bypassed ? 'opacity-45' : ''}`}
|
||||
style={{ insetInlineStart: `${(lock.atM / total) * 100}%` }}
|
||||
>
|
||||
<PaddleGlyph kind="lock" size={10} strokeWidth={2.6} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
{obstacles.map((obstacle) => {
|
||||
const kind = t(`tours.paddle.obstacle.kind.${obstacle.kind}`);
|
||||
const color = paddleKindCss(obstacle.kind);
|
||||
const picked = obstacle.ref === selectedRef;
|
||||
return (
|
||||
<Tooltip
|
||||
key={obstacle.ref}
|
||||
label={`${obstacle.name ? `${kind}: ${obstacle.name}` : kind} ${km(obstacle.atM)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="paddle-route-strip-obstacle"
|
||||
aria-label={obstacle.name ? `${kind}: ${obstacle.name}` : kind}
|
||||
aria-pressed={picked}
|
||||
onClick={() => (picked ? selectPaddleObstacle(null) : focusPaddleObstacle(obstacle.ref))}
|
||||
className={`absolute top-1/2 grid h-4 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-surface-card shadow-sm rtl:translate-x-1/2 ${picked ? 'z-30 scale-125' : 'z-20'} ${obstacle.relation === 'near' && !picked ? 'opacity-60' : ''}`}
|
||||
style={{
|
||||
insetInlineStart: `${(obstacle.atM / total) * 100}%`,
|
||||
color,
|
||||
border: `1.5px ${obstacle.relation === 'near' && !picked ? 'dashed' : 'solid'} ${color}`,
|
||||
}}
|
||||
>
|
||||
<PaddleGlyph kind={OBSTACLE_GLYPH[obstacle.kind]} size={9} strokeWidth={2.6} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div className="flex h-2.5 w-full gap-[2px] overflow-hidden rounded-full">
|
||||
{layout.stretches.map((stretch) => {
|
||||
const land = stretch.kind === 'portage';
|
||||
const length = stretch.toM - stretch.fromM;
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${stretch.kind}-${stretch.fromM}`}
|
||||
label={`${t(land ? 'tours.paddle.portage' : 'tours.paddle.time.water')} ${km(length)}`}
|
||||
>
|
||||
<span
|
||||
data-stretch={stretch.kind}
|
||||
className={`h-full min-w-[3px] ${land ? '' : 'bg-info'}`}
|
||||
style={{
|
||||
flexGrow: share(length),
|
||||
flexBasis: 0,
|
||||
...(land ? { background: PORTAGE_CSS_COLOR } : {}),
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="mt-1 flex justify-between font-geist tabular-nums text-content-faint" style={fs(10)}>
|
||||
<span>{km(0)}</span>
|
||||
<span>{km(total)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// PSF-001 to PSF-006: the safety note of a paddle tour: the quiet row and the dialog it opens, which never asks for
|
||||
// a confirmation, never stands in the way and closes on Escape without taking the tour detail with it.
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { PaddleSafetyDialog, PaddleSafetyRow } from './PaddleSafety';
|
||||
|
||||
/** The note as shared/src/i18n/en/tours.ts words it. */
|
||||
const SHORT = 'Paddling has risks. Check water, rules and weather yourself.';
|
||||
const TITLE = 'Safety on the water';
|
||||
const TEXT =
|
||||
'Paddling carries risks. Maps, route suggestions and notes can be incomplete or out of date. Before the tour, check the current local conditions, the rules for using the water and the weather. Take suitable safety measures. TREK supports your planning and does not replace your own check and responsible decision.';
|
||||
|
||||
/** The footer's Close, told apart from the round close of the head band by its words. */
|
||||
function footerClose(dialog: HTMLElement): HTMLElement {
|
||||
const button = within(dialog).getByText('Close').closest('button');
|
||||
if (!button) throw new Error('no Close button in the footer');
|
||||
return button;
|
||||
}
|
||||
|
||||
/** The round close of the head band, named by its label alone. */
|
||||
function headClose(dialog: HTMLElement): HTMLElement {
|
||||
const button = within(dialog)
|
||||
.getAllByRole('button', { name: 'Close' })
|
||||
.find((candidate) => candidate.textContent === '');
|
||||
if (!button) throw new Error('no close button in the head band');
|
||||
return button;
|
||||
}
|
||||
|
||||
describe('PaddleSafetyRow', () => {
|
||||
it('PSF-001: is a quiet button saying paddling has risks that opens a dialog, closed at first', () => {
|
||||
render(<PaddleSafetyRow />);
|
||||
|
||||
const row = screen.getByRole('button', { name: SHORT });
|
||||
expect(row).toHaveAttribute('type', 'button');
|
||||
expect(row).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(row.querySelector('svg.lucide-life-buoy')).not.toBeNull();
|
||||
expect(row.querySelector('svg.lucide-chevron-right')).not.toBeNull();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PSF-002: opens the full note, closes on Close and opens again', () => {
|
||||
render(<PaddleSafetyRow />);
|
||||
const row = screen.getByRole('button', { name: SHORT });
|
||||
|
||||
fireEvent.click(row);
|
||||
const dialog = screen.getByRole('dialog', { name: TITLE });
|
||||
expect(within(dialog).getByText(TEXT)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(footerClose(dialog));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(row);
|
||||
expect(screen.getByRole('dialog', { name: TITLE })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PSF-003: the round close, Escape and the backdrop close it as well', () => {
|
||||
render(<PaddleSafetyRow />);
|
||||
const row = screen.getByRole('button', { name: SHORT });
|
||||
|
||||
fireEvent.click(row);
|
||||
fireEvent.click(headClose(screen.getByRole('dialog', { name: TITLE })));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(row);
|
||||
fireEvent.keyDown(screen.getByRole('dialog', { name: TITLE }), { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(row);
|
||||
const backdrop = screen.getByRole('dialog', { name: TITLE }).parentElement;
|
||||
if (!backdrop) throw new Error('no backdrop around the dialog');
|
||||
fireEvent.mouseDown(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PSF-004: Escape closes the note alone; the tour detail listening on the page does not see it', () => {
|
||||
// The tour detail closes on an Escape nobody handled, from a listener it registered before the note opened.
|
||||
const detailEscape = vi.fn();
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !event.defaultPrevented) detailEscape();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
try {
|
||||
render(<PaddleSafetyRow />);
|
||||
fireEvent.click(screen.getByRole('button', { name: SHORT }));
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog', { name: TITLE }), { key: 'Escape' });
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(detailEscape).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
}
|
||||
});
|
||||
|
||||
it('PSF-005: inside a form the row and its dialog submit nothing', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<PaddleSafetyRow />
|
||||
</form>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: SHORT }));
|
||||
fireEvent.click(footerClose(screen.getByRole('dialog', { name: TITLE })));
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleSafetyDialog', () => {
|
||||
it('PSF-006: shows nothing while closed; open it is narrow, its one action closes it and nothing asks to confirm', () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(<PaddleSafetyDialog open={false} onClose={onClose} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<PaddleSafetyDialog open onClose={onClose} />);
|
||||
const dialog = screen.getByRole('dialog', { name: TITLE });
|
||||
expect(dialog.className).toContain('max-w-[520px]');
|
||||
expect(within(dialog).getAllByRole('button')).toHaveLength(2);
|
||||
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(footerClose(dialog));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ChevronRight, LifeBuoy } from 'lucide-react';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
NEUTRAL_TINT,
|
||||
fs,
|
||||
} from '../../shared/DialogShell';
|
||||
|
||||
/**
|
||||
* Escape closes the note and not the tour detail or panel it was opened from:
|
||||
* captured and marked handled before their own listeners on the document see it.
|
||||
*/
|
||||
function useOwnEscape(open: boolean, onClose: () => void) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => document.removeEventListener('keydown', onKey, true);
|
||||
}, [open, onClose]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The safety note for paddle tours in full: what paddling risks and what to
|
||||
* check before setting out. It is read and closed; it asks for no
|
||||
* confirmation and blocks nothing.
|
||||
*/
|
||||
export function PaddleSafetyDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
useOwnEscape(open, onClose);
|
||||
return (
|
||||
<DialogShell
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width="narrow"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<LifeBuoy size={20} strokeWidth={1.9} className="text-content-muted" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
title={t('tours.paddle.safety.title')}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
<p className="m-0 leading-relaxed text-content-secondary" style={fs(13, 'body')}>
|
||||
{t('tours.paddle.safety.text')}
|
||||
</p>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reminder that paddling has risks, as a quiet row in the look of a
|
||||
* panel's notices. It opens the full note and never stands in the way of
|
||||
* planning or saving.
|
||||
*/
|
||||
export function PaddleSafetyRow() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-start gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2 text-start leading-snug text-content-muted transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<LifeBuoy size={13} strokeWidth={2} aria-hidden="true" className="mt-[2px] flex-none" />
|
||||
<span className="min-w-0 flex-1">{t('tours.paddle.safety.short')}</span>
|
||||
<ChevronRight size={14} strokeWidth={2.2} aria-hidden="true" className="flex-none self-center rtl:rotate-180" />
|
||||
</button>
|
||||
<PaddleSafetyDialog open={open} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// FE-COMP-PADDLE-TIME-001 to FE-COMP-PADDLE-TIME-015: the time estimate of a paddle tour (PAD-07).
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { paddleLayoutOfPoints, type PaddleLock } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { hikePlannerDefaults } from '../../../../tests/helpers/tourPlannerStub';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { paddleEstimateOf, paddlePlanPoints } from '../planner/paddlePlanOf';
|
||||
import { portageLegBetween } from '../planner/tourRouteActions';
|
||||
import type { TourPlannerController, TourPlannerWaypoint } from '../planner/useTourPlanner';
|
||||
import { PaddleEstimateBody, PaddleTimeCard, type SpeedControl } from './PaddleTimeCard';
|
||||
|
||||
/** What the card writes for a time it cannot give: an en dash. */
|
||||
const OPEN = '–';
|
||||
/** The sign of a planned total shorter than the estimate: a minus, not a hyphen. */
|
||||
const MINUS = '−';
|
||||
|
||||
const TIMED_LOCK: PaddleLock = { wait_minutes: 20, transit_minutes: 5 };
|
||||
const NO_TIMES: PaddleLock = { wait_minutes: null, transit_minutes: null };
|
||||
|
||||
function point(id: string, lat: number, extra: Partial<TourPlannerWaypoint> = {}): TourPlannerWaypoint {
|
||||
return { id, lat, lng: 11, role: 'via', ...extra };
|
||||
}
|
||||
|
||||
interface RouteShape {
|
||||
/** The lock on the water after the portage. */
|
||||
lock?: PaddleLock | null;
|
||||
/** A lock where the portage starts, so the boat is carried round it. */
|
||||
takeOutLock?: PaddleLock | null;
|
||||
/** A lock at the final take-out, on the water too. */
|
||||
endLock?: PaddleLock | null;
|
||||
portage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Along one meridian, so each stretch is its span of latitude: 8 km of water
|
||||
* from the put-in, a 500 m portage, then 2 km of water past a lock to the
|
||||
* final take-out, 10.5 km in all.
|
||||
*/
|
||||
function route(shape: RouteShape = {}): TourPlannerWaypoint[] {
|
||||
const { lock = TIMED_LOCK, takeOutLock = null, endLock = null, portage = true } = shape;
|
||||
const takeOut = point('take-out', 48.072, { lock: takeOutLock });
|
||||
const reEntry = point('re-entry', 48.0765);
|
||||
return [
|
||||
point('put-in', 48, { role: 'start' }),
|
||||
portage ? { ...takeOut, fixedLeg: portageLegBetween(takeOut, reEntry) } : takeOut,
|
||||
reEntry,
|
||||
point('lock', 48.0855, { lock }),
|
||||
point('final', 48.0945, { role: 'end', lock: endLock }),
|
||||
];
|
||||
}
|
||||
|
||||
interface CardSetup {
|
||||
waypoints?: TourPlannerWaypoint[];
|
||||
/** The paddling speed in effect: 4 km/h unless told otherwise, null for none. */
|
||||
speedKmh?: number | null;
|
||||
portageSpeedKmh?: number | null;
|
||||
breakMinutes?: number | null;
|
||||
plannedMinutes?: number | null;
|
||||
/** False leaves the estimate out, as the planner does for a hike. */
|
||||
estimate?: boolean;
|
||||
}
|
||||
|
||||
/** A paddle planner as far as the card reads it, its estimate worked out the way the planner works it out. */
|
||||
function paddlePlanner(setup: CardSetup = {}) {
|
||||
const base = hikePlannerDefaults();
|
||||
const waypoints = setup.waypoints ?? route();
|
||||
const setPortageSpeedKmh = vi.fn();
|
||||
const paddle = {
|
||||
...base.paddle,
|
||||
effectiveSpeedKmh: setup.speedKmh === undefined ? 4 : setup.speedKmh,
|
||||
portageSpeedKmh: setup.portageSpeedKmh ?? null,
|
||||
setPortageSpeedKmh,
|
||||
};
|
||||
const planner = {
|
||||
...base,
|
||||
tourType: 'kayak',
|
||||
isPaddle: true,
|
||||
paddle,
|
||||
waypoints,
|
||||
paddleEstimate: setup.estimate === false ? null : paddleEstimateOf(waypoints, paddle, false),
|
||||
breakAdditionalMinutes: setup.breakMinutes ?? null,
|
||||
plannedDurationMinutes: setup.plannedMinutes ?? null,
|
||||
} as unknown as TourPlannerController;
|
||||
return { planner, setPortageSpeedKmh };
|
||||
}
|
||||
|
||||
function card(setup: CardSetup = {}, locked = false) {
|
||||
return <PaddleTimeCard planner={paddlePlanner(setup).planner} locked={locked} />;
|
||||
}
|
||||
|
||||
/** Renders the card and hands back the planner's carrying speed setter. */
|
||||
function renderCard(setup: CardSetup = {}, locked = false) {
|
||||
const { planner, setPortageSpeedKmh } = paddlePlanner(setup);
|
||||
render(<PaddleTimeCard planner={planner} locked={locked} />);
|
||||
return setPortageSpeedKmh;
|
||||
}
|
||||
|
||||
/** The block as a recorded track shows it, with the paddling speed set in the water tile. */
|
||||
function renderBody(paddleSpeed: SpeedControl, locked = false) {
|
||||
const waypoints = route();
|
||||
const speeds = { effectiveSpeedKmh: paddleSpeed.value, portageSpeedKmh: null };
|
||||
const estimate = paddleEstimateOf(waypoints, speeds, false);
|
||||
render(
|
||||
<PaddleEstimateBody
|
||||
estimate={estimate}
|
||||
layout={paddleLayoutOfPoints(paddlePlanPoints(waypoints))}
|
||||
breakMinutes={null}
|
||||
planned={null}
|
||||
locked={locked}
|
||||
portageSpeed={{ value: null, startValue: 2, onChange: vi.fn() }}
|
||||
paddleSpeed={paddleSpeed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Types into a speed field the way a paddler does: into the field, a value, out of it again. */
|
||||
function enter(input: HTMLElement, value: string) {
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value } });
|
||||
fireEvent.blur(input);
|
||||
}
|
||||
|
||||
const tile = (name: string) => screen.getByTestId(`paddle-time-${name}`);
|
||||
const estimateText = () => screen.getByTestId('paddle-time-estimate').textContent;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
});
|
||||
|
||||
describe('PaddleTimeCard (PAD-07)', () => {
|
||||
it('FE-COMP-PADDLE-TIME-001: renders nothing without an estimate or before the tour has two points', () => {
|
||||
const { container, rerender } = render(card({ estimate: false }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
rerender(card({ waypoints: route().slice(0, 1) }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-002: heads the card with the estimate, breaks included, and says its values are planning values', () => {
|
||||
renderCard({ breakMinutes: 30 });
|
||||
|
||||
const region = screen.getByRole('region', { name: 'Time estimate' });
|
||||
expect(within(region).getByText('Planning values')).toBeInTheDocument();
|
||||
const info = within(region).getByRole('button', { name: 'Planning values' });
|
||||
expect(info).toHaveAccessibleDescription(/^TREK starts with 4 km\/h on the water/);
|
||||
expect(within(region).getByText('Estimate')).toBeInTheDocument();
|
||||
// 200 minutes of water, carrying, getting out and in and the lock, then 30 minutes of breaks.
|
||||
expect(estimateText()).toBe('3 h 50 min');
|
||||
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-time-planned')).not.toBeInTheDocument();
|
||||
// The planner sets its paddling speed in the type fields; the card shows it as it is.
|
||||
expect(screen.queryByRole('textbox', { name: 'Paddling speed' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-003: breaks the estimate into water, carrying, getting out and in, locks and breaks', () => {
|
||||
renderCard({ breakMinutes: 30 });
|
||||
|
||||
const water = tile('water');
|
||||
expect(within(water).getByText('Water')).toBeInTheDocument();
|
||||
expect(within(water).getByText('10 km')).toBeInTheDocument();
|
||||
expect(within(water).getByText('4 km/h')).toBeInTheDocument();
|
||||
expect(within(water).getByText('2 h 30 min')).toBeInTheDocument();
|
||||
|
||||
const carry = tile('carry');
|
||||
expect(within(carry).getByText('Carrying')).toBeInTheDocument();
|
||||
expect(within(carry).getByText('500 m')).toBeInTheDocument();
|
||||
expect(within(carry).getByText('km/h')).toBeInTheDocument();
|
||||
// 500 m at the starting 2 km/h.
|
||||
expect(within(carry).getByText('15 min')).toBeInTheDocument();
|
||||
|
||||
const outIn = tile('out-in');
|
||||
expect(within(outIn).getByText('Out and back in')).toBeInTheDocument();
|
||||
expect(within(outIn).getByText('1×')).toBeInTheDocument();
|
||||
expect(within(outIn).getByText('10 min')).toBeInTheDocument();
|
||||
|
||||
const locks = tile('locks');
|
||||
expect(within(locks).getByText('Locks')).toBeInTheDocument();
|
||||
expect(within(locks).getByText('1×')).toBeInTheDocument();
|
||||
expect(within(locks).getByText('25 min')).not.toHaveClass('text-warning');
|
||||
|
||||
const breaks = tile('breaks');
|
||||
expect(within(breaks).getByText('Breaks')).toHaveClass('text-content');
|
||||
expect(within(breaks).getByText('30 min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-004: draws the route as a band of its stretches with the lock on it', () => {
|
||||
renderCard();
|
||||
|
||||
const strip = within(screen.getByTestId('paddle-time-card')).getByTestId('paddle-route-strip');
|
||||
const kinds = Array.from(strip.querySelectorAll('[data-stretch]'), (node) => node.getAttribute('data-stretch'));
|
||||
expect(kinds).toEqual(['water', 'portage', 'water']);
|
||||
expect(within(strip).getAllByTestId('paddle-route-strip-lock')).toHaveLength(1);
|
||||
expect(within(strip).getByText('0 m')).toBeInTheDocument();
|
||||
expect(within(strip).getByText('10.5 km')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-005: gives the distances in the unit the user reads', () => {
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'imperial' } });
|
||||
renderCard();
|
||||
|
||||
expect(within(tile('water')).getByText('6.2 mi')).toBeInTheDocument();
|
||||
expect(within(tile('carry')).getByText('0.3 mi')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('paddle-route-strip')).getByText('6.5 mi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-006: a lock without times leaves the estimate incomplete and says so', () => {
|
||||
const { rerender } = render(card({ waypoints: route({ lock: NO_TIMES }) }));
|
||||
|
||||
// What is known still adds up: water, carrying and getting out and in.
|
||||
expect(estimateText()).toBe('2 h 55 min');
|
||||
expect(screen.getByText('Incomplete')).toHaveClass('text-warning');
|
||||
expect(within(tile('locks')).getByText(OPEN)).toHaveClass('text-warning');
|
||||
expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument();
|
||||
// Without breaks entered the breaks tile stays open rather than reading zero.
|
||||
expect(within(tile('breaks')).getByText('Breaks')).toHaveClass('text-content-muted');
|
||||
expect(within(tile('breaks')).getByText(OPEN)).toBeInTheDocument();
|
||||
|
||||
// A wait without its passage counts what is known and still warns.
|
||||
rerender(card({ waypoints: route({ lock: { wait_minutes: 20, transit_minutes: null } }) }));
|
||||
expect(estimateText()).toBe('3 h 15 min');
|
||||
expect(within(tile('locks')).getByText('20 min')).toHaveClass('text-warning');
|
||||
expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument();
|
||||
|
||||
rerender(card({ waypoints: route({ lock: NO_TIMES, endLock: NO_TIMES }) }));
|
||||
expect(within(tile('locks')).getByText('2×')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 locks have no times yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-007: without a paddling speed the water time stays open and the rest still adds up', () => {
|
||||
const { rerender } = render(card({ speedKmh: null }));
|
||||
|
||||
const water = tile('water');
|
||||
expect(within(water).getByText('10 km')).toBeInTheDocument();
|
||||
expect(within(water).queryByText(/km\/h/)).not.toBeInTheDocument();
|
||||
expect(within(water).getByText(OPEN)).toBeInTheDocument();
|
||||
// Carrying, getting out and in and the lock: 15 + 10 + 25 minutes.
|
||||
expect(estimateText()).toBe('50 min');
|
||||
expect(screen.getByText('Incomplete')).toBeInTheDocument();
|
||||
expect(screen.getByText('Without a paddling speed the time on the water stays open.')).toBeInTheDocument();
|
||||
|
||||
// Everything still missing is named in one notice.
|
||||
rerender(card({ speedKmh: null, waypoints: route({ lock: NO_TIMES }) }));
|
||||
const both = 'Without a paddling speed the time on the water stays open. One lock has no times yet.';
|
||||
expect(screen.getByText(both)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-008: a lock the portage carries round is not counted, and a tour without portage has no carry tiles', () => {
|
||||
const { rerender } = render(card({ waypoints: route({ takeOutLock: NO_TIMES }) }));
|
||||
|
||||
const marks = screen.getAllByTestId('paddle-route-strip-lock');
|
||||
expect(marks).toHaveLength(2);
|
||||
expect(marks[0]).toHaveClass('opacity-45');
|
||||
expect(marks[1]).not.toHaveClass('opacity-45');
|
||||
expect(within(tile('locks')).getByText('1×')).toBeInTheDocument();
|
||||
expect(within(tile('locks')).getByText('25 min')).toBeInTheDocument();
|
||||
// Its missing times are the portage's business, not a gap in the estimate.
|
||||
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('One lock has no times yet.')).not.toBeInTheDocument();
|
||||
|
||||
rerender(card({ waypoints: route({ portage: false, lock: null }) }));
|
||||
expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-time-out-in')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-time-locks')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-route-strip-lock')).not.toBeInTheDocument();
|
||||
expect(within(tile('water')).getByText('10.5 km')).toBeInTheDocument();
|
||||
expect(within(tile('water')).getByText('2 h 38 min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-009: sets the planned total beside the estimate with the gap to it', () => {
|
||||
const { rerender } = render(card({ breakMinutes: 30, plannedMinutes: 240 }));
|
||||
|
||||
const planned = screen.getByTestId('paddle-time-planned');
|
||||
expect(planned).toHaveTextContent('Your planned total');
|
||||
expect(within(planned).getByText('4 h')).toBeInTheDocument();
|
||||
expect(within(planned).getByText('+10 min')).toHaveClass('text-content-secondary');
|
||||
|
||||
// A plan shorter than the estimate is the one to look at again.
|
||||
rerender(card({ breakMinutes: 30, plannedMinutes: 200 }));
|
||||
const shorter = within(screen.getByTestId('paddle-time-deviation')).getByText(`${MINUS}30 min`);
|
||||
expect(shorter).toHaveClass('text-warning');
|
||||
|
||||
rerender(card({ breakMinutes: 30, plannedMinutes: 230 }));
|
||||
expect(screen.getByTestId('paddle-time-planned')).toHaveTextContent('3 h 50 min');
|
||||
expect(screen.queryByTestId('paddle-time-deviation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-010: offers the starting carrying speed and hands a typed one to the planner on Enter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const setPortageSpeedKmh = renderCard();
|
||||
|
||||
const input = within(tile('carry')).getByRole('textbox', { name: 'Carrying speed' });
|
||||
expect(input).toHaveValue('');
|
||||
expect(input).toHaveAttribute('placeholder', '2');
|
||||
expect(input).toBeEnabled();
|
||||
|
||||
await user.type(input, '2,5{Enter}');
|
||||
expect(setPortageSpeedKmh).toHaveBeenCalledTimes(1);
|
||||
expect(setPortageSpeedKmh).toHaveBeenCalledWith(2.5);
|
||||
expect(input).not.toHaveFocus();
|
||||
// The field shows the planner's value again, which the stub leaves unset.
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-011: keeps digits and separators, four characters at most, and rounds to a tenth', () => {
|
||||
const setPortageSpeedKmh = renderCard();
|
||||
const input = screen.getByRole('textbox', { name: 'Carrying speed' });
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '3a,5' } });
|
||||
expect(input).toHaveValue('3,5');
|
||||
fireEvent.blur(input);
|
||||
expect(setPortageSpeedKmh).toHaveBeenLastCalledWith(3.5);
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '1.2789' } });
|
||||
expect(input).toHaveValue('1.27');
|
||||
fireEvent.blur(input);
|
||||
expect(setPortageSpeedKmh).toHaveBeenLastCalledWith(1.3);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-012: ignores a carrying speed out of range or malformed, and an emptied field returns to the starting value', () => {
|
||||
const setPortageSpeedKmh = renderCard({ portageSpeedKmh: 3 });
|
||||
const input = screen.getByRole('textbox', { name: 'Carrying speed' });
|
||||
expect(input).toHaveValue('3');
|
||||
// 500 m at 3 km/h.
|
||||
expect(within(tile('carry')).getByText('10 min')).toBeInTheDocument();
|
||||
|
||||
for (const typed of ['12', '0,4', '1.2.']) enter(input, typed);
|
||||
// Leaving the field without having been in it changes nothing either.
|
||||
fireEvent.blur(input);
|
||||
expect(setPortageSpeedKmh).not.toHaveBeenCalled();
|
||||
expect(input).toHaveValue('3');
|
||||
|
||||
enter(input, '');
|
||||
expect(setPortageSpeedKmh).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-013: a locked planner shows the carrying speed without taking a new one', () => {
|
||||
renderCard({ portageSpeedKmh: 3 }, true);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Carrying speed' });
|
||||
expect(input).toHaveValue('3');
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleEstimateBody (PAD-07)', () => {
|
||||
it('FE-COMP-PADDLE-TIME-014: a recorded track types its paddling speed into the water tile, up to 20 km/h', () => {
|
||||
const onChange = vi.fn();
|
||||
renderBody({ value: 5, startValue: 4, onChange });
|
||||
|
||||
const water = tile('water');
|
||||
const input = within(water).getByRole('textbox', { name: 'Paddling speed' });
|
||||
expect(input).toHaveValue('5');
|
||||
expect(input).toHaveAttribute('placeholder', '4');
|
||||
expect(within(water).queryByText('5 km/h')).not.toBeInTheDocument();
|
||||
// 10 km at 5 km/h.
|
||||
expect(within(water).getByText('2 h')).toBeInTheDocument();
|
||||
|
||||
enter(input, '25');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Faster than a boat is carried, but a speed on the water.
|
||||
enter(input, '12');
|
||||
expect(onChange).toHaveBeenCalledWith(12);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TIME-015: without a starting paddling speed the field shows a dash, and a locked track takes none', () => {
|
||||
renderBody({ value: null, startValue: null, onChange: vi.fn() }, true);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Paddling speed' });
|
||||
expect(input).toHaveValue('');
|
||||
expect(input).toHaveAttribute('placeholder', OPEN);
|
||||
expect(input).toBeDisabled();
|
||||
expect(screen.getByText('Without a paddling speed the time on the water stays open.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
DEFAULT_PORTAGE_SPEED_KMH,
|
||||
MAX_PADDLE_SPEED_KMH,
|
||||
MAX_PORTAGE_SPEED_KMH,
|
||||
paddleLayoutOfPoints,
|
||||
type PaddleLayout,
|
||||
type PaddleTimeEstimate,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, Timer } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { paddlePlanPoints } from '../planner/paddlePlanOf';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { InfoDot, RailCard, TourNotice } from '../tourParts';
|
||||
import { formatPlannedTourDuration } from '../tourPresentation';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
import { PaddleRouteStrip, WATER_COLOR } from './PaddleRouteStrip';
|
||||
|
||||
const minutes = (value: number | null) => (value === null ? '–' : formatPlannedTourDuration(value));
|
||||
|
||||
/** A speed in km/h typed in place, decimals with either separator; the starting value shows until one is typed. */
|
||||
function KmhChip({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
max,
|
||||
disabled,
|
||||
onChange,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
/** The starting value, shown while none is typed; null shows a dash. */
|
||||
placeholder: number | null;
|
||||
/** The highest speed the field takes, in km/h. */
|
||||
max: number;
|
||||
disabled: boolean;
|
||||
onChange: (kmh: number | null) => void;
|
||||
testId: string;
|
||||
}) {
|
||||
const { locale } = useTranslation();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const shown = (kmh: number) => kmh.toLocaleString(locale, { maximumFractionDigits: 1 });
|
||||
const commit = (text: string) => {
|
||||
const trimmed = text.trim().replace(',', '.');
|
||||
if (trimmed === '') return onChange(null);
|
||||
const kmh = Number(trimmed);
|
||||
if (Number.isFinite(kmh) && kmh >= 0.5 && kmh <= max) onChange(Math.round(kmh * 10) / 10);
|
||||
};
|
||||
return (
|
||||
<label className="inline-flex items-center gap-0.5 rounded-sm focus-within:outline focus-within:outline-1 focus-within:outline-accent">
|
||||
<input
|
||||
data-testid={testId}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
value={draft ?? (value === null ? '' : shown(value))}
|
||||
placeholder={placeholder === null ? '–' : shown(placeholder)}
|
||||
onFocus={() => setDraft(value === null ? '' : shown(value))}
|
||||
onChange={(event) => setDraft(event.target.value.replace(/[^\d.,]/g, '').slice(0, 4))}
|
||||
onBlur={() => {
|
||||
if (draft !== null) commit(draft);
|
||||
setDraft(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
className="w-[3ch] min-w-0 border-b border-dashed border-edge bg-transparent text-end font-geist tabular-nums text-content outline-none placeholder:text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
/>
|
||||
<span className="text-content-faint" style={fs(10.5)}>
|
||||
km/h
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures that belong together (a distance and the speed it is covered at)
|
||||
* as one white badge within the grey tile, its parts split by a hairline, so
|
||||
* a narrow rail keeps them on the tile's first line.
|
||||
*/
|
||||
function MeasureGroup({ parts }: { parts: ReactNode[] }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-[20px] items-center rounded-full bg-surface-card px-1.5 font-geist tabular-nums text-content-secondary ring-1 ring-inset ring-edge-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
// The parts are fixed per tile; their order is their identity.
|
||||
<span key={index} className={index > 0 ? 'ms-1.5 border-s border-edge-faint ps-1.5' : ''}>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A measure inside a part's tile: a white badge within the grey one. */
|
||||
function MeasureBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-[20px] items-center rounded-full bg-surface-card px-1.5 font-geist tabular-nums text-content-secondary ring-1 ring-inset ring-edge-faint"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One part of the estimate as a tile: its glyph in the colour the band gives
|
||||
* it, its name and measures as badges within, and its time as the white badge
|
||||
* at the end. The tile wraps in a narrow rail rather than cutting anything.
|
||||
*/
|
||||
function PartTile({
|
||||
icon,
|
||||
color,
|
||||
label,
|
||||
measures,
|
||||
time,
|
||||
muted = false,
|
||||
warn = false,
|
||||
testId,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
/** The band's colour for this part, so tile and band read as one. */
|
||||
color?: string;
|
||||
label: string;
|
||||
measures?: ReactNode;
|
||||
time: string;
|
||||
muted?: boolean;
|
||||
/** A time that leaves something out: drawn in the warning tone. */
|
||||
warn?: boolean;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex min-w-0 flex-wrap items-center gap-1.5 rounded-[12px] bg-surface-secondary py-1.5 pe-1.5 ps-1.5"
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm"
|
||||
style={color ? { color } : undefined}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
className={`min-w-0 truncate font-semibold ${muted ? 'text-content-muted' : 'text-content'}`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{measures}
|
||||
<span
|
||||
className={`ms-auto inline-flex h-[22px] flex-none items-center rounded-full bg-surface-card px-2 font-geist font-semibold tabular-nums shadow-sm ${warn ? 'text-warning' : 'text-content'}`}
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** What of the estimate is still missing, said plainly, never folded into a total that looks whole. */
|
||||
function missingParts(estimate: PaddleTimeEstimate, t: Translate): string[] {
|
||||
const parts: string[] = [];
|
||||
if (estimate.waterMinutes === null) parts.push(t('tours.paddle.time.noSpeed'));
|
||||
if (estimate.locksMissingTime > 0)
|
||||
parts.push(t('tours.paddle.time.locksMissing', { count: estimate.locksMissingTime }));
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The estimate as the card's headline, with the paddler's own planned total and the gap to it beside it. */
|
||||
function EstimateHead({
|
||||
total,
|
||||
complete,
|
||||
planned,
|
||||
t,
|
||||
}: {
|
||||
total: number;
|
||||
complete: boolean;
|
||||
planned: number | null;
|
||||
t: Translate;
|
||||
}) {
|
||||
const deviation = planned === null ? null : planned - total;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.time.estimate')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
data-testid="paddle-time-estimate"
|
||||
className="font-geist font-semibold tabular-nums text-content"
|
||||
style={fs(20)}
|
||||
>
|
||||
{formatPlannedTourDuration(total)}
|
||||
</span>
|
||||
{!complete && <SoftPill tone="warning">{t('tours.paddle.time.incomplete')}</SoftPill>}
|
||||
</span>
|
||||
</div>
|
||||
{planned !== null && deviation !== null && (
|
||||
<span
|
||||
data-testid="paddle-time-planned"
|
||||
className="inline-flex h-[24px] flex-none items-center gap-1 rounded-full bg-surface-secondary pe-1 ps-2 text-content-muted"
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{t('tours.paddle.time.plannedOwn')}
|
||||
<MeasureBadge>{formatPlannedTourDuration(planned)}</MeasureBadge>
|
||||
{deviation !== 0 && (
|
||||
<span data-testid="paddle-time-deviation">
|
||||
<SoftPill tone={deviation < 0 ? 'warning' : 'neutral'}>
|
||||
{`${deviation > 0 ? '+' : '−'}${formatPlannedTourDuration(Math.abs(deviation))}`}
|
||||
</SoftPill>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A speed the rail lets the paddler set, with the starting value it falls back to. */
|
||||
export interface SpeedControl {
|
||||
value: number | null;
|
||||
startValue: number | null;
|
||||
onChange: (kmh: number | null) => void;
|
||||
}
|
||||
|
||||
/** The tiles of the parts, water first, then what a portage and a lock add, then the breaks. */
|
||||
function PartTiles({
|
||||
estimate,
|
||||
breakMinutes,
|
||||
locked,
|
||||
portageSpeed,
|
||||
paddleSpeed,
|
||||
}: {
|
||||
estimate: PaddleTimeEstimate;
|
||||
breakMinutes: number | null;
|
||||
locked: boolean;
|
||||
portageSpeed: SpeedControl;
|
||||
/** Set here where the tour has no boat form of its own (a recorded track); else shown as it is. */
|
||||
paddleSpeed?: SpeedControl;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const km = (m: number) => formatDistance(m / 1000, unit);
|
||||
const speed = estimate.paddleSpeedKmh;
|
||||
const water = paddleSpeed ? (
|
||||
<KmhChip
|
||||
key="speed"
|
||||
testId="paddle-time-water-speed"
|
||||
label={t('tours.paddle.speed')}
|
||||
value={paddleSpeed.value}
|
||||
placeholder={paddleSpeed.startValue}
|
||||
max={MAX_PADDLE_SPEED_KMH}
|
||||
disabled={locked}
|
||||
onChange={paddleSpeed.onChange}
|
||||
/>
|
||||
) : speed !== null ? (
|
||||
`${speed.toLocaleString()} km/h`
|
||||
) : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<PartTile
|
||||
testId="paddle-time-water"
|
||||
icon={<PaddleGlyph kind="put_in" size={12} strokeWidth={2.4} />}
|
||||
color={WATER_COLOR}
|
||||
label={t('tours.paddle.time.waterShort')}
|
||||
measures={<MeasureGroup parts={water === null ? [km(estimate.waterM)] : [km(estimate.waterM), water]} />}
|
||||
time={minutes(estimate.waterMinutes)}
|
||||
/>
|
||||
{estimate.portageCount > 0 && (
|
||||
<PartTile
|
||||
testId="paddle-time-carry"
|
||||
icon={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
|
||||
color={PORTAGE_CSS_COLOR}
|
||||
label={t('tours.paddle.time.carry')}
|
||||
measures={
|
||||
<MeasureGroup
|
||||
parts={[
|
||||
km(estimate.landM),
|
||||
<KmhChip
|
||||
key="speed"
|
||||
testId="paddle-time-carry-speed"
|
||||
label={t('tours.paddle.time.carrySpeed')}
|
||||
value={portageSpeed.value}
|
||||
placeholder={portageSpeed.startValue}
|
||||
max={MAX_PORTAGE_SPEED_KMH}
|
||||
disabled={locked}
|
||||
onChange={portageSpeed.onChange}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
time={minutes(estimate.carryMinutes)}
|
||||
/>
|
||||
)}
|
||||
{estimate.portageCount > 0 && (
|
||||
<PartTile
|
||||
testId="paddle-time-out-in"
|
||||
icon={<PaddleGlyph kind="take_out" size={12} strokeWidth={2.4} />}
|
||||
color={PORTAGE_CSS_COLOR}
|
||||
label={t('tours.paddle.time.outIn')}
|
||||
measures={<MeasureBadge>{`${estimate.portageCount}×`}</MeasureBadge>}
|
||||
time={minutes(estimate.portageExtraMinutes)}
|
||||
/>
|
||||
)}
|
||||
{estimate.locksCounted > 0 && (
|
||||
<PartTile
|
||||
testId="paddle-time-locks"
|
||||
icon={<PaddleGlyph kind="lock" size={12} strokeWidth={2.4} />}
|
||||
label={t('tours.paddle.time.locks')}
|
||||
measures={<MeasureBadge>{`${estimate.locksCounted}×`}</MeasureBadge>}
|
||||
time={estimate.locksMissingTime > 0 && estimate.lockMinutes === 0 ? '–' : minutes(estimate.lockMinutes)}
|
||||
warn={estimate.locksMissingTime > 0}
|
||||
/>
|
||||
)}
|
||||
<PartTile
|
||||
testId="paddle-time-breaks"
|
||||
icon={<Timer size={12} strokeWidth={2.4} />}
|
||||
label={t('tours.planner.breaksShort')}
|
||||
time={minutes(breakMinutes)}
|
||||
muted={breakMinutes === null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The estimate of a paddle tour as one block: the total as its headline beside
|
||||
* the paddler's own planned total, the band of its stretches, the tiles of its
|
||||
* parts and what is still missing. The planner and a recorded track show it alike.
|
||||
*/
|
||||
export function PaddleEstimateBody({
|
||||
estimate,
|
||||
layout,
|
||||
breakMinutes,
|
||||
planned,
|
||||
locked,
|
||||
portageSpeed,
|
||||
paddleSpeed,
|
||||
}: {
|
||||
estimate: PaddleTimeEstimate;
|
||||
layout: PaddleLayout;
|
||||
breakMinutes: number | null;
|
||||
planned: number | null;
|
||||
locked: boolean;
|
||||
portageSpeed: SpeedControl;
|
||||
paddleSpeed?: SpeedControl;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const missing = missingParts(estimate, t);
|
||||
return (
|
||||
<div data-testid="paddle-time-card" className="flex flex-col gap-3">
|
||||
<EstimateHead
|
||||
total={estimate.estimateMinutes + (breakMinutes ?? 0)}
|
||||
complete={estimate.complete}
|
||||
planned={planned}
|
||||
t={t}
|
||||
/>
|
||||
<PaddleRouteStrip layout={layout} />
|
||||
<PartTiles
|
||||
estimate={estimate}
|
||||
breakMinutes={breakMinutes}
|
||||
locked={locked}
|
||||
portageSpeed={portageSpeed}
|
||||
paddleSpeed={paddleSpeed}
|
||||
/>
|
||||
{missing.length > 0 && (
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{missing.join(' ')}
|
||||
</TourNotice>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: its title, and that its values are planning values to adjust. */
|
||||
export function PaddleTimeCardShell({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Timer size={14} strokeWidth={2} />}
|
||||
title={t('tours.paddle.time.title')}
|
||||
aside={
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
<SoftPill tone="info">{t('tours.paddle.time.assumption')}</SoftPill>
|
||||
<InfoDot label={t('tours.paddle.time.assumption')} description={t('tours.paddle.time.assumptionInfo')} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-07: how the time of a paddle tour comes about. Water at the paddling
|
||||
* speed, portages at the carrying speed with their time out and in, locks at
|
||||
* the times entered for them, the breaks; their sum is the estimate, set
|
||||
* beside a total the paddler planned themselves. Every starting value says it
|
||||
* is one, and what is unknown is named rather than counted as nothing.
|
||||
*/
|
||||
export function PaddleTimeCard({ planner, locked }: { planner: TourPlannerController; locked: boolean }) {
|
||||
const estimate = planner.paddleEstimate;
|
||||
if (!estimate || planner.waypoints.length < 2) return null;
|
||||
return (
|
||||
<PaddleTimeCardShell>
|
||||
<PaddleEstimateBody
|
||||
estimate={estimate}
|
||||
layout={paddleLayoutOfPoints(paddlePlanPoints(planner.waypoints))}
|
||||
breakMinutes={planner.breakAdditionalMinutes}
|
||||
planned={planner.plannedDurationMinutes}
|
||||
locked={locked}
|
||||
portageSpeed={{
|
||||
value: planner.paddle.portageSpeedKmh,
|
||||
startValue: DEFAULT_PORTAGE_SPEED_KMH,
|
||||
onChange: planner.paddle.setPortageSpeedKmh,
|
||||
}}
|
||||
/>
|
||||
</PaddleTimeCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { DEFAULT_PORTAGE_EXTRA_MINUTES, MAX_PADDLE_STOP_MINUTES, type PaddleLock, type PaddleRole } from '@trek/shared';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
import { useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PORTAGE_CSS_COLOR } from './paddlePaint';
|
||||
|
||||
const ROLE_GLYPH = { put_in: 'put_in', take_out: 'take_out', re_entry: 'put_in', final_take_out: 'take_out' } as const;
|
||||
|
||||
/** Put-in, take-out, re-entry or final take-out, as the badge of a waypoint row of a paddle tour. */
|
||||
export function PaddleRolePill({ role }: { role: PaddleRole }) {
|
||||
const { t } = useTranslation();
|
||||
const land = role === 'take_out' || role === 're_entry';
|
||||
return (
|
||||
<span
|
||||
data-paddle-role={role}
|
||||
className="inline-flex h-[20px] min-w-0 flex-none items-center gap-1 whitespace-nowrap rounded-full bg-surface-secondary px-2 font-geist leading-none text-content-secondary"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<PaddleGlyph
|
||||
kind={ROLE_GLYPH[role]}
|
||||
size={11}
|
||||
strokeWidth={2.4}
|
||||
style={land ? { color: PORTAGE_CSS_COLOR } : undefined}
|
||||
/>
|
||||
{t(`tours.paddle.role.${role}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A number of minutes typed in place: a value or nothing. Text that is no
|
||||
* whole number of minutes in range is dropped on leaving the field, and the
|
||||
* value before it stays.
|
||||
*/
|
||||
function MinutesInput({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
onChange,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
/** What shows while there is no value: the starting value, or a dash. */
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
onChange: (minutes: number | null) => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const commit = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === '') return onChange(null);
|
||||
const minutes = Number(trimmed);
|
||||
if (Number.isInteger(minutes) && minutes >= 0 && minutes <= MAX_PADDLE_STOP_MINUTES) onChange(minutes);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
return (
|
||||
<label className="inline-flex h-[26px] flex-none items-center gap-1 rounded-full border border-edge bg-surface-card pe-2.5 ps-2.5 transition-colors focus-within:border-accent">
|
||||
<input
|
||||
data-testid={testId}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
value={draft ?? (value === null ? '' : String(value))}
|
||||
placeholder={placeholder}
|
||||
onFocus={() => setDraft(value === null ? '' : String(value))}
|
||||
onChange={(event) => setDraft(event.target.value.replace(/[^\d]/g, '').slice(0, 3))}
|
||||
onBlur={() => {
|
||||
if (draft !== null) commit(draft);
|
||||
setDraft(null);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
if (event.key === 'Escape') {
|
||||
setDraft(null);
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="w-[3ch] min-w-0 bg-transparent text-end font-geist font-semibold tabular-nums text-content outline-none placeholder:font-normal placeholder:text-content-faint"
|
||||
style={fs(12)}
|
||||
/>
|
||||
<span className="text-content-faint" style={fs(11)}>
|
||||
min
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** One labelled value of an opened stop, its input on the right. */
|
||||
function StopField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-content" style={fs(12, 'body')}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && (
|
||||
<span className="block truncate text-content-faint" style={fs(10.5, 'body')}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A value of a stop as the closed card shows it: quiet, or in the warning tone when it is missing. */
|
||||
function StopPill({ children, missing = false }: { children: ReactNode; missing?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`flex-none rounded-full px-1.5 font-geist tabular-nums leading-[18px] ${missing ? 'bg-warning-soft text-warning' : 'bg-surface-secondary text-content-secondary'}`}
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The quiet way to take a stop off again, at the foot of its opened card. */
|
||||
function RemoveStop({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="flex items-center gap-1 self-start rounded-full px-1 text-danger transition-opacity hover:opacity-80"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<X size={12} strokeWidth={2.4} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portage or a lock under its waypoint, in the shape of the via ferrata leg:
|
||||
* a round glyph, the name and its values as pills. A click opens it to change
|
||||
* those values in labelled fields, and closes it again.
|
||||
*/
|
||||
function StopCard({
|
||||
testId,
|
||||
glyph,
|
||||
color,
|
||||
title,
|
||||
pills,
|
||||
muted = false,
|
||||
children,
|
||||
}: {
|
||||
testId: string;
|
||||
glyph: ReactNode;
|
||||
/** The colour of its ring and its round glyph: the portage brown, or the quiet default. */
|
||||
color?: string;
|
||||
title: string;
|
||||
pills: ReactNode;
|
||||
muted?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<li data-testid={testId} className="flex min-w-0 flex-col gap-1 py-0.5 ps-[34px]">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`flex h-[28px] w-full min-w-0 items-center gap-1.5 rounded-full bg-surface-card pe-1.5 ps-1 text-start shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover ${muted ? 'opacity-60' : ''}`}
|
||||
style={color ? { boxShadow: `inset 0 0 0 1px ${color}` } : undefined}
|
||||
>
|
||||
<span
|
||||
className="grid h-[20px] w-[20px] flex-none place-items-center rounded-full bg-content-secondary text-surface-card"
|
||||
style={color ? { background: color } : undefined}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(11.5, 'body')}>
|
||||
{title}
|
||||
</span>
|
||||
{pills}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-card px-2.5 py-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A portage under the point it starts at, in the earth brown the map draws it
|
||||
* in: its length on land and the time for getting out, unloading and back in
|
||||
* (the starting value until one is typed).
|
||||
*/
|
||||
export function PortageLegRow({
|
||||
lengthM,
|
||||
extraMinutes,
|
||||
disabled,
|
||||
onExtraChange,
|
||||
position,
|
||||
onRemove,
|
||||
}: {
|
||||
lengthM: number;
|
||||
extraMinutes: number | null;
|
||||
disabled: boolean;
|
||||
onExtraChange: (minutes: number | null) => void;
|
||||
/** Where it lies on a recorded track ("at 4.2 km"); a planned tour shows it under its waypoint instead. */
|
||||
position?: string;
|
||||
/** Takes the portage off a recorded track; a planned tour carries it on its leg instead. */
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit);
|
||||
const extra = extraMinutes ?? DEFAULT_PORTAGE_EXTRA_MINUTES;
|
||||
return (
|
||||
<StopCard
|
||||
testId="tour-planner-portage-leg"
|
||||
glyph={<PaddleGlyph kind="portage" size={12} strokeWidth={2.4} />}
|
||||
color={PORTAGE_CSS_COLOR}
|
||||
title={t('tours.paddle.portage')}
|
||||
pills={
|
||||
<>
|
||||
{position && <StopPill>{position}</StopPill>}
|
||||
<StopPill>{formatDistance(lengthM / 1000, unit ?? 'metric')}</StopPill>
|
||||
<StopPill>{`+${extra} min`}</StopPill>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="m-0 leading-snug text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.paddle.portageInfo')}
|
||||
</p>
|
||||
{onRemove && !disabled && <RemoveStop label={t('tours.paddle.portageRemove')} onRemove={onRemove} />}
|
||||
<StopField
|
||||
label={t('tours.paddle.portageExtra')}
|
||||
hint={extraMinutes === null ? t('tours.paddle.startValue') : undefined}
|
||||
>
|
||||
<MinutesInput
|
||||
testId="tour-planner-portage-extra"
|
||||
label={t('tours.paddle.portageExtra')}
|
||||
value={extraMinutes}
|
||||
placeholder={String(DEFAULT_PORTAGE_EXTRA_MINUTES)}
|
||||
disabled={disabled}
|
||||
onChange={onExtraChange}
|
||||
/>
|
||||
</StopField>
|
||||
</StopCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lock at a waypoint with the wait and the passage expected there. A time
|
||||
* not typed is missing, never zero, and the closed card says so; a lock a
|
||||
* portage carries round is dimmed and its times do not count.
|
||||
*/
|
||||
export function LockRow({
|
||||
lock,
|
||||
bypassed,
|
||||
disabled,
|
||||
onChange,
|
||||
position,
|
||||
}: {
|
||||
lock: PaddleLock;
|
||||
bypassed: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (lock: PaddleLock | null) => void;
|
||||
/** Where it lies on a recorded track ("at 4.2 km"). */
|
||||
position?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const missing = lock.wait_minutes === null || lock.transit_minutes === null;
|
||||
const known = (lock.wait_minutes ?? 0) + (lock.transit_minutes ?? 0);
|
||||
const lockPill = bypassed ? (
|
||||
<StopPill>{t('tours.paddle.lockBypassed')}</StopPill>
|
||||
) : missing ? (
|
||||
<StopPill missing>{t('tours.paddle.timeMissing')}</StopPill>
|
||||
) : (
|
||||
<StopPill>{`${known} min`}</StopPill>
|
||||
);
|
||||
return (
|
||||
<StopCard
|
||||
testId="tour-planner-lock"
|
||||
glyph={<PaddleGlyph kind="lock" size={12} strokeWidth={2.4} />}
|
||||
title={t('tours.paddle.lock')}
|
||||
muted={bypassed}
|
||||
pills={
|
||||
<>
|
||||
{position && <StopPill>{position}</StopPill>}
|
||||
{lockPill}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StopField label={t('tours.paddle.lockWait')}>
|
||||
<MinutesInput
|
||||
testId="tour-planner-lock-wait"
|
||||
label={t('tours.paddle.lockWait')}
|
||||
value={lock.wait_minutes}
|
||||
placeholder="–"
|
||||
disabled={disabled}
|
||||
onChange={(minutes) => onChange({ ...lock, wait_minutes: minutes })}
|
||||
/>
|
||||
</StopField>
|
||||
<StopField label={t('tours.paddle.lockTransit')}>
|
||||
<MinutesInput
|
||||
testId="tour-planner-lock-transit"
|
||||
label={t('tours.paddle.lockTransit')}
|
||||
value={lock.transit_minutes}
|
||||
placeholder="–"
|
||||
disabled={disabled}
|
||||
onChange={(minutes) => onChange({ ...lock, transit_minutes: minutes })}
|
||||
/>
|
||||
</StopField>
|
||||
{!disabled && <RemoveStop label={t('tours.paddle.lockRemove')} onRemove={() => onChange(null)} />}
|
||||
</StopCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// FE-PADDLE-WIND-001 to FE-PADDLE-WIND-016: the wind card of a paddle tour (PAD-08) and the section that feeds it.
|
||||
// FE-PADDLE-FIGURE-001 to FE-PADDLE-FIGURE-002: the figure badge the card shows its values in.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Day, WindForecastResponse, WindHour } from '@trek/shared';
|
||||
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import type { TourDay } from '../tourDays';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { PaddleWindCard, PaddleWindSection } from './PaddleWindCard';
|
||||
import type { TourWind, TourWindState } from './useTourWind';
|
||||
|
||||
const hook = vi.hoisted(() => ({
|
||||
useTourWind: vi.fn<(placeId: number | null, start: { lat: number; lng: number } | null) => TourWind>(),
|
||||
}));
|
||||
vi.mock('./useTourWind', () => ({ useTourWind: hook.useTourWind }));
|
||||
|
||||
/** The dash a figure the source left out reads as, never a zero. */
|
||||
const NONE = '–';
|
||||
|
||||
/** The notices as shared/src/i18n/en/tours.ts words them. */
|
||||
const SAYS = {
|
||||
noDay: 'Put the tour on a day of the trip to see the wind forecast for that day.',
|
||||
noDate: 'This day of the trip has no date, so there is no forecast for it.',
|
||||
noPlace: 'The tour has no start point yet to ask the forecast for.',
|
||||
offline: 'Without a connection there is no wind forecast.',
|
||||
error: 'The wind forecast cannot be fetched right now.',
|
||||
past: 'This day has passed. A forecast exists only for days to come.',
|
||||
};
|
||||
|
||||
const HINTS = [
|
||||
'Headwind costs time and strength.',
|
||||
'Crosswind pushes the boat off course.',
|
||||
'Open crossings are more exposed than the shore.',
|
||||
'Gusts are stronger than the mean and come suddenly.',
|
||||
'No figure is an all-clear. Check the conditions on site.',
|
||||
];
|
||||
|
||||
const FETCHED_AT = '2026-07-07T09:45:00Z';
|
||||
const FETCHED_CLOCK = new Date(FETCHED_AT).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
/** When the forecast was asked, with the spaces the DOM queries read (ICU puts a narrow one before AM). */
|
||||
const FETCHED_TIME = FETCHED_CLOCK.replace(/\s/g, ' ');
|
||||
|
||||
const dayOf = (id: number, date: string | null): Day => ({ id, trip_id: 3, date });
|
||||
const JULY_8: TourDay = { day: dayOf(11, '2026-07-08'), index: 1 };
|
||||
const JULY_9: TourDay = { day: dayOf(12, '2026-07-09'), index: 3 };
|
||||
const UNDATED: TourDay = { day: dayOf(13, null), index: 2 };
|
||||
|
||||
const hourOf = (hour: number, speed: number | null, gusts: number | null, direction: number | null): WindHour => ({
|
||||
hour,
|
||||
speed_kmh: speed,
|
||||
gusts_kmh: gusts,
|
||||
direction_deg: direction,
|
||||
});
|
||||
|
||||
/** A day on the water: an hour either side of the strip, a calm one and one the source left out. */
|
||||
const HOURS: WindHour[] = [
|
||||
hourOf(5, 30, 50, 90),
|
||||
hourOf(6, 8, 14, 200),
|
||||
hourOf(7, 0, 0, 0),
|
||||
hourOf(9, null, null, null),
|
||||
hourOf(10, 10, null, null),
|
||||
hourOf(12, 20, 40, 270),
|
||||
hourOf(21, 4, 6, 180),
|
||||
hourOf(22, 60, 80, 90),
|
||||
];
|
||||
|
||||
function forecastOf(overrides: Partial<WindForecastResponse> = {}): WindForecastResponse {
|
||||
return {
|
||||
status: 'forecast',
|
||||
date: '2026-07-08',
|
||||
lat: 47.05,
|
||||
lng: 8.31,
|
||||
timezone: 'Europe/Zurich',
|
||||
source: 'Open-Meteo',
|
||||
fetched_at: FETCHED_AT,
|
||||
horizon_days: 16,
|
||||
hours: [],
|
||||
day: { speed_max_kmh: 18, gusts_max_kmh: 32, direction_dominant_deg: 225 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const ready = (forecast: WindForecastResponse): TourWindState => ({ kind: 'ready', forecast });
|
||||
|
||||
const retry = vi.fn<TourWind['retry']>();
|
||||
const chooseDay = vi.fn<TourWind['chooseDay']>();
|
||||
|
||||
/** What useTourWind hands the card: a state, the tour's days and the one shown. */
|
||||
function windOf(
|
||||
state: TourWindState,
|
||||
tourDays: TourDay[] = [JULY_8],
|
||||
day: TourDay | null = tourDays[0] ?? null
|
||||
): TourWind {
|
||||
return { state, day, tourDays, chooseDay, retry };
|
||||
}
|
||||
|
||||
const renderCard = (wind: TourWind) => render(<PaddleWindCard wind={wind} />);
|
||||
|
||||
/** A day as the card names it, with the spaces the DOM queries read. */
|
||||
function dateLabel(date: string): string {
|
||||
const label = formatDate(date, 'en-US');
|
||||
if (!label) throw new Error(`no label for ${date}`);
|
||||
return label.replace(/\s/g, ' ');
|
||||
}
|
||||
|
||||
/** What an element's tooltip says once it is hovered; the hover ends before it returns. */
|
||||
async function tooltipOf(element: HTMLElement): Promise<string | null> {
|
||||
fireEvent.mouseEnter(element);
|
||||
const text = (await screen.findByRole('tooltip')).textContent;
|
||||
fireEvent.mouseLeave(element);
|
||||
return text;
|
||||
}
|
||||
|
||||
/** The column of one hour in the strip, the element its tooltip hangs on. */
|
||||
function hourColumn(hour: number): HTMLElement {
|
||||
const column = within(screen.getByTestId('paddle-wind-hours')).getByText(String(hour)).parentElement;
|
||||
if (!column) throw new Error(`no column for ${hour}:00`);
|
||||
return column;
|
||||
}
|
||||
|
||||
/** The height of a column's bar for the mean or the gusts; undefined when the column has none. */
|
||||
function barHeight(column: HTMLElement, bar: 'mean' | 'gusts'): string | undefined {
|
||||
return column.querySelector<HTMLElement>(bar === 'mean' ? '.bg-info' : '.bg-info-soft')?.style.height;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('PaddleWindCard', () => {
|
||||
it('FE-PADDLE-WIND-001: a tour on no day asks to be put on one, names no day and still offers the hints', () => {
|
||||
renderCard(windOf({ kind: 'no_day' }, [], null));
|
||||
|
||||
const card = screen.getByRole('region', { name: 'Wind' });
|
||||
expect(within(card).getByText(SAYS.noDay)).toBeInTheDocument();
|
||||
expect(within(card).queryByText(/^Day \d+$/)).not.toBeInTheDocument();
|
||||
expect(within(card).queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
|
||||
expect(within(card).getByRole('button', { name: 'What wind means for paddling' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-002: a day without a date says there is no forecast for it and is named by its number', () => {
|
||||
renderCard(windOf({ kind: 'no_date' }, [UNDATED]));
|
||||
|
||||
expect(screen.getByText(SAYS.noDate)).toBeInTheDocument();
|
||||
expect(screen.getByText('Day 3')).toBeInTheDocument();
|
||||
// One day is a label, not a choice.
|
||||
expect(screen.queryByRole('button', { name: 'Day 3' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-003: a tour without a start point says so, under the date of its day', () => {
|
||||
renderCard(windOf({ kind: 'no_place' }));
|
||||
|
||||
expect(screen.getByText(SAYS.noPlace)).toBeInTheDocument();
|
||||
expect(screen.getByText(dateLabel('2026-07-08'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-004: while the forecast loads it holds a placeholder and no figures', () => {
|
||||
renderCard(windOf({ kind: 'loading' }));
|
||||
|
||||
expect(screen.getByTestId('paddle-wind-loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-hours')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-005: offline it says there is no forecast without a connection and offers no retry', () => {
|
||||
renderCard(windOf({ kind: 'offline' }));
|
||||
|
||||
expect(screen.getByText(SAYS.offline)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Try again' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-006: a failed request is a warning whose Try again asks once more', () => {
|
||||
renderCard(windOf({ kind: 'error' }));
|
||||
|
||||
const notice = screen.getByText(SAYS.error);
|
||||
expect(notice.closest('.bg-warning-soft')).not.toBeNull();
|
||||
fireEvent.click(within(notice).getByRole('button', { name: 'Try again' }));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-007: a day already gone says a forecast exists only for days to come and shows no figures', () => {
|
||||
renderCard(windOf(ready(forecastOf({ status: 'past', timezone: null, day: null }))));
|
||||
|
||||
expect(screen.getByText(SAYS.past)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-hours')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-008: a day beyond the forecast names the first day its forecast can be had', () => {
|
||||
const later = forecastOf({ status: 'beyond_horizon', date: '2027-03-20', timezone: null, day: null });
|
||||
renderCard(windOf(ready(later)));
|
||||
|
||||
// Sixteen days, today included, reach the 20th from the 5th on.
|
||||
const notice = `No forecast for this day yet. It comes from ${dateLabel('2027-03-05')}.`;
|
||||
expect(screen.getByText(notice)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-mean')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-009: a forecast shows the direction, the mean and the gusts of the day, the legend and its source', async () => {
|
||||
renderCard(windOf(ready(forecastOf())));
|
||||
|
||||
const direction = screen.getByTestId('paddle-wind-direction');
|
||||
expect(direction).toHaveTextContent(/^SW$/);
|
||||
// From the south west, so the arrow turns half round and points the way the wind blows.
|
||||
expect(direction.querySelector('svg')?.style.transform).toBe('rotate(405deg)');
|
||||
const mean = screen.getByTestId('paddle-wind-mean');
|
||||
expect(within(mean).getByText('Mean')).toBeInTheDocument();
|
||||
expect(within(mean).getByText('18 km/h')).toBeInTheDocument();
|
||||
const gusts = screen.getByTestId('paddle-wind-gusts');
|
||||
expect(within(gusts).getByText('Gusts')).toBeInTheDocument();
|
||||
expect(within(gusts).getByText('32 km/h')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mean wind')).toBeInTheDocument();
|
||||
expect(screen.getByText(`Open-Meteo, ${FETCHED_TIME}, forecast at the start`)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-loading')).not.toBeInTheDocument();
|
||||
|
||||
expect(await tooltipOf(direction)).toBe('Wind from SW, the direction it blows from most of the day');
|
||||
expect(await tooltipOf(mean)).toBe('The strongest hourly mean wind of the day');
|
||||
expect(await tooltipOf(gusts)).toBe('The strongest gust of the day');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-010: the hours from 6 to 21 are columns, scaled to the strongest gust among them', async () => {
|
||||
renderCard(windOf(ready(forecastOf({ hours: HOURS }))));
|
||||
|
||||
const strip = screen.getByTestId('paddle-wind-hours');
|
||||
expect(Array.from(strip.children, (column) => column.textContent)).toEqual(['6', '7', '9', '10', '12', '21']);
|
||||
|
||||
// The 40 km/h gusts at noon set the scale; the 80 at 22:00 lie outside the strip and do not.
|
||||
const noon = hourColumn(12);
|
||||
expect(barHeight(noon, 'mean')).toBe('50%');
|
||||
expect(barHeight(noon, 'gusts')).toBe('100%');
|
||||
// From the west, so the arrow points east.
|
||||
expect(noon.querySelector('svg')?.style.transform).toBe('rotate(450deg)');
|
||||
expect(await tooltipOf(noon)).toBe('12:00: 20 km/h, gusts 40 km/h, from W');
|
||||
|
||||
// A mean without gusts or a direction draws its bar alone and says what it lacks.
|
||||
const meanOnly = hourColumn(10);
|
||||
expect(barHeight(meanOnly, 'mean')).toBe('25%');
|
||||
expect(barHeight(meanOnly, 'gusts')).toBeUndefined();
|
||||
expect(meanOnly.querySelector('svg')).toBeNull();
|
||||
expect(await tooltipOf(meanOnly)).toBe(`10:00: 10 km/h, gusts ${NONE} km/h, from ${NONE}`);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-011: an hour the source left out stays an empty column, while a calm hour is a figure', async () => {
|
||||
renderCard(windOf(ready(forecastOf({ hours: HOURS }))));
|
||||
|
||||
const unknown = hourColumn(9);
|
||||
expect(barHeight(unknown, 'mean')).toBeUndefined();
|
||||
expect(barHeight(unknown, 'gusts')).toBeUndefined();
|
||||
expect(unknown.querySelector('.border-dashed')).not.toBeNull();
|
||||
expect(unknown.querySelector('svg')).toBeNull();
|
||||
expect(await tooltipOf(unknown)).toBe('9:00: no value');
|
||||
|
||||
// 0 km/h from the north is a measured calm: a sliver of a bar and an arrow, no dashed outline.
|
||||
const calm = hourColumn(7);
|
||||
expect(barHeight(calm, 'mean')).toBe('4%');
|
||||
expect(barHeight(calm, 'gusts')).toBe('4%');
|
||||
expect(calm.querySelector('.border-dashed')).toBeNull();
|
||||
expect(calm.querySelector('svg')?.style.transform).toBe('rotate(180deg)');
|
||||
expect(await tooltipOf(calm)).toBe('7:00: 0 km/h, gusts 0 km/h, from N');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-012: a light day is drawn against 10 km/h at least, so a breeze never fills its column', () => {
|
||||
renderCard(windOf(ready(forecastOf({ hours: [hourOf(8, 5, null, 90)] }))));
|
||||
|
||||
expect(barHeight(hourColumn(8), 'mean')).toBe('50%');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-013: a day figure the source left out reads as a dash, never as calm; a real calm reads 0 km/h', () => {
|
||||
const blank = forecastOf({
|
||||
day: { speed_max_kmh: null, gusts_max_kmh: null, direction_dominant_deg: null },
|
||||
hours: [hourOf(9, null, null, null)],
|
||||
});
|
||||
const { rerender } = renderCard(windOf(ready(blank)));
|
||||
|
||||
expect(within(screen.getByTestId('paddle-wind-mean')).getByText(NONE)).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('paddle-wind-gusts')).getByText(NONE)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-wind-direction')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-wind-card')).not.toHaveTextContent('km/h');
|
||||
|
||||
// A forecast without day figures at all reads the same.
|
||||
rerender(<PaddleWindCard wind={windOf(ready(forecastOf({ day: null })))} />);
|
||||
expect(within(screen.getByTestId('paddle-wind-mean')).getByText(NONE)).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('paddle-wind-gusts')).getByText(NONE)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-wind-card')).not.toHaveTextContent('km/h');
|
||||
|
||||
const calm = forecastOf({ day: { speed_max_kmh: 0, gusts_max_kmh: 0, direction_dominant_deg: 0 } });
|
||||
rerender(<PaddleWindCard wind={windOf(ready(calm))} />);
|
||||
expect(within(screen.getByTestId('paddle-wind-mean')).getByText('0 km/h')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('paddle-wind-gusts')).getByText('0 km/h')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-wind-direction')).toHaveTextContent(/^N$/);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-014: a tour on two days offers each as a button, marks the one shown and asks for the other', () => {
|
||||
const { rerender } = renderCard(windOf(ready(forecastOf()), [JULY_8, JULY_9], JULY_9));
|
||||
|
||||
const second = screen.getByRole('button', { name: 'Day 2' });
|
||||
expect(second).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Day 4' })).toHaveAttribute('aria-pressed', 'true');
|
||||
// The buttons stand in for the date pill a single day gets.
|
||||
expect(screen.queryByText(dateLabel('2026-07-09'))).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(second);
|
||||
expect(chooseDay).toHaveBeenCalledTimes(1);
|
||||
expect(chooseDay).toHaveBeenCalledWith(11);
|
||||
|
||||
// The card follows the day the hook shows.
|
||||
rerender(<PaddleWindCard wind={windOf(ready(forecastOf()), [JULY_8, JULY_9], JULY_8)} />);
|
||||
expect(screen.getByRole('button', { name: 'Day 2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Day 4' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-WIND-015: the hints for paddlers fold out and back in', () => {
|
||||
renderCard(windOf({ kind: 'offline' }));
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'What wind means for paddling' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual(HINTS);
|
||||
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaddleWindSection', () => {
|
||||
it('FE-PADDLE-WIND-016: asks the wind for its tour and start and shows the card for the answer', () => {
|
||||
hook.useTourWind.mockReturnValue(windOf({ kind: 'offline' }));
|
||||
|
||||
render(<PaddleWindSection placeId={7} start={{ lat: 47.05, lng: 8.31 }} />);
|
||||
|
||||
expect(hook.useTourWind).toHaveBeenCalledWith(7, { lat: 47.05, lng: 8.31 });
|
||||
expect(screen.getByRole('region', { name: 'Wind' })).toHaveTextContent(SAYS.offline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FigureBadge', () => {
|
||||
it('FE-PADDLE-FIGURE-001: holds a label and a value; the warning tone colours the value alone', () => {
|
||||
const { rerender } = render(<FigureBadge testId="figure" label="Height" value="1.2 m" tone="warning" />);
|
||||
|
||||
expect(within(screen.getByTestId('figure')).getByText('Height')).not.toHaveClass('text-warning');
|
||||
expect(screen.getByText('1.2 m').parentElement).toHaveClass('text-warning');
|
||||
|
||||
// Without a label the badge holds its value alone, in the neutral tone.
|
||||
rerender(<FigureBadge testId="figure" value="Private" />);
|
||||
expect(screen.getByTestId('figure')).toHaveTextContent(/^Private$/);
|
||||
expect(screen.getByText('Private').parentElement).toHaveClass('text-content');
|
||||
expect(screen.getByText('Private').parentElement).not.toHaveClass('text-warning');
|
||||
});
|
||||
|
||||
it('FE-PADDLE-FIGURE-002: only a badge with an info opens a tooltip', async () => {
|
||||
render(
|
||||
<>
|
||||
<FigureBadge testId="plain" value="Private" />
|
||||
<FigureBadge testId="explained" label="Access" value="No" info="Recorded as private on OpenStreetMap" />
|
||||
</>
|
||||
);
|
||||
|
||||
// Both are hovered: had the plain badge a tooltip of its own, it would still be open afterwards.
|
||||
fireEvent.mouseEnter(screen.getByTestId('plain'));
|
||||
expect(await tooltipOf(screen.getByTestId('explained'))).toBe('Recorded as private on OpenStreetMap');
|
||||
expect(screen.queryAllByRole('tooltip')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { compassPointOf, type WindForecastResponse, type WindHour } from '@trek/shared';
|
||||
import { ChevronDown, RefreshCw, WifiOff, Wind } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { SoftPill } from '../../Planner/planParts';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { FigureBadge } from './FigureBadge';
|
||||
import { useTourWind, type TourWind } from './useTourWind';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** The hours a day tour is out on the water, as the strip shows them. */
|
||||
const FIRST_HOUR = 6;
|
||||
const LAST_HOUR = 21;
|
||||
|
||||
/**
|
||||
* An arrow the way the wind blows: meteorological degrees name where it comes
|
||||
* from, so the arrow turns half round from them.
|
||||
*/
|
||||
function FlowArrow({ from, size }: { from: number; size: number }) {
|
||||
// Drawn about its own centre, so every hour's arrow turns in place at the same height.
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-none"
|
||||
style={{ transform: `rotate(${from + 180}deg)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 20V4" />
|
||||
<path d="M6 10l6-6 6 6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function directionName(degrees: number | null, t: Translate): string | null {
|
||||
return degrees === null ? null : t(`tours.paddle.wind.dir.${compassPointOf(degrees)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day's hours as columns: the mean wind as a bar, the gusts as the paler
|
||||
* bar behind it, an arrow the way it blows on top. An hour the source left
|
||||
* out stays an empty column, never a calm one.
|
||||
*/
|
||||
function HourStrip({ hours, t }: { hours: readonly WindHour[]; t: Translate }) {
|
||||
const shown = hours.filter((hour) => hour.hour >= FIRST_HOUR && hour.hour <= LAST_HOUR);
|
||||
const top = Math.max(10, ...shown.map((hour) => hour.gusts_kmh ?? hour.speed_kmh ?? 0));
|
||||
const height = (value: number | null) => `${Math.max(4, ((value ?? 0) / top) * 100)}%`;
|
||||
return (
|
||||
<div data-testid="paddle-wind-hours" className="flex items-end gap-[3px]">
|
||||
{shown.map((hour) => {
|
||||
const from = hour.direction_deg;
|
||||
const known = hour.speed_kmh !== null;
|
||||
const label = known
|
||||
? t('tours.paddle.wind.hourInfo', {
|
||||
hour: hour.hour,
|
||||
speed: hour.speed_kmh,
|
||||
gusts: hour.gusts_kmh ?? '–',
|
||||
direction: directionName(from, t) ?? '–',
|
||||
})
|
||||
: t('tours.paddle.wind.hourUnknown', { hour: hour.hour });
|
||||
return (
|
||||
<Tooltip key={hour.hour} label={label}>
|
||||
<span className="flex min-w-0 flex-1 flex-col items-center gap-1">
|
||||
<span className="grid h-3.5 w-3.5 place-items-center text-content-secondary">
|
||||
{from !== null && <FlowArrow from={from} size={11} />}
|
||||
</span>
|
||||
<span
|
||||
className={`relative flex h-12 w-full items-end justify-center overflow-hidden rounded-[3px] ${known ? 'bg-surface-secondary' : 'border border-dashed border-edge'}`}
|
||||
>
|
||||
{hour.gusts_kmh !== null && (
|
||||
<span
|
||||
className="absolute inset-x-0 bottom-0 bg-info-soft"
|
||||
style={{ height: height(hour.gusts_kmh) }}
|
||||
/>
|
||||
)}
|
||||
{known && (
|
||||
<span
|
||||
className="relative w-full rounded-t-[2px] bg-info"
|
||||
style={{ height: height(hour.speed_kmh) }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(9.5)}>
|
||||
{hour.hour}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The forecast of the chosen day: its figures, its hours and where and when it was asked. */
|
||||
function Forecast({ forecast, t, locale }: { forecast: WindForecastResponse; t: Translate; locale: string }) {
|
||||
const day = forecast.day;
|
||||
const from = day?.direction_dominant_deg ?? null;
|
||||
const fetched = new Date(forecast.fetched_at).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{from !== null && (
|
||||
<FigureBadge
|
||||
testId="paddle-wind-direction"
|
||||
value={directionName(from, t) ?? ''}
|
||||
icon={<FlowArrow from={from} size={11} />}
|
||||
info={t('tours.paddle.wind.fromInfo', { direction: directionName(from, t) ?? '' })}
|
||||
/>
|
||||
)}
|
||||
<FigureBadge
|
||||
testId="paddle-wind-mean"
|
||||
label={t('tours.paddle.wind.mean')}
|
||||
value={day?.speed_max_kmh == null ? '–' : `${day.speed_max_kmh} km/h`}
|
||||
info={t('tours.paddle.wind.meanInfo')}
|
||||
/>
|
||||
<FigureBadge
|
||||
testId="paddle-wind-gusts"
|
||||
label={t('tours.paddle.wind.gusts')}
|
||||
value={day?.gusts_max_kmh == null ? '–' : `${day.gusts_max_kmh} km/h`}
|
||||
info={t('tours.paddle.wind.gustsInfo')}
|
||||
/>
|
||||
</div>
|
||||
<HourStrip hours={forecast.hours} t={t} />
|
||||
<div className="-mt-1 flex items-center gap-3 text-content-muted" style={fs(10.5, 'body')}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-[2px] bg-info" />
|
||||
{t('tours.paddle.wind.legendMean')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-[2px] bg-info-soft ring-1 ring-inset ring-edge-faint" />
|
||||
{t('tours.paddle.wind.legendGusts')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="m-0 text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.paddle.wind.source', { source: forecast.source, time: fetched })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Why there is no forecast to show, each case in its own words, with a way to ask again after a failure. */
|
||||
function Unavailable({ wind, t, locale }: { wind: TourWind; t: Translate; locale: string }) {
|
||||
const { state } = wind;
|
||||
if (state.kind === 'loading')
|
||||
return <div data-testid="paddle-wind-loading" className="h-24 animate-pulse rounded-[10px] bg-surface-secondary" />;
|
||||
if (state.kind === 'error')
|
||||
return (
|
||||
<TourNotice tone="warning" icon={<RefreshCw size={13} strokeWidth={2} />}>
|
||||
<span className="flex flex-wrap items-center gap-x-2">
|
||||
{t('tours.paddle.wind.error')}
|
||||
<button type="button" onClick={wind.retry} className="font-semibold underline-offset-2 hover:underline">
|
||||
{t('tours.paddle.wind.retry')}
|
||||
</button>
|
||||
</span>
|
||||
</TourNotice>
|
||||
);
|
||||
if (state.kind === 'offline')
|
||||
return <TourNotice icon={<WifiOff size={13} strokeWidth={2} />}>{t('tours.paddle.wind.offline')}</TourNotice>;
|
||||
if (state.kind === 'ready' && state.forecast.status === 'beyond_horizon') {
|
||||
const from = new Date(Date.parse(state.forecast.date) - (state.forecast.horizon_days - 1) * 86_400_000);
|
||||
return (
|
||||
<TourNotice icon={<Wind size={13} strokeWidth={2} />}>
|
||||
{t('tours.paddle.wind.beyond', { date: formatDate(from.toISOString().slice(0, 10), locale) })}
|
||||
</TourNotice>
|
||||
);
|
||||
}
|
||||
// A day without a forecast to ask for: past, not on a day, a day without a date, a tour without a start.
|
||||
const notice = state.kind === 'ready' ? t('tours.paddle.wind.past') : t(`tours.paddle.wind.${state.kind}`);
|
||||
return <TourNotice icon={<Wind size={13} strokeWidth={2} />}>{notice}</TourNotice>;
|
||||
}
|
||||
|
||||
/** What wind means for a paddler, in general terms; it rates no passage and clears nothing. */
|
||||
function WindHints({ t }: { t: Translate }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="border-t border-edge-faint pt-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="flex w-full items-center gap-1.5 text-start font-semibold text-content-secondary hover:text-content"
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{t('tours.paddle.wind.hintsTitle')}</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="m-0 mt-2 flex list-disc flex-col gap-1.5 ps-4 text-content-muted" style={fs(11.5, 'body')}>
|
||||
{(['headwind', 'drift', 'crossing', 'gusts', 'local'] as const).map((hint) => (
|
||||
<li key={hint}>{t(`tours.paddle.wind.hint.${hint}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-08: the wind on the day a paddle tour is planned for, at its start. The
|
||||
* mean, the gusts and the direction are figures of their own, with the source
|
||||
* and when it was asked; no value is turned into a verdict, and missing data
|
||||
* is never shown as calm.
|
||||
*/
|
||||
export function PaddleWindCard({ wind }: { wind: TourWind }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const ready = wind.state.kind === 'ready' && wind.state.forecast.status === 'forecast' ? wind.state.forecast : null;
|
||||
return (
|
||||
<RailCard
|
||||
icon={<Wind size={14} strokeWidth={2} />}
|
||||
title={t('tours.paddle.wind.title')}
|
||||
aside={
|
||||
wind.tourDays.length > 1 ? (
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
{wind.tourDays.map(({ day, index }) => (
|
||||
<button
|
||||
key={day.id}
|
||||
type="button"
|
||||
aria-pressed={wind.day?.day.id === day.id}
|
||||
onClick={() => wind.chooseDay(day.id)}
|
||||
className={`rounded-full px-2 font-semibold leading-[20px] transition-colors ${wind.day?.day.id === day.id ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary ring-1 ring-inset ring-edge-faint hover:text-content'}`}
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{t('dayplan.dayN', { n: index + 1 })}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
) : wind.day ? (
|
||||
<SoftPill>{formatDate(wind.day.day.date, locale) || t('dayplan.dayN', { n: wind.day.index + 1 })}</SoftPill>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div data-testid="paddle-wind-card" className="flex flex-col gap-3">
|
||||
{ready ? (
|
||||
<Forecast forecast={ready} t={t} locale={locale} />
|
||||
) : (
|
||||
<Unavailable wind={wind} t={t} locale={locale} />
|
||||
)}
|
||||
<WindHints t={t} />
|
||||
</div>
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** The wind card for one tour: its days read from the trip, its start as the place the forecast is for. */
|
||||
export function PaddleWindSection({
|
||||
placeId,
|
||||
start,
|
||||
}: {
|
||||
placeId: number | null;
|
||||
start: { lat: number; lng: number } | null;
|
||||
}) {
|
||||
const wind = useTourWind(placeId, start);
|
||||
return <PaddleWindCard wind={wind} />;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
// FE-COMP-PADDLE-TRACKPLAN-001 to FE-COMP-PADDLE-TRACKPLAN-016: the cards of a recorded paddle track, its time
|
||||
// estimate reckoned with the portages and locks laid onto it, and the card that marks them by a click on the line.
|
||||
import {
|
||||
DEFAULT_PORTAGE_SPEED_KMH,
|
||||
estimatePaddleTime,
|
||||
paddleLayoutOfTrack,
|
||||
recommendedPaddleSpeedKmh,
|
||||
type TourListItem,
|
||||
type TrackMarks,
|
||||
} from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { buildSettings } from '../../../../tests/helpers/factories';
|
||||
import { act, fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import {
|
||||
loadTrackMarks,
|
||||
pickOnTrack,
|
||||
refuseTrackPick,
|
||||
startTrackPick,
|
||||
trackMarkEdits,
|
||||
useTrackMarkToolStore,
|
||||
type TrackSpeeds,
|
||||
} from '../../../store/trackMarkToolStore';
|
||||
import { NO_TRACK_MARKS } from '../../../utils/paddleTrackMarks';
|
||||
import { TrackPaddlePlanCards } from './TrackPaddlePlanCard';
|
||||
import type { TrackPaddlePlan } from './useTrackPaddlePlan';
|
||||
|
||||
const EMPTY = 'Nothing marked yet. The recorded track stays as it is.';
|
||||
|
||||
/** What the card says a click on the line does next, or why the last one placed nothing. */
|
||||
const HINT = {
|
||||
lock: 'Click the line where the lock is.',
|
||||
takeOut: 'Click the line where you get out.',
|
||||
reEntry: 'Now click where you put back in.',
|
||||
overlap: 'A portage is already marked there. Mark the new one beside it.',
|
||||
offLine: 'That was too far from the line. Click right on the track.',
|
||||
};
|
||||
|
||||
/** A time that is not known reads as an en dash; a planned total that falls short carries a real minus sign. */
|
||||
const DASH = '–';
|
||||
const MINUS = '−';
|
||||
|
||||
/** The recorded track is 6.5 km long. */
|
||||
const TOTAL_M = 6500;
|
||||
|
||||
const NO_SPEEDS: TrackSpeeds = { paddle: null, portage: null };
|
||||
|
||||
/** A portage from 2 to 2.5 km at the starting time out and in, and a lock at 4 km with both its times. */
|
||||
const MARKS: TrackMarks = {
|
||||
portages: [{ from_m: 2000, to_m: 2500, extra_minutes: null }],
|
||||
locks: [{ at_m: 4000, wait_minutes: 5, transit_minutes: 10 }],
|
||||
};
|
||||
|
||||
/** Two of each in order along the track: the second portage has its own time, the second lock none yet. */
|
||||
const TWO: TrackMarks = {
|
||||
portages: [
|
||||
{ from_m: 1000, to_m: 1300, extra_minutes: null },
|
||||
{ from_m: 3000, to_m: 3400, extra_minutes: 25 },
|
||||
],
|
||||
locks: [
|
||||
{ at_m: 2000, wait_minutes: 5, transit_minutes: 10 },
|
||||
{ at_m: 5000, wait_minutes: null, transit_minutes: null },
|
||||
],
|
||||
};
|
||||
|
||||
const TOUR: TourListItem = {
|
||||
place_id: 21,
|
||||
name: 'Recorded river run',
|
||||
tour_type: 'kayak',
|
||||
distance: 6.5,
|
||||
elevation_gain: null,
|
||||
elevation_loss: null,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: null,
|
||||
max_hiking_difficulty: null,
|
||||
planned: false,
|
||||
caution: false,
|
||||
boat_type: 'kayak',
|
||||
break_additional_minutes: 20,
|
||||
planned_duration_minutes: 120,
|
||||
track_marks: MARKS,
|
||||
};
|
||||
|
||||
/**
|
||||
* A plan as useTrackPaddlePlan hands it to the cards: an open paddle track the
|
||||
* viewer may edit, nothing picked, its layout and estimate reckoned from the
|
||||
* marks and speeds the way the hook reckons them, and every action a spy.
|
||||
*/
|
||||
function planOf({
|
||||
marks = NO_TRACK_MARKS,
|
||||
speeds = NO_SPEEDS,
|
||||
recommendedPaddleSpeed = recommendedPaddleSpeedKmh(TOUR.boat_type),
|
||||
...rest
|
||||
}: Partial<TrackPaddlePlan> = {}): TrackPaddlePlan {
|
||||
const layout = paddleLayoutOfTrack(TOTAL_M, marks);
|
||||
return {
|
||||
active: true,
|
||||
canEdit: true,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
pick: null,
|
||||
pendingFromM: null,
|
||||
refused: null,
|
||||
totalM: TOTAL_M,
|
||||
marks,
|
||||
speeds,
|
||||
layout,
|
||||
estimate: estimatePaddleTime(layout, {
|
||||
paddleSpeedKmh: speeds.paddle ?? recommendedPaddleSpeed,
|
||||
portageSpeedKmh: speeds.portage,
|
||||
breakMinutes: null,
|
||||
}),
|
||||
recommendedPaddleSpeed,
|
||||
defaultPortageSpeed: DEFAULT_PORTAGE_SPEED_KMH,
|
||||
startPick: vi.fn(),
|
||||
edits: { removePortage: vi.fn(), setPortageExtra: vi.fn(), setLock: vi.fn(), setSpeeds: vi.fn() },
|
||||
save: vi.fn(() => Promise.resolve()),
|
||||
discard: vi.fn(),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
/** The two cards for a plan, on the tour the plan belongs to. */
|
||||
function cards(plan: TrackPaddlePlan, tour: TourListItem = TOUR) {
|
||||
return <TrackPaddlePlanCards plan={plan} tour={tour} />;
|
||||
}
|
||||
|
||||
/** The cards on the shared store the map is clicked into, wired to it the way useTrackPaddlePlan wires them. */
|
||||
function StoreCards() {
|
||||
const state = useTrackMarkToolStore(
|
||||
useShallow((store) => ({
|
||||
marks: store.marks,
|
||||
speeds: store.speeds,
|
||||
dirty: store.dirty,
|
||||
pick: store.pick,
|
||||
pendingFromM: store.pendingFromM,
|
||||
refused: store.refused,
|
||||
}))
|
||||
);
|
||||
const plan = planOf({
|
||||
...state,
|
||||
startPick: (kind) => startTrackPick(state.pick === kind ? null : kind),
|
||||
edits: trackMarkEdits,
|
||||
});
|
||||
return cards(plan);
|
||||
}
|
||||
|
||||
/** Opens a portage or lock row to its fields. */
|
||||
function openRow(row: HTMLElement): void {
|
||||
fireEvent.click(within(row).getByRole('button', { expanded: false }));
|
||||
}
|
||||
|
||||
/** Types into a field and leaves it: the fields take their value on leaving. */
|
||||
function enter(input: HTMLElement, value: string): void {
|
||||
fireEvent.change(input, { target: { value } });
|
||||
fireEvent.blur(input);
|
||||
}
|
||||
|
||||
describe('TrackPaddlePlanCards', () => {
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-001: shows nothing while no recorded paddle track is open', () => {
|
||||
const { container } = render(cards(planOf({ active: false, marks: MARKS, dirty: true })));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-002: reckons the time with the portages and locks laid on, the breaks, and the planned total beside it', () => {
|
||||
render(cards(planOf({ marks: MARKS })));
|
||||
|
||||
expect(screen.getByRole('region', { name: 'Time estimate' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Portages and locks' })).toBeInTheDocument();
|
||||
// 6 km of water at the kayak's 4 km/h, 500 m carried at 2 km/h, 10 min out and in, a 15 min lock, 20 min of breaks.
|
||||
expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('2 h 30 min');
|
||||
const water = screen.getByTestId('paddle-time-water');
|
||||
expect(water).toHaveTextContent('6 km');
|
||||
expect(water).toHaveTextContent('1 h 30 min');
|
||||
expect(screen.getByTestId('paddle-time-water-speed')).toHaveAttribute('placeholder', '4');
|
||||
const carry = screen.getByTestId('paddle-time-carry');
|
||||
expect(carry).toHaveTextContent('500 m');
|
||||
expect(carry).toHaveTextContent('15 min');
|
||||
expect(screen.getByTestId('paddle-time-carry-speed')).toHaveAttribute('placeholder', '2');
|
||||
expect(screen.getByTestId('paddle-time-out-in')).toHaveTextContent('1×');
|
||||
expect(screen.getByTestId('paddle-time-out-in')).toHaveTextContent('10 min');
|
||||
expect(screen.getByTestId('paddle-time-locks')).toHaveTextContent('1×');
|
||||
expect(screen.getByTestId('paddle-time-locks')).toHaveTextContent('15 min');
|
||||
expect(screen.getByTestId('paddle-time-breaks')).toHaveTextContent('20 min');
|
||||
const planned = screen.getByTestId('paddle-time-planned');
|
||||
expect(planned).toHaveTextContent('Your planned total');
|
||||
expect(within(planned).getByText('2 h')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-time-deviation')).toHaveTextContent(`${MINUS}30 min`);
|
||||
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('paddle-route-strip-lock')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-003: without a paddling speed or lock times the estimate is incomplete and names what is missing', () => {
|
||||
const marks: TrackMarks = { portages: [], locks: [{ at_m: 3000, wait_minutes: null, transit_minutes: null }] };
|
||||
const tour = { ...TOUR, break_additional_minutes: null, planned_duration_minutes: null };
|
||||
render(cards(planOf({ marks, recommendedPaddleSpeed: null }), tour));
|
||||
|
||||
const missing = 'Without a paddling speed the time on the water stays open. One lock has no times yet.';
|
||||
expect(screen.getByText(missing)).toBeInTheDocument();
|
||||
expect(screen.getByText('Incomplete')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('0 min');
|
||||
const water = screen.getByTestId('paddle-time-water');
|
||||
expect(water).toHaveTextContent('6.5 km');
|
||||
expect(within(water).getByText(DASH)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-time-water-speed')).toHaveAttribute('placeholder', DASH);
|
||||
const locks = screen.getByTestId('paddle-time-locks');
|
||||
expect(locks).toHaveTextContent('1×');
|
||||
expect(within(locks).getByText(DASH)).toHaveClass('text-warning');
|
||||
expect(within(screen.getByTestId('paddle-time-breaks')).getByText(DASH)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-time-planned')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-004: the paddling and carrying speeds are typed in place, with either decimal separator, empty for the starting value', () => {
|
||||
const plan = planOf({ marks: MARKS, speeds: { paddle: 4.5, portage: null } });
|
||||
render(cards(plan));
|
||||
const paddle = screen.getByTestId('paddle-time-water-speed');
|
||||
const carry = screen.getByTestId('paddle-time-carry-speed');
|
||||
expect(paddle).toHaveValue('4.5');
|
||||
expect(screen.getByTestId('paddle-time-water')).toHaveTextContent('1 h 20 min');
|
||||
expect(carry).toHaveValue('');
|
||||
expect(carry).toHaveAttribute('placeholder', '2');
|
||||
|
||||
enter(paddle, '5,5');
|
||||
expect(plan.edits.setSpeeds).toHaveBeenCalledWith({ paddle: 5.5 });
|
||||
enter(carry, '3');
|
||||
expect(plan.edits.setSpeeds).toHaveBeenLastCalledWith({ portage: 3 });
|
||||
enter(paddle, '');
|
||||
expect(plan.edits.setSpeeds).toHaveBeenLastCalledWith({ paddle: null });
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-005: with nothing marked it says the track stays as recorded, and each tool asks for its own pick', () => {
|
||||
const plan = planOf();
|
||||
render(cards(plan));
|
||||
|
||||
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-planner-portage-leg')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('tour-planner-lock')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('paddle-time-carry')).not.toBeInTheDocument();
|
||||
for (const hint of Object.values(HINT)) expect(screen.queryByText(hint)).not.toBeInTheDocument();
|
||||
const portage = screen.getByTestId('track-marks-pick-portage');
|
||||
const lock = screen.getByTestId('track-marks-pick-lock');
|
||||
expect(portage).toHaveTextContent('Portage');
|
||||
expect(lock).toHaveTextContent('Lock');
|
||||
expect(portage).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(lock).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(portage);
|
||||
expect(plan.startPick).toHaveBeenCalledWith('portage');
|
||||
fireEvent.click(lock);
|
||||
expect(plan.startPick).toHaveBeenLastCalledWith('lock');
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-006: the tool that is on shows pressed and says what the next click on the line does', () => {
|
||||
const { rerender } = render(cards(planOf({ pick: 'lock' })));
|
||||
expect(screen.getByTestId('track-marks-pick-lock')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByTestId('track-marks-pick-portage')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByText(HINT.lock).parentElement).toHaveClass('bg-surface-card');
|
||||
expect(screen.getByText(HINT.lock).parentElement).not.toHaveClass('bg-warning-soft');
|
||||
|
||||
rerender(cards(planOf({ pick: 'portage' })));
|
||||
expect(screen.getByTestId('track-marks-pick-portage')).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText(HINT.takeOut)).toBeInTheDocument();
|
||||
expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument();
|
||||
|
||||
rerender(cards(planOf({ pick: 'portage', pendingFromM: 2000 })));
|
||||
expect(screen.getByText(HINT.reEntry)).toBeInTheDocument();
|
||||
expect(screen.queryByText(HINT.takeOut)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-007: a click that placed nothing says why in the warning tone, instead of what the tool does', () => {
|
||||
const { rerender } = render(cards(planOf({ pick: 'portage', refused: 'overlap' })));
|
||||
expect(screen.getByText(HINT.overlap).parentElement).toHaveClass('bg-warning-soft');
|
||||
expect(screen.queryByText(HINT.takeOut)).not.toBeInTheDocument();
|
||||
|
||||
rerender(cards(planOf({ pick: 'portage', refused: 'off_line' })));
|
||||
expect(screen.getByText(HINT.offLine).parentElement).toHaveClass('bg-warning-soft');
|
||||
expect(screen.queryByText(HINT.overlap)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-008: lists every portage and lock where it lies on the track, with its length and its times', () => {
|
||||
render(cards(planOf({ marks: TWO })));
|
||||
|
||||
expect(screen.queryByText(EMPTY)).not.toBeInTheDocument();
|
||||
const [near, far] = screen.getAllByTestId('tour-planner-portage-leg');
|
||||
expect(near).toHaveTextContent('at 1 km');
|
||||
expect(near).toHaveTextContent('300 m');
|
||||
expect(near).toHaveTextContent('+10 min');
|
||||
expect(far).toHaveTextContent('at 3 km');
|
||||
expect(far).toHaveTextContent('400 m');
|
||||
expect(far).toHaveTextContent('+25 min');
|
||||
const [timed, untimed] = screen.getAllByTestId('tour-planner-lock');
|
||||
expect(timed).toHaveTextContent('at 2 km');
|
||||
expect(timed).toHaveTextContent('15 min');
|
||||
expect(untimed).toHaveTextContent('at 5 km');
|
||||
expect(within(untimed).getByText('Time missing')).toHaveClass('text-warning');
|
||||
|
||||
openRow(near);
|
||||
expect(within(near).getByText('Starting value')).toBeInTheDocument();
|
||||
expect(within(near).getByTestId('tour-planner-portage-extra')).toHaveAttribute('placeholder', '10');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-009: a lock a portage carries round is dimmed, reads carried round, and its times do not count', () => {
|
||||
const marks: TrackMarks = {
|
||||
portages: [{ from_m: 2000, to_m: 2500, extra_minutes: null }],
|
||||
locks: [{ at_m: 2200, wait_minutes: null, transit_minutes: null }],
|
||||
};
|
||||
render(cards(planOf({ marks })));
|
||||
|
||||
const lock = screen.getByTestId('tour-planner-lock');
|
||||
expect(lock).toHaveTextContent('Carried round, the portage counts');
|
||||
expect(lock).not.toHaveTextContent('Time missing');
|
||||
expect(within(lock).getByRole('button', { expanded: false })).toHaveClass('opacity-60');
|
||||
expect(screen.queryByTestId('paddle-time-locks')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-010: a change or a removal goes to the mark of its own row, by its place in the list', () => {
|
||||
const plan = planOf({ marks: TWO });
|
||||
render(cards(plan));
|
||||
|
||||
const portage = screen.getAllByTestId('tour-planner-portage-leg')[1];
|
||||
openRow(portage);
|
||||
const extra = within(portage).getByTestId('tour-planner-portage-extra');
|
||||
enter(extra, '15');
|
||||
expect(plan.edits.setPortageExtra).toHaveBeenCalledWith(1, 15);
|
||||
enter(extra, '');
|
||||
expect(plan.edits.setPortageExtra).toHaveBeenLastCalledWith(1, null);
|
||||
fireEvent.click(within(portage).getByRole('button', { name: 'Remove the portage' }));
|
||||
expect(plan.edits.removePortage).toHaveBeenCalledWith(1);
|
||||
|
||||
const lock = screen.getAllByTestId('tour-planner-lock')[1];
|
||||
openRow(lock);
|
||||
enter(within(lock).getByTestId('tour-planner-lock-wait'), '8');
|
||||
expect(plan.edits.setLock).toHaveBeenCalledWith(1, { at_m: 5000, wait_minutes: 8, transit_minutes: null });
|
||||
fireEvent.click(within(lock).getByRole('button', { name: 'Remove the lock' }));
|
||||
expect(plan.edits.setLock).toHaveBeenLastCalledWith(1, null);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-011: says where a mark lies in the distance unit the user reads, metric without one', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ distance_unit: 'imperial' }) });
|
||||
render(cards(planOf({ marks: MARKS })));
|
||||
expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('at 1.2 mi');
|
||||
expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('0.3 mi');
|
||||
expect(screen.getByTestId('tour-planner-lock')).toHaveTextContent('at 2.5 mi');
|
||||
|
||||
act(() => seedStore(useSettingsStore, { settings: buildSettings() }));
|
||||
expect(screen.getByTestId('tour-planner-portage-leg')).toHaveTextContent('at 2 km');
|
||||
expect(screen.getByTestId('tour-planner-lock')).toHaveTextContent('at 4 km');
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-012: offers save and discard only once something changed, and hands each on', () => {
|
||||
const { rerender } = render(cards(planOf({ marks: MARKS })));
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Discard' })).not.toBeInTheDocument();
|
||||
|
||||
const plan = planOf({ marks: MARKS, dirty: true });
|
||||
rerender(cards(plan));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(plan.save).toHaveBeenCalledTimes(1);
|
||||
expect(plan.discard).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
||||
expect(plan.discard).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-013: while it saves, the tools, the fields and both buttons hold still and nothing can be removed', () => {
|
||||
const plan = planOf({ marks: MARKS, dirty: true, saving: true });
|
||||
render(cards(plan));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Discard' })).toBeDisabled();
|
||||
expect(screen.getByTestId('track-marks-pick-portage')).toBeDisabled();
|
||||
const pickLock = screen.getByTestId('track-marks-pick-lock');
|
||||
expect(pickLock).toBeDisabled();
|
||||
fireEvent.click(pickLock);
|
||||
expect(plan.startPick).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('paddle-time-water-speed')).toBeDisabled();
|
||||
expect(screen.getByTestId('paddle-time-carry-speed')).toBeDisabled();
|
||||
|
||||
const portage = screen.getByTestId('tour-planner-portage-leg');
|
||||
openRow(portage);
|
||||
expect(within(portage).getByTestId('tour-planner-portage-extra')).toBeDisabled();
|
||||
expect(within(portage).queryByRole('button', { name: 'Remove the portage' })).not.toBeInTheDocument();
|
||||
const lock = screen.getByTestId('tour-planner-lock');
|
||||
openRow(lock);
|
||||
expect(within(lock).queryByRole('button', { name: 'Remove the lock' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-014: a viewer who may not edit reads the marks and the estimate, with no tools, no save and nothing to change', () => {
|
||||
render(cards(planOf({ marks: MARKS, dirty: true, canEdit: false })));
|
||||
|
||||
expect(screen.queryByTestId('track-marks-pick-portage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('track-marks-pick-lock')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Discard' })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('paddle-time-estimate')).toHaveTextContent('2 h 30 min');
|
||||
expect(screen.getByTestId('paddle-time-water-speed')).toBeDisabled();
|
||||
expect(screen.getByTestId('paddle-time-carry-speed')).toBeDisabled();
|
||||
|
||||
const lock = screen.getByTestId('tour-planner-lock');
|
||||
openRow(lock);
|
||||
expect(within(lock).getByTestId('tour-planner-lock-wait')).toBeDisabled();
|
||||
expect(within(lock).getByTestId('tour-planner-lock-transit')).toBeDisabled();
|
||||
expect(within(lock).queryByRole('button', { name: 'Remove the lock' })).not.toBeInTheDocument();
|
||||
const portage = screen.getByTestId('tour-planner-portage-leg');
|
||||
openRow(portage);
|
||||
expect(within(portage).queryByRole('button', { name: 'Remove the portage' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-015: on the shared store, two clicks lay a portage, a refused click says why, and Remove takes it off', () => {
|
||||
loadTrackMarks(TOUR.place_id, null, NO_SPEEDS);
|
||||
render(<StoreCards />);
|
||||
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||
|
||||
// A second press on the tool that is on turns it off again.
|
||||
const pickLock = screen.getByTestId('track-marks-pick-lock');
|
||||
fireEvent.click(pickLock);
|
||||
expect(pickLock).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(pickLock);
|
||||
expect(pickLock).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument();
|
||||
|
||||
const pickPortage = screen.getByTestId('track-marks-pick-portage');
|
||||
fireEvent.click(pickPortage);
|
||||
expect(screen.getByText(HINT.takeOut)).toBeInTheDocument();
|
||||
act(() => pickOnTrack(1000));
|
||||
expect(screen.getByText(HINT.reEntry)).toBeInTheDocument();
|
||||
act(() => pickOnTrack(1500));
|
||||
const portage = screen.getByTestId('tour-planner-portage-leg');
|
||||
expect(portage).toHaveTextContent('at 1 km');
|
||||
expect(portage).toHaveTextContent('500 m');
|
||||
expect(pickPortage).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByTestId('paddle-time-carry')).toHaveTextContent('500 m');
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
|
||||
fireEvent.click(pickPortage);
|
||||
act(() => pickOnTrack(1200));
|
||||
act(() => pickOnTrack(1800));
|
||||
expect(screen.getByText(HINT.overlap)).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('tour-planner-portage-leg')).toHaveLength(1);
|
||||
act(() => refuseTrackPick());
|
||||
expect(screen.getByText(HINT.offLine)).toBeInTheDocument();
|
||||
|
||||
openRow(portage);
|
||||
fireEvent.click(within(portage).getByRole('button', { name: 'Remove the portage' }));
|
||||
expect(screen.queryByTestId('tour-planner-portage-leg')).not.toBeInTheDocument();
|
||||
expect(useTrackMarkToolStore.getState().marks.portages).toEqual([]);
|
||||
expect(screen.getByText(EMPTY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PADDLE-TRACKPLAN-016: on the shared store, a lock laid on the line misses its times until both are entered', () => {
|
||||
loadTrackMarks(TOUR.place_id, null, NO_SPEEDS);
|
||||
render(<StoreCards />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('track-marks-pick-lock'));
|
||||
expect(screen.getByText(HINT.lock)).toBeInTheDocument();
|
||||
act(() => pickOnTrack(3000.4));
|
||||
expect(screen.getByTestId('track-marks-pick-lock')).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.queryByText(HINT.lock)).not.toBeInTheDocument();
|
||||
const lock = screen.getByTestId('tour-planner-lock');
|
||||
expect(lock).toHaveTextContent('at 3 km');
|
||||
expect(lock).toHaveTextContent('Time missing');
|
||||
expect(screen.getByText('One lock has no times yet.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Incomplete')).toBeInTheDocument();
|
||||
|
||||
openRow(lock);
|
||||
enter(within(lock).getByTestId('tour-planner-lock-wait'), '5');
|
||||
enter(within(lock).getByTestId('tour-planner-lock-transit'), '10');
|
||||
expect(lock).toHaveTextContent('15 min');
|
||||
expect(lock).not.toHaveTextContent('Time missing');
|
||||
expect(screen.queryByText('One lock has no times yet.')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Incomplete')).not.toBeInTheDocument();
|
||||
const { marks } = useTrackMarkToolStore.getState();
|
||||
expect(marks.locks).toEqual([{ at_m: 3000, wait_minutes: 5, transit_minutes: 10 }]);
|
||||
|
||||
fireEvent.click(within(lock).getByRole('button', { name: 'Remove the lock' }));
|
||||
expect(screen.queryByTestId('tour-planner-lock')).not.toBeInTheDocument();
|
||||
expect(useTrackMarkToolStore.getState()).toMatchObject({ dirty: true, marks: { locks: [] } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { MousePointerClick, Plus, RotateCcw, Save } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { TrackPick } from '../../../store/trackMarkToolStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { DialogButton, fs } from '../../shared/DialogShell';
|
||||
import { RailCard, TourNotice } from '../tourParts';
|
||||
import { PaddleGlyph } from './PaddleGlyph';
|
||||
import { PaddleEstimateBody, PaddleTimeCardShell } from './PaddleTimeCard';
|
||||
import { LockRow, PortageLegRow } from './PaddleWaypointParts';
|
||||
import type { TrackPaddlePlan } from './useTrackPaddlePlan';
|
||||
|
||||
/** One of the two tools: pressed while a click on the line places its mark. */
|
||||
function PickButton({
|
||||
active,
|
||||
disabled,
|
||||
onPress,
|
||||
icon,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
icon: ReactNode;
|
||||
children: ReactNode;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={onPress}
|
||||
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 font-semibold transition-colors disabled:cursor-default disabled:opacity-40 ${active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint enabled:hover:text-content'}`}
|
||||
style={fs(11.5, 'body')}
|
||||
>
|
||||
<Plus size={12} strokeWidth={2.6} className="flex-none" />
|
||||
<span className="flex-none">{icon}</span>
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a click on the line does now, or why the last one placed nothing. */
|
||||
function pickHint(plan: TrackPaddlePlan, t: (key: string) => string): string | null {
|
||||
if (plan.refused === 'overlap') return t('tours.paddle.marks.overlap');
|
||||
if (plan.refused === 'off_line') return t('tours.paddle.marks.offLine');
|
||||
if (plan.pick === 'lock') return t('tours.paddle.marks.pickLock');
|
||||
if (plan.pick === 'portage')
|
||||
return t(plan.pendingFromM === null ? 'tours.paddle.marks.pickTakeOut' : 'tours.paddle.marks.pickReEntry');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The portages and locks laid onto a recorded paddle track: two tools to mark
|
||||
* them by clicking the line, the list of what is marked with its times, and
|
||||
* save once something changed. The recorded track is never redrawn.
|
||||
*/
|
||||
function TrackMarksCard({ plan }: { plan: TrackPaddlePlan }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
const at = (m: number) => t('tours.paddle.marks.at', { distance: formatDistance(m / 1000, unit) });
|
||||
const locked = !plan.canEdit || plan.saving;
|
||||
const hint = pickHint(plan, t);
|
||||
const pick = (kind: TrackPick) => () => plan.startPick(kind);
|
||||
const empty = plan.marks.portages.length === 0 && plan.marks.locks.length === 0;
|
||||
return (
|
||||
<RailCard icon={<PaddleGlyph kind="portage" size={14} />} title={t('tours.paddle.marks.title')}>
|
||||
<div data-testid="track-marks-card" className="flex flex-col gap-2.5">
|
||||
{plan.canEdit && (
|
||||
<div className="flex gap-1.5">
|
||||
<PickButton
|
||||
testId="track-marks-pick-portage"
|
||||
active={plan.pick === 'portage'}
|
||||
disabled={locked}
|
||||
onPress={pick('portage')}
|
||||
icon={<PaddleGlyph kind="portage" size={13} strokeWidth={2.2} />}
|
||||
>
|
||||
{t('tours.paddle.marks.markPortage')}
|
||||
</PickButton>
|
||||
<PickButton
|
||||
testId="track-marks-pick-lock"
|
||||
active={plan.pick === 'lock'}
|
||||
disabled={locked}
|
||||
onPress={pick('lock')}
|
||||
icon={<PaddleGlyph kind="lock" size={13} strokeWidth={2.2} />}
|
||||
>
|
||||
{t('tours.paddle.marks.markLock')}
|
||||
</PickButton>
|
||||
</div>
|
||||
)}
|
||||
{hint && (
|
||||
<TourNotice
|
||||
tone={plan.refused ? 'warning' : 'neutral'}
|
||||
icon={<MousePointerClick size={13} strokeWidth={2} />}
|
||||
>
|
||||
{hint}
|
||||
</TourNotice>
|
||||
)}
|
||||
{empty ? (
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{t('tours.paddle.marks.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="m-0 -ms-[34px] flex list-none flex-col p-0">
|
||||
{plan.marks.portages.map((portage, index) => (
|
||||
<PortageLegRow
|
||||
key={`p-${portage.from_m}`}
|
||||
lengthM={portage.to_m - portage.from_m}
|
||||
extraMinutes={portage.extra_minutes}
|
||||
disabled={locked}
|
||||
position={at(portage.from_m)}
|
||||
onExtraChange={(minutes) => plan.edits.setPortageExtra(index, minutes)}
|
||||
onRemove={() => plan.edits.removePortage(index)}
|
||||
/>
|
||||
))}
|
||||
{plan.marks.locks.map((lock, index) => (
|
||||
<LockRow
|
||||
key={`l-${lock.at_m}-${index}`}
|
||||
lock={lock}
|
||||
bypassed={!!plan.layout.locks.find((stop) => stop.atM === lock.at_m)?.bypassed}
|
||||
disabled={locked}
|
||||
position={at(lock.at_m)}
|
||||
onChange={(next) => plan.edits.setLock(index, next)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{plan.dirty && plan.canEdit && (
|
||||
<div className="grid grid-cols-[auto_1fr] gap-2 [&>button]:justify-center">
|
||||
<DialogButton icon={<RotateCcw size={14} strokeWidth={2} />} onClick={plan.discard} disabled={plan.saving}>
|
||||
{t('common.discard')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
icon={<Save size={14} strokeWidth={2} />}
|
||||
onClick={() => void plan.save()}
|
||||
disabled={plan.saving}
|
||||
>
|
||||
{t('common.save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-03 and PAD-07 on a recorded paddle track: its time estimate, reckoned
|
||||
* with the portages and locks laid onto it, and the card to lay them.
|
||||
*/
|
||||
export function TrackPaddlePlanCards({ plan, tour }: { plan: TrackPaddlePlan; tour: TourListItem }) {
|
||||
if (!plan.active) return null;
|
||||
const locked = !plan.canEdit || plan.saving;
|
||||
return (
|
||||
<>
|
||||
<PaddleTimeCardShell>
|
||||
<PaddleEstimateBody
|
||||
estimate={plan.estimate}
|
||||
layout={plan.layout}
|
||||
breakMinutes={tour.break_additional_minutes ?? null}
|
||||
planned={tour.planned_duration_minutes ?? null}
|
||||
locked={locked}
|
||||
portageSpeed={{
|
||||
value: plan.speeds.portage,
|
||||
startValue: plan.defaultPortageSpeed,
|
||||
onChange: (portage) => plan.edits.setSpeeds({ portage }),
|
||||
}}
|
||||
paddleSpeed={{
|
||||
value: plan.speeds.paddle,
|
||||
startValue: plan.recommendedPaddleSpeed,
|
||||
onChange: (paddle) => plan.edits.setSpeeds({ paddle }),
|
||||
}}
|
||||
/>
|
||||
</PaddleTimeCardShell>
|
||||
<TrackMarksCard plan={plan} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// PCM-001 to PCM-019: the check bench of a paddle tour in one pill, how the dates of the checklist read, where the
|
||||
// camera goes for an entry and the order the checklist lists its rows in (PAD-06, PAD-09).
|
||||
import { Ban, CircleDashed, ListChecks } from 'lucide-react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PaddleCheck, PaddleCheckData } from '@trek/shared';
|
||||
|
||||
import {
|
||||
earlierEntry,
|
||||
obstacleCheck,
|
||||
obstacleEntry,
|
||||
ownPortage,
|
||||
paddleBench,
|
||||
} from '../../../../tests/helpers/paddleCheck';
|
||||
import {
|
||||
checkDataWord,
|
||||
checklistRows,
|
||||
focusPointsOf,
|
||||
formatCheckDate,
|
||||
nextPendingEntry,
|
||||
paddleCheckFact,
|
||||
} from './paddleCheckModel';
|
||||
|
||||
/** The key as it is, with its values appended: a test reads which string and which count. */
|
||||
const t = (key: string, params?: Record<string, string | number>) => (params ? key + JSON.stringify(params) : key);
|
||||
|
||||
const fact = (bench: Partial<PaddleCheck>) => paddleCheckFact(paddleBench(bench), t);
|
||||
const lines = (bench: Partial<PaddleCheck>) => fact(bench).label.split('\n');
|
||||
|
||||
const NOT_CLEARANCE = 'tours.paddle.check.notClearance';
|
||||
|
||||
describe('paddleCheckFact', () => {
|
||||
it('PCM-001: missing data says so and never shows a count, whatever the bench counts', () => {
|
||||
for (const data of ['unavailable', 'none'] as const) {
|
||||
const shown = fact({ data, open: 2, review: 1, decided: 3, bans: 1 });
|
||||
expect(shown.value).toBe(`tours.paddle.check.data.${data}`);
|
||||
expect(shown.value).not.toContain('count');
|
||||
expect(shown.icon).toBe(CircleDashed);
|
||||
}
|
||||
expect(lines({ data: 'none', entries: 0, open: 0 })).toEqual([
|
||||
'tours.paddle.check.dataLabel: tours.paddle.check.data.none',
|
||||
NOT_CLEARANCE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('PCM-002: open entries and the ones to check again are counted together in the pill, apart in the label', () => {
|
||||
const shown = fact({ entries: 4, open: 2, review: 1, decided: 1 });
|
||||
expect(shown.value).toBe('tours.paddle.check.open{"count":3}');
|
||||
expect(shown.icon).toBe(ListChecks);
|
||||
expect(shown.label.split('\n')).toEqual([
|
||||
'tours.paddle.check.open{"count":2}',
|
||||
'tours.paddle.check.review{"count":1}',
|
||||
'tours.paddle.check.decided{"count":1}',
|
||||
NOT_CLEARANCE,
|
||||
]);
|
||||
expect(fact({ entries: 2, open: 0, review: 2 }).value).toBe('tours.paddle.check.open{"count":2}');
|
||||
});
|
||||
|
||||
it('PCM-003: open entries come before a partial coverage, and with nothing open the coverage word shows', () => {
|
||||
expect(fact({ data: 'partial', open: 1 }).value).toBe('tours.paddle.check.open{"count":1}');
|
||||
expect(fact({ data: 'partial', open: 0, decided: 1 }).value).toBe('tours.paddle.check.data.partial');
|
||||
expect(fact({ data: 'limited', open: 0, decided: 1 }).value).toBe('tours.paddle.check.data.limited');
|
||||
expect(lines({ data: 'limited', open: 0, decided: 1 })[0]).toBe(
|
||||
'tours.paddle.check.dataLabel: tours.paddle.check.data.limited'
|
||||
);
|
||||
});
|
||||
|
||||
it('PCM-004: a route without known obstacles says none are known, never that it is clear', () => {
|
||||
const shown = fact({ entries: 0, open: 0 });
|
||||
expect(shown.value).toBe('tours.paddle.check.noneKnown');
|
||||
expect(shown.icon).toBe(ListChecks);
|
||||
expect(shown.label).toBe(NOT_CLEARANCE);
|
||||
// Data that covers only part of the route says that first.
|
||||
expect(fact({ data: 'partial', entries: 0, open: 0 }).value).toBe('tours.paddle.check.data.partial');
|
||||
});
|
||||
|
||||
it('PCM-005: with every entry decided it counts the decisions', () => {
|
||||
const shown = fact({ entries: 3, open: 0, decided: 3 });
|
||||
expect(shown.value).toBe('tours.paddle.check.decided{"count":3}');
|
||||
expect(shown.icon).toBe(ListChecks);
|
||||
});
|
||||
|
||||
it('PCM-006: a ban on the route shows the ban icon, and the label says a decision does not lift it', () => {
|
||||
const shown = fact({ entries: 2, open: 0, decided: 2, bans: 1 });
|
||||
expect(shown.icon).toBe(Ban);
|
||||
expect(shown.value).toBe('tours.paddle.check.decided{"count":2}');
|
||||
expect(shown.label.split('\n')).toEqual([
|
||||
'tours.paddle.check.decided{"count":2}',
|
||||
'tours.paddle.check.bans{"count":1}',
|
||||
'tours.paddle.check.bansInfo',
|
||||
NOT_CLEARANCE,
|
||||
]);
|
||||
// Without data the missing data wins over the ban.
|
||||
expect(fact({ data: 'unavailable', bans: 1 }).icon).toBe(CircleDashed);
|
||||
});
|
||||
|
||||
it('PCM-007: the label always ends with the note that a checklist is no clearance, once', () => {
|
||||
const benches: Partial<PaddleCheck>[] = [
|
||||
{},
|
||||
{ data: 'none', entries: 0, open: 0 },
|
||||
{ data: 'unavailable' },
|
||||
{ data: 'partial', open: 0, review: 2, bans: 3 },
|
||||
{ entries: 0, open: 0 },
|
||||
{ open: 0, decided: 1, bans: 1 },
|
||||
];
|
||||
for (const bench of benches) {
|
||||
const parts = lines(bench);
|
||||
expect(parts[parts.length - 1]).toBe(NOT_CLEARANCE);
|
||||
expect(parts.filter((part) => part === NOT_CLEARANCE)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDataWord', () => {
|
||||
it('PCM-008: data that covers the route has no word, every other coverage its own', () => {
|
||||
expect(checkDataWord('covered', t)).toBeNull();
|
||||
const coverages: PaddleCheckData[] = ['partial', 'none', 'limited', 'unavailable'];
|
||||
for (const data of coverages) expect(checkDataWord(data, t)).toBe(`tours.paddle.check.data.${data}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCheckDate', () => {
|
||||
it('PCM-009: a bare month from the data reads as month and year in the language', () => {
|
||||
const may = new Date(Date.UTC(2026, 4, 1));
|
||||
const month = (language: string) =>
|
||||
new Intl.DateTimeFormat(language, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(may);
|
||||
expect(formatCheckDate('2026-05', 'en')).toBe(month('en'));
|
||||
expect(formatCheckDate('2026-05', 'en')).toMatch(/May.*2026/);
|
||||
expect(formatCheckDate('2026-05', 'de')).toBe(month('de'));
|
||||
});
|
||||
|
||||
it('PCM-010: a bare day is the calendar day it names, an instant the day it falls on here', () => {
|
||||
const day = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: 'UTC' }).format(
|
||||
new Date(Date.UTC(2026, 9, 1))
|
||||
);
|
||||
expect(formatCheckDate('2026-10-01', 'en')).toBe(day);
|
||||
expect(formatCheckDate('2026-10-01', 'en')).toMatch(/Oct.*1.*2026/);
|
||||
const instant = '2026-10-02T04:20:00.000Z';
|
||||
expect(formatCheckDate(instant, 'en')).toBe(
|
||||
new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date(instant))
|
||||
);
|
||||
});
|
||||
|
||||
it('PCM-011: a value that is no date, or a language Intl cannot read, comes back as written', () => {
|
||||
expect(formatCheckDate('last summer', 'en')).toBe('last summer');
|
||||
expect(formatCheckDate('', 'en')).toBe('');
|
||||
expect(formatCheckDate('2026-05', 'not a language')).toBe('2026-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusPointsOf', () => {
|
||||
it('PCM-012: an area small enough to frame whole gives its corners', () => {
|
||||
expect(focusPointsOf(obstacleEntry())).toEqual([
|
||||
[49.2, 12.005],
|
||||
[49.2, 12.005],
|
||||
]);
|
||||
expect(focusPointsOf(ownPortage())).toEqual([
|
||||
[49.2, 12.0045],
|
||||
[49.2001, 12.0058],
|
||||
]);
|
||||
// A lock of 1.1 km is still framed whole.
|
||||
expect(
|
||||
focusPointsOf({
|
||||
at: [49.205, 12],
|
||||
bounds: [
|
||||
[49.2, 12],
|
||||
[49.21, 12],
|
||||
],
|
||||
})
|
||||
).toEqual([
|
||||
[49.2, 12],
|
||||
[49.21, 12],
|
||||
]);
|
||||
});
|
||||
|
||||
it('PCM-013: an area too wide to frame goes to the point of the entry', () => {
|
||||
// About 3.4 km from corner to corner.
|
||||
expect(
|
||||
focusPointsOf({
|
||||
at: [49.215, 12.01],
|
||||
bounds: [
|
||||
[49.2, 12],
|
||||
[49.23, 12.01],
|
||||
],
|
||||
})
|
||||
).toEqual([[49.215, 12.01]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checklistRows', () => {
|
||||
it('PCM-014: lists the obstacles and the own portages and locks in route order, then the vanished ones, the earlier decisions apart', () => {
|
||||
const weir = obstacleEntry();
|
||||
const rapids = obstacleEntry({
|
||||
key: 'node/2',
|
||||
refs: ['node/2'],
|
||||
kind: 'rapids',
|
||||
relation: 'near',
|
||||
along_m: 300,
|
||||
distance_m: 40,
|
||||
});
|
||||
const dam = obstacleEntry({ key: 'node/3', refs: ['node/3'], kind: 'dam', along_m: 2600 });
|
||||
const gone = earlierEntry();
|
||||
const offRoute = earlierEntry({ key: 'node/10', refs: ['node/10'], status: 'off_route' });
|
||||
const portage = ownPortage();
|
||||
const lock = ownPortage({ kind: 'lock', index: 2, from_m: 2000, to_m: 2000, covers: [] });
|
||||
const check = obstacleCheck({ entries: [dam, weir, gone, offRoute, rapids], own: [lock, portage] });
|
||||
|
||||
expect(checklistRows(check)).toEqual({
|
||||
rows: [
|
||||
{ type: 'obstacle', entry: rapids },
|
||||
{ type: 'own', own: portage },
|
||||
{ type: 'obstacle', entry: weir },
|
||||
{ type: 'own', own: lock },
|
||||
{ type: 'obstacle', entry: dam },
|
||||
{ type: 'obstacle', entry: gone },
|
||||
],
|
||||
earlier: [offRoute],
|
||||
});
|
||||
});
|
||||
|
||||
it('PCM-015: an empty checklist has no rows and nothing earlier', () => {
|
||||
expect(checklistRows(obstacleCheck({ entries: [], own: [] }))).toEqual({ rows: [], earlier: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextPendingEntry', () => {
|
||||
const decision = { decision: 'pass' as const, note: null, decided_at: '2026-10-10T09:00:00.000Z', decided_by: 5 };
|
||||
const first = obstacleEntry({ along_m: 100 });
|
||||
const decided = obstacleEntry({ key: 'node/2', refs: ['node/2'], along_m: 500, status: 'decided', decision });
|
||||
const todo = obstacleEntry({
|
||||
key: 'node/3',
|
||||
refs: ['node/3'],
|
||||
along_m: 900,
|
||||
status: 'todo',
|
||||
decision: { ...decision, decision: 'portage' },
|
||||
});
|
||||
const review = obstacleEntry({
|
||||
key: 'node/4',
|
||||
refs: ['node/4'],
|
||||
along_m: 1300,
|
||||
status: 'review',
|
||||
changed: ['route'],
|
||||
decision,
|
||||
});
|
||||
const last = obstacleEntry({ key: 'node/5', refs: ['node/5'], along_m: 1700, status: 'decided', decision });
|
||||
const gone = earlierEntry();
|
||||
const offRoute = earlierEntry({ key: 'node/11', refs: ['node/11'], status: 'off_route' });
|
||||
// Listed out of route order: the next one is found by its metre, not by its place in the list.
|
||||
const check = obstacleCheck({ entries: [review, last, gone, offRoute, todo, first, decided] });
|
||||
|
||||
it('PCM-016: skips what is decided and takes the next one after the entry, one to check again counted', () => {
|
||||
expect(nextPendingEntry(check, first)).toEqual(todo);
|
||||
expect(nextPendingEntry(check, decided)).toEqual(todo);
|
||||
expect(nextPendingEntry(check, todo)).toEqual(review);
|
||||
});
|
||||
|
||||
it('PCM-017: from an earlier decision outside the rows the first pending entry of the route comes next', () => {
|
||||
expect(nextPendingEntry(check, offRoute)).toEqual(first);
|
||||
});
|
||||
|
||||
it('PCM-018: a vanished object, listed after the route, comes after its last entry, and after it there is none', () => {
|
||||
expect(nextPendingEntry(check, review)).toEqual(gone);
|
||||
expect(nextPendingEntry(check, last)).toEqual(gone);
|
||||
expect(nextPendingEntry(check, gone)).toBeNull();
|
||||
expect(nextPendingEntry(obstacleCheck({ entries: [first] }), first)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextPendingEntry at one metre', () => {
|
||||
it('PCM-019: two pending entries at the same metre follow each other in list order', () => {
|
||||
const first = obstacleEntry({ key: 'node/1', along_m: 1200 });
|
||||
const second = obstacleEntry({ key: 'node/2', refs: ['node/2'], along_m: 1200 });
|
||||
const check = obstacleCheck({ entries: [first, second] });
|
||||
expect(nextPendingEntry(check, first)?.key).toBe('node/2');
|
||||
expect(nextPendingEntry(check, second)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
haversineMetres,
|
||||
type PaddleCheck,
|
||||
type PaddleCheckData,
|
||||
type TourObstacleCheck,
|
||||
type TourObstacleEntry,
|
||||
type TourOwnEntry,
|
||||
} from '@trek/shared';
|
||||
import { Ban, CircleDashed, ListChecks, type LucideIcon } from 'lucide-react';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
/**
|
||||
* PAD-06 and PAD-09 on the client, without React: what the check bench of a
|
||||
* paddle tour says in one pill, how its dates read, where the camera goes for
|
||||
* an entry, and the order the checklist lists its rows in.
|
||||
*/
|
||||
|
||||
/** The word for data that does not cover the whole route; null when it does. */
|
||||
export function checkDataWord(data: PaddleCheckData, t: Translate): string | null {
|
||||
return data === 'covered' ? null : t(`tours.paddle.check.data.${data}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bench in one pill: the first rule that applies gives its value. Missing
|
||||
* data says so and never shows a count, open entries come before a partial
|
||||
* coverage, and a route without known obstacles says that, never "clear".
|
||||
*/
|
||||
export function paddleCheckFact(check: PaddleCheck, t: Translate): { icon: LucideIcon; value: string; label: string } {
|
||||
const pending = check.open + check.review;
|
||||
const word = checkDataWord(check.data, t);
|
||||
let value: string;
|
||||
if (check.data === 'unavailable' || check.data === 'none') value = word ?? '';
|
||||
else if (pending > 0) value = t('tours.paddle.check.open', { count: pending });
|
||||
else if (word) value = word;
|
||||
else if (check.entries === 0) value = t('tours.paddle.check.noneKnown');
|
||||
else value = t('tours.paddle.check.decided', { count: check.decided });
|
||||
const noData = check.data === 'unavailable' || check.data === 'none';
|
||||
const label = [
|
||||
word ? `${t('tours.paddle.check.dataLabel')}: ${word}` : null,
|
||||
check.open > 0 ? t('tours.paddle.check.open', { count: check.open }) : null,
|
||||
check.review > 0 ? t('tours.paddle.check.review', { count: check.review }) : null,
|
||||
check.decided > 0 ? t('tours.paddle.check.decided', { count: check.decided }) : null,
|
||||
check.bans > 0 ? t('tours.paddle.check.bans', { count: check.bans }) : null,
|
||||
check.bans > 0 ? t('tours.paddle.check.bansInfo') : null,
|
||||
t('tours.paddle.check.notClearance'),
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join('\n');
|
||||
return { icon: noData ? CircleDashed : check.bans > 0 ? Ban : ListChecks, value, label };
|
||||
}
|
||||
|
||||
const MONTH = /^(\d{4})-(\d{2})$/;
|
||||
|
||||
/** A date of the checklist in the user's locale: a bare `YYYY-MM` (an OSM check date) reads as month and year. */
|
||||
export function formatCheckDate(value: string, locale: string): string {
|
||||
const month = MONTH.exec(value);
|
||||
try {
|
||||
if (month) {
|
||||
const date = new Date(Date.UTC(Number(month[1]), Number(month[2]) - 1, 1));
|
||||
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(date);
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
// A bare day is a calendar day, not an instant in the user's zone.
|
||||
const timeZone = /^\d{4}-\d{2}-\d{2}$/.test(value) ? 'UTC' : undefined;
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** The widest an area may be for the camera to frame it whole; beyond, it goes to the entry's point. */
|
||||
const FRAME_MAX_M = 2000;
|
||||
|
||||
/** Where the camera goes for an entry or an own portage: its whole extent, or its point when that is too wide. */
|
||||
export function focusPointsOf(item: Pick<TourObstacleEntry, 'at' | 'bounds'>): [number, number][] {
|
||||
const [south, north] = item.bounds;
|
||||
const at: [number, number] = [Number(item.at[0]), Number(item.at[1])];
|
||||
const corners: [number, number][] = [
|
||||
[Number(south[0]), Number(south[1])],
|
||||
[Number(north[0]), Number(north[1])],
|
||||
];
|
||||
const diagonal = haversineMetres(corners[0][0], corners[0][1], corners[1][0], corners[1][1]);
|
||||
return diagonal <= FRAME_MAX_M ? corners : [at];
|
||||
}
|
||||
|
||||
export type ChecklistRow = { type: 'obstacle'; entry: TourObstacleEntry } | { type: 'own'; own: TourOwnEntry };
|
||||
|
||||
/**
|
||||
* The rows of a checklist in route order, the paddler's own portages and
|
||||
* locks among the obstacles at their metre, then the decisions about objects
|
||||
* the complete data no longer holds (they count as to check again, so they
|
||||
* stay in view). Apart from them the earlier decisions the route no longer
|
||||
* meets or the data cannot tell.
|
||||
*/
|
||||
export function checklistRows(check: TourObstacleCheck): { rows: ChecklistRow[]; earlier: TourObstacleEntry[] } {
|
||||
const metreOf = (row: ChecklistRow) => (row.type === 'obstacle' ? (row.entry.along_m ?? 0) : row.own.from_m);
|
||||
const onRoute: ChecklistRow[] = [
|
||||
...check.entries.filter((entry) => entry.relation !== null).map((entry) => ({ type: 'obstacle' as const, entry })),
|
||||
...check.own.map((own) => ({ type: 'own' as const, own })),
|
||||
].sort((a, b) => metreOf(a) - metreOf(b));
|
||||
const earlier = check.entries.filter((entry) => entry.relation === null);
|
||||
const gone = earlier.filter((entry) => entry.status === 'gone');
|
||||
return {
|
||||
rows: [...onRoute, ...gone.map((entry) => ({ type: 'obstacle' as const, entry }))],
|
||||
earlier: earlier.filter((entry) => entry.status !== 'gone'),
|
||||
};
|
||||
}
|
||||
|
||||
/** The statuses that still ask for a look, as the bench counts them (a vanished object among them). */
|
||||
const PENDING = new Set(['open', 'todo', 'review', 'gone']);
|
||||
|
||||
/**
|
||||
* The next entry still asking for a look, in the order the checklist lists
|
||||
* its rows (route order, entries at one metre in list order, then the
|
||||
* vanished ones); from an earlier decision outside the rows, the first one.
|
||||
*/
|
||||
export function nextPendingEntry(check: TourObstacleCheck, entry: TourObstacleEntry): TourObstacleEntry | null {
|
||||
const listed = checklistRows(check).rows.flatMap((row) => (row.type === 'obstacle' ? [row.entry] : []));
|
||||
const from = listed.findIndex((candidate) => candidate.key === entry.key);
|
||||
return listed.slice(from + 1).find((candidate) => PENDING.has(candidate.status)) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PaddleObstacleKind } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The paddler's symbols as SVG paths on lucide's 24 grid, one source for the
|
||||
* React glyph and the map markers (Leaflet takes markup, not components).
|
||||
*/
|
||||
export type PaddleGlyphKind =
|
||||
'lock' | 'weir' | 'dam' | 'rapids' | 'waterfall' | 'put_in' | 'take_out' | 'portage' | 'hazard' | 'access';
|
||||
|
||||
const WAVE = 'M3 18c1.5-1.5 3-1.5 4.5 0s3 1.5 4.5 0 3-1.5 4.5 0 3 1.5 4.5 0';
|
||||
|
||||
export const PADDLE_GLYPH_PATHS: Record<PaddleGlyphKind, readonly string[]> = {
|
||||
lock: ['M3 7h18', 'M3 17h18', 'M4 7l3 5-3 5', 'M20 7l-3 5 3 5', 'M9 12h6'],
|
||||
weir: ['M2 8h7c3 0 3 8 6 8h7', 'M9 8v12', 'M15 20h7'],
|
||||
dam: ['M9 4h5l5 16H5z', 'M2 9h5', 'M2 13h4'],
|
||||
rapids: [
|
||||
'M2 7l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5',
|
||||
'M2 12l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5',
|
||||
'M2 17l3 2 3-2 3 2 3-2 3 2 3-2 2 1.5',
|
||||
],
|
||||
waterfall: ['M3 5h9v14', 'M15 9v10', 'M19 7v12', 'M3 19h18'],
|
||||
put_in: ['M12 3v10', 'M8 9l4 4 4-4', WAVE],
|
||||
take_out: ['M12 14V4', 'M8 8l4-4 4 4', WAVE],
|
||||
// lucide's footprints
|
||||
portage: [
|
||||
'M4 16v-2.38C4 11.5 2.97 10.5 3 8c.03-2.72 1.49-6 4.5-6C9.37 2 10 3.8 10 5.5c0 3.11-2 5.66-2 8.68V16a2 2 0 1 1-4 0Z',
|
||||
'M20 20v-2.38c0-2.12 1.03-3.12 1-5.62-.03-2.72-1.49-6-4.5-6C14.63 6 14 7.8 14 9.5c0 3.11 2 5.66 2 8.68V20a2 2 0 1 0 4 0Z',
|
||||
'M16 17h4',
|
||||
'M4 13h4',
|
||||
],
|
||||
hazard: ['M12 3 2 20h20Z', 'M12 9v5', 'M12 17h.01'],
|
||||
// lucide's ban
|
||||
access: ['M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20Z', 'M4.929 4.929 19.07 19.071'],
|
||||
};
|
||||
|
||||
/** The symbol an obstacle of a kind is shown with. */
|
||||
export const OBSTACLE_GLYPH: Record<PaddleObstacleKind, PaddleGlyphKind> = {
|
||||
weir: 'weir',
|
||||
dam: 'dam',
|
||||
lock: 'lock',
|
||||
lock_gate: 'lock',
|
||||
waterfall: 'waterfall',
|
||||
rapids: 'rapids',
|
||||
hazard: 'hazard',
|
||||
put_in: 'put_in',
|
||||
slipway: 'put_in',
|
||||
egress: 'take_out',
|
||||
portage: 'portage',
|
||||
access: 'access',
|
||||
};
|
||||
|
||||
/** The symbol as SVG markup for a map marker; it carries no user content. */
|
||||
export function paddleGlyphSvg(kind: PaddleGlyphKind, size: number, color: string): string {
|
||||
const paths = PADDLE_GLYPH_PATHS[kind].map((d) => `<path d="${d}"/>`).join('');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// FE-PADDLE-MAP-001 to FE-PADDLE-MAP-017: the paddle objects as the planner maps draw them, one model for Leaflet and GL.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type PaddleFeature,
|
||||
type PaddleGeometry,
|
||||
type PaddleObstacleKind,
|
||||
paddleObstacleKindSchema,
|
||||
type RelatedObstacle,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { OBSTACLE_GLYPH, PADDLE_GLYPH_PATHS, paddleGlyphSvg } from './paddleGlyphPaths';
|
||||
import {
|
||||
isAreaOnly,
|
||||
isPaddleItemSelected,
|
||||
markerSize,
|
||||
PADDLE_AREA_MIN_ZOOM,
|
||||
paddleItemsAtZoom,
|
||||
type PaddleMapItem,
|
||||
paddleMapItems,
|
||||
type PaddleMapRelation,
|
||||
paddleMarkerHtml,
|
||||
paddleMarkerImageId,
|
||||
paddleMarkerSvg,
|
||||
selectedPaddleKey,
|
||||
} from './paddleMapLayer';
|
||||
import { paddleKindPaint, type PaddlePaint } from './paddlePaint';
|
||||
|
||||
const KINDS = paddleObstacleKindSchema.options;
|
||||
const RELATIONS: readonly PaddleMapRelation[] = ['on_route', 'near', 'area'];
|
||||
/** Test colours standing in for the theme tokens (styles/paddle.css), distinct so each can be told apart. */
|
||||
const PAINT: PaddlePaint = {
|
||||
obstacle: '#334155',
|
||||
access: '#4338ca',
|
||||
landing: '#0f766e',
|
||||
portage: '#9a6233',
|
||||
disc: '#ffffff',
|
||||
shadow: 'rgba(15, 23, 42, 0.35)',
|
||||
};
|
||||
const { obstacle: SLATE, access: INDIGO, landing: TEAL, portage: BROWN } = PAINT;
|
||||
|
||||
const point = (lng: number, lat: number): PaddleGeometry => ({ type: 'Point', coordinates: [lng, lat] });
|
||||
|
||||
function feature(ref: string, kind: PaddleObstacleKind, geometry: PaddleGeometry = point(12.1, 49.1)): PaddleFeature {
|
||||
return { ref, kind, group_key: null, name: null, geometry, tags: {}, tag_hash: ref, region_id: 'oberpfalz' };
|
||||
}
|
||||
|
||||
function related(entry: PaddleFeature, relation: RelatedObstacle['relation']): RelatedObstacle {
|
||||
return { feature: entry, relation, distanceM: relation === 'on_route' ? 0 : 80, alongM: 1200 };
|
||||
}
|
||||
|
||||
/** One object as the map draws it, standing to the route as `relation` says. */
|
||||
function item(relation: PaddleMapRelation, kind: PaddleObstacleKind = 'weir', ref = 'way/1'): PaddleMapItem {
|
||||
const entry = feature(ref, kind);
|
||||
const [made] = paddleMapItems([entry], relation === 'area' ? [] : [related(entry, relation)], PAINT);
|
||||
return made;
|
||||
}
|
||||
|
||||
/** The marker markup as Leaflet's divIcon puts it on the page. */
|
||||
function parseHtml(html: string): Element {
|
||||
const host = document.createElement('div');
|
||||
host.innerHTML = html;
|
||||
const marker = host.firstElementChild;
|
||||
if (!marker || host.children.length !== 1) throw new Error(`not one marker element: ${html}`);
|
||||
return marker;
|
||||
}
|
||||
|
||||
/** An image as a GL map decodes it: well-formed XML or nothing. */
|
||||
function parseSvg(svg: string): Element {
|
||||
const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement;
|
||||
if (root.localName !== 'svg') throw new Error(`not well-formed SVG: ${svg}`);
|
||||
return root;
|
||||
}
|
||||
|
||||
function pathsOf(svg: Element): Array<string | null> {
|
||||
return Array.from(svg.getElementsByTagName('path'), (path) => path.getAttribute('d'));
|
||||
}
|
||||
|
||||
function htmlLook(html: string) {
|
||||
const marker = parseHtml(html);
|
||||
const glyph = marker.querySelector('svg');
|
||||
return {
|
||||
style: marker.getAttribute('style') ?? '',
|
||||
glyph: glyph?.getAttribute('width') ?? null,
|
||||
stroke: glyph?.getAttribute('stroke') ?? null,
|
||||
paths: pathsOf(marker),
|
||||
};
|
||||
}
|
||||
|
||||
function svgLook(svg: string) {
|
||||
const root = parseSvg(svg);
|
||||
const disc = root.getElementsByTagName('circle').item(0);
|
||||
const glyph = root.getElementsByTagName('svg').item(0);
|
||||
return {
|
||||
size: root.getAttribute('width'),
|
||||
opacity: root.getAttribute('opacity'),
|
||||
r: disc?.getAttribute('r') ?? null,
|
||||
ring: disc?.getAttribute('stroke-width') ?? null,
|
||||
dash: disc?.getAttribute('stroke-dasharray') ?? null,
|
||||
glyph: glyph?.getAttribute('width') ?? null,
|
||||
x: glyph?.getAttribute('x') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('paddle map layer', () => {
|
||||
it('FE-PADDLE-MAP-001: sorts every kind into one of four distinct colours by what the object is', () => {
|
||||
const kindsIn = (color: string) => KINDS.filter((kind) => paddleKindPaint(kind, PAINT) === color);
|
||||
expect(kindsIn(SLATE)).toEqual(['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard']);
|
||||
expect(kindsIn(INDIGO)).toEqual(['access']);
|
||||
expect(kindsIn(TEAL)).toEqual(['put_in', 'egress', 'slipway']);
|
||||
expect(kindsIn(BROWN)).toEqual(['portage']);
|
||||
expect(new Set([SLATE, INDIGO, TEAL, BROWN]).size).toBe(4);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-002: draws every kind with a glyph of well-formed paths, sharing one where the paddler meets the same thing', () => {
|
||||
const glyphs = new Set(KINDS.map((kind) => OBSTACLE_GLYPH[kind]));
|
||||
expect(glyphs).toEqual(new Set(Object.keys(PADDLE_GLYPH_PATHS)));
|
||||
expect(OBSTACLE_GLYPH.lock_gate).toBe('lock');
|
||||
expect(OBSTACLE_GLYPH.slipway).toBe('put_in');
|
||||
expect(OBSTACLE_GLYPH.egress).toBe('take_out');
|
||||
for (const glyph of glyphs) {
|
||||
expect(PADDLE_GLYPH_PATHS[glyph].length, glyph).toBeGreaterThan(0);
|
||||
expect(pathsOf(parseSvg(paddleGlyphSvg(glyph, 24, SLATE))), glyph).toEqual(PADDLE_GLYPH_PATHS[glyph]);
|
||||
}
|
||||
for (const d of Object.values(PADDLE_GLYPH_PATHS).flat()) expect(d).toMatch(/^M/);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-003: places a point where it is, as the route relates it, and the rest of the area quietly', () => {
|
||||
const weir = feature('node/1', 'weir', point(12.1, 49.1));
|
||||
const slipway = feature('node/2', 'slipway', point(12.2, 49.2));
|
||||
const gate = feature('node/3', 'lock_gate', point(12.3, 49.3));
|
||||
const elsewhere = feature('node/4', 'dam', point(13.1, 50.1));
|
||||
const items = paddleMapItems(
|
||||
[weir, slipway, gate],
|
||||
[related(gate, 'near'), related(weir, 'on_route'), related(elsewhere, 'on_route')],
|
||||
PAINT
|
||||
);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
feature: weir,
|
||||
relation: 'on_route',
|
||||
color: SLATE,
|
||||
disc: PAINT.disc,
|
||||
shadow: PAINT.shadow,
|
||||
glyph: 'weir',
|
||||
marker: [49.1, 12.1],
|
||||
line: null,
|
||||
},
|
||||
{
|
||||
feature: slipway,
|
||||
relation: 'area',
|
||||
color: TEAL,
|
||||
disc: PAINT.disc,
|
||||
shadow: PAINT.shadow,
|
||||
glyph: 'put_in',
|
||||
marker: [49.2, 12.2],
|
||||
line: null,
|
||||
},
|
||||
{
|
||||
feature: gate,
|
||||
relation: 'near',
|
||||
color: SLATE,
|
||||
disc: PAINT.disc,
|
||||
shadow: PAINT.shadow,
|
||||
glyph: 'lock',
|
||||
marker: [49.3, 12.3],
|
||||
line: null,
|
||||
},
|
||||
]);
|
||||
expect(items[0].feature).toBe(weir);
|
||||
expect(paddleMapItems([], [related(weir, 'on_route')], PAINT)).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-004: turns a line into [lat, lng] pairs and sets its marker halfway along', () => {
|
||||
const weir = feature('way/1', 'weir', {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[12.1, 49.1],
|
||||
[12.2, 49.2],
|
||||
[12.3, 49.3],
|
||||
],
|
||||
});
|
||||
const carry = feature('way/2', 'portage', {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[12.1, 49.1],
|
||||
[12.2, 49.2],
|
||||
[12.3, 49.3],
|
||||
[12.4, 49.4],
|
||||
],
|
||||
});
|
||||
const [crossing, portage] = paddleMapItems([weir, carry], [], PAINT);
|
||||
expect(crossing.line).toEqual([
|
||||
[49.1, 12.1],
|
||||
[49.2, 12.2],
|
||||
[49.3, 12.3],
|
||||
]);
|
||||
expect(crossing.marker).toEqual([49.2, 12.2]);
|
||||
expect(portage.marker).toEqual([49.3, 12.3]);
|
||||
expect(portage).toMatchObject({ relation: 'area', color: BROWN, glyph: 'portage' });
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-005: outlines an area by its outer ring, and puts a shape that came without points at the origin', () => {
|
||||
const lake = feature('way/1', 'access', {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[12.1, 49.1],
|
||||
[12.2, 49.1],
|
||||
[12.2, 49.2],
|
||||
[12.1, 49.2],
|
||||
[12.1, 49.1],
|
||||
],
|
||||
[
|
||||
[12.14, 49.14],
|
||||
[12.16, 49.14],
|
||||
[12.16, 49.16],
|
||||
[12.14, 49.14],
|
||||
],
|
||||
],
|
||||
});
|
||||
const noRing = feature('way/2', 'access', { type: 'Polygon', coordinates: [] });
|
||||
const noLine = feature('way/3', 'weir', { type: 'LineString', coordinates: [] });
|
||||
const [area, ringless, lineless] = paddleMapItems([lake, noRing, noLine], [], PAINT);
|
||||
expect(area.line).toEqual([
|
||||
[49.1, 12.1],
|
||||
[49.1, 12.2],
|
||||
[49.2, 12.2],
|
||||
[49.2, 12.1],
|
||||
[49.1, 12.1],
|
||||
]);
|
||||
expect(area.marker).toEqual([49.2, 12.2]);
|
||||
expect(area.color).toBe(INDIGO);
|
||||
expect(ringless).toMatchObject({ line: [], marker: [0, 0] });
|
||||
expect(lineless).toMatchObject({ line: [], marker: [0, 0] });
|
||||
});
|
||||
|
||||
describe('at a zoom', () => {
|
||||
const onRoute = item('on_route', 'weir', 'way/1');
|
||||
const near = item('near', 'lock', 'way/2');
|
||||
const quiet = item('area', 'put_in', 'node/3');
|
||||
const picked = item('area', 'access', 'way/4');
|
||||
const all = [onRoute, near, quiet, picked];
|
||||
const refs = (items: readonly PaddleMapItem[]) => items.map((entry) => entry.feature.ref);
|
||||
|
||||
it('FE-PADDLE-MAP-006: counts only an unpicked object off the route as one of the quiet ones of the area', () => {
|
||||
expect(isAreaOnly(quiet, null)).toBe(true);
|
||||
expect(isAreaOnly(quiet, 'way/4')).toBe(true);
|
||||
expect(isAreaOnly(picked, 'way/4')).toBe(false);
|
||||
expect(isAreaOnly(onRoute, null)).toBe(false);
|
||||
expect(isAreaOnly(near, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-007: keeps an overview to the route and the picked object, and shows the whole area from the threshold on', () => {
|
||||
expect(refs(paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM - 1, null))).toEqual(['way/1', 'way/2']);
|
||||
expect(refs(paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM - 0.5, 'way/4'))).toEqual(['way/1', 'way/2', 'way/4']);
|
||||
const zoomedIn = paddleItemsAtZoom(all, PADDLE_AREA_MIN_ZOOM, null);
|
||||
expect(refs(zoomedIn)).toEqual(['way/1', 'way/2', 'node/3', 'way/4']);
|
||||
expect(zoomedIn).not.toBe(all);
|
||||
expect(paddleItemsAtZoom([], PADDLE_AREA_MIN_ZOOM - 1, 'way/4')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-008: sizes a marker by how it stands to the route, and a picked one largest of all', () => {
|
||||
expect(RELATIONS.map((relation) => markerSize(relation, false))).toEqual([30, 26, 22]);
|
||||
expect(RELATIONS.map((relation) => markerSize(relation, true))).toEqual([36, 36, 36]);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-009: draws a Leaflet marker solid on the route, dashed near it and faded in the area', () => {
|
||||
const onRoute = htmlLook(paddleMarkerHtml(item('on_route'), false));
|
||||
expect(onRoute.style).toContain('width:30px;height:30px;');
|
||||
expect(onRoute.style).toContain(`border:2px solid ${SLATE};`);
|
||||
expect(onRoute.style).toMatch(/opacity:1$/);
|
||||
expect(onRoute).toMatchObject({ glyph: '17', stroke: SLATE, paths: PADDLE_GLYPH_PATHS.weir });
|
||||
|
||||
const near = htmlLook(paddleMarkerHtml(item('near', 'lock'), false));
|
||||
expect(near.style).toContain('width:26px;height:26px;');
|
||||
expect(near.style).toContain(`border:2px dashed ${SLATE};`);
|
||||
expect(near.style).toMatch(/opacity:1$/);
|
||||
expect(near).toMatchObject({ glyph: '15', stroke: SLATE, paths: PADDLE_GLYPH_PATHS.lock });
|
||||
|
||||
const quiet = htmlLook(paddleMarkerHtml(item('area', 'put_in'), false));
|
||||
expect(quiet.style).toContain('width:22px;height:22px;');
|
||||
expect(quiet.style).toContain(`border:2px solid ${TEAL};`);
|
||||
expect(quiet.style).toMatch(/opacity:0\.6$/);
|
||||
expect(quiet).toMatchObject({ glyph: '13', stroke: TEAL, paths: PADDLE_GLYPH_PATHS.put_in });
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-010: draws a picked Leaflet marker larger, with a heavier solid ring and at full strength', () => {
|
||||
for (const relation of RELATIONS) {
|
||||
const picked = htmlLook(paddleMarkerHtml(item(relation, 'access'), true));
|
||||
expect(picked.style, relation).toContain('width:36px;height:36px;');
|
||||
expect(picked.style, relation).toContain(`border:3px solid ${INDIGO};`);
|
||||
expect(picked.style, relation).toMatch(/opacity:1$/);
|
||||
expect(picked.glyph, relation).toBe('21');
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-011: draws the GL image of a marker at twice its size, the ring dashed near the route and faded in the area', () => {
|
||||
const onRoute = svgLook(paddleMarkerSvg(item('on_route'), false));
|
||||
const near = svgLook(paddleMarkerSvg(item('near', 'lock'), false));
|
||||
const quiet = svgLook(paddleMarkerSvg(item('area', 'put_in'), false));
|
||||
expect(onRoute).toEqual({ size: '60', opacity: '1', r: '26', ring: '4', dash: null, glyph: '35', x: '12.5' });
|
||||
expect(near).toEqual({ size: '52', opacity: '1', r: '22', ring: '4', dash: '6 5', glyph: '30', x: '11' });
|
||||
expect(quiet).toEqual({ size: '44', opacity: '0.6', r: '18', ring: '4', dash: null, glyph: '26', x: '9' });
|
||||
|
||||
const weir = parseSvg(paddleMarkerSvg(item('on_route'), false));
|
||||
expect(weir.namespaceURI).toBe('http://www.w3.org/2000/svg');
|
||||
expect(weir.getElementsByTagName('circle').item(0)?.getAttribute('stroke')).toBe(SLATE);
|
||||
expect(weir.getElementsByTagName('svg').item(0)?.getAttribute('stroke')).toBe(SLATE);
|
||||
expect(pathsOf(weir)).toEqual(PADDLE_GLYPH_PATHS.weir);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-012: draws a picked GL image larger and solid, every look at twice the size of its Leaflet twin', () => {
|
||||
const picked = { size: '72', opacity: '1', r: '31', ring: '6', dash: null, glyph: '42', x: '15' };
|
||||
for (const relation of RELATIONS) {
|
||||
expect(svgLook(paddleMarkerSvg(item(relation), true)), relation).toEqual(picked);
|
||||
for (const selected of [false, true]) {
|
||||
const { size } = svgLook(paddleMarkerSvg(item(relation), selected));
|
||||
expect(size, relation).toBe(String(markerSize(relation, selected) * 2));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-013: names one GL image per look, which two kinds that look alike share', () => {
|
||||
expect(paddleMarkerImageId(item('on_route'), false)).toBe('trek-paddle-weir-334155ffffff-on_route');
|
||||
expect(paddleMarkerImageId(item('on_route'), true)).toBe('trek-paddle-weir-334155ffffff-on_route-sel');
|
||||
expect(paddleMarkerImageId(item('near', 'egress'), false)).toBe('trek-paddle-take_out-0f766effffff-near');
|
||||
|
||||
const slipway = item('area', 'slipway');
|
||||
const putIn = item('area', 'put_in', 'node/9');
|
||||
expect(paddleMarkerImageId(slipway, false)).toBe(paddleMarkerImageId(putIn, false));
|
||||
expect(paddleMarkerSvg(slipway, false)).toBe(paddleMarkerSvg(putIn, false));
|
||||
|
||||
const looks = RELATIONS.flatMap((relation) =>
|
||||
[false, true].map((selected) => paddleMarkerImageId(item(relation), selected))
|
||||
);
|
||||
expect(new Set(looks).size).toBe(6);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-014: renders a glyph as standalone SVG in the size and colour it is asked for', () => {
|
||||
const glyph = parseSvg(paddleGlyphSvg('lock', 20, INDIGO));
|
||||
expect(glyph.getAttribute('width')).toBe('20');
|
||||
expect(glyph.getAttribute('height')).toBe('20');
|
||||
expect(glyph.getAttribute('viewBox')).toBe('0 0 24 24');
|
||||
expect(glyph.getAttribute('fill')).toBe('none');
|
||||
expect(glyph.getAttribute('stroke')).toBe(INDIGO);
|
||||
expect(pathsOf(glyph)).toEqual(PADDLE_GLYPH_PATHS.lock);
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-015: hands the map no part of the object data, so a hostile name in OSM cannot reach the markup', () => {
|
||||
const hostile = '<img src=x onerror=alert(1)>';
|
||||
const entry = { ...feature('way/1', 'weir'), name: hostile, tags: { name: hostile, note: hostile } };
|
||||
const [drawn] = paddleMapItems([entry], [related(entry, 'near')], PAINT);
|
||||
const markup = [false, true].flatMap((selected) => [
|
||||
paddleMarkerHtml(drawn, selected),
|
||||
paddleMarkerSvg(drawn, selected),
|
||||
paddleMarkerImageId(drawn, selected),
|
||||
]);
|
||||
for (const text of markup) expect(text).not.toContain('onerror');
|
||||
expect(parseHtml(paddleMarkerHtml(drawn, true)).querySelector('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the selected lock (PAD-06)', () => {
|
||||
const chamber = { ...feature('way/7', 'lock'), group_key: 'lock:node/3' };
|
||||
const gate = { ...feature('node/3', 'lock_gate'), group_key: 'lock:node/3' };
|
||||
const weir = feature('node/9', 'weir');
|
||||
const items = paddleMapItems([chamber, gate, weir], [], PAINT);
|
||||
|
||||
it('FE-PADDLE-MAP-016: the key of the selected object is its lock, or its own ref, and nothing without a selection', () => {
|
||||
expect(selectedPaddleKey([chamber, gate, weir], 'way/7')).toBe('lock:node/3');
|
||||
expect(selectedPaddleKey([chamber, gate, weir], 'node/9')).toBe('node/9');
|
||||
expect(selectedPaddleKey([chamber, gate, weir], 'node/404')).toBeNull();
|
||||
expect(selectedPaddleKey([chamber, gate, weir], null)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PADDLE-MAP-017: every part of a selected lock shows as selected, another object does not', () => {
|
||||
const selected = items.filter((item) => isPaddleItemSelected(item, 'way/7', 'lock:node/3'));
|
||||
expect(selected.map((item) => item.feature.ref)).toEqual(['way/7', 'node/3']);
|
||||
expect(items.filter((item) => isPaddleItemSelected(item, 'node/9', 'node/9')).map((i) => i.feature.ref)).toEqual([
|
||||
'node/9',
|
||||
]);
|
||||
expect(items.some((item) => isPaddleItemSelected(item, null, null))).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user