mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 52ed63715c | |||
| 1d7758b819 | |||
| b33727ec75 | |||
| 97b5f36ef0 | |||
| 845979902c | |||
| a0884685ef | |||
| 485e360c64 | |||
| 356df90095 | |||
| cd1b1410bd | |||
| 3d9095ccde | |||
| 018d2d1425 | |||
| cab742e641 | |||
| 11f23997e8 | |||
| f1c437b8b8 | |||
| f690065de8 | |||
| 782e613506 | |||
| 31ffcf1228 |
@@ -1,25 +1,25 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 404,
|
||||
"@typescript-eslint/no-misused-promises": 876,
|
||||
"@typescript-eslint/no-misused-promises": 875,
|
||||
"no-console": 88,
|
||||
"complexity": 230,
|
||||
"@typescript-eslint/no-explicit-any": 148,
|
||||
"@typescript-eslint/no-explicit-any": 147,
|
||||
"trek/no-swallowed-catch": 133,
|
||||
"max-params": 35,
|
||||
"trek/store-without-selector": 69,
|
||||
"react-hooks/refs": 561,
|
||||
"react-hooks/refs": 559,
|
||||
"react-hooks/set-state-in-effect": 197,
|
||||
"@typescript-eslint/no-floating-promises": 167,
|
||||
"react-hooks/exhaustive-deps": 121,
|
||||
"@typescript-eslint/no-unused-vars": 155,
|
||||
"@typescript-eslint/no-unused-vars": 130,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 133,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/preserve-manual-memoization": 15,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 42,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 46,
|
||||
"unused-disable-directive": 6,
|
||||
"max-depth": 21,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 491,
|
||||
"@typescript-eslint/no-non-null-assertion": 1949,
|
||||
"@typescript-eslint/no-non-null-assertion": 1946,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -717,12 +717,17 @@
|
||||
"hooks": ["src/components/Tours/tourPresentation.ts"],
|
||||
"desktop": [
|
||||
"src/components/Tours/TourDetailDialog.tsx",
|
||||
"src/components/Tours/ToursSidebar.tsx",
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx",
|
||||
"src/components/Tours/tourParts.tsx"
|
||||
],
|
||||
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Tours list facets",
|
||||
"hooks": ["src/components/Tours/useTourListFilters.ts"],
|
||||
"desktop": ["src/components/Tours/ToursSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/places/MPlacesBrowser.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Transport booking detail view: reservationFiles",
|
||||
"hooks": ["src/utils/reservationFiles.ts"],
|
||||
|
||||
@@ -1028,7 +1028,6 @@
|
||||
"src/components/Todo/useTodoList.ts",
|
||||
"src/components/Tours/PlacesToursModeSwitch.tsx",
|
||||
"src/components/Tours/TourDetailDialog.test.tsx",
|
||||
"src/components/Tours/TourDetailDialog.tsx",
|
||||
"src/components/Tours/TourListRow.tsx",
|
||||
"src/components/Tours/ToursSidebar.test.tsx",
|
||||
"src/components/Tours/ToursSidebar.tsx",
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
|
||||
"src/pages/JourneyDetailPage.tsx",
|
||||
"src/pages/OAuthAuthorizePage.tsx",
|
||||
"src/pages/TripPlannerPage.tsx",
|
||||
"src/pages/admin/AdminSettingsTab.tsx",
|
||||
"src/pages/admin/useAdmin.ts",
|
||||
"src/pages/admin/useNotificationDefaults.ts",
|
||||
|
||||
@@ -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": 1450,
|
||||
"src/components/Map/MapView.tsx": 1409,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.tsx": 2414,
|
||||
"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": 1153,
|
||||
"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,
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"src/components/Todo/todoListModel.ts": 1,
|
||||
"src/components/Tours/planner/TourPlannerPanels.tsx": 2,
|
||||
"src/components/Tours/planner/useTourPlanner.ts": 2,
|
||||
"src/components/Tours/tourParts.tsx": 8,
|
||||
"src/components/Trips/TripFormModal.tsx": 3,
|
||||
"src/components/Trips/TripMembersModal.tsx": 1,
|
||||
"src/components/Vacay/VacayCalendar.tsx": 1,
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"components/Map/LocationButton.tsx": 1,
|
||||
"components/Map/MapPluginMarkers.tsx": 2,
|
||||
"components/Map/MapTogglePill.tsx": 1,
|
||||
"components/Map/MapView.tsx": 10,
|
||||
"components/Map/MapView.tsx": 8,
|
||||
"components/Map/MapViewGL.tsx": 11,
|
||||
"components/Map/NightPauseTooltip.tsx": 1,
|
||||
"components/Map/PlaceHoverCard.tsx": 1,
|
||||
@@ -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,8 @@
|
||||
import type { TourDetailResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** An import that was planned over in place keeps its recorded track; this brings it back. */
|
||||
export const recordedTrackApi = {
|
||||
restore: (tripId: number | string, placeId: number | string): Promise<TourDetailResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/${placeId}/restore-recorded`).then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TourCopyRequest, TourCreateResponse } from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/** P2C-07: a tour of the trip saved again as a new, independent tour. */
|
||||
export const tourCopiesApi = {
|
||||
copy: (tripId: number | string, placeId: number | string, body: TourCopyRequest): Promise<TourCreateResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/${placeId}/copy`, body).then((r) => 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,53 @@
|
||||
import type {
|
||||
AdminFeatureToggleResponse,
|
||||
TourSourceCheckResponse,
|
||||
WandererConnection,
|
||||
WandererImportPreview,
|
||||
WandererImportRequest,
|
||||
WandererImportResponse,
|
||||
WandererSaveResponse,
|
||||
WandererSearchRequest,
|
||||
WandererSearchResponse,
|
||||
WandererSettingsRequest,
|
||||
WandererTestRequest,
|
||||
WandererTestResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* How long the browser waits for a call that makes the server ask wanderer.
|
||||
* A preview reads the trail's index entry, its record and its GPX one after
|
||||
* another, each allowed 15 s on the server, so the shared 8 s would give up
|
||||
* while the server carries on.
|
||||
*/
|
||||
const WANDERER_UPSTREAM_TIMEOUT_MS = 60_000;
|
||||
|
||||
const upstream = { timeout: WANDERER_UPSTREAM_TIMEOUT_MS };
|
||||
|
||||
export const wandererApi = {
|
||||
getSettings: (): Promise<WandererConnection> => apiClient.get('/integrations/wanderer/settings').then((r) => r.data),
|
||||
saveSettings: (data: WandererSettingsRequest): Promise<WandererSaveResponse> =>
|
||||
apiClient.put('/integrations/wanderer/settings', data).then((r) => r.data),
|
||||
disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data),
|
||||
test: (data: WandererTestRequest): Promise<WandererTestResponse> =>
|
||||
apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data),
|
||||
|
||||
search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise<WandererSearchResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data),
|
||||
preview: (tripId: number, trailId: string, signal?: AbortSignal): Promise<WandererImportPreview> =>
|
||||
apiClient
|
||||
.get(`/trips/${tripId}/tours/wanderer/trails/${encodeURIComponent(trailId)}`, { ...upstream, signal })
|
||||
.then((r) => r.data),
|
||||
importTrail: (tripId: number, body: WandererImportRequest): Promise<WandererImportResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/import`, body, upstream).then((r) => r.data),
|
||||
/** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */
|
||||
checkSource: (tripId: number, placeId: number): Promise<TourSourceCheckResponse> =>
|
||||
apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data),
|
||||
};
|
||||
|
||||
/** The admin's switch for wanderer under the Tours addon. */
|
||||
export const wandererAdminApi = {
|
||||
get: (): Promise<AdminFeatureToggleResponse> => apiClient.get('/admin/tours-wanderer').then((r) => r.data),
|
||||
update: (enabled: boolean): Promise<AdminFeatureToggleResponse> =>
|
||||
apiClient.put('/admin/tours-wanderer', { enabled }).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)),
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-039
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
@@ -56,6 +55,12 @@ function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
/** A shelf of more than one row starts folded; open every one so its rows can be read. */
|
||||
async function unfoldShelves(): Promise<void> {
|
||||
const bars = await screen.findAllByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
for (const bar of bars) await userEvent.click(bar);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -72,7 +77,8 @@ beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: false } });
|
||||
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
|
||||
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/admin/tours-wanderer', () => HttpResponse.json({ enabled: false }))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -228,6 +234,7 @@ describe('AddonManager', () => {
|
||||
)
|
||||
);
|
||||
render(<AddonManager />);
|
||||
await unfoldShelves();
|
||||
|
||||
// Provider sub-rows are visible under Journey addon
|
||||
await screen.findByText('Unsplash');
|
||||
@@ -293,6 +300,7 @@ describe('AddonManager', () => {
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
@@ -427,6 +435,8 @@ describe('AddonManager', () => {
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Paperless-ngx');
|
||||
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
|
||||
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
|
||||
@@ -524,6 +534,8 @@ describe('AddonManager', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await unfoldShelves();
|
||||
|
||||
await screen.findByText('Chat');
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Polls')).toBeInTheDocument();
|
||||
@@ -551,8 +563,9 @@ describe('AddonManager', () => {
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>,
|
||||
/>
|
||||
);
|
||||
await unfoldShelves();
|
||||
|
||||
const links = await screen.findByText('Links');
|
||||
expect(links).toHaveAttribute('title');
|
||||
@@ -839,4 +852,89 @@ describe('AddonManager', () => {
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
it('FE-ADMIN-ADDON-040: a shelf of several options starts folded and says how many there are and how many are on', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: false, links: true, polls: false, whatsnext: false }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const bar = await screen.findByRole('button', { name: /5 options/, expanded: false });
|
||||
expect(bar).toHaveTextContent('2 on');
|
||||
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(bar);
|
||||
expect(bar).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText('Chat')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(bar);
|
||||
expect(screen.queryByText('Chat')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-041: a shelf of one option shows it at once, without a fold', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Lists', icon: 'ListChecks', enabled: true })]));
|
||||
render(<AddonManager bagTrackingEnabled onToggleBagTracking={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Bag Tracking' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /\d+ options?/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-042: wanderer hangs off Tours and saves its switch', async () => {
|
||||
const puts: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
http.put('/api/admin/tours-wanderer', async ({ request }) => {
|
||||
puts.push(await request.json());
|
||||
return HttpResponse.json({ enabled: true });
|
||||
})
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<AddonManager />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
|
||||
const toggle = await screen.findByRole('button', { name: 'wanderer' });
|
||||
expect(isOn(toggle)).toBe(false);
|
||||
await userEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ enabled: true }]));
|
||||
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
http.put('/api/admin/tours-wanderer', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))
|
||||
);
|
||||
render(
|
||||
<>
|
||||
<AddonManager />
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'wanderer' }));
|
||||
|
||||
await waitFor(() => expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(false));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-044: Tours and Road trip show their own icons, not the fallback puzzle', async () => {
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: false }),
|
||||
buildAddon({ id: 'roadtrip', name: 'Road trip', icon: 'Route', enabled: false }),
|
||||
])
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
const tours = (await screen.findByText('Tours')).closest('article') as HTMLElement;
|
||||
const roadtrip = screen.getByText('Road trip').closest('article') as HTMLElement;
|
||||
expect(tours.querySelector('svg.lucide-mountain')).toBeInTheDocument();
|
||||
expect(roadtrip.querySelector('svg.lucide-car-front')).toBeInTheDocument();
|
||||
expect(tours.querySelector('svg.lucide-puzzle')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, type ComponentType } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2, RefreshCw, Mountain, CarFront } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { asLlmVision, LLM_VISION_MODES, type LlmVision } from '@trek/shared'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -18,12 +18,16 @@ import { type Addon, type CollabFeatures, ANTHROPIC_HINT, COLLAB_SUB_FEATURES, g
|
||||
import { useAddonManager } from './addons/useAddonManager'
|
||||
import { useLlmParsingConfig } from './addons/useLlmParsingConfig'
|
||||
import { ImmichIcon, SynologyIcon } from './addons/PhotoProviderIcons'
|
||||
import WandererMark from '../Tours/wanderer/WandererMark'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
Mountain,
|
||||
// Road trip was seeded as 'Route'; the car says what it is better than a line does.
|
||||
Route: CarFront,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
@@ -71,6 +75,8 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const dark = useIsDark()
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
} = useAddonManager()
|
||||
|
||||
if (loading) {
|
||||
@@ -147,6 +153,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
)
|
||||
})
|
||||
}
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return (
|
||||
<AddonSubRow
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Children, type ReactNode, isValidElement, useId, useState } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/** How many of the shelf's rows are switched on, read from the rows' own `enabled`. */
|
||||
function enabledRows(rows: ReactNode[]): number {
|
||||
return rows.filter((row) => isValidElement<{ enabled?: boolean }>(row) && row.props.enabled === true).length
|
||||
}
|
||||
|
||||
/**
|
||||
* One addon in its type's card: a row like the bag sidebar's (#2541), with the
|
||||
@@ -14,6 +21,10 @@ import { fs } from '../shared/DialogShell'
|
||||
*
|
||||
* The row is not a click target, only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*
|
||||
* A shelf of more than one row starts folded behind a bar that says how many
|
||||
* options there are and how many are on, so a page of addons stays short and
|
||||
* the options are still plain to see.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
icon,
|
||||
@@ -31,6 +42,13 @@ export default function AddonTile({
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const shelfId = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const rows = Children.toArray(children)
|
||||
const foldable = rows.length > 1
|
||||
const active = enabledRows(rows)
|
||||
|
||||
return (
|
||||
<article className="px-3.5 py-3 transition-colors duration-150 focus-within:bg-surface-secondary">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -62,11 +80,40 @@ export default function AddonTile({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
{rows.length > 0 && (
|
||||
<div className="mt-3 overflow-hidden rounded-[12px] border border-edge-faint bg-surface-secondary">
|
||||
{foldable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-controls={shelfId}
|
||||
className={`flex min-h-[44px] w-full items-center gap-2.5 px-3 py-1.5 text-start transition-colors hover:bg-surface-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${open ? 'border-b border-edge-faint' : ''}`}
|
||||
>
|
||||
{/* The same slot and type as a row below, so the bar reads as one more row. */}
|
||||
<span className="grid h-6 w-6 flex-none place-items-center text-content-muted">
|
||||
<ChevronDown
|
||||
size={15}
|
||||
strokeWidth={2.2}
|
||||
className={`transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12.5, 'body')}>
|
||||
{t('admin.addons.subOptions', { count: rows.length })}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-none rounded-full px-2 py-0.5 font-semibold ${active > 0 ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}`}
|
||||
style={fs(10.5, 'body')}
|
||||
>
|
||||
{t('admin.addons.subOptionsOn', { count: active })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it: no rail, no indent guides. */}
|
||||
<ul className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
{(!foldable || open) && (
|
||||
<ul id={shelfId} className="m-0 list-none divide-y divide-edge-faint p-0">{children}</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../../api/client';
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
@@ -18,6 +19,7 @@ export function useAddonManager() {
|
||||
const refreshGlobalAddons = useAddonStore((s) => s.loadAddons);
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
|
||||
const loadAddons = async () => {
|
||||
try {
|
||||
@@ -30,6 +32,10 @@ export function useAddonManager() {
|
||||
|
||||
const loadOnMount = useEffectEvent(() => {
|
||||
void loadAddons().finally(() => setLoading(false));
|
||||
wandererAdminApi
|
||||
.get()
|
||||
.then((flag) => setToursWanderer(flag.enabled))
|
||||
.catch(() => toast.error(t('admin.addons.toast.error')));
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOnMount();
|
||||
@@ -57,6 +63,21 @@ export function useAddonManager() {
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
/** wanderer under Tours: flipped at once, put back when the save fails. */
|
||||
const toggleToursWanderer = async () => {
|
||||
const next = !toursWanderer;
|
||||
setToursWanderer(next);
|
||||
try {
|
||||
await wandererAdminApi.update(next);
|
||||
} catch {
|
||||
setToursWanderer(!next);
|
||||
toast.error(t('admin.addons.toast.error'));
|
||||
return;
|
||||
}
|
||||
void refreshGlobalAddons();
|
||||
toast.success(t('admin.addons.toast.updated'));
|
||||
};
|
||||
|
||||
const asOption = (provider: Addon): ProviderOption => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
@@ -83,5 +104,7 @@ export function useAddonManager() {
|
||||
integrationAddons,
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
toggleToursWanderer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,13 @@ export function useTripFerratas(
|
||||
);
|
||||
// The tiles a tour's line runs through load with the layer off too, for the warning
|
||||
// that a route climbs a ferrata; only the layer's own state decides what it shows.
|
||||
const routeKey = routeTiles && routeTiles.length > 0 ? routeTiles.map(ferrataTileKey).sort().join('|') : null;
|
||||
const routeKey =
|
||||
routeTiles && routeTiles.length > 0
|
||||
? routeTiles
|
||||
.map(ferrataTileKey)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join('|')
|
||||
: null;
|
||||
const layerOn = enabled && !!store;
|
||||
const active = layerOn || (!!routeKey && !!store);
|
||||
const fits = !!view && ferrataBboxFits(view);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -61,12 +60,14 @@ import type { DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { poiDetailRowKey, poiDetailRows, poiPinCacheKey, poiPinParts } from './poiMarker'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { SelectedTrackLine, TrackLines } from './TrackLines'
|
||||
import DawarichTrailLayer from './DawarichTrailLayer'
|
||||
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, OPENTOPOMAP_TILE_URL, OPENTOPOMAP_TILE_ATTRIBUTION, OPENTOPOMAP_TILE_MAXZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { 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'
|
||||
@@ -830,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)
|
||||
@@ -1034,55 +1035,11 @@ export const MapView = memo(function MapView({
|
||||
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
|
||||
}), [places])
|
||||
|
||||
// Keeps the click handler out of the polyline memo below: `handleMarkerClick`
|
||||
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
|
||||
// changes on every selection, and depending on it would redraw all tracks.
|
||||
const markerClickRef = useRef(handleMarkerClick)
|
||||
markerClickRef.current = handleMarkerClick
|
||||
|
||||
const gpxPolylines = useMemo(() => (
|
||||
<>
|
||||
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
|
||||
insertion order, so drawing them inline would put the casing of a track
|
||||
added later — or of one just given a colour — on top of an earlier
|
||||
track's line. Always rendered, hidden via opacity when there is no
|
||||
colour, so toggling one never remounts the path. */}
|
||||
{gpxTracks.map(({ place, coords, cased }) => (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}-casing`}
|
||||
positions={coords}
|
||||
pane={hasCasingPane ? TRACK_CASING_PANE : undefined}
|
||||
pathOptions={{ color: '#ffffff', weight: 6.5, opacity: cased ? 0.7 : 0, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* pathOptions, not bare color/weight props: react-leaflet only calls
|
||||
setStyle when the pathOptions reference changes, so bare props would
|
||||
stick at their mount-time colour and a repaint would never arrive. */}
|
||||
{gpxTracks.map(({ place, coords, cased, color }) => (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
|
||||
not a target, least of all on touch, and the start markers cluster below
|
||||
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
|
||||
default (markers do not), and the map's own click handler clears the
|
||||
selection this one just made. */}
|
||||
{gpxTracks.map(({ place, coords }) => (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}-hit`}
|
||||
positions={coords}
|
||||
pathOptions={{ color: '#000', weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
|
||||
bubblingMouseEvents={false}
|
||||
eventHandlers={{ click: () => markerClickRef.current(place.id) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
), [gpxTracks, hasCasingPane])
|
||||
|
||||
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
|
||||
&& (!hoveredPlace.routeVia || routeVias.includes(hoveredPlace.routeVia))
|
||||
|
||||
@@ -1312,7 +1269,8 @@ export const MapView = memo(function MapView({
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
<TrackLines tracks={gpxTracks} casingPane={hasCasingPane ? TRACK_CASING_PANE : undefined} selectRef={markerClickRef} />
|
||||
<SelectedTrackLine tracks={gpxTracks} selectedPlaceId={selectedPlaceId} />
|
||||
|
||||
{routeProfileFocus && (
|
||||
<CircleMarker
|
||||
@@ -1333,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
|
||||
@@ -1381,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'
|
||||
@@ -41,11 +42,10 @@ import PlaceHoverCard from './PlaceHoverCard'
|
||||
import { ratingBadgeHtml } from './ratingBadge'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { poiPinParts } from './poiMarker'
|
||||
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry'
|
||||
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'
|
||||
|
||||
@@ -66,26 +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'
|
||||
const GPX_HIT_LAYER_ID = 'trip-gpx-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 }
|
||||
|
||||
@@ -203,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
|
||||
@@ -626,7 +553,7 @@ export function MapViewGL({
|
||||
onPlannerWaypointClick,
|
||||
onPlannerWaypointMove,
|
||||
routeProfileFocus = null,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool,
|
||||
viewBaseLayer, onViewBaseLayerChange, onToggleTerrain3d, ferrataLayer, placeTool, tourLayers,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
@@ -685,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
|
||||
@@ -1345,40 +1272,8 @@ export function MapViewGL({
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// Casing under the tracks that carry a picked colour (#776) — keeps them
|
||||
// legible on satellite and dark styles. Untouched tracks are filtered
|
||||
// out, so they look exactly as they did before.
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-casing',
|
||||
type: 'line',
|
||||
source: 'trip-gpx',
|
||||
filter: ['==', ['get', 'cased'], true],
|
||||
paint: { 'line-color': '#ffffff', 'line-width': 6.5, 'line-opacity': 0.7 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-line',
|
||||
type: 'line',
|
||||
source: 'trip-gpx',
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
||||
'line-width': 3.5,
|
||||
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// Invisible fat line that catches the click — 3.5px is not a target,
|
||||
// and the start markers cluster below zoom 11, so without this a track
|
||||
// is unreachable at the very zoom where you compare walks side by side.
|
||||
map.addLayer({
|
||||
id: GPX_HIT_LAYER_ID,
|
||||
type: 'line',
|
||||
source: 'trip-gpx',
|
||||
paint: { 'line-color': '#000', 'line-width': 14, 'line-opacity': 0 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
if (!hasGpxTrackLayers(map)) {
|
||||
addGpxTrackLayers(map)
|
||||
const selectTrack = (e: any) => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// A click on a cluster bubble sitting over a track belongs to the
|
||||
// cluster (zoom-to-expand), not to the line underneath it.
|
||||
@@ -1534,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
|
||||
@@ -2075,24 +1970,15 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = places.flatMap(place => {
|
||||
const coords = parseRenderableRouteGeometry(place.route_geometry)
|
||||
if (!coords) return []
|
||||
return [{
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
color: resolveTrackColor(place),
|
||||
cased: hasManualTrackColor(place),
|
||||
place_id: place.id,
|
||||
},
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
}]
|
||||
})
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
setGpxTracks(map, places)
|
||||
}, [places, mapReady])
|
||||
|
||||
// The picked tour's track stands out (PR1-08).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (map && mapReady) selectGpxTrack(map, selectedPlaceId)
|
||||
}, [selectedPlaceId, mapReady])
|
||||
|
||||
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
|
||||
// circle arcs for flights/cruises, straight lines for trains/cars,
|
||||
// clickable endpoint badges, rotating mid-arc stats label for flights.
|
||||
@@ -2354,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,109 @@
|
||||
import { memo, type RefObject } from 'react';
|
||||
import { Polyline } from 'react-leaflet';
|
||||
import type { Place } from '../../types';
|
||||
import { TRACK_CASING_COLOR } from './trackColors';
|
||||
|
||||
/** A place's track as the Leaflet map draws it: its points, its colour, and whether it carries a rim. */
|
||||
export interface TrackLine {
|
||||
place: Place;
|
||||
coords: [number, number][];
|
||||
cased: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface TrackLinesProps {
|
||||
tracks: readonly TrackLine[];
|
||||
/** The pane the rims go into, below the lines; none while the map has not made it yet. */
|
||||
casingPane?: string;
|
||||
/** Read on click, so a new selection handler never redraws every track. */
|
||||
selectRef: RefObject<(placeId: number) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every track of the map: the rims, the lines and an invisible fat line on top
|
||||
* that takes the click. Memoised on the tracks alone; a selection changes
|
||||
* nothing here, the picked track is drawn by {@link SelectedTrackLine}.
|
||||
*/
|
||||
export const TrackLines = memo(function TrackLines({ tracks, casingPane, selectRef }: TrackLinesProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Casings live in their own pane below the lines. Leaflet stacks paths by
|
||||
insertion order, so drawing them inline would put the casing of a track
|
||||
added later — or of one just given a colour — on top of an earlier
|
||||
track's line. Always rendered, hidden via opacity when there is no
|
||||
colour, so toggling one never remounts the path. */}
|
||||
{tracks.map(({ place, coords, cased }) => (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}-casing`}
|
||||
positions={coords}
|
||||
pane={casingPane}
|
||||
pathOptions={{
|
||||
color: TRACK_CASING_COLOR,
|
||||
weight: 6.5,
|
||||
opacity: cased ? 0.7 : 0,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* pathOptions, not bare color/weight props: react-leaflet only calls
|
||||
setStyle when the pathOptions reference changes, so bare props would
|
||||
stick at their mount-time colour and a repaint would never arrive. */}
|
||||
{tracks.map(({ place, coords, cased, color }) => (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
pathOptions={{ color, weight: 3.5, opacity: cased ? 0.9 : 0.75 }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
{/* Invisible fat line on top so the track can actually be hit — 3.5px is
|
||||
not a target, least of all on touch, and the start markers cluster below
|
||||
zoom 11. bubblingMouseEvents is essential: paths bubble to the map by
|
||||
default (markers do not), and the map's own click handler clears the
|
||||
selection this one just made. */}
|
||||
{tracks.map(({ place, coords }) => (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}-hit`}
|
||||
positions={coords}
|
||||
pathOptions={{ color: TRACK_CASING_COLOR, weight: 14, opacity: 0, lineCap: 'round', lineJoin: 'round' }}
|
||||
bubblingMouseEvents={false}
|
||||
eventHandlers={{ click: () => selectRef.current?.(place.id) }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* PR1-08: the track of the picked tour, wider and with a light rim on top of
|
||||
* the others, so the line and the list's row read as one. It takes no click:
|
||||
* the hit line below still does.
|
||||
*/
|
||||
export function SelectedTrackLine({
|
||||
tracks,
|
||||
selectedPlaceId,
|
||||
}: {
|
||||
tracks: readonly TrackLine[];
|
||||
selectedPlaceId: number | null;
|
||||
}) {
|
||||
const track = selectedPlaceId == null ? null : tracks.find((item) => item.place.id === selectedPlaceId);
|
||||
if (!track) return null;
|
||||
return (
|
||||
<>
|
||||
<Polyline
|
||||
key={`gpx-selected-${track.place.id}-casing`}
|
||||
positions={track.coords}
|
||||
pathOptions={{ color: TRACK_CASING_COLOR, weight: 9, opacity: 0.95, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
<Polyline
|
||||
key={`gpx-selected-${track.place.id}`}
|
||||
positions={track.coords}
|
||||
pathOptions={{ color: track.color, weight: 5.5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Place } from '../../types';
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers';
|
||||
|
||||
function fakeMap() {
|
||||
const layers = new Map<string, { filter?: unknown }>();
|
||||
const sources = new Map<string, { setData: ReturnType<typeof vi.fn> }>();
|
||||
const map = {
|
||||
addSource: vi.fn((id: string) => sources.set(id, { setData: vi.fn() })),
|
||||
getSource: (id: string) => sources.get(id),
|
||||
addLayer: vi.fn((layer: { id: string; filter?: unknown }) => layers.set(layer.id, { filter: layer.filter })),
|
||||
getLayer: (id: string) => layers.get(id),
|
||||
setFilter: vi.fn((id: string, filter: unknown) => layers.set(id, { filter })),
|
||||
};
|
||||
return { map: map as unknown as mapboxgl.Map, layers, sources, raw: map };
|
||||
}
|
||||
|
||||
describe('gpxTrackLayers (PR1-08)', () => {
|
||||
it('adds the rim, the line, the picked track and the hit line once, picking nothing yet', () => {
|
||||
const { map, layers } = fakeMap();
|
||||
expect(hasGpxTrackLayers(map)).toBe(false);
|
||||
addGpxTrackLayers(map);
|
||||
expect(hasGpxTrackLayers(map)).toBe(true);
|
||||
expect([...layers.keys()]).toEqual([
|
||||
'trip-gpx-casing',
|
||||
'trip-gpx-line',
|
||||
'trip-gpx-selected-casing',
|
||||
'trip-gpx-selected',
|
||||
GPX_HIT_LAYER_ID,
|
||||
]);
|
||||
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
|
||||
});
|
||||
|
||||
it('puts only places with a drawable track into the source, with their colour and rim', () => {
|
||||
const { map, sources } = fakeMap();
|
||||
addGpxTrackLayers(map);
|
||||
const track = { id: 5, route_geometry: '[[47,11],[47.1,11.1]]', route_color: '#ff0000' } as Place;
|
||||
const plain = { id: 6, route_geometry: null } as Place;
|
||||
setGpxTracks(map, [track, plain]);
|
||||
const data = sources.get('trip-gpx')?.setData.mock.calls[0]?.[0];
|
||||
expect(data.features).toHaveLength(1);
|
||||
expect(data.features[0].properties).toEqual({ color: '#ff0000', cased: true, place_id: 5 });
|
||||
expect(data.features[0].geometry.coordinates).toEqual([
|
||||
[11, 47],
|
||||
[11.1, 47.1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('points the picked layers at the selected place, and at none again when nothing is picked', () => {
|
||||
const { map, layers } = fakeMap();
|
||||
addGpxTrackLayers(map);
|
||||
selectGpxTrack(map, 5);
|
||||
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
|
||||
expect(layers.get('trip-gpx-selected-casing')?.filter).toEqual(['==', ['get', 'place_id'], 5]);
|
||||
selectGpxTrack(map, null);
|
||||
expect(layers.get('trip-gpx-selected')?.filter).toEqual(['==', ['get', 'place_id'], -1]);
|
||||
});
|
||||
|
||||
it('does nothing before the style has its layers', () => {
|
||||
const { map, raw } = fakeMap();
|
||||
selectGpxTrack(map, 5);
|
||||
setGpxTracks(map, []);
|
||||
expect(raw.setFilter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { TRACK_COLOR_FALLBACK } from '@trek/shared';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
import type { Place } from '../../types';
|
||||
import { parseRenderableRouteGeometry } from '../../utils/routeGeometry';
|
||||
import { hasManualTrackColor, resolveTrackColor, TRACK_CASING_COLOR } from './trackColors';
|
||||
|
||||
const SOURCE_ID = 'trip-gpx';
|
||||
/** The invisible fat line that takes a click on a track. */
|
||||
export const GPX_HIT_LAYER_ID = 'trip-gpx-hit';
|
||||
const SELECTED_CASING_LAYER_ID = 'trip-gpx-selected-casing';
|
||||
const SELECTED_LAYER_ID = 'trip-gpx-selected';
|
||||
|
||||
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
|
||||
const NO_TRACK = ['==', ['get', 'place_id'], -1] as mapboxgl.FilterSpecification;
|
||||
|
||||
/**
|
||||
* The tracks of the vector map, as the Leaflet map draws them (TrackLines): a
|
||||
* rim under the tracks that carry a picked colour, the lines, the picked
|
||||
* track on top wider with a light rim (PR1-08), and an invisible fat line that
|
||||
* takes the click. Added once with the style; whoever adds it wires the click.
|
||||
*/
|
||||
export function addGpxTrackLayers(map: mapboxgl.Map): void {
|
||||
map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
// Casing under the tracks that carry a picked colour (#776): legible on satellite and dark styles.
|
||||
// Untouched tracks are filtered out, so they look exactly as they did before.
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-casing',
|
||||
type: 'line',
|
||||
source: SOURCE_ID,
|
||||
filter: ['==', ['get', 'cased'], true],
|
||||
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 6.5, 'line-opacity': 0.7 },
|
||||
layout: ROUND,
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-line',
|
||||
type: 'line',
|
||||
source: SOURCE_ID,
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK],
|
||||
'line-width': 3.5,
|
||||
'line-opacity': ['case', ['==', ['get', 'cased'], true], 0.9, 0.75],
|
||||
},
|
||||
layout: ROUND,
|
||||
});
|
||||
// The filter follows the selection (selectGpxTrack); until something is picked it matches no track.
|
||||
map.addLayer({
|
||||
id: SELECTED_CASING_LAYER_ID,
|
||||
type: 'line',
|
||||
source: SOURCE_ID,
|
||||
filter: NO_TRACK,
|
||||
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 9, 'line-opacity': 0.95 },
|
||||
layout: ROUND,
|
||||
});
|
||||
map.addLayer({
|
||||
id: SELECTED_LAYER_ID,
|
||||
type: 'line',
|
||||
source: SOURCE_ID,
|
||||
filter: NO_TRACK,
|
||||
paint: { 'line-color': ['coalesce', ['get', 'color'], TRACK_COLOR_FALLBACK], 'line-width': 5.5 },
|
||||
layout: ROUND,
|
||||
});
|
||||
// 3.5px is not a target, and the start markers cluster below zoom 11, so without this a track
|
||||
// is unreachable at the very zoom where you compare walks side by side.
|
||||
map.addLayer({
|
||||
id: GPX_HIT_LAYER_ID,
|
||||
type: 'line',
|
||||
source: SOURCE_ID,
|
||||
paint: { 'line-color': TRACK_CASING_COLOR, 'line-width': 14, 'line-opacity': 0 },
|
||||
layout: ROUND,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the track layers are there yet (they come with the style). */
|
||||
export function hasGpxTrackLayers(map: mapboxgl.Map): boolean {
|
||||
return !!map.getSource(SOURCE_ID);
|
||||
}
|
||||
|
||||
/** The places' tracks into the source, each with its colour and whether it carries a rim. */
|
||||
export function setGpxTracks(map: mapboxgl.Map, places: readonly Place[]): void {
|
||||
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
const features = places.flatMap((place) => {
|
||||
const coords = parseRenderableRouteGeometry(place.route_geometry);
|
||||
if (!coords) return [];
|
||||
return [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties: { color: resolveTrackColor(place), cased: hasManualTrackColor(place), place_id: place.id },
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
},
|
||||
];
|
||||
});
|
||||
src.setData({ type: 'FeatureCollection', features });
|
||||
}
|
||||
|
||||
/** PR1-08: the picked place's track stands out; none picked, none does. */
|
||||
export function selectGpxTrack(map: mapboxgl.Map, placeId: number | null): void {
|
||||
if (typeof map.getLayer !== 'function') return;
|
||||
const filter = ['==', ['get', 'place_id'], placeId ?? -1] as mapboxgl.FilterSpecification;
|
||||
for (const id of [SELECTED_CASING_LAYER_ID, SELECTED_LAYER_ID]) {
|
||||
if (map.getLayer(id)) map.setFilter(id, filter);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -18,18 +18,32 @@
|
||||
* both end up as a real DOM node, which is all HTML5 drag needs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Start dragging `placeId` towards the day plan, from any row or marker that holds a
|
||||
* place (a tour is one too). Every source goes through here, so the day plan cannot
|
||||
* tell them apart.
|
||||
*/
|
||||
export function startPlaceDrag(transfer: DataTransfer | null, placeId: number): void {
|
||||
transfer?.setData('placeId', String(placeId))
|
||||
if (transfer) transfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(placeId) }
|
||||
}
|
||||
|
||||
/** The drag `startPlaceDrag` began has ended, dropped or not. */
|
||||
export function endPlaceDrag(): void {
|
||||
window.__dragData = null
|
||||
}
|
||||
|
||||
/** Wire a marker element up as a drag source for `placeId`. */
|
||||
export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => void {
|
||||
el.setAttribute('draggable', 'true')
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setData('placeId', String(placeId))
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(placeId) }
|
||||
startPlaceDrag(e.dataTransfer, placeId)
|
||||
el.classList.add('marker-dragging')
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
window.__dragData = null
|
||||
endPlaceDrag()
|
||||
el.classList.remove('marker-dragging')
|
||||
}
|
||||
// The map pans on mousedown anywhere on its surface, and a marker sits on that
|
||||
|
||||
@@ -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,6 +1,9 @@
|
||||
import { TRACK_COLOR_FALLBACK } from '@trek/shared'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
/** The light rim under a track that carries a picked colour, and under the track picked on the map. */
|
||||
export const TRACK_CASING_COLOR = '#ffffff'
|
||||
|
||||
/**
|
||||
* The colour a track's polyline is drawn in (#776): a manually picked colour
|
||||
* wins, then the category colour, then the blue every imported track used to
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { TourListItem } from '@trek/shared';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import '../../styles/fadeEnd.css';
|
||||
import type { Category, Place } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import TourDayRowFacts from '../Tours/TourDayRowFacts';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { TimePill } from './planParts';
|
||||
|
||||
@@ -34,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>
|
||||
)}
|
||||
@@ -65,7 +68,7 @@ function PlannedTourPills({ tourTimes }: { tourTimes: DayRowTourTimes }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Description line; tour stops get two clamped lines instead of one.
|
||||
// Description line, one line like every stop's.
|
||||
function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace: boolean }) {
|
||||
return (
|
||||
<div
|
||||
@@ -75,10 +78,9 @@ function SublineDescription({ text, isTourPlace }: { text: string; isTourPlace:
|
||||
...fs(10.5),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: isTourPlace ? 'normal' : 'nowrap',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.3,
|
||||
maxHeight: isTourPlace ? '2.6em' : '1.3em',
|
||||
...(isTourPlace ? { display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' as const } : {}),
|
||||
maxHeight: '1.3em',
|
||||
}}
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
|
||||
@@ -94,14 +96,16 @@ export function DayPlanPlaceSubline({ place, cat, tourTimes, isTourPlace, timeFo
|
||||
const description = place.description || place.address || cat?.name;
|
||||
if (!(dayRowTour || place.place_time || hasPlannedTimes(tourTimes) || description)) return null;
|
||||
return (
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
// One line like the row's name: when the hover controls come out, the description
|
||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
{place.place_time && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}
|
||||
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</TimePill>
|
||||
)}
|
||||
{dayRowTour ? <TourDayRowFacts tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{dayRowTour ? <TourDayRowPills tour={dayRowTour} /> : tourTimes && <PlannedTourPills tourTimes={tourTimes} />}
|
||||
{description && <SublineDescription text={description} isTourPlace={isTourPlace} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -155,7 +155,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('DayPlanSidebar', () => {
|
||||
it('RS-01: previews Tour descriptions in two lines only while Tours is enabled', () => {
|
||||
it('RS-01: previews a Tour description on one line like every stop, marked only while Tours is enabled', () => {
|
||||
const description = 'A long description of the ridge route, the lake overlook, and the return path.';
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', description, tour_place_id: 45 });
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
@@ -172,9 +172,9 @@ describe('DayPlanSidebar', () => {
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
const tourPreview = view.container.querySelector('[data-testid="trip-plan-tour-description"]') as HTMLElement;
|
||||
expect(tourPreview).toHaveTextContent(description);
|
||||
expect(tourPreview.style.display).toBe('-webkit-box');
|
||||
expect(tourPreview.style.webkitLineClamp).toBe('2');
|
||||
expect(tourPreview.style.maxHeight).toBe('2.6em');
|
||||
// One line like the row's name, so hover controls make the description give way instead of wrapping.
|
||||
expect(tourPreview).toHaveStyle({ whiteSpace: 'nowrap', maxHeight: '1.3em' });
|
||||
expect(tourPreview.parentElement).toHaveClass('flex-nowrap', 'overflow-hidden');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveTextContent('Walk: 1 h');
|
||||
expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveTextContent('Breaks: 35 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('Planned: 1 h 35 min');
|
||||
@@ -231,14 +231,77 @@ describe('DayPlanSidebar', () => {
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
const view = render(<DayPlanSidebar {...props} />);
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||
// The tile carries the grade, as in the tour lists; the facts sit in the plan's white badges.
|
||||
expect(screen.getByTestId('plan-tour-icon')).toHaveTextContent('T2');
|
||||
expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
|
||||
expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
|
||||
expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
|
||||
expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
|
||||
const time = screen.getByTestId('trip-plan-tour-time');
|
||||
expect(time).toHaveTextContent('1 h 3 min');
|
||||
expect(time).toHaveAccessibleName(
|
||||
'Walking time: 53 min, Breaks / additional time: 10 min, Planned total duration: 1 h 3 min'
|
||||
);
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: false }] });
|
||||
view.rerender(<DayPlanSidebar {...props} />);
|
||||
expect(screen.queryByTestId('plan-tour-icon')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('plan-tour-facts')).not.toBeInTheDocument();
|
||||
});
|
||||
it('RC-01: an imported tour without a grade shows its type on the tile', () => {
|
||||
const place = buildPlace({ id: 46, name: 'Recorded loop', tour_place_id: 46 });
|
||||
const day = buildDay({ id: 10, title: 'Day 1' });
|
||||
const assignment = buildAssignment({ id: 102, day_id: 10, order_index: 0, place });
|
||||
const tour = {
|
||||
place_id: 46,
|
||||
name: place.name,
|
||||
tour_type: 'hike' as const,
|
||||
distance: 6,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: 300,
|
||||
duration: null,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: null,
|
||||
planned: true,
|
||||
caution: false,
|
||||
has_waypoints: false,
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place],
|
||||
tourPlaceIds: new Set([46]),
|
||||
assignments: { '10': [assignment] },
|
||||
plannedTourDurations: new Map([
|
||||
[46, { tour, walkingMinutes: null, breakMinutes: null, plannedTotalMinutes: null, manuallyOverridden: false }],
|
||||
]),
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
render(<DayPlanSidebar {...props} />);
|
||||
|
||||
expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
|
||||
expect(screen.queryByTestId('trip-plan-tour-time')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('RC-01: a tour row offers no booking, a tour is not booked', () => {
|
||||
const place = buildPlace({ id: 45, name: 'Ridge walk', tour_place_id: 45 });
|
||||
const cafe = buildPlace({ id: 47, name: 'Lakeside cafe' });
|
||||
const day = buildDay({ id: 10, title: 'Day 1' });
|
||||
const assignments = {
|
||||
'10': [
|
||||
buildAssignment({ id: 101, day_id: 10, order_index: 0, place }),
|
||||
buildAssignment({ id: 103, day_id: 10, order_index: 1, place: cafe }),
|
||||
],
|
||||
};
|
||||
const props = makeDefaultProps({
|
||||
days: [day],
|
||||
places: [place, cafe],
|
||||
tourPlaceIds: new Set([45]),
|
||||
assignments,
|
||||
onAddBookingToAssignment: vi.fn(),
|
||||
});
|
||||
useAddonStore.setState({ addons: [{ id: 'tours', name: 'Tours', type: 'trip', icon: '', enabled: true }] });
|
||||
render(<DayPlanSidebar {...props} />);
|
||||
|
||||
// The cafe can be booked; the tour beside it cannot.
|
||||
expect(screen.getAllByRole('button', { name: 'Add booking' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
|
||||
import type { TourPlannedTimes } from '../Tours/tourPresentation'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { TourDayRowIcon } from '../Tours/TourDayRowFacts'
|
||||
import { TourDayRowAvatar } from '../Tours/TourDayRowFacts'
|
||||
import { DayPlanPlaceSubline } from './DayPlanPlaceSubline'
|
||||
|
||||
export interface DayPlanSidebarProps {
|
||||
@@ -2000,7 +2000,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onMouseLeave={() => setLockHoverId(null)}
|
||||
className="relative flex flex-none rounded-full"
|
||||
>
|
||||
{dayRowTour ? <TourDayRowIcon type={dayRowTour.tour_type} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
|
||||
{dayRowTour ? <TourDayRowAvatar tour={dayRowTour} size={32} /> : <PlaceAvatar place={place} category={cat} size={32} />}
|
||||
{/* Hover/locked overlay */}
|
||||
{(lockHoverId === assignment.id || isLocked) && (
|
||||
<span className="absolute inset-0 grid place-items-center rounded-full transition-colors"
|
||||
@@ -2141,7 +2141,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>}
|
||||
{canEditDays && onAddBookingToAssignment && (
|
||||
{canEditDays && onAddBookingToAssignment && !isTourPlace && (
|
||||
<Tooltip label={t('reservations.addBooking')} placement="top">
|
||||
<button type="button" aria-label={t('reservations.addBooking')}
|
||||
onClick={e => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { resolveTrackColor } from '../Map/trackColors'
|
||||
import { MoreButton, SoftPill, type MenuEntry } from './planParts'
|
||||
import type { Place, Category } from '../../types'
|
||||
import { startPlaceDrag } from '../Map/markerDrag'
|
||||
|
||||
interface MemoPlaceRowProps {
|
||||
place: Place
|
||||
@@ -73,9 +74,7 @@ export const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
draggable={!selectMode && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
if (dragDisabled) { e.preventDefault(); return }
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
startPlaceDrag(e.dataTransfer, place.id)
|
||||
}}
|
||||
onClick={activate}
|
||||
onKeyDown={e => {
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EditorField, INPUT, Segmented, TEXTAREA } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
|
||||
import { useIntegrationGates } from './useIntegrationGates'
|
||||
import WandererConnectionSection from './WandererConnectionSection'
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const S = useIntegrations()
|
||||
@@ -28,6 +29,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{!managed && <PhotoProvidersSection />}
|
||||
{S.airtrailEnabled && !managed && <AirTrailConnectionSection />}
|
||||
{S.dawarichEnabled && !managed && <DawarichConnectionSection />}
|
||||
{S.wandererEnabled && !managed && <WandererConnectionSection />}
|
||||
{/* Which model reads a booking, and what that costs, comes with the instance on
|
||||
a managed install. The per-user fallback exists for people who supply their
|
||||
own key, and there nobody does. */}
|
||||
@@ -45,11 +47,14 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
function useIntegrations() {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
|
||||
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
|
||||
|
||||
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled, managed, ...mcp }
|
||||
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, dawarichEnabled,
|
||||
wandererEnabled,
|
||||
managed, ...mcp,
|
||||
}
|
||||
}
|
||||
|
||||
type IntegrationsState = ReturnType<typeof useIntegrations>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { WandererConnection } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import WandererConnectionSection from './WandererConnectionSection';
|
||||
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const disconnected: WandererConnection = {
|
||||
url: '',
|
||||
tokenMasked: '',
|
||||
allowInsecureTls: false,
|
||||
connected: false,
|
||||
lastCheckedAt: null,
|
||||
lastCheckOk: null,
|
||||
lastCheckError: null,
|
||||
};
|
||||
const connected: WandererConnection = {
|
||||
...disconnected,
|
||||
url: 'https://wanderer.test',
|
||||
tokenMasked: '••••••••',
|
||||
connected: true,
|
||||
};
|
||||
|
||||
function settingsRoute(state: { current: typeof disconnected }) {
|
||||
return http.get('/api/integrations/wanderer/settings', () => HttpResponse.json(state.current));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('WandererConnectionSection', () => {
|
||||
it('RS-03: saves an address and a token, and never fills the token back in', async () => {
|
||||
const state = { current: disconnected };
|
||||
const saved: unknown[] = [];
|
||||
server.use(
|
||||
settingsRoute(state),
|
||||
http.put('/api/integrations/wanderer/settings', async ({ request }) => {
|
||||
saved.push(await request.json());
|
||||
state.current = connected;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<WandererConnectionSection />);
|
||||
|
||||
expect(await screen.findByText('Not connected')).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText('Instance URL'), 'https://wanderer.test');
|
||||
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saved).toEqual([{ url: 'https://wanderer.test', token: 'wanderer_key_abc', allowInsecureTls: false }])
|
||||
);
|
||||
expect(await screen.findByText('Connected')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('API token')).toHaveValue('');
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RS-03: says a token that is not a wanderer API token before sending it', async () => {
|
||||
server.use(settingsRoute({ current: disconnected }));
|
||||
render(<WandererConnectionSection />);
|
||||
await userEvent.type(await screen.findByLabelText('Instance URL'), 'https://wanderer.test');
|
||||
await userEvent.type(screen.getByLabelText('API token'), 'my-password');
|
||||
|
||||
expect(screen.getByLabelText('API token')).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('RS-03: a test names how many own trails there are, or why it failed', async () => {
|
||||
const answers = [
|
||||
{ ok: true, trailCount: 5 },
|
||||
{ ok: false, code: 'unauthorized' },
|
||||
];
|
||||
server.use(
|
||||
settingsRoute({ current: connected }),
|
||||
http.post('/api/integrations/wanderer/test', () => HttpResponse.json(answers.shift()))
|
||||
);
|
||||
render(<WandererConnectionSection />);
|
||||
await screen.findByText('Connected');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
expect(await screen.findByText('Connected. You can import 5 of your trails.')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Test connection' }));
|
||||
expect(await screen.findByRole('status')).not.toHaveTextContent('Connected. You can');
|
||||
});
|
||||
|
||||
it('RS-03: shows what the last check said when it failed', async () => {
|
||||
server.use(
|
||||
settingsRoute({
|
||||
current: { ...connected, lastCheckedAt: '2026-10-15 10:00:00', lastCheckOk: false, lastCheckError: 'disabled' },
|
||||
})
|
||||
);
|
||||
render(<WandererConnectionSection />);
|
||||
expect(await screen.findByText('Your admin has not enabled wanderer for Tours.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('RS-03: disconnecting forgets the connection', async () => {
|
||||
const state = { current: connected };
|
||||
server.use(
|
||||
settingsRoute(state),
|
||||
http.delete('/api/integrations/wanderer/settings', () => {
|
||||
state.current = disconnected;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
render(<WandererConnectionSection />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Disconnect' }));
|
||||
expect(await screen.findByText('Not connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('RS-03: a refused save is said as its reason', async () => {
|
||||
server.use(
|
||||
settingsRoute({ current: disconnected }),
|
||||
http.put('/api/integrations/wanderer/settings', () =>
|
||||
HttpResponse.json({ error: 'x', code: 'blocked_address' }, { status: 400 })
|
||||
)
|
||||
);
|
||||
render(<WandererConnectionSection />);
|
||||
await userEvent.type(await screen.findByLabelText('Instance URL'), 'http://10.0.0.5');
|
||||
await userEvent.type(screen.getByLabelText('API token'), 'wanderer_key_abc');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Loader2, Plug, Save, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { EditorField, GRID_2, INPUT } from '../shared/dialogParts';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import WandererMark from '../Tours/wanderer/WandererMark';
|
||||
import Section from './Section';
|
||||
import {
|
||||
SETTINGS_BUTTON,
|
||||
SETTINGS_BUTTON_DANGER,
|
||||
SETTINGS_BUTTON_PRIMARY,
|
||||
SettingRow,
|
||||
SettingRows,
|
||||
SettingsHint,
|
||||
StatusPill,
|
||||
} from './settingsKit';
|
||||
import ToggleSwitch from './ToggleSwitch';
|
||||
import { useWandererConnection } from './useWandererConnection';
|
||||
|
||||
/**
|
||||
* Settings → Integrations → wanderer: the reader's own trail database, from
|
||||
* which the Tours workspace imports trails. Laid out like the Dawarich and
|
||||
* AirTrail cards beside it; the behaviour is in `useWandererConnection`.
|
||||
*/
|
||||
export default function WandererConnectionSection(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const S = useWandererConnection();
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('wanderer.title')}
|
||||
icon={WandererMark}
|
||||
badge={
|
||||
<StatusPill
|
||||
tone={S.connected ? 'success' : 'neutral'}
|
||||
icon={<span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
>
|
||||
{S.connected ? t('wanderer.connected') : t('wanderer.notConnected')}
|
||||
</StatusPill>
|
||||
}
|
||||
>
|
||||
<SettingsHint>{t('wanderer.intro')}</SettingsHint>
|
||||
|
||||
<div className={GRID_2}>
|
||||
<EditorField label={t('wanderer.url')} htmlFor="wanderer-url">
|
||||
<input
|
||||
id="wanderer-url"
|
||||
type="url"
|
||||
value={S.url}
|
||||
onChange={(event) => S.setUrl(event.target.value)}
|
||||
placeholder="https://wanderer.example.com"
|
||||
className={INPUT}
|
||||
/>
|
||||
</EditorField>
|
||||
<EditorField
|
||||
label={t('wanderer.token')}
|
||||
htmlFor="wanderer-token"
|
||||
hint={S.tokenInvalid ? undefined : t('wanderer.tokenHint')}
|
||||
>
|
||||
<input
|
||||
id="wanderer-token"
|
||||
type="password"
|
||||
value={S.token}
|
||||
onChange={(event) => S.setToken(event.target.value)}
|
||||
autoComplete="off"
|
||||
aria-invalid={S.tokenInvalid || undefined}
|
||||
placeholder={S.connected && !S.token ? '••••••••' : 'wanderer_key_…'}
|
||||
className={INPUT}
|
||||
/>
|
||||
{S.tokenInvalid && (
|
||||
<p className="m-0 mt-1 text-danger" style={fs(11.5, 'body')}>
|
||||
{t('wanderer.tokenInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</EditorField>
|
||||
</div>
|
||||
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('wanderer.allowInsecureTls')}
|
||||
hint={t('wanderer.allowInsecureTlsHint')}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={S.allowInsecureTls}
|
||||
onToggle={S.toggleInsecureTls}
|
||||
label={t('wanderer.allowInsecureTls')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" style={fs(13, 'body')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void S.save()}
|
||||
disabled={S.saving || S.loading || !S.canSave}
|
||||
className={SETTINGS_BUTTON_PRIMARY}
|
||||
>
|
||||
<Save size={14} strokeWidth={2.2} /> {t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void S.test()}
|
||||
disabled={S.testing || S.loading || !S.url.trim() || S.tokenInvalid}
|
||||
className={SETTINGS_BUTTON}
|
||||
>
|
||||
{S.testing ? (
|
||||
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
) : (
|
||||
<Plug size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
{t('wanderer.test.button')}
|
||||
</button>
|
||||
{S.connected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void S.disconnect()}
|
||||
disabled={S.saving}
|
||||
className={`${SETTINGS_BUTTON_DANGER} ms-auto`}
|
||||
>
|
||||
<Unplug size={14} strokeWidth={2.2} /> {t('wanderer.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(S.probe || S.lastCheck) && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-[12px] border border-edge-faint bg-surface-card px-3.5 py-3"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{S.probe ? (
|
||||
<p className={`m-0 font-medium ${S.probe.ok ? 'text-success' : 'text-danger'}`}>{S.probe.message}</p>
|
||||
) : (
|
||||
<p className="m-0 text-danger">{S.lastCheck}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -52,4 +52,11 @@ describe('useIntegrationGates', () => {
|
||||
renderHook(() => useIntegrationGates({ reloadAddons: true }));
|
||||
expect(loud).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('RS-03: offers the wanderer connection only while the admin allows wanderer under Tours', () => {
|
||||
seedAddons(['tours']);
|
||||
useAddonStore.setState({ toursWanderer: false });
|
||||
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(false);
|
||||
useAddonStore.setState({ toursWanderer: true });
|
||||
expect(renderHook(() => useIntegrationGates({ reloadAddons: false })).result.current.wandererEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
|
||||
const airtrailEnabled = addonEnabled('airtrail');
|
||||
const llmEnabled = addonEnabled('llm_parsing');
|
||||
const dawarichEnabled = addonEnabled('dawarich');
|
||||
// The wanderer connection feeds the Tours workspace, while the admin allows it there.
|
||||
const wandererEnabled = useAddonStore((s) => s.toursWanderer);
|
||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,6 +33,7 @@ export function useIntegrationGates({ reloadAddons }: { reloadAddons: boolean })
|
||||
airtrailEnabled,
|
||||
llmEnabled,
|
||||
dawarichEnabled,
|
||||
wandererEnabled,
|
||||
hasIntegrations,
|
||||
managed,
|
||||
loadAddons,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
WANDERER_TOKEN_MASK,
|
||||
WANDERER_TOKEN_PREFIX,
|
||||
type WandererConnection,
|
||||
type WandererTestResponse,
|
||||
} from '@trek/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { wandererErrorCode, wandererRepo } from '../../repo/wandererRepo';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { wandererErrorText } from '../Tours/wanderer/wandererErrors';
|
||||
|
||||
/** What the last test found, said in the card; null before a test. */
|
||||
export interface WandererProbe {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
||||
|
||||
/** What a test answered, as the line the card shows under its buttons. */
|
||||
function probeOf(result: WandererTestResponse, t: Translate): WandererProbe {
|
||||
if (!result.ok) return { ok: false, message: wandererErrorText(t, result.code ?? 'unknown') };
|
||||
const message =
|
||||
result.trailCount != null ? t('wanderer.test.okCount', { count: result.trailCount }) : t('wanderer.test.ok');
|
||||
return { ok: true, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* The wanderer connection card's logic: the form, saving, testing and
|
||||
* disconnecting, with every failure turned into a sentence the reader can act
|
||||
* on. The token field is never filled from the server; blank keeps the stored one.
|
||||
*/
|
||||
export function useWandererConnection() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const [url, setUrl] = useState('');
|
||||
const [token, setToken] = useState('');
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false);
|
||||
const [saved, setSaved] = useState<WandererConnection | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [probe, setProbe] = useState<WandererProbe | null>(null);
|
||||
|
||||
const errorText = useCallback((code: string) => wandererErrorText(t, code), [t]);
|
||||
|
||||
const apply = useCallback((connection: WandererConnection) => {
|
||||
setSaved(connection);
|
||||
setUrl(connection.url);
|
||||
setAllowInsecureTls(connection.allowInsecureTls);
|
||||
setToken('');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
wandererRepo
|
||||
.getSettings()
|
||||
.then((connection) => {
|
||||
if (!cancelled) apply(connection);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) toast.error(t('wanderer.loadFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apply, t, toast]);
|
||||
|
||||
const typedToken = token.trim();
|
||||
/** A typed token that wanderer would read as no token at all, said before it is sent. */
|
||||
const tokenInvalid =
|
||||
typedToken !== '' && typedToken !== WANDERER_TOKEN_MASK && !typedToken.startsWith(WANDERER_TOKEN_PREFIX);
|
||||
const connected = !!saved?.connected;
|
||||
const canSave = !!url.trim() && !tokenInvalid && (connected || typedToken !== '');
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!canSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await wandererRepo.saveSettings({
|
||||
url: url.trim(),
|
||||
token: typedToken || undefined,
|
||||
allowInsecureTls,
|
||||
});
|
||||
apply(await wandererRepo.getSettings());
|
||||
setProbe(null);
|
||||
toast.success(t('wanderer.saved'));
|
||||
if (result.warning === 'private_ip') toast.info(t('wanderer.privateIp'));
|
||||
} catch (error: unknown) {
|
||||
toast.error(errorText(wandererErrorCode(error)));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [allowInsecureTls, apply, canSave, errorText, t, toast, typedToken, url]);
|
||||
|
||||
const test = useCallback(async () => {
|
||||
if (!url.trim() || tokenInvalid) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await wandererRepo.test({ url: url.trim(), token: typedToken || undefined, allowInsecureTls });
|
||||
setProbe(probeOf(result, t));
|
||||
if (!typedToken) setSaved(await wandererRepo.getSettings());
|
||||
} catch (error: unknown) {
|
||||
setProbe({ ok: false, message: errorText(wandererErrorCode(error)) });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [allowInsecureTls, errorText, t, tokenInvalid, typedToken, url]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await wandererRepo.disconnect();
|
||||
apply(await wandererRepo.getSettings());
|
||||
setProbe(null);
|
||||
toast.success(t('wanderer.disconnected'));
|
||||
} catch (error: unknown) {
|
||||
toast.error(errorText(wandererErrorCode(error)));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [apply, errorText, t, toast]);
|
||||
|
||||
const lastCheck =
|
||||
saved?.lastCheckedAt && saved.lastCheckOk === false && saved.lastCheckError
|
||||
? errorText(saved.lastCheckError)
|
||||
: null;
|
||||
|
||||
return {
|
||||
url,
|
||||
setUrl,
|
||||
token,
|
||||
setToken,
|
||||
allowInsecureTls,
|
||||
toggleInsecureTls: () => setAllowInsecureTls((value) => !value),
|
||||
connected,
|
||||
loading,
|
||||
saving,
|
||||
testing,
|
||||
probe,
|
||||
tokenInvalid,
|
||||
canSave,
|
||||
lastCheck,
|
||||
save,
|
||||
test,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
@@ -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,11 +1,15 @@
|
||||
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';
|
||||
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 }) {
|
||||
@@ -73,6 +77,138 @@ function Fact({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour's tile in the day plan and in its detail: the T-scale grade in a round
|
||||
* tile, the same tile the tour lists use, so a tour reads alike wherever it
|
||||
* shows. A GPX import has no grade and shows its type's icon instead.
|
||||
*/
|
||||
export function TourDayRowAvatar({
|
||||
tour,
|
||||
size = 32,
|
||||
}: {
|
||||
tour: Pick<TourListItem, 'tour_type' | 'max_hiking_difficulty'>;
|
||||
size?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const level = tour.max_hiking_difficulty;
|
||||
const known = ['hike', 'bike', 'walk', 'kayak'].includes(tour.tour_type);
|
||||
const label =
|
||||
level != null
|
||||
? t(`tours.planner.difficulty.t${level}`)
|
||||
: known
|
||||
? t(`tourTypes.${tour.tour_type}`)
|
||||
: t('tours.mode.tours');
|
||||
return (
|
||||
<span
|
||||
data-testid="plan-tour-icon"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="grid flex-none place-items-center rounded-full bg-edge font-geist font-semibold tabular-nums text-content"
|
||||
style={{ width: size, height: size, ...fs(size >= 44 ? 17 : 12, 'body') }}
|
||||
>
|
||||
{level != null
|
||||
? `T${level}`
|
||||
: createElement(tourDayRowIcon(tour.tour_type), {
|
||||
size: Math.round(size * 0.46),
|
||||
strokeWidth: 1.9,
|
||||
className: 'text-content-secondary',
|
||||
'aria-hidden': 'true',
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One fact of a tour as the white badge a place's time sits in, named by its tooltip. */
|
||||
export function TourDayRowPill({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
testId,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
value: string;
|
||||
label: string;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<span data-testid={testId} aria-label={label} className="inline-flex">
|
||||
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{value}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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, 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
|
||||
? `${movingTimeLabel(tour, t('tours.planner.inspector.duration'), t)}: ${formatPlannedTourDuration(times.walkingMinutes)}`
|
||||
: null,
|
||||
times.breakMinutes
|
||||
? `${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(times.breakMinutes)}`
|
||||
: null,
|
||||
times.plannedTotalMinutes != null
|
||||
? `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(times.plannedTotalMinutes)}${times.manuallyOverridden ? ` (${t('tours.planner.plannedTotalManual')})` : ''}`
|
||||
: null,
|
||||
].filter((part): part is string => part !== null);
|
||||
const check = paddleCheckOf(tour, t);
|
||||
const time = shownMinutes != null && (
|
||||
<TourDayRowPill
|
||||
key="time"
|
||||
icon={Clock}
|
||||
value={formatPlannedTourDuration(shownMinutes)}
|
||||
label={timeParts.join(', ')}
|
||||
testId="trip-plan-tour-time"
|
||||
/>
|
||||
);
|
||||
const 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 }) {
|
||||
const { t } = useTranslation();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) || 'metric';
|
||||
@@ -86,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)
|
||||
@@ -105,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) => (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
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'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
vi.mock('../../../../server/src/config', () => { throw new Error('Config initialization is forbidden in client tests') })
|
||||
vi.mock('../../../../server/src/db/database', () => { throw new Error('Legacy database imports are forbidden in client tests') })
|
||||
@@ -96,16 +98,19 @@ describe('TourDetailDialog', () => {
|
||||
expect(screen.getByText('Maximum altitude')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ascent')).toBeInTheDocument()
|
||||
expect(screen.getByText('Descent')).toBeInTheDocument()
|
||||
expect(screen.getByText('50 m')).toBeInTheDocument()
|
||||
expect(screen.getByText('30 m')).toBeInTheDocument()
|
||||
// The head's pills and the fields below both measure the line as it is now.
|
||||
expect(screen.getAllByText('50 m').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('30 m').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('999 m')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('999 km')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Bring water')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Opening Hours')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => {
|
||||
it('RS-01: shows Tour description and opens the classified HTTPS information link without fetching it', () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
render(
|
||||
<TourDetailDialog
|
||||
{...defaultProps}
|
||||
@@ -115,19 +120,36 @@ describe('TourDetailDialog', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('Ridge above the lake')).toBeInTheDocument()
|
||||
const link = screen.getByRole('link', { name: 'Komoot · komoot.com' })
|
||||
expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
|
||||
// The footer names the provider; the domain rides along in the tooltip.
|
||||
const website = screen.getByRole('button', { name: 'Website: Komoot' })
|
||||
expect(website).toHaveTextContent('Komoot')
|
||||
fireEvent.click(website)
|
||||
expect(openSpy).toHaveBeenCalledWith('https://www.komoot.com/tour/42', '_blank', 'noopener,noreferrer')
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
fetchSpy.mockRestore()
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('RS-02: labels calculated walking time and planned total as separate values', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, duration: 60, break_additional_minutes: 35, planned_duration_minutes: 125 }} />)
|
||||
|
||||
expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument()
|
||||
expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument()
|
||||
// One time in the head, its parts named in full behind it; the fields below keep them apart.
|
||||
expect(screen.getByLabelText('Walk: 1 h, Breaks: 35 min, Planned (Manual override): 2 h 5 min')).toHaveTextContent(
|
||||
'2 h 5 min'
|
||||
)
|
||||
expect(screen.getByText('Walk')).toBeInTheDocument()
|
||||
expect(screen.getByText('Breaks')).toBeInTheDocument()
|
||||
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', () => {
|
||||
@@ -144,7 +166,7 @@ describe('TourDetailDialog', () => {
|
||||
it('uses existing assignment callbacks for add and remove actions', () => {
|
||||
const { rerender } = render(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
|
||||
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42)
|
||||
expect(defaultProps.onAssignToDay).toHaveBeenCalledWith(42, 7)
|
||||
|
||||
rerender(<TourDetailDialog {...defaultProps} days={[{ id: 7 } as never]} selectedDayId={7} assignments={{ '7': [{ id: 9, place }] as never }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Remove from Day/i }))
|
||||
@@ -176,10 +198,46 @@ describe('TourDetailDialog', () => {
|
||||
expect(defaultProps.onUpdatePlace).toHaveBeenCalledWith(42, { name: 'New route name' })
|
||||
})
|
||||
|
||||
it('labels GPX imports as view-only planner content', () => {
|
||||
it('RC-03: names a GPX import as such, without a read-only notice in the plan', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, has_waypoints: false }} />)
|
||||
|
||||
expect(screen.getByText('GPX import')).toBeInTheDocument()
|
||||
expect(screen.getByText('GPX tours are view-only and cannot be edited in the planner yet.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/view-only/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PR1-05B: names the import a tour was planned from while it is still in the trip', () => {
|
||||
seedStore(useTripStore, { places: [buildPlace({ id: 7, name: 'Recorded ridge' }), place] })
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
|
||||
|
||||
expect(screen.getByText('Based on Recorded ridge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('PR1-05B: says nothing of an origin that is gone from the trip', () => {
|
||||
seedStore(useTripStore, { places: [place] })
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, derived_from_place_id: 7 }} />)
|
||||
|
||||
expect(screen.queryByText(/Based on/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grade on the tile and no second difficulty scale', () => {
|
||||
render(<TourDetailDialog {...defaultProps} tour={{ ...tour, difficulty: 'easy', max_hiking_difficulty: 1 }} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import type { Day } from '../../types';
|
||||
import { dayMenuLabel, tourDaysOf } from './tourDays';
|
||||
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(),
|
||||
preview: null,
|
||||
confirm: vi.fn().mockResolvedValue(undefined),
|
||||
cancel: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' });
|
||||
const filesDrag = { dataTransfer: { types: ['Files'], files: [file], dropEffect: 'none' } };
|
||||
const rowDrag = { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' } };
|
||||
|
||||
describe('TourGpxDropZone', () => {
|
||||
it('RC-04: a file dragged over the workspace says it will be imported, and a drop imports it', () => {
|
||||
const importer = gpxImport();
|
||||
render(
|
||||
<TourGpxDropZone gpxImport={importer} canEdit>
|
||||
<p>workspace</p>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
const zone = screen.getByText('workspace').parentElement as HTMLElement;
|
||||
|
||||
fireEvent.dragEnter(zone, filesDrag);
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Drop GPX files to import them');
|
||||
|
||||
fireEvent.drop(zone, filesDrag);
|
||||
expect(importer.importFiles).toHaveBeenCalledWith([file]);
|
||||
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('RC-04: a waypoint or tour row being dragged passes straight through', () => {
|
||||
const importer = gpxImport();
|
||||
render(
|
||||
<TourGpxDropZone gpxImport={importer} canEdit>
|
||||
<p>workspace</p>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
const zone = screen.getByText('workspace').parentElement as HTMLElement;
|
||||
|
||||
fireEvent.dragEnter(zone, rowDrag);
|
||||
fireEvent.drop(zone, rowDrag);
|
||||
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
|
||||
expect(importer.importFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RC-04: leaving every child it entered takes the overlay away again', () => {
|
||||
render(
|
||||
<TourGpxDropZone gpxImport={gpxImport()} canEdit>
|
||||
<p>workspace</p>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
const zone = screen.getByText('workspace').parentElement as HTMLElement;
|
||||
fireEvent.dragEnter(zone, filesDrag);
|
||||
fireEvent.dragEnter(zone, filesDrag);
|
||||
fireEvent.dragLeave(zone, filesDrag);
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toBeInTheDocument();
|
||||
fireEvent.dragLeave(zone, filesDrag);
|
||||
expect(screen.queryByTestId('tour-gpx-drop')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('RC-04: without the right to add tours it says so and imports nothing', () => {
|
||||
const importer = gpxImport();
|
||||
render(
|
||||
<TourGpxDropZone gpxImport={importer} canEdit={false}>
|
||||
<p>workspace</p>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
const zone = screen.getByText('workspace').parentElement as HTMLElement;
|
||||
fireEvent.dragEnter(zone, filesDrag);
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('You cannot add tours to this trip.');
|
||||
fireEvent.drop(zone, filesDrag);
|
||||
expect(importer.importFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('RC-04: stays up and says so while an import runs', () => {
|
||||
render(
|
||||
<TourGpxDropZone gpxImport={gpxImport({ importing: true })} canEdit>
|
||||
<p>workspace</p>
|
||||
</TourGpxDropZone>
|
||||
);
|
||||
expect(screen.getByTestId('tour-gpx-drop')).toHaveTextContent('Importing GPX…');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tourDays', () => {
|
||||
const days = [
|
||||
{ id: 1, title: null, date: '2026-07-08' },
|
||||
{ id: 2, title: 'Lakes', date: null },
|
||||
{ id: 3, title: null, date: null },
|
||||
] as Day[];
|
||||
|
||||
it('RC-02: lists the days a tour is on in the order of the trip, each once', () => {
|
||||
const assignments = {
|
||||
'3': [{ id: 9, place_id: 42 }],
|
||||
'1': [
|
||||
{ id: 7, place_id: 42 },
|
||||
{ id: 8, place_id: 42 },
|
||||
],
|
||||
'2': [{ id: 6, place_id: 5 }],
|
||||
} as never;
|
||||
expect(tourDaysOf(42, days, assignments).map(({ day, index }) => [day.id, index])).toEqual([
|
||||
[1, 0],
|
||||
[3, 2],
|
||||
]);
|
||||
expect(tourDaysOf(99, days, assignments)).toEqual([]);
|
||||
});
|
||||
|
||||
it('RC-02: names a day by its number, its date and its title as far as it has them', () => {
|
||||
const t = (key: string, params?: Record<string, unknown>) => (key === 'dayplan.dayN' ? `Day ${params?.n}` : key);
|
||||
expect(dayMenuLabel(days[1], 1, t, 'en')).toBe('Day 2: Lakes');
|
||||
expect(dayMenuLabel(days[2], 2, t, 'en')).toBe('Day 3');
|
||||
expect(dayMenuLabel(days[0], 0, t, 'en')).toMatch(/^Day 1, .*8/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
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. */
|
||||
const carriesFiles = (event: DragEvent) => Array.from(event.dataTransfer.types).includes('Files');
|
||||
|
||||
/**
|
||||
* The tours workspace as a drop target for GPX files: a file dragged over it
|
||||
* dims the workspace and says what will happen, and a drop hands the files to
|
||||
* the same import the button runs. While an import runs, the overlay stays and
|
||||
* says so. A drag of anything else (a waypoint in the list, the map) passes
|
||||
* straight through.
|
||||
*/
|
||||
export default function TourGpxDropZone({
|
||||
gpxImport,
|
||||
canEdit,
|
||||
className = 'relative',
|
||||
children,
|
||||
}: {
|
||||
gpxImport: TourGpxImport;
|
||||
canEdit: boolean;
|
||||
/** Positions the zone; it must be positioned for the overlay to cover it. */
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [over, setOver] = useState(false);
|
||||
// dragenter and dragleave fire for every child the pointer crosses, so the
|
||||
// overlay goes only when the count of entered elements is back at zero.
|
||||
const depth = useRef(0);
|
||||
|
||||
const reset = () => {
|
||||
depth.current = 0;
|
||||
setOver(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onDragEnter={(event) => {
|
||||
if (!carriesFiles(event)) return;
|
||||
event.preventDefault();
|
||||
depth.current += 1;
|
||||
setOver(true);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!carriesFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = canEdit && !gpxImport.importing ? 'copy' : 'none';
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (!carriesFiles(event)) return;
|
||||
depth.current = Math.max(0, depth.current - 1);
|
||||
if (depth.current === 0) setOver(false);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!carriesFiles(event)) return;
|
||||
event.preventDefault();
|
||||
reset();
|
||||
if (canEdit) void gpxImport.importFiles(Array.from(event.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
{(over || gpxImport.importing) && (
|
||||
<div
|
||||
data-testid="tour-gpx-drop"
|
||||
role="status"
|
||||
className="pointer-events-none absolute inset-2.5 z-30 flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-accent backdrop-blur-[2px]"
|
||||
style={{ background: 'color-mix(in srgb, var(--bg-card) 82%, transparent)' }}
|
||||
>
|
||||
<span className="grid h-12 w-12 place-items-center rounded-[14px] bg-surface-card text-content shadow-sm">
|
||||
{gpxImport.importing ? (
|
||||
<Loader2 size={22} strokeWidth={1.9} className="animate-spin" />
|
||||
) : canEdit ? (
|
||||
<FileDown size={22} strokeWidth={1.9} />
|
||||
) : (
|
||||
<Ban size={22} strokeWidth={1.9} />
|
||||
)}
|
||||
</span>
|
||||
<span className="font-semibold text-content" style={fs(14, 'body')}>
|
||||
{gpxImport.importing
|
||||
? t('tours.import.importing')
|
||||
: canEdit
|
||||
? t('tours.import.drop')
|
||||
: t('tours.import.dropNoPermission')}
|
||||
</span>
|
||||
{!gpxImport.importing && canEdit && (
|
||||
<span className="max-w-[320px] text-center text-content-muted" style={fs(12, 'body')}>
|
||||
{t('tours.import.dropHint')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import { InfoDot } from './tourParts';
|
||||
import { ORIGIN_LABEL_KEY, statusLabel } from './tourPresentation';
|
||||
import type { TourListFilters } from './useTourListFilters';
|
||||
|
||||
function MenuRow({
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>
|
||||
{count}
|
||||
</span>
|
||||
<span className="grid w-3.5 flex-none place-items-center">
|
||||
{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSection({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className="flex flex-col gap-px border-t border-edge-faint pt-1.5 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<div className="flex items-center gap-1 px-2 pb-0.5">
|
||||
<span className="font-semibold uppercase tracking-[.06em] text-content-faint" style={fs(9.5)}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <InfoDot label={label} description={hint} />}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter of a tour list (PR1-13): one button that says what is set, and a
|
||||
* menu with the day status and, where the list varies in them, the source,
|
||||
* the stored routing limit and the tour type, each with how many tours it
|
||||
* keeps. Choosing a set value again clears it.
|
||||
*/
|
||||
export default function TourListFilterMenu({ filters }: { filters: TourListFilters }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { facets, selection, statusCounts } = filters;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative min-w-0">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
aria-label={t('places.filterShow')}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<span className="min-w-0 truncate text-start">{statusLabel(filters.status, t)}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>
|
||||
{filters.filtered.length}
|
||||
</span>
|
||||
{filters.activeFacets > 0 && (
|
||||
<span
|
||||
className="rounded-full bg-accent px-1.5 font-geist font-semibold leading-[16px] text-accent-text"
|
||||
style={fs(10)}
|
||||
>
|
||||
+{filters.activeFacets}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
size={13}
|
||||
strokeWidth={2.2}
|
||||
className={`ms-auto flex-none text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('places.filterShow')}
|
||||
className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex max-h-[min(420px,60vh)] w-full min-w-[200px] flex-col gap-1.5 overflow-y-auto rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover"
|
||||
>
|
||||
<MenuSection label={t('tours.filter.status')}>
|
||||
{(['all', 'unplanned', 'planned'] as const).map((status) => (
|
||||
<MenuRow
|
||||
key={status}
|
||||
active={filters.status === status}
|
||||
label={statusLabel(status, t)}
|
||||
count={statusCounts[status]}
|
||||
onClick={() => filters.setStatus(status)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
{facets.origin.length > 0 && (
|
||||
<MenuSection label={t('tours.filter.source')}>
|
||||
{facets.origin.map((option) => (
|
||||
<MenuRow
|
||||
key={option.value}
|
||||
active={selection.origin === option.value}
|
||||
label={t(ORIGIN_LABEL_KEY[option.value])}
|
||||
count={option.count}
|
||||
onClick={() => filters.select('origin', option.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
)}
|
||||
{facets.grade.length > 0 && (
|
||||
<MenuSection label={t('tours.filter.grade')} hint={t('tours.filter.gradeHint')}>
|
||||
{facets.grade.map((option) => (
|
||||
<MenuRow
|
||||
key={option.value}
|
||||
active={selection.grade === option.value}
|
||||
label={`T${option.value}`}
|
||||
count={option.count}
|
||||
onClick={() => filters.select('grade', option.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
)}
|
||||
{facets.type.length > 0 && (
|
||||
<MenuSection label={t('tours.filter.type')}>
|
||||
{facets.type.map((option) => (
|
||||
<MenuRow
|
||||
key={option.value}
|
||||
active={selection.type === option.value}
|
||||
label={t(`tourTypes.${option.value}`)}
|
||||
count={option.count}
|
||||
onClick={() => filters.select('type', option.value)}
|
||||
/>
|
||||
))}
|
||||
</MenuSection>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,126 @@
|
||||
import '../../styles/fadeEnd.css'
|
||||
import { useId, type ReactNode } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { Link2 } from 'lucide-react'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { SoftPill } from '../Planner/planParts'
|
||||
import { TourFactPills, TourGradeTile } from './tourParts'
|
||||
import { tourWebsitePresentation } from './tourPresentation'
|
||||
import { TourCautionBadge, TourDaysBadge, TourGradeTile } from './tourParts'
|
||||
import type { Day } from '../../types'
|
||||
import { endPlaceDrag, startPlaceDrag } from '../Map/markerDrag'
|
||||
import { TourDayRowPills } from './TourDayRowFacts'
|
||||
import { useTourDays } from './tourDays'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
const NO_DAYS: Day[] = []
|
||||
|
||||
interface TourListRowProps {
|
||||
tour: TourListItem
|
||||
/** The trip's days, to name the ones the tour is on; without them the row names none. */
|
||||
days?: Day[]
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
/** The row can be picked up and dropped on a day of the plan, like a place. */
|
||||
draggableToDay?: boolean
|
||||
onSelect: (tour: TourListItem, opener: HTMLElement) => void
|
||||
action?: ReactNode
|
||||
children?: ReactNode
|
||||
/** The colour the tour's line has on the map, shown beside it so the two read as one. */
|
||||
trackColor?: string
|
||||
/** PR1-07: the person's switch for this tour's line on the trip map. */
|
||||
mapToggle?: { hidden: boolean; onToggle: () => void; showLabel: string; hideLabel: string }
|
||||
}
|
||||
|
||||
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
|
||||
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
|
||||
/** The eye beside a row: shown on hover like the other actions, and always while the tour is hidden. */
|
||||
function MapToggleButton({ toggle }: { toggle: NonNullable<TourListRowProps['mapToggle']> }) {
|
||||
const label = toggle.hidden ? toggle.showLabel : toggle.hideLabel
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={toggle.hidden}
|
||||
onClick={event => { event.stopPropagation(); toggle.onToggle() }}
|
||||
className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-content-muted shadow-sm ring-1 ring-edge-faint transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${toggle.hidden ? 'bg-surface-tertiary' : 'bg-surface-card'}`}
|
||||
>
|
||||
{toggle.hidden ? <EyeOff size={13} strokeWidth={2.2} /> : <Eye size={13} strokeWidth={2.2} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
tourDays: ReturnType<typeof useTourDays>
|
||||
dimmed: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex min-w-0 flex-1 items-center gap-2.5 transition-opacity ${dimmed ? 'opacity-55' : ''}`}>
|
||||
<TourGradeTile tour={tour} />
|
||||
<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>
|
||||
{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="-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} compact />
|
||||
<TourDaysBadge tourDays={tourDays} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The eye and the row's actions on the right. A hidden tour keeps its eye in
|
||||
* sight; the rest come out on hover, focus or selection, as a plan row's
|
||||
* controls do, so the name and badges have the room otherwise.
|
||||
*/
|
||||
function TourRowActions({ action, mapToggle, selected }: Pick<TourListRowProps, 'action' | 'mapToggle'> & { selected: boolean }) {
|
||||
const hoverToggle = mapToggle && !mapToggle.hidden ? mapToggle : null
|
||||
return (
|
||||
<>
|
||||
{mapToggle?.hidden && <MapToggleButton toggle={mapToggle} />}
|
||||
{(action || hoverToggle) && (
|
||||
<div data-testid="tour-action-row" role="presentation"
|
||||
onClick={event => event.stopPropagation()}
|
||||
className={`flex-none items-center gap-1.5 group-focus-within:flex group-hover:flex ${selected ? 'flex' : 'hidden'}`}>
|
||||
{hoverToggle && <MapToggleButton toggle={hoverToggle} />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A tour in a list, laid out like a stop in the day plan: its tile with the
|
||||
* 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,
|
||||
selected = false, disabled = false,
|
||||
draggableToDay = false, onSelect, action, children, trackColor, mapToggle }: TourListRowProps) {
|
||||
const nameId = useId()
|
||||
const tourDays = useTourDays(tour.place_id, days)
|
||||
const draggable = draggableToDay && !disabled
|
||||
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
|
||||
const website = tourWebsitePresentation(tour.website)
|
||||
const cursor = disabled ? 'cursor-default opacity-60' : draggable ? 'cursor-grab' : 'cursor-pointer'
|
||||
return (
|
||||
<li
|
||||
role="option"
|
||||
@@ -27,7 +128,11 @@ export default function TourListRow({ tour, selected = false, disabled = false,
|
||||
aria-selected={selected}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-labelledby={nameId}
|
||||
className={`group mb-px rounded-[12px] px-2.5 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${tone} ${disabled ? 'cursor-default opacity-60' : 'cursor-pointer'}`}
|
||||
data-place-id={tour.place_id}
|
||||
draggable={draggable || undefined}
|
||||
onDragStart={draggable ? (event) => startPlaceDrag(event.dataTransfer, tour.place_id) : undefined}
|
||||
onDragEnd={draggable ? endPlaceDrag : undefined}
|
||||
className={`group mx-1.5 rounded-[10px] px-1.5 py-2 outline-none transition-colors focus-visible:bg-surface-hover ${tone} ${cursor}`}
|
||||
onClick={event => { if (!disabled) onSelect(tour, event.currentTarget) }}
|
||||
onKeyDown={event => {
|
||||
if (disabled || event.target !== event.currentTarget) return
|
||||
@@ -37,31 +142,14 @@ export default function TourListRow({ tour, selected = false, disabled = false,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* The tile, the name and the day actions share the head line; the rest runs the
|
||||
full width under them, below the tile too. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<TourGradeTile tour={tour} />
|
||||
<span id={nameId} className="block min-w-0 flex-1 truncate font-semibold leading-tight text-content" style={fs(13, 'body')}>{tour.name}</span>
|
||||
{action && (
|
||||
<div data-testid="tour-action-row" role="presentation" className="flex flex-none items-center gap-1.5" onClick={event => event.stopPropagation()}>
|
||||
{action}
|
||||
</div>
|
||||
{trackColor && (
|
||||
// The line's colour on the map, as a plan row shows its category.
|
||||
<span aria-hidden="true" className="-me-1 h-8 w-[3px] flex-none rounded-full" style={{ background: trackColor }} />
|
||||
)}
|
||||
<TourRowBody tour={tour} nameId={nameId} tourDays={tourDays} dimmed={!!mapToggle?.hidden} />
|
||||
<TourRowActions action={action} mapToggle={mapToggle} selected={selected} />
|
||||
</div>
|
||||
{tour.description && (
|
||||
<div data-testid="tour-description-preview" className="mt-2.5 rounded-[10px] bg-surface-card px-2 py-1.5 text-content-secondary shadow-sm ring-1 ring-inset ring-edge-faint" style={fs(11)}>
|
||||
<span className="overflow-hidden text-ellipsis" style={{ lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{tour.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<TourFactPills tour={tour} className={tour.description ? 'mt-1.5' : 'mt-2.5'}>
|
||||
{website && (
|
||||
<SoftPill icon={<Link2 size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
<span className="min-w-0 truncate">{website.provider ?? website.domain}</span>
|
||||
</SoftPill>
|
||||
)}
|
||||
</TourFactPills>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
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';
|
||||
|
||||
const seen = vi.hoisted(() => ({ rail: [] as Array<Record<string, unknown>> }));
|
||||
vi.mock('./planner/TourPlannerPanels', () => ({
|
||||
TourPlannerRail: (props: Record<string, unknown>) => {
|
||||
seen.rail.push(props);
|
||||
return <p>tour editor</p>;
|
||||
},
|
||||
}));
|
||||
vi.mock('./wanderer/WandererBrowserRail', () => ({ default: () => <p>wanderer trails</p> }));
|
||||
|
||||
const planner = {} as TourPlannerController;
|
||||
const conversion = {} as GpxConversion;
|
||||
const recordedTrack = {} as RecordedTrack;
|
||||
|
||||
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 workspace={workspace(wanderer({ open: true }))} canEdit canAssign />);
|
||||
expect(screen.getByText('wanderer trails')).toBeInTheDocument();
|
||||
expect(screen.queryByText('tour editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 workspace={workspace(browser)} canEdit canAssign={false} />);
|
||||
expect(screen.getByText('tour editor')).toBeInTheDocument();
|
||||
expect(seen.rail[seen.rail.length - 1]).toMatchObject({
|
||||
planner,
|
||||
conversion,
|
||||
recordedTrack,
|
||||
canEdit: true,
|
||||
canAssign: false,
|
||||
onOpenWanderer: browser.openBrowser,
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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({
|
||||
workspace,
|
||||
canEdit,
|
||||
canAssign,
|
||||
}: {
|
||||
workspace: TourWorkspaceRailParts;
|
||||
canEdit: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const {
|
||||
tourPlanner,
|
||||
wandererBrowser: wanderer,
|
||||
tourConversion,
|
||||
recordedTrack,
|
||||
trackPaddlePlan,
|
||||
paddleChecklist,
|
||||
} = workspace;
|
||||
return wanderer.open ? (
|
||||
<WandererBrowserRail browser={wanderer} />
|
||||
) : (
|
||||
<TourPlannerRail
|
||||
planner={tourPlanner}
|
||||
canEdit={canEdit}
|
||||
canAssign={canAssign}
|
||||
conversion={tourConversion}
|
||||
recordedTrack={recordedTrack}
|
||||
trackPaddlePlan={trackPaddlePlan}
|
||||
paddleChecklist={paddleChecklist}
|
||||
onOpenWanderer={wanderer.enabled ? wanderer.openBrowser : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
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') })
|
||||
@@ -32,10 +36,25 @@ const tour: TourListItem = {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Object.values(toast).forEach((notify) => notify.mockClear())
|
||||
resetAllStores()
|
||||
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()} />)
|
||||
@@ -56,15 +75,20 @@ describe('ToursSidebar', () => {
|
||||
expect(row).toHaveClass('bg-surface-selected')
|
||||
})
|
||||
|
||||
it('RS-01: previews the Tour description and classified information-link host', () => {
|
||||
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 })
|
||||
expect(row).toHaveTextContent('A quiet path above the lake')
|
||||
expect(row).toHaveTextContent('Komoot')
|
||||
expect(row).not.toHaveTextContent('komoot.com')
|
||||
expect(row).toHaveTextContent('1 h 35 min')
|
||||
expect(row.querySelector('[data-testid="tour-description-preview"] > span')).toHaveStyle({ WebkitLineClamp: '2' })
|
||||
const line = within(row).getByTestId('tour-metrics')
|
||||
expect(line).toHaveClass('flex-wrap', 'h-6', 'overflow-hidden')
|
||||
expect(line).toHaveTextContent('1 h 35 min')
|
||||
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')
|
||||
})
|
||||
|
||||
it('selects the tour from keyboard activation', () => {
|
||||
@@ -98,15 +122,13 @@ describe('ToursSidebar', () => {
|
||||
const metrics = row.querySelector('[data-testid="tour-metrics"]')!
|
||||
expect(metrics).toHaveTextContent('1.9 km')
|
||||
expect(metrics).toHaveTextContent('13 m')
|
||||
expect(metrics).toHaveTextContent('11 m')
|
||||
const labels = [...metrics.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label'))
|
||||
expect(labels[0]).toContain('1.9 km')
|
||||
expect(labels[1]).toContain('13 m')
|
||||
expect(labels[2]).toContain('11 m')
|
||||
expect(within(row).queryByText('TREK')).not.toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument()
|
||||
const actionRow = row.querySelector('[data-testid="tour-action-row"]')!
|
||||
expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu')
|
||||
expect(within(actionRow as HTMLElement).getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })).toHaveAttribute('aria-haspopup', 'menu')
|
||||
expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ }))
|
||||
@@ -200,12 +222,15 @@ 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()
|
||||
const loadTrip = vi.spyOn(useTripStore.getState(), 'loadTrip').mockResolvedValue(undefined)
|
||||
const { container } = render(<ToursSidebar
|
||||
const refreshPlaces = vi.fn().mockResolvedValue(undefined)
|
||||
seedStore(useTripStore, { refreshPlaces })
|
||||
render(<ToursSidebar
|
||||
tripId={1}
|
||||
days={[]}
|
||||
tours={[]}
|
||||
@@ -215,22 +240,26 @@ describe('ToursSidebar', () => {
|
||||
onSelectTour={vi.fn()}
|
||||
/>)
|
||||
|
||||
const input = container.querySelector('input[type="file"]')!
|
||||
const file = new File(['<gpx/>'], 'ridge.gpx', { type: 'application/gpx+xml' })
|
||||
fireEvent.change(input, { target: { files: [file] } })
|
||||
expect(pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [file])).toHaveAttribute('accept', '.gpx')
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Import 1 tour' }))
|
||||
|
||||
await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1))
|
||||
// Only the places are read again, not the whole trip: that would rebuild the workspace.
|
||||
await waitFor(() => expect(refreshPlaces).toHaveBeenCalledWith(1))
|
||||
expect(loadTrip).not.toHaveBeenCalled()
|
||||
expect(onToursChanged).toHaveBeenCalledWith([42])
|
||||
expect(onToursChanged.mock.invocationCallOrder[0]).toBeLessThan(loadTrip.mock.invocationCallOrder[0])
|
||||
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)
|
||||
const { container } = render(<ToursSidebar
|
||||
render(<ToursSidebar
|
||||
tripId={1}
|
||||
days={[]}
|
||||
tours={[]}
|
||||
@@ -240,13 +269,169 @@ describe('ToursSidebar', () => {
|
||||
onSelectTour={vi.fn()}
|
||||
/>)
|
||||
|
||||
fireEvent.change(container.querySelector('input[type=file]')!, {
|
||||
target: { files: [new File(['<gpx/>'], 'already-imported.gpx', { type: 'application/gpx+xml' })] },
|
||||
})
|
||||
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('All places were already in the trip.'))
|
||||
await waitFor(() => expect(toast.warning).toHaveBeenCalledWith('Everything in that file is already in this trip.'))
|
||||
expect(onToursChanged).not.toHaveBeenCalled()
|
||||
expect(loadTrip).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
it('RC-04: names each file that fails and still imports the rest', async () => {
|
||||
vi.spyOn(tourGpxApi, 'preview')
|
||||
.mockRejectedValueOnce({ response: { data: { error: 'No track or route found in GPX file' } } })
|
||||
.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()} />
|
||||
)
|
||||
|
||||
pickFiles(screen.getByRole('button', { name: 'Import GPX' }), [
|
||||
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(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(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(['x'], 'notes.txt', { type: 'text/plain' }),
|
||||
])
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Only GPX files can be imported as tours.'))
|
||||
expect(importCall).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('RC-02: names the days a tour is on in one badge, in the order of the trip', () => {
|
||||
const days = [
|
||||
{ id: 7, trip_id: 1, day_number: 1, title: null, date: null },
|
||||
{ id: 8, trip_id: 1, day_number: 2, title: null, date: null },
|
||||
{ id: 9, trip_id: 1, day_number: 3, title: 'Summit', date: null },
|
||||
] as never[]
|
||||
seedStore(useTripStore, {
|
||||
assignments: { '9': [{ id: 2, place_id: 42 }], '7': [{ id: 1, place_id: 42 }] } as never,
|
||||
})
|
||||
render(<ToursSidebar tripId={1} days={days} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
|
||||
const badge = within(screen.getByRole('option', { name: /Selected ridge walk/ })).getByTestId('tour-days-badge')
|
||||
expect(badge).toHaveTextContent('Days 1, 3')
|
||||
expect(badge).toHaveAttribute('aria-label', 'Day 1, Day 3: Summit')
|
||||
})
|
||||
|
||||
it('PR1-13: offers the source and routing limit only where the list varies, and narrows by them', () => {
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[
|
||||
{ ...tour, place_id: 1, name: 'Drawn', origin: 'trek', max_hiking_difficulty: 2 },
|
||||
{ ...tour, place_id: 2, name: 'Imported', origin: 'gpx', max_hiking_difficulty: null },
|
||||
{ ...tour, place_id: 3, name: 'Snapshot', origin: 'wanderer', max_hiking_difficulty: 3 },
|
||||
]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show' }))
|
||||
const limit = () => within(screen.getByRole('group', { name: 'Routing limit' })).getByRole('button', { name: /T3/ })
|
||||
fireEvent.click(limit())
|
||||
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Snapshot')])
|
||||
// Under that limit only the wanderer snapshot is left, so the source offers nothing else.
|
||||
expect(within(screen.getByRole('group', { name: 'Source' })).queryByRole('button', { name: /GPX import/ })).toBeNull()
|
||||
fireEvent.click(limit())
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Source' })).getByRole('button', { name: /GPX import\s*1/ }))
|
||||
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual([expect.stringContaining('Imported')])
|
||||
expect(screen.getByRole('button', { name: 'Show' })).toHaveTextContent('+1')
|
||||
// Only one tour type in the list: no type filter.
|
||||
expect(screen.queryByRole('group', { name: 'Type' })).toBeNull()
|
||||
// The import has no routing limit, so none is offered for it; the day status still narrows it away.
|
||||
expect(screen.queryByRole('group', { name: 'Routing limit' })).toBeNull()
|
||||
fireEvent.click(within(screen.getByRole('group', { name: 'Status' })).getByRole('button', { name: /^Planned/ }))
|
||||
expect(screen.getByRole('status')).toHaveTextContent('No tour matches these filters.')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset filters' }))
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('PR1-07/08: a row shows its line colour, and its eye hides the tour from the map until shown again', () => {
|
||||
seedStore(useAuthStore, { user: { id: 3 } as never })
|
||||
seedStore(useTripStore, { places: [{ id: 42, route_color: '#e11d48' }] as never })
|
||||
render(<ToursSidebar tripId={1} days={[]} tours={[tour]} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />)
|
||||
const row = screen.getByRole('option', { name: /Selected ridge walk/ })
|
||||
expect(row.querySelector('[aria-hidden="true"][style]')).toHaveStyle({ background: '#e11d48' })
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Hide on the map' }))
|
||||
expect(useTourMapVisibilityStore.getState().hidden).toEqual([42])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Hidden on the map')
|
||||
// A hidden tour keeps its eye in sight, not only on hover.
|
||||
expect(within(row).getByRole('button', { name: 'Show on the map' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show all' }))
|
||||
expect(useTourMapVisibilityStore.getState().hidden).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, FileDown } from 'lucide-react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { EyeOff, FileDown } from 'lucide-react'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import type { Day } from '../../types'
|
||||
import { tourRepo } from '../../repo/tourRepo'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import TourListRow from './TourListRow'
|
||||
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
|
||||
import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { TourDayMenu } from './tourParts'
|
||||
import { filterTours, type TourFilter } from './tourPresentation'
|
||||
|
||||
const MAX_FILE_BYTES = 10 * 1024 * 1024
|
||||
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'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { resolveTrackColor } from '../Map/trackColors'
|
||||
|
||||
interface ToursSidebarProps extends TourPermissionProps {
|
||||
tripId: number
|
||||
@@ -22,7 +24,7 @@ interface ToursSidebarProps extends TourPermissionProps {
|
||||
tours: TourListItem[]
|
||||
loading?: boolean
|
||||
selectedPlaceId?: number | null
|
||||
onAssignToDay: (placeId: number, dayId: number) => void | boolean | Promise<void | boolean>
|
||||
onAssignToDay: AssignTourToDay
|
||||
pushUndo?: (label: string, undo: () => void | Promise<void>) => void
|
||||
/** Imported place ids are excluded immediately; no ids means refresh derived Tour metadata. */
|
||||
onToursChanged?: (placeIds?: number[]) => void | Promise<void>
|
||||
@@ -43,82 +45,25 @@ interface ToursSidebarProps extends TourPermissionProps {
|
||||
export default function ToursSidebar({ tripId, days, tours, loading = false, selectedPlaceId = null, onAssignToDay, onToursChanged, onSelectTour, canEdit: editPermission, canAssign: assignPermission }: ToursSidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { canEdit, canAssign } = useTourPermissions({ tripId, canEdit: editPermission, canAssign: assignPermission })
|
||||
const toast = useToast()
|
||||
const loadTrip = useTripStore(s => s.loadTrip)
|
||||
const [filter, setFilter] = useState<TourFilter>('all')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const filterRef = useRef<HTMLDivElement>(null)
|
||||
const filterButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
|
||||
const handleImportClick = () => { if (canEdit) fileInputRef.current?.click() }
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!canEdit || !file) return
|
||||
const ext = file.name.toLowerCase().split('.').pop()
|
||||
if (ext !== 'gpx') { toast.error(t('places.importFileUnsupported')); return }
|
||||
if (file.size > MAX_FILE_BYTES) { toast.error(t('places.importFileTooLarge', { maxMb: 10 })); return }
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
const result = await tourRepo.importGpx(tripId, file)
|
||||
if (result.tours.length > 0) {
|
||||
// The server publishes place:created with sender echo suppression.
|
||||
// Reload the importing client's trip so new places and route geometry
|
||||
// enter the shared store used by the day map.
|
||||
await onToursChanged?.(result.tours.map(tour => tour.place_id))
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('tours.import.success', { count: result.tours.length }))
|
||||
if (result.caution) toast.info(t('tours.import.caution'))
|
||||
} else {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.error
|
||||
toast.error(message === 'No track or route found in GPX file' ? t('tours.import.noTrack') : t('tours.import.error'))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAssign = async (tour: TourListItem, day: Day, index: number) => {
|
||||
if (!canAssign) return
|
||||
const assigned = await onAssignToDay(tour.place_id, day.id)
|
||||
if (assigned === false) return
|
||||
toast.success(t('tours.addedToDay', { n: index + 1 }))
|
||||
}
|
||||
|
||||
const counts = {
|
||||
all: tours.length,
|
||||
unplanned: tours.filter(tr => !tr.planned).length,
|
||||
planned: tours.filter(tr => tr.planned).length,
|
||||
}
|
||||
const filtered = filterTours(tours, filter)
|
||||
const gpxImport = useTourGpxImport({ tripId, canEdit, onToursChanged })
|
||||
const filters = useTourListFilters(tours)
|
||||
|
||||
const dayAssignment = useTourDayAssignment({ canAssign, onAssignToDay })
|
||||
const tourIds = useMemo(() => new Set(tours.map(tour => tour.place_id)), [tours])
|
||||
const visibility = useTourMapVisibility(tripId, tourIds)
|
||||
// The colour each line has on the map, so a row and its line read as one (PR1-08).
|
||||
const places = useTripStore(state => state.places)
|
||||
const trackColors = useMemo(
|
||||
() => new Map(places.filter(place => tourIds.has(place.id)).map(place => [place.id, resolveTrackColor(place)])),
|
||||
[places, tourIds],
|
||||
)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
// A tour picked on the map shows in the list too, scrolled into view.
|
||||
useEffect(() => {
|
||||
if (!filterOpen) return
|
||||
const onDown = (event: MouseEvent) => {
|
||||
if (!filterRef.current?.contains(event.target as Node)) setFilterOpen(false)
|
||||
}
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.stopPropagation()
|
||||
setFilterOpen(false)
|
||||
filterButtonRef.current?.focus()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [filterOpen])
|
||||
|
||||
const filterLabel = filter === 'all' ? t('places.all') : filter === 'unplanned' ? t('places.unplanned') : t('places.planned')
|
||||
const filterCount = counts[filter]
|
||||
if (selectedPlaceId == null) return
|
||||
const row = listRef.current?.querySelector<HTMLElement>(`[data-place-id="${selectedPlaceId}"]`)
|
||||
row?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [selectedPlaceId])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: 'var(--font-system)' }}>
|
||||
@@ -126,61 +71,67 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
|
||||
{/* GPX import stays — the tour-appropriate import path. List Import
|
||||
(place/route-of-driving oriented) is deliberately absent here. */}
|
||||
<div>
|
||||
<Tooltip label={t('places.importFile')}>
|
||||
<button type="button" onClick={handleImportClick} disabled={!canEdit || importing}
|
||||
<Tooltip label={t('tours.import.hint')}>
|
||||
<button type="button" onClick={gpxImport.pick} disabled={!canEdit || gpxImport.importing}
|
||||
className="flex h-8 w-full min-w-0 items-center justify-center gap-1.5 overflow-hidden rounded-[10px] bg-accent px-3 font-semibold text-accent-text shadow-sm transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40"
|
||||
style={fs(12.5, 'body')}>
|
||||
<FileDown size={14} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{t('places.importFile')}</span>
|
||||
<span className="truncate">{t('tours.import.button')}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<input ref={fileInputRef} type="file" accept=".gpx" onChange={handleFileChange} style={{ display: 'none' }} />
|
||||
</div>
|
||||
<div ref={filterRef} className="relative min-w-0">
|
||||
<button ref={filterButtonRef} type="button" onClick={() => setFilterOpen(value => !value)}
|
||||
aria-expanded={filterOpen} aria-haspopup="listbox" aria-label={t('places.filterShow')}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-1.5 rounded-[10px] bg-surface-card px-2.5 font-semibold text-content-secondary shadow-sm transition-colors hover:text-content"
|
||||
style={fs(12, 'body')}>
|
||||
<span className="min-w-0 truncate text-start">{filterLabel}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{filterCount}</span>
|
||||
<ChevronDown size={13} strokeWidth={2.2} className={`ms-auto flex-none text-content-faint transition-transform ${filterOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{filterOpen && (
|
||||
<div role="group" aria-label={t('places.filterShow')} className="trek-popover-enter absolute start-0 top-full z-50 mt-1.5 flex w-full min-w-[180px] flex-col gap-px rounded-[12px] border border-edge-secondary bg-surface-card p-1.5 shadow-popover">
|
||||
{(['all', 'unplanned', 'planned'] as const).map(id => {
|
||||
const label = id === 'all' ? t('places.all') : id === 'unplanned' ? t('places.unplanned') : t('places.planned')
|
||||
const active = filter === id
|
||||
return <button key={id} type="button" onClick={() => { setFilter(id); setFilterOpen(false) }} aria-pressed={active}
|
||||
className={`flex w-full items-center gap-2 rounded-[9px] px-2 py-1.5 text-start transition-colors ${active ? 'bg-surface-tertiary' : 'hover:bg-surface-hover'}`}
|
||||
style={fs(12.5, 'body')}>
|
||||
<span className="min-w-0 flex-1 truncate text-content">{label}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11)}>{counts[id]}</span>
|
||||
<span className="grid w-3.5 flex-none place-items-center">{active && <Check size={13} strokeWidth={2.4} className="text-content-muted" />}</span>
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TourListFilterMenu filters={filters} />
|
||||
{visibility.hiddenCount > 0 && (
|
||||
<div role="status" className="flex min-w-0 items-center gap-2 rounded-[10px] bg-surface-card px-2.5 py-1.5 shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<EyeOff size={13} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
<span className="min-w-0 flex-1 truncate text-content-secondary" style={fs(12, 'body')}>{t('tours.visibility.hidden')}</span>
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(10.5)}>{visibility.hiddenCount}</span>
|
||||
<button type="button" onClick={visibility.showAll}
|
||||
className="flex-none rounded-full px-2 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}>
|
||||
{t('tours.visibility.showAll')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="trek-stagger min-h-0 flex-1 overflow-y-auto px-2 pb-2 pt-1.5">
|
||||
{!loading && filtered.length === 0 ? (
|
||||
{!loading && filters.emptyByFilter ? (
|
||||
<div role="status" className="mx-1.5 mt-2 flex flex-col items-center gap-2 rounded-[12px] bg-surface-card px-4 py-5 text-center shadow-sm ring-1 ring-inset ring-edge-faint">
|
||||
<p className="m-0 text-content-muted" style={fs(12.5, 'body')}>{t('tours.filter.empty')}</p>
|
||||
<button type="button" onClick={filters.reset}
|
||||
className="rounded-full bg-surface-secondary px-3 py-1 font-semibold text-content-secondary transition-colors hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
style={fs(11.5, 'body')}>
|
||||
{t('tours.filter.reset')}
|
||||
</button>
|
||||
</div>
|
||||
) : !loading && filters.filtered.length === 0 ? (
|
||||
<EmptyState scene="tours" size={92} fill surface="var(--bg-secondary)" title={t('tours.empty.title')}
|
||||
action={<p className="m-0 max-w-[260px] text-content-muted" style={fs(12, 'body')}>{t('tours.empty.body')}</p>} />
|
||||
) : (
|
||||
<ul role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
|
||||
{filtered.map(tour => (
|
||||
<ul ref={listRef} role="listbox" aria-label={t('tours.mode.tours')} className="m-0 list-none p-0">
|
||||
{filters.filtered.map(tour => (
|
||||
<TourListRow
|
||||
key={tour.place_id}
|
||||
tour={tour}
|
||||
days={days}
|
||||
draggableToDay={canAssign && days.length > 0}
|
||||
selected={tour.place_id === selectedPlaceId}
|
||||
trackColor={trackColors.get(tour.place_id)}
|
||||
mapToggle={{
|
||||
hidden: visibility.isHidden(tour.place_id),
|
||||
onToggle: () => visibility.toggle(tour.place_id),
|
||||
showLabel: t('tours.visibility.show'),
|
||||
hideLabel: t('tours.visibility.hide'),
|
||||
}}
|
||||
onSelect={(selectedTour, opener) => onSelectTour?.(selectedTour, opener)}
|
||||
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} disabled={!canAssign} onPick={(day, index) => { void handleAssign(tour, day, index) }} />}
|
||||
action={<TourDayMenu days={days} placeId={tour.place_id} label={t('tours.addToDay')} assignment={dayAssignment} />}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<TourGpxPreviewDialog gpxImport={gpxImport} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import TourConversionSection from './TourConversionSection';
|
||||
import { useGpxConversion } from './useGpxConversion';
|
||||
|
||||
const routing = vi.hoisted(() => ({ route: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
|
||||
vi.mock('../planner/tourRouting', () => ({ routeWalkingTour: routing.route }));
|
||||
vi.mock('../../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const imported: TourListItem = {
|
||||
place_id: 14,
|
||||
name: 'Recorded ridge',
|
||||
description: 'Above the lake',
|
||||
website: null,
|
||||
tour_type: 'hike',
|
||||
distance: 2,
|
||||
elevation_gain: 100,
|
||||
elevation_loss: 100,
|
||||
duration: null,
|
||||
difficulty: 'moderate',
|
||||
wanderer_ref: 'https://wanderer.test#abc',
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: false,
|
||||
caution: false,
|
||||
has_waypoints: false,
|
||||
};
|
||||
|
||||
/** A straight track north, about two kilometres. */
|
||||
const track: [number, number][] = Array.from({ length: 181 }, (_, i) => [47 + (i * 2) / 180 / 111.2, 11]);
|
||||
|
||||
function planner(overrides: Partial<TourPlannerController> = {}): TourPlannerController {
|
||||
return {
|
||||
canEdit: true,
|
||||
mode: { type: 'view-gpx', placeId: imported.place_id, tour: imported },
|
||||
readOnlyGpxTour: { tour: imported, routeGeometry: null },
|
||||
readOnlyGpxAnalysis: { routeCoordinates: track },
|
||||
startDerivedTour: vi.fn().mockReturnValue(true),
|
||||
...overrides,
|
||||
} as unknown as TourPlannerController;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
seedStore(useSettingsStore, { settings: { distance_unit: 'metric' } });
|
||||
routing.route.mockResolvedValue({ coordinates: track, distanceMeters: 2000, durationSeconds: 1800 });
|
||||
});
|
||||
|
||||
describe('useGpxConversion', () => {
|
||||
it('PR1-05A: analyses only when asked, then compares the routed suggestion to the track', async () => {
|
||||
const { result } = renderHook(() => useGpxConversion(planner()));
|
||||
expect(result.current.status).toBe('idle');
|
||||
expect(result.current.available).toBe(true);
|
||||
expect(routing.route).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
|
||||
expect(routing.route).toHaveBeenCalledWith(result.current.points, expect.any(AbortSignal), 2);
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.comparison?.withinCorridor).toBe(1);
|
||||
expect(result.current.line).toEqual(track);
|
||||
});
|
||||
|
||||
it('PR1-05A: routes an import without a grade with T3 paths allowed', async () => {
|
||||
const ungraded = { ...imported, max_hiking_difficulty: null };
|
||||
const { result } = renderHook(() =>
|
||||
useGpxConversion(
|
||||
planner({
|
||||
mode: { type: 'view-gpx', placeId: ungraded.place_id, tour: ungraded },
|
||||
readOnlyGpxTour: { tour: ungraded, routeGeometry: null },
|
||||
} as Partial<TourPlannerController>)
|
||||
)
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
expect(routing.route).toHaveBeenLastCalledWith(expect.any(Array), expect.any(AbortSignal), 3);
|
||||
});
|
||||
|
||||
it('PR1-05A: says so when no route is found, and stays retryable', async () => {
|
||||
routing.route.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useGpxConversion(planner()));
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
expect(result.current.status).toBe('failed');
|
||||
expect(result.current.error).toBe('routing');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
expect(result.current.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('PR1-05A: a track too short to plan from fails without asking the router', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useGpxConversion(
|
||||
planner({
|
||||
readOnlyGpxAnalysis: {
|
||||
routeCoordinates: [
|
||||
[47, 11],
|
||||
[47, 11],
|
||||
],
|
||||
},
|
||||
} as Partial<TourPlannerController>)
|
||||
)
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
// Two identical points simplify to start and end, which the router may still connect;
|
||||
// a single point has nothing to plan from.
|
||||
const single = renderHook(() =>
|
||||
useGpxConversion(
|
||||
planner({ readOnlyGpxAnalysis: { routeCoordinates: [[47, 11]] } } as Partial<TourPlannerController>)
|
||||
)
|
||||
);
|
||||
expect(single.result.current.available).toBe(false);
|
||||
});
|
||||
|
||||
it('PR1-05B: replacing hands the points to the planner as the import planned over in place', async () => {
|
||||
const controller = planner();
|
||||
const { result } = renderHook(() => useGpxConversion(controller));
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
let started = false;
|
||||
act(() => {
|
||||
started = result.current.adopt('replace');
|
||||
});
|
||||
expect(started).toBe(true);
|
||||
expect(controller.startDerivedTour).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Recorded ridge',
|
||||
description: 'Above the lake',
|
||||
difficulty: 2,
|
||||
sourcePlaceId: 14,
|
||||
inPlace: true,
|
||||
})
|
||||
);
|
||||
expect(result.current.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('PR1-05B: a new tour beside the import remembers where it came from', async () => {
|
||||
const controller = planner();
|
||||
const { result } = renderHook(() => useGpxConversion(controller));
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
act(() => {
|
||||
result.current.adopt('new');
|
||||
});
|
||||
expect(controller.startDerivedTour).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sourcePlaceId: 14, inPlace: false })
|
||||
);
|
||||
});
|
||||
|
||||
it('PR1-05B: nothing is adopted before the suggestion is ready', () => {
|
||||
const controller = planner();
|
||||
const { result } = renderHook(() => useGpxConversion(controller));
|
||||
expect(result.current.adopt('new')).toBe(false);
|
||||
expect(controller.startDerivedTour).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('PR1-05A: another import in view starts idle again, and discarding forgets the analysis', async () => {
|
||||
const { result, rerender } = renderHook(({ controller }) => useGpxConversion(controller), {
|
||||
initialProps: { controller: planner() },
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
expect(result.current.status).toBe('ready');
|
||||
|
||||
const other = { ...imported, place_id: 15 };
|
||||
rerender({
|
||||
controller: planner({
|
||||
mode: { type: 'view-gpx', placeId: 15, tour: other },
|
||||
readOnlyGpxTour: { tour: other, routeGeometry: null },
|
||||
} as Partial<TourPlannerController>),
|
||||
});
|
||||
expect(result.current.status).toBe('idle');
|
||||
|
||||
rerender({ controller: planner() });
|
||||
await act(async () => {
|
||||
await result.current.analyze();
|
||||
});
|
||||
act(() => result.current.cancel());
|
||||
expect(result.current.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('PR1-05A: is not offered without the right to add tours', () => {
|
||||
const { result } = renderHook(() => useGpxConversion(planner({ canEdit: false })));
|
||||
expect(result.current.available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourConversionSection', () => {
|
||||
function Section({ controller }: { controller: TourPlannerController }) {
|
||||
const conversion = useGpxConversion(controller);
|
||||
return <TourConversionSection conversion={conversion} />;
|
||||
}
|
||||
|
||||
it('PR1-05A: offers the analysis, then the comparison with both ways to take it over', async () => {
|
||||
const controller = planner();
|
||||
render(<Section controller={controller} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Within 50 m of the track')).toBeInTheDocument();
|
||||
expect(screen.getByText('100 %')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('The suggestion follows the track closely. It is routed anew, so it is not the same line.')
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Replace the import' }));
|
||||
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: true }));
|
||||
expect(toast.info).toHaveBeenCalledWith(
|
||||
'Opened in the editor. Save it to replace the import; the recorded track stays restorable.'
|
||||
);
|
||||
});
|
||||
|
||||
it('PR1-05B: plans a new tour beside the import on request', async () => {
|
||||
const controller = planner();
|
||||
render(<Section controller={controller} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Plan as new tour' }));
|
||||
expect(controller.startDerivedTour).toHaveBeenCalledWith(expect.objectContaining({ inPlace: false }));
|
||||
expect(toast.info).toHaveBeenCalledWith('Opened as a new tour. Save it to keep it; the import stays as it is.');
|
||||
});
|
||||
|
||||
it('PR1-05A: warns when the suggestion leaves the track for longer stretches', async () => {
|
||||
const elsewhere: [number, number][] = track.map(([lat, lng]) => [lat, lng + 0.01]);
|
||||
routing.route.mockResolvedValue({ coordinates: elsewhere, distanceMeters: 2000, durationSeconds: 1800 });
|
||||
render(<Section controller={planner()} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'The suggestion leaves the track over longer stretches. Check it on the map before you plan with it.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PR1-05A: reports a failed routing and retries it', async () => {
|
||||
routing.route.mockResolvedValueOnce(null);
|
||||
render(<Section controller={planner()} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
||||
expect(await screen.findByText('No route was found through the control points.')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
expect(await screen.findByRole('button', { name: 'Replace the import' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PR1-05A: discarding returns to the offer', async () => {
|
||||
render(<Section controller={planner()} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Plan an editable version' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Discard' }));
|
||||
expect(screen.getByRole('button', { name: 'Plan an editable version' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('PR1-05A: shows nothing to someone who may not add tours', () => {
|
||||
const { container } = render(<Section controller={planner({ canEdit: false })} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { AlertTriangle, CheckCircle2, CopyPlus, Loader2, Replace, Route, X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance } from '../../../utils/units';
|
||||
import { DialogButton, fs } from '../../shared/DialogShell';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { CardAction, RailCard, RailFacts, type TourMetricField } from '../tourParts';
|
||||
import { CORRIDOR_M, FAITHFUL_SHARE } from './gpxConversion';
|
||||
import type { GpxConversion } from './useGpxConversion';
|
||||
|
||||
/** The two lines of the comparison as the map draws them: the import dotted, the suggestion solid. */
|
||||
function LineLegend() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-content-muted" style={fs(11, 'body')}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span aria-hidden="true" className="w-5 border-t-[3px] border-dotted border-info" />
|
||||
{t('tours.conversion.legendImport')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span aria-hidden="true" className="h-[3px] w-5 rounded-full bg-info" />
|
||||
{t('tours.conversion.legendSuggestion')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line of status inside the card: an icon and a sentence. */
|
||||
function StatusLine({
|
||||
icon,
|
||||
tone = 'muted',
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tone?: 'muted' | 'warning';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className={`m-0 flex items-start gap-2 leading-snug ${tone === 'warning' ? 'text-warning' : 'text-content-secondary'}`}
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
<span className="mt-[2px] flex-none">{icon}</span>
|
||||
<span className="min-w-0">{children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In the view of an import: plan an editable tour from it. Asked for, the
|
||||
* import's track turns into control points routed anew, and the card compares
|
||||
* that route to the track before anything is taken over. Taking it over either
|
||||
* plans the import over in place (its days and notes stay, the recorded track
|
||||
* is kept to go back to) or opens a new tour beside it. Both open in the editor
|
||||
* and are stored only when saved.
|
||||
*/
|
||||
export default function TourConversionSection({ conversion }: { conversion: GpxConversion }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
if (!conversion.available) return null;
|
||||
|
||||
const adopt = (how: 'replace' | 'new') => {
|
||||
if (conversion.adopt(how))
|
||||
toast.info(t(how === 'replace' ? 'tours.conversion.replacing' : 'tours.conversion.adopted'));
|
||||
};
|
||||
const card = (children: ReactNode) => (
|
||||
<RailCard icon={<Route size={14} strokeWidth={2} />} title={t('tours.conversion.title')}>
|
||||
{children}
|
||||
</RailCard>
|
||||
);
|
||||
|
||||
if (conversion.status === 'idle') {
|
||||
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(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')}
|
||||
</CardAction>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversion.status === 'analyzing') {
|
||||
return card(
|
||||
<StatusLine icon={<Loader2 size={13} strokeWidth={2} className="animate-spin text-content-faint" />}>
|
||||
{t('tours.conversion.analyzing', { count: conversion.points.length })}
|
||||
</StatusLine>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversion.status === 'failed' || !conversion.comparison) {
|
||||
return card(
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<StatusLine tone="warning" icon={<AlertTriangle size={13} strokeWidth={2.2} />}>
|
||||
{t(conversion.error === 'track' ? 'tours.conversion.failedTrack' : 'tours.conversion.failedRouting')}
|
||||
</StatusLine>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void conversion.analyze()}
|
||||
className="font-semibold text-content underline"
|
||||
style={fs(12, 'body')}
|
||||
>
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { comparison } = conversion;
|
||||
const faithful = comparison.withinCorridor >= FAITHFUL_SHARE;
|
||||
const facts: TourMetricField[] = [
|
||||
{ label: t('tours.conversion.importLength'), value: formatDistance(comparison.originalKm, unit) },
|
||||
{ label: t('tours.conversion.suggestionLength'), value: formatDistance(comparison.suggestionKm, unit) },
|
||||
{
|
||||
label: t('tours.conversion.withinCorridor', { m: CORRIDOR_M }),
|
||||
value: `${Math.round(comparison.withinCorridor * 100)} %`,
|
||||
},
|
||||
{ label: t('tours.conversion.maxDeviation'), value: `${Math.round(comparison.maxDeviationM)} m` },
|
||||
{ label: t('tours.conversion.controlPoints'), value: String(conversion.points.length) },
|
||||
];
|
||||
return card(
|
||||
<div className="flex flex-col gap-3">
|
||||
<LineLegend />
|
||||
<RailFacts facts={facts} />
|
||||
<StatusLine
|
||||
tone={faithful ? 'muted' : 'warning'}
|
||||
icon={
|
||||
faithful ? (
|
||||
<CheckCircle2 size={13} strokeWidth={2.2} className="text-success" />
|
||||
) : (
|
||||
<AlertTriangle size={13} strokeWidth={2.2} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{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')}>
|
||||
<span className="flex [&>button]:flex-1">
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
onClick={() => adopt('replace')}
|
||||
icon={<Replace size={14} strokeWidth={2} />}
|
||||
>
|
||||
{t('tours.conversion.replace')}
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div className="flex gap-2">
|
||||
<Tooltip label={t('tours.conversion.adoptHint')}>
|
||||
<span className="flex flex-1 [&>button]:flex-1">
|
||||
<DialogButton onClick={() => adopt('new')} icon={<CopyPlus size={14} strokeWidth={2} />}>
|
||||
{t('tours.conversion.adopt')}
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<DialogButton onClick={conversion.cancel} icon={<X size={14} strokeWidth={2} />}>
|
||||
{t('tours.conversion.discard')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CORRIDOR_M, compareRoutes, deriveControlPoints, lineLengthMeters, type TrackPoint } from './gpxConversion';
|
||||
|
||||
/** About 111 m per 0.001 degree of latitude. */
|
||||
const north = (lat: number, lng = 11): TrackPoint => ({ lat, lng });
|
||||
|
||||
/** A straight line north, one point every ~11 m, `km` long. */
|
||||
function straightTrack(km: number): TrackPoint[] {
|
||||
const steps = Math.round(km * 90);
|
||||
return Array.from({ length: steps + 1 }, (_, i) => north(47 + (i * km) / steps / 111.2));
|
||||
}
|
||||
|
||||
/** An L: north for `legKm`, then east for `legKm`. */
|
||||
function bentTrack(legKm: number): TrackPoint[] {
|
||||
const up = straightTrack(legKm);
|
||||
const corner = up[up.length - 1];
|
||||
const east = Array.from({ length: 90 }, (_, i) => ({
|
||||
lat: corner.lat,
|
||||
lng: corner.lng + ((i + 1) * legKm) / 90 / 75.8,
|
||||
}));
|
||||
return [...up, ...east];
|
||||
}
|
||||
|
||||
describe('deriveControlPoints', () => {
|
||||
it('PR1-05A: keeps start and end and nothing else on a short straight track', () => {
|
||||
const track = straightTrack(1);
|
||||
const points = deriveControlPoints(track);
|
||||
expect(points).toEqual([track[0], track[track.length - 1]]);
|
||||
});
|
||||
|
||||
it('PR1-05A: keeps the bend a track turns at', () => {
|
||||
const track = bentTrack(1);
|
||||
const points = deriveControlPoints(track);
|
||||
expect(points.length).toBe(3);
|
||||
const corner = track[90];
|
||||
expect(points[1].lat).toBeCloseTo(corner.lat, 3);
|
||||
expect(points[1].lng).toBeCloseTo(corner.lng, 3);
|
||||
});
|
||||
|
||||
it('PR1-05A: adds a point wherever a track runs on longer than the longest gap', () => {
|
||||
const track = straightTrack(10);
|
||||
const points = deriveControlPoints(track, { maxGapM: 2_500 });
|
||||
expect(points.length).toBeGreaterThanOrEqual(5);
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
expect(lineLengthMeters([points[i - 1], points[i]])).toBeLessThanOrEqual(2_600);
|
||||
}
|
||||
});
|
||||
|
||||
it('PR1-05A: never keeps more points than asked for, start and end included', () => {
|
||||
// A zigzag every point of which is a bend.
|
||||
const zigzag = Array.from({ length: 400 }, (_, i) => ({ lat: 47 + i * 0.0009, lng: 11 + (i % 2) * 0.002 }));
|
||||
const points = deriveControlPoints(zigzag, { maxPoints: 12 });
|
||||
expect(points.length).toBeLessThanOrEqual(12);
|
||||
expect(points[0]).toEqual(zigzag[0]);
|
||||
expect(points[points.length - 1]).toEqual(zigzag[zigzag.length - 1]);
|
||||
});
|
||||
|
||||
it('PR1-05A: merges points closer than the minimum spacing, keeping start and end', () => {
|
||||
const track = [north(47), north(47.0002), north(47.0004), north(47.02)];
|
||||
const points = deriveControlPoints(track, { minSpacingM: 150 });
|
||||
expect(points[0]).toEqual(track[0]);
|
||||
expect(points[points.length - 1]).toEqual(track[3]);
|
||||
expect(points).not.toContainEqual(track[1]);
|
||||
});
|
||||
|
||||
it('PR1-05A: has nothing to offer for a track of fewer than two points', () => {
|
||||
expect(deriveControlPoints([])).toEqual([]);
|
||||
expect(deriveControlPoints([north(47)])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareRoutes', () => {
|
||||
it('PR1-05A: a suggestion on the track itself lies wholly in the corridor', () => {
|
||||
const track = straightTrack(2);
|
||||
const result = compareRoutes(track, [track[0], track[track.length - 1]]);
|
||||
expect(result.withinCorridor).toBe(1);
|
||||
expect(result.maxDeviationM).toBeLessThan(1);
|
||||
expect(result.originalKm).toBeCloseTo(2, 1);
|
||||
expect(result.suggestionKm).toBeCloseTo(2, 1);
|
||||
});
|
||||
|
||||
it('PR1-05A: a suggestion that cuts a corner says how far it strays and for how much of the track', () => {
|
||||
const track = bentTrack(1);
|
||||
const shortcut = [track[0], track[track.length - 1]];
|
||||
const result = compareRoutes(track, shortcut);
|
||||
expect(result.maxDeviationM).toBeGreaterThan(500);
|
||||
expect(result.withinCorridor).toBeLessThan(0.5);
|
||||
expect(result.suggestionKm).toBeLessThan(result.originalKm);
|
||||
});
|
||||
|
||||
it('PR1-05A: counts a stray of exactly the corridor width as followed', () => {
|
||||
const track = [north(47), north(47.001)];
|
||||
const offset = CORRIDOR_M / 75_800; // degrees of longitude for the corridor width at 47° N
|
||||
const beside = track.map((point) => ({ lat: point.lat, lng: point.lng + offset * 0.99 }));
|
||||
expect(compareRoutes(track, beside).withinCorridor).toBe(1);
|
||||
});
|
||||
|
||||
it('PR1-05A: compares a one-point suggestion by plain distance', () => {
|
||||
const result = compareRoutes([north(47), north(47.001)], [north(47)]);
|
||||
expect(result.maxDeviationM).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Turning an imported track into control points a tour can be planned through,
|
||||
* and measuring how far the line routed through them strays from the track.
|
||||
*
|
||||
* A recorded track is thousands of points, most of them noise of the walk, and
|
||||
* none of them says where the walker meant to go. The points kept here are the
|
||||
* ones the track's shape needs: a Douglas-Peucker pass keeps the bends, and a
|
||||
* longest-gap rule adds a point wherever the track runs on too long without
|
||||
* one, so the router is not left to guess across kilometres. Nothing here
|
||||
* claims the result is the same tour; the comparison says how close it is.
|
||||
*/
|
||||
|
||||
export interface TrackPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface ControlPointOptions {
|
||||
/** The most control points to keep, start and end included. */
|
||||
maxPoints?: number;
|
||||
/** The longest stretch of track, in metres, left without a control point. */
|
||||
maxGapM?: number;
|
||||
/** Control points closer than this, in metres, are merged (start and end stay). */
|
||||
minSpacingM?: number;
|
||||
}
|
||||
|
||||
export interface RouteComparison {
|
||||
/** Length of the imported track, in kilometres. */
|
||||
originalKm: number;
|
||||
/** Length of the routed suggestion, in kilometres. */
|
||||
suggestionKm: number;
|
||||
/** The farthest any point of the track lies from the suggestion, in metres. */
|
||||
maxDeviationM: number;
|
||||
/** The share of the track within {@link CORRIDOR_M} of the suggestion, from 0 to 1. */
|
||||
withinCorridor: number;
|
||||
}
|
||||
|
||||
/** How far from the suggestion a stretch of track still counts as followed, in metres. */
|
||||
export const CORRIDOR_M = 50;
|
||||
/** Below this share of the track in the corridor the suggestion is called a different route. */
|
||||
export const FAITHFUL_SHARE = 0.85;
|
||||
|
||||
const EARTH_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
/** The track's points compared against the suggestion, at most; a long track is sampled evenly. */
|
||||
const COMPARE_SAMPLES = 600;
|
||||
|
||||
/** Points on a local plane in metres, good enough over the span of one tour. */
|
||||
function projector(points: readonly TrackPoint[]) {
|
||||
const meanLat = points.reduce((sum, p) => sum + p.lat, 0) / Math.max(points.length, 1);
|
||||
const kx = Math.cos(meanLat * RAD) * EARTH_M * RAD;
|
||||
const ky = EARTH_M * RAD;
|
||||
return (p: TrackPoint): [number, number] => [p.lng * kx, p.lat * ky];
|
||||
}
|
||||
|
||||
function distanceToSegment(p: [number, number], a: [number, number], b: [number, number]): number {
|
||||
const dx = b[0] - a[0];
|
||||
const dy = b[1] - a[1];
|
||||
const length2 = dx * dx + dy * dy;
|
||||
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length2));
|
||||
return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy));
|
||||
}
|
||||
|
||||
/** The length of a line, in metres. */
|
||||
export function lineLengthMeters(points: readonly TrackPoint[]): number {
|
||||
const project = projector(points);
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i += 1) {
|
||||
const [ax, ay] = project(points[i - 1]);
|
||||
const [bx, by] = project(points[i]);
|
||||
total += Math.hypot(bx - ax, by - ay);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Douglas-Peucker on projected points: the indices a line of the given tolerance keeps. */
|
||||
function simplify(xy: readonly [number, number][], tolerance: number): number[] {
|
||||
const keep = new Uint8Array(xy.length);
|
||||
keep[0] = 1;
|
||||
keep[xy.length - 1] = 1;
|
||||
const stack: Array<[number, number]> = [[0, xy.length - 1]];
|
||||
for (let range = stack.pop(); range; range = stack.pop()) {
|
||||
const [first, last] = range;
|
||||
let farthest = -1;
|
||||
let farthestDistance = tolerance;
|
||||
for (let i = first + 1; i < last; i += 1) {
|
||||
const distance = distanceToSegment(xy[i], xy[first], xy[last]);
|
||||
if (distance > farthestDistance) {
|
||||
farthest = i;
|
||||
farthestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (farthest !== -1) {
|
||||
keep[farthest] = 1;
|
||||
stack.push([first, farthest], [farthest, last]);
|
||||
}
|
||||
}
|
||||
return xy.flatMap((_, index) => (keep[index] ? [index] : []));
|
||||
}
|
||||
|
||||
/** Adds a kept index wherever the track runs longer than `maxGapM` between two kept ones. */
|
||||
function fillGaps(kept: number[], along: readonly number[], maxGapM: number): number[] {
|
||||
const result: number[] = [kept[0]];
|
||||
for (let k = 1; k < kept.length; k += 1) {
|
||||
const from = kept[k - 1];
|
||||
const to = kept[k];
|
||||
let last = from;
|
||||
for (let i = from + 1; i < to; i += 1) {
|
||||
if (along[i] - along[last] >= maxGapM && along[to] - along[i] >= maxGapM / 4) {
|
||||
result.push(i);
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
result.push(to);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Drops kept indices closer than `minSpacingM` along the track to the one before; the end always stays. */
|
||||
function spaceOut(kept: number[], along: readonly number[], minSpacingM: number): number[] {
|
||||
const result: number[] = [kept[0]];
|
||||
for (let k = 1; k < kept.length - 1; k += 1) {
|
||||
if (along[kept[k]] - along[result[result.length - 1]] >= minSpacingM) result.push(kept[k]);
|
||||
}
|
||||
const end = kept[kept.length - 1];
|
||||
if (result.length > 1 && along[end] - along[result[result.length - 1]] < minSpacingM) result.pop();
|
||||
result.push(end);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control points a tour planned from this track would run through: start
|
||||
* and end, the bends the track's shape needs, and a point wherever it runs on
|
||||
* longer than `maxGapM` without one. A tolerance that keeps too many is widened
|
||||
* until the points fit `maxPoints`. A track with fewer than two points has none.
|
||||
*/
|
||||
export function deriveControlPoints(track: readonly TrackPoint[], options: ControlPointOptions = {}): TrackPoint[] {
|
||||
const { maxPoints = 25, maxGapM = 2_500, minSpacingM = 150 } = options;
|
||||
if (track.length < 2) return [];
|
||||
const project = projector(track);
|
||||
const xy = track.map(project);
|
||||
const along = [0];
|
||||
for (let i = 1; i < xy.length; i += 1)
|
||||
along.push(along[i - 1] + Math.hypot(xy[i][0] - xy[i - 1][0], xy[i][1] - xy[i - 1][1]));
|
||||
let tolerance = 25;
|
||||
let kept: number[] = [];
|
||||
for (let round = 0; round < 24; round += 1) {
|
||||
kept = spaceOut(fillGaps(simplify(xy, tolerance), along, maxGapM), along, minSpacingM);
|
||||
if (kept.length <= maxPoints) break;
|
||||
tolerance *= 1.4;
|
||||
}
|
||||
if (kept.length > maxPoints) {
|
||||
// Even a wide tolerance keeps too many (a very long track): evenly along it instead.
|
||||
const total = along[along.length - 1];
|
||||
kept = Array.from({ length: maxPoints }, (_, n) => {
|
||||
const target = (total * n) / (maxPoints - 1);
|
||||
return Math.max(
|
||||
0,
|
||||
along.findIndex((distance) => distance >= target)
|
||||
);
|
||||
});
|
||||
}
|
||||
return kept.map((index) => ({ lat: track[index].lat, lng: track[index].lng }));
|
||||
}
|
||||
|
||||
/** How closely the suggestion follows the track: lengths, the farthest stray and the share inside the corridor. */
|
||||
export function compareRoutes(track: readonly TrackPoint[], suggestion: readonly TrackPoint[]): RouteComparison {
|
||||
const project = projector([...track, ...suggestion]);
|
||||
const line = suggestion.map(project);
|
||||
const step = Math.max(1, Math.ceil(track.length / COMPARE_SAMPLES));
|
||||
const samples = track.filter((_, index) => index % step === 0 || index === track.length - 1).map(project);
|
||||
let maxDeviationM = 0;
|
||||
let within = 0;
|
||||
for (const point of samples) {
|
||||
let nearest = Infinity;
|
||||
for (let i = 1; i < line.length && nearest > 0; i += 1)
|
||||
nearest = Math.min(nearest, distanceToSegment(point, line[i - 1], line[i]));
|
||||
if (line.length === 1) nearest = Math.hypot(point[0] - line[0][0], point[1] - line[0][1]);
|
||||
maxDeviationM = Math.max(maxDeviationM, nearest);
|
||||
if (nearest <= CORRIDOR_M) within += 1;
|
||||
}
|
||||
return {
|
||||
originalKm: lineLengthMeters(track) / 1000,
|
||||
suggestionKm: lineLengthMeters(suggestion) / 1000,
|
||||
maxDeviationM,
|
||||
withinCorridor: samples.length ? within / samples.length : 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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';
|
||||
|
||||
/** The grade a planned version routes with when the import carries none: the paths a T3 walk may take. */
|
||||
const DEFAULT_DIFFICULTY: TourMaxHikingDifficulty = 3;
|
||||
|
||||
type GpxConversionStatus = 'idle' | 'analyzing' | 'ready' | 'failed';
|
||||
|
||||
interface ConversionState {
|
||||
/** The import the analysis belongs to; another import shown means it is idle again. */
|
||||
placeId: number | null;
|
||||
status: GpxConversionStatus;
|
||||
points: TrackPoint[];
|
||||
line: [number, number][] | null;
|
||||
comparison: RouteComparison | null;
|
||||
/** Why it failed: the track is too short to plan from, or the router found no way. */
|
||||
error: 'track' | 'routing' | null;
|
||||
}
|
||||
|
||||
const IDLE: ConversionState = { placeId: null, status: 'idle', points: [], line: null, comparison: null, error: null };
|
||||
|
||||
/**
|
||||
* Planning an editable tour from an import shown in the tour planner (PR1-05A/B).
|
||||
* The analysis starts only when asked: control points from the track, a route
|
||||
* through them by the planner's own router, and how far that route strays from
|
||||
* the track. Nothing is stored. Adopting hands the points to the planner as a
|
||||
* new draft that remembers the import; only saving that draft creates a tour,
|
||||
* and the import stays as it is.
|
||||
*/
|
||||
export function useGpxConversion(planner: TourPlannerController) {
|
||||
const [state, setState] = useState<ConversionState>(IDLE);
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
const tour = planner.mode.type === 'view-gpx' ? (planner.readOnlyGpxTour?.tour ?? null) : null;
|
||||
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(), []);
|
||||
|
||||
const analyze = useCallback(async () => {
|
||||
if (!tour || !track) return;
|
||||
abort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
const trackPoints = track.map(([lat, lng]) => ({ lat, lng }));
|
||||
const points = deriveControlPoints(trackPoints);
|
||||
const base = { ...IDLE, placeId: tour.place_id, points };
|
||||
if (points.length < 2) {
|
||||
setState({ ...base, status: 'failed', error: 'track' });
|
||||
return;
|
||||
}
|
||||
setState({ ...base, status: 'analyzing' });
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
const suggestion = routed.coordinates.map(([lat, lng]) => ({ lat, lng }));
|
||||
setState({
|
||||
...base,
|
||||
status: 'ready',
|
||||
line: routed.coordinates,
|
||||
comparison: compareRoutes(trackPoints, suggestion),
|
||||
});
|
||||
}, [difficulty, paddle, tour, track]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abort.current?.abort();
|
||||
setState(IDLE);
|
||||
}, []);
|
||||
|
||||
const { startDerivedTour } = planner;
|
||||
/**
|
||||
* The suggestion as a draft in the planner: the import planned over in place
|
||||
* (`replace`), or a new tour beside it. True once the planner took it.
|
||||
*/
|
||||
const adopt = useCallback(
|
||||
(how: 'replace' | 'new') => {
|
||||
if (!tour || current.status !== 'ready') return false;
|
||||
const started = startDerivedTour({
|
||||
name: tour.name,
|
||||
description: tour.description ?? null,
|
||||
website: tour.website ?? null,
|
||||
difficulty,
|
||||
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, paddle, startDerivedTour, tour]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...current,
|
||||
/** 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, paddle, planner.canEdit, tour, track]
|
||||
);
|
||||
}
|
||||
|
||||
export type GpxConversion = ReturnType<typeof useGpxConversion>;
|
||||
@@ -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} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user